1040 lines
37 KiB
PHP
1040 lines
37 KiB
PHP
<?php
|
||
|
||
namespace app\common;
|
||
|
||
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;//作者自引;
|
||
|
||
/**
|
||
* 按阈值规则实时统计:同作者(>15%)、同刊(>20%)、自引(>10%)
|
||
*
|
||
* @param int $pArticleId
|
||
* @return array
|
||
*/
|
||
public function getThresholdStackingByPArticleId($pArticleId)
|
||
{
|
||
$pArticleId = intval($pArticleId);
|
||
if ($pArticleId <= 0) {
|
||
throw new \InvalidArgumentException('p_article_id is required');
|
||
}
|
||
|
||
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 的完整结果
|
||
*/
|
||
public function formatThresholdStackingReport(array $full)
|
||
{
|
||
$total = max(1, intval($full['total_references'] ?? 0));
|
||
$referMap = (array)($full['refer_map'] ?? []);
|
||
if (empty($referMap) && intval($full['p_article_id'] ?? 0) > 0) {
|
||
$referMap = $this->loadReferMapByPArticleId(intval($full['p_article_id']));
|
||
}
|
||
|
||
$authorItems = [];
|
||
foreach ((array)($full['author_details'] ?? []) as $item) {
|
||
$count = intval($item['ref_count'] ?? 0);
|
||
$ratio = $count / $total;
|
||
if ($ratio <= self::THRESHOLD_SAME_AUTHOR) {
|
||
continue;
|
||
}
|
||
$pReferIds = array_values((array)($item['p_refer_ids'] ?? []));
|
||
$authorItems[] = [
|
||
'author_name' => (string)($item['group_name'] ?? ''),
|
||
'orcid' => (string)($item['orcid'] ?? ''),
|
||
'cite_count' => $count,
|
||
'cite_ratio' => round($ratio, 4),
|
||
'threshold' => self::THRESHOLD_SAME_AUTHOR,
|
||
'exceeded' => true,
|
||
'p_refer_ids' => $pReferIds,
|
||
'reference_nos' => array_values((array)($item['reference_nos'] ?? [])),
|
||
'references' => $this->buildReferBriefs($pReferIds, $referMap),
|
||
];
|
||
}
|
||
|
||
$journalItems = [];
|
||
foreach ((array)($full['journal_details'] ?? []) as $item) {
|
||
$count = intval($item['ref_count'] ?? 0);
|
||
$ratio = $count / $total;
|
||
if ($ratio <= self::THRESHOLD_SAME_JOURNAL) {
|
||
continue;
|
||
}
|
||
$pReferIds = array_values((array)($item['p_refer_ids'] ?? []));
|
||
$journalItems[] = [
|
||
'journal_name' => (string)($item['group_name'] ?? ''),
|
||
'cite_count' => $count,
|
||
'cite_ratio' => round($ratio, 4),
|
||
'threshold' => self::THRESHOLD_SAME_JOURNAL,
|
||
'exceeded' => true,
|
||
'p_refer_ids' => $pReferIds,
|
||
'reference_nos' => array_values((array)($item['reference_nos'] ?? [])),
|
||
'references' => $this->buildReferBriefs($pReferIds, $referMap),
|
||
];
|
||
}
|
||
|
||
$selfDetails = (array)($full['self_citation_details'] ?? []);
|
||
$selfCount = count($selfDetails);
|
||
$selfRatio = $selfCount / $total;
|
||
$selfItems = [];
|
||
foreach ($selfDetails as $item) {
|
||
$pReferId = intval($item['p_refer_id'] ?? 0);
|
||
$selfItems[] = [
|
||
'manuscript_author' => (string)($item['matched_manuscript_author'] ?? ''),
|
||
'manuscript_orcid' => (string)($item['matched_orcid'] ?? ''),
|
||
'matched_refer_author' => (string)($item['matched_refer_author'] ?? ''),
|
||
'reference_no' => intval($item['reference_no'] ?? 0),
|
||
'p_refer_id' => $pReferId,
|
||
'reference' => $this->buildReferBriefs([$pReferId], $referMap)[0] ?? null,
|
||
];
|
||
}
|
||
|
||
$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),
|
||
'total_references' => intval($full['total_references'] ?? 0),
|
||
'same_author_stacking' => [
|
||
'threshold' => self::THRESHOLD_SAME_AUTHOR,
|
||
'exceeded' => !empty($authorItems),
|
||
'items' => $authorItems,
|
||
],
|
||
'same_journal_stacking' => [
|
||
'threshold' => self::THRESHOLD_SAME_JOURNAL,
|
||
'exceeded' => !empty($journalItems),
|
||
'items' => $journalItems,
|
||
],
|
||
'self_citation' => [
|
||
'threshold' => self::THRESHOLD_SELF_CITATION,
|
||
'exceeded' => $selfRatio > self::THRESHOLD_SELF_CITATION,
|
||
'cite_count' => $selfCount,
|
||
'cite_ratio' => round($selfRatio, 4),
|
||
'reference_nos' => array_values((array)($full['self_citation_reference_nos'] ?? [])),
|
||
'items' => $selfItems,
|
||
'note' => (string)($full['author_identity_note'] ?? ''),
|
||
],
|
||
'author_data_issues' => $authorDataIssues,
|
||
'computed_at' => (string)($full['computed_at'] ?? ''),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param int $pArticleId
|
||
* @return array
|
||
*/
|
||
public function compute($pArticleId)
|
||
{
|
||
$pArticleId = intval($pArticleId);
|
||
if ($pArticleId <= 0) {
|
||
throw new \InvalidArgumentException('p_article_id is required');
|
||
}
|
||
|
||
DbReconnectHelper::release();
|
||
|
||
$articleId = $this->resolveArticleId($pArticleId);
|
||
|
||
$refers = Db::name('production_article_refer')
|
||
->field('p_refer_id,index,author,joura,refer_type,refer_doi,doilink,refer_content,refer_frag')
|
||
->where('p_article_id', $pArticleId)
|
||
->where('state', 0)
|
||
->order('index asc')
|
||
->select();
|
||
|
||
$manuscriptAuthors = $this->loadManuscriptAuthorsLocal($pArticleId);
|
||
$ambiguousManuscriptNameKeys = $this->buildAmbiguousManuscriptNameKeys($manuscriptAuthors);
|
||
$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->resolveReferMetaLocal($refer);
|
||
$joura = (string)$meta['joura'];
|
||
$journalKey = $this->normalizeJournalKey($joura);
|
||
if ($journalKey !== '') {
|
||
if (!isset($journalBuckets[$journalKey])) {
|
||
$journalBuckets[$journalKey] = [
|
||
'group_key' => $journalKey,
|
||
'name' => trim(trim($joura), '.'),
|
||
'count' => 0,
|
||
'reference_nos' => [],
|
||
'p_refer_ids' => [],
|
||
];
|
||
}
|
||
$journalBuckets[$journalKey]['count']++;
|
||
$journalBuckets[$journalKey]['reference_nos'][] = $refNo;
|
||
$journalBuckets[$journalKey]['p_refer_ids'][] = $pReferId;
|
||
}
|
||
|
||
$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(
|
||
$referAuthors,
|
||
$manuscriptAuthors,
|
||
$ambiguousManuscriptNameKeys
|
||
);
|
||
if ($matchedManuscript !== null) {
|
||
$referAuthorNames = array_map(function ($ra) {
|
||
return (string)($ra['name'] ?? '');
|
||
}, $referAuthors);
|
||
$selfCitationDetails[] = [
|
||
'reference_no' => $refNo,
|
||
'p_refer_id' => $pReferId,
|
||
'refer_author' => implode(', ', array_filter($referAuthorNames)),
|
||
'matched_refer_author' => (string)($matchedManuscript['matched_refer_author'] ?? ''),
|
||
'matched_manuscript_author' => (string)($matchedManuscript['display_name'] ?? ''),
|
||
'matched_orcid' => (string)($matchedManuscript['orcid'] ?? ''),
|
||
'match_confidence' => ReferenceAuthorIdentityService::MATCH_FUZZY,
|
||
'meta_source' => (string)$meta['meta_source'],
|
||
];
|
||
}
|
||
}
|
||
|
||
$journalDetails = $this->formatBucketDetails($journalBuckets, self::DETAIL_JOURNAL);
|
||
$authorDetails = $this->formatAuthorBucketDetails($authorBuckets, self::DETAIL_AUTHOR);
|
||
$selfDetails = $this->formatSelfCitationDetails($selfCitationDetails);
|
||
|
||
$selfCitationRefNos = array_column($selfCitationDetails, 'reference_no');
|
||
|
||
return [
|
||
'article_id' => $articleId,
|
||
'p_article_id' => $pArticleId,
|
||
'total_references' => count($refers),
|
||
'self_citation_reference_nos' => array_values(array_unique($selfCitationRefNos)),
|
||
'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。统计仅用本地数据,不实时请求 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 = [];
|
||
foreach ($buckets as $bucket) {
|
||
$item = [
|
||
'detail_type' => $detailType,
|
||
'group_key' => (string)($bucket['group_key'] ?? ''),
|
||
'group_name' => (string)($bucket['name'] ?? ''),
|
||
'ref_count' => intval($bucket['count'] ?? 0),
|
||
'reference_nos' => array_values((array)($bucket['reference_nos'] ?? [])),
|
||
'p_refer_ids' => array_values((array)($bucket['p_refer_ids'] ?? [])),
|
||
'match_confidence' => (string)($bucket['match_confidence'] ?? ''),
|
||
];
|
||
if (!empty($bucket['openalex_id'])) {
|
||
$item['openalex_id'] = (string)$bucket['openalex_id'];
|
||
}
|
||
if (!empty($bucket['orcid'])) {
|
||
$item['orcid'] = (string)$bucket['orcid'];
|
||
}
|
||
$list[] = $item;
|
||
}
|
||
|
||
usort($list, function ($a, $b) {
|
||
$cmp = intval($b['ref_count']) <=> intval($a['ref_count']);
|
||
if ($cmp !== 0) {
|
||
return $cmp;
|
||
}
|
||
return strcmp((string)$a['group_name'], (string)$b['group_name']);
|
||
});
|
||
|
||
return $list;
|
||
}
|
||
|
||
/**
|
||
* @return array<int, array{name:string,orcid:string}>
|
||
*/
|
||
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) {
|
||
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[] = [
|
||
'name' => $name,
|
||
'orcid' => $this->cleanOrcid($row['orcid'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
|
||
if (!empty($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($authorString) as $name) {
|
||
$list[] = [
|
||
'name' => $name,
|
||
'orcid' => '',
|
||
];
|
||
}
|
||
|
||
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,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return string[]
|
||
*/
|
||
private function parseAuthorStringParts($authorString)
|
||
{
|
||
$authorString = trim(trim((string)$authorString), '.');
|
||
if ($authorString === '') {
|
||
return [];
|
||
}
|
||
|
||
$authorString = preg_replace('/\s+et\s+al\.?\s*$/iu', '', $authorString);
|
||
$names = [];
|
||
foreach (preg_split('/,\s*/u', $authorString) as $part) {
|
||
$part = trim($part);
|
||
if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) {
|
||
continue;
|
||
}
|
||
$names[] = $part;
|
||
}
|
||
|
||
return $names;
|
||
}
|
||
|
||
/**
|
||
* 本文多位作者姓名归一化后相同(如两位 Lin)则视为歧义,不参与自引匹配
|
||
*
|
||
* @return array<string,bool>
|
||
*/
|
||
private function buildAmbiguousManuscriptNameKeys(array $manuscriptAuthors)
|
||
{
|
||
$counts = [];
|
||
foreach ($manuscriptAuthors as $author) {
|
||
$key = $this->normalizeAuthorNameKey((string)($author['display_name'] ?? ''));
|
||
if ($key === '') {
|
||
continue;
|
||
}
|
||
if (!isset($counts[$key])) {
|
||
$counts[$key] = 0;
|
||
}
|
||
$counts[$key]++;
|
||
}
|
||
|
||
$ambiguous = [];
|
||
foreach ($counts as $key => $count) {
|
||
if ($count > 1) {
|
||
$ambiguous[$key] = true;
|
||
}
|
||
}
|
||
|
||
return $ambiguous;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array{name:string,orcid:string}> $referAuthors
|
||
* @param array<int, array{display_name:string,orcid:string}> $manuscriptAuthors
|
||
* @param array<string,bool> $ambiguousNameKeys
|
||
* @return array{display_name:string,orcid:string,matched_refer_author:string}|null
|
||
*/
|
||
private function matchManuscriptAuthorForSelfCitation(array $referAuthors, array $manuscriptAuthors, array $ambiguousNameKeys)
|
||
{
|
||
foreach ($referAuthors as $referAuthor) {
|
||
$referName = trim((string)($referAuthor['name'] ?? ''));
|
||
$referKey = $this->normalizeAuthorNameKey($referName);
|
||
if ($referKey === '' || !empty($ambiguousNameKeys[$referKey])) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($manuscriptAuthors as $manuscriptAuthor) {
|
||
$manuscriptName = trim((string)($manuscriptAuthor['display_name'] ?? ''));
|
||
$manuscriptKey = $this->normalizeAuthorNameKey($manuscriptName);
|
||
if ($manuscriptKey !== '' && $manuscriptKey === $referKey) {
|
||
return [
|
||
'display_name' => $manuscriptName,
|
||
'orcid' => trim((string)($manuscriptAuthor['orcid'] ?? '')),
|
||
'matched_refer_author' => $referName,
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array{name:string,orcid:string}> $referAuthors
|
||
*/
|
||
private function accumulateAuthorBuckets(array &$buckets, array $referAuthors, $refNo, $pReferId)
|
||
{
|
||
if (empty($referAuthors)) {
|
||
return;
|
||
}
|
||
|
||
$seenKeys = [];
|
||
foreach ($referAuthors as $author) {
|
||
$name = trim((string)($author['name'] ?? ''));
|
||
if ($name === '' || preg_match('/^et\s+al\.?$/iu', $name)) {
|
||
continue;
|
||
}
|
||
|
||
$key = $this->normalizeAuthorNameKey($name);
|
||
if ($key === '' || isset($seenKeys[$key])) {
|
||
continue;
|
||
}
|
||
$seenKeys[$key] = true;
|
||
|
||
if (!isset($buckets[$key])) {
|
||
$buckets[$key] = [
|
||
'group_key' => $key,
|
||
'name' => $name,
|
||
'orcid' => '',
|
||
'match_confidence' => ReferenceAuthorIdentityService::MATCH_FUZZY,
|
||
'count' => 0,
|
||
'reference_nos' => [],
|
||
'p_refer_ids' => [],
|
||
];
|
||
}
|
||
|
||
$orcid = trim((string)($author['orcid'] ?? ''));
|
||
if ($orcid !== '' && trim((string)($buckets[$key]['orcid'] ?? '')) === '') {
|
||
$buckets[$key]['orcid'] = $orcid;
|
||
}
|
||
|
||
$buckets[$key]['count']++;
|
||
$buckets[$key]['reference_nos'][] = $refNo;
|
||
$buckets[$key]['p_refer_ids'][] = $pReferId;
|
||
}
|
||
}
|
||
|
||
private function normalizeAuthorNameKey($name)
|
||
{
|
||
$name = trim(preg_replace('/\.+$/u', '', trim((string)$name)));
|
||
$name = preg_replace('/\s+/u', ' ', $name);
|
||
if ($name === '') {
|
||
return '';
|
||
}
|
||
|
||
return mb_strtolower($name);
|
||
}
|
||
|
||
/**
|
||
* @param int[] $pReferIds
|
||
* @param array<int,array> $referMap
|
||
* @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)
|
||
{
|
||
$list = [];
|
||
foreach ($pReferIds as $pReferId) {
|
||
$pReferId = intval($pReferId);
|
||
if ($pReferId <= 0 || empty($referMap[$pReferId])) {
|
||
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);
|
||
if ($pArticleId <= 0) {
|
||
return [];
|
||
}
|
||
|
||
DbReconnectHelper::release();
|
||
$refers = Db::name('production_article_refer')
|
||
->field('p_refer_id,index,author,joura,refer_type,refer_doi,doilink,refer_content,refer_frag')
|
||
->where('p_article_id', $pArticleId)
|
||
->where('state', 0)
|
||
->order('index asc')
|
||
->select();
|
||
|
||
$map = [];
|
||
foreach ($refers as $refer) {
|
||
$map[intval($refer['p_refer_id'])] = $refer;
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
private function formatBucketDetails(array $buckets, $detailType)
|
||
{
|
||
$list = [];
|
||
foreach ($buckets as $bucket) {
|
||
$list[] = [
|
||
'detail_type' => $detailType,
|
||
'group_key' => (string)($bucket['group_key'] ?? ''),
|
||
'group_name' => (string)($bucket['name'] ?? ''),
|
||
'ref_count' => intval($bucket['count'] ?? 0),
|
||
'reference_nos' => array_values((array)($bucket['reference_nos'] ?? [])),
|
||
'p_refer_ids' => array_values((array)($bucket['p_refer_ids'] ?? [])),
|
||
];
|
||
}
|
||
|
||
usort($list, function ($a, $b) {
|
||
$cmp = intval($b['ref_count']) <=> intval($a['ref_count']);
|
||
if ($cmp !== 0) {
|
||
return $cmp;
|
||
}
|
||
return strcmp((string)$a['group_name'], (string)$b['group_name']);
|
||
});
|
||
|
||
return $list;
|
||
}
|
||
|
||
private function formatSelfCitationDetails(array $items)
|
||
{
|
||
usort($items, function ($a, $b) {
|
||
return intval($a['reference_no']) <=> intval($b['reference_no']);
|
||
});
|
||
return $items;
|
||
}
|
||
|
||
/**
|
||
* 仅用本地字段 + refer_frag/refer_content 解析,不请求 Crossref
|
||
*
|
||
* @return array{
|
||
* author:string,
|
||
* joura:string,
|
||
* author_keys:string[],
|
||
* first_author_display:string,
|
||
* meta_source:string,
|
||
* resolved:bool
|
||
* }
|
||
*/
|
||
private function resolveReferMetaLocal(array $refer)
|
||
{
|
||
$author = trim(trim((string)($refer['author'] ?? '')), '.');
|
||
$joura = trim(trim((string)($refer['joura'] ?? '')), '.');
|
||
$sources = [];
|
||
$authorKeys = [];
|
||
|
||
if ($author !== '') {
|
||
$sources[] = 'local';
|
||
$authorKeys = $this->extractAuthorKeysFromAuthorString($author);
|
||
}
|
||
|
||
if ($joura === '' || $author === '') {
|
||
$fragParsed = $this->parseStructuredReferText($refer);
|
||
if (is_array($fragParsed)) {
|
||
$sources[] = 'frag';
|
||
if ($joura === '' && trim((string)($fragParsed['joura'] ?? '')) !== '') {
|
||
$joura = trim((string)$fragParsed['joura']);
|
||
}
|
||
if ($author === '' && trim((string)($fragParsed['author'] ?? '')) !== '') {
|
||
$author = trim((string)$fragParsed['author']);
|
||
$authorKeys = $this->extractAuthorKeysFromAuthorString($author);
|
||
}
|
||
}
|
||
}
|
||
|
||
$sources = array_values(array_unique($sources));
|
||
$metaSource = empty($sources) ? 'unresolved' : implode('+', $sources);
|
||
$resolved = ($joura !== '' || !empty($authorKeys));
|
||
|
||
return [
|
||
'author' => $author,
|
||
'joura' => $joura,
|
||
'author_keys' => array_values(array_unique($authorKeys)),
|
||
'first_author_display' => $this->firstAuthorDisplayFromAuthorString($author),
|
||
'meta_source' => $metaSource,
|
||
'resolved' => $resolved,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 解析 refer_frag / refer_content 中「作者.标题.期刊.年卷页」四段式结构
|
||
*
|
||
* @return array{author:string,joura:string}|null
|
||
*/
|
||
private function parseStructuredReferText(array $refer)
|
||
{
|
||
foreach (['refer_frag', 'refer_content'] as $field) {
|
||
$text = trim((string)($refer[$field] ?? ''));
|
||
if ($text === '') {
|
||
continue;
|
||
}
|
||
$text = preg_replace('/\s+Available at:.*$/is', '', $text);
|
||
$text = trim($text, " \t\n\r\0\x0B.");
|
||
if ($text === '' || mb_substr_count($text, '.') !== 3) {
|
||
continue;
|
||
}
|
||
|
||
$parts = explode('.', $text);
|
||
if (count($parts) < 4) {
|
||
continue;
|
||
}
|
||
|
||
$authorPart = trim((string)$parts[0]);
|
||
$journalPart = trim((string)$parts[2]);
|
||
if ($authorPart === '' || $journalPart === '') {
|
||
continue;
|
||
}
|
||
|
||
$bj = bekjournal($journalPart);
|
||
$joura = formateJournal(trim((string)($bj[0] ?? '')));
|
||
$author = trim(prgeAuthor($authorPart), '.');
|
||
if ($joura === '' && $author === '') {
|
||
continue;
|
||
}
|
||
|
||
return [
|
||
'author' => $author,
|
||
'joura' => $joura,
|
||
];
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function referSnippet(array $refer)
|
||
{
|
||
foreach (['refer_content', 'refer_frag'] as $field) {
|
||
$text = trim((string)($refer[$field] ?? ''));
|
||
if ($text !== '') {
|
||
$text = preg_replace('/\s+/u', ' ', $text);
|
||
return mb_substr($text, 0, 240);
|
||
}
|
||
}
|
||
$doi = trim((string)($refer['refer_doi'] ?? ''));
|
||
if ($doi !== '') {
|
||
return 'DOI: ' . $doi;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
private function resolveArticleId($pArticleId)
|
||
{
|
||
$row = Db::name('production_article')
|
||
->field('article_id')
|
||
->where('p_article_id', $pArticleId)
|
||
->whereIn('state', [0, 2])
|
||
->find();
|
||
|
||
return empty($row['article_id']) ? 0 : intval($row['article_id']);
|
||
}
|
||
|
||
/**
|
||
* @return string[]
|
||
*/
|
||
private function extractAuthorKeysFromAuthorString($author)
|
||
{
|
||
$author = trim(trim((string)$author), '.');
|
||
if ($author === '') {
|
||
return [];
|
||
}
|
||
|
||
$keys = [];
|
||
foreach (preg_split('/,\s*/u', $author) as $part) {
|
||
$part = trim($part);
|
||
if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) {
|
||
continue;
|
||
}
|
||
$key = $this->authorKeyFromCitationPart($part);
|
||
if ($key !== '') {
|
||
$keys[] = $key;
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique($keys));
|
||
}
|
||
|
||
private function firstAuthorDisplayFromAuthorString($author)
|
||
{
|
||
$author = trim(trim((string)$author), '.');
|
||
if ($author === '') {
|
||
return '';
|
||
}
|
||
$parts = preg_split('/,\s*/u', $author);
|
||
$first = trim((string)($parts[0] ?? ''));
|
||
if (preg_match('/^et\s+al\.?$/iu', $first)) {
|
||
return '';
|
||
}
|
||
return $first;
|
||
}
|
||
|
||
private function authorKeyFromCitationPart($part)
|
||
{
|
||
$part = trim(preg_replace('/\.+$/u', '', trim((string)$part)));
|
||
if ($part === '') {
|
||
return '';
|
||
}
|
||
|
||
$tokens = preg_split('/\s+/u', $part, -1, PREG_SPLIT_NO_EMPTY);
|
||
if (count($tokens) === 1) {
|
||
return mb_strtoupper($tokens[0]) . '|';
|
||
}
|
||
|
||
$last = array_pop($tokens);
|
||
if (preg_match('/^[A-Za-z]{1,4}$/u', $last)) {
|
||
$family = implode(' ', $tokens);
|
||
return 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 mb_strtoupper($family) . '|' . $initials;
|
||
}
|
||
|
||
private function normalizeJournalKey($joura)
|
||
{
|
||
$joura = trim(trim((string)$joura), '.');
|
||
if ($joura === '') {
|
||
return '';
|
||
}
|
||
|
||
$mapped = formateJournal($joura);
|
||
$key = mb_strtolower($mapped);
|
||
$key = preg_replace('/[^\p{L}\p{N}\s]/u', '', $key);
|
||
$key = preg_replace('/\s+/u', ' ', trim($key));
|
||
return $key;
|
||
}
|
||
|
||
private function cleanOrcid($orcid)
|
||
{
|
||
$orcid = trim((string)$orcid);
|
||
if ($orcid === '') {
|
||
return '';
|
||
}
|
||
$orcid = preg_replace('#^https?://orcid\.org/#i', '', $orcid);
|
||
return trim($orcid, " \t\n\r\0\x0B/");
|
||
}
|
||
}
|