From 175d2edc550b1a8ded01b3b0c73d8399411663b4 Mon Sep 17 00:00:00 2001 From: wangjinlei <751475802@qq.com> Date: Fri, 7 Aug 2026 15:26:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=82=E8=80=83=E6=96=87=E7=8C=AE=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F--book?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/common/BookMetadataService.php | 743 ++++++++++++++++++ .../common/ReferenceDispatchService.php | 59 +- 2 files changed, 796 insertions(+), 6 deletions(-) create mode 100644 application/common/BookMetadataService.php diff --git a/application/common/BookMetadataService.php b/application/common/BookMetadataService.php new file mode 100644 index 00000000..58562565 --- /dev/null +++ b/application/common/BookMetadataService.php @@ -0,0 +1,743 @@ +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; + } + + // 章节引用(In: 编者. 书名. ...)反查的是书,不是章节 + $bookPart = $content; + if (preg_match('/\bIn:\s*(.+)$/i', $content, $m)) { + $bookPart = trim($m[1]); + $hints['title'] = ''; + } + + $hints['edition'] = $this->parseEdition($content); + + if ($hints['year'] === '' && preg_match_all('/\b(?:19|20)\d{2}\b/', $content, $ym)) { + $hints['year'] = end($ym[0]); + } + + // 地点: 出版社; 年份 + if ($hints['publisher'] === '' && preg_match('/:\s*([^;:]{2,60}?)\s*[;,]\s*(?:19|20)\d{2}/u', $bookPart, $m)) { + $hints['publisher'] = trim($m[1], " .,"); + } + + if ($hints['title'] === '') { + $hints['title'] = $this->parseTitle($bookPart); + } + if ($hints['author'] === '') { + $hints['author'] = $this->parseAuthor($bookPart); + } + + 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 parseTitle($text) + { + $parts = preg_split('/\.\s+/u', trim((string)$text), 3); + if (is_array($parts) && count($parts) >= 2) { + return $this->cleanTitle($parts[1]); + } + + return ''; + } + + private function parseAuthor($text) + { + $parts = preg_split('/\.\s+/u', trim((string)$text), 2); + + return is_array($parts) && count($parts) >= 2 ? trim($parts[0]) : ''; + } + + private function parseEdition($text) + { + if (preg_match('/\b(\d{1,2})\s*(?:st|nd|rd|th|d)?\s*(?:ed\.?|edition)\b/iu', $text, $m)) { + return $m[1]; + } + if (preg_match('/\b(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\s+(?:ed\.?|edition)\b/iu', $text, $m)) { + return $m[1]; + } + if (preg_match('/第\s*(\d{1,2})\s*版/u', $text, $m)) { + return $m[1]; + } + + return ''; + } + + 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)); + } +} diff --git a/application/common/ReferenceDispatchService.php b/application/common/ReferenceDispatchService.php index cc43c6c7..95953f9d 100644 --- a/application/common/ReferenceDispatchService.php +++ b/application/common/ReferenceDispatchService.php @@ -21,10 +21,14 @@ class ReferenceDispatchService /** @var ReferenceMetadataService */ private $metadata; + /** @var BookMetadataService */ + private $bookMetadata; + public function __construct() { $this->classifier = new ReferenceTypeClassifier(['use_llm' => true]); $this->metadata = new ReferenceMetadataService(); + $this->bookMetadata = new BookMetadataService(); } /** @@ -185,18 +189,24 @@ class ReferenceDispatchService $update['title'] = trim((string)$meta['title']); $update['joura'] = !empty($raw) ? $this->extractBookPublisher($raw, $summary) : ''; $update['dateno'] = !empty($raw) ? $this->extractBookDateno($raw) : ''; - $isbn = !empty($raw) ? $this->extractIsbnFromRaw($raw) : ''; - if ($isbn === '' && !empty($refer['refer_doi'])) { - $doi = $this->normalizeDoi($refer['refer_doi']); - $isbn = $doi !== '' ? 'https://doi.org/' . $doi : ''; - } - $update['isbn'] = $isbn; + $update['isbn'] = !empty($raw) ? $this->extractIsbnFromRaw($raw) : ''; $update['is_ja'] = 1; } else { $parsed = $this->parseBookFromContent($content); $update = array_merge($update, $parsed); } + // 图书绝大多数没有 DOI,Crossref 和原文都给不出 ISBN,按书目信息去外部书目库反查 + if (trim((string)($update['isbn'] ?? '')) === '') { + $update = array_merge($update, $this->lookupBookIsbn($content, $update)); + } + + // 反查不到时退回 DOI 链接,保持 isbn 字段"可点开验证"的既有语义 + if (trim((string)($update['isbn'] ?? '')) === '' && !empty($refer['refer_doi'])) { + $doi = $this->normalizeDoi($refer['refer_doi']); + $update['isbn'] = $doi !== '' ? 'https://doi.org/' . $doi : ''; + } + $hasCore = trim((string)($update['author'] ?? '')) !== '' && trim((string)($update['title'] ?? '')) !== ''; $update['cs'] = $hasCore ? 1 : 0; @@ -207,6 +217,43 @@ class ReferenceDispatchService Db::name('production_article_refer')->where('p_refer_id', $pReferId)->update($update); } + /** + * 按书名/作者/年份/版次反查 ISBN,顺带补齐缺失的出版社与出版年 + * + * @return array 只包含需要覆盖的字段 + */ + private function lookupBookIsbn($content, array $update) + { + $known = [ + 'title' => (string)($update['title'] ?? ''), + 'author' => (string)($update['author'] ?? ''), + 'publisher' => (string)($update['joura'] ?? ''), + 'year' => preg_match('/\b(19|20)\d{2}\b/', (string)($update['dateno'] ?? ''), $m) ? $m[0] : '', + ]; + + try { + $hints = $this->bookMetadata->extractHints($content, $known); + $found = $this->bookMetadata->resolve($hints); + } catch (\Exception $e) { + \think\Log::write('book isbn lookup failed: ' . $e->getMessage(), 'error'); + return []; + } + + if (trim((string)$found['isbn']) === '') { + return []; + } + + $patch = ['isbn' => trim((string)$found['isbn'])]; + if (trim((string)($update['joura'] ?? '')) === '' && trim((string)$found['publisher']) !== '') { + $patch['joura'] = trim((string)$found['publisher']); + } + if (trim((string)($update['dateno'] ?? '')) === '' && trim((string)$found['year']) !== '') { + $patch['dateno'] = trim((string)$found['year']); + } + + return $patch; + } + private function extractBookPublisher(array $raw, array $summary) { $publisher = trim((string)($raw['publisher'] ?? ''));