作者堆叠 专刊

This commit is contained in:
wyn
2026-07-29 17:05:42 +08:00
parent f583f67e24
commit 1ee795b6b9
10 changed files with 2833 additions and 141 deletions

View File

@@ -6,33 +6,16 @@ use think\Db;
/**
* 参考文献引用堆叠统计:同刊、同作者、自引(实时计算,不入库)
* 仅读本地库/字段,不请求 Crossref/OpenAlex避免网关超时。
*/
class ReferenceStackingStatsService
{
const DETAIL_JOURNAL = 'journal';
const DETAIL_AUTHOR = 'author';
const THRESHOLD_SAME_AUTHOR = 0.15;
const THRESHOLD_SAME_JOURNAL = 0.20;
const THRESHOLD_SELF_CITATION = 0.10;
/** @var ReferenceCheckService */
private $refUtil;
/** @var CrossrefService */
private $crossref;
/** @var ReferenceAuthorIdentityService */
private $identity;
public function __construct()
{
$this->refUtil = new ReferenceCheckService();
$this->crossref = new CrossrefService([
'mailto' => trim((string)\think\Env::get('crossref_mailto', '')),
]);
$this->identity = new ReferenceAuthorIdentityService();
}
const THRESHOLD_SAME_AUTHOR = 0.15;//相同作者;
const THRESHOLD_SAME_JOURNAL = 0.20;//同一期刊;
const THRESHOLD_SELF_CITATION = 0.10;//作者自引;
/**
* 按阈值规则实时统计:同作者(>15%)、同刊(>20%)、自引(>10%)
@@ -50,6 +33,44 @@ class ReferenceStackingStatsService
return $this->formatThresholdStackingReport($this->compute($pArticleId));
}
/**
* 堆叠摘要(展示用):同作者名称+比例、同刊名称+比例、自引比例
*
* @param int $pArticleId
* @return array
*/
public function getStackingSummaryByPArticleId($pArticleId)
{
$full = $this->getThresholdStackingByPArticleId($pArticleId);
$authors = [];
foreach ((array)(($full['same_author_stacking']['items'] ?? [])) as $item) {
$authors[] = [
'name' => (string)($item['author_name'] ?? ''),
'ratio' => round(floatval($item['cite_ratio'] ?? 0), 4),
];
}
$journals = [];
foreach ((array)(($full['same_journal_stacking']['items'] ?? [])) as $item) {
$journals[] = [
'name' => (string)($item['journal_name'] ?? ''),
'ratio' => round(floatval($item['cite_ratio'] ?? 0), 4),
];
}
$self = (array)($full['self_citation'] ?? []);
return [
'p_article_id' => intval($full['p_article_id'] ?? 0),
'total_references' => intval($full['total_references'] ?? 0),
'same_author' => $authors,
'same_journal' => $journals,
'self_citation_ratio' => round(floatval($self['cite_ratio'] ?? 0), 4),
'computed_at' => (string)($full['computed_at'] ?? ''),
];
}
/**
* @param array $full compute/analyze 或 getStored 的完整结果
*/
@@ -118,6 +139,11 @@ class ReferenceStackingStatsService
];
}
$authorDataIssues = $this->formatAuthorDataIssues(
(array)($full['author_data_issues'] ?? []),
$referMap
);
return [
'p_article_id' => intval($full['p_article_id'] ?? 0),
'article_id' => intval($full['article_id'] ?? 0),
@@ -141,6 +167,7 @@ class ReferenceStackingStatsService
'items' => $selfItems,
'note' => (string)($full['author_identity_note'] ?? ''),
],
'author_data_issues' => $authorDataIssues,
'computed_at' => (string)($full['computed_at'] ?? ''),
];
}
@@ -167,21 +194,22 @@ class ReferenceStackingStatsService
->order('index asc')
->select();
$manuscriptAuthors = $this->identity->resolveManuscriptAuthors($pArticleId);
$manuscriptAuthors = $this->loadManuscriptAuthorsLocal($pArticleId);
$ambiguousManuscriptNameKeys = $this->buildAmbiguousManuscriptNameKeys($manuscriptAuthors);
$doiCache = [];
$referAuthorRowsMap = $this->loadReferAuthorRowsByPArticleId($pArticleId);
$referMap = [];
$journalBuckets = [];
$authorBuckets = [];
$selfCitationDetails = [];
$authorDataIssues = [];
foreach ($refers as $refer) {
$refNo = intval($refer['index']) + 1;
$pReferId = intval($refer['p_refer_id']);
$referMap[$pReferId] = $refer;
$meta = $this->resolveReferMeta($refer, $doiCache);
$meta = $this->resolveReferMetaLocal($refer);
$joura = (string)$meta['joura'];
$journalKey = $this->normalizeJournalKey($joura);
if ($journalKey !== '') {
@@ -199,7 +227,14 @@ class ReferenceStackingStatsService
$journalBuckets[$journalKey]['p_refer_ids'][] = $pReferId;
}
$referAuthors = $this->resolveReferAuthorsWithMeta($pReferId, $meta);
$dbRows = isset($referAuthorRowsMap[$pReferId]) ? $referAuthorRowsMap[$pReferId] : [];
$resolved = $this->resolveReferAuthorsWithMetaDetailed($pReferId, $meta, $dbRows);
$referAuthors = $resolved['authors'];
$authorIssue = $this->buildAuthorDataIssue($refNo, $pReferId, $meta, $resolved);
if ($authorIssue !== null) {
$authorDataIssues[] = $authorIssue;
}
$this->accumulateAuthorBuckets($authorBuckets, $referAuthors, $refNo, $pReferId);
$matchedManuscript = $this->matchManuscriptAuthorForSelfCitation(
@@ -238,12 +273,61 @@ class ReferenceStackingStatsService
'journal_details' => $journalDetails,
'author_details' => $authorDetails,
'self_citation_details' => $selfDetails,
'author_data_issues' => $authorDataIssues,
'refer_map' => $referMap,
'author_identity_note' => '同作者堆叠按 citation_name空则 display_name姓名精确匹配同名即同人本文出现重名作者如两位 Lin时跳过该姓名的自引判定。优先读 t_production_article_refer_author否则解析 refer.author。',
'author_identity_note' => '同作者堆叠按 citation_name空则 display_name姓名精确匹配同名即同人本文出现重名作者如两位 Lin时跳过该姓名的自引判定。优先读 t_production_article_refer_author否则解析 refer.author。统计仅用本地数据,不实时请求 Crossref/OpenAlex。',
'computed_at' => date('Y-m-d H:i:s'),
];
}
/**
* 本文作者(仅本地库,不打 OpenAlex
*
* @return array<int, array{display_name:string,orcid:string}>
*/
private function loadManuscriptAuthorsLocal($pArticleId)
{
$rows = Db::name('production_article_author')
->field('first_name,last_name,author_name,orcid')
->where('p_article_id', intval($pArticleId))
->where('state', 0)
->select();
$list = [];
foreach ($rows as $row) {
$first = trim((string)($row['first_name'] ?? ''));
$last = trim((string)($row['last_name'] ?? ''));
$displayName = ($first !== '' && $last !== '')
? trim($first . ' ' . $last)
: trim((string)($row['author_name'] ?? ''));
$list[] = [
'display_name' => $displayName,
'orcid' => $this->cleanOrcid($row['orcid'] ?? ''),
];
}
return $list;
}
/**
* @return array<int, array>
*/
private function loadReferAuthorRowsByPArticleId($pArticleId)
{
$rows = Db::name('production_article_refer_author')
->where('p_article_id', intval($pArticleId))
->order('p_refer_id asc, author_seq asc, id asc')
->field('p_refer_id,display_name,citation_name,orcid')
->select();
$map = [];
foreach ($rows as $row) {
$map[intval($row['p_refer_id'])][] = $row;
}
return $map;
}
private function formatAuthorBucketDetails(array $buckets, $detailType)
{
$list = [];
@@ -280,23 +364,47 @@ class ReferenceStackingStatsService
/**
* @return array<int, array{name:string,orcid:string}>
*/
private function resolveReferAuthorsWithMeta($pReferId, array $meta)
private function resolveReferAuthorsWithMeta($pReferId, array $meta, array $dbRows = null)
{
return $this->resolveReferAuthorsWithMetaDetailed($pReferId, $meta, $dbRows)['authors'];
}
/**
* @param array|null $dbRows 预加载的 production_article_refer_author 行null 则按 p_refer_id 查询
* @return array{
* authors:array<int, array{name:string,orcid:string}>,
* source:string,
* db_row_count:int,
* db_usable_count:int,
* db_blank_name_count:int,
* has_et_al:bool
* }
*/
private function resolveReferAuthorsWithMetaDetailed($pReferId, array $meta, array $dbRows = null)
{
$pReferId = intval($pReferId);
$list = [];
$dbRowCount = 0;
$dbBlankNameCount = 0;
$authorString = (string)($meta['author'] ?? '');
$hasEtAl = (bool)preg_match('/\bet\s+al\.?\b/iu', $authorString);
if ($pReferId > 0) {
$rows = Db::name('production_article_refer_author')
->where('p_refer_id', $pReferId)
->order('author_seq asc, id asc')
->field('display_name,citation_name,orcid')
->select();
foreach ($rows as $row) {
if ($dbRows === null) {
$dbRows = Db::name('production_article_refer_author')
->where('p_refer_id', $pReferId)
->order('author_seq asc, id asc')
->field('display_name,citation_name,orcid')
->select();
}
$dbRowCount = count($dbRows);
foreach ($dbRows as $row) {
$name = trim((string)($row['citation_name'] ?? ''));
if ($name === '') {
$name = trim((string)($row['display_name'] ?? ''));
}
if ($name === '') {
$dbBlankNameCount++;
continue;
}
$list[] = [
@@ -307,17 +415,148 @@ class ReferenceStackingStatsService
}
if (!empty($list)) {
return $list;
return [
'authors' => $list,
'source' => 'refer_author_table',
'db_row_count' => $dbRowCount,
'db_usable_count' => count($list),
'db_blank_name_count' => $dbBlankNameCount,
'has_et_al' => $hasEtAl,
];
}
foreach ($this->parseAuthorStringParts((string)($meta['author'] ?? '')) as $name) {
foreach ($this->parseAuthorStringParts($authorString) as $name) {
$list[] = [
'name' => $name,
'orcid' => '',
];
}
return $list;
return [
'authors' => $list,
'source' => empty($list) ? 'none' : 'author_string',
'db_row_count' => $dbRowCount,
'db_usable_count' => 0,
'db_blank_name_count' => $dbBlankNameCount,
'has_et_al' => $hasEtAl,
];
}
/**
* @param array{
* authors:array,
* source:string,
* db_row_count:int,
* db_usable_count:int,
* db_blank_name_count:int,
* has_et_al:bool
* } $resolved
* @return array|null
*/
private function buildAuthorDataIssue($refNo, $pReferId, array $meta, array $resolved)
{
$authors = (array)($resolved['authors'] ?? []);
$authorCount = count($authors);
$source = (string)($resolved['source'] ?? 'none');
$dbRowCount = intval($resolved['db_row_count'] ?? 0);
$dbBlankNameCount = intval($resolved['db_blank_name_count'] ?? 0);
$hasEtAl = !empty($resolved['has_et_al']);
$rawAuthor = trim((string)($meta['author'] ?? ''));
if ($authorCount <= 0) {
$reason = '未获取到作者';
if ($dbRowCount > 0 && $dbBlankNameCount > 0) {
$reason = '作者明细表有记录但姓名均为空';
} elseif ($rawAuthor !== '') {
$reason = '作者字段无法解析出有效姓名';
}
return [
'issue_type' => 'missing',
'reason' => $reason,
'reference_no' => intval($refNo),
'p_refer_id' => intval($pReferId),
'author_source' => $source,
'author_count' => 0,
'raw_author' => $rawAuthor,
'meta_source' => (string)($meta['meta_source'] ?? ''),
];
}
$reasons = [];
if ($dbRowCount > 0 && $dbBlankNameCount > 0) {
$reasons[] = '部分作者姓名为空(空姓名 ' . $dbBlankNameCount . ' 条)';
}
// refer.author 常因引用格式截断带 et al.;明细表已有完整作者时不再判为不全
if ($hasEtAl && $source !== 'refer_author_table') {
$reasons[] = '作者列表含 et al.,可能不全';
}
if (empty($reasons)) {
return null;
}
return [
'issue_type' => 'incomplete',
'reason' => implode('', $reasons),
'reference_no' => intval($refNo),
'p_refer_id' => intval($pReferId),
'author_source' => $source,
'author_count' => $authorCount,
'raw_author' => $rawAuthor,
'meta_source' => (string)($meta['meta_source'] ?? ''),
];
}
/**
* @param array $issues
* @param array<int,array> $referMap
* @return array{
* missing_count:int,
* incomplete_count:int,
* missing_reference_nos:int[],
* incomplete_reference_nos:int[],
* items:array
* }
*/
private function formatAuthorDataIssues(array $issues, array $referMap)
{
$missing = [];
$incomplete = [];
$items = [];
usort($issues, function ($a, $b) {
return intval($a['reference_no'] ?? 0) <=> intval($b['reference_no'] ?? 0);
});
foreach ($issues as $issue) {
$pReferId = intval($issue['p_refer_id'] ?? 0);
$item = [
'issue_type' => (string)($issue['issue_type'] ?? ''),
'reason' => (string)($issue['reason'] ?? ''),
'reference_no' => intval($issue['reference_no'] ?? 0),
'p_refer_id' => $pReferId,
'author_source' => (string)($issue['author_source'] ?? ''),
'author_count' => intval($issue['author_count'] ?? 0),
'raw_author' => (string)($issue['raw_author'] ?? ''),
'meta_source' => (string)($issue['meta_source'] ?? ''),
'reference' => $this->buildReferBriefs([$pReferId], $referMap)[0] ?? null,
];
$items[] = $item;
if ($item['issue_type'] === 'missing') {
$missing[] = $item['reference_no'];
} elseif ($item['issue_type'] === 'incomplete') {
$incomplete[] = $item['reference_no'];
}
}
return [
'missing_count' => count($missing),
'incomplete_count' => count($incomplete),
'missing_reference_nos' => array_values($missing),
'incomplete_reference_nos' => array_values($incomplete),
'items' => $items,
];
}
/**
@@ -462,7 +701,7 @@ class ReferenceStackingStatsService
/**
* @param int[] $pReferIds
* @param array<int,array> $referMap
* @return array<int,array{p_refer_id:int,reference_no:int,refer_text:string}>
* @return array<int,array{p_refer_id:int,reference_no:int,refer_text:string,doi:string,url:string}>
*/
private function buildReferBriefs(array $pReferIds, array $referMap)
{
@@ -473,16 +712,62 @@ class ReferenceStackingStatsService
continue;
}
$refer = $referMap[$pReferId];
$doi = $this->extractReferDoi($refer);
$list[] = [
'p_refer_id' => $pReferId,
'reference_no' => intval($refer['index'] ?? 0) + 1,
'refer_text' => $this->referSnippet($refer),
'doi' => $doi,
'url' => $this->buildReferOpenUrl($refer, $doi),
];
}
return $list;
}
/**
* 从参考文献行提取 DOI去前缀
*/
private function extractReferDoi(array $refer)
{
foreach (['refer_doi', 'doilink'] as $field) {
$raw = trim((string)($refer[$field] ?? ''));
if ($raw === '') {
continue;
}
$raw = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', $raw);
$raw = trim($raw, " \t\n\r\0\x0B/");
if ($raw !== '' && stripos($raw, '10.') !== false) {
if (preg_match('#(10\.\d{4,9}/[^\s]+)#i', $raw, $m)) {
return rtrim($m[1], '.,;');
}
return $raw;
}
}
return '';
}
/**
* 可跳转打开的文献链接:优先 DOI其次 doilink 若已是 URL
*/
private function buildReferOpenUrl(array $refer, $doi = '')
{
$doi = trim((string)$doi);
if ($doi === '') {
$doi = $this->extractReferDoi($refer);
}
if ($doi !== '') {
return 'https://doi.org/' . $doi;
}
$doilink = trim((string)($refer['doilink'] ?? ''));
if ($doilink !== '' && preg_match('#^https?://#i', $doilink)) {
return $doilink;
}
return '';
}
private function loadReferMapByPArticleId($pArticleId)
{
$pArticleId = intval($pArticleId);
@@ -540,7 +825,7 @@ class ReferenceStackingStatsService
}
/**
* 合并 refer 行已有字段、Crossref、refer_frag/refer_content 解析结果
* 仅用本地字段 + refer_frag/refer_content 解析,不请求 Crossref
*
* @return array{
* author:string,
@@ -551,7 +836,7 @@ class ReferenceStackingStatsService
* resolved:bool
* }
*/
private function resolveReferMeta(array $refer, array &$doiCache)
private function resolveReferMetaLocal(array $refer)
{
$author = trim(trim((string)($refer['author'] ?? '')), '.');
$joura = trim(trim((string)($refer['joura'] ?? '')), '.');
@@ -563,38 +848,6 @@ class ReferenceStackingStatsService
$authorKeys = $this->extractAuthorKeysFromAuthorString($author);
}
$doi = $this->refUtil->extractDoiFromRefer($refer);
$summary = null;
if ($doi !== '') {
if (!array_key_exists($doi, $doiCache)) {
try {
$doiCache[$doi] = $this->crossref->fetchWorkSummary($doi);
} catch (\Throwable $e) {
$doiCache[$doi] = null;
}
}
$summary = $doiCache[$doi];
}
if (is_array($summary)) {
$sources[] = 'crossref';
if ($joura === '' && trim((string)($summary['joura'] ?? '')) !== '') {
$joura = trim((string)$summary['joura']);
}
$crossrefKeys = $this->authorKeysFromCrossrefMessage($summary['raw'] ?? []);
if ($author === '') {
$citationAuthor = $this->crossref->getAuthorsCitation($summary['raw'] ?? [], 3);
if ($citationAuthor !== '') {
$author = $citationAuthor;
}
$authorKeys = !empty($crossrefKeys)
? $crossrefKeys
: $this->extractAuthorKeysFromAuthorString($author);
} elseif (!empty($crossrefKeys)) {
$authorKeys = array_values(array_unique(array_merge($authorKeys, $crossrefKeys)));
}
}
if ($joura === '' || $author === '') {
$fragParsed = $this->parseStructuredReferText($refer);
if (is_array($fragParsed)) {
@@ -668,37 +921,6 @@ class ReferenceStackingStatsService
return null;
}
/**
* @return string[]
*/
private function authorKeysFromCrossrefMessage(array $message)
{
$keys = [];
if (empty($message['author']) || !is_array($message['author'])) {
return $keys;
}
foreach ($message['author'] as $author) {
if (!is_array($author)) {
continue;
}
$family = trim((string)($author['family'] ?? ''));
$given = trim((string)($author['given'] ?? ''));
if ($family === '' && $given === '') {
$org = trim((string)($author['name'] ?? ''));
if ($org !== '') {
$keys[] = $this->authorKeyFromCitationPart($org);
}
continue;
}
if ($family !== '') {
$keys[] = mb_strtoupper($family) . '|' . $this->givenToInitials($given);
}
}
return array_values(array_unique(array_filter($keys)));
}
private function referSnippet(array $refer)
{
foreach (['refer_content', 'refer_frag'] as $field) {
@@ -805,23 +1027,6 @@ class ReferenceStackingStatsService
return $key;
}
private function givenToInitials($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 cleanOrcid($orcid)
{
$orcid = trim((string)$orcid);