Files
tougao/application/common/ReferenceAuthorIdentityService.php
wyn 8785610e6d 参考文献作者堆叠
参考文献相关性检测
作者ai写作辅助检测工作
2026-07-15 10:49:05 +08:00

284 lines
9.1 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;
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;
}
}