Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.29% covered (success)
98.29%
115 / 117
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaSearchEntitiesFetcher
98.29% covered (success)
98.29%
115 / 117
75.00% covered (warning)
75.00%
6 / 8
22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
8
 gatherEntitySearchRequests
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 gatherTitleMatchRequests
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
2.00
 mbUcFirst
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 transformTitleMatchResult
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 addToTransformedResponses
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 transformEntitySearchResult
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace Wikibase\MediaInfo\Search;
4
5use Wikimedia\Http\MultiHttpClient;
6
7class MediaSearchEntitiesFetcher {
8    public function __construct(
9        protected readonly MultiHttpClient $multiHttpClient,
10        protected readonly string $entitySearchUrl,
11        protected readonly string $titleMatchUrl,
12        protected readonly string $inputLanguage,
13        protected readonly string $outputLanguage,
14    ) {
15    }
16
17    /**
18     * Find wikibase entities that match given search queries and return their ids,
19     * along with a (normalized, between 0-1) score indicating good of a match
20     * they are.
21     *
22     * @param array $searchQueries
23     * @return array
24     */
25    public function get( array $searchQueries ): array {
26        if ( count( $searchQueries ) === 0 ) {
27            return [];
28        }
29
30        $entitySearchRequests = $this->gatherEntitySearchRequests( $searchQueries );
31        $titleMatchRequests = $this->gatherTitleMatchRequests( $searchQueries );
32
33        $responses = $this->multiHttpClient->runMulti(
34            array_merge( $entitySearchRequests, $titleMatchRequests )
35        );
36
37        $transformedResponses = array_fill_keys(
38            array_values( $searchQueries ),
39            []
40        );
41        foreach ( $responses as $response ) {
42            $body = json_decode( $response['response']['body'], true ) ?: [];
43            $term = $response['_term'];
44            if ( $response['_type'] === 'entitySearch' ) {
45                // iterate each result
46                foreach ( $body['query']['pages'] ?? [] as $result ) {
47                    $transformedResponses[$term] = $this->addToTransformedResponses(
48                        $transformedResponses[$term],
49                        $this->transformEntitySearchResult( $result )
50                    );
51                }
52            } else {
53                $titleMatch = $this->transformTitleMatchResult( $body );
54                if ( $titleMatch ) {
55                    $transformedResponses[$term] = $this->addToTransformedResponses(
56                        $transformedResponses[$term],
57                        $titleMatch
58                    );
59                }
60            }
61        }
62
63        // Sort items by score.
64        foreach ( $transformedResponses as $i => $term ) {
65            $scores = array_column( $term, 'score' );
66            array_multisort( $scores, SORT_DESC, $transformedResponses[$i] );
67        }
68
69        return $transformedResponses;
70    }
71
72    private function gatherEntitySearchRequests( array $searchQueries ): array {
73        return array_map( function ( $query ) {
74            $params = [
75                'format' => 'json',
76                'action' => 'query',
77                'generator' => 'search',
78                'gsrsearch' => $query,
79                'gsrnamespace' => 0,
80                'gsrlimit' => 50,
81                'gsrprop' => 'snippet|titlesnippet|extensiondata',
82                'uselang' => $this->inputLanguage,
83                'prop' => 'entityterms',
84                'wbetterms' => 'alias|label',
85                'wbetlanguage' => $this->outputLanguage,
86            ];
87
88            return [
89                'method' => 'GET',
90                '_term' => $query,
91                '_type' => 'entitySearch',
92                'url' => $this->entitySearchUrl . '?' . http_build_query( $params ),
93            ];
94        }, $searchQueries );
95    }
96
97    private function gatherTitleMatchRequests( array $searchQueries ): array {
98        if ( !$this->titleMatchUrl ) {
99            return [];
100        }
101        return array_map( function ( $query ) {
102            $params = [
103                'format' => 'json',
104                'action' => 'query',
105                // ucfirst() the string, and strip quotes (in case the query comes from
106                // a phrase query)
107                'titles' => $this->mbUcFirst( trim( $query, " \n\r\t\v\0\"" ) ),
108                'prop' => 'pageprops',
109                'redirects' => 1,
110            ];
111
112            return [
113                'method' => 'GET',
114                '_term' => $query,
115                '_type' => 'titleMatch',
116                'url' => sprintf( $this->titleMatchUrl, $this->inputLanguage ) . '?' .
117                         http_build_query( $params ),
118            ];
119        }, $searchQueries );
120    }
121
122    /**
123     * Replicates php's ucfirst() function with multibyte support.
124     *
125     * @param string $str The string being converted.
126     *
127     * @return string The input string with first character uppercased.
128     * @see https://github.com/cofirazak/phpMissingFunctions/blob/master/src/StringFunc.php
129     */
130    public function mbUcFirst( string $str ): string {
131        return mb_strtoupper( mb_substr( $str, 0, 1 ) ) .
132               mb_substr( $str, 1 );
133    }
134
135    private function transformTitleMatchResult( array $result ): ?array {
136        if ( isset( $result['query']['pages'] ) ) {
137            $page = array_shift( $result['query']['pages'] );
138            if ( isset( $page['pageprops']['wikibase_item'] ) ) {
139                return [
140                    'entityId' => $page['pageprops']['wikibase_item'],
141                    'score' => 1.0,
142                    'synonyms' => array_column( $result['query']['redirects'] ?? [], 'to' ),
143                ];
144            }
145        }
146        return null;
147    }
148
149    private function addToTransformedResponses( array $collection, array $item ): array {
150        if ( !isset( $collection[ $item['entityId'] ] ) ) {
151            $collection[ $item['entityId'] ] = $item;
152            return $collection;
153        }
154        $collection[ $item['entityId'] ] = [
155            'entityId' => $item['entityId'],
156            'synonyms' => array_merge(
157                $collection[ $item['entityId'] ]['synonyms'] ?? [],
158                $item['synonyms'] ?? []
159            ),
160            'score' => max( $collection[ $item['entityId'] ]['score'], $item['score'] ),
161        ];
162        return $collection;
163    }
164
165    protected function transformEntitySearchResult( array $result ): array {
166        // unfortunately, the search API doesn't return an actual score
167        // (for relevancy of the match), which means that we have no way
168        // of telling which results are awesome matches and which are only
169        // somewhat relevant
170        // since we can't rely on the order to tell us much about how
171        // relevant a result is (except for relative to one another), and
172        // we don't know the actual score of these results, we'll try to
173        // approximate a term frequency - it won't be great, but at least
174        // we'll be able to tell which of "cat" and "Pirates of Catalonia"
175        // most resemble "cat"
176        // the highlight will either be in extensiondata (in the case
177        // of a matching alias), snippet (for descriptions), or
178        // titlesnippet (for labels)
179        $snippets = [
180            $result['snippet'],
181            $result['titlesnippet'],
182            $result['extensiondata']['wikibase']['extrasnippet'] ?? ''
183        ];
184
185        $maxTermFrequency = 0;
186        foreach ( $snippets as $snippet ) {
187            // let's figure out how much of the snippet actually matched
188            // the search term based on the highlight
189            $source = preg_replace( '/<span class="searchmatch">(.*?)<\/span>/', '$1', $snippet );
190            $omitted = preg_replace( '/<span class="searchmatch">.*?<\/span>/', '', $snippet );
191            $termFrequency = $source === '' ? 0 : 1 - mb_strlen( $omitted ) / mb_strlen( $source );
192            $maxTermFrequency = max( $maxTermFrequency, $termFrequency );
193        }
194
195        // average the order in which results were returned (because that
196        // takes into account additional factors such as popularity of
197        // the page) and the naive term frequency to calculate how relevant
198        // the results are relative to one another
199        $relativeOrder = 1 / $result['index'];
200
201        $synonyms = [];
202        if ( isset( $result['entityterms'] ) ) {
203            $synonyms = array_merge(
204                $synonyms,
205                $result['entityterms']['label'] ?? [],
206                $result['entityterms']['alias'] ?? []
207             );
208        }
209
210        return [
211            'entityId' => $result['title'],
212            'score' => ( $relativeOrder + $maxTermFrequency ) / 2,
213            'synonyms' => $synonyms,
214        ];
215    }
216}