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>/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)); } }