706 lines
24 KiB
PHP
706 lines
24 KiB
PHP
<?php
|
||
|
||
namespace app\common;
|
||
|
||
/**
|
||
* 图书元数据反查:按 书名 + 作者 + 年份 + 版次 检索 ISBN / 出版社 / 出版年。
|
||
*
|
||
* 背景:绝大多数图书参考文献既没有 DOI(拿不到 Crossref 的 ISBN 数组),
|
||
* 原文里也不会写 ISBN,所以只靠"抽取"永远拿不到,必须按书目信息去反查。
|
||
*
|
||
* 数据源:
|
||
* - Open Library(主源,免密钥可直连):先检索 work,再拉 editions 按年份/版次/出版社挑具体版本
|
||
* - Google Books(备源,需 .env 配 google_books_api_key,无密钥时该 IP 会被 429)
|
||
*
|
||
* 环境变量:
|
||
* - book_isbn_lookup 0 关闭反查,默认开启
|
||
* - book_lookup_timeout 单次请求超时秒数,默认 8
|
||
* - google_books_api_key 配置后启用 Google Books 备源
|
||
*/
|
||
class BookMetadataService
|
||
{
|
||
/** @var int */
|
||
private $timeout = 8;
|
||
|
||
/** @var bool */
|
||
private $enabled = true;
|
||
|
||
/** @var string */
|
||
private $googleKey = '';
|
||
|
||
/** @var int 单条文献反查的总耗时上限(秒),防止队列被慢请求拖住 */
|
||
private $budget = 20;
|
||
|
||
/** @var float */
|
||
private $deadline = 0;
|
||
|
||
public function __construct(array $config = [])
|
||
{
|
||
$this->enabled = $this->envGet('book_isbn_lookup', '1') !== '0';
|
||
$this->googleKey = trim((string)$this->envGet('google_books_api_key', ''));
|
||
$timeout = intval($this->envGet('book_lookup_timeout', 0));
|
||
if ($timeout > 0) {
|
||
$this->timeout = max(3, $timeout);
|
||
}
|
||
|
||
$budget = intval($this->envGet('book_lookup_budget', 0));
|
||
if ($budget > 0) {
|
||
$this->budget = max(5, $budget);
|
||
}
|
||
|
||
if (isset($config['timeout'])) $this->timeout = max(3, intval($config['timeout']));
|
||
if (isset($config['enabled'])) $this->enabled = (bool)$config['enabled'];
|
||
if (isset($config['google_key'])) $this->googleKey = (string)$config['google_key'];
|
||
if (isset($config['budget'])) $this->budget = max(5, intval($config['budget']));
|
||
}
|
||
|
||
private function outOfBudget()
|
||
{
|
||
return $this->deadline > 0 && microtime(true) >= $this->deadline;
|
||
}
|
||
|
||
/**
|
||
* 反查 ISBN
|
||
*
|
||
* @param array $hints title / author / year / publisher / edition / content
|
||
* @return array ['isbn'=>'','publisher'=>'','year'=>'','title'=>'','source'=>'']
|
||
*/
|
||
public function resolve(array $hints)
|
||
{
|
||
$empty = ['isbn' => '', 'publisher' => '', 'year' => '', 'title' => '', 'source' => ''];
|
||
if (!$this->enabled) {
|
||
return $empty;
|
||
}
|
||
|
||
$hints = $this->normalizeHints($hints);
|
||
if ($hints['title'] === '') {
|
||
return $empty;
|
||
}
|
||
|
||
// 原文里直接写了 ISBN 的,优先采信,不用外网
|
||
$inText = $this->extractIsbnFromText($hints['content']);
|
||
if ($inText !== '') {
|
||
return array_merge($empty, ['isbn' => $inText, 'source' => 'text']);
|
||
}
|
||
|
||
$cacheKey = 'book_' . sha1(strtolower($hints['title'] . '|' . $hints['author'] . '|' . $hints['year'] . '|' . $hints['edition']));
|
||
$cached = $this->cacheGet($cacheKey, 30 * 86400);
|
||
if (is_array($cached) && array_key_exists('isbn', $cached)) {
|
||
return array_merge($empty, $cached);
|
||
}
|
||
|
||
$this->deadline = microtime(true) + $this->budget;
|
||
$result = $this->searchOpenLibrary($hints);
|
||
if ($result['isbn'] === '' && $this->googleKey !== '' && !$this->outOfBudget()) {
|
||
$result = $this->searchGoogleBooks($hints);
|
||
}
|
||
$this->deadline = 0;
|
||
|
||
$this->cacheSet($cacheKey, $result);
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 从参考文献原文抽取书目线索(书名 / 作者 / 年份 / 版次 / 出版社)
|
||
*/
|
||
public function extractHints($content, array $known = [])
|
||
{
|
||
$content = trim((string)$content);
|
||
$hints = [
|
||
'title' => trim((string)($known['title'] ?? '')),
|
||
'author' => trim((string)($known['author'] ?? '')),
|
||
'year' => trim((string)($known['year'] ?? '')),
|
||
'publisher' => trim((string)($known['publisher'] ?? '')),
|
||
'edition' => '',
|
||
'content' => $content,
|
||
];
|
||
|
||
if ($content === '') {
|
||
return $hints;
|
||
}
|
||
|
||
// 反查用规则解析即可,不为了取个 ISBN 再多花一次大模型调用
|
||
$parsed = (new BookCitationParser(['use_llm' => false]))->parse($content);
|
||
|
||
// 章节引用反查的是书,不是章节
|
||
if ($parsed['container'] !== '') {
|
||
$hints['title'] = $parsed['container'];
|
||
} elseif ($hints['title'] === '') {
|
||
$hints['title'] = $parsed['title'];
|
||
}
|
||
|
||
foreach (['author', 'publisher', 'edition'] as $field) {
|
||
if ($hints[$field] === '' && $parsed[$field] !== '') {
|
||
$hints[$field] = $parsed[$field];
|
||
}
|
||
}
|
||
if ($hints['year'] === '') {
|
||
$hints['year'] = $parsed['year'];
|
||
}
|
||
|
||
return $hints;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Open Library
|
||
// ------------------------------------------------------------------
|
||
|
||
private function searchOpenLibrary(array $hints)
|
||
{
|
||
$empty = ['isbn' => '', 'publisher' => '', 'year' => '', 'title' => '', 'source' => ''];
|
||
|
||
$q = 'title:"' . $this->escapeSolr($hints['title']) . '"';
|
||
$surnames = $this->authorSurnames($hints['author']);
|
||
if (!empty($surnames)) {
|
||
$q .= ' author:' . $this->escapeSolr($surnames[0]);
|
||
}
|
||
|
||
$url = 'https://openlibrary.org/search.json?' . http_build_query([
|
||
'q' => $q,
|
||
'limit' => 5,
|
||
'fields' => 'key,title,author_name,first_publish_year,edition_count,isbn,publisher',
|
||
]);
|
||
$docs = $this->getJson($url);
|
||
$docs = isset($docs['docs']) && is_array($docs['docs']) ? $docs['docs'] : [];
|
||
if (empty($docs) && !empty($surnames) && !$this->outOfBudget()) {
|
||
// 作者写法不一致时(编者、机构作者)退回只按书名检索
|
||
$url = 'https://openlibrary.org/search.json?' . http_build_query([
|
||
'q' => 'title:"' . $this->escapeSolr($hints['title']) . '"',
|
||
'limit' => 5,
|
||
'fields' => 'key,title,author_name,first_publish_year,edition_count,isbn,publisher',
|
||
]);
|
||
$docs = $this->getJson($url);
|
||
$docs = isset($docs['docs']) && is_array($docs['docs']) ? $docs['docs'] : [];
|
||
}
|
||
|
||
// 同一本书常被拆成多个 work(书名带不带副标题、换了出版社),
|
||
// 只看第一个命中的 work 会漏掉真正对得上版次的那一版,所以候选放在一起比
|
||
$best = null;
|
||
$bestScore = -1;
|
||
$checked = 0;
|
||
foreach ($docs as $doc) {
|
||
if ($checked >= 3 || $this->outOfBudget()) {
|
||
break;
|
||
}
|
||
$docTitle = trim((string)($doc['title'] ?? ''));
|
||
if (!$this->titlesMatch($hints['title'], $docTitle)) {
|
||
continue;
|
||
}
|
||
// 章节引用里"作者"是章节作者、编者写法也各式各样,对不上时不直接放弃,
|
||
// 改为要求出版年精确命中且另有版次或出版社佐证才采信
|
||
$authorOk = $this->authorsMatch($surnames, $doc['author_name'] ?? []);
|
||
|
||
$key = trim((string)($doc['key'] ?? ''));
|
||
if ($key === '') {
|
||
continue;
|
||
}
|
||
$checked++;
|
||
$edition = $this->pickEdition($key, $hints);
|
||
if (!$authorOk && $edition['score'] < 70) {
|
||
continue;
|
||
}
|
||
if ($edition['isbn'] !== '' && $edition['score'] > $bestScore) {
|
||
$bestScore = $edition['score'];
|
||
$best = [
|
||
'isbn' => $edition['isbn'],
|
||
'publisher' => $edition['publisher'],
|
||
'year' => $edition['year'],
|
||
'title' => $docTitle,
|
||
'source' => 'openlibrary',
|
||
];
|
||
}
|
||
// 年份与版次都对上了,没有更好的可能,不用再翻其他 work
|
||
if ($bestScore >= 100) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return $best === null ? $empty : $best;
|
||
}
|
||
|
||
/**
|
||
* 在 work 的版本列表里挑最贴合引文的那一版。
|
||
* 年份与版次是硬条件(对不上直接淘汰),出版社和 ISBN-13 只作同分时的取舍,
|
||
* 否则会出现"出版社对上了就把 2020 年第 14 版当成 2006 年第 11 版"的错配。
|
||
*
|
||
* @return array 附带 score,供跨 work 比较
|
||
*/
|
||
private function pickEdition($workKey, array $hints)
|
||
{
|
||
$empty = ['isbn' => '', 'publisher' => '', 'year' => '', 'score' => -1];
|
||
|
||
$entries = [];
|
||
for ($page = 0; $page < 2; $page++) {
|
||
$url = 'https://openlibrary.org' . $workKey . '/editions.json?limit=100&offset=' . ($page * 100);
|
||
$data = $this->getJson($url);
|
||
$batch = isset($data['entries']) && is_array($data['entries']) ? $data['entries'] : [];
|
||
if (empty($batch)) {
|
||
break;
|
||
}
|
||
$entries = array_merge($entries, $batch);
|
||
if (count($batch) < 100 || count($entries) >= intval($data['size'] ?? 0) || $this->outOfBudget()) {
|
||
break;
|
||
}
|
||
}
|
||
if (empty($entries)) {
|
||
return $empty;
|
||
}
|
||
|
||
$wantYear = intval($hints['year']);
|
||
$wantEdition = $this->editionOrdinal($hints['edition']);
|
||
$wantPublisher = $this->normalizeToken($hints['publisher']);
|
||
$hasConstraint = ($wantYear > 0 || $wantEdition > 0);
|
||
|
||
$best = null;
|
||
$bestScore = -1;
|
||
foreach ($entries as $entry) {
|
||
$isbn = $this->pickBestIsbn(array_merge(
|
||
is_array($entry['isbn_13'] ?? null) ? $entry['isbn_13'] : [],
|
||
is_array($entry['isbn_10'] ?? null) ? $entry['isbn_10'] : []
|
||
));
|
||
if ($isbn === '') {
|
||
continue;
|
||
}
|
||
|
||
$year = $this->parseYear((string)($entry['publish_date'] ?? ''));
|
||
$entryEdition = $this->editionOrdinal((string)($entry['edition_name'] ?? ''));
|
||
|
||
// 版次写明且不一致,一定不是同一版
|
||
if ($wantEdition > 0 && $entryEdition > 0 && $wantEdition !== $entryEdition) {
|
||
continue;
|
||
}
|
||
$editionMatched = ($wantEdition > 0 && $wantEdition === $entryEdition);
|
||
|
||
$score = 0;
|
||
if ($wantYear > 0) {
|
||
if ($year > 0) {
|
||
$diff = abs($wantYear - $year);
|
||
if ($diff === 0) {
|
||
$score += 60;
|
||
} elseif ($diff === 1) {
|
||
// 版权年与实际发行年常差一年
|
||
$score += 40;
|
||
} elseif ($diff <= 2 && $editionMatched) {
|
||
$score += 20;
|
||
} else {
|
||
continue;
|
||
}
|
||
} elseif (!$editionMatched) {
|
||
// 年份对不上又没有版次佐证,不敢认
|
||
continue;
|
||
}
|
||
}
|
||
if ($editionMatched) {
|
||
$score += 40;
|
||
}
|
||
|
||
if ($wantPublisher !== '') {
|
||
foreach ((array)($entry['publishers'] ?? []) as $p) {
|
||
if ($this->tokensOverlap($wantPublisher, $this->normalizeToken($p))) {
|
||
$score += 10;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (strlen($isbn) === 13) {
|
||
$score += 3;
|
||
}
|
||
|
||
if ($score > $bestScore) {
|
||
$bestScore = $score;
|
||
$best = [
|
||
'isbn' => $isbn,
|
||
'publisher' => $this->firstString($entry['publishers'] ?? []),
|
||
'year' => $year > 0 ? (string)$year : '',
|
||
'score' => $score,
|
||
];
|
||
}
|
||
}
|
||
|
||
if ($best === null) {
|
||
return $empty;
|
||
}
|
||
// 引文给了年份/版次,就必须真的对上其中之一
|
||
if ($hasConstraint && $bestScore < 40) {
|
||
return $empty;
|
||
}
|
||
// 引文什么线索都没给:只有单一版本的书才敢直接给 ISBN
|
||
if (!$hasConstraint && count($entries) > 3) {
|
||
return $empty;
|
||
}
|
||
|
||
return $best;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Google Books(备源)
|
||
// ------------------------------------------------------------------
|
||
|
||
private function searchGoogleBooks(array $hints)
|
||
{
|
||
$empty = ['isbn' => '', 'publisher' => '', 'year' => '', 'title' => '', 'source' => ''];
|
||
|
||
$q = 'intitle:"' . $hints['title'] . '"';
|
||
$surnames = $this->authorSurnames($hints['author']);
|
||
if (!empty($surnames)) {
|
||
$q .= ' inauthor:' . $surnames[0];
|
||
}
|
||
|
||
$url = 'https://www.googleapis.com/books/v1/volumes?' . http_build_query([
|
||
'q' => $q,
|
||
'maxResults' => 5,
|
||
'key' => $this->googleKey,
|
||
]);
|
||
$data = $this->getJson($url);
|
||
$items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
|
||
|
||
foreach ($items as $item) {
|
||
$info = isset($item['volumeInfo']) && is_array($item['volumeInfo']) ? $item['volumeInfo'] : [];
|
||
$title = trim((string)($info['title'] ?? ''));
|
||
if (!$this->titlesMatch($hints['title'], $title)) {
|
||
continue;
|
||
}
|
||
if (!$this->authorsMatch($surnames, $info['authors'] ?? [])) {
|
||
continue;
|
||
}
|
||
|
||
$year = $this->parseYear((string)($info['publishedDate'] ?? ''));
|
||
$wantYear = intval($hints['year']);
|
||
if ($wantYear > 0 && $year > 0 && abs($wantYear - $year) > 3) {
|
||
continue;
|
||
}
|
||
|
||
$candidates = [];
|
||
foreach ((array)($info['industryIdentifiers'] ?? []) as $id) {
|
||
$type = strtoupper((string)($id['type'] ?? ''));
|
||
if ($type === 'ISBN_13' || $type === 'ISBN_10') {
|
||
$candidates[] = (string)($id['identifier'] ?? '');
|
||
}
|
||
}
|
||
$isbn = $this->pickBestIsbn($candidates);
|
||
if ($isbn !== '') {
|
||
return [
|
||
'isbn' => $isbn,
|
||
'publisher' => trim((string)($info['publisher'] ?? '')),
|
||
'year' => $year > 0 ? (string)$year : '',
|
||
'title' => $title,
|
||
'source' => 'googlebooks',
|
||
];
|
||
}
|
||
}
|
||
|
||
return $empty;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// 解析与校验
|
||
// ------------------------------------------------------------------
|
||
|
||
private function normalizeHints(array $hints)
|
||
{
|
||
$out = [
|
||
'title' => trim((string)($hints['title'] ?? '')),
|
||
'author' => trim((string)($hints['author'] ?? '')),
|
||
'year' => trim((string)($hints['year'] ?? '')),
|
||
'publisher' => trim((string)($hints['publisher'] ?? '')),
|
||
'edition' => trim((string)($hints['edition'] ?? '')),
|
||
'content' => trim((string)($hints['content'] ?? '')),
|
||
];
|
||
$out['title'] = $this->cleanTitle($out['title']);
|
||
if (mb_strlen($out['title'], 'UTF-8') < 4) {
|
||
$out['title'] = '';
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
private function cleanTitle($title)
|
||
{
|
||
$title = trim((string)$title);
|
||
$title = preg_replace('/\s+/u', ' ', $title);
|
||
// 去掉粘在书名后的版次、卷次、出版地
|
||
$title = preg_replace('/[\.,;]?\s*\b\d{1,2}(?:st|nd|rd|th)?\s*(?:ed\.?|edition|版)\b.*$/iu', '', $title);
|
||
$title = preg_replace('/[\.,;]?\s*\b(?:first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\s+(?:ed\.?|edition)\b.*$/iu', '', $title);
|
||
|
||
return trim($title, " .,;:");
|
||
}
|
||
|
||
private function editionOrdinal($edition)
|
||
{
|
||
$edition = strtolower(trim((string)$edition));
|
||
if ($edition === '') {
|
||
return 0;
|
||
}
|
||
if (preg_match('/\d{1,2}/', $edition, $m)) {
|
||
return intval($m[0]);
|
||
}
|
||
$words = [
|
||
'first' => 1, 'second' => 2, 'third' => 3, 'fourth' => 4, 'fifth' => 5,
|
||
'sixth' => 6, 'seventh' => 7, 'eighth' => 8, 'ninth' => 9, 'tenth' => 10,
|
||
];
|
||
foreach ($words as $word => $num) {
|
||
if (strpos($edition, $word) !== false) {
|
||
return $num;
|
||
}
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
private function parseYear($date)
|
||
{
|
||
return preg_match('/\b(1[5-9]\d{2}|20\d{2})\b/', (string)$date, $m) ? intval($m[1]) : 0;
|
||
}
|
||
|
||
/**
|
||
* @return string[]
|
||
*/
|
||
private function authorSurnames($author)
|
||
{
|
||
$author = trim((string)$author);
|
||
if ($author === '') {
|
||
return [];
|
||
}
|
||
$surnames = [];
|
||
foreach (preg_split('/[,;]+/u', $author) as $chunk) {
|
||
$chunk = trim(preg_replace('/\b(et al\.?|eds?\.?|editors?)\b/i', '', $chunk));
|
||
if (preg_match_all('/[A-Za-z][A-Za-z\'\-]{2,}/u', $chunk, $m)) {
|
||
$surnames[] = $m[0][0];
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique($surnames));
|
||
}
|
||
|
||
private function authorsMatch(array $surnames, $candidates)
|
||
{
|
||
if (empty($surnames)) {
|
||
return true;
|
||
}
|
||
$blob = strtolower(is_array($candidates) ? implode(' ', $candidates) : (string)$candidates);
|
||
if ($blob === '') {
|
||
return true;
|
||
}
|
||
foreach ($surnames as $surname) {
|
||
if (strpos($blob, strtolower($surname)) !== false) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function titlesMatch($expected, $found)
|
||
{
|
||
$a = $this->normalizeToken($expected);
|
||
$b = $this->normalizeToken($found);
|
||
if ($a === '' || $b === '') {
|
||
return false;
|
||
}
|
||
if ($a === $b || strpos($a, $b) !== false || strpos($b, $a) !== false) {
|
||
return true;
|
||
}
|
||
// 中日韩书名逐字比较不可靠,只认包含关系
|
||
if (preg_match('/[\x{4e00}-\x{9fff}]/u', $expected . $found)) {
|
||
return false;
|
||
}
|
||
similar_text($a, $b, $pct);
|
||
|
||
return $pct >= 80;
|
||
}
|
||
|
||
private function normalizeToken($text)
|
||
{
|
||
$text = strtolower(trim((string)$text));
|
||
$text = preg_replace('/[^a-z0-9\x{4e00}-\x{9fff}\s]+/u', ' ', $text);
|
||
|
||
return trim(preg_replace('/\s+/u', ' ', $text));
|
||
}
|
||
|
||
private function tokensOverlap($a, $b)
|
||
{
|
||
if ($a === '' || $b === '') {
|
||
return false;
|
||
}
|
||
if (strpos($a, $b) !== false || strpos($b, $a) !== false) {
|
||
return true;
|
||
}
|
||
$wordsA = array_filter(explode(' ', $a), function ($w) {
|
||
return strlen($w) > 3;
|
||
});
|
||
foreach ($wordsA as $word) {
|
||
if (strpos($b, $word) !== false) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* ISBN-13 优先,且必须通过校验位;只有 ISBN-10 时换算成等价的 ISBN-13
|
||
*/
|
||
private function pickBestIsbn($list)
|
||
{
|
||
$list = is_array($list) ? $list : [$list];
|
||
$isbn10 = '';
|
||
foreach ($list as $raw) {
|
||
$isbn = strtoupper(preg_replace('/[^0-9Xx]/', '', (string)$raw));
|
||
if (!$this->isValidIsbn($isbn)) {
|
||
continue;
|
||
}
|
||
if (strlen($isbn) === 13) {
|
||
return $isbn;
|
||
}
|
||
if ($isbn10 === '') {
|
||
$isbn10 = $isbn;
|
||
}
|
||
}
|
||
|
||
return $isbn10 === '' ? '' : $this->isbn10To13($isbn10);
|
||
}
|
||
|
||
private function isbn10To13($isbn10)
|
||
{
|
||
$body = '978' . substr($isbn10, 0, 9);
|
||
$sum = 0;
|
||
for ($i = 0; $i < 12; $i++) {
|
||
$sum += intval($body[$i]) * (($i % 2 === 0) ? 1 : 3);
|
||
}
|
||
|
||
return $body . ((10 - $sum % 10) % 10);
|
||
}
|
||
|
||
private function isValidIsbn($isbn)
|
||
{
|
||
$len = strlen($isbn);
|
||
if ($len === 13) {
|
||
if (!preg_match('/^\d{13}$/', $isbn)) {
|
||
return false;
|
||
}
|
||
$sum = 0;
|
||
for ($i = 0; $i < 12; $i++) {
|
||
$sum += intval($isbn[$i]) * (($i % 2 === 0) ? 1 : 3);
|
||
}
|
||
|
||
return ((10 - $sum % 10) % 10) === intval($isbn[12]);
|
||
}
|
||
if ($len === 10) {
|
||
if (!preg_match('/^\d{9}[\dX]$/', $isbn)) {
|
||
return false;
|
||
}
|
||
$sum = 0;
|
||
for ($i = 0; $i < 9; $i++) {
|
||
$sum += intval($isbn[$i]) * (10 - $i);
|
||
}
|
||
$sum += ($isbn[9] === 'X') ? 10 : intval($isbn[9]);
|
||
|
||
return $sum % 11 === 0;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function extractIsbnFromText($content)
|
||
{
|
||
if (!preg_match_all('/\bISBN(?:-1[03])?[:\s]*([0-9][0-9\-\s]{8,20}[0-9Xx])/i', (string)$content, $m)) {
|
||
return '';
|
||
}
|
||
|
||
return $this->pickBestIsbn($m[1]);
|
||
}
|
||
|
||
private function firstString($list)
|
||
{
|
||
foreach ((array)$list as $item) {
|
||
$item = trim((string)$item);
|
||
if ($item !== '' && strcasecmp($item, 'Other') !== 0) {
|
||
return $item;
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
private function escapeSolr($text)
|
||
{
|
||
return trim(preg_replace('/["\\\\+\-!(){}\[\]^~*?:\/]+/u', ' ', (string)$text));
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// HTTP / 缓存
|
||
// ------------------------------------------------------------------
|
||
|
||
private function getJson($url)
|
||
{
|
||
$timeout = $this->timeout;
|
||
if ($this->deadline > 0) {
|
||
$left = (int)ceil($this->deadline - microtime(true));
|
||
if ($left <= 0) {
|
||
return [];
|
||
}
|
||
$timeout = min($timeout, $left);
|
||
}
|
||
|
||
$ch = curl_init();
|
||
curl_setopt($ch, CURLOPT_URL, $url);
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(5, $timeout));
|
||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||
'Accept: application/json',
|
||
'User-Agent: TMRjournals-BookLookup/1.0 (' . $this->envGet('crossref_mailto', 'support@tmrjournals.com') . ')',
|
||
]);
|
||
$res = curl_exec($ch);
|
||
$code = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
|
||
curl_close($ch);
|
||
|
||
if ($code !== 200 || !is_string($res) || $res === '') {
|
||
return [];
|
||
}
|
||
$decoded = json_decode($res, true);
|
||
|
||
return is_array($decoded) ? $decoded : [];
|
||
}
|
||
|
||
private function envGet($key, $default = '')
|
||
{
|
||
if (!class_exists('\think\Env')) {
|
||
return $default;
|
||
}
|
||
|
||
return \think\Env::get($key, $default);
|
||
}
|
||
|
||
private function cacheDir()
|
||
{
|
||
return rtrim(ROOT_PATH, '/') . '/runtime/book_cache';
|
||
}
|
||
|
||
private function cacheGet($key, $ttlSeconds)
|
||
{
|
||
$file = $this->cacheDir() . '/' . $key . '.json';
|
||
if (!is_file($file)) {
|
||
return null;
|
||
}
|
||
$mtime = filemtime($file);
|
||
if (!$mtime || (time() - $mtime) > $ttlSeconds) {
|
||
return null;
|
||
}
|
||
|
||
return json_decode((string)@file_get_contents($file), true);
|
||
}
|
||
|
||
private function cacheSet($key, $value)
|
||
{
|
||
$dir = $this->cacheDir();
|
||
if (!is_dir($dir)) {
|
||
@mkdir($dir, 0777, true);
|
||
}
|
||
@file_put_contents($dir . '/' . $key . '.json', json_encode($value, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
}
|