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 */ 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 */ 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 */ 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, * 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 $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 */ 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 $referAuthors * @param array $manuscriptAuthors * @param array $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 $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 $referMap * @return array */ 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/"); } }