参考文献模型校对换阿里云百炼,完善各种校对细节
This commit is contained in:
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<?php
|
||||
namespace app\common;
|
||||
use think\Db;
|
||||
use think\Env;
|
||||
use app\common\CrossrefService;
|
||||
class ProductionArticleRefer
|
||||
{
|
||||
|
||||
@@ -87,60 +85,56 @@ class ProductionArticleRefer
|
||||
}
|
||||
|
||||
|
||||
//开始用crossref接口的方式处理数据
|
||||
$doiNorm = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', $aRefer['refer_doi']);
|
||||
$doiNorm = trim($doiNorm, " \t\n\r\0\x0B/");
|
||||
//开始用 PubMed(优先)+Crossref(补全) 的方式处理数据
|
||||
$oMeta = new ReferenceMetadataService();
|
||||
$doiNorm = $oMeta->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 的方式处理数据
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,18 @@ class PubmedService
|
||||
$pmid = trim($pmid);
|
||||
if ($pmid === '') return null;
|
||||
|
||||
// v3:解析结果新增 language / journal_country / affiliations,换 key 避免命中旧缓存
|
||||
// v3:解析结果新增 authors / volume / issue / pages / doilanguage / journal_country / affiliations换 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;
|
||||
@@ -283,6 +313,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,
|
||||
@@ -296,9 +331,133 @@ class PubmedService
|
||||
'languages' => $languages,
|
||||
'journal_country' => $journalCountry,
|
||||
'affiliations' => $affiliations,
|
||||
'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);
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
263
application/common/ReferenceMetadataService.php
Normal file
263
application/common/ReferenceMetadataService.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?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, 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/");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user