492 lines
16 KiB
PHP
492 lines
16 KiB
PHP
<?php
|
||
|
||
namespace app\common;
|
||
|
||
/**
|
||
* PubMed 工具类(E-utilities)
|
||
*
|
||
* 功能:
|
||
* - DOI -> PMID
|
||
* - PMID -> 文章结构化信息(title/abstract/mesh/publication_types/year/journal)
|
||
*
|
||
* 说明:
|
||
* - 默认使用 runtime 文件缓存,避免重复请求 NCBI
|
||
*/
|
||
class PubmedService
|
||
{
|
||
private $base = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/';
|
||
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 (!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;
|
||
}
|
||
|
||
/**
|
||
* DOI -> PMID(优先用 [DOI],命中不到再用 [AID])
|
||
*/
|
||
public function doiToPmid(string $doi): ?string
|
||
{
|
||
$doi = trim($doi);
|
||
if ($doi === '') return null;
|
||
|
||
$cacheKey = 'doi2pmid_' . sha1(strtolower($doi));
|
||
$cached = $this->cacheGet($cacheKey, 30 * 86400);
|
||
if (is_string($cached) && $cached !== '') {
|
||
return $cached;
|
||
}
|
||
|
||
$pmid = $this->esearch($doi . '[DOI]');
|
||
if (!$pmid) {
|
||
$pmid = $this->esearch($doi . '[AID]');
|
||
}
|
||
if ($pmid) {
|
||
$this->cacheSet($cacheKey, $pmid);
|
||
return $pmid;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* PMID -> 文章信息(title/abstract/mesh/publication_types/year/journal)
|
||
*/
|
||
public function fetchByPmid(string $pmid): ?array
|
||
{
|
||
$pmid = trim($pmid);
|
||
if ($pmid === '') return null;
|
||
|
||
// 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(array_merge([
|
||
'db' => 'pubmed',
|
||
'id' => $pmid,
|
||
'retmode' => 'xml',
|
||
], $this->commonParams()));
|
||
|
||
$xml = $this->httpGet($url);
|
||
if (!is_string($xml) || trim($xml) === '') return null;
|
||
|
||
$data = $this->parseEfetchXml($xml);
|
||
if (!$data) return null;
|
||
|
||
$this->cacheSet($cacheKey, $data);
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* DOI -> PubMed 信息(含 abstract/mesh)
|
||
*/
|
||
public function fetchByDoi(string $doi): ?array
|
||
{
|
||
$pmid = $this->doiToPmid($doi);
|
||
if (!$pmid) return null;
|
||
$info = $this->fetchByPmid($pmid);
|
||
if (!$info) return null;
|
||
$info['pmid'] = $pmid;
|
||
$info['doi'] = $doi;
|
||
return $info;
|
||
}
|
||
|
||
/**
|
||
* DOI -> 期刊规范缩写(NLM/ISO 形式,如 "J Clin Oncol")
|
||
* 优先 ISOAbbreviation,回退 MedlineTA;查不到返回 null。
|
||
*/
|
||
public function journalAbbrByDoi(string $doi): ?string
|
||
{
|
||
$info = $this->fetchByDoi($doi);
|
||
if (!is_array($info)) return null;
|
||
|
||
$abbr = trim((string)($info['journal_iso_abbr'] ?? ''));
|
||
if ($abbr === '') {
|
||
$abbr = trim((string)($info['journal_medline_ta'] ?? ''));
|
||
}
|
||
return $abbr !== '' ? $abbr : null;
|
||
}
|
||
|
||
/**
|
||
* 按书目信息检索 PubMed(标题 + 第一作者 + 年份)
|
||
*/
|
||
public function searchByBibliographic($title, $author = '', $year = ''): ?array
|
||
{
|
||
$title = trim((string)$title);
|
||
if ($title === '') {
|
||
return null;
|
||
}
|
||
|
||
$terms = ['(' . $this->quoteTerm($title) . '[Title])'];
|
||
$author = trim((string)$author);
|
||
if ($author !== '') {
|
||
$parts = preg_split('/[,;]/', $author);
|
||
$first = trim((string)($parts[0] ?? ''));
|
||
if ($first !== '') {
|
||
$terms[] = '(' . $this->quoteTerm($first) . '[Author])';
|
||
}
|
||
}
|
||
$year = trim((string)$year);
|
||
if ($year !== '' && preg_match('/^(19|20)\d{2}$/', $year)) {
|
||
$terms[] = '(' . $year . '[pdat])';
|
||
}
|
||
|
||
$pmid = $this->esearch(implode(' AND ', $terms));
|
||
if (!$pmid) {
|
||
return null;
|
||
}
|
||
|
||
$info = $this->fetchByPmid($pmid);
|
||
if (!$info) {
|
||
return null;
|
||
}
|
||
$info['pmid'] = $pmid;
|
||
$info['doi'] = $this->extractDoiFromPmidRecord($pmid);
|
||
return $info;
|
||
}
|
||
|
||
private function quoteTerm($text)
|
||
{
|
||
return str_replace('"', '', trim((string)$text));
|
||
}
|
||
|
||
private function extractDoiFromPmidRecord($pmid)
|
||
{
|
||
$url = $this->base . 'efetch.fcgi?' . http_build_query(array_merge([
|
||
'db' => 'pubmed',
|
||
'id' => $pmid,
|
||
'retmode' => 'xml',
|
||
], $this->commonParams()));
|
||
$xml = $this->httpGet($url);
|
||
if ($xml === '') {
|
||
return '';
|
||
}
|
||
if (preg_match('/<ArticleId IdType="doi">([^<]+)<\/ArticleId>/i', $xml, $m)) {
|
||
return trim($m[1]);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ----------------- Internals -----------------
|
||
|
||
private function esearch(string $term): ?string
|
||
{
|
||
$url = $this->base . 'esearch.fcgi?' . http_build_query(array_merge([
|
||
'db' => 'pubmed',
|
||
'retmode' => 'json',
|
||
'retmax' => 1,
|
||
'term' => $term,
|
||
], $this->commonParams()));
|
||
|
||
$res = $this->httpGet($url);
|
||
$json = json_decode((string)$res, true);
|
||
$ids = $json['esearchresult']['idlist'] ?? [];
|
||
if (!empty($ids[0])) return (string)$ids[0];
|
||
return null;
|
||
}
|
||
|
||
private function parseEfetchXml(string $xml): ?array
|
||
{
|
||
libxml_use_internal_errors(true);
|
||
$doc = new \DOMDocument();
|
||
if (!$doc->loadXML($xml)) {
|
||
return null;
|
||
}
|
||
$xp = new \DOMXPath($doc);
|
||
|
||
$title = $this->xpText($xp, '//PubmedArticle//ArticleTitle');
|
||
|
||
$abstractParts = [];
|
||
$absNodes = $xp->query('//PubmedArticle//Abstract//AbstractText');
|
||
if ($absNodes) {
|
||
foreach ($absNodes as $n) {
|
||
$label = $n->attributes && $n->attributes->getNamedItem('Label')
|
||
? trim($n->attributes->getNamedItem('Label')->nodeValue)
|
||
: '';
|
||
$txt = trim($n->textContent);
|
||
if ($txt === '') continue;
|
||
$abstractParts[] = $label ? ($label . ': ' . $txt) : $txt;
|
||
}
|
||
}
|
||
$abstract = trim(implode("\n", $abstractParts));
|
||
|
||
$mesh = [];
|
||
$meshNodes = $xp->query('//PubmedArticle//MeshHeadingList//MeshHeading//DescriptorName');
|
||
if ($meshNodes) {
|
||
foreach ($meshNodes as $n) {
|
||
$t = trim($n->textContent);
|
||
if ($t !== '') $mesh[] = $t;
|
||
}
|
||
}
|
||
$mesh = array_values(array_unique($mesh));
|
||
|
||
$pubTypes = [];
|
||
$ptNodes = $xp->query('//PubmedArticle//PublicationTypeList//PublicationType');
|
||
if ($ptNodes) {
|
||
foreach ($ptNodes as $n) {
|
||
$t = trim($n->textContent);
|
||
if ($t !== '') $pubTypes[] = $t;
|
||
}
|
||
}
|
||
$pubTypes = array_values(array_unique($pubTypes));
|
||
|
||
$journal = $this->xpText($xp, '//PubmedArticle//Journal//Title');
|
||
// 期刊规范缩写:ISOAbbreviation(Journal 下)与 MedlineTA(MedlineJournalInfo 下)
|
||
$journalIsoAbbr = $this->xpText($xp, '//PubmedArticle//Journal//ISOAbbreviation');
|
||
$journalMedlineTa = $this->xpText($xp, '//PubmedArticle//MedlineJournalInfo//MedlineTA');
|
||
|
||
$year = '';
|
||
$year = $this->xpText($xp, '//PubmedArticle//JournalIssue//PubDate//Year');
|
||
if ($year === '') {
|
||
$medlineDate = $this->xpText($xp, '//PubmedArticle//JournalIssue//PubDate//MedlineDate');
|
||
if (preg_match('/(19\\d{2}|20\\d{2})/', $medlineDate, $m)) {
|
||
$year = $m[1];
|
||
}
|
||
}
|
||
|
||
if ($title === '' && $abstract === '') {
|
||
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,
|
||
'mesh_terms' => $mesh,
|
||
'publication_types' => $pubTypes,
|
||
'journal' => $journal,
|
||
'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);
|
||
if ($n && $n->length > 0) {
|
||
return trim($n->item(0)->textContent);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 限流(429)与服务端错误退避重试;耗尽重试返回空串,由调用方回落到 Crossref
|
||
*/
|
||
private function httpGet(string $url): string
|
||
{
|
||
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
|
||
{
|
||
return rtrim(ROOT_PATH, '/') . '/runtime/pubmed_cache';
|
||
}
|
||
|
||
private function cacheGet(string $key, int $ttlSeconds)
|
||
{
|
||
$file = $this->cacheDir() . '/' . $key . '.json';
|
||
if (!is_file($file)) return null;
|
||
$mtime = filemtime($file);
|
||
if (!$mtime || (time() - $mtime) > $ttlSeconds) return null;
|
||
$raw = @file_get_contents($file);
|
||
$decoded = json_decode((string)$raw, true);
|
||
return $decoded;
|
||
}
|
||
|
||
private function cacheSet(string $key, $value): void
|
||
{
|
||
$dir = $this->cacheDir();
|
||
if (!is_dir($dir)) @mkdir($dir, 0777, true);
|
||
$file = $dir . '/' . $key . '.json';
|
||
@file_put_contents($file, json_encode($value, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
}
|
||
|