classifier = new ReferenceTypeClassifier(['use_llm' => true]); $this->metadata = new ReferenceMetadataService(); $this->bookMetadata = new BookMetadataService(); $this->bookParser = new BookCitationParser(); } /** * 将文章下全部有效参考文献逐条入队分流(HTTP 内只做 push,立即返回) */ public function enqueueRefersByType($pArticleId) { $pArticleId = intval($pArticleId); if ($pArticleId <= 0) { return; } $refers = Db::name('production_article_refer') ->where('p_article_id', $pArticleId) ->where('state', 0) ->order('index asc, p_refer_id asc') ->select(); if (empty($refers)) { return; } foreach ($refers as $refer) { Queue::push( 'app\api\job\ReferenceDispatchQueue@fire', $refer, 'ReferenceDispatchQueue' ); } } /** * 处理单条参考文献分流(由 ReferenceDispatchQueue 调用) */ public function dispatchReferByType($pReferId) { $pReferId = intval($pReferId); if ($pReferId <= 0) { return; } $refer = Db::name('production_article_refer') ->where('p_refer_id', $pReferId) ->where('state', 0) ->find(); if (empty($refer)) { return; } $meta = null; $typeHint = ''; if (trim((string)$refer['refer_doi']) !== '') { $doiNorm = $this->normalizeDoi($refer['refer_doi']); if ($doiNorm !== '') { $meta = $this->metadata->fetchByDoi($doiNorm); if (is_array($meta)) { $typeHint = trim((string)$meta['crossref_type']); // Crossref 无 type 但 PubMed 收录时,给分类器一个等价的 Crossref type if ($typeHint === '' && $meta['type'] === ReferenceTypeClassifier::TYPE_JOURNAL) { $typeHint = 'journal-article'; } } } } $typeInfo = $this->classifier->classify((string)$refer['refer_content'], $typeHint); $dispatchType = $this->classifier->normalizeDispatchType($typeInfo['type']); Db::name('production_article_refer')->where('p_refer_id', $refer['p_refer_id'])->update([ 'refer_type' => $dispatchType, 'update_time' => time(), ]); $refer['refer_type'] = $dispatchType; switch ($dispatchType) { case ReferenceTypeClassifier::TYPE_BOOK: $this->processBookRefer($refer, $meta); break; case ReferenceTypeClassifier::TYPE_OTHER: $this->processOtherRefer($refer); break; default: $this->processJournalRefer($refer); break; } } /** * 同步处理整篇文章(仅供脚本/调试,勿在 HTTP 或单条队列任务中批量调用) */ public function dispatchRefersByType($pArticleId) { $pArticleId = intval($pArticleId); if ($pArticleId <= 0) { return; } $refers = Db::name('production_article_refer') ->where('p_article_id', $pArticleId) ->where('state', 0) ->order('index asc, p_refer_id asc') ->column('p_refer_id'); foreach ($refers as $pReferId) { $this->dispatchReferByType($pReferId); } } private function processJournalRefer(array $refer) { $pReferId = intval($refer['p_refer_id']); if (trim((string)$refer['refer_doi']) === '') { Db::name('production_article_refer')->where('p_refer_id', $pReferId)->update([ 'refer_frag' => $refer['refer_content'], 'refer_type' => ReferenceTypeClassifier::TYPE_JOURNAL, 'is_deal' => 1, 'update_time' => time(), ]); return; } Queue::push('app\api\job\ArticleReferDetailQueue@fire', $refer, 'ArticleReferDetailQueue'); } private function processOtherRefer(array $refer) { Db::name('production_article_refer')->where('p_refer_id', intval($refer['p_refer_id']))->update([ 'refer_frag' => $refer['refer_content'], 'refer_type' => ReferenceTypeClassifier::TYPE_OTHER, 'cs' => 0, 'is_deal' => 1, 'update_time' => time(), ]); } /** * book:结构化字段,DOI 仅用于补数据 */ private function processBookRefer(array $refer, $meta) { $pReferId = intval($refer['p_refer_id']); $content = (string)$refer['refer_content']; $update = [ 'refer_type' => ReferenceTypeClassifier::TYPE_BOOK, 'is_deal' => 1, 'update_time' => time(), ]; $summary = is_array($meta) ? $meta['crossref_summary'] : null; $raw = is_array($summary) ? ($summary['raw'] ?? []) : []; $hasMeta = is_array($meta) && (trim((string)$meta['title']) !== '' || trim((string)$meta['author']) !== '') // 图书著录里挂的 DOI 常常是被错误关联的期刊文章,题名对不上就不能拿来覆盖 && $this->metaMatchesContent($meta, $content); if ($hasMeta) { $authorCitation = trim((string)$meta['author']); $update['author'] = $authorCitation !== '' ? rtrim($authorCitation, '.') . '.' : ''; $update['title'] = trim((string)$meta['title']); $update['joura'] = !empty($raw) ? $this->extractBookPublisher($raw, $summary) : ''; $update['dateno'] = !empty($raw) ? $this->extractBookDateno($raw) : ''; $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; if (!$hasCore) { $update['refer_frag'] = $content; } Db::name('production_article_refer')->where('p_refer_id', $pReferId)->update($update); } /** * DOI 抓回来的题名是否确实是这条著录说的那本书 */ private function metaMatchesContent($meta, $content) { $title = $this->normalizeForMatch((string)$meta['title']); if ($title === '') { return false; } $blob = $this->normalizeForMatch($content); if ($blob === '' || strpos($blob, $title) !== false) { return true; } $words = array_filter(explode(' ', $title), function ($w) { return strlen($w) > 3; }); if (empty($words)) { return false; } $hit = 0; foreach ($words as $word) { if (strpos($blob, $word) !== false) { $hit++; } } return ($hit / count($words)) >= 0.6; } private function normalizeForMatch($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)); } /** * 按书名/作者/年份/版次反查 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'] ?? '')); if ($publisher !== '') { return $publisher; } $pub = $summary['publisher'] ?? []; if (!empty($pub['publisher'])) { return trim((string)$pub['publisher']); } if (!empty($pub['title'])) { return trim((string)$pub['title']); } return ''; } private function extractBookDateno(array $raw) { if (!empty($raw['published']['date-parts'][0][0])) { return (string)$raw['published']['date-parts'][0][0]; } if (!empty($raw['issued']['date-parts'][0][0])) { return (string)$raw['issued']['date-parts'][0][0]; } if (!empty($raw['created']['date-parts'][0][0])) { return (string)$raw['created']['date-parts'][0][0]; } return ''; } private function extractIsbnFromRaw(array $raw) { if (empty($raw['ISBN']) || !is_array($raw['ISBN'])) { return ''; } foreach ($raw['ISBN'] as $isbn) { $isbn = trim((string)$isbn); if ($isbn !== '') { return $isbn; } } return ''; } /** * 无可用 DOI 元数据时,从原文著录抽取 book 结构化字段 */ private function parseBookFromContent($content) { $parsed = $this->bookParser->parse($content); $author = trim((string)$parsed['author']); return [ 'author' => $author !== '' ? rtrim($author, '.') . '.' : '', 'title' => trim((string)$parsed['title']), 'joura' => trim((string)$parsed['publisher']), 'dateno' => trim((string)$parsed['year']), 'isbn' => trim((string)$parsed['isbn']), 'is_ja' => 1, ]; } private function normalizeDoi($doi) { $doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi)); return trim($doi, " \t\n\r\0\x0B/"); } }