Files
tougao/application/common/PubmedService.php
2026-08-07 17:51:18 +08:00

512 lines
17 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;
/**
* 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 / 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(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;
$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([
'db' => 'pubmed',
'id' => $pmid,
'retmode' => 'xml',
'tool' => $this->tool,
'email' => $this->email,
]);
$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([
'db' => 'pubmed',
'retmode' => 'json',
'retmax' => 1,
'term' => $term,
'tool' => $this->tool,
'email' => $this->email,
]);
$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');
// 期刊规范缩写ISOAbbreviationJournal 下)与 MedlineTAMedlineJournalInfo 下)
$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];
}
}
// 文献语种PubMed 用三字母代码eng/chi/ger…一篇可有多个
$languages = [];
$langNodes = $xp->query('//PubmedArticle//Article//Language');
if ($langNodes) {
foreach ($langNodes as $n) {
$t = strtolower(trim($n->textContent));
if ($t !== '') $languages[] = $t;
}
}
$languages = array_values(array_unique($languages));
// 期刊出版国MedlineJournalInfo/Country非研究开展国仅作兜底
$journalCountry = $this->xpText($xp, '//PubmedArticle//MedlineJournalInfo//Country');
// 作者单位原文,用于推断研究开展国
$affiliations = [];
$affNodes = $xp->query('//PubmedArticle//AuthorList//Author//AffiliationInfo//Affiliation');
if ($affNodes) {
foreach ($affNodes as $n) {
$t = trim($n->textContent);
if ($t !== '') $affiliations[] = $t;
}
}
$affiliations = array_values(array_unique($affiliations));
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,
'language' => isset($languages[0]) ? $languages[0] : '',
'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 表示 210218展开逻辑由调用方处理。
*/
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 时全部列出,超过时只列前 $keep 个再加 et al
* 例7 个作者 → Smith JA, Jones B, Lee C, et al
*/
public function authorsCitation(array $authors, int $maxAuthors = 6, int $keep = 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);
$keep = min(max(1, $keep), $maxAuthors);
if (count($list) > $maxAuthors) {
return implode(', ', array_slice($list, 0, $keep)) . ', 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 '';
}
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 : '';
}
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));
}
}