Files
tougao/application/common/BookMetadataService.php
2026-08-07 15:26:09 +08:00

744 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
// 章节引用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));
}
}