284 lines
9.1 KiB
PHP
284 lines
9.1 KiB
PHP
<?php
|
||
|
||
namespace app\common;
|
||
|
||
use think\Db;
|
||
|
||
/**
|
||
* 参考文献堆叠 — 作者精准定位(OpenAlex ID / ORCID,姓名仅作模糊参考)
|
||
*/
|
||
class ReferenceAuthorIdentityService
|
||
{
|
||
const MATCH_PRECISE = 'precise';
|
||
const MATCH_FUZZY = 'fuzzy';
|
||
const MATCH_NONE = 'none';
|
||
|
||
/** @var BackgroundCheckService */
|
||
private $bgCheck;
|
||
|
||
/** @var ReferenceCheckService */
|
||
private $refUtil;
|
||
|
||
/** @var CrossrefService */
|
||
private $crossref;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->bgCheck = new BackgroundCheckService();
|
||
$this->refUtil = new ReferenceCheckService();
|
||
$this->crossref = new CrossrefService([
|
||
'mailto' => trim((string)\think\Env::get('crossref_mailto', '')),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 本文作者身份(精准统计仅认 ORCID → OpenAlex / ORCID 键)
|
||
*
|
||
* @return array<int, array{
|
||
* p_article_author_id:int,
|
||
* display_name:string,
|
||
* openalex_id:string,
|
||
* orcid:string,
|
||
* identity_keys:string[],
|
||
* match_confidence:string
|
||
* }>
|
||
*/
|
||
public function resolveManuscriptAuthors($pArticleId)
|
||
{
|
||
$pArticleId = intval($pArticleId);
|
||
if ($pArticleId <= 0) {
|
||
return [];
|
||
}
|
||
|
||
$rows = Db::name('production_article_author')
|
||
->field('p_article_author_id,first_name,last_name,author_name,orcid')
|
||
->where('p_article_id', $pArticleId)
|
||
->where('state', 0)
|
||
->select();
|
||
|
||
$identities = [];
|
||
foreach ($rows as $row) {
|
||
$identities[] = $this->resolveOneManuscriptAuthor($row);
|
||
}
|
||
|
||
return $identities;
|
||
}
|
||
|
||
/**
|
||
* 单条参考文献的作者身份(优先 OpenAlex work by DOI,其次 Crossref ORCID)
|
||
*
|
||
* @return array{
|
||
* authorships:array,
|
||
* first_author:array|null,
|
||
* match_confidence:string,
|
||
* identity_source:string
|
||
* }
|
||
*/
|
||
public function resolveReferAuthorships(array $refer, array &$workCache, array &$crossrefCache)
|
||
{
|
||
$pReferId = intval($refer['p_refer_id'] ?? 0);
|
||
if ($pReferId > 0) {
|
||
$stored = (new ReferenceReferAuthorService())->loadAuthorshipsByPReferId($pReferId);
|
||
if (!empty($stored)) {
|
||
return [
|
||
'authorships' => $stored,
|
||
'first_author' => $this->pickFirstAuthorship($stored),
|
||
'match_confidence' => self::MATCH_PRECISE,
|
||
'identity_source' => 'stored',
|
||
];
|
||
}
|
||
}
|
||
|
||
$doi = $this->refUtil->extractDoiFromRefer($refer);
|
||
if ($doi === '') {
|
||
return [
|
||
'authorships' => [],
|
||
'first_author' => null,
|
||
'match_confidence' => self::MATCH_NONE,
|
||
'identity_source' => '',
|
||
];
|
||
}
|
||
|
||
if (!array_key_exists($doi, $workCache)) {
|
||
$workCache[$doi] = $this->fetchOpenAlexAuthorships($doi);
|
||
usleep(80000);
|
||
}
|
||
|
||
$authorships = $workCache[$doi];
|
||
$source = 'openalex';
|
||
|
||
if (empty($authorships)) {
|
||
if (!array_key_exists($doi, $crossrefCache)) {
|
||
$crossrefCache[$doi] = $this->fetchCrossrefAuthorships($doi);
|
||
usleep(80000);
|
||
}
|
||
$authorships = $crossrefCache[$doi];
|
||
$source = 'crossref_orcid';
|
||
}
|
||
|
||
$firstAuthor = $this->pickFirstAuthorship($authorships);
|
||
$confidence = empty($authorships) ? self::MATCH_NONE : self::MATCH_PRECISE;
|
||
|
||
return [
|
||
'authorships' => $authorships,
|
||
'first_author' => $firstAuthor,
|
||
'match_confidence' => $confidence,
|
||
'identity_source' => $source,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array{identity_keys:string[]}> $manuscriptAuthors
|
||
* @param array<int, array{identity_keys:string[]}> $referAuthorships
|
||
*/
|
||
public function matchManuscriptToRefer(array $manuscriptAuthors, array $referAuthorships)
|
||
{
|
||
$manuscriptKeys = [];
|
||
foreach ($manuscriptAuthors as $author) {
|
||
if (($author['match_confidence'] ?? '') !== self::MATCH_PRECISE) {
|
||
continue;
|
||
}
|
||
foreach ((array)($author['identity_keys'] ?? []) as $key) {
|
||
$manuscriptKeys[$key] = $author;
|
||
}
|
||
}
|
||
|
||
foreach ($referAuthorships as $auth) {
|
||
foreach ((array)($auth['identity_keys'] ?? []) as $key) {
|
||
if (isset($manuscriptKeys[$key])) {
|
||
return $manuscriptKeys[$key];
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
public function buildFuzzyAuthorKey($authorCitationPart)
|
||
{
|
||
$part = trim(preg_replace('/\.+$/u', '', trim((string)$authorCitationPart)));
|
||
if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) {
|
||
return '';
|
||
}
|
||
|
||
$tokens = preg_split('/\s+/u', $part, -1, PREG_SPLIT_NO_EMPTY);
|
||
if (count($tokens) === 1) {
|
||
return 'fuzzy:' . mb_strtoupper($tokens[0]) . '|';
|
||
}
|
||
|
||
$last = array_pop($tokens);
|
||
if (preg_match('/^[A-Za-z]{1,4}$/u', $last)) {
|
||
$family = implode(' ', $tokens);
|
||
return 'fuzzy:' . mb_strtoupper(preg_replace('/\s+/u', ' ', trim($family))) . '|' . mb_strtoupper($last);
|
||
}
|
||
|
||
$family = $last;
|
||
$initials = '';
|
||
foreach ($tokens as $token) {
|
||
$initials .= mb_strtoupper(mb_substr($token, 0, 1));
|
||
}
|
||
return 'fuzzy:' . mb_strtoupper($family) . '|' . $initials;
|
||
}
|
||
|
||
public function extractFuzzyFirstAuthorKeyFromRefer(array $refer, array $meta)
|
||
{
|
||
$author = trim(trim((string)($meta['author'] ?? $refer['author'] ?? '')), '.');
|
||
if ($author === '') {
|
||
return '';
|
||
}
|
||
$parts = preg_split('/,\s*/u', $author);
|
||
$first = trim((string)($parts[0] ?? ''));
|
||
return $this->buildFuzzyAuthorKey($first);
|
||
}
|
||
|
||
private function resolveOneManuscriptAuthor(array $row)
|
||
{
|
||
$first = trim((string)($row['first_name'] ?? ''));
|
||
$last = trim((string)($row['last_name'] ?? ''));
|
||
$displayName = ($first !== '' && $last !== '') ? trim($first . ' ' . $last) : trim((string)($row['author_name'] ?? ''));
|
||
$orcid = $this->bgCheck->cleanOrcid($row['orcid'] ?? '');
|
||
$openalexId = '';
|
||
$identityKeys = [];
|
||
$confidence = self::MATCH_NONE;
|
||
$fuzzyKey = '';
|
||
|
||
if ($last !== '') {
|
||
$initials = $this->initialsFromGiven($first);
|
||
$fuzzyKey = 'fuzzy:' . mb_strtoupper($last) . '|' . $initials;
|
||
}
|
||
|
||
if ($orcid !== '') {
|
||
$identityKeys[] = 'orcid:' . $orcid;
|
||
$confidence = self::MATCH_PRECISE;
|
||
$res = $this->bgCheck->resolveAuthor(['orcid' => $orcid]);
|
||
if (!empty($res['success']) && !empty($res['data'])) {
|
||
$openalexId = $this->bgCheck->extractOpenAlexId($res['data']['id'] ?? '');
|
||
if ($openalexId !== '') {
|
||
$identityKeys[] = 'openalex:' . $openalexId;
|
||
}
|
||
if (trim((string)($res['data']['display_name'] ?? '')) !== '') {
|
||
$displayName = trim((string)$res['data']['display_name']);
|
||
}
|
||
}
|
||
}
|
||
|
||
return [
|
||
'p_article_author_id' => intval($row['p_article_author_id']),
|
||
'display_name' => $displayName,
|
||
'openalex_id' => $openalexId,
|
||
'orcid' => $orcid,
|
||
'fuzzy_key' => $fuzzyKey,
|
||
'identity_keys' => array_values(array_unique($identityKeys)),
|
||
'match_confidence' => $confidence,
|
||
];
|
||
}
|
||
|
||
private function initialsFromGiven($given)
|
||
{
|
||
$given = trim((string)$given);
|
||
if ($given === '') {
|
||
return '';
|
||
}
|
||
$parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY);
|
||
$initials = '';
|
||
foreach ($parts as $part) {
|
||
$first = mb_substr($part, 0, 1);
|
||
if ($first !== '') {
|
||
$initials .= mb_strtoupper($first);
|
||
}
|
||
}
|
||
return $initials;
|
||
}
|
||
|
||
private function fetchOpenAlexAuthorships($doi)
|
||
{
|
||
$res = $this->bgCheck->fetchOpenAlexWorkByDoi($doi);
|
||
if (empty($res['success']) || empty($res['work']) || !is_array($res['work'])) {
|
||
return [];
|
||
}
|
||
return $this->bgCheck->parseWorkAuthorships($res['work']);
|
||
}
|
||
|
||
private function fetchCrossrefAuthorships($doi)
|
||
{
|
||
$res = $this->bgCheck->fetchCrossRefWork($doi);
|
||
if (empty($res['success']) || empty($res['message'])) {
|
||
return [];
|
||
}
|
||
return $this->bgCheck->authorshipsFromCrossrefAuthors($res['message']['author'] ?? []);
|
||
}
|
||
|
||
private function pickFirstAuthorship(array $authorships)
|
||
{
|
||
if (empty($authorships)) {
|
||
return null;
|
||
}
|
||
foreach ($authorships as $auth) {
|
||
if (($auth['author_position'] ?? '') === 'first') {
|
||
return $auth;
|
||
}
|
||
}
|
||
return reset($authorships) ?: null;
|
||
}
|
||
}
|