370 lines
12 KiB
PHP
370 lines
12 KiB
PHP
<?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 约定一致)
|
||
* - other:refer_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);
|
||
}
|
||
|
||
// 图书绝大多数没有 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/");
|
||
}
|
||
}
|