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

521 lines
18 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;
/**
* 参考文献作者明细入库/读取(供引用堆叠等同作者精准统计)
*/
class ReferenceReferAuthorService
{
/** @var BackgroundCheckService */
private $bgCheck;
/** @var CrossrefService */
private $crossref;
public function __construct()
{
$this->bgCheck = new BackgroundCheckService();
$this->crossref = new CrossrefService([
'mailto' => trim((string)\think\Env::get('crossref_mailto', '')),
]);
}
/**
* Crossref enrichment 成功后:解析并覆盖写入作者明细
*
* @return int 写入作者条数
*/
public function syncFromWorkSummary($pReferId, $pArticleId, $doi, array $summary)
{
$pReferId = intval($pReferId);
$pArticleId = intval($pArticleId);
if ($pReferId <= 0) {
return 0;
}
$rows = $this->buildAuthorRows($doi, $summary);
Db::name('production_article_refer_author')->where('p_refer_id', $pReferId)->delete();
if (empty($rows)) {
return 0;
}
$now = date('Y-m-d H:i:s');
$insertRows = [];
foreach ($rows as $row) {
$insertRows[] = [
'p_article_id' => $pArticleId,
'p_refer_id' => $pReferId,
'author_seq' => intval($row['author_seq']),
'author_position' => $this->clipField((string)$row['author_position'], 16),
'is_first_author' => intval($row['is_first_author']),
'family' => $this->clipField((string)$row['family'], 128),
'given' => $this->clipField((string)$row['given'], 128),
'display_name' => $this->clipField((string)$row['display_name'], 256),
'citation_name' => $this->clipField((string)$row['citation_name'], 128),
'orcid' => $this->clipField((string)$row['orcid'], 32),
'openalex_id' => $this->clipField((string)$row['openalex_id'], 32),
'identity_source' => $this->clipField((string)$row['identity_source'], 32),
'created_at' => $now,
'updated_at' => $now,
];
}
Db::name('production_article_refer_author')->insertAll($insertRows);
return count($insertRows);
}
/**
* 从 refer 表已有 author 字段解析并入库Crossref/OpenAlex 均不可用时的兜底)
*
* @return int
*/
public function syncFromReferAuthorField($pReferId, $pArticleId, $authorString)
{
$pReferId = intval($pReferId);
$pArticleId = intval($pArticleId);
if ($pReferId <= 0) {
return 0;
}
$rows = $this->parseReferAuthorString($authorString);
Db::name('production_article_refer_author')->where('p_refer_id', $pReferId)->delete();
if (empty($rows)) {
return 0;
}
$now = date('Y-m-d H:i:s');
$insertRows = [];
foreach ($rows as $row) {
$insertRows[] = [
'p_article_id' => $pArticleId,
'p_refer_id' => $pReferId,
'author_seq' => intval($row['author_seq']),
'author_position' => $this->clipField((string)$row['author_position'], 16),
'is_first_author' => intval($row['is_first_author']),
'family' => '',
'given' => '',
'display_name' => $this->clipField((string)$row['display_name'], 256),
'citation_name' => $this->clipField((string)$row['citation_name'], 128),
'orcid' => '',
'openalex_id' => '',
'identity_source' => 'refer_author',
'created_at' => $now,
'updated_at' => $now,
];
}
Db::name('production_article_refer_author')->insertAll($insertRows);
return count($insertRows);
}
/**
* 单条参考文献同步作者明细:有 DOI 走 Crossref+OpenAlex否则解析 refer.author
*
* @param int $pReferId
* @param int $pArticleId
* @param array $refer 可传 production_article_refer 行;为空则从库读取
* @return int 写入作者条数
*/
public function syncOneRefer($pReferId, $pArticleId, array $refer = [])
{
$pReferId = intval($pReferId);
$pArticleId = intval($pArticleId);
if ($pReferId <= 0 || $pArticleId <= 0) {
return 0;
}
if (empty($refer)) {
$refer = Db::name('production_article_refer')
->where('p_refer_id', $pReferId)
->where('p_article_id', $pArticleId)
->where('state', 0)
->find();
if (empty($refer)) {
return 0;
}
}
$refUtil = new ReferenceCheckService();
$doi = $refUtil->extractDoiFromRefer($refer);
$count = 0;
if ($doi !== '') {
$summary = $this->crossref->fetchWorkSummary($doi);
if ($summary === null || empty($summary['doi'])) {
$summary = ['doi' => $doi, 'raw' => []];
}
$count = $this->syncFromWorkSummary($pReferId, $pArticleId, $doi, $summary);
}
if ($count <= 0) {
$count = $this->syncFromReferAuthorField(
$pReferId,
$pArticleId,
(string)($refer['author'] ?? '')
);
}
return $count;
}
/**
* 按篇批量同步参考文献作者明细(有 DOI 则 Crossref + OpenAlex
*
* @return array{total:int,synced:int,authors:int,skipped_no_doi:int,failed:int,errors:array}
*/
public function syncByPArticleId($pArticleId, array $options = [])
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
throw new \InvalidArgumentException('p_article_id is required');
}
$sleepMs = max(0, intval($options['sleep_ms'] ?? 100));
$refUtil = new ReferenceCheckService();
$refers = Db::name('production_article_refer')
->field('p_refer_id,p_article_id,index,refer_doi,doilink,refer_content,refer_frag,author')
->where('p_article_id', $pArticleId)
->where('state', 0)
->order('index asc')
->select();
$result = [
'p_article_id' => $pArticleId,
'total' => count($refers),
'synced' => 0,
'authors' => 0,
'skipped_no_doi' => 0,
'failed' => 0,
'errors' => [],
];
foreach ($refers as $refer) {
$pReferId = intval($refer['p_refer_id']);
$doi = $refUtil->extractDoiFromRefer($refer);
if ($doi === '') {
$result['skipped_no_doi']++;
continue;
}
try {
$summary = $this->crossref->fetchWorkSummary($doi);
if ($summary === null || empty($summary['doi'])) {
$summary = ['doi' => $doi, 'raw' => []];
}
$count = $this->syncFromWorkSummary($pReferId, $pArticleId, $doi, $summary);
if ($count <= 0) {
$count = $this->syncFromReferAuthorField(
$pReferId,
$pArticleId,
(string)($refer['author'] ?? '')
);
}
if ($count > 0) {
$result['synced']++;
$result['authors'] += $count;
} else {
$result['failed']++;
$result['errors'][] = [
'p_refer_id' => $pReferId,
'reference_no' => intval($refer['index']) + 1,
'doi' => $doi,
'msg' => 'no author rows from Crossref/OpenAlex/refer.author',
];
}
} catch (\Throwable $e) {
$result['failed']++;
$result['errors'][] = [
'p_refer_id' => $pReferId,
'reference_no' => intval($refer['index']) + 1,
'doi' => $doi,
'msg' => $e->getMessage(),
];
}
if ($sleepMs > 0) {
usleep($sleepMs * 1000);
}
}
return $result;
}
/**
* 读取已入库的作者身份(与 ReferenceAuthorIdentityService 结构兼容)
*
* @return array<int, array{openalex_id:string,orcid:string,display_name:string,author_position:string,is_corresponding:bool,identity_keys:string[]}>
*/
public function loadAuthorshipsByPReferId($pReferId)
{
$pReferId = intval($pReferId);
if ($pReferId <= 0) {
return [];
}
$rows = Db::name('production_article_refer_author')
->where('p_refer_id', $pReferId)
->order('author_seq asc, id asc')
->select();
$list = [];
foreach ($rows as $row) {
$openalexId = trim((string)($row['openalex_id'] ?? ''));
$orcid = trim((string)($row['orcid'] ?? ''));
$identityKeys = [];
if ($openalexId !== '') {
$identityKeys[] = 'openalex:' . $openalexId;
}
if ($orcid !== '') {
$identityKeys[] = 'orcid:' . $orcid;
}
if (empty($identityKeys)) {
continue;
}
$position = trim((string)($row['author_position'] ?? ''));
if ($position === '' && intval($row['is_first_author'] ?? 0) === 1) {
$position = 'first';
}
$list[] = [
'openalex_id' => $openalexId,
'orcid' => $orcid,
'display_name' => trim((string)($row['display_name'] ?? '')),
'author_position' => $position,
'is_corresponding' => false,
'identity_keys' => $identityKeys,
];
}
return $list;
}
/**
* @return array<int, array>
*/
private function buildAuthorRows($doi, array $summary)
{
$doi = trim((string)$doi);
$raw = is_array($summary['raw'] ?? null) ? $summary['raw'] : [];
$crossrefAuthors = $this->parseCrossrefAuthorList($raw['author'] ?? []);
$openalexList = $doi !== '' ? $this->fetchOpenAlexAuthorships($doi) : [];
$max = max(count($crossrefAuthors), count($openalexList));
if ($max <= 0) {
return [];
}
$rows = [];
for ($i = 0; $i < $max; $i++) {
$cr = $crossrefAuthors[$i] ?? null;
$oa = $openalexList[$i] ?? null;
if (is_array($oa) && is_array($cr) && trim((string)($cr['orcid'] ?? '')) !== '' && trim((string)($oa['orcid'] ?? '')) === '') {
$oa['orcid'] = trim((string)$cr['orcid']);
}
if (is_array($oa) && is_array($cr) && trim((string)($oa['display_name'] ?? '')) === '' && trim((string)($cr['display_name'] ?? '')) !== '') {
$oa['display_name'] = trim((string)$cr['display_name']);
}
$row = $this->mergeOneAuthorRow($i, $cr, $oa);
if ($row !== null) {
$rows[] = $row;
}
}
return $rows;
}
private function mergeOneAuthorRow($seq, $crossrefAuthor, $openalexAuthor)
{
$family = '';
$given = '';
$displayName = '';
$citationName = '';
$orcid = '';
$openalexId = '';
$position = '';
$sources = [];
if (is_array($crossrefAuthor)) {
$family = trim((string)($crossrefAuthor['family'] ?? ''));
$given = trim((string)($crossrefAuthor['given'] ?? ''));
$displayName = trim((string)($crossrefAuthor['display_name'] ?? ''));
$citationName = trim((string)($crossrefAuthor['citation_name'] ?? ''));
$orcid = trim((string)($crossrefAuthor['orcid'] ?? ''));
$position = trim((string)($crossrefAuthor['author_position'] ?? ''));
$sources[] = 'crossref';
}
if (is_array($openalexAuthor)) {
$openalexId = trim((string)($openalexAuthor['openalex_id'] ?? ''));
if (trim((string)($openalexAuthor['orcid'] ?? '')) !== '') {
$orcid = trim((string)$openalexAuthor['orcid']);
}
if (trim((string)($openalexAuthor['display_name'] ?? '')) !== '') {
$displayName = trim((string)$openalexAuthor['display_name']);
}
if (trim((string)($openalexAuthor['author_position'] ?? '')) !== '') {
$position = trim((string)$openalexAuthor['author_position']);
}
$sources[] = 'openalex';
}
if ($displayName === '' && ($family !== '' || $given !== '')) {
$displayName = trim($given . ' ' . $family);
}
if ($citationName === '' && is_array($crossrefAuthor) && !empty($crossrefAuthor['raw_author'])) {
$citationName = $this->crossref->getAuthorsCitation(['author' => [$crossrefAuthor['raw_author']]], 1);
}
if ($citationName === '' && $displayName !== '') {
$citationName = $displayName;
}
if ($displayName === '' && $citationName === '' && $orcid === '' && $openalexId === '') {
return null;
}
$sources = array_values(array_unique($sources));
$isFirst = ($position === 'first') || ($seq === 0 && $position === '');
return [
'author_seq' => intval($seq),
'author_position' => $position,
'is_first_author' => $isFirst ? 1 : 0,
'family' => $family,
'given' => $given,
'display_name' => $displayName,
'citation_name' => $citationName,
'orcid' => $orcid,
'openalex_id' => $openalexId,
'identity_source' => implode('+', $sources),
];
}
/**
* @return array<int, array>
*/
private function parseCrossrefAuthorList($authorList)
{
if (empty($authorList) || !is_array($authorList)) {
return [];
}
$parsed = $this->bgCheck->parseCrossRefAuthors($authorList);
$rows = [];
foreach ($parsed as $i => $item) {
$rawAuthor = $authorList[$i] ?? [];
if (!is_array($rawAuthor)) {
$rawAuthor = [];
}
$family = trim((string)($item['family'] ?? ''));
$given = trim((string)($item['given'] ?? ''));
$displayName = trim((string)($item['name'] ?? ''));
$position = trim((string)($rawAuthor['sequence'] ?? ''));
if ($position === '' && $i === 0) {
$position = 'first';
}
$rows[] = [
'family' => $family,
'given' => $given,
'display_name' => $displayName,
'orcid' => trim((string)($item['orcid'] ?? '')),
'author_position' => $position,
'citation_name' => $this->crossref->getAuthorsCitation(['author' => [$rawAuthor]], 1),
'raw_author' => $rawAuthor,
];
}
return $rows;
}
/**
* @return array<int, array{openalex_id:string,orcid:string,display_name:string,author_position:string}>
*/
private function fetchOpenAlexAuthorships($doi)
{
$res = $this->bgCheck->fetchOpenAlexWorkByDoi($doi);
if (empty($res['success']) || empty($res['work']) || !is_array($res['work'])) {
return [];
}
$list = [];
foreach ($res['work']['authorships'] ?? [] as $auth) {
if (!is_array($auth)) {
continue;
}
$author = is_array($auth['author'] ?? null) ? $auth['author'] : [];
$openalexId = $this->bgCheck->extractOpenAlexId($author['id'] ?? '');
$orcid = $this->bgCheck->cleanOrcid($author['orcid'] ?? '');
$displayName = trim((string)($author['display_name'] ?? ''));
if ($openalexId === '' && $orcid === '' && $displayName === '') {
continue;
}
$list[] = [
'openalex_id' => $openalexId,
'orcid' => $orcid,
'display_name' => $displayName,
'author_position' => (string)($auth['author_position'] ?? ''),
'is_corresponding' => !empty($auth['is_corresponding']),
];
}
return $list;
}
/**
* @return array<int, array{author_seq:int,author_position:string,is_first_author:int,display_name:string,citation_name:string}>
*/
private function parseReferAuthorString($authorString)
{
$authorString = trim((string)$authorString);
if ($authorString === '') {
return [];
}
$authorString = preg_replace('/\s+et\s+al\.?\s*$/iu', '', $authorString);
$parts = preg_split('/\s*,\s*/u', $authorString);
if (!is_array($parts)) {
return [];
}
$rows = [];
foreach ($parts as $i => $part) {
$name = trim((string)$part);
if ($name === '' || preg_match('/^et\s+al\.?$/iu', $name)) {
continue;
}
$seq = count($rows);
$rows[] = [
'author_seq' => $seq,
'author_position' => $seq === 0 ? 'first' : 'additional',
'is_first_author' => $seq === 0 ? 1 : 0,
'display_name' => $name,
'citation_name' => $name,
];
}
return $rows;
}
private function clipField($value, $maxLen)
{
$value = trim((string)$value);
if ($value === '' || $maxLen <= 0) {
return $value;
}
if (mb_strlen($value) <= $maxLen) {
return $value;
}
return mb_substr($value, 0, $maxLen);
}
}