1: <?php
2:
3: namespace Scopus;
4:
5: use Exception;
6: use GuzzleHttp\ClientInterface;
7: use GuzzleHttp\Exception\GuzzleException;
8: use Scopus\Exception\JsonException;
9: use Scopus\Exception\XmlException;
10: use Scopus\Response\AbstractCitations;
11: use Scopus\Response\Abstracts;
12: use Scopus\Response\Author;
13: use Scopus\Response\CitationCount;
14: use Scopus\Response\Entry;
15: use Scopus\Response\SearchResults;
16: use Scopus\Util\XmlUtil;
17:
18: class ScopusApi
19: {
20: const SEARCH_URI = 'https://api.elsevier.com/content/search/scopus';
21: const ABSTRACT_URI = 'https://api.elsevier.com/content/abstract/scopus_id/';
22: const AUTHOR_URI = 'https://api.elsevier.com/content/author/author_id/';
23: const AFFILIATION_URI = 'https://api.elsevier.com/content/affiliation/affiliation_id/';
24: const SEARCH_AUTHOR_URI = 'https://api.elsevier.com/content/search/author';
25: const CITATION_OVERVIEW_URI = 'https://api.elsevier.com/content/abstract/citations';
26: const CITATION_COUNT_URI = 'https://api.elsevier.com/content/abstract/citation-count';
27:
28: private $client;
29:
30: public function __construct(ClientInterface $httpClient)
31: {
32: $this->client = $httpClient;
33: }
34:
35: /**
36: * @return SearchQuery
37: */
38: public function query($query)
39: {
40: return new SearchQuery($this, $query);
41: }
42:
43: /**
44: * @param string $uri
45: * @param array $options
46: *
47: * @return array|Abstracts|Author|SearchResults|AbstractCitations|CitationCount[]
48: *
49: * @throws Exception|GuzzleException
50: */
51: public function retrieve($uri, array $options = [])
52: {
53: $response = $this->client->get($uri, $options);
54:
55: if ($response->getStatusCode() === 200) {
56: $body = (string) $response->getBody();
57: $contentType = $response->getHeader('Content-Type');
58: if ($contentType && strpos(strtolower($contentType[0]), '/xml') !== false) {
59: $internalErrors = libxml_use_internal_errors(true);
60: $pendingErrors = count(libxml_get_errors());
61: $xml = simplexml_load_string($body, "SimpleXMLElement", LIBXML_NOCDATA);
62:
63: $errors = libxml_get_errors();
64: $error = count($errors) > $pendingErrors ? end($errors) : null;
65:
66: if (!$internalErrors) {
67: // The caller was not collecting libxml errors, so the buffer is ours to clear.
68: libxml_clear_errors();
69: }
70: libxml_use_internal_errors($internalErrors);
71:
72: if ($xml === false) {
73: $message = $error ? trim($error->message) : 'Unknown XML parsing error';
74: $code = $error ? (int) $error->code : 0;
75: throw new XmlException(sprintf('Xml response could not be parsed "%s" (%d) for %s', $message, $code, $uri), $code);
76: }
77: $body = json_encode(XmlUtil::toArray($xml));
78: }
79: $json = json_decode($body, true);
80: if (!is_array($json)) {
81: $message = json_last_error_msg();
82: $error = json_last_error();
83: throw new JsonException(sprintf('Json response could not be decoded "%s" (%d) for "%s"', $message, $error, $uri), $error);
84: }
85:
86: $type = key($json);
87: switch ($type) {
88: case 'search-results':
89: return new SearchResults($json['search-results']);
90: case 'abstracts-retrieval-response':
91: return new Abstracts($json['abstracts-retrieval-response']);
92: case 'abstracts-retrieval-multidoc-response':
93: return array_map(function ($data) {
94: return new Abstracts($data);
95: }, $json['abstracts-retrieval-multidoc-response']['abstracts-retrieval-response']);
96: case 'author-retrieval-response':
97: return new Author($json['author-retrieval-response'][0]);
98: case 'author-retrieval-response-list':
99: return array_map(function ($data) {
100: if ($data['@status'] === 'found') {
101: return new Author($data);
102: }
103: }, $json['author-retrieval-response-list']['author-retrieval-response']);
104: case 'abstract-citations-response':
105: return new AbstractCitations($json['abstract-citations-response']);
106: case 'citation-count-response':
107: $document = $json['citation-count-response']['document'];
108:
109: return array_map(function ($data) {
110: return new CitationCount($data);
111: }, isset($document['@status']) ? [$document] : $document);
112: default:
113: throw new Exception(sprintf('Unsupported response type: "%s" for "%s"', $type, $uri));
114: }
115: }
116: }
117:
118: /**
119: * https://dev.elsevier.com/documentation/ScopusSearchAPI.wadl
120: * @param array $query
121: * @return SearchResults
122: */
123: public function search(array $query)
124: {
125: return $this->retrieve(self::SEARCH_URI, [
126: 'query' => $query,
127: ]);
128: }
129:
130: /**
131: * I look for authors by name, surname or affiliation
132: * with https://dev.elsevier.com/documentation/AuthorSearchAPI.wadl
133: *
134: * @param string|null $lastName last name of author to look for
135: * @param string|null $firstName first name of author to look for
136: * @param string|null $affiliation affiliation of author to look for
137: *
138: * @param array $options -> https://dev.elsevier.com/tips/AuthorSearchTips.htm
139: *
140: * @return SearchResults use getEntries() method for get the array of author format -> https://dev.elsevier.com/guides/AuthorSearchViews.htm
141: */
142: public function searchAuthors(?string $lastName = null, ?string $firstName = null, ?string $affiliation = null, array $options = [])
143: {
144: if (empty($lastName) && empty($firstName) && empty($affiliation)) return null;
145:
146: $query = (!empty($lastName)) ? 'authlast("' . $lastName . '")' : "";
147: if (!empty($firstName)) {
148: $query .= (empty($query)) ? "" : " and ";
149: $query .= 'authfirst("' . $firstName . '")';
150: }
151: if (!empty($affiliation)) {
152: $query .= (empty($query)) ? "" : " and ";
153: $query .= 'affil("' . $affiliation . '")';
154: }
155:
156: $query = array_merge($options, $this->query($query)->toArray());
157: return $this->retrieve(self::SEARCH_AUTHOR_URI, [
158: 'query' => $query,
159: ]);
160: }
161:
162: /**
163: * I recover the citations on a specific document
164: * https://dev.elsevier.com/documentation/AbstractCitationAPI.wadl
165: *
166: * I can set a range of years to show: startYear - endYear
167: *
168: * @param array/String $documentId Scopus_id of the document or array of the document Scopus_id
169: * @param string|null $startYear Start year range
170: * @param string|null $endYear End of range year
171: * @param array $options -> https://dev.elsevier.com/documentation/AbstractCitationAPI.wadl#simple
172: *
173: * @return AbstractCitations[] Return all quotes over the years grouped by 25 documents.
174: *
175: * Call the getCompactInfo() method, in the single instance, to retrieve the document citations in details (max 25 for instance),
176: * Call the getTotalCompactInfo() method, in the single instance, to retrieve all documents citations (max 25 for instance)
177: */
178: public function overviewCitation($documentId, ?string $startYear = null, ?string $endYear = null, array $options = [])
179: {
180: if ($startYear && $endYear) $options["date"] = $startYear . "-" . $endYear;
181: if (!is_array($documentId)) $documentId = [$documentId];
182:
183: $responses = [];
184: $pieces = array_chunk($documentId, 25);
185: foreach ($pieces as $piece) {
186: $options["scopus_id"] = "(" . implode(",", $piece) . ")";
187: array_push($responses, $this->retrieve(self::CITATION_OVERVIEW_URI, [
188: 'query' => $options,
189: ]));
190: }
191: return $responses;
192: }
193:
194: /**
195: * @param string|string[] $scopusId
196: * @param array $options
197: *
198: * @return CitationCount[]
199: *
200: * @throws Exception
201: */
202: public function retrieveCitationCount($scopusId, array $options = [])
203: {
204: if (is_array($scopusId)) {
205: $scopusId = implode(',', $scopusId);
206: }
207:
208: if (count(explode(',', $scopusId)) > 25) {
209: throw new Exception("The maximum number of 25 document id's exceeded!");
210: }
211:
212: $options['scopus_id'] = $scopusId;
213:
214: return $this->retrieve(self::CITATION_COUNT_URI, [
215: 'query' => $options
216: ]);
217: }
218:
219: /**
220: * @param $scopusId
221: * @param array $options
222: * @return Abstracts|Abstracts[]
223: * @throws Exception
224: */
225: public function retrieveAbstract($scopusId, array $options = [])
226: {
227: if (is_array($scopusId)) {
228: $scopusId = implode(',', $scopusId);
229: }
230: if (count(explode(',', $scopusId)) > 25) {
231: throw new Exception("The maximum number of 25 abstract id's exceeded!");
232: }
233: return $this->retrieve(self::ABSTRACT_URI . $scopusId, [
234: 'query' => $options
235: ]);
236: }
237:
238: /**
239: * @param $scopusIds
240: * @param array $options
241: * @return Abstracts[]
242: */
243: public function retrieveAbstracts($scopusIds, array $options = []): array
244: {
245: $scopusIds = array_values(array_unique($scopusIds));
246:
247: if (!$scopusIds) {
248: return [];
249: }
250:
251: if (count($scopusIds) > 1) {
252: $chunks = array_chunk($scopusIds, 25);
253: $abstracts = [];
254: foreach ($chunks as $chunk) {
255: // Union, not array_merge: array_merge renumbers integer keys, which
256: // would silently drop numeric scopus ids from the result.
257: $abstracts += array_combine($chunk, $this->keyable($this->retrieveAbstract($chunk, $options), $chunk));
258: }
259: return $abstracts;
260: }
261:
262: return [
263: $scopusIds[0] => $this->retrieveAbstract($scopusIds[0], $options),
264: ];
265: }
266:
267: /**
268: * Retrieve an author with
269: * https://dev.elsevier.com/documentation/AuthorRetrievalAPI.wadl
270: * @param $authorId author id
271: * @param array $options -> ['view'=>'ENHANCED'] https://dev.elsevier.com/guides/AuthorRetrievalViews.htm
272: * @return Author|Author[] an Author returns
273: * @throws Exception
274: */
275: public function retrieveAuthor($authorId, array $options = [])
276: {
277: if (is_array($authorId)) {
278: $authorId = implode(',', $authorId);
279: }
280: if (count(explode(',', $authorId)) > 25) {
281: throw new Exception("The maximum number of 25 author id's exceeded!");
282: }
283: return $this->retrieve(self::AUTHOR_URI . $authorId, [
284: 'query' => $options
285: ]);
286: }
287:
288: /**
289: * @param $authorIds
290: * @param array $options
291: * @return Author[]
292: */
293: public function retrieveAuthors($authorIds, array $options = []): array
294: {
295: $authorIds = array_values(array_unique($authorIds));
296:
297: if (!$authorIds) {
298: return [];
299: }
300:
301: if (count($authorIds) > 1) {
302: $chunks = array_chunk($authorIds, 25);
303: $authors = [];
304: foreach ($chunks as $chunk) {
305: $authors += array_combine($chunk, $this->keyable($this->retrieveAuthor($chunk, $options), $chunk));
306: }
307: return $authors;
308: }
309:
310: return [
311: $authorIds[0] => $this->retrieveAuthor($authorIds[0], $options),
312: ];
313: }
314:
315: /**
316: * A chunk of one id gets a single-document response rather than a list, and Scopus may
317: * return fewer documents than were asked for. Either way array_combine() would fail, so
318: * normalise the shape and refuse to key results we cannot key correctly.
319: *
320: * @param mixed $retrieved
321: * @param array $chunk
322: *
323: * @return array
324: *
325: * @throws Exception
326: */
327: private function keyable($retrieved, array $chunk): array
328: {
329: $retrieved = is_array($retrieved) ? $retrieved : [$retrieved];
330:
331: if (count($retrieved) !== count($chunk)) {
332: throw new Exception(sprintf(
333: 'Scopus returned %d of the %d requested documents (%s), so results cannot be keyed by id.',
334: count($retrieved),
335: count($chunk),
336: implode(',', $chunk)
337: ));
338: }
339:
340: return $retrieved;
341: }
342:
343: public function retrieveAffiliation($affiliationId, array $options = [])
344: {
345: return $this->retrieve(self::AFFILIATION_URI . $affiliationId, $options);
346: }
347:
348: /**
349: * I recover the documents of a specific Author with
350: * https://dev.elsevier.com/documentation/ScopusSearchAPI.wadl
351: * cursor next = prendo i successivi 25
352: *
353: * I can set a search range: startYear < AnnoDocumento < endYear
354: * ! Warning: do not startYear <= Document Year <= endYear
355: *
356: * @param string $authorId AUTHOR_ID
357: * @param string|null $startYear Start year range
358: * @param string|null $endYear End of range year
359: * @param bool $jrDocument If I only want items of type j or r
360: *
361: * @return Entry[] Return all articles by the selected author https://dev.elsevier.com/guides/ScopusSearchViews.htm
362: */
363: public function retrieveDocumentsAuthor(string $authorId, ?string $startYear = null, ?string $endYear = null, bool $jrDocument = false)
364: {
365: //Query parameters -> https://dev.elsevier.com/tips/ScopusSearchTips.htm
366: $query = "au-id(" . $authorId . ")";
367: if ($startYear != null && $endYear != null) $query .= " and PUBYEAR > $startYear AND PUBYEAR < $endYear";
368: if ($jrDocument) $query .= " and SRCTYPE(r OR j)";
369:
370: $searchResults = $this->query($query)->withCursor()->viewComplete()->search();
371: $documents = $searchResults->getEntries(); //Entries[]
372:
373: $numDocument = $searchResults->getTotalResults() - $searchResults->countEntries();
374: while ($numDocument > 0) { //recover all documents
375: $cursor = $searchResults->getNextCursor();
376: $searchResults = $this->query($query)->setCursor($cursor)->viewComplete()->search();
377: $documents = array_merge($documents, $searchResults->getEntries());
378: $numDocument -= $searchResults->countEntries();
379: }
380: return $documents;
381: }
382: }
383: