264 lines
8.2 KiB
PHP
264 lines
8.2 KiB
PHP
<?php
|
||
|
||
namespace app\common;
|
||
|
||
use think\Env;
|
||
|
||
/**
|
||
* 参考文献元数据聚合:PubMed 优先,Crossref 补全。
|
||
*
|
||
* 医学期刊的著录信息以 PubMed(NLM 人工校订)更贴合期次著录:
|
||
* - 年份: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);
|
||
if ($citation !== '') {
|
||
return $citation;
|
||
}
|
||
}
|
||
return $this->crossref->getAuthorsCitation($cr['raw'] ?? []);
|
||
}
|
||
|
||
/**
|
||
* 卷期页: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/");
|
||
}
|
||
}
|