Files
tougao/application/common/ReferenceMetadataService.php
2026-08-05 18:36:17 +08:00

264 lines
8.2 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;
/**
* 参考文献元数据聚合PubMed 优先Crossref 补全。
*
* 医学期刊的著录信息以 PubMedNLM 人工校订)更贴合期次著录:
* - 年份PubMed 取期次年Crossref 的 issued 是线上/线下较早者,在线优先出版会偏早一年
* - 期刊名PubMed 的 ISOAbbreviation / MedlineTA 就是排版要的规范缩写
* - 卷期页PubMed 覆盖完整Crossref 在 ahead-of-print 阶段常缺失
*
* Crossref 负责非 MEDLINE 收录文献的兜底,并提供 ORCID 作者明细与撤稿关系。
*/
class ReferenceMetadataService
{
/** @var PubmedService */
private $pubmed;
/** @var CrossrefService */
private $crossref;
/** @var ReferenceTypeClassifier */
private $classifier;
public function __construct()
{
$this->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/");
}
}