Files
tougao/application/common/ReferenceLiteratureFetchService.php
wyn 8785610e6d 参考文献作者堆叠
参考文献相关性检测
作者ai写作辅助检测工作
2026-07-15 10:49:05 +08:00

623 lines
22 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\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,
];
}
}