参考文献格式--book
This commit is contained in:
743
application/common/BookMetadataService.php
Normal file
743
application/common/BookMetadataService.php
Normal file
@@ -0,0 +1,743 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user