Files
tougao/application/common/ReferenceDispatchService.php
2026-08-07 16:04:59 +08:00

370 lines
12 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;
use think\Db;
use think\Queue;
/**
* 参考文献分流处理:按 journal / book / other 三路处理。
* 每条文献单独入 ReferenceDispatchQueue由 worker 并行消费。
*
* - journal有 DOI 入 ArticleReferDetailQueue无 DOI 保留原文
* - book填充结构化字段 author/title/joura/dateno/isbn与 Preaccept、References 约定一致)
* - otherrefer_frag = refer_content不做结构化
*/
class ReferenceDispatchService
{
/** @var ReferenceTypeClassifier */
private $classifier;
/** @var ReferenceMetadataService */
private $metadata;
/** @var BookMetadataService */
private $bookMetadata;
/** @var BookCitationParser */
private $bookParser;
public function __construct()
{
$this->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);
}
// 图书绝大多数没有 DOICrossref 和原文都给不出 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/");
}
}