Files
tougao/application/common/ReferenceStackingStatsService.php
2026-07-29 17:05:42 +08:00

1040 lines
37 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;
/**
* 参考文献引用堆叠统计:同刊、同作者、自引(实时计算,不入库)
* 仅读本地库/字段,不请求 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/");
}
}