参考文献作者堆叠

参考文献相关性检测
作者ai写作辅助检测工作
This commit is contained in:
wyn
2026-07-15 10:49:05 +08:00
parent da71dfc04e
commit 8785610e6d
27 changed files with 10008 additions and 204 deletions

View File

@@ -0,0 +1,622 @@
<?php
namespace app\common;
use think\Env;
use Smalot\PdfParser\Parser as PdfParser;
/**
* 参考文献内容抓取cma.j.cn 优先中文源 → Europe PMC → PubMed → PMC全文 → Unpaywall PDF → Crossref
*/
class ReferenceLiteratureFetchService
{
/** @var EuropePmcService */
private $epmc;
/** @var PubmedService */
private $pubmed;
/** @var CrossrefService */
private $crossref;
/** @var UnpaywallService */
private $unpaywall;
/** @var CmaJournalLiteratureService */
private $cmaJournal;
/** @var ReferenceCheckService */
private $refUtil;
/** @var bool 预抓取阶段暂不调用 Yiigle 机构 API */
private $skipYiigle = false;
public function __construct()
{
$this->epmc = new EuropePmcService();
$this->pubmed = new PubmedService([
'email' => trim((string)Env::get('pubmed_email', '')),
'tool' => trim((string)Env::get('pubmed_tool', 'tmrjournals')),
]);
$this->crossref = new CrossrefService([
'mailto' => trim((string)Env::get('crossref_mailto', '')),
]);
$this->unpaywall = new UnpaywallService();
$this->cmaJournal = new CmaJournalLiteratureService();
$this->refUtil = new ReferenceCheckService();
}
public function setSkipYiigle($skip = true)
{
$this->skipYiigle = (bool)$skip;
return $this;
}
/**
* @return array{
* doi:string,pmid:string,pmcid:string,title:string,journal:string,year:string,
* abstract:string,raw_content:string,pdf_url:string,mesh_terms:array,sources:array,fetch_log:string
* }
*/
public function fetchForRefer(array $refer)
{
DbReconnectHelper::release();
// 图书/ISBN 类参考文献不走 DOI 管道(书上的 DOI 常为错误挂接的期刊文章)
if ($this->shouldSkipDoiFetchForRefer($refer)) {
return $this->emptyResult('book_skip_doi_fetch');
}
$dois = $this->resolveDoiCandidatesForFetch($refer);
foreach ($dois as $doi) {
$result = $this->fetchByDoiPipeline($doi, $refer);
if ($this->fetchedResultMatchesRefer($result, $refer)) {
return $result;
}
}
$title = trim((string)($refer['title'] ?? ''));
$author = trim((string)($refer['author'] ?? ''));
$year = $this->extractYearFromRefer($refer);
if ($title === '') {
$title = $this->guessTitleFromReferContent($refer);
}
$resolvedDoi = '';
$meta = null;
if ($title !== '') {
$meta = $this->epmc->searchByBibliographic($title, $author, $year);
if (is_array($meta) && trim((string)($meta['doi'] ?? '')) !== '') {
$resolvedDoi = trim((string)$meta['doi']);
}
if ($resolvedDoi === '') {
$pub = $this->pubmed->searchByBibliographic($title, $author, $year);
if (is_array($pub)) {
$resolvedDoi = trim((string)($pub['doi'] ?? ''));
if ($meta === null) {
$meta = $pub;
}
}
}
}
if ($resolvedDoi !== '') {
$result = $this->fetchByDoiPipeline($resolvedDoi, $refer);
if (is_array($meta)) {
if ($result['title'] === '' && trim((string)($meta['title'] ?? '')) !== '') {
$result['title'] = trim((string)$meta['title']);
}
}
if ($this->fetchedResultMatchesRefer($result, $refer)) {
$result['fetch_log'] = 'no_doi_in_refer; resolved_doi=' . $resolvedDoi . '; ' . $result['fetch_log'];
return $result;
}
}
return $this->emptyResult('no_doi_and_bibliographic_search_failed');
}
/**
* 判断已入库/已清洗内容与 refer 元数据是否明显错配(用于跳过错误缓存、触发重抓)。
*/
public function storedContentMatchesRefer(array $refer, $abstract, $cleaned)
{
$text = trim((string)$abstract . "\n" . (string)$cleaned);
if ($text === '') {
return true;
}
// 子表内容已按 p_refer_id 绑定;用 refer 标题锚定,避免中文摘要因不含英文作者姓氏被误判为错配
$expectedTitle = trim((string)($refer['title'] ?? ''));
return $this->fetchedResultMatchesRefer([
'title' => $expectedTitle,
'abstract' => $text,
'raw_content' => $text,
], $refer);
}
/**
* 图书类参考文献refer_type=book 或带 ISBN 且呈教材/专著特征时,不通过 DOI 抓外部摘要。
*/
private function shouldSkipDoiFetchForRefer(array $refer)
{
if (strtolower(trim((string)($refer['refer_type'] ?? ''))) === 'book') {
return true;
}
$isbn = trim((string)($refer['isbn'] ?? ''));
if ($isbn === '') {
return false;
}
$blob = strtolower(
trim((string)($refer['joura'] ?? '')) . ' '
. trim((string)($refer['dateno'] ?? '')) . ' '
. trim((string)($refer['title'] ?? ''))
);
return preg_match('/\bed\.?|edition|publishing|press|lippincott|elsevier|springer|wiley|company|图书|教材|专著/u', $blob);
}
/**
* 相关性校对抓取:优先 refer_doi/doilink结构化字段再 refer_content原始文本可能错链
*
* @return string[]
*/
private function resolveDoiCandidatesForFetch(array $refer)
{
$result = [];
foreach (['refer_doi', 'doilink', 'doi', 'refer_content', 'refer_frag'] as $field) {
$slice = array_merge($refer, ['refer_content' => (string)($refer[$field] ?? '')]);
foreach ($this->refUtil->extractAllDoiCandidatesFromRefer($slice) as $doi) {
if (!in_array($doi, $result, true)) {
$result[] = $doi;
}
}
}
return $result;
}
/**
* 校验抓取结果标题/作者是否与 refer 行一致,防止 refer_content 错链到另一篇文献。
*/
private function fetchedResultMatchesRefer(array $result, array $refer)
{
$expectedTitle = trim((string)($refer['title'] ?? ''));
$fetchedTitle = trim((string)($result['title'] ?? ''));
$blob = strtolower(
$fetchedTitle . ' '
. trim((string)($result['abstract'] ?? '')) . ' '
. trim((string)($result['raw_content'] ?? ''))
);
$titleConfirmed = false;
if ($expectedTitle !== '' && $fetchedTitle !== '') {
if (!$this->titlesLikelyMatch($expectedTitle, $fetchedTitle)) {
return false;
}
$titleConfirmed = true;
}
$author = trim((string)($refer['author'] ?? ''));
// 标题已能确认同一文献时,不再要求摘要/正文里出现作者姓氏PubMed 摘要通常不含作者)
if ($author !== '' && !$titleConfirmed) {
$needles = $this->extractAuthorNeedles($author);
$matched = 0;
foreach ($needles as $needle) {
if ($needle !== '' && strpos($blob, $needle) !== false) {
$matched++;
}
}
if (!empty($needles) && $matched === 0) {
return false;
}
}
return true;
}
private function titlesLikelyMatch($expected, $fetched)
{
$a = $this->normalizeTitleForMatch($expected);
$b = $this->normalizeTitleForMatch($fetched);
if ($a === '' || $b === '') {
return true;
}
if ($a === $b || strpos($a, $b) !== false || strpos($b, $a) !== false) {
return true;
}
similar_text($a, $b, $pct);
return $pct >= 38;
}
private function normalizeTitleForMatch($title)
{
$title = strtolower(trim((string)$title));
$title = preg_replace('/[^a-z0-9\s]+/u', ' ', $title);
return trim(preg_replace('/\s+/u', ' ', $title));
}
/**
* @return string[]
*/
private function extractAuthorNeedles($author)
{
$author = trim((string)$author);
if ($author === '') {
return [];
}
$needles = [];
if (preg_match_all('/[A-Za-z]{3,}/', $author, $m)) {
foreach ($m[0] as $part) {
$needles[] = strtolower($part);
}
}
return array_values(array_unique($needles));
}
/**
* 抓取 + LLM 清洗(校对执行时调用)
*
* @return array 含 abstract_final, content_cleaned
*/
public function fetchAndCleanForRefer(array $refer)
{
$fetched = $this->fetchForRefer($refer);
DbReconnectHelper::ensure();
$raw = trim((string)($fetched['raw_content'] ?? ''));
if ($raw === '') {
return array_merge($fetched, [
'abstract_final' => trim((string)($fetched['abstract'] ?? '')),
'content_cleaned' => '',
'content_clean_skip'=> true,
]);
}
DbReconnectHelper::release();
$fetchedForClean = $fetched;
$clean = (new \app\common\service\ReferenceContentCleanLlmService())->clean($raw, $fetchedForClean);
DbReconnectHelper::ensure();
$abstractFinal = trim((string)($clean['abstract'] ?? ''));
if ($abstractFinal === '') {
$abstractFinal = trim((string)($fetched['abstract'] ?? ''));
}
return array_merge($fetched, [
'abstract_final' => $abstractFinal,
'content_cleaned' => trim((string)($clean['cleaned'] ?? '')),
'content_clean_skip' => !empty($clean['skipped']),
]);
}
private function fetchByDoiPipeline($doi, array $refer)
{
$doi = trim((string)$doi);
$blocks = [];
$sources = [];
$abstract = '';
$title = trim((string)($refer['title'] ?? ''));
$pmid = '';
$pmcid = '';
$journal = '';
$year = $this->extractYearFromRefer($refer);
$fetchLogs = [];
$pdfUrl = '';
$meshTerms = [];
// 0) 中华医学会期刊 DOIOpenAlex 中文摘要(可选 Yiigle 机构 API
if (CmaJournalLiteratureService::isCmaJournalDoi($doi)) {
$cmaSvc = $this->cmaJournal;
if ($this->skipYiigle) {
$cmaSvc = new CmaJournalLiteratureService(['skip_yiigle' => true]);
}
$cma = $cmaSvc->fetchByDoi($doi);
if (is_array($cma)) {
$sources = array_merge($sources, (array)($cma['sources'] ?? []));
if ($title === '' && trim((string)($cma['title'] ?? '')) !== '') {
$title = trim((string)$cma['title']);
}
if ($journal === '' && trim((string)($cma['journal'] ?? '')) !== '') {
$journal = trim((string)$cma['journal']);
}
if ($year === '' && trim((string)($cma['year'] ?? '')) !== '') {
$year = trim((string)$cma['year']);
}
if ($pmid === '' && trim((string)($cma['pmid'] ?? '')) !== '') {
$pmid = trim((string)$cma['pmid']);
}
if (trim((string)($cma['abstract'] ?? '')) !== '') {
$abstract = trim((string)$cma['abstract']);
}
foreach ((array)($cma['blocks'] ?? []) as $block) {
$block = trim((string)$block);
if ($block !== '') {
$blocks[] = $block;
}
}
$cmaContent = trim((string)($cma['content'] ?? ''));
if ($cmaContent !== '') {
$blocks[] = "=== 中华医学期刊全文 ===\n" . $this->truncate($cmaContent, 20000);
}
if (trim((string)($cma['fetch_log'] ?? '')) !== '') {
$fetchLogs[] = (string)$cma['fetch_log'];
}
}
}
// 1) Europe PMC by DOI
$epmc = $this->epmc->searchByDoi($doi);
if (is_array($epmc)) {
$sources[] = 'europe_pmc';
if ($title === '') {
$title = trim((string)($epmc['title'] ?? ''));
}
if (trim((string)($epmc['abstract'] ?? '')) !== '') {
$abstract = trim((string)$epmc['abstract']);
$blocks[] = "=== Europe PMC ===\n" . $abstract;
}
$pmid = trim((string)($epmc['pmid'] ?? ''));
$pmcid = trim((string)($epmc['pmcid'] ?? ''));
$journal = trim((string)($epmc['journal'] ?? ''));
if ($year === '' && trim((string)($epmc['year'] ?? '')) !== '') {
$year = trim((string)$epmc['year']);
}
}
// 2) PubMed metadata
$pub = $this->pubmed->fetchByDoi($doi);
if (is_array($pub)) {
$sources[] = 'pubmed';
if ($pmid === '' && trim((string)($pub['pmid'] ?? '')) !== '') {
$pmid = trim((string)$pub['pmid']);
}
if ($title === '' && trim((string)($pub['title'] ?? '')) !== '') {
$title = trim((string)$pub['title']);
}
if ($abstract === '' && trim((string)($pub['abstract'] ?? '')) !== '') {
$abstract = trim((string)$pub['abstract']);
}
if (!empty($pub['mesh_terms']) && is_array($pub['mesh_terms'])) {
$meshTerms = array_values(array_unique(array_merge($meshTerms, $pub['mesh_terms'])));
}
$pubBlock = $this->formatPubmedBlock($pub, $doi);
if ($pubBlock !== '' && $abstract === '') {
$blocks[] = $pubBlock;
} elseif ($pubBlock !== '' && !CmaJournalLiteratureService::isCmaJournalDoi($doi)) {
$blocks[] = $pubBlock;
}
}
// 3) PMC full text
if ($pmcid !== '') {
$full = $this->epmc->fetchFullTextByPmcid($pmcid);
if ($full !== '') {
$sources[] = 'pmc_fulltext';
$blocks[] = "=== PMC Full Text ({$pmcid}) ===\n" . $this->truncate($full, 20000);
}
}
// 4) Unpaywall OA PDF + PDF parse
$oaPdfUrl = $this->unpaywall->findOaPdfUrl($doi);
if ($oaPdfUrl !== '') {
$pdfUrl = $oaPdfUrl;
$pdfText = $this->downloadAndExtractPdf($oaPdfUrl);
if ($pdfText !== '') {
$sources[] = 'unpaywall_pdf';
$blocks[] = "=== OA PDF Extract ===\nSource: {$oaPdfUrl}\n" . $this->truncate($pdfText, 25000);
}
}
if ($pdfUrl === '' && $pmcid !== '') {
$pdfUrl = $this->buildPmcPdfUrl($pmcid);
}
// 5) Crossref supplement已有实质性摘要或全文时跳过避免与 PubMed 等重复)
if (!$this->shouldSkipCrossrefSupplement($abstract, $sources, $blocks)) {
$cr = $this->refUtil->fetchCrossrefAbstractByReferDoi(['refer_doi' => $doi, 'doi' => $doi]);
if (is_array($cr) && trim((string)($cr['text'] ?? '')) !== '') {
$sources[] = 'crossref';
$blocks[] = trim((string)$cr['text']);
if ($abstract === '' && !empty($cr['has_abstract'])) {
if (preg_match('/Abstract:\s*(.+)/uis', (string)$cr['text'], $m)) {
$abstract = trim($m[1]);
}
}
}
} else {
$fetchLogs[] = 'crossref=skipped_has_abstract_or_fulltext';
}
$raw = trim(implode("\n\n", array_filter($blocks)));
if ($raw === '' && $abstract !== '') {
$raw = $abstract;
}
return [
'doi' => $doi,
'pmid' => $pmid,
'pmcid' => $pmcid,
'title' => $title,
'journal' => $journal,
'year' => $year,
'abstract' => $abstract,
'raw_content' => $raw,
'pdf_url' => $pdfUrl,
'mesh_terms' => $meshTerms,
'sources' => array_values(array_unique($sources)),
'fetch_log' => trim('doi=' . $doi . '; sources=' . implode(',', $sources) . ($fetchLogs ? '; ' . implode('; ', $fetchLogs) : '')),
];
}
private function shouldSkipCrossrefSupplement($abstract, array $sources, array $blocks)
{
if (mb_strlen(trim((string)$abstract)) >= 40) {
return true;
}
if (!empty(array_intersect($sources, ['pmc_fulltext', 'unpaywall_pdf', 'cma_yiigle']))) {
return true;
}
foreach ($blocks as $block) {
if (preg_match('/===\s*(PMC Full Text|OA PDF Extract|中华医学期刊全文)/u', (string)$block)) {
return true;
}
}
return false;
}
private function buildPmcPdfUrl($pmcid)
{
$pmcid = strtoupper(trim((string)$pmcid));
if ($pmcid === '') {
return '';
}
if (strpos($pmcid, 'PMC') !== 0) {
$pmcid = 'PMC' . preg_replace('/\D/', '', $pmcid);
}
return 'https://pmc.ncbi.nlm.nih.gov/articles/' . rawurlencode($pmcid) . '/pdf/';
}
private function formatPubmedBlock(array $pub, $doi)
{
$lines = ['=== PubMed (DOI ' . $doi . ') ==='];
foreach (['title', 'journal', 'year'] as $k) {
if (!empty($pub[$k])) {
$lines[] = ucfirst($k) . ': ' . trim((string)$pub[$k]);
}
}
if (!empty($pub['publication_types'])) {
$lines[] = 'Publication Types: ' . implode('; ', (array)$pub['publication_types']);
}
if (!empty($pub['mesh_terms'])) {
$lines[] = 'MeSH: ' . implode('; ', (array)$pub['mesh_terms']);
}
if (!empty($pub['abstract'])) {
$lines[] = 'Abstract: ' . trim((string)$pub['abstract']);
}
return implode("\n", $lines);
}
private function downloadAndExtractPdf($url)
{
$url = trim((string)$url);
if ($url === '') {
return '';
}
$dir = ROOT_PATH . 'runtime' . DS . 'ref_literature_pdf';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$path = $dir . DS . date('YmdHis') . '_' . uniqid('', true) . '.pdf';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 90,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['User-Agent: TMRjournals-RefFetch/1.0'],
]);
$body = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code < 200 || $code >= 300 || strlen($body) < 1000) {
return '';
}
if (strlen($body) > 15 * 1024 * 1024) {
return '';
}
if (@file_put_contents($path, $body) === false) {
return '';
}
try {
$text = $this->extractPdfText($path);
} finally {
@unlink($path);
}
return $text;
}
private function extractPdfText($path)
{
if (!class_exists(PdfParser::class)) {
return $this->extractPdfTextByPython($path);
}
try {
$parser = new PdfParser();
$pdf = $parser->parseFile($path);
$text = $pdf->getText();
return is_string($text) ? trim($text) : '';
} catch (\Throwable $e) {
return $this->extractPdfTextByPython($path);
}
}
private function extractPdfTextByPython($path)
{
$script = ROOT_PATH . 'scripts' . DS . 'extract_pdf_text.py';
if (!is_file($script)) {
return '';
}
$cmd = 'python ' . escapeshellarg($script) . ' ' . escapeshellarg($path) . ' 2>nul';
$out = shell_exec($cmd);
return is_string($out) ? trim($out) : '';
}
private function extractYearFromRefer(array $refer)
{
$dateno = trim((string)($refer['dateno'] ?? ''));
if (preg_match('/(19|20)\d{2}/', $dateno, $m)) {
return $m[0];
}
return '';
}
private function guessTitleFromReferContent(array $refer)
{
$content = trim((string)($refer['refer_content'] ?? ''));
if ($content === '') {
return '';
}
$line = preg_split('/\n/', $content)[0] ?? $content;
$line = preg_replace('/^\[\d+\]\s*/', '', trim($line));
return mb_substr($line, 0, 300);
}
private function truncate($text, $max)
{
$text = trim((string)$text);
if ($text === '') {
return '';
}
if (mb_strlen($text) <= $max) {
return $text;
}
return mb_substr($text, 0, $max) . "\n...(truncated)";
}
private function emptyResult($reason)
{
return [
'doi' => '',
'pmid' => '',
'pmcid' => '',
'title' => '',
'journal' => '',
'year' => '',
'abstract' => '',
'raw_content' => '',
'pdf_url' => '',
'mesh_terms' => [],
'sources' => [],
'fetch_log' => (string)$reason,
];
}
}