框架本地无法运行,修复尝试
This commit is contained in:
386
application/api/controller/BackgroundCheck.php
Normal file
386
application/api/controller/BackgroundCheck.php
Normal file
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\Cache;
|
||||
use app\common\BackgroundCheckService;
|
||||
|
||||
/**
|
||||
* 背景调查控制器
|
||||
*
|
||||
* 功能:查询学者公开学术指标 + 撤稿/不端公开记录 + 按领域批量筛查
|
||||
*
|
||||
* 接口:
|
||||
* api/background_check/demo - 快速体验
|
||||
* api/background_check/searchAuthor - 按姓名搜索学者
|
||||
* api/background_check/checkProfile - 完整背景调查报告
|
||||
* api/background_check/checkRetractionByDoi - 单篇 DOI 撤稿详情(CrossRef + RW)
|
||||
* api/background_check/batchScreenByField - 按领域批量筛查专家
|
||||
*/
|
||||
class BackgroundCheck extends Base
|
||||
{
|
||||
/** @var BackgroundCheckService */
|
||||
private $service;
|
||||
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
$this->service = new BackgroundCheckService();
|
||||
}
|
||||
|
||||
// ===================== 公开 API =====================
|
||||
|
||||
/**
|
||||
* Demo:快速体验
|
||||
*
|
||||
* @param string orcid 可选,默认 0000-0002-2582-7480
|
||||
*/
|
||||
public function demo()
|
||||
{
|
||||
$orcid = $this->service->cleanOrcid($this->request->param('orcid', '0000-0002-2582-7480'));
|
||||
|
||||
$report = $this->buildProfileReport([
|
||||
'orcid' => $orcid,
|
||||
'with_crossref_detail'=> 1,
|
||||
]);
|
||||
if (!$report['success']) {
|
||||
return jsonError($report['error']);
|
||||
}
|
||||
|
||||
$report['data']['_demo_note'] = 'Demo 接口,正式使用请调用 checkProfile 或 batchScreenByField';
|
||||
return jsonSuccess($report['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按姓名搜索学者
|
||||
*
|
||||
* @param string name 学者姓名(必填)
|
||||
* @param string affiliation 单位关键词(可选)
|
||||
* @param int limit 返回条数,默认5,最大20
|
||||
*/
|
||||
public function searchAuthor()
|
||||
{
|
||||
$name = trim($this->request->param('name', ''));
|
||||
if ($name === '') {
|
||||
return jsonError('name不能为空');
|
||||
}
|
||||
|
||||
$affiliation = trim($this->request->param('affiliation', ''));
|
||||
$limit = min(max(intval($this->request->param('limit', 5)), 1), 20);
|
||||
|
||||
$cacheKey = 'bg_search_' . md5($name . $affiliation . $limit);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached) {
|
||||
return jsonSuccess($cached);
|
||||
}
|
||||
|
||||
$filter = 'display_name.search:' . $name;
|
||||
if ($affiliation !== '') {
|
||||
$filter .= ',last_known_institutions.display_name.search:' . $affiliation;
|
||||
}
|
||||
|
||||
$res = $this->service->openAlexGet('/authors', [
|
||||
'search' => $name,
|
||||
'filter' => $filter,
|
||||
'sort' => 'cited_by_count:desc',
|
||||
'per-page' => $limit,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return jsonError($res['error']);
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($res['data']['results'] ?? [] as $author) {
|
||||
$list[] = $this->service->formatAuthorBrief($author);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'query' => ['name' => $name, 'affiliation' => $affiliation],
|
||||
'total' => $res['data']['meta']['count'] ?? count($list),
|
||||
'list' => $list,
|
||||
];
|
||||
|
||||
Cache::set($cacheKey, $result, 1800);
|
||||
return jsonSuccess($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整背景调查报告
|
||||
*
|
||||
* @param string openalex_id OpenAlex 作者ID
|
||||
* @param string orcid ORCID
|
||||
* @param string name 姓名
|
||||
* @param string affiliation 单位关键词
|
||||
* @param int user_id 本地用户ID(可选)
|
||||
* @param int with_crossref_detail 是否补充 CrossRef 撤稿详情,默认1
|
||||
*/
|
||||
public function checkProfile()
|
||||
{
|
||||
$params = [
|
||||
'openalex_id' => trim($this->request->param('openalex_id', '')),
|
||||
'orcid' => $this->service->cleanOrcid($this->request->param('orcid', '')),
|
||||
'name' => trim($this->request->param('name', '')),
|
||||
'affiliation' => trim($this->request->param('affiliation', '')),
|
||||
'user_id' => intval($this->request->param('user_id', 0)),
|
||||
'with_crossref_detail' => intval($this->request->param('with_crossref_detail', 1)),
|
||||
];
|
||||
|
||||
if ($params['openalex_id'] === '' && $params['orcid'] === '' && $params['name'] === '') {
|
||||
return jsonError('请至少提供 openalex_id、orcid 或 name 之一');
|
||||
}
|
||||
|
||||
$report = $this->buildProfileReport($params);
|
||||
if (!$report['success']) {
|
||||
return jsonError($report['error']);
|
||||
}
|
||||
|
||||
return jsonSuccess($report['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单篇 DOI 撤稿详情(CrossRef + Retraction Watch 来源标记)
|
||||
*
|
||||
* @param string doi 文章 DOI(必填)
|
||||
*/
|
||||
public function checkRetractionByDoi()
|
||||
{
|
||||
$doi = trim($this->request->param('doi', ''));
|
||||
if ($doi === '') {
|
||||
return jsonError('doi不能为空');
|
||||
}
|
||||
|
||||
$res = $this->service->fetchCrossRefWork($doi);
|
||||
if (!$res['success']) {
|
||||
return jsonError($res['error']);
|
||||
}
|
||||
|
||||
$detail = $this->service->parseCrossRefRetractionDetail($doi, $res['message']);
|
||||
$sources = $detail['retraction_detail']['sources'] ?? [];
|
||||
$fromRw = false;
|
||||
foreach ($sources as $src) {
|
||||
if (stripos($src, 'retraction-watch') !== false || stripos($src, 'retraction_watch') !== false) {
|
||||
$fromRw = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return jsonSuccess([
|
||||
'doi' => $detail['doi'],
|
||||
'title' => $detail['title'],
|
||||
'journal' => $detail['journal'],
|
||||
'publisher' => $detail['publisher'],
|
||||
'authors' => $detail['authors'],
|
||||
'is_retracted' => $detail['is_retracted'],
|
||||
'from_retraction_watch' => $fromRw || !empty($detail['retraction_detail']['record_ids']),
|
||||
'retraction_detail' => $detail['retraction_detail'],
|
||||
'url' => $detail['url'],
|
||||
'data_sources' => ['CrossRef', 'Retraction Watch'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按领域批量筛查专家(含撤稿风险标签)
|
||||
*
|
||||
* @param string keyword 领域关键词(必填),如 immunotherapy、cancer
|
||||
* @param int min_h_index 最低 h-index,默认5
|
||||
* @param int limit 每页数量,默认10,最大30
|
||||
* @param int page 页码,默认1
|
||||
* @param int check_retraction 是否检查撤稿,默认1
|
||||
*/
|
||||
public function batchScreenByField()
|
||||
{
|
||||
$keyword = trim($this->request->param('keyword', ''));
|
||||
if ($keyword === '') {
|
||||
return jsonError('keyword不能为空');
|
||||
}
|
||||
|
||||
$options = [
|
||||
'min_h_index' => intval($this->request->param('min_h_index', 5)),
|
||||
'limit' => min(max(intval($this->request->param('limit', 10)), 1), 30),
|
||||
'page' => max(intval($this->request->param('page', 1)), 1),
|
||||
];
|
||||
$checkRetraction = intval($this->request->param('check_retraction', 1));
|
||||
|
||||
$cacheKey = 'bg_batch_' . md5(json_encode([$keyword, $options, $checkRetraction]));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached) {
|
||||
return jsonSuccess($cached);
|
||||
}
|
||||
|
||||
$searchRes = $this->service->searchAuthorsByField($keyword, $options);
|
||||
if (!$searchRes['success']) {
|
||||
return jsonError($searchRes['error']);
|
||||
}
|
||||
|
||||
$data = $searchRes['data'];
|
||||
$screened = [];
|
||||
$riskStats = ['low' => 0, 'medium' => 0, 'high' => 0];
|
||||
|
||||
foreach ($data['list'] as $author) {
|
||||
$item = [
|
||||
'profile' => $author,
|
||||
'metrics' => [
|
||||
'works_count' => $author['works_count'],
|
||||
'cited_by_count' => $author['cited_by_count'],
|
||||
'h_index' => $author['h_index'],
|
||||
'level_label' => $this->service->parseAuthorMetrics([
|
||||
'works_count' => $author['works_count'],
|
||||
'cited_by_count' => $author['cited_by_count'],
|
||||
'summary_stats' => ['h_index' => $author['h_index']],
|
||||
])['level_label'],
|
||||
],
|
||||
];
|
||||
|
||||
if ($checkRetraction) {
|
||||
$openAlexId = $author['openalex_id'];
|
||||
$oaRetractions = $this->service->fetchRetractedWorksOpenAlex($openAlexId);
|
||||
$rwRetractions = $this->service->fetchRetractionWatchByAuthor($author['name']);
|
||||
$merged = $this->service->mergeRetractionRecords($oaRetractions, $rwRetractions, false);
|
||||
|
||||
$metrics = ['works_count' => $author['works_count']];
|
||||
$risk = $this->service->assessRisk($metrics, $merged);
|
||||
|
||||
$item['retractions'] = [
|
||||
'count' => $merged['count'],
|
||||
'openalex_count'=> $merged['openalex_count'],
|
||||
'rw_count' => $merged['rw_count'],
|
||||
'rw_only_count' => $merged['rw_only_count'],
|
||||
'list' => array_slice($merged['list'], 0, 3),
|
||||
];
|
||||
$item['risk_assessment'] = $risk;
|
||||
$riskStats[$risk['level']]++;
|
||||
} else {
|
||||
$item['risk_assessment'] = [
|
||||
'level' => 'unknown',
|
||||
'level_label' => '未检测',
|
||||
];
|
||||
}
|
||||
|
||||
$screened[] = $item;
|
||||
usleep(300000);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'query' => [
|
||||
'keyword' => $keyword,
|
||||
'topic_id' => $data['topic_id'],
|
||||
'min_h_index' => $options['min_h_index'],
|
||||
'page' => $options['page'],
|
||||
'limit' => $options['limit'],
|
||||
'check_retraction' => $checkRetraction,
|
||||
],
|
||||
'total' => $data['total'],
|
||||
'risk_stats' => $checkRetraction ? $riskStats : null,
|
||||
'list' => $screened,
|
||||
'disclaimer' => '批量筛查基于公开数据,同名作者可能存在误匹配,高风险条目需人工复核。',
|
||||
];
|
||||
|
||||
Cache::set($cacheKey, $result, 900);
|
||||
return jsonSuccess($result);
|
||||
}
|
||||
|
||||
// ===================== 核心逻辑 =====================
|
||||
|
||||
/**
|
||||
* 构建完整背景调查报告
|
||||
*/
|
||||
private function buildProfileReport($params)
|
||||
{
|
||||
$withCrossRef = !empty($params['with_crossref_detail']);
|
||||
|
||||
$author = $this->service->resolveAuthor($params);
|
||||
if (!$author['success']) {
|
||||
return $author;
|
||||
}
|
||||
|
||||
$authorData = $author['data'];
|
||||
$openAlexId = $this->service->extractOpenAlexId($authorData['id']);
|
||||
$authorName = $authorData['display_name'] ?? '';
|
||||
|
||||
$metrics = $this->service->parseAuthorMetrics($authorData);
|
||||
$topics = $this->service->parseResearchTopics($authorData);
|
||||
$recentWorks = $this->service->fetchRecentWorks($openAlexId, 5);
|
||||
|
||||
// 扩展1: OpenAlex 撤稿
|
||||
$oaRetractions = $this->service->fetchRetractedWorksOpenAlex($openAlexId);
|
||||
|
||||
// 扩展1: Retraction Watch 二次核对(CrossRef 来源)
|
||||
$rwRetractions = $this->service->fetchRetractionWatchByAuthor($authorName);
|
||||
|
||||
// 扩展2: 合并并按需补充 CrossRef 撤稿详情
|
||||
$retractions = $this->service->mergeRetractionRecords(
|
||||
$oaRetractions,
|
||||
$rwRetractions,
|
||||
$withCrossRef
|
||||
);
|
||||
|
||||
$risk = $this->service->assessRisk($metrics, $retractions);
|
||||
|
||||
$localInfo = null;
|
||||
if (!empty($params['user_id'])) {
|
||||
$localInfo = $this->fetchLocalUserInfo($params['user_id']);
|
||||
}
|
||||
|
||||
$dataSources = ['OpenAlex', 'Retraction Watch (via CrossRef)'];
|
||||
if ($withCrossRef) {
|
||||
$dataSources[] = 'CrossRef';
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'profile' => $this->service->formatAuthorBrief($authorData),
|
||||
'metrics' => $metrics,
|
||||
'research_topics' => $topics,
|
||||
'recent_works' => $recentWorks,
|
||||
'retractions' => $retractions,
|
||||
'risk_assessment' => $risk,
|
||||
'local_info' => $localInfo,
|
||||
'data_sources' => $dataSources,
|
||||
'disclaimer' => '本报告仅基于公开学术数据,不能替代正式背调或机构调查;未公开的不端行为无法检测。',
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并本地用户信息
|
||||
*/
|
||||
private function fetchLocalUserInfo($userId)
|
||||
{
|
||||
$user = $this->user_obj->where('user_id', $userId)->find();
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reviewer = $this->user_reviewer_info_obj
|
||||
->where('reviewer_id', $userId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
|
||||
$majors = $this->major_to_user_obj
|
||||
->where('user_id', $userId)
|
||||
->where('state', 0)
|
||||
->select();
|
||||
|
||||
$majorList = [];
|
||||
foreach ($majors as $m) {
|
||||
$majorList[] = [
|
||||
'major_id' => $m['major_id'],
|
||||
'path' => getMajorStr($m['major_id']),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'realname' => $user['realname'] ?? '',
|
||||
'email' => $user['email'] ?? '',
|
||||
'orcid' => $user['orcid'] ?? '',
|
||||
'company' => $reviewer['company'] ?? '',
|
||||
'title' => $reviewer['technical'] ?? '',
|
||||
'field' => $reviewer['field'] ?? '',
|
||||
'majors' => $majorList,
|
||||
];
|
||||
}
|
||||
}
|
||||
705
application/common/BackgroundCheckService.php
Normal file
705
application/common/BackgroundCheckService.php
Normal file
@@ -0,0 +1,705 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* 背景调查公共服务
|
||||
* 封装 OpenAlex / CrossRef / Retraction Watch 数据查询
|
||||
*/
|
||||
class BackgroundCheckService
|
||||
{
|
||||
private $openAlexBase = 'https://api.openalex.org';
|
||||
private $crossRefBase = 'https://api.crossref.org';
|
||||
private $mailto = 'publisher@tmrjournals.com';
|
||||
|
||||
// ===================== OpenAlex =====================
|
||||
|
||||
public function openAlexGet($path, $query = [])
|
||||
{
|
||||
$query['mailto'] = $this->mailto;
|
||||
$url = $this->openAlexBase . $path . '?' . http_build_query($query);
|
||||
|
||||
$result = $this->httpGet($url, [
|
||||
'Accept: application/json',
|
||||
'User-Agent: TMRJournals-BackgroundCheck/1.0 (mailto:' . $this->mailto . ')',
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$data = json_decode($result['body'], true);
|
||||
if (!is_array($data)) {
|
||||
return ['success' => false, 'error' => 'OpenAlex返回数据格式异常'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $data];
|
||||
}
|
||||
|
||||
public function resolveAuthor($params)
|
||||
{
|
||||
if (!empty($params['openalex_id'])) {
|
||||
$id = preg_replace('/^https?:\/\/openalex\.org\//', '', $params['openalex_id']);
|
||||
$res = $this->openAlexGet('/authors/' . urlencode($id));
|
||||
if (!$res['success']) {
|
||||
return ['success' => false, 'error' => $res['error']];
|
||||
}
|
||||
return ['success' => true, 'data' => $res['data']];
|
||||
}
|
||||
|
||||
if (!empty($params['orcid'])) {
|
||||
$orcid = $this->cleanOrcid($params['orcid']);
|
||||
$res = $this->openAlexGet('/authors/https://orcid.org/' . $orcid);
|
||||
if (!$res['success']) {
|
||||
return ['success' => false, 'error' => '未在 OpenAlex 找到该 ORCID 对应学者'];
|
||||
}
|
||||
return ['success' => true, 'data' => $res['data']];
|
||||
}
|
||||
|
||||
if (empty($params['name'])) {
|
||||
return ['success' => false, 'error' => '请提供 openalex_id、orcid 或 name'];
|
||||
}
|
||||
|
||||
$filter = 'display_name.search:' . $params['name'];
|
||||
if (!empty($params['affiliation'])) {
|
||||
$filter .= ',last_known_institutions.display_name.search:' . $params['affiliation'];
|
||||
}
|
||||
|
||||
$res = $this->openAlexGet('/authors', [
|
||||
'search' => $params['name'],
|
||||
'filter' => $filter,
|
||||
'sort' => 'cited_by_count:desc',
|
||||
'per-page' => 1,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return ['success' => false, 'error' => $res['error']];
|
||||
}
|
||||
|
||||
$results = $res['data']['results'] ?? [];
|
||||
if (empty($results)) {
|
||||
return ['success' => false, 'error' => '未找到匹配学者,请补充 affiliation 或使用 orcid'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $results[0]];
|
||||
}
|
||||
|
||||
public function fetchRetractedWorksOpenAlex($openAlexId)
|
||||
{
|
||||
$res = $this->openAlexGet('/works', [
|
||||
'filter' => 'authorships.author.id:' . $openAlexId . ',is_retracted:true',
|
||||
'sort' => 'publication_date:desc',
|
||||
'per-page' => 25,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return ['count' => 0, 'list' => [], 'error' => $res['error']];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($res['data']['results'] ?? [] as $work) {
|
||||
$list[] = $this->formatOpenAlexWork($work);
|
||||
}
|
||||
|
||||
return ['count' => count($list), 'list' => $list, 'source' => 'openalex'];
|
||||
}
|
||||
|
||||
public function fetchRecentWorks($openAlexId, $limit = 5)
|
||||
{
|
||||
$res = $this->openAlexGet('/works', [
|
||||
'filter' => 'authorships.author.id:' . $openAlexId,
|
||||
'sort' => 'publication_date:desc',
|
||||
'per-page' => $limit,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($res['data']['results'] ?? [] as $work) {
|
||||
$item = $this->formatOpenAlexWork($work);
|
||||
$item['is_retracted'] = !empty($work['is_retracted']);
|
||||
$list[] = $item;
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按领域/关键词批量搜索学者(OpenAlex)
|
||||
*/
|
||||
public function searchAuthorsByField($keyword, $options = [])
|
||||
{
|
||||
$minHIndex = intval($options['min_h_index'] ?? 5);
|
||||
$limit = min(max(intval($options['limit'] ?? 10), 1), 30);
|
||||
$page = max(intval($options['page'] ?? 1), 1);
|
||||
|
||||
$topicId = $this->resolveTopicId($keyword);
|
||||
$filters = [];
|
||||
|
||||
if ($topicId !== '') {
|
||||
$filters[] = 'topics.id:' . $topicId;
|
||||
}
|
||||
if ($minHIndex > 0) {
|
||||
$filters[] = 'summary_stats.h_index:>' . $minHIndex;
|
||||
}
|
||||
|
||||
$query = [
|
||||
'sort' => 'cited_by_count:desc',
|
||||
'per-page' => $limit,
|
||||
'page' => $page,
|
||||
];
|
||||
|
||||
if (!empty($filters)) {
|
||||
$query['filter'] = implode(',', $filters);
|
||||
$query['search'] = $keyword;
|
||||
} else {
|
||||
$query['search'] = $keyword;
|
||||
}
|
||||
|
||||
$res = $this->openAlexGet('/authors', $query);
|
||||
if (!$res['success']) {
|
||||
return ['success' => false, 'error' => $res['error']];
|
||||
}
|
||||
|
||||
$authors = [];
|
||||
foreach ($res['data']['results'] ?? [] as $author) {
|
||||
$authors[] = $this->formatAuthorBrief($author);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'keyword' => $keyword,
|
||||
'topic_id' => $topicId,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'total' => $res['data']['meta']['count'] ?? count($authors),
|
||||
'list' => $authors,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveTopicId($keyword)
|
||||
{
|
||||
$res = $this->openAlexGet('/topics', [
|
||||
'search' => $keyword,
|
||||
'sort' => 'works_count:desc',
|
||||
'per-page' => 1,
|
||||
]);
|
||||
|
||||
if (!$res['success']) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$results = $res['data']['results'] ?? [];
|
||||
if (empty($results)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->extractOpenAlexId($results[0]['id'] ?? '');
|
||||
}
|
||||
|
||||
// ===================== CrossRef =====================
|
||||
|
||||
public function cleanDoi($doi)
|
||||
{
|
||||
$doi = trim($doi);
|
||||
$doi = preg_replace('/^https?:\/\/doi\.org\//', '', $doi);
|
||||
$doi = preg_replace('/^doi:\s*/i', '', $doi);
|
||||
return trim($doi);
|
||||
}
|
||||
|
||||
public function fetchCrossRefWork($doi)
|
||||
{
|
||||
$doi = $this->cleanDoi($doi);
|
||||
if ($doi === '') {
|
||||
return ['success' => false, 'error' => 'DOI为空'];
|
||||
}
|
||||
|
||||
$url = $this->crossRefBase . '/works/' . urlencode($doi);
|
||||
$result = $this->httpGet($url, [
|
||||
'Accept: application/json',
|
||||
'User-Agent: TMRJournals-BackgroundCheck/1.0 (mailto:' . $this->mailto . ')',
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
if ($result['http_code'] == 404) {
|
||||
return ['success' => false, 'error' => 'DOI在CrossRef中未找到'];
|
||||
}
|
||||
if ($result['http_code'] != 200) {
|
||||
return ['success' => false, 'error' => 'CrossRef返回 HTTP ' . $result['http_code']];
|
||||
}
|
||||
|
||||
$data = json_decode($result['body'], true);
|
||||
if (!isset($data['message'])) {
|
||||
return ['success' => false, 'error' => 'CrossRef返回数据格式异常'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => $data['message']];
|
||||
}
|
||||
|
||||
public function parseCrossRefRetractionDetail($doi, $message)
|
||||
{
|
||||
$retraction = $this->detectCrossRefRetraction($message);
|
||||
|
||||
return [
|
||||
'doi' => $this->cleanDoi($doi),
|
||||
'title' => isset($message['title'][0]) ? $message['title'][0] : '',
|
||||
'is_retracted' => $retraction['is_retracted'],
|
||||
'retraction_detail' => $retraction['retraction_detail'],
|
||||
'journal' => isset($message['container-title'][0]) ? $message['container-title'][0] : '',
|
||||
'publisher' => $message['publisher'] ?? '',
|
||||
'published_date' => isset($message['published-print']) ? $this->parseDateParts($message['published-print']) : '',
|
||||
'authors' => $this->parseCrossRefAuthors($message['author'] ?? []),
|
||||
'url' => $message['URL'] ?? ('https://doi.org/' . $this->cleanDoi($doi)),
|
||||
];
|
||||
}
|
||||
|
||||
public function enrichRetractionsWithCrossRef($retractionList)
|
||||
{
|
||||
$enriched = [];
|
||||
foreach ($retractionList as $item) {
|
||||
$doi = $this->cleanDoi($item['doi'] ?? '');
|
||||
if ($doi === '') {
|
||||
$item['crossref'] = ['success' => false, 'error' => '无DOI'];
|
||||
$enriched[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$res = $this->fetchCrossRefWork($doi);
|
||||
if (!$res['success']) {
|
||||
$item['crossref'] = ['success' => false, 'error' => $res['error']];
|
||||
} else {
|
||||
$item['crossref'] = [
|
||||
'success' => true,
|
||||
'data' => $this->parseCrossRefRetractionDetail($doi, $res['message']),
|
||||
];
|
||||
}
|
||||
|
||||
$enriched[] = $item;
|
||||
usleep(200000);
|
||||
}
|
||||
|
||||
return $enriched;
|
||||
}
|
||||
|
||||
private function detectCrossRefRetraction($message)
|
||||
{
|
||||
$isRetracted = false;
|
||||
$retractionDetail = [
|
||||
'sources' => [],
|
||||
'retraction_notices' => [],
|
||||
'record_ids' => [],
|
||||
];
|
||||
|
||||
foreach (['updated-by', 'update-to'] as $field) {
|
||||
if (!isset($message[$field]) || !is_array($message[$field])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($message[$field] as $update) {
|
||||
$updateType = strtolower($update['type'] ?? '');
|
||||
$updateLabel = strtolower($update['label'] ?? '');
|
||||
if (strpos($updateType, 'retract') === false && strpos($updateLabel, 'retract') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isRetracted = true;
|
||||
$source = $update['source'] ?? 'publisher';
|
||||
$retractionDetail['sources'][] = $source;
|
||||
|
||||
$notice = [
|
||||
'type' => $update['type'] ?? '',
|
||||
'label' => $update['label'] ?? '',
|
||||
'source' => $source,
|
||||
'notice_doi'=> $update['DOI'] ?? '',
|
||||
'date' => isset($update['updated']) ? $this->parseDateParts($update['updated']) : '',
|
||||
'record_id' => $update['record-id'] ?? '',
|
||||
];
|
||||
$retractionDetail['retraction_notices'][] = $notice;
|
||||
|
||||
if (!empty($notice['record_id'])) {
|
||||
$retractionDetail['record_ids'][] = $notice['record_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$type = strtolower($message['type'] ?? '');
|
||||
$subtype = strtolower($message['subtype'] ?? '');
|
||||
if (strpos($type, 'retract') !== false || strpos($subtype, 'retract') !== false) {
|
||||
$isRetracted = true;
|
||||
$retractionDetail['is_retraction_notice'] = true;
|
||||
}
|
||||
|
||||
if (isset($message['relation']) && is_array($message['relation'])) {
|
||||
foreach ($message['relation'] as $relType => $relations) {
|
||||
if (strpos(strtolower($relType), 'retract') !== false) {
|
||||
$isRetracted = true;
|
||||
$retractionDetail['relation'] = [$relType => $relations];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$retractionDetail['sources'] = array_values(array_unique($retractionDetail['sources']));
|
||||
$retractionDetail['record_ids'] = array_values(array_unique($retractionDetail['record_ids']));
|
||||
|
||||
return ['is_retracted' => $isRetracted, 'retraction_detail' => $retractionDetail];
|
||||
}
|
||||
|
||||
// ===================== Retraction Watch (via CrossRef) =====================
|
||||
|
||||
/**
|
||||
* 通过 CrossRef 检索 Retraction Watch 来源的撤稿记录(按作者姓名)
|
||||
*/
|
||||
public function fetchRetractionWatchByAuthor($authorName)
|
||||
{
|
||||
$url = $this->crossRefBase . '/works?' . http_build_query([
|
||||
'query.author' => $authorName,
|
||||
'filter' => 'update-type:retraction',
|
||||
'rows' => 25,
|
||||
'mailto' => $this->mailto,
|
||||
]);
|
||||
|
||||
$result = $this->httpGet($url, [
|
||||
'Accept: application/json',
|
||||
'User-Agent: TMRJournals-BackgroundCheck/1.0 (mailto:' . $this->mailto . ')',
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['count' => 0, 'list' => [], 'error' => $result['error']];
|
||||
}
|
||||
|
||||
if ($result['http_code'] != 200) {
|
||||
return ['count' => 0, 'list' => [], 'error' => 'CrossRef返回 HTTP ' . $result['http_code']];
|
||||
}
|
||||
|
||||
$data = json_decode($result['body'], true);
|
||||
$items = $data['message']['items'] ?? [];
|
||||
|
||||
$list = [];
|
||||
foreach ($items as $message) {
|
||||
$parsed = $this->parseCrossRefRetractionDetail($message['DOI'] ?? '', $message);
|
||||
if (!$parsed['is_retracted']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rwSources = array_filter($parsed['retraction_detail']['sources'] ?? [], function ($s) {
|
||||
return stripos($s, 'retraction-watch') !== false || stripos($s, 'retraction_watch') !== false;
|
||||
});
|
||||
|
||||
$list[] = [
|
||||
'title' => $parsed['title'],
|
||||
'doi' => $parsed['doi'],
|
||||
'journal' => $parsed['journal'],
|
||||
'publisher' => $parsed['publisher'],
|
||||
'published_date' => $parsed['published_date'],
|
||||
'is_retracted' => true,
|
||||
'retraction_detail' => $parsed['retraction_detail'],
|
||||
'from_retraction_watch' => !empty($rwSources) || !empty($parsed['retraction_detail']['record_ids']),
|
||||
'source' => 'retraction_watch',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'count' => count($list),
|
||||
'list' => $list,
|
||||
'source' => 'retraction_watch',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 OpenAlex + Retraction Watch 撤稿记录(按 DOI 去重)
|
||||
*/
|
||||
public function mergeRetractionRecords($openAlexRetractions, $rwRetractions, $withCrossRefDetail = false)
|
||||
{
|
||||
$merged = [];
|
||||
$doiMap = [];
|
||||
|
||||
foreach ([$openAlexRetractions, $rwRetractions] as $sourceData) {
|
||||
foreach ($sourceData['list'] ?? [] as $item) {
|
||||
$doi = $this->cleanDoi($item['doi'] ?? '');
|
||||
$key = $doi !== '' ? strtolower($doi) : md5(json_encode($item));
|
||||
|
||||
if (!isset($doiMap[$key])) {
|
||||
$doiMap[$key] = [
|
||||
'title' => $item['title'] ?? '',
|
||||
'doi' => $doi,
|
||||
'journal' => $item['journal'] ?? '',
|
||||
'publication_date' => $item['publication_date'] ?? ($item['published_date'] ?? ''),
|
||||
'sources' => [],
|
||||
'retraction_detail'=> $item['retraction_detail'] ?? [],
|
||||
'from_retraction_watch' => !empty($item['from_retraction_watch']),
|
||||
];
|
||||
}
|
||||
|
||||
$src = $item['source'] ?? 'unknown';
|
||||
if (!in_array($src, $doiMap[$key]['sources'])) {
|
||||
$doiMap[$key]['sources'][] = $src;
|
||||
}
|
||||
if (!empty($item['from_retraction_watch'])) {
|
||||
$doiMap[$key]['from_retraction_watch'] = true;
|
||||
}
|
||||
if (!empty($item['retraction_detail']) && empty($doiMap[$key]['retraction_detail'])) {
|
||||
$doiMap[$key]['retraction_detail'] = $item['retraction_detail'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$merged = array_values($doiMap);
|
||||
|
||||
if ($withCrossRefDetail) {
|
||||
$merged = $this->enrichRetractionsWithCrossRef($merged);
|
||||
}
|
||||
|
||||
$rwOnlyCount = 0;
|
||||
foreach ($merged as $row) {
|
||||
if (!empty($row['from_retraction_watch']) && count($row['sources'] ?? []) <= 1) {
|
||||
$rwOnlyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'count' => count($merged),
|
||||
'openalex_count' => intval($openAlexRetractions['count'] ?? 0),
|
||||
'rw_count' => intval($rwRetractions['count'] ?? 0),
|
||||
'rw_only_count' => $rwOnlyCount,
|
||||
'list' => $merged,
|
||||
];
|
||||
}
|
||||
|
||||
// ===================== 格式化 =====================
|
||||
|
||||
public function formatAuthorBrief($author)
|
||||
{
|
||||
$institutions = [];
|
||||
foreach ($author['last_known_institutions'] ?? [] as $inst) {
|
||||
$institutions[] = [
|
||||
'name' => $inst['display_name'] ?? '',
|
||||
'country' => $inst['country_code'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'openalex_id' => $this->extractOpenAlexId($author['id'] ?? ''),
|
||||
'name' => $author['display_name'] ?? '',
|
||||
'orcid' => $this->extractOrcid($author['orcid'] ?? ''),
|
||||
'works_count' => intval($author['works_count'] ?? 0),
|
||||
'cited_by_count' => intval($author['cited_by_count'] ?? 0),
|
||||
'h_index' => intval($author['summary_stats']['h_index'] ?? 0),
|
||||
'institutions' => $institutions,
|
||||
'openalex_url' => $author['id'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public function parseAuthorMetrics($author)
|
||||
{
|
||||
$stats = $author['summary_stats'] ?? [];
|
||||
|
||||
return [
|
||||
'works_count' => intval($author['works_count'] ?? 0),
|
||||
'cited_by_count' => intval($author['cited_by_count'] ?? 0),
|
||||
'h_index' => intval($stats['h_index'] ?? 0),
|
||||
'i10_index' => intval($stats['i10_index'] ?? 0),
|
||||
'two_year_mean_cited' => round(floatval($stats['2yr_mean_citedness'] ?? 0), 2),
|
||||
'level_label' => $this->getAcademicLevelLabel($stats),
|
||||
];
|
||||
}
|
||||
|
||||
public function parseResearchTopics($author)
|
||||
{
|
||||
$topics = [];
|
||||
foreach ($author['x_concepts'] ?? [] as $concept) {
|
||||
if (empty($concept['display_name'])) {
|
||||
continue;
|
||||
}
|
||||
$topics[] = [
|
||||
'name' => $concept['display_name'],
|
||||
'score' => round(floatval($concept['score'] ?? 0), 3),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($topics)) {
|
||||
foreach ($author['topics'] ?? [] as $topic) {
|
||||
if (empty($topic['display_name'])) {
|
||||
continue;
|
||||
}
|
||||
$topics[] = [
|
||||
'name' => $topic['display_name'],
|
||||
'score' => round(floatval($topic['score'] ?? 0), 3),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice($topics, 0, 8);
|
||||
}
|
||||
|
||||
public function assessRisk($metrics, $retractions)
|
||||
{
|
||||
$retractionCount = intval($retractions['count'] ?? 0);
|
||||
$rwOnlyCount = intval($retractions['rw_only_count'] ?? 0);
|
||||
$level = 'low';
|
||||
$score = 0;
|
||||
$reasons = [];
|
||||
|
||||
if ($retractionCount === 0) {
|
||||
$level = 'low';
|
||||
$score = 10;
|
||||
$reasons[] = 'OpenAlex 与 Retraction Watch 均未发现撤稿记录';
|
||||
} elseif ($retractionCount === 1) {
|
||||
$level = 'medium';
|
||||
$score = 50;
|
||||
$reasons[] = '发现 1 篇撤稿论文,建议人工核实撤稿原因';
|
||||
} else {
|
||||
$level = 'high';
|
||||
$score = 80 + min($retractionCount * 5, 20);
|
||||
$reasons[] = '发现 ' . $retractionCount . ' 篇撤稿论文,存在较高学术风险';
|
||||
}
|
||||
|
||||
if ($rwOnlyCount > 0) {
|
||||
$reasons[] = 'Retraction Watch 额外发现 ' . $rwOnlyCount . ' 条 OpenAlex 未收录的撤稿记录';
|
||||
if ($level === 'low') {
|
||||
$level = 'medium';
|
||||
$score = max($score, 45);
|
||||
}
|
||||
}
|
||||
|
||||
$worksCount = max(intval($metrics['works_count'] ?? 0), 1);
|
||||
$retractionRate = round($retractionCount / $worksCount * 100, 2);
|
||||
if ($retractionCount > 0 && $retractionRate >= 5) {
|
||||
$reasons[] = '撤稿率 ' . $retractionRate . '%,比例偏高';
|
||||
if ($level === 'medium') {
|
||||
$level = 'high';
|
||||
$score = max($score, 70);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'level' => $level,
|
||||
'level_label' => $this->getRiskLevelLabel($level),
|
||||
'score' => min($score, 100),
|
||||
'retraction_count' => $retractionCount,
|
||||
'retraction_rate' => $retractionRate . '%',
|
||||
'rw_only_count' => $rwOnlyCount,
|
||||
'reasons' => $reasons,
|
||||
];
|
||||
}
|
||||
|
||||
// ===================== 内部工具 =====================
|
||||
|
||||
private function formatOpenAlexWork($work)
|
||||
{
|
||||
return [
|
||||
'title' => $work['display_name'] ?? '',
|
||||
'doi' => $this->extractDoi($work),
|
||||
'publication_date' => $work['publication_date'] ?? '',
|
||||
'journal' => $work['primary_location']['source']['display_name'] ?? '',
|
||||
'cited_by_count' => intval($work['cited_by_count'] ?? 0),
|
||||
'openalex_url' => $work['id'] ?? '',
|
||||
'source' => 'openalex',
|
||||
];
|
||||
}
|
||||
|
||||
private function parseCrossRefAuthors($authorList)
|
||||
{
|
||||
if (empty($authorList) || !is_array($authorList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($authorList as $a) {
|
||||
$result[] = [
|
||||
'given' => $a['given'] ?? '',
|
||||
'family' => $a['family'] ?? '',
|
||||
'name' => isset($a['name']) ? $a['name'] : trim(($a['given'] ?? '') . ' ' . ($a['family'] ?? '')),
|
||||
'orcid' => $a['ORCID'] ?? '',
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function parseDateParts($dateObj)
|
||||
{
|
||||
if (!isset($dateObj['date-parts'][0])) {
|
||||
return '';
|
||||
}
|
||||
$parts = $dateObj['date-parts'][0];
|
||||
$y = isset($parts[0]) ? $parts[0] : '';
|
||||
$m = isset($parts[1]) ? sprintf('%02d', $parts[1]) : '';
|
||||
$d = isset($parts[2]) ? sprintf('%02d', $parts[2]) : '';
|
||||
if ($y && $m && $d) {
|
||||
return "{$y}-{$m}-{$d}";
|
||||
}
|
||||
if ($y && $m) {
|
||||
return "{$y}-{$m}";
|
||||
}
|
||||
return (string)$y;
|
||||
}
|
||||
|
||||
private function getAcademicLevelLabel($stats)
|
||||
{
|
||||
$h = intval($stats['h_index'] ?? 0);
|
||||
if ($h >= 50) return '国际顶尖学者';
|
||||
if ($h >= 30) return '资深专家';
|
||||
if ($h >= 15) return '活跃研究者';
|
||||
if ($h >= 5) return '青年学者';
|
||||
if ($h > 0) return '初入领域';
|
||||
return '暂无足够公开数据';
|
||||
}
|
||||
|
||||
private function getRiskLevelLabel($level)
|
||||
{
|
||||
$map = ['low' => '低风险', 'medium' => '中风险', 'high' => '高风险'];
|
||||
return $map[$level] ?? '未知';
|
||||
}
|
||||
|
||||
public function extractOpenAlexId($id)
|
||||
{
|
||||
return preg_replace('/^https?:\/\/openalex\.org\//', '', $id);
|
||||
}
|
||||
|
||||
public function extractOrcid($orcid)
|
||||
{
|
||||
if ($orcid === '') return '';
|
||||
return preg_replace('/^https?:\/\/orcid\.org\//', '', $orcid);
|
||||
}
|
||||
|
||||
public function cleanOrcid($orcid)
|
||||
{
|
||||
$orcid = trim($orcid);
|
||||
$orcid = preg_replace('/^https?:\/\/orcid\.org\//', '', $orcid);
|
||||
return trim($orcid);
|
||||
}
|
||||
|
||||
private function extractDoi($work)
|
||||
{
|
||||
$doi = $work['doi'] ?? '';
|
||||
return preg_replace('/^https?:\/\/doi\.org\//', '', $doi);
|
||||
}
|
||||
|
||||
private function httpGet($url, $headers = [])
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
return ['success' => false, 'error' => 'HTTP请求失败: ' . $error];
|
||||
}
|
||||
curl_close($ch);
|
||||
|
||||
return ['success' => true, 'body' => $body, 'http_code' => $httpCode];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user