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)); } }