From d72b16d9e81778f742241fc6489c73cd07220076 Mon Sep 17 00:00:00 2001 From: wangjinlei <751475802@qq.com> Date: Wed, 5 Aug 2026 18:36:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=82=E8=80=83=E6=96=87=E7=8C=AE=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 3 + application/api/controller/Production.php | 5 +- application/common/CrossrefService.php | 51 ++-- application/common/ProductionArticleRefer.php | 82 +++--- application/common/PubmedService.php | 224 +++++++++++++-- .../common/ReferenceDispatchService.php | 47 ++-- .../common/ReferenceMetadataService.php | 263 ++++++++++++++++++ 7 files changed, 563 insertions(+), 112 deletions(-) create mode 100644 application/common/ReferenceMetadataService.php diff --git a/.env b/.env index c17a547a..a0abd51b 100644 --- a/.env +++ b/.env @@ -85,6 +85,9 @@ citation_chat_url = http://127.0.0.1:11434/v1/chat/completions citation_chat_model = qwen2.5:7b citation_chat_api_key = citation_chat_timeout = 120 +pubmed_email = 13662001490@126.com +pubmed_api_key = c6f752b40432eb6e4522f0ff219bb9ff7608 +crossref_mailto = 13662001490@126.com [expert_country] chat_url_local = http://125.39.141.154:10002/v1/chat/completions diff --git a/application/api/controller/Production.php b/application/api/controller/Production.php index d1e451c7..d6daa605 100644 --- a/application/api/controller/Production.php +++ b/application/api/controller/Production.php @@ -2824,7 +2824,8 @@ class Production extends Base ]; // 匹配模式:year;volume(number):pages - $pattern = '/(\d{4})\s*;\s*(\d+)(?:\(([^)]+)\))?(?:\s*:\s*([a-zA-Z0-9\u2013\u2014\-]+(?:\s*[\u2013\u2014\-]\s*[a-zA-Z0-9]+)?))?/'; + // PCRE 不认 \uXXXX(会当成字面量 u),页码连接符须写成 \x{2013} 并加 u 修饰符 + $pattern = '/(\d{4})\s*;\s*(\d+)(?:\(([^)]+)\))?(?:\s*:\s*([a-zA-Z0-9]+(?:\s*[\x{2013}\x{2014}\x{2212}-]\s*[a-zA-Z0-9]+)?))?/u'; if (preg_match($pattern, $referenceText, $matches)) { @@ -2863,7 +2864,7 @@ class Production extends Base } // 提取卷号和期号(格式如 54(4) 或 54 (4)) - if (preg_match('/(\d+)\s*\(?(\d*)\)?\s*:?\s*([a-zA-Z0-9\-]*)/', $referenceText, $volMatches)) { + if (preg_match('/(\d+)\s*\(?(\d*)\)?\s*:?\s*([a-zA-Z0-9\x{2013}\x{2014}\x{2212}-]*)/u', $referenceText, $volMatches)) { if (isset($volMatches[1])) { $result['volume'] = $volMatches[1]; } diff --git a/application/common/CrossrefService.php b/application/common/CrossrefService.php index 6c029425..b2ebc00e 100644 --- a/application/common/CrossrefService.php +++ b/application/common/CrossrefService.php @@ -470,39 +470,52 @@ class CrossrefService */ public function getPublishYear($aDoiInfo = []) { - if (!empty($aDoiInfo['issued']['date-parts'][0][0])) { - return (string)$aDoiInfo['issued']['date-parts'][0][0]; + // 著录用的是期次年。issued 取线上/线下较早者,在线优先出版的文献会偏早一年, + // 因此先看 published-print,再回退 issued。 + $candidates = [ + $aDoiInfo['published-print']['date-parts'][0][0] ?? null, + $aDoiInfo['journal-issue']['published-print']['date-parts'][0][0] ?? null, + $aDoiInfo['issued']['date-parts'][0][0] ?? null, + $aDoiInfo['published']['date-parts'][0][0] ?? null, + $aDoiInfo['published-online']['date-parts'][0][0] ?? null, + ]; + + foreach ($candidates as $year) { + if (!empty($year)) { + return (string)$year; + } } return ''; } /** - * 提取卷(期):起始页-终止页(格式:2024:10(2):100-120) + * 著录用的年卷期页,格式 Year;Volume(Issue):Pages(如 2024;10(2):100-120) */ public function getVolumeIssuePages($aDoiInfo = []) { - $parts = []; - $year = $this->getPublishYear($aDoiInfo); - if ($year) $parts[] = $year; - $volume = $aDoiInfo['volume'] ?? ''; - $issue = $aDoiInfo['issue'] ?? ''; - if ($volume) { - $parts[] = $volume . ($issue ? "({$issue})" : ''); + $volume = trim((string)($aDoiInfo['volume'] ?? '')); + $issue = trim((string)($aDoiInfo['issue'] ?? ($aDoiInfo['journal-issue']['issue'] ?? ''))); + if ($volume !== '') { + $volume .= $issue !== '' ? "({$issue})" : ''; } - $pageStart = $aDoiInfo['page']['start'] ?? ($aDoiInfo['first-page'] ?? ''); - $pageEnd = $aDoiInfo['page']['end'] ?? ($aDoiInfo['last-page'] ?? ''); - $pages = ''; - if ($pageStart) { - $pages = $pageStart . ($pageEnd ? "-{$pageEnd}" : ''); - } else { - $pages = $aDoiInfo['page'] ?? ''; + // page 是 "100-120" 这样的字符串;BMC/PLOS 等电子刊无连续页码,改用文章号 + $pages = trim((string)($aDoiInfo['page'] ?? '')); + if ($pages === '') { + $pages = trim((string)($aDoiInfo['article-number'] ?? '')); } - if ($pages) $parts[] = $pages; - return implode(':', $parts); + $tail = $volume; + if ($pages !== '') { + $tail = $tail !== '' ? $tail . ':' . $pages : $pages; + } + + if ($year === '' || $tail === '') { + return $year !== '' ? $year : $tail; + } + return $year . ';' . $tail; } /** diff --git a/application/common/ProductionArticleRefer.php b/application/common/ProductionArticleRefer.php index cb3684c9..7f5990c3 100644 --- a/application/common/ProductionArticleRefer.php +++ b/application/common/ProductionArticleRefer.php @@ -1,8 +1,6 @@ normalizeDoi($aRefer['refer_doi']); - $svc = new CrossrefService([ - 'mailto' => trim((string)Env::get('crossref_mailto', '')), - ]); - $summary = $svc->fetchWorkSummary($doiNorm); - if ($summary !== null && !empty($summary['doi'])) { - $title = trim((string)($summary['title'] ?? '')); - $jouraRaw = trim((string)($summary['joura'] ?? '')); - // 姓全写 + 名首字母,超过 3 个作者取前 3 个 + et al - $authorCitation = $svc->getAuthorsCitation($summary['raw'] ?? [], 3); + // 元数据仍是中文时放弃本路径,改走下方 citation.doi.org(lang=en-US) 取英文著录 + $meta = $oMeta->fetchByDoi($doiNorm); + if ($meta !== null && !$meta['has_cjk'] && trim((string)$meta['title']) !== '') { + $title = trim((string)$meta['title']); + $authorCitation = trim((string)$meta['author']); - // 英文优先兜底:若 CrossRef 结果的标题/期刊/作者仍含中日韩字符, - // 说明该 DOI 元数据是中文,放弃 CrossRef 路径,改走下方 citation.doi.org(lang=en-US) - $hasCjk = $svc->hasCjk($title) || $svc->hasCjk($jouraRaw) || $svc->hasCjk($authorCitation); - if (!$hasCjk) { - $update_a = []; - $dateno = trim((string)($summary['dateno'] ?? '')); - $doilink = trim((string)($summary['doilink'] ?? '')); - $update_a['title'] = $title; - $update_a['author'] = $authorCitation !== '' ? $authorCitation . '.' : ''; - $update_a['joura'] = $jouraRaw; - $update_a['dateno'] = $dateno; - // CrossRef 的 type 最权威,据此确定参考文献类型,未命中回退 journal - $crossrefType = isset($summary['raw']['type']) ? $summary['raw']['type'] : ''; - $mappedType = (new ReferenceTypeClassifier())->mapCrossrefType($crossrefType); - $update_a['refer_type'] = $mappedType !== '' ? $mappedType : "journal"; - $update_a['is_ja'] = 1; - $update_a['doilink'] = $doilink; - $update_a['cs'] = 1; - $update_a['update_time'] = time(); - $update_a['is_deal'] = 1; + $update_a = []; + $update_a['title'] = $title; + $update_a['author'] = $authorCitation !== '' ? $authorCitation . '.' : ''; + $update_a['joura'] = trim((string)$meta['joura']); + $update_a['dateno'] = trim((string)$meta['dateno']); + $update_a['refer_type'] = $meta['type'] !== '' ? $meta['type'] : "journal"; + $update_a['is_ja'] = 1; + $update_a['doilink'] = trim((string)$meta['doilink']); + $update_a['cs'] = 1; + $update_a['update_time'] = time(); + $update_a['is_deal'] = 1; - try { - (new ReferenceReferAuthorService())->syncFromWorkSummary( + try { + $oReferAuthor = new ReferenceReferAuthorService(); + if (is_array($meta['crossref_summary'])) { + // Crossref 带 ORCID,作者明细优先用它 + $oReferAuthor->syncFromWorkSummary( $iPReferId, $iPArticleId, $doiNorm, - $summary + $meta['crossref_summary'] ); - } catch (\Throwable $e) { - \think\Log::error( - 'ProductionArticleRefer sync refer authors failed p_refer_id=' - . $iPReferId . ' ' . $e->getMessage() + } else { + $oReferAuthor->syncFromReferAuthorField( + $iPReferId, + $iPArticleId, + $authorCitation ); } - Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a); - return json_encode(['status' => 1,'msg' => 'Update successful']); + } catch (\Throwable $e) { + \think\Log::error( + 'ProductionArticleRefer sync refer authors failed p_refer_id=' + . $iPReferId . ' ' . $e->getMessage() + ); } + Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a); + return json_encode(['status' => 1,'msg' => 'Update successful']); } - //结束---用crossref接口的方式处理数据 + //结束---用 PubMed+Crossref 的方式处理数据 diff --git a/application/common/PubmedService.php b/application/common/PubmedService.php index 49aec572..df2c11d4 100644 --- a/application/common/PubmedService.php +++ b/application/common/PubmedService.php @@ -18,13 +18,43 @@ class PubmedService private $timeout = 20; private $tool = 'tmrjournals'; private $email = ''; + // NCBI 限流:无 key 时 3 次/秒,配 key 后 10 次/秒。多 worker 并行处理参考文献时必须配。 + private $apiKey = ''; + private $maxRetry = 3; public function __construct(array $config = []) { + $this->email = (string)$this->envGet('pubmed_email', ''); + $this->apiKey = (string)$this->envGet('pubmed_api_key', ''); + $tool = trim((string)$this->envGet('pubmed_tool', '')); + if ($tool !== '') $this->tool = $tool; + if (isset($config['base'])) $this->base = rtrim((string)$config['base'], '/') . '/'; if (isset($config['timeout'])) $this->timeout = max(5, intval($config['timeout'])); - if (isset($config['tool'])) $this->tool = (string)$config['tool']; - if (isset($config['email'])) $this->email = (string)$config['email']; + if (!empty($config['tool'])) $this->tool = (string)$config['tool']; + if (!empty($config['email'])) $this->email = (string)$config['email']; + if (!empty($config['api_key'])) $this->apiKey = (string)$config['api_key']; + if (isset($config['max_retry'])) $this->maxRetry = max(1, intval($config['max_retry'])); + } + + private function envGet($key, $default = '') + { + if (!class_exists('\think\Env')) { + return $default; + } + return \think\Env::get($key, $default); + } + + /** + * tool/email/api_key 是 NCBI 要求的调用方标识,缺失会被更严格限流 + */ + private function commonParams(): array + { + $params = ['tool' => $this->tool, 'email' => $this->email]; + if ($this->apiKey !== '') { + $params['api_key'] = $this->apiKey; + } + return $params; } /** @@ -60,18 +90,16 @@ class PubmedService $pmid = trim($pmid); if ($pmid === '') return null; - // v2:解析结果新增 journal_iso_abbr / journal_medline_ta,换 key 避免命中旧缓存 - $cacheKey = 'pmid_v2_' . $pmid; + // v3:解析结果新增 authors / volume / issue / pages / doi,换 key 避免命中旧缓存 + $cacheKey = 'pmid_v3_' . $pmid; $cached = $this->cacheGet($cacheKey, 30 * 86400); if (is_array($cached)) return $cached; - $url = $this->base . 'efetch.fcgi?' . http_build_query([ + $url = $this->base . 'efetch.fcgi?' . http_build_query(array_merge([ 'db' => 'pubmed', 'id' => $pmid, 'retmode' => 'xml', - 'tool' => $this->tool, - 'email' => $this->email, - ]); + ], $this->commonParams())); $xml = $this->httpGet($url); if (!is_string($xml) || trim($xml) === '') return null; @@ -158,13 +186,11 @@ class PubmedService private function extractDoiFromPmidRecord($pmid) { - $url = $this->base . 'efetch.fcgi?' . http_build_query([ + $url = $this->base . 'efetch.fcgi?' . http_build_query(array_merge([ 'db' => 'pubmed', 'id' => $pmid, 'retmode' => 'xml', - 'tool' => $this->tool, - 'email' => $this->email, - ]); + ], $this->commonParams())); $xml = $this->httpGet($url); if ($xml === '') { return ''; @@ -179,14 +205,12 @@ class PubmedService private function esearch(string $term): ?string { - $url = $this->base . 'esearch.fcgi?' . http_build_query([ + $url = $this->base . 'esearch.fcgi?' . http_build_query(array_merge([ 'db' => 'pubmed', 'retmode' => 'json', 'retmax' => 1, 'term' => $term, - 'tool' => $this->tool, - 'email' => $this->email, - ]); + ], $this->commonParams())); $res = $this->httpGet($url); $json = json_decode((string)$res, true); @@ -258,6 +282,11 @@ class PubmedService return null; } + $doi = $this->xpText($xp, '//PubmedArticle//ArticleIdList/ArticleId[@IdType="doi"]'); + if ($doi === '') { + $doi = $this->xpText($xp, '//PubmedArticle//ELocationID[@EIdType="doi"]'); + } + return [ 'title' => $title, 'abstract' => $abstract, @@ -267,9 +296,133 @@ class PubmedService 'journal_iso_abbr' => $journalIsoAbbr, 'journal_medline_ta' => $journalMedlineTa, 'year' => $year, + 'authors' => $this->parseAuthors($xp), + 'volume' => $this->xpText($xp, '//PubmedArticle//JournalIssue//Volume'), + 'issue' => $this->xpText($xp, '//PubmedArticle//JournalIssue//Issue'), + 'pages' => $this->parsePagination($xp), + 'doi' => $doi, ]; } + /** + * 作者列表:LastName/ForeName/Initials,机构作者用 CollectiveName + */ + private function parseAuthors(\DOMXPath $xp): array + { + $out = []; + $nodes = $xp->query('//PubmedArticle//AuthorList/Author'); + if (!$nodes) { + return $out; + } + + foreach ($nodes as $n) { + $family = $given = $initials = $collective = ''; + foreach ($n->childNodes as $c) { + switch ($c->nodeName) { + case 'LastName': + $family = trim($c->textContent); + break; + case 'ForeName': + $given = trim($c->textContent); + break; + case 'Initials': + $initials = trim($c->textContent); + break; + case 'CollectiveName': + $collective = trim($c->textContent); + break; + } + } + if ($family === '' && $given === '' && $collective === '') { + continue; + } + $out[] = [ + 'family' => $family, + 'given' => $given, + 'initials' => $initials, + 'collective' => $collective, + ]; + } + + return $out; + } + + /** + * 页码:StartPage/EndPage → MedlinePgn → 电子刊文章号(ELocationID pii) + * + * MedlinePgn 用 NLM 缩写式尾页(210-8 表示 210–218),展开逻辑由调用方处理。 + */ + private function parsePagination(\DOMXPath $xp): string + { + $start = $this->xpText($xp, '//PubmedArticle//Pagination//StartPage'); + if ($start !== '') { + $end = $this->xpText($xp, '//PubmedArticle//Pagination//EndPage'); + return $end !== '' ? $start . '-' . $end : $start; + } + + $medlinePgn = $this->xpText($xp, '//PubmedArticle//Pagination//MedlinePgn'); + if ($medlinePgn !== '') { + return $medlinePgn; + } + + // 无连续页码的电子刊(PLOS 等)以文章号著录;pii 也可能是出版社流水号,故只认 e12345/12345 + $pii = $this->xpText($xp, '//PubmedArticle//ELocationID[@EIdType="pii"]'); + return preg_match('/^e?\d+$/i', $pii) === 1 ? $pii : ''; + } + + /** + * 引用格式作者串:姓全写 + 名首字母,超过 $maxAuthors 个取前 N 个 + et al + * 例:Smith JA, Jones B, Lee C, et al + */ + public function authorsCitation(array $authors, int $maxAuthors = 3): string + { + $list = []; + foreach ($authors as $a) { + $collective = trim((string)($a['collective'] ?? '')); + if ($collective !== '') { + $list[] = $collective; + continue; + } + + $family = trim((string)($a['family'] ?? '')); + if ($family === '') { + continue; + } + $initials = trim((string)($a['initials'] ?? '')); + if ($initials === '') { + $initials = $this->givenToInitials((string)($a['given'] ?? '')); + } + $list[] = $initials !== '' ? $family . ' ' . $initials : $family; + } + + if (empty($list)) { + return ''; + } + + $maxAuthors = max(1, $maxAuthors); + if (count($list) > $maxAuthors) { + return implode(', ', array_slice($list, 0, $maxAuthors)) . ', et al'; + } + return implode(', ', $list); + } + + private function givenToInitials($given): string + { + $given = trim((string)$given); + if ($given === '') { + return ''; + } + $parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY); + $initials = ''; + foreach ($parts as $p) { + $first = mb_substr($p, 0, 1); + if ($first !== '') { + $initials .= mb_strtoupper($first); + } + } + return $initials; + } + private function xpText(\DOMXPath $xp, string $query): string { $n = $xp->query($query); @@ -279,19 +432,36 @@ class PubmedService return ''; } + /** + * 限流(429)与服务端错误退避重试;耗尽重试返回空串,由调用方回落到 Crossref + */ private function httpGet(string $url): string { - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'User-Agent: TMRjournals-PubMed/1.0' - ]); - $res = curl_exec($ch); - curl_close($ch); - return is_string($res) ? $res : ''; + for ($attempt = 0; $attempt < $this->maxRetry; $attempt++) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'User-Agent: TMRjournals-PubMed/1.0' + ]); + $res = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200 && is_string($res) && trim($res) !== '') { + return $res; + } + + if ($httpCode !== 429 && $httpCode < 500 && $httpCode !== 0) { + return ''; + } + + usleep((int)(($attempt + 1) * 400000)); + } + + return ''; } private function cacheDir(): string diff --git a/application/common/ReferenceDispatchService.php b/application/common/ReferenceDispatchService.php index e2ef01d8..cc43c6c7 100644 --- a/application/common/ReferenceDispatchService.php +++ b/application/common/ReferenceDispatchService.php @@ -3,7 +3,6 @@ namespace app\common; use think\Db; -use think\Env; use think\Queue; /** @@ -19,9 +18,13 @@ class ReferenceDispatchService /** @var ReferenceTypeClassifier */ private $classifier; + /** @var ReferenceMetadataService */ + private $metadata; + public function __construct() { $this->classifier = new ReferenceTypeClassifier(['use_llm' => true]); + $this->metadata = new ReferenceMetadataService(); } /** @@ -72,23 +75,23 @@ class ReferenceDispatchService return; } - $crossref = new CrossrefService([ - 'mailto' => trim((string)Env::get('crossref_mailto', '')), - ]); - - $summary = null; - $crossrefType = ''; + $meta = null; + $typeHint = ''; if (trim((string)$refer['refer_doi']) !== '') { $doiNorm = $this->normalizeDoi($refer['refer_doi']); if ($doiNorm !== '') { - $summary = $crossref->fetchWorkSummary($doiNorm); - if ($summary && !empty($summary['raw']['type'])) { - $crossrefType = (string)$summary['raw']['type']; + $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'], $crossrefType); + $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([ @@ -99,7 +102,7 @@ class ReferenceDispatchService switch ($dispatchType) { case ReferenceTypeClassifier::TYPE_BOOK: - $this->processBookRefer($refer, $summary, $crossref); + $this->processBookRefer($refer, $meta); break; case ReferenceTypeClassifier::TYPE_OTHER: $this->processOtherRefer($refer); @@ -161,7 +164,7 @@ class ReferenceDispatchService /** * book:结构化字段,DOI 仅用于补数据 */ - private function processBookRefer(array $refer, $summary, CrossrefService $crossref) + private function processBookRefer(array $refer, $meta) { $pReferId = intval($refer['p_refer_id']); $content = (string)$refer['refer_content']; @@ -171,14 +174,18 @@ class ReferenceDispatchService 'update_time' => time(), ]; - if (is_array($summary) && !empty($summary['raw'])) { - $raw = $summary['raw']; - $authorCitation = $crossref->getAuthorsCitation($raw, 3); + $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']) !== ''); + + if ($hasMeta) { + $authorCitation = trim((string)$meta['author']); $update['author'] = $authorCitation !== '' ? rtrim($authorCitation, '.') . '.' : ''; - $update['title'] = trim((string)($summary['title'] ?? '')); - $update['joura'] = $this->extractBookPublisher($raw, $summary); - $update['dateno'] = $this->extractBookDateno($raw); - $isbn = $this->extractIsbnFromRaw($raw); + $update['title'] = trim((string)$meta['title']); + $update['joura'] = !empty($raw) ? $this->extractBookPublisher($raw, $summary) : ''; + $update['dateno'] = !empty($raw) ? $this->extractBookDateno($raw) : ''; + $isbn = !empty($raw) ? $this->extractIsbnFromRaw($raw) : ''; if ($isbn === '' && !empty($refer['refer_doi'])) { $doi = $this->normalizeDoi($refer['refer_doi']); $isbn = $doi !== '' ? 'https://doi.org/' . $doi : ''; diff --git a/application/common/ReferenceMetadataService.php b/application/common/ReferenceMetadataService.php new file mode 100644 index 00000000..e86bb91d --- /dev/null +++ b/application/common/ReferenceMetadataService.php @@ -0,0 +1,263 @@ +pubmed = new PubmedService(); + $this->crossref = new CrossrefService([ + 'mailto' => trim((string)Env::get('crossref_mailto', '')), + ]); + $this->classifier = new ReferenceTypeClassifier(['use_llm' => false]); + } + + /** + * 按 DOI 聚合元数据 + * + * @return array|null 两个源都查不到时返回 null + */ + public function fetchByDoi($doi) + { + $doi = $this->normalizeDoi($doi); + if ($doi === '') { + return null; + } + + $pub = null; + try { + $pub = $this->pubmed->fetchByDoi($doi); + } catch (\Throwable $e) { + $pub = null; + } + if (!is_array($pub)) { + $pub = null; + } + + $cr = $this->crossref->fetchWorkSummary($doi); + if (!is_array($cr)) { + $cr = null; + } + + if ($pub === null && $cr === null) { + return null; + } + + $sources = []; + if ($pub !== null) $sources[] = 'pubmed'; + if ($cr !== null) $sources[] = 'crossref'; + + $title = $this->pickTitle($pub, $cr); + $joura = $this->pickJournal($pub, $cr); + $author = $this->pickAuthor($pub, $cr); + $dateno = $this->pickDateno($pub, $cr); + + $doilink = trim((string)($cr['doilink'] ?? '')); + if ($doilink === '') { + $doilink = 'https://doi.org/' . $doi; + } + + $crossrefType = trim((string)($cr['raw']['type'] ?? '')); + $type = $this->pickType($pub, $crossrefType); + + $retract = $this->pickRetraction($pub, $cr); + + return [ + 'doi' => $doi, + 'pmid' => trim((string)($pub['pmid'] ?? '')), + 'title' => $title, + 'author' => $author, + 'joura' => $joura, + 'dateno' => $dateno, + 'doilink' => $doilink, + 'type' => $type, + 'crossref_type' => $crossrefType, + 'is_retracted' => $retract['is_retracted'], + 'retract_reason' => $retract['reason'], + 'has_cjk' => $this->crossref->hasCjk($title) + || $this->crossref->hasCjk($joura) + || $this->crossref->hasCjk($author), + 'sources' => $sources, + 'crossref_summary' => $cr, + 'pubmed' => $pub, + ]; + } + + /** + * 标题:PubMed 优先。非英文原文在 PubMed 里是括号包裹的英译标题,去掉括号更贴合排版。 + */ + private function pickTitle($pub, $cr) + { + $title = trim((string)($pub['title'] ?? '')); + if ($title !== '') { + $title = rtrim($title, '.'); + if (preg_match('/^\[(.+)\]$/s', $title, $m)) { + $title = trim($m[1]); + } + return $title; + } + return trim((string)($cr['title'] ?? '')); + } + + /** + * 期刊名:PubMed 的规范缩写优先,其次 Crossref + */ + private function pickJournal($pub, $cr) + { + $abbr = trim((string)($pub['journal_iso_abbr'] ?? '')); + if ($abbr === '') { + $abbr = trim((string)($pub['journal_medline_ta'] ?? '')); + } + if ($abbr !== '') { + return $abbr; + } + return trim((string)($cr['joura'] ?? '')); + } + + private function pickAuthor($pub, $cr) + { + $authors = isset($pub['authors']) && is_array($pub['authors']) ? $pub['authors'] : []; + if (!empty($authors)) { + $citation = $this->pubmed->authorsCitation($authors, 3); + if ($citation !== '') { + return $citation; + } + } + return $this->crossref->getAuthorsCitation($cr['raw'] ?? [], 3); + } + + /** + * 卷期页:PubMed 有卷或页时以 PubMed 为准,否则用 Crossref + * + * 格式与 citation.doi.org 路径一致:Year;Volume(Issue):Pages + */ + private function pickDateno($pub, $cr) + { + $volume = trim((string)($pub['volume'] ?? '')); + $pages = $this->expandPageRange((string)($pub['pages'] ?? '')); + if ($volume === '' && $pages === '') { + return trim((string)($cr['dateno'] ?? '')); + } + + if ($volume !== '') { + $issue = trim((string)($pub['issue'] ?? '')); + $volume .= $issue !== '' ? "({$issue})" : ''; + } + + $tail = $volume; + if ($pages !== '') { + $tail = $tail !== '' ? $tail . ':' . $pages : $pages; + } + + $year = trim((string)($pub['year'] ?? '')); + if ($year === '' || $tail === '') { + return $year !== '' ? $year : $tail; + } + return $year . ';' . $tail; + } + + /** + * 展开 NLM 缩写式尾页:210-8 表示 210-218 + */ + private function expandPageRange($pages) + { + $pages = trim((string)$pages); + if ($pages === '' || strpos($pages, '-') === false) { + return $pages; + } + + $parts = explode('-', $pages, 2); + $start = trim($parts[0]); + $end = trim($parts[1]); + if ($start === '' || $end === '') { + return $pages; + } + + if (ctype_digit($start) && ctype_digit($end) && strlen($end) < strlen($start)) { + $end = substr($start, 0, strlen($start) - strlen($end)) . $end; + } + + return $start . '-' . $end; + } + + /** + * 类型:Crossref 的 type 最权威,PubMed 的 publication_types 兜底 + */ + private function pickType($pub, $crossrefType) + { + $mapped = $this->classifier->mapCrossrefType($crossrefType); + if ($mapped !== '') { + return $mapped; + } + + $types = isset($pub['publication_types']) && is_array($pub['publication_types']) + ? $pub['publication_types'] : []; + foreach ($types as $t) { + $t = strtolower(trim((string)$t)); + if ($t === '') { + continue; + } + if (strpos($t, 'congress') !== false) { + return ReferenceTypeClassifier::TYPE_CONFERENCE; + } + if (strpos($t, 'journal article') !== false + || strpos($t, 'review') !== false + || strpos($t, 'clinical trial') !== false + || strpos($t, 'meta-analysis') !== false + || strpos($t, 'case reports') !== false + || strpos($t, 'observational study') !== false + || strpos($t, 'comparative study') !== false + || strpos($t, 'multicenter study') !== false + || strpos($t, 'editorial') !== false + || strpos($t, 'letter') !== false) { + return ReferenceTypeClassifier::TYPE_JOURNAL; + } + } + + return ''; + } + + private function pickRetraction($pub, $cr) + { + $types = isset($pub['publication_types']) && is_array($pub['publication_types']) + ? $pub['publication_types'] : []; + foreach ($types as $t) { + if (stripos((string)$t, 'retract') !== false) { + return ['is_retracted' => 1, 'reason' => 'PubMed 标记:' . trim((string)$t)]; + } + } + + return [ + 'is_retracted' => !empty($cr['is_retracted']) ? 1 : 0, + 'reason' => (string)($cr['retract_reason'] ?? ''), + ]; + } + + public function normalizeDoi($doi) + { + $doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi)); + return trim($doi, " \t\n\r\0\x0B/"); + } +}