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(); $this->bibMeta = new \app\common\service\BibliographicMetaService(); } 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 = []; $language = ''; $country = ''; // 0) 中华医学会期刊 DOI:OpenAlex 中文摘要(可选 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']))); } if ($language === '' && trim((string)($pub['language'] ?? '')) !== '') { $language = strtolower(trim((string)$pub['language'])); } if ($country === '') { // 研究开展国以作者单位为准,期刊出版国只作兜底 $country = $this->bibMeta->detectCountry((array)($pub['affiliations'] ?? [])); if ($country === '' && trim((string)($pub['journal_country'] ?? '')) !== '') { $country = $this->bibMeta->detectCountry((string)$pub['journal_country']); } } $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, 'language' => $language, 'country' => $country, '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' => [], 'language' => '', 'country' => '', 'sources' => [], 'fetch_log' => (string)$reason, ]; } }