330 lines
11 KiB
PHP
330 lines
11 KiB
PHP
<?php
|
||
|
||
namespace app\common;
|
||
|
||
use think\Env;
|
||
|
||
/**
|
||
* 中华医学会期刊 DOI(cma.j.cn)文献抓取:优先中文数据源。
|
||
*
|
||
* 1. 可选:Yiigle 机构资源 API(需配置 yiigle_api_base + yiigle_api_key)
|
||
* 2. OpenAlex:常含中文摘要(abstract_inverted_index),中华期刊在 PubMed 无摘要时仍可用
|
||
*/
|
||
class CmaJournalLiteratureService
|
||
{
|
||
private $timeout = 20;
|
||
private $mailto = '';
|
||
private $yiigleApiBase = '';
|
||
private $yiigleApiKey = '';
|
||
private $skipYiigle = false;
|
||
|
||
public function __construct(array $config = [])
|
||
{
|
||
if (isset($config['mailto'])) {
|
||
$this->mailto = trim((string)$config['mailto']);
|
||
} else {
|
||
$this->mailto = trim((string)Env::get('crossref_mailto', Env::get('pubmed_email', '')));
|
||
}
|
||
$this->yiigleApiBase = rtrim(trim((string)($config['yiigle_api_base'] ?? Env::get('yiigle_api_base', ''))), '/');
|
||
$this->yiigleApiKey = trim((string)($config['yiigle_api_key'] ?? Env::get('yiigle_api_key', '')));
|
||
$this->skipYiigle = !empty($config['skip_yiigle']);
|
||
}
|
||
|
||
public static function isCmaJournalDoi($doi)
|
||
{
|
||
$doi = strtolower(trim((string)$doi));
|
||
|
||
return $doi !== '' && strpos($doi, 'cma.j.cn') !== false;
|
||
}
|
||
|
||
/**
|
||
* @return array{
|
||
* title:string,journal:string,year:string,abstract:string,content:string,
|
||
* pmid:string,blocks:array,sources:array,fetch_log:string
|
||
* }|null
|
||
*/
|
||
public function fetchByDoi($doi)
|
||
{
|
||
$doi = $this->normalizeDoi($doi);
|
||
if ($doi === '' || !self::isCmaJournalDoi($doi)) {
|
||
return null;
|
||
}
|
||
|
||
$cacheKey = 'cma_lit_' . sha1(strtolower($doi));
|
||
$cached = $this->cacheGet($cacheKey, 7 * 86400);
|
||
if (is_array($cached)) {
|
||
return $cached;
|
||
}
|
||
|
||
$blocks = [];
|
||
$sources = [];
|
||
$abstract = '';
|
||
$content = '';
|
||
$title = '';
|
||
$journal = '';
|
||
$year = '';
|
||
$pmid = '';
|
||
$logs = [];
|
||
|
||
if (!$this->skipYiigle && $this->yiigleApiBase !== '' && $this->yiigleApiKey !== '') {
|
||
$yiigle = $this->fetchFromYiigleApi($doi);
|
||
if (is_array($yiigle)) {
|
||
$sources[] = 'cma_yiigle';
|
||
$title = $this->pickNonEmpty($title, $yiigle['title'] ?? '');
|
||
$journal = $this->pickNonEmpty($journal, $yiigle['journal'] ?? '');
|
||
$year = $this->pickNonEmpty($year, $yiigle['year'] ?? '');
|
||
if (trim((string)($yiigle['abstract'] ?? '')) !== '') {
|
||
$abstract = trim((string)$yiigle['abstract']);
|
||
}
|
||
if (trim((string)($yiigle['content'] ?? '')) !== '') {
|
||
$content = trim((string)$yiigle['content']);
|
||
}
|
||
if (!empty($yiigle['block'])) {
|
||
$blocks[] = (string)$yiigle['block'];
|
||
}
|
||
$logs[] = 'yiigle=' . ($yiigle['status'] ?? 'ok');
|
||
}
|
||
}
|
||
|
||
$openAlex = $this->fetchFromOpenAlex($doi);
|
||
if (is_array($openAlex)) {
|
||
$sources[] = 'cma_openalex';
|
||
$title = $this->pickNonEmpty($title, $openAlex['title'] ?? '');
|
||
$journal = $this->pickNonEmpty($journal, $openAlex['journal'] ?? '');
|
||
$year = $this->pickNonEmpty($year, $openAlex['year'] ?? '');
|
||
$pmid = $this->pickNonEmpty($pmid, $openAlex['pmid'] ?? '');
|
||
if ($abstract === '' && trim((string)($openAlex['abstract'] ?? '')) !== '') {
|
||
$abstract = trim((string)$openAlex['abstract']);
|
||
}
|
||
if (!empty($openAlex['block'])) {
|
||
$blocks[] = (string)$openAlex['block'];
|
||
}
|
||
$logs[] = 'openalex=' . ($abstract !== '' ? 'abstract' : 'meta');
|
||
}
|
||
|
||
if ($abstract === '' && $content === '' && empty($blocks)) {
|
||
return null;
|
||
}
|
||
|
||
$result = [
|
||
'title' => $title,
|
||
'journal' => $journal,
|
||
'year' => $year,
|
||
'abstract' => $abstract,
|
||
'content' => $content,
|
||
'pmid' => $pmid,
|
||
'blocks' => array_values(array_filter($blocks)),
|
||
'sources' => array_values(array_unique($sources)),
|
||
'fetch_log' => 'cma_doi=' . $doi . '; ' . implode('; ', $logs),
|
||
];
|
||
$this->cacheSet($cacheKey, $result);
|
||
|
||
return $result;
|
||
}
|
||
|
||
private function fetchFromOpenAlex($doi)
|
||
{
|
||
$url = 'https://api.openalex.org/works/https://doi.org/' . rawurlencode($doi);
|
||
if ($this->mailto !== '') {
|
||
$url .= '?mailto=' . rawurlencode($this->mailto);
|
||
}
|
||
|
||
$raw = $this->httpGet($url);
|
||
$json = json_decode((string)$raw, true);
|
||
if (!is_array($json)) {
|
||
return null;
|
||
}
|
||
|
||
$title = trim((string)($json['title'] ?? $json['display_name'] ?? ''));
|
||
$journal = trim((string)($json['primary_location']['raw_source_name'] ?? ''));
|
||
if ($journal === '') {
|
||
$journal = trim((string)($json['primary_location']['source']['display_name'] ?? ''));
|
||
}
|
||
if (strcasecmp($journal, 'PubMed') === 0) {
|
||
$journal = '';
|
||
}
|
||
$year = trim((string)($json['publication_year'] ?? ''));
|
||
$abstract = $this->reconstructOpenAlexAbstract($json['abstract_inverted_index'] ?? null);
|
||
if ($abstract === '') {
|
||
$abstract = trim((string)($json['abstract'] ?? ''));
|
||
}
|
||
|
||
$pmid = '';
|
||
$pmidRaw = (string)($json['ids']['pmid'] ?? '');
|
||
if (preg_match('/(\d+)/', $pmidRaw, $m)) {
|
||
$pmid = $m[1];
|
||
}
|
||
|
||
$lines = ['=== 中华医学期刊 / OpenAlex (DOI ' . $doi . ') ==='];
|
||
if ($title !== '') {
|
||
$lines[] = 'Title: ' . $title;
|
||
}
|
||
if ($journal !== '') {
|
||
$lines[] = 'Journal: ' . $journal;
|
||
}
|
||
if ($year !== '') {
|
||
$lines[] = 'Year: ' . $year;
|
||
}
|
||
if ($abstract !== '') {
|
||
$lines[] = 'Abstract: ' . $abstract;
|
||
}
|
||
|
||
return [
|
||
'title' => $title,
|
||
'journal' => $journal,
|
||
'year' => $year,
|
||
'abstract' => $abstract,
|
||
'pmid' => $pmid,
|
||
'block' => implode("\n", $lines),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 可选:机构购买的 Yiigle 资源 API(未配置则跳过)。
|
||
*/
|
||
private function fetchFromYiigleApi($doi)
|
||
{
|
||
$url = $this->yiigleApiBase . '/resource/queryByDoi?doi=' . rawurlencode($doi);
|
||
$raw = $this->httpGet($url, [
|
||
'Authorization: Bearer ' . $this->yiigleApiKey,
|
||
'Accept: application/json',
|
||
]);
|
||
$json = json_decode((string)$raw, true);
|
||
if (!is_array($json)) {
|
||
return ['status' => 'empty'];
|
||
}
|
||
|
||
$data = $json['data'] ?? $json;
|
||
if (!is_array($data)) {
|
||
return ['status' => 'invalid'];
|
||
}
|
||
|
||
$abstract = trim((string)($data['abstract'] ?? $data['summary'] ?? ''));
|
||
$content = trim((string)($data['content'] ?? $data['fullText'] ?? ''));
|
||
$title = trim((string)($data['title'] ?? ''));
|
||
$journal = trim((string)($data['journal'] ?? $data['journalName'] ?? ''));
|
||
$year = trim((string)($data['year'] ?? $data['pubYear'] ?? ''));
|
||
|
||
$block = '';
|
||
if ($abstract !== '' || $content !== '') {
|
||
$lines = ['=== 中华医学期刊网 Yiigle (DOI ' . $doi . ') ==='];
|
||
if ($title !== '') {
|
||
$lines[] = 'Title: ' . $title;
|
||
}
|
||
if ($abstract !== '') {
|
||
$lines[] = 'Abstract: ' . $abstract;
|
||
}
|
||
if ($content !== '') {
|
||
$lines[] = 'Content: ' . mb_substr($content, 0, 8000);
|
||
}
|
||
$block = implode("\n", $lines);
|
||
}
|
||
|
||
return [
|
||
'status' => ($abstract !== '' || $content !== '') ? 'ok' : 'no_text',
|
||
'title' => $title,
|
||
'journal' => $journal,
|
||
'year' => $year,
|
||
'abstract' => $abstract,
|
||
'content' => $content,
|
||
'block' => $block,
|
||
];
|
||
}
|
||
|
||
private function reconstructOpenAlexAbstract($invertedIndex)
|
||
{
|
||
if (!is_array($invertedIndex) || empty($invertedIndex)) {
|
||
return '';
|
||
}
|
||
|
||
$tokens = [];
|
||
foreach ($invertedIndex as $token => $positions) {
|
||
if (!is_array($positions)) {
|
||
continue;
|
||
}
|
||
foreach ($positions as $pos) {
|
||
$tokens[intval($pos)] = (string)$token;
|
||
}
|
||
}
|
||
if (empty($tokens)) {
|
||
return '';
|
||
}
|
||
ksort($tokens, SORT_NUMERIC);
|
||
|
||
return trim(implode(' ', $tokens));
|
||
}
|
||
|
||
private function normalizeDoi($doi)
|
||
{
|
||
$doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi));
|
||
return trim($doi, " \t\n\r\0\x0B/");
|
||
}
|
||
|
||
private function pickNonEmpty($current, $candidate)
|
||
{
|
||
$current = trim((string)$current);
|
||
$candidate = trim((string)$candidate);
|
||
if ($current !== '') {
|
||
return $current;
|
||
}
|
||
|
||
return $candidate;
|
||
}
|
||
|
||
private function httpGet($url, array $headers = [])
|
||
{
|
||
$headers = array_merge([
|
||
'User-Agent: TMRjournals-CmaJournal/1.0',
|
||
'Accept: application/json, text/plain, */*',
|
||
], $headers);
|
||
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_FOLLOWLOCATION => true,
|
||
CURLOPT_CONNECTTIMEOUT => 10,
|
||
CURLOPT_TIMEOUT => $this->timeout,
|
||
CURLOPT_SSL_VERIFYPEER => false,
|
||
CURLOPT_HTTPHEADER => $headers,
|
||
]);
|
||
$body = curl_exec($ch);
|
||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($body === false || $code < 200 || $code >= 300) {
|
||
return '';
|
||
}
|
||
|
||
return is_string($body) ? $body : '';
|
||
}
|
||
|
||
private function cacheDir()
|
||
{
|
||
return rtrim(ROOT_PATH, '/') . '/runtime/cma_journal_cache';
|
||
}
|
||
|
||
private function cacheGet($key, $ttlSeconds)
|
||
{
|
||
$file = $this->cacheDir() . '/' . $key . '.json';
|
||
if (!is_file($file)) {
|
||
return null;
|
||
}
|
||
$mtime = filemtime($file);
|
||
if (!$mtime || (time() - $mtime) > $ttlSeconds) {
|
||
return null;
|
||
}
|
||
$decoded = json_decode((string)@file_get_contents($file), true);
|
||
|
||
return is_array($decoded) ? $decoded : null;
|
||
}
|
||
|
||
private function cacheSet($key, array $value)
|
||
{
|
||
$dir = $this->cacheDir();
|
||
if (!is_dir($dir)) {
|
||
@mkdir($dir, 0777, true);
|
||
}
|
||
@file_put_contents($dir . '/' . $key . '.json', json_encode($value, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
}
|