参考文献作者堆叠

参考文献相关性检测
作者ai写作辅助检测工作
This commit is contained in:
wyn
2026-07-15 10:49:05 +08:00
parent da71dfc04e
commit 8785610e6d
27 changed files with 10008 additions and 204 deletions

View File

@@ -32,10 +32,9 @@ class ArticleParserService
// $this->log("✅ 文档直接加载成功,节数量:{$sectionCount}");
$this->phpWord = $reader->load($filePath);
$this->sections = $this->phpWord->getSections();
} catch (\Exception $e) {
// 预处理:移除 DOCX 中的 EMF 图片
} catch (\Throwable $e) {
// 预处理:移除 EMF、表格内分页符等 PhpWord 不兼容内容后重试
$processedFilePath = $this->removeEmfFromDocx($filePath);
// 加载处理后的文档
$reader = IOFactory::createReader();
$reader->setReadDataOnly(false);
Settings::setCompatibility(false);
@@ -44,9 +43,9 @@ class ArticleParserService
$this->phpWord = $reader->load($processedFilePath);
$this->sections = $this->phpWord->getSections();
// 可选:删除临时处理文件(避免冗余)
unlink($processedFilePath);
return json_encode(['status' => 5, 'msg' => $e->getMessage()]);
if (is_file($processedFilePath)) {
@unlink($processedFilePath);
}
}
}
/**
@@ -77,6 +76,10 @@ class ArticleParserService
unlink($file->getPathname());
}
}
// 3.1 清理表格单元格内分页符PhpWord 无法解析,会抛 Cannot add PageBreak in Cell
$this->sanitizePageBreaksInDocxXmlFiles($tempDir);
// 4. 重新打包为 DOCX
$processedPath = $tempDir . '_processed.docx';
$newZip = new ZipArchive();
@@ -94,6 +97,76 @@ class ArticleParserService
return $processedPath;
}
/**
* 移除 word/*.xml 中表格单元格里的分页符,避免 PhpWord 读取失败
*/
private function sanitizePageBreaksInDocxXmlFiles($tempDir)
{
$wordDir = rtrim($tempDir, '/\\') . '/word';
if (!is_dir($wordDir)) {
return;
}
$xmlFiles = glob($wordDir . '/*.xml');
if (empty($xmlFiles)) {
return;
}
foreach ($xmlFiles as $xmlPath) {
$this->sanitizePageBreaksInWordXmlFile($xmlPath);
}
}
/**
* @param string $xmlPath
*/
private function sanitizePageBreaksInWordXmlFile($xmlPath)
{
if (!is_file($xmlPath) || !is_readable($xmlPath)) {
return;
}
$xml = file_get_contents($xmlPath);
if ($xml === false || trim($xml) === '') {
return;
}
$dom = new DOMDocument();
$dom->preserveWhiteSpace = true;
$dom->formatOutput = false;
if (@$dom->loadXML($xml) === false) {
return;
}
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
$queries = [
'//w:tc//w:br[@w:type="page"]',
'//w:tc//w:lastRenderedPageBreak',
'//w:tc//w:pPr/w:pageBreakBefore',
];
$removed = false;
foreach ($queries as $query) {
$nodes = $xpath->query($query);
if (!$nodes || $nodes->length === 0) {
continue;
}
for ($i = $nodes->length - 1; $i >= 0; $i--) {
$node = $nodes->item($i);
if ($node && $node->parentNode) {
$node->parentNode->removeChild($node);
$removed = true;
}
}
}
if ($removed) {
file_put_contents($xmlPath, $dom->saveXML());
}
}
/**
* 递归添加目录文件到 ZipArchive
* @param string $dir 目录路径
@@ -837,13 +910,18 @@ class ArticleParserService
$text .= $textPart;
}
// 处理超链接(逻辑不变,保持邮箱优先提取
// 处理超链接(PhpWord 用 getSource旧版曾用 getTarget
if ($element instanceof \PhpOffice\PhpWord\Element\Link) {
$target = (string)$element->getTarget();
if (strpos($target, 'mailto:') === 0) {
$target = '';
if (method_exists($element, 'getSource')) {
$target = (string) $element->getSource();
} elseif (method_exists($element, 'getTarget')) {
$target = (string) $element->getTarget();
}
if ($target !== '' && strpos($target, 'mailto:') === 0) {
$text .= rtrim(str_replace('mailto:', '', $target)) . ' ';
}
$linkText = strtr((string)$element->getText(), $specialQuotesMap);
$linkText = strtr((string) $element->getText(), $specialQuotesMap);
$text .= $linkText . ' ';
}
@@ -1059,19 +1137,29 @@ class ArticleParserService
$result['positions']['abstract'] = $absPos;
$absEndPos = $absPos + strlen($abstract);
// 4. 定位 Keywords需在 Abstract 之后,不区分大小写
$keyPos = stripos($str, $keywords, $absEndPos);
// 4. 定位 Keywords需在 Abstract 之后,兼容 Key words / 关键词
$keyPos = false;
$keyEndPos = 0;
foreach (['keywords', 'key words', '关键词'] as $marker) {
$pos = stripos($str, $marker, $absEndPos);
if ($pos === false) {
continue;
}
if ($keyPos === false || $pos < $keyPos) {
$keyPos = $pos;
$keyEndPos = $pos + strlen($marker);
}
}
if ($keyPos === false) {
$result['error'] = "未找到 {$keywords} 或在 {$abstract} 之前";
$result['error'] = "未找到 keywords 或在 {$abstract} 之前";
return $result;
}
$result['positions']['keywords'] = $keyPos;
$keyEndPos = $keyPos + strlen($keywords);
// 5. 定位 end-span需在 Keywords 之后,严格匹配)
$endPos = strpos($str, $end_span, $keyEndPos);
if ($endPos === false) {
$result['error'] = "未找到 {$end_span} 或在 {$keywords} 之前";
$result['error'] = "未找到 {$end_span} 或在 keywords 之前";
return $result;
}
$result['positions']['end_span'] = $endPos;
@@ -1090,6 +1178,16 @@ class ArticleParserService
$part2 = substr($str, $keyEndPos, $len2);
$part2 = trim($part2);
$part2 = ltrim($part2, ': -—');
// Keywords 标题独占一段时,正文在下一段
if ($part2 === '') {
$nextStart = $endPos + strlen($end_span);
$nextEnd = strpos($str, $end_span, $nextStart);
if ($nextEnd !== false) {
$part2 = trim(substr($str, $nextStart, $nextEnd - $nextStart));
$part2 = ltrim($part2, ': -—');
$result['positions']['end_span'] = $nextEnd;
}
}
$result['keywords_to_end'] = trim($part2);
// 7. 标记为有效
@@ -1134,6 +1232,9 @@ class ArticleParserService
$abstract = mb_convert_encoding($abstract, 'UTF-8', 'GBK');
}
$keywords = empty($result['keywords_to_end']) ? '' : $result['keywords_to_end'];
if ($keywords === '') {
$keywords = self::extractKeywordsFromLines($this->getParagraphLinesFromSections());
}
if(!empty($keywords) && !mb_check_encoding($keywords, 'UTF-8')){
$keywords = mb_convert_encoding($keywords, 'UTF-8', 'GBK');
}
@@ -1152,6 +1253,728 @@ class ArticleParserService
];
}
/**
* 从已加载节中提取段落行
* @return array<int,string>
*/
private function getParagraphLinesFromSections(): array
{
if (empty($this->sections)) {
return [];
}
$lines = [];
foreach ($this->sections as $section) {
foreach ($section->getElements() as $element) {
$text = trim((string) $this->getTextFromElement($element));
if ($text === '') {
continue;
}
if (!mb_check_encoding($text, 'UTF-8')) {
$text = mb_convert_encoding($text, 'UTF-8', 'GBK');
}
$lines[] = preg_replace('/\s+/u', ' ', $text);
}
}
return $lines;
}
/**
* 按段落标题行提取关键词Keywords / Key words / 关键词)
*/
private static function extractKeywordsFromLines(array $lines): string
{
$headingPattern = '/^\s*(keywords?|key\s+words|关键词)\s*[:]?\s*(.*)$/iu';
$stopPattern = '/^\s*(introduction|background|methods?|materials|results?|discussion|conclusions?|references?|引言|前言|方法|结果|讨论|结论|参考文献)\b/iu';
foreach ($lines as $i => $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
if (!preg_match($headingPattern, $line, $match)) {
continue;
}
$inline = trim((string) ($match[2] ?? ''));
if ($inline !== '') {
return $inline;
}
for ($j = $i + 1; $j < count($lines); $j++) {
$next = trim((string) $lines[$j]);
if ($next === '') {
continue;
}
if (preg_match($stopPattern, $next)) {
break;
}
return $next;
}
}
return '';
}
/**
* 解析稿件并输出完整结构(含风险指标原始数据)
* @param string $filePath 本地 .docx 路径
* @return array{status:int,msg:string,data:array}
*/
public static function parseManuscriptStructure($filePath): array
{
$result = self::buildManuscriptAnalysis($filePath);
if (intval($result['status'] ?? 0) !== 1) {
return $result;
}
unset($result['data']['risk_analysis']);
return $result;
}
/**
* 编辑辅助:稿件解析 + AI 写作风险综合分析
*/
public static function analyzeWritingRisk($filePath): array
{
return self::buildManuscriptAnalysis($filePath);
}
/**
* 获取稿件解析结果(供实时风险检测使用)
* @return array{status:int,msg:string,data:array}
*/
public static function getManuscriptDetectionPayload($filePath): array
{
return self::buildManuscriptAnalysis($filePath);
}
/**
* 检测英文连续 6 词重复短语(出现大于 3 次;中文仍按 4 字、候选阈值≥2上层再按 >3 过滤)
*/
public static function detectRepeatedFourWordPhrases(string $text, array $topicPhrases = []): array
{
return self::computeRepeatedPhrases($text, 6, 6, 4, 50, $topicPhrases, 4, 4);
}
/**
* 检测英文连续 6 词重复(出现 > 3 次)
*/
public static function detectRepeatedSixWordPhrases(string $text, array $topicPhrases = []): array
{
return self::detectRepeatedFourWordPhrases($text, $topicPhrases);
}
/**
* 从标题、关键词构建主题短语白名单
* @return array<int,string>
*/
public static function buildTopicPhrasesForDetection(string $title, string $keywords): array
{
return self::buildTopicPhrases($title, $keywords);
}
/**
* @return array{status:int,msg:string,data:array}
*/
private static function buildManuscriptAnalysis($filePath): array
{
$filePath = trim((string) $filePath);
if ($filePath === '' || !is_file($filePath) || !is_readable($filePath)) {
return ['status' => 0, 'msg' => '稿件文件不存在或不可读', 'data' => []];
}
if (strtolower(pathinfo($filePath, PATHINFO_EXTENSION)) !== 'docx') {
return ['status' => 0, 'msg' => '仅支持 .docx 稿件', 'data' => []];
}
try {
$parser = new self($filePath);
} catch (\Throwable $e) {
return ['status' => 0, 'msg' => $e->getMessage(), 'data' => []];
}
if (empty($parser->sections)) {
return ['status' => 0, 'msg' => '稿件解析失败,未读取到文档内容', 'data' => []];
}
$title = trim((string) $parser->getTitle());
if ($title !== '') {
$title = $parser->fullDecode($title);
}
$extracted = $parser->extractFromWord();
$meta = empty($extracted['data']) || !is_array($extracted['data']) ? [] : $extracted['data'];
$abstract = empty($meta['abstrart']) ? '' : (string) $meta['abstrart'];
$keywords = empty($meta['keywords']) ? '' : (string) $meta['keywords'];
$lines = self::collectParagraphLines($filePath);
$sections = self::splitImradSections($lines);
$references = self::getReferencesFromWord($filePath);
$abstractForDetect = ManuscriptTextCleanService::clean($abstract);
$sectionsForDetect = ManuscriptTextCleanService::cleanSections([
'Introduction' => (string) ($sections['Introduction'] ?? ''),
'Methods' => (string) ($sections['Methods'] ?? ''),
'Results' => (string) ($sections['Results'] ?? ''),
'Discussion' => (string) ($sections['Discussion'] ?? ''),
'Conclusion' => (string) ($sections['Conclusion'] ?? ''),
]);
$sectionOrder = ['Introduction', 'Methods', 'Results', 'Discussion', 'Conclusion'];
$bodyParts = [];
$sentenceStatsBySection = [];
foreach ($sectionOrder as $sectionKey) {
$sectionText = empty($sectionsForDetect[$sectionKey]) ? '' : (string) $sectionsForDetect[$sectionKey];
if ($sectionText !== '') {
$bodyParts[] = $sectionText;
$sentenceStatsBySection[$sectionKey] = self::computeSentenceLengthStats($sectionText);
}
}
$sentenceStats = self::computeSentenceLengthStats(implode("\n\n", $bodyParts));
$bodyText = implode("\n\n", $bodyParts);
$topicPhrases = self::buildTopicPhrases($title, $keywords);
$repeatedPhrases = self::computeRepeatedPhrases($bodyText, 2, 4, 2, 100, $topicPhrases);
$repeatedPhrasesBySection = [];
foreach ($sectionOrder as $sectionKey) {
$sectionText = empty($sectionsForDetect[$sectionKey]) ? '' : (string) $sectionsForDetect[$sectionKey];
if ($sectionText !== '') {
$repeatedPhrasesBySection[$sectionKey] = self::computeRepeatedPhrases($sectionText, 2, 4, 2, 100, $topicPhrases);
}
}
$templateSentenceStats = (new AiTemplateSentenceService())->countInManuscript([
'abstract' => $abstractForDetect,
'introduction' => $sectionsForDetect['Introduction'],
'methods' => $sectionsForDetect['Methods'],
'results' => $sectionsForDetect['Results'],
'discussion' => $sectionsForDetect['Discussion'],
'conclusion' => $sectionsForDetect['Conclusion'],
]);
$manuscript = [
'title' => $title,
'abstract' => $abstract,
'keywords' => $keywords,
'Introduction' => $sections['Introduction'],
'Methods' => $sections['Methods'],
'Results' => $sections['Results'],
'Discussion' => $sections['Discussion'],
'Conclusion' => $sections['Conclusion'],
'References' => $references,
];
$metrics = [
'sentence_stats' => $sentenceStats,
'sentence_stats_by_section' => $sentenceStatsBySection,
'repeated_phrases' => $repeatedPhrases,
'repeated_phrases_by_section' => $repeatedPhrasesBySection,
'template_sentence_stats' => $templateSentenceStats,
];
$riskAnalysis = (new AiWritingRiskAnalysisService())->buildReport(array_merge($manuscript, $metrics));
return [
'status' => 1,
'msg' => 'success',
'data' => array_merge($manuscript, $metrics, [
'risk_analysis' => $riskAnalysis,
]),
];
}
/**
* 统计文本句长:平均、标准差、最长、最短(英文按词数,中文按字数)
* @return array{avg:float,std:float,max:int,min:int,sentence_count:int}
*/
private static function computeSentenceLengthStats(string $text): array
{
$empty = ['avg' => 0, 'std' => 0, 'max' => 0, 'min' => 0, 'sentence_count' => 0];
$sentences = self::splitSentences($text);
if (empty($sentences)) {
return $empty;
}
$lengths = [];
foreach ($sentences as $sentence) {
$len = self::measureSentenceLength($sentence);
if ($len > 0) {
$lengths[] = $len;
}
}
if (empty($lengths)) {
return $empty;
}
$count = count($lengths);
$avg = array_sum($lengths) / $count;
$variance = 0.0;
foreach ($lengths as $len) {
$variance += ($len - $avg) ** 2;
}
$std = $count > 1 ? sqrt($variance / ($count - 1)) : 0.0;
return [
'avg' => round($avg, 2),
'std' => round($std, 2),
'max' => max($lengths),
'min' => min($lengths),
'sentence_count' => $count,
];
}
/**
* 切分句子(支持中英文句末标点)
* @return array<int,string>
*/
private static function splitSentences(string $text): array
{
$text = trim($text);
if ($text === '') {
return [];
}
$text = preg_replace('/\s+/u', ' ', $text);
$parts = preg_split('/(?<=[\.\?\!。!?])\s+/u', $text);
if ($parts === false) {
return [];
}
$sentences = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part !== '') {
$sentences[] = $part;
}
}
return $sentences;
}
/**
* 单句长度:英文按词数,纯中文按字数,混合文本取词数+汉字数
*/
private static function measureSentenceLength(string $sentence): int
{
$sentence = trim($sentence);
if ($sentence === '') {
return 0;
}
$cjkCount = 0;
if (preg_match_all('/\p{Han}/u', $sentence, $cjkMatches)) {
$cjkCount = count($cjkMatches[0]);
}
$wordCount = 0;
if (preg_match_all('/[a-zA-Z0-9]+(?:[\'\-][a-zA-Z0-9]+)*/u', $sentence, $wordMatches)) {
$wordCount = count($wordMatches[0]);
}
if ($wordCount > 0 || $cjkCount > 0) {
return $wordCount + $cjkCount;
}
return mb_strlen(preg_replace('/\s+/u', '', $sentence));
}
/**
* 统计重复短语(英文按词 n-gram中文按连续汉字 n-gram
* @param int|null $cjkMinLen 中文最小长度null 则同 $minLen
* @param int|null $cjkMaxLen 中文最大长度null 则同 $maxLen
* @return array{items:array,unique_count:int,total_occurrences:int}
*/
private static function computeRepeatedPhrases(
string $text,
int $minLen = 2,
int $maxLen = 4,
int $minCount = 2,
int $limit = 100,
array $topicPhrases = [],
?int $cjkMinLen = null,
?int $cjkMaxLen = null
): array {
$empty = [
'items' => [],
'suspicious_items' => [],
'expected_topic_items' => [],
'unique_count' => 0,
'suspicious_count' => 0,
'total_occurrences' => 0,
];
$text = trim($text);
if ($text === '') {
return $empty;
}
$cjkMin = $cjkMinLen === null ? $minLen : $cjkMinLen;
$cjkMax = $cjkMaxLen === null ? $maxLen : $cjkMaxLen;
$counts = [];
self::collectEnglishPhraseCounts($text, $counts, $minLen, $maxLen, $topicPhrases);
self::collectCjkPhraseCounts($text, $counts, $cjkMin, $cjkMax);
$items = [];
$suspiciousItems = [];
$expectedItems = [];
foreach ($counts as $row) {
if (intval($row['count']) < $minCount) {
continue;
}
$items[] = $row;
if (($row['category'] ?? '') === 'expected_topic') {
$expectedItems[] = $row;
} elseif (($row['category'] ?? '') === 'suspicious') {
$suspiciousItems[] = $row;
}
}
if (empty($items)) {
return $empty;
}
$sortFn = function ($a, $b) {
if ($a['count'] !== $b['count']) {
return $b['count'] - $a['count'];
}
if ($a['length'] !== $b['length']) {
return $b['length'] - $a['length'];
}
return strcmp($a['phrase'], $b['phrase']);
};
usort($items, $sortFn);
usort($suspiciousItems, $sortFn);
usort($expectedItems, $sortFn);
if (count($items) > $limit) {
$items = array_slice($items, 0, $limit);
}
if (count($suspiciousItems) > $limit) {
$suspiciousItems = array_slice($suspiciousItems, 0, $limit);
}
$totalOccurrences = 0;
foreach ($items as $item) {
$totalOccurrences += intval($item['count']);
}
return [
'items' => $items,
'suspicious_items' => $suspiciousItems,
'expected_topic_items' => $expectedItems,
'unique_count' => count($items),
'suspicious_count' => count($suspiciousItems),
'total_occurrences' => $totalOccurrences,
];
}
/**
* 从标题、关键词提取主题短语,用于排除正常重复
* @return array<int,string>
*/
private static function buildTopicPhrases(string $title, string $keywords): array
{
$parts = preg_split('/[;,\n]+/u', strtolower($title . ';' . $keywords));
$phrases = [];
foreach ($parts as $part) {
$part = trim((string) preg_replace('/\s+/u', ' ', $part));
if ($part === '') {
continue;
}
if (!preg_match_all('/[a-z0-9]+(?:[\'\-][a-z0-9]+)*/u', $part, $matches)) {
continue;
}
$words = $matches[0];
$wordCount = count($words);
for ($n = 2; $n <= min(4, $wordCount); $n++) {
for ($i = 0; $i <= $wordCount - $n; $i++) {
$phrases[] = implode(' ', array_slice($words, $i, $n));
}
}
}
return array_values(array_unique($phrases));
}
private static function classifyRepeatedPhrase(string $phrase, array $topicPhrases): string
{
if (self::isMethodologyNoisePhrase($phrase)) {
return 'noise';
}
if (self::isTopicExpectedPhrase($phrase, $topicPhrases)) {
return 'expected_topic';
}
if (self::hasEdgeStopword($phrase)) {
return 'noise';
}
return 'suspicious';
}
private static function isMethodologyNoisePhrase(string $phrase): bool
{
static $patterns = [
'/^title abstract\b/u',
'/\babstract or\b/u',
'/\b(title|abstract|mesh|emtree|pubmed|cochrane|cinahl|scopus)\b/u',
'/\b(search strategy|search terms?|systematic search)\b/u',
'/\b(prisma|randomized controlled|inclusion criteria|exclusion criteria)\b/u',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $phrase)) {
return true;
}
}
return false;
}
private static function isTopicExpectedPhrase(string $phrase, array $topicPhrases): bool
{
if (in_array($phrase, $topicPhrases, true)) {
return true;
}
foreach ($topicPhrases as $topic) {
if ($topic !== '' && strpos($topic, $phrase) !== false && substr_count($phrase, ' ') >= 1) {
return true;
}
}
return false;
}
private static function hasEdgeStopword(string $phrase): bool
{
static $stopwords = [
'the', 'a', 'an', 'and', 'or', 'of', 'in', 'on', 'at', 'to', 'for', 'with', 'by', 'from', 'as',
'is', 'was', 'were', 'be', 'been', 'being', 'that', 'this', 'these', 'those', 'it', 'its',
'we', 'our', 'they', 'their', 'he', 'she', 'his', 'her', 'are', 'has', 'have', 'had',
];
$words = preg_split('/\s+/u', trim($phrase));
if (empty($words)) {
return true;
}
$first = $words[0];
$last = $words[count($words) - 1];
return in_array($first, $stopwords, true) || in_array($last, $stopwords, true);
}
/**
* 英文词序列 n-gram 计数
*/
private static function collectEnglishPhraseCounts(string $text, array &$counts, int $minLen, int $maxLen, array $topicPhrases = []): void
{
$text = strtolower($text);
if (!preg_match_all('/[a-z0-9]+(?:[\'\-][a-z0-9]+)*/u', $text, $matches)) {
return;
}
$words = [];
foreach ($matches[0] as $word) {
if (strlen($word) === 1 && !ctype_digit($word)) {
continue;
}
$words[] = $word;
}
$wordCount = count($words);
if ($wordCount < $minLen) {
return;
}
for ($n = $minLen; $n <= $maxLen; $n++) {
for ($i = 0; $i <= $wordCount - $n; $i++) {
$slice = array_slice($words, $i, $n);
if (self::isStopwordOnlyPhrase($slice)) {
continue;
}
$phrase = implode(' ', $slice);
$category = self::classifyRepeatedPhrase($phrase, $topicPhrases);
if ($category === 'noise') {
continue;
}
if (!isset($counts[$phrase])) {
$counts[$phrase] = [
'phrase' => $phrase,
'count' => 0,
'length' => $n,
'type' => 'en',
'category' => $category,
];
}
$counts[$phrase]['count']++;
}
}
}
/**
* 中文连续汉字 n-gram 计数
*/
private static function collectCjkPhraseCounts(string $text, array &$counts, int $minLen, int $maxLen): void
{
if (!preg_match_all('/\p{Han}+/u', $text, $blocks)) {
return;
}
foreach ($blocks[0] as $block) {
$chars = preg_split('//u', $block, -1, PREG_SPLIT_NO_EMPTY);
if (!is_array($chars)) {
continue;
}
$charCount = count($chars);
if ($charCount < $minLen) {
continue;
}
for ($n = $minLen; $n <= $maxLen; $n++) {
for ($i = 0; $i <= $charCount - $n; $i++) {
$phrase = implode('', array_slice($chars, $i, $n));
if (!isset($counts[$phrase])) {
$counts[$phrase] = [
'phrase' => $phrase,
'count' => 0,
'length' => $n,
'type' => 'zh',
];
}
$counts[$phrase]['count']++;
}
}
}
}
/**
* 是否全为英文停用词(过滤无意义短语)
*/
private static function isStopwordOnlyPhrase(array $words): bool
{
static $stopwords = [
'the', 'a', 'an', 'and', 'or', 'of', 'in', 'on', 'at', 'to', 'for', 'with', 'by', 'from', 'as',
'is', 'was', 'were', 'be', 'been', 'being', 'that', 'this', 'these', 'those', 'it', 'its',
'we', 'our', 'they', 'their', 'he', 'she', 'his', 'her', 'are', 'has', 'have', 'had',
];
foreach ($words as $word) {
if (!in_array($word, $stopwords, true)) {
return false;
}
}
return true;
}
/**
* 按 IMRaD 标题将段落行切分为各节正文
* @param array<int,string> $lines
* @return array<string,string>
*/
private static function splitImradSections(array $lines): array
{
$sectionOrder = ['Introduction', 'Methods', 'Results', 'Discussion', 'Conclusion'];
$buffers = array_fill_keys($sectionOrder, []);
$currentKey = null;
$refStopPattern = '/^\s*(references|reference|bibliography|参考文献|文献)\b\s*[:]?\s*/iu';
foreach ($lines as $line) {
$t = trim((string) $line);
if ($t === '') {
if ($currentKey !== null) {
$buffers[$currentKey][] = '';
}
continue;
}
if (preg_match($refStopPattern, $t)) {
break;
}
$detected = self::detectSectionKey($t);
if ($detected !== null) {
$currentKey = $detected;
$remainder = self::extractSectionRemainder($t, $detected);
if ($remainder !== '') {
$buffers[$currentKey][] = $remainder;
}
continue;
}
if ($currentKey !== null) {
$buffers[$currentKey][] = $t;
}
}
$sections = array_fill_keys($sectionOrder, '');
foreach ($sectionOrder as $key) {
if (!empty($buffers[$key])) {
$sections[$key] = trim(implode("\n", $buffers[$key]));
}
}
return $sections;
}
/**
* 识别段落是否为 IMRaD 节标题
*/
private static function detectSectionKey($line): ?string
{
$patterns = [
'Introduction' => [
'/^\s*(?:\d+[\.\s、]+)?(introduction|background)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(引言|前言|背景)\s*[:]?\s*(.*)$/u',
],
'Methods' => [
'/^\s*(?:\d+[\.\s、]+)?(methods?|materials\s+and\s+methods|materials\s*&\s*methods|methodology|experimental(?:\s+section)?)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(方法|材料与方法|资料与方法|研究方法|实验方法)\s*[:]?\s*(.*)$/u',
],
'Results' => [
'/^\s*(?:\d+[\.\s、]+)?(results?|findings)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(结果|研究结果)\s*[:]?\s*(.*)$/u',
],
'Discussion' => [
'/^\s*(?:\d+[\.\s、]+)?(discussion|discussions)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(讨论|商榷)\s*[:]?\s*(.*)$/u',
],
'Conclusion' => [
'/^\s*(?:\d+[\.\s、]+)?(conclusions?|summary|concluding\s+remarks)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(结论|结语|总结)\s*[:]?\s*(.*)$/u',
],
];
foreach ($patterns as $key => $regexList) {
foreach ($regexList as $regex) {
if (preg_match($regex, $line)) {
return $key;
}
}
}
return null;
}
/**
* 节标题同行后的正文残余(如 "Introduction: xxx"
*/
private static function extractSectionRemainder($line, $sectionKey): string
{
$patterns = [
'Introduction' => [
'/^\s*(?:\d+[\.\s、]+)?(?:introduction|background)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(?:引言|前言|背景)\s*[:]?\s*(.*)$/u',
],
'Methods' => [
'/^\s*(?:\d+[\.\s、]+)?(?:methods?|materials\s+and\s+methods|materials\s*&\s*methods|methodology|experimental(?:\s+section)?)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(?:方法|材料与方法|资料与方法|研究方法|实验方法)\s*[:]?\s*(.*)$/u',
],
'Results' => [
'/^\s*(?:\d+[\.\s、]+)?(?:results?|findings)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(?:结果|研究结果)\s*[:]?\s*(.*)$/u',
],
'Discussion' => [
'/^\s*(?:\d+[\.\s、]+)?(?:discussion|discussions)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(?:讨论|商榷)\s*[:]?\s*(.*)$/u',
],
'Conclusion' => [
'/^\s*(?:\d+[\.\s、]+)?(?:conclusions?|summary|concluding\s+remarks)\b\s*[:]?\s*(.*)$/iu',
'/^\s*(?:结论|结语|总结)\s*[:]?\s*(.*)$/u',
],
];
$regexList = empty($patterns[$sectionKey]) ? [] : $patterns[$sectionKey];
foreach ($regexList as $regex) {
if (preg_match($regex, $line, $m)) {
return trim((string) ($m[1] ?? ''));
}
}
return '';
}
/**
* 按段落提取 Word 全文行(供正文裁切、参考文献识别等复用)
* @return array<int,string>

View File

@@ -104,6 +104,64 @@ class BackgroundCheckService
return ['count' => count($list), 'list' => $list, 'source' => 'openalex'];
}
/**
* 按 DOI 从 OpenAlex 获取文献及作者身份(用于引用堆叠精准作者定位)
*/
public function fetchOpenAlexWorkByDoi($doi)
{
$doi = $this->cleanDoi($doi);
if ($doi === '') {
return ['success' => false, 'error' => 'DOI为空'];
}
$res = $this->openAlexGet('/works/https://doi.org/' . rawurlencode($doi));
if (!$res['success']) {
return $res;
}
return ['success' => true, 'work' => $res['data']];
}
/**
* 解析 OpenAlex work 中的作者身份OpenAlex ID + ORCID
*
* @return array<int, array{openalex_id:string,orcid:string,display_name:string,author_position:string,is_corresponding:bool,identity_keys:string[]}>
*/
public function parseWorkAuthorships(array $work)
{
$list = [];
foreach ($work['authorships'] ?? [] as $auth) {
if (!is_array($auth)) {
continue;
}
$author = is_array($auth['author'] ?? null) ? $auth['author'] : [];
$openalexId = $this->extractOpenAlexId($author['id'] ?? '');
$orcid = $this->cleanOrcid($author['orcid'] ?? '');
if ($openalexId === '' && $orcid === '') {
continue;
}
$identityKeys = [];
if ($openalexId !== '') {
$identityKeys[] = 'openalex:' . $openalexId;
}
if ($orcid !== '') {
$identityKeys[] = 'orcid:' . $orcid;
}
$list[] = [
'openalex_id' => $openalexId,
'orcid' => $orcid,
'display_name' => trim((string)($author['display_name'] ?? '')),
'author_position' => (string)($auth['author_position'] ?? ''),
'is_corresponding' => !empty($auth['is_corresponding']),
'identity_keys' => $identityKeys,
];
}
return $list;
}
public function fetchRecentWorks($openAlexId, $limit = 5)
{
$res = $this->openAlexGet('/works', [
@@ -604,7 +662,7 @@ class BackgroundCheckService
];
}
private function parseCrossRefAuthors($authorList)
public function parseCrossRefAuthors($authorList)
{
if (empty($authorList) || !is_array($authorList)) {
return [];
@@ -612,16 +670,52 @@ class BackgroundCheckService
$result = [];
foreach ($authorList as $a) {
$orcid = '';
if (!empty($a['ORCID']) && is_array($a['ORCID'])) {
$orcid = $this->cleanOrcid((string)($a['ORCID'][0] ?? ''));
} elseif (!empty($a['ORCID']) && is_string($a['ORCID'])) {
$orcid = $this->cleanOrcid($a['ORCID']);
}
$result[] = [
'given' => $a['given'] ?? '',
'family' => $a['family'] ?? '',
'name' => isset($a['name']) ? $a['name'] : trim(($a['given'] ?? '') . ' ' . ($a['family'] ?? '')),
'orcid' => $a['ORCID'] ?? '',
'orcid' => $orcid,
];
}
return $result;
}
/**
* Crossref 作者列表 → 带 identity_keys 的结构ORCID 兜底)
*
* @return array<int, array{openalex_id:string,orcid:string,display_name:string,author_position:string,is_corresponding:bool,identity_keys:string[]}>
*/
public function authorshipsFromCrossrefAuthors(array $authorList)
{
$parsed = $this->parseCrossRefAuthors($authorList);
$list = [];
$total = count($parsed);
foreach ($parsed as $i => $a) {
$orcid = trim((string)($a['orcid'] ?? ''));
if ($orcid === '') {
continue;
}
$displayName = trim((string)($a['name'] ?? ''));
$position = ($i === 0) ? 'first' : (($i === $total - 1) ? 'last' : 'middle');
$list[] = [
'openalex_id' => '',
'orcid' => $orcid,
'display_name' => $displayName,
'author_position' => $position,
'is_corresponding' => false,
'identity_keys' => ['orcid:' . $orcid],
];
}
return $list;
}
private function parseDateParts($dateObj)
{
if (!isset($dateObj['date-parts'][0])) {

View File

@@ -0,0 +1,329 @@
<?php
namespace app\common;
use think\Env;
/**
* 中华医学会期刊 DOIcma.j.cn文献抓取优先中文数据源。
*
* 1. 可选Yiigle 机构资源 API需配置 yiigle_api_base + yiigle_api_key
* 2. OpenAlex常含中文摘要abstract_inverted_index中华期刊在 PubMed 无摘要时仍可用
*/
class CmaJournalLiteratureService
{
private $timeout = 20;
private $mailto = '';
private $yiigleApiBase = '';
private $yiigleApiKey = '';
private $skipYiigle = false;
public function __construct(array $config = [])
{
if (isset($config['mailto'])) {
$this->mailto = trim((string)$config['mailto']);
} else {
$this->mailto = trim((string)Env::get('crossref_mailto', Env::get('pubmed_email', '')));
}
$this->yiigleApiBase = rtrim(trim((string)($config['yiigle_api_base'] ?? Env::get('yiigle_api_base', ''))), '/');
$this->yiigleApiKey = trim((string)($config['yiigle_api_key'] ?? Env::get('yiigle_api_key', '')));
$this->skipYiigle = !empty($config['skip_yiigle']);
}
public static function isCmaJournalDoi($doi)
{
$doi = strtolower(trim((string)$doi));
return $doi !== '' && strpos($doi, 'cma.j.cn') !== false;
}
/**
* @return array{
* title:string,journal:string,year:string,abstract:string,content:string,
* pmid:string,blocks:array,sources:array,fetch_log:string
* }|null
*/
public function fetchByDoi($doi)
{
$doi = $this->normalizeDoi($doi);
if ($doi === '' || !self::isCmaJournalDoi($doi)) {
return null;
}
$cacheKey = 'cma_lit_' . sha1(strtolower($doi));
$cached = $this->cacheGet($cacheKey, 7 * 86400);
if (is_array($cached)) {
return $cached;
}
$blocks = [];
$sources = [];
$abstract = '';
$content = '';
$title = '';
$journal = '';
$year = '';
$pmid = '';
$logs = [];
if (!$this->skipYiigle && $this->yiigleApiBase !== '' && $this->yiigleApiKey !== '') {
$yiigle = $this->fetchFromYiigleApi($doi);
if (is_array($yiigle)) {
$sources[] = 'cma_yiigle';
$title = $this->pickNonEmpty($title, $yiigle['title'] ?? '');
$journal = $this->pickNonEmpty($journal, $yiigle['journal'] ?? '');
$year = $this->pickNonEmpty($year, $yiigle['year'] ?? '');
if (trim((string)($yiigle['abstract'] ?? '')) !== '') {
$abstract = trim((string)$yiigle['abstract']);
}
if (trim((string)($yiigle['content'] ?? '')) !== '') {
$content = trim((string)$yiigle['content']);
}
if (!empty($yiigle['block'])) {
$blocks[] = (string)$yiigle['block'];
}
$logs[] = 'yiigle=' . ($yiigle['status'] ?? 'ok');
}
}
$openAlex = $this->fetchFromOpenAlex($doi);
if (is_array($openAlex)) {
$sources[] = 'cma_openalex';
$title = $this->pickNonEmpty($title, $openAlex['title'] ?? '');
$journal = $this->pickNonEmpty($journal, $openAlex['journal'] ?? '');
$year = $this->pickNonEmpty($year, $openAlex['year'] ?? '');
$pmid = $this->pickNonEmpty($pmid, $openAlex['pmid'] ?? '');
if ($abstract === '' && trim((string)($openAlex['abstract'] ?? '')) !== '') {
$abstract = trim((string)$openAlex['abstract']);
}
if (!empty($openAlex['block'])) {
$blocks[] = (string)$openAlex['block'];
}
$logs[] = 'openalex=' . ($abstract !== '' ? 'abstract' : 'meta');
}
if ($abstract === '' && $content === '' && empty($blocks)) {
return null;
}
$result = [
'title' => $title,
'journal' => $journal,
'year' => $year,
'abstract' => $abstract,
'content' => $content,
'pmid' => $pmid,
'blocks' => array_values(array_filter($blocks)),
'sources' => array_values(array_unique($sources)),
'fetch_log' => 'cma_doi=' . $doi . '; ' . implode('; ', $logs),
];
$this->cacheSet($cacheKey, $result);
return $result;
}
private function fetchFromOpenAlex($doi)
{
$url = 'https://api.openalex.org/works/https://doi.org/' . rawurlencode($doi);
if ($this->mailto !== '') {
$url .= '?mailto=' . rawurlencode($this->mailto);
}
$raw = $this->httpGet($url);
$json = json_decode((string)$raw, true);
if (!is_array($json)) {
return null;
}
$title = trim((string)($json['title'] ?? $json['display_name'] ?? ''));
$journal = trim((string)($json['primary_location']['raw_source_name'] ?? ''));
if ($journal === '') {
$journal = trim((string)($json['primary_location']['source']['display_name'] ?? ''));
}
if (strcasecmp($journal, 'PubMed') === 0) {
$journal = '';
}
$year = trim((string)($json['publication_year'] ?? ''));
$abstract = $this->reconstructOpenAlexAbstract($json['abstract_inverted_index'] ?? null);
if ($abstract === '') {
$abstract = trim((string)($json['abstract'] ?? ''));
}
$pmid = '';
$pmidRaw = (string)($json['ids']['pmid'] ?? '');
if (preg_match('/(\d+)/', $pmidRaw, $m)) {
$pmid = $m[1];
}
$lines = ['=== 中华医学期刊 / OpenAlex (DOI ' . $doi . ') ==='];
if ($title !== '') {
$lines[] = 'Title: ' . $title;
}
if ($journal !== '') {
$lines[] = 'Journal: ' . $journal;
}
if ($year !== '') {
$lines[] = 'Year: ' . $year;
}
if ($abstract !== '') {
$lines[] = 'Abstract: ' . $abstract;
}
return [
'title' => $title,
'journal' => $journal,
'year' => $year,
'abstract' => $abstract,
'pmid' => $pmid,
'block' => implode("\n", $lines),
];
}
/**
* 可选:机构购买的 Yiigle 资源 API未配置则跳过
*/
private function fetchFromYiigleApi($doi)
{
$url = $this->yiigleApiBase . '/resource/queryByDoi?doi=' . rawurlencode($doi);
$raw = $this->httpGet($url, [
'Authorization: Bearer ' . $this->yiigleApiKey,
'Accept: application/json',
]);
$json = json_decode((string)$raw, true);
if (!is_array($json)) {
return ['status' => 'empty'];
}
$data = $json['data'] ?? $json;
if (!is_array($data)) {
return ['status' => 'invalid'];
}
$abstract = trim((string)($data['abstract'] ?? $data['summary'] ?? ''));
$content = trim((string)($data['content'] ?? $data['fullText'] ?? ''));
$title = trim((string)($data['title'] ?? ''));
$journal = trim((string)($data['journal'] ?? $data['journalName'] ?? ''));
$year = trim((string)($data['year'] ?? $data['pubYear'] ?? ''));
$block = '';
if ($abstract !== '' || $content !== '') {
$lines = ['=== 中华医学期刊网 Yiigle (DOI ' . $doi . ') ==='];
if ($title !== '') {
$lines[] = 'Title: ' . $title;
}
if ($abstract !== '') {
$lines[] = 'Abstract: ' . $abstract;
}
if ($content !== '') {
$lines[] = 'Content: ' . mb_substr($content, 0, 8000);
}
$block = implode("\n", $lines);
}
return [
'status' => ($abstract !== '' || $content !== '') ? 'ok' : 'no_text',
'title' => $title,
'journal' => $journal,
'year' => $year,
'abstract' => $abstract,
'content' => $content,
'block' => $block,
];
}
private function reconstructOpenAlexAbstract($invertedIndex)
{
if (!is_array($invertedIndex) || empty($invertedIndex)) {
return '';
}
$tokens = [];
foreach ($invertedIndex as $token => $positions) {
if (!is_array($positions)) {
continue;
}
foreach ($positions as $pos) {
$tokens[intval($pos)] = (string)$token;
}
}
if (empty($tokens)) {
return '';
}
ksort($tokens, SORT_NUMERIC);
return trim(implode(' ', $tokens));
}
private function normalizeDoi($doi)
{
$doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi));
return trim($doi, " \t\n\r\0\x0B/");
}
private function pickNonEmpty($current, $candidate)
{
$current = trim((string)$current);
$candidate = trim((string)$candidate);
if ($current !== '') {
return $current;
}
return $candidate;
}
private function httpGet($url, array $headers = [])
{
$headers = array_merge([
'User-Agent: TMRjournals-CmaJournal/1.0',
'Accept: application/json, text/plain, */*',
], $headers);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => $headers,
]);
$body = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code < 200 || $code >= 300) {
return '';
}
return is_string($body) ? $body : '';
}
private function cacheDir()
{
return rtrim(ROOT_PATH, '/') . '/runtime/cma_journal_cache';
}
private function cacheGet($key, $ttlSeconds)
{
$file = $this->cacheDir() . '/' . $key . '.json';
if (!is_file($file)) {
return null;
}
$mtime = filemtime($file);
if (!$mtime || (time() - $mtime) > $ttlSeconds) {
return null;
}
$decoded = json_decode((string)@file_get_contents($file), true);
return is_array($decoded) ? $decoded : null;
}
private function cacheSet($key, array $value)
{
$dir = $this->cacheDir();
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
@file_put_contents($dir . '/' . $key . '.json', json_encode($value, JSON_UNESCAPED_UNICODE));
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace app\common;
use think\Db;
/**
* 长耗时任务HTTP 拉摘要、LLM前后释放/重建 MySQL 连接,避免 Windows errno=10053
*/
class DbReconnectHelper
{
public static function release()
{
try {
Db::close();
} catch (\Throwable $e) {
// ignore
}
}
/**
* 确保当前进程可用数据库连接(断线则强制重连)
*/
public static function ensure()
{
if (self::ping()) {
return true;
}
self::release();
return self::reconnect();
}
private static function ping()
{
try {
Db::query('SELECT 1');
return true;
} catch (\Throwable $e) {
return false;
}
}
private static function reconnect()
{
try {
Db::connect(config('database'), true);
Db::query('SELECT 1');
return true;
} catch (\Throwable $e) {
return false;
}
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace app\common;
/**
* Europe PMC REST API
* @see https://europepmc.org/RestfulWebService
*/
class EuropePmcService
{
private $base = 'https://www.ebi.ac.uk/europepmc/webservices/rest';
private $timeout = 25;
public function searchByDoi($doi)
{
$doi = trim((string)$doi);
if ($doi === '') {
return null;
}
$query = 'DOI:"' . str_replace('"', '', $doi) . '"';
return $this->searchFirst($query);
}
public function searchByBibliographic($title, $author, $year = '')
{
$title = trim((string)$title);
if ($title === '') {
return null;
}
$parts = ['TITLE:"' . str_replace('"', '', $title) . '"'];
$author = trim((string)$author);
if ($author !== '') {
$firstAuthor = preg_split('/[,;]/', $author);
$firstAuthor = trim((string)($firstAuthor[0] ?? ''));
if ($firstAuthor !== '') {
$parts[] = 'AUTH:"' . str_replace('"', '', $firstAuthor) . '"';
}
}
$year = trim((string)$year);
if ($year !== '' && preg_match('/^(19|20)\d{2}$/', $year)) {
$parts[] = 'PUB_YEAR:' . $year;
}
return $this->searchFirst(implode(' AND ', $parts));
}
public function fetchFullTextByPmcid($pmcid)
{
$pmcid = strtoupper(trim((string)$pmcid));
if ($pmcid === '') {
return '';
}
if (strpos($pmcid, 'PMC') !== 0) {
$pmcid = 'PMC' . preg_replace('/\D/', '', $pmcid);
}
$url = $this->base . '/' . rawurlencode($pmcid) . '/fullTextXML';
$xml = $this->httpGet($url);
if ($xml === '') {
return '';
}
return $this->xmlToPlainText($xml);
}
private function searchFirst($query)
{
$url = $this->base . '/search?' . http_build_query([
'query' => $query,
'format' => 'json',
'pageSize' => 1,
]);
$raw = $this->httpGet($url);
if ($raw === '') {
return null;
}
$json = json_decode($raw, true);
$list = $json['resultList']['result'] ?? [];
if (empty($list[0]) || !is_array($list[0])) {
return null;
}
$row = $list[0];
return [
'title' => trim((string)($row['title'] ?? '')),
'abstract' => trim((string)($row['abstractText'] ?? '')),
'doi' => trim((string)($row['doi'] ?? '')),
'pmid' => trim((string)($row['pmid'] ?? '')),
'pmcid' => trim((string)($row['pmcid'] ?? '')),
'journal' => trim((string)($row['journalTitle'] ?? '')),
'year' => trim((string)($row['pubYear'] ?? '')),
'source' => 'europe_pmc',
];
}
private function xmlToPlainText($xml)
{
$xml = trim((string)$xml);
if ($xml === '') {
return '';
}
libxml_use_internal_errors(true);
$doc = new \DOMDocument();
if (!$doc->loadXML($xml)) {
return trim(strip_tags($xml));
}
return trim(preg_replace('/\s+/u', ' ', $doc->textContent));
}
private function httpGet($url)
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['User-Agent: TMRjournals-EuropePMC/1.0'],
]);
$res = curl_exec($ch);
curl_close($ch);
return is_string($res) ? $res : '';
}
}

View File

@@ -0,0 +1,512 @@
<?php
namespace app\api\controller;
use app\api\controller\Base;
use think\Validate;
use app\api\controller\User as usercontroller;
use think\Db;
/**
* @title 期刊相关
* @description 期刊相关
*/
class Journal extends Base {
protected $sJournalUrl = 'http://journalapi.tmrjournals.com/public/index.php/';//'http://zmzm.journal.dev.com/';
public function __construct(\think\Request $request = null) {
parent::__construct($request);
}
/**
* @title 获取期刊列表除去审稿人已存在审稿关系
* @description 获取期刊列表除去审稿人已存在审稿关系
* @author wangjinlei
* @url /api/Journal/getJournalOutReviewer
* @method POST
*
* @param name:username type:string require:1 desc:用户名
*
* @return journals:期刊列表#
*/
public function getJournalOutReviewer(){
$data = $this->request->post();
$user_info = $this->user_obj->where("account",$data['username'])->find();
$journalIds = $this->reviewer_to_journal_obj->where('reviewer_id',$user_info['user_id'])->where('state',0)->column('journal_id');
$list = $this->journal_obj->where('journal_id',"not in",$journalIds)->where('state',0)->select();
$re['journals'] = $list;
return jsonSuccess($re);
}
/**
* @title 获取审稿人所属期刊列表
* @description 获取审稿人所属期刊列表
* @author wangjinlei
* @url /api/Journal/getJournalInReviewer
* @method POST
*
* @param name:username type:string require:1 desc:用户名
*
* @return journals:期刊列表#
*/
public function getJournalInReviewer(){
$data = $this->request->post();
$user_info = $this->user_obj->where('account',$data['username'])->where('state',0)->find();
$list = $this->reviewer_to_journal_obj
->field("t_journal.*")
->join('t_journal',"t_journal.journal_id = t_reviewer_to_journal.journal_id","left")
->where('t_reviewer_to_journal.reviewer_id',$user_info['user_id'])
->where('t_reviewer_to_journal.state',0)
->select();
$re['journals'] = $list;
return jsonSuccess($re);
}
public function getAllJournal(){
$list = $this->journal_obj->where('state',0)->select();
//接口请求获取journal_topic 和 abstract_chinese chengxiaoling 20250514 start
if(!empty($list)){
$list = $this->_getJournalForApi($list);
}
//接口请求获取journal_topic 和 abstract_chinese chengxiaoling 20250514 end
$re['journals'] = $list;
return jsonSuccess($re);
}
/**获取连续出刊的当年分期信息
* @return void
*/
public function getJournalStageLX(){
$data = $this->request->post();
$rule = new Validate([
"journal_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find();
$url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/getJournalStageLXForSubmission";
$program['issn'] = $journal_info['issn'];
$res = object_to_array(json_decode(myPost($url,$program)));
$list = $res['data']['detail'];
$re['detail'] = $list;
return jsonSuccess($re);
}
public function creatJournalStage(){
$data = $this->request->post();
$rule = new Validate([
"issn"=>"require",
"stage_year"=>"require",
"stage_vol"=>"require",
"stage_no"=>"require",
"stage_page"=>"require",
"issue_date"=>"require",
"stage_icon"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/addStageForTG";
$program['issn'] = $data['issn'];
$program['stage_year'] = $data['stage_year'];
$program['stage_vol'] = $data['stage_vol'];
$program['stage_no'] = $data['stage_no'];
$program['stage_pagename'] = "No.";
$program['stage_page'] = $data['stage_page'];
$program['issue_date'] = $data['issue_date'];
$program['stage_icon'] = $data['stage_icon'];
object_to_array(json_decode(myPost($url,$program)));
return jsonSuccess($program);
}
public function citeMate(){
$data = $this->request->post();
$rule = new Validate([
"journal_id"=>"require",
"year"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find();
$url = "http://journalapi.tmrjournals.com/public/index.php/api/Main/citeMate";
$program['journal_issn'] = $journal_info['issn'];
$program['year'] = $data['year'];
$res = object_to_array(json_decode(myPost($url,$program)));
return json($res);
}
public function delJournalStage(){
$data = $this->request->post();
$rule = new Validate([
"journal_stage_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/delStage";
$program['journal_stage_id'] = $data['journal_stage_id'];
$res = object_to_array(json_decode(myPost($url,$program)));
if($res['code']==0){
return jsonSuccess($res);
}else{
return jsonError($res['msg']);
}
}
public function getJournalStageArticles(){
$data = $this->request->post();
$rule = new Validate([
"journal_id" => "require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find();
$url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/getJournalStageArticlesForSubmission";
$program['issn'] = $journal_info['issn'];
$res = object_to_array(json_decode(myPost($url,$program)));
$list = empty($res['data']['list']) ? [] : $res['data']['list'];
//获取微信公众号文章状态 chengxiaoling 20250522 start
if(!empty($list)){
$aArticleId = array_column($list, 'article_id');
$aWechatArticle = $this->getWechatInfo($aArticleId);
$aAiArticle = empty($aWechatArticle['ai_article']) ? [] : $aWechatArticle['ai_article'];
$aAiWechatArticle = empty($aWechatArticle['ai_wechat_article']) ? [] : $aWechatArticle['ai_wechat_article'];
foreach ($list as $key => $val) {
//获取微信公众号文章状态 chengxiaoling 20250522 start
$list[$key]['ai_wechat_status'] = 2; //1 Ai内容已生成 2ai内容未生成
if(in_array($val['article_id'],$aAiArticle)){
$list[$key]['ai_wechat_status'] = 1;
//是否推送到微信
$aDraft = empty($aAiWechatArticle[$val['article_id']]) ? [] : $aAiWechatArticle[$val['article_id']];
$list[$key]['ai_wechat_status'] = empty($aDraft) ? 3 : 4; //3 未生成草稿 4 已生成草稿未发布 10 发布成功 11 发布中 >11发布失败
if(!empty($aDraft)){
foreach ($aDraft as $kk => $value) {
if($kk == '-1'){
$list[$key]['ai_wechat_status'] = 4;
}else{
$list[$key]['ai_wechat_status'] = '1'.$kk;
}
}
}
}
//获取微信公众号文章状态 chengxiaoling 20250522 end
}
}
//获取微信公众号文章状态 chengxiaoling 20250522 end
$re['list'] = $list;
return jsonSuccess($re);
}
public function pushArticleToPublic(){
$data = $this->request->post();
$rule = new Validate([
"article_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
//推送数据到数据库
$uu = "http://journalapi.tmrjournals.com/public/index.php/master/Datebase/dataPushForLx";
$program['article_id'] = $data['article_id'];
$res = object_to_array(json_decode(myPost($uu,$program)));
//更改文章状态
$url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/publishArticleForSubmission";
$program['article_id'] = $data['article_id'];
$res = object_to_array(json_decode(myPost($url,$program)));
return jsonSuccess([]);
}
public function editJournalLeftZc(){
$data = $this->request->post();
$rule = new Validate([
"journal_id"=>"require",
"ethics"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$this->journal_obj->where("journal_id",$data['journal_id'])->update(["ethics"=>$data['ethics']]);
return jsonSuccess([]);
}
/**
* 获取期刊列表
*/
public function getJournalByeditor()
{
$user_id = $this->request->post('user_id');
$list = $this->journal_obj->where('editor_id',$user_id)->where("state",0)->select();
//接口请求获取journal_topic 和 abstract_chinese chengxiaoling 20250514 start
if(!empty($list)){
$list = $this->_getJournalForApi($list);
}
//接口请求获取journal_topic 和 abstract_chinese chengxiaoling 20250514 end
$re['journals'] = $list;
return jsonSuccess($re);
}
/**
* 获取可申请审稿人的期刊
*/
public function getJournalsForReviewerInEditor(){
$data = $this->request->post();
$rule = new Validate([
'editor_id' => 'require',
'reviewer_id' => 'require'
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$editor_info = $this->user_obj->where('user_id',$data['editor_id'])->find();
$journalIds = [];
if($editor_info['type']==2){//责任编辑
$journalIds = $this->journal_obj->where('editor_id',$editor_info['user_id'])->column('journal_id');
}else{//客座编辑
$guests = $this->user_to_special_obj->where('user_id',$data['reviewer_id'])->where('uts_state',0)->select();
$usercontroller = new usercontroller();
foreach($guests as $v){
$c_res = $usercontroller->getSpecialDetailById($v['special_id']);
$journalIds[] = $this->journal_obj->where('issn',$c_res['journal_issn'])->value('journal_id');
}
}
$njournalIds = $this->reviewer_to_journal_obj->where('reviewer_id',$data['reviewer_id'])->where('state',0)->column('journal_id');
$list = $this->journal_obj->where('journal_id',"not in",$njournalIds)->where('journal_id',"in",$journalIds)->where('state',0)->select();
$re['journals'] = $list;
return jsonSuccess($re);
}
public function editJournal(){
$data = $this->request->post();
$rule = new Validate([
'journal_id'=>'require',
'level'=>'require',
'email'=>'require',
'epassword'=>'require',
"kfen"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find();
$url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/editJournalEmailPasswordForSubmission";
$program['issn'] = $journal_info['issn'];
$program['epassword'] = $data['epassword'];
$res = object_to_array(json_decode(myPost($url,$program)));
$update['level'] = $data['level'];
$update['email'] = $data['email'];
$update['epassword'] = $data['epassword'];
$update['kfen'] = $data['kfen'];
//新增字段期刊涵盖主题多个逗号分隔\中文简介\发布作者\编辑二维码 chengxiaoling 20250507 start
if(isset($data['journal_topic'])){
if(is_array($data['journal_topic'])){
$update['journal_topic'] = implode(',', $data['journal_topic']);
}else{
$update['journal_topic'] = $data['journal_topic'];
}
$aJournalUpdate['journal_topic'] = $update['journal_topic'];
}
if(isset($data['abstract_chinese'])){
$update['abstract_chinese'] = $data['abstract_chinese'];
$aJournalUpdate['abstract_chinese'] = $update['abstract_chinese'];
}
if(isset($data['publish_author'])){
$update['publish_author'] = $data['publish_author'];
$aJournalUpdate['publish_author'] = $update['publish_author'];
}
if(isset($data['editor_qrcode'])){
$update['editor_qrcode'] = $data['editor_qrcode'];
$aJournalUpdate['editor_qrcode'] = $update['editor_qrcode'];
}
if(isset($data['wechat_name'])){
$update['wechat_name'] = $data['wechat_name'];
$aJournalUpdate['wechat_name'] = $update['wechat_name'];
}
if(isset($data['wechat_app_id'])){
$update['wechat_app_id'] = $data['wechat_app_id'];
$aJournalUpdate['wechat_app_id'] = $update['wechat_app_id'];
}
if(isset($data['wechat_app_secret'])){
$update['wechat_app_secret'] = $data['wechat_app_secret'];
$aJournalUpdate['wechat_app_secret'] = $update['wechat_app_secret'];
}
if (isset($data['wechat_yboard_qrcode'])){
$update['wechat_yboard_qrcode'] = $data['wechat_yboard_qrcode'];
}
if(isset($data['editor_name'])&&$data['editor_name']!=''){
$update['editor_name'] = $data['editor_name'];
}
if(isset($data['databases'])&&$data['databases']!=''){
$update['databases'] = $data['databases'];
}
if(!empty($aJournalUpdate)){
$aJournalUpdate['issn'] = $journal_info['issn'];
$sUrl = $this->sJournalUrl."wechat/Article/updateJournal";
$program['issn'] = $journal_info['issn'];
$res = object_to_array(json_decode(myPost($sUrl,$aJournalUpdate)));
}
//新增字段期刊涵盖主题多个逗号分隔 chengxiaoling 20250507 end
if(isset($data['fee'])&&$data['fee']!=0){
$update['fee'] = $data['fee'];
}
//新增字段 收费说明及链接 20260104 start
//收费链接
if(isset($data['apc_url'])){
$update['apc_url'] = trim($data['apc_url']);
}
//收费说明
if(isset($data['apc_content'])){
$update['apc_content'] = trim($data['apc_content']);
}
//新增字段 收费说明及链接 20260104 end
$update['scope'] = isset($data['scope'])?trim($data['scope']):"";
$this->journal_obj->where('journal_id',$data['journal_id'])->update($update);
return jsonSuccess([]);
}
/**
* 获取期刊详情
*/
public function getJournalDetail(){
$data = $this->request->post();
$rule = new Validate([
'journal_id'=>'require'
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$info = $this->journal_obj->where('journal_id',$data['journal_id'])->find();
$re['journal'] = $info;
return jsonSuccess($re);
}
/**
* 获取期刊详情通过文章id
*/
public function getJournalDetailByArticleId(){
$data = $this->request->post();
$rule = new Validate([
'article_id'=>'require'
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$article_info = $this->article_obj->where('article_id',$data['article_id'])->find();
$info = $this->journal_obj->where('journal_id',$article_info['journal_id'])->find();
$re['journal'] = $info;
return jsonSuccess($re);
}
/**
* 接口请求获取Journal数据库里的期刊话题及中文简介
*/
private function _getJournalForApi($list = []){
if(empty($list)){
return [];
}
$aIssn = array_column($list, 'issn');
$sUrl = $this->sJournalUrl."master/Journal/getJournals";
$aParam['issn'] = $aIssn;
$aResult = object_to_array(json_decode(myPost1($sUrl,$aParam)));
$aData = empty($aResult['data']) ? [] : $aResult['data'];
$aJournal = empty($aData['journals']) ? [] : array_column($aData['journals'],null,'issn');
foreach ($list as $key => $value) {
$aJournalInfo = empty($aJournal[$value['issn']]) ? [] : $aJournal[$value['issn']];
$list[$key]['journal_topic'] = empty($aJournalInfo['journal_topic']) ? '' : $aJournalInfo['journal_topic'];
$list[$key]['abstract_chinese'] = empty($aJournalInfo['abstract_chinese']) ? '' : $aJournalInfo['abstract_chinese'];
$list[$key]['publish_author'] = empty($aJournalInfo['publish_author']) ? '' : $aJournalInfo['publish_author'];
$list[$key]['editor_qrcode'] = empty($aJournalInfo['editor_qrcode']) ? '' : $aJournalInfo['editor_qrcode'];
$list[$key]['wechat_name'] = empty($aJournalInfo['wechat_name']) ? '' : $aJournalInfo['wechat_name'];
$list[$key]['wechat_app_id'] = empty($aJournalInfo['wechat_app_id']) ? '' : $aJournalInfo['wechat_app_id'];
$list[$key]['wechat_app_secret'] = empty($aJournalInfo['wechat_app_secret']) ? '' : $aJournalInfo['wechat_app_secret'];
}
return $list;
}
/**
* 上传期刊编辑的二维码
*/
public function uploadEditorQrcode()
{
$file = request()->file('qrcode_url');
if ($file) {
$info = $file->move(ROOT_PATH . 'public' . DS . 'journaleditorqrcode');
if ($info) {
return json(['code' => 0, 'upurl' => str_replace("\\", "/", $info->getSaveName())]);
} else {
return json(['code' => 1, 'msg' => $file->getError()]);
}
}
}
public function uploadYboardQrcode()
{
$file = request()->file('qrcode_url');
if ($file) {
$info = $file->move(ROOT_PATH . 'public' . DS . 'journalyboardqrcode');
if ($info) {
return json(['code' => 0, 'upurl' => str_replace("\\", "/", $info->getSaveName())]);
} else {
return json(['code' => 1, 'msg' => $file->getError()]);
}
}
}
/**
* 获取微信公众号相关数量
*/
public function getWechatInfo($aArticleId){
if(empty($aArticleId)){
return [];
}
//获取文章生成记录
$aWhere = ['article_id' => ['in',$aArticleId],'is_delete' => 2];
$aAiArticle = Db::name('ai_article')->where($aWhere)->column('article_id');
if(!empty($aAiArticle)){
//获取推送到草稿箱否
$aWhere['article_id'] = ['in',$aAiArticle];
$ai_wechat_article = Db::name('ai_wechat_article')->field('article_id,template_id,wechat_id,is_publish,publish_status')->where($aWhere)->select();
if(!empty($ai_wechat_article)){
foreach ($ai_wechat_article as $key => $value) {
$aWechatArticle[$value['article_id']][$value['publish_status']][] = $value['template_id'];
}
}
}
unset($ai_wechat_article);
//返回数据
return ['ai_article' => $aAiArticle,'ai_wechat_article' => empty($aWechatArticle) ? [] : $aWechatArticle];
}
}

View File

@@ -106,6 +106,21 @@ class ProductionArticleRefer
$update_a['cs'] = 1;
$update_a['update_time'] = time();
$update_a['is_deal'] = 1;
try {
(new ReferenceReferAuthorService())->syncFromWorkSummary(
$iPReferId,
$iPArticleId,
$doiNorm,
$summary
);
} catch (\Throwable $e) {
\think\Log::error(
'ProductionArticleRefer sync refer authors failed p_refer_id='
. $iPReferId . ' ' . $e->getMessage()
);
}
Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a);
return json_encode(['status' => 1,'msg' => 'Update successful']);
}

View File

@@ -0,0 +1,171 @@
<?php
namespace app\common;
use think\Db;
/**
* 参考文献外部文献内容t_production_article_refer_literature
*/
class ProductionArticleReferLiteratureService
{
/**
* @return array|null
*/
public function getByPReferId($pReferId, $pArticleId = 0)
{
$pReferId = intval($pReferId);
if ($pReferId <= 0) {
return null;
}
$q = Db::name('production_article_refer_literature')->where('p_refer_id', $pReferId);
if (intval($pArticleId) > 0) {
$q->where('p_article_id', intval($pArticleId));
}
$row = $q->find();
return is_array($row) ? $row : null;
}
/**
* 从 t_production_article_refer_literature 读取校对用文献字段(不回落 refer 主表)
*
* @return array{
* abstract_text:string,
* content_text:string,
* mesh_terms:string,
* refer_content_cleaned:string,
* literature_pdf_url:string,
* fetch_sources:string,
* fetch_log:string,
* refer_doi:string
* }
*/
public function loadForCheck($pReferId, $pArticleId = 0)
{
$empty = [
'abstract_text' => '',
'content_text' => '',
'mesh_terms' => '',
'refer_content_cleaned' => '',
'literature_pdf_url' => '',
'fetch_sources' => '',
'fetch_log' => '',
'refer_doi' => '',
];
$stored = $this->getByPReferId($pReferId, $pArticleId);
if (empty($stored)) {
return $empty;
}
return [
'abstract_text' => trim((string)($stored['abstract_text'] ?? '')),
'content_text' => trim((string)($stored['content_text'] ?? '')),
'mesh_terms' => trim((string)($stored['mesh_terms'] ?? '')),
'refer_content_cleaned' => trim((string)($stored['refer_content_cleaned'] ?? '')),
'literature_pdf_url' => trim((string)($stored['literature_pdf_url'] ?? '')),
'fetch_sources' => trim((string)($stored['fetch_sources'] ?? '')),
'fetch_log' => trim((string)($stored['fetch_log'] ?? '')),
'refer_doi' => trim((string)($stored['refer_doi'] ?? '')),
];
}
/**
* @deprecated 使用 loadForCheck保留兼容仅读下属表
*/
public function resolveLiteratureFields(array $refer, $pArticleId = 0)
{
$pReferId = intval($refer['p_refer_id'] ?? 0);
$lit = $this->loadForCheck($pReferId, $pArticleId);
return [
'abstract_text' => $lit['abstract_text'],
'content_text' => $lit['content_text'],
'mesh_terms' => $lit['mesh_terms'],
'refer_content_cleaned' => $lit['refer_content_cleaned'],
'literature_pdf_url' => $lit['literature_pdf_url'],
];
}
/**
* 写入/更新文献内容
*
* @param array $data abstract_text, content_text, mesh_terms, refer_content_cleaned, literature_pdf_url, refer_doi, fetch_sources, fetch_log
*/
public function upsert($pArticleId, $pReferId, array $data)
{
$pArticleId = intval($pArticleId);
$pReferId = intval($pReferId);
if ($pReferId <= 0) {
return 0;
}
$now = date('Y-m-d H:i:s');
$row = [
'p_article_id' => $pArticleId,
'p_refer_id' => $pReferId,
'refer_doi' => $this->clip((string)($data['refer_doi'] ?? ''), 128),
'abstract_text' => (string)($data['abstract_text'] ?? ''),
'content_text' => (string)($data['content_text'] ?? ''),
'mesh_terms' => $this->formatMeshTerms($data['mesh_terms'] ?? ''),
'refer_content_cleaned' => (string)($data['refer_content_cleaned'] ?? ''),
'literature_pdf_url' => $this->clip((string)($data['literature_pdf_url'] ?? ''), 1024),
'fetch_sources' => $this->clip($this->formatSources($data['fetch_sources'] ?? ''), 255),
'fetch_log' => $this->clip((string)($data['fetch_log'] ?? ''), 512),
'updated_at' => $now,
];
$existing = Db::name('production_article_refer_literature')->where('p_refer_id', $pReferId)->find();
if (!empty($existing)) {
Db::name('production_article_refer_literature')
->where('p_refer_id', $pReferId)
->update($row);
return intval($existing['id']);
}
$row['created_at'] = $now;
return intval(Db::name('production_article_refer_literature')->insertGetId($row));
}
public function formatMeshTerms($mesh)
{
if (is_array($mesh)) {
$parts = [];
foreach ($mesh as $term) {
$term = trim((string)$term);
if ($term !== '') {
$parts[$term] = $term;
}
}
return implode('; ', array_values($parts));
}
return trim((string)$mesh);
}
private function formatSources($sources)
{
if (is_array($sources)) {
return implode(',', array_values(array_unique(array_filter(array_map('strval', $sources)))));
}
return trim((string)$sources);
}
private function clip($text, $max)
{
$text = trim((string)$text);
if ($text === '' || $max <= 0) {
return '';
}
if (mb_strlen($text) <= $max) {
return $text;
}
return mb_substr($text, 0, $max);
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace app\common;
use think\Db;
/**
* 参考文献堆叠 — 作者精准定位OpenAlex ID / ORCID姓名仅作模糊参考
*/
class ReferenceAuthorIdentityService
{
const MATCH_PRECISE = 'precise';
const MATCH_FUZZY = 'fuzzy';
const MATCH_NONE = 'none';
/** @var BackgroundCheckService */
private $bgCheck;
/** @var ReferenceCheckService */
private $refUtil;
/** @var CrossrefService */
private $crossref;
public function __construct()
{
$this->bgCheck = new BackgroundCheckService();
$this->refUtil = new ReferenceCheckService();
$this->crossref = new CrossrefService([
'mailto' => trim((string)\think\Env::get('crossref_mailto', '')),
]);
}
/**
* 本文作者身份(精准统计仅认 ORCID → OpenAlex / ORCID 键)
*
* @return array<int, array{
* p_article_author_id:int,
* display_name:string,
* openalex_id:string,
* orcid:string,
* identity_keys:string[],
* match_confidence:string
* }>
*/
public function resolveManuscriptAuthors($pArticleId)
{
$pArticleId = intval($pArticleId);
if ($pArticleId <= 0) {
return [];
}
$rows = Db::name('production_article_author')
->field('p_article_author_id,first_name,last_name,author_name,orcid')
->where('p_article_id', $pArticleId)
->where('state', 0)
->select();
$identities = [];
foreach ($rows as $row) {
$identities[] = $this->resolveOneManuscriptAuthor($row);
}
return $identities;
}
/**
* 单条参考文献的作者身份(优先 OpenAlex work by DOI其次 Crossref ORCID
*
* @return array{
* authorships:array,
* first_author:array|null,
* match_confidence:string,
* identity_source:string
* }
*/
public function resolveReferAuthorships(array $refer, array &$workCache, array &$crossrefCache)
{
$pReferId = intval($refer['p_refer_id'] ?? 0);
if ($pReferId > 0) {
$stored = (new ReferenceReferAuthorService())->loadAuthorshipsByPReferId($pReferId);
if (!empty($stored)) {
return [
'authorships' => $stored,
'first_author' => $this->pickFirstAuthorship($stored),
'match_confidence' => self::MATCH_PRECISE,
'identity_source' => 'stored',
];
}
}
$doi = $this->refUtil->extractDoiFromRefer($refer);
if ($doi === '') {
return [
'authorships' => [],
'first_author' => null,
'match_confidence' => self::MATCH_NONE,
'identity_source' => '',
];
}
if (!array_key_exists($doi, $workCache)) {
$workCache[$doi] = $this->fetchOpenAlexAuthorships($doi);
usleep(80000);
}
$authorships = $workCache[$doi];
$source = 'openalex';
if (empty($authorships)) {
if (!array_key_exists($doi, $crossrefCache)) {
$crossrefCache[$doi] = $this->fetchCrossrefAuthorships($doi);
usleep(80000);
}
$authorships = $crossrefCache[$doi];
$source = 'crossref_orcid';
}
$firstAuthor = $this->pickFirstAuthorship($authorships);
$confidence = empty($authorships) ? self::MATCH_NONE : self::MATCH_PRECISE;
return [
'authorships' => $authorships,
'first_author' => $firstAuthor,
'match_confidence' => $confidence,
'identity_source' => $source,
];
}
/**
* @param array<int, array{identity_keys:string[]}> $manuscriptAuthors
* @param array<int, array{identity_keys:string[]}> $referAuthorships
*/
public function matchManuscriptToRefer(array $manuscriptAuthors, array $referAuthorships)
{
$manuscriptKeys = [];
foreach ($manuscriptAuthors as $author) {
if (($author['match_confidence'] ?? '') !== self::MATCH_PRECISE) {
continue;
}
foreach ((array)($author['identity_keys'] ?? []) as $key) {
$manuscriptKeys[$key] = $author;
}
}
foreach ($referAuthorships as $auth) {
foreach ((array)($auth['identity_keys'] ?? []) as $key) {
if (isset($manuscriptKeys[$key])) {
return $manuscriptKeys[$key];
}
}
}
return null;
}
public function buildFuzzyAuthorKey($authorCitationPart)
{
$part = trim(preg_replace('/\.+$/u', '', trim((string)$authorCitationPart)));
if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) {
return '';
}
$tokens = preg_split('/\s+/u', $part, -1, PREG_SPLIT_NO_EMPTY);
if (count($tokens) === 1) {
return 'fuzzy:' . mb_strtoupper($tokens[0]) . '|';
}
$last = array_pop($tokens);
if (preg_match('/^[A-Za-z]{1,4}$/u', $last)) {
$family = implode(' ', $tokens);
return 'fuzzy:' . 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 'fuzzy:' . mb_strtoupper($family) . '|' . $initials;
}
public function extractFuzzyFirstAuthorKeyFromRefer(array $refer, array $meta)
{
$author = trim(trim((string)($meta['author'] ?? $refer['author'] ?? '')), '.');
if ($author === '') {
return '';
}
$parts = preg_split('/,\s*/u', $author);
$first = trim((string)($parts[0] ?? ''));
return $this->buildFuzzyAuthorKey($first);
}
private function resolveOneManuscriptAuthor(array $row)
{
$first = trim((string)($row['first_name'] ?? ''));
$last = trim((string)($row['last_name'] ?? ''));
$displayName = ($first !== '' && $last !== '') ? trim($first . ' ' . $last) : trim((string)($row['author_name'] ?? ''));
$orcid = $this->bgCheck->cleanOrcid($row['orcid'] ?? '');
$openalexId = '';
$identityKeys = [];
$confidence = self::MATCH_NONE;
$fuzzyKey = '';
if ($last !== '') {
$initials = $this->initialsFromGiven($first);
$fuzzyKey = 'fuzzy:' . mb_strtoupper($last) . '|' . $initials;
}
if ($orcid !== '') {
$identityKeys[] = 'orcid:' . $orcid;
$confidence = self::MATCH_PRECISE;
$res = $this->bgCheck->resolveAuthor(['orcid' => $orcid]);
if (!empty($res['success']) && !empty($res['data'])) {
$openalexId = $this->bgCheck->extractOpenAlexId($res['data']['id'] ?? '');
if ($openalexId !== '') {
$identityKeys[] = 'openalex:' . $openalexId;
}
if (trim((string)($res['data']['display_name'] ?? '')) !== '') {
$displayName = trim((string)$res['data']['display_name']);
}
}
}
return [
'p_article_author_id' => intval($row['p_article_author_id']),
'display_name' => $displayName,
'openalex_id' => $openalexId,
'orcid' => $orcid,
'fuzzy_key' => $fuzzyKey,
'identity_keys' => array_values(array_unique($identityKeys)),
'match_confidence' => $confidence,
];
}
private function initialsFromGiven($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 fetchOpenAlexAuthorships($doi)
{
$res = $this->bgCheck->fetchOpenAlexWorkByDoi($doi);
if (empty($res['success']) || empty($res['work']) || !is_array($res['work'])) {
return [];
}
return $this->bgCheck->parseWorkAuthorships($res['work']);
}
private function fetchCrossrefAuthorships($doi)
{
$res = $this->bgCheck->fetchCrossRefWork($doi);
if (empty($res['success']) || empty($res['message'])) {
return [];
}
return $this->bgCheck->authorshipsFromCrossrefAuthors($res['message']['author'] ?? []);
}
private function pickFirstAuthorship(array $authorships)
{
if (empty($authorships)) {
return null;
}
foreach ($authorships as $auth) {
if (($auth['author_position'] ?? '') === 'first') {
return $auth;
}
}
return reset($authorships) ?: null;
}
}

View File

@@ -2345,7 +2345,7 @@ class ReferenceCheckService
}
$slice = $this->buildCitationContextText($raw, $extendedStart, $textEnd);
$slice = ltrim($slice, ". \t\n\r");
$slice = ltrim($slice, "., \t\n\r");
if (trim($slice) === '') {
return $fallback;
}
@@ -3894,9 +3894,13 @@ class ReferenceCheckService
$hasPriorCiteInParagraph = ($prevTagEnd > $paragraphStart);
$sentenceStart = $this->findSentenceStart($content, $tagStart);
// 段内首个引用:整段到标签前;后续引用:取「本句」起点(可早于上一标签),避免只剩 “and external environment” 再误用标签后文本
// 段内首个引用:整段到标签前;后续引用:不早于上一标签结束,并可向前扩展若干句覆盖紧邻 claim
if ($hasPriorCiteInParagraph) {
$localStart = max($paragraphStart, $sentenceStart);
$anchor = max($prevTagEnd, $sentenceStart);
$localStart = $this->extendContextStartBackward($content, $anchor, $prevTagEnd, 2);
if ($localStart >= $prevTagEnd) {
$localStart = $this->advancePastPriorCitationBoundary($content, $localStart);
}
} else {
$localStart = $this->capContextStartBeforeTag($content, $tagStart, $paragraphStart);
}
@@ -3904,19 +3908,34 @@ class ReferenceCheckService
// 默认:引用标签前的论述
$localEnd = $tagStart;
$originalText = $this->buildCitationContextText($content, $localStart, $localEnd);
$before = $originalText;
$isAuthorOnly = $this->isAuthorOnlyLeadIn($before);
// 仅段内首个引用、且标签前极短(如句末 ICU nurses [14])时,才改用标签后片段;同段多引禁止标签后截取(会错取下一句)
$allowTrailing = !$hasPriorCiteInParagraph;
if ($allowTrailing && (
!$this->isMeaningfulCitationContext($originalText)
|| $this->shouldUseTrailingCitationContext($content, $localStart, $tagStart, $tagEnd)
)) {
// 作者缩写引用Chen [33]、Zou [24] found that...):向前扩展到段落/句群,必要时并入标签后叙述
if ($isAuthorOnly) {
$localStart = $this->capContextStartBeforeTag($content, $tagStart, $paragraphStart);
$trailEnd = ($nextTagStart < $sentenceEnd) ? $nextTagStart : $sentenceEnd;
$trailText = $this->buildCitationContextText($content, $tagEnd, $trailEnd);
if ($this->isMeaningfulCitationContext($trailText)) {
$localStart = $tagEnd;
$localEnd = $trailEnd;
$originalText = $trailText;
} else {
$localEnd = $tagStart;
}
$originalText = $this->buildCitationContextText($content, $localStart, $localEnd);
} else {
// 仅段内首个引用、且标签前极短时,才改用标签后片段;同段多引禁止(会错取下一句)
$allowTrailing = !$hasPriorCiteInParagraph;
if ($allowTrailing && (
!$this->isMeaningfulCitationContext($originalText)
|| $this->shouldUseTrailingCitationContext($content, $localStart, $tagStart, $tagEnd)
)) {
$trailEnd = ($nextTagStart < $sentenceEnd) ? $nextTagStart : $sentenceEnd;
$trailText = $this->buildCitationContextText($content, $tagEnd, $trailEnd);
if ($this->isMeaningfulCitationContext($trailText)) {
$localStart = $tagEnd;
$localEnd = $trailEnd;
$originalText = $trailText;
}
}
}
@@ -3934,6 +3953,22 @@ class ReferenceCheckService
return [$localStart, $localEnd, $originalText];
}
/**
* 标签前仅有作者姓氏/缩写(如 Chen、Zou时视为作者引导引用需扩展上下文。
*/
private function isAuthorOnlyLeadIn($text)
{
$text = trim((string)$text);
if ($text === '') {
return false;
}
if (mb_strlen($text) < 25) {
return true;
}
return preg_match('/^[A-Z][a-zA-Z\'\-]{0,24}\.?$/u', $text) === 1;
}
/**
* 标签前仅有作者缩写等极短片段时,改用标签后上下文
*/
@@ -4097,6 +4132,27 @@ class ReferenceCheckService
return $start;
}
/**
* 上一引用标签结束后,跳过空白及紧随其后的句末/分句标点(如 "[1]. And"、"[2], accounting"
*/
private function advancePastPriorCitationBoundary($content, $pos)
{
$pos = intval($pos);
$len = strlen($content);
while ($pos < $len) {
while ($pos < $len && ($content[$pos] === ' ' || $content[$pos] === "\t" || $content[$pos] === "\n" || $content[$pos] === "\r")) {
$pos++;
}
if ($pos < $len && ($content[$pos] === '.' || $content[$pos] === ',')) {
$pos++;
continue;
}
break;
}
return $pos;
}
/**
* 过滤仅标点、过短或无字母/汉字的上下文(如去掉标签后只剩 "."
*/

View File

@@ -0,0 +1,622 @@
<?php
namespace app\common;
use think\Env;
use Smalot\PdfParser\Parser as PdfParser;
/**
* 参考文献内容抓取cma.j.cn 优先中文源 → Europe PMC → PubMed → PMC全文 → Unpaywall PDF → Crossref
*/
class ReferenceLiteratureFetchService
{
/** @var EuropePmcService */
private $epmc;
/** @var PubmedService */
private $pubmed;
/** @var CrossrefService */
private $crossref;
/** @var UnpaywallService */
private $unpaywall;
/** @var CmaJournalLiteratureService */
private $cmaJournal;
/** @var ReferenceCheckService */
private $refUtil;
/** @var bool 预抓取阶段暂不调用 Yiigle 机构 API */
private $skipYiigle = false;
public function __construct()
{
$this->epmc = new EuropePmcService();
$this->pubmed = new PubmedService([
'email' => trim((string)Env::get('pubmed_email', '')),
'tool' => trim((string)Env::get('pubmed_tool', 'tmrjournals')),
]);
$this->crossref = new CrossrefService([
'mailto' => trim((string)Env::get('crossref_mailto', '')),
]);
$this->unpaywall = new UnpaywallService();
$this->cmaJournal = new CmaJournalLiteratureService();
$this->refUtil = new ReferenceCheckService();
}
public function setSkipYiigle($skip = true)
{
$this->skipYiigle = (bool)$skip;
return $this;
}
/**
* @return array{
* doi:string,pmid:string,pmcid:string,title:string,journal:string,year:string,
* abstract:string,raw_content:string,pdf_url:string,mesh_terms:array,sources:array,fetch_log:string
* }
*/
public function fetchForRefer(array $refer)
{
DbReconnectHelper::release();
// 图书/ISBN 类参考文献不走 DOI 管道(书上的 DOI 常为错误挂接的期刊文章)
if ($this->shouldSkipDoiFetchForRefer($refer)) {
return $this->emptyResult('book_skip_doi_fetch');
}
$dois = $this->resolveDoiCandidatesForFetch($refer);
foreach ($dois as $doi) {
$result = $this->fetchByDoiPipeline($doi, $refer);
if ($this->fetchedResultMatchesRefer($result, $refer)) {
return $result;
}
}
$title = trim((string)($refer['title'] ?? ''));
$author = trim((string)($refer['author'] ?? ''));
$year = $this->extractYearFromRefer($refer);
if ($title === '') {
$title = $this->guessTitleFromReferContent($refer);
}
$resolvedDoi = '';
$meta = null;
if ($title !== '') {
$meta = $this->epmc->searchByBibliographic($title, $author, $year);
if (is_array($meta) && trim((string)($meta['doi'] ?? '')) !== '') {
$resolvedDoi = trim((string)$meta['doi']);
}
if ($resolvedDoi === '') {
$pub = $this->pubmed->searchByBibliographic($title, $author, $year);
if (is_array($pub)) {
$resolvedDoi = trim((string)($pub['doi'] ?? ''));
if ($meta === null) {
$meta = $pub;
}
}
}
}
if ($resolvedDoi !== '') {
$result = $this->fetchByDoiPipeline($resolvedDoi, $refer);
if (is_array($meta)) {
if ($result['title'] === '' && trim((string)($meta['title'] ?? '')) !== '') {
$result['title'] = trim((string)$meta['title']);
}
}
if ($this->fetchedResultMatchesRefer($result, $refer)) {
$result['fetch_log'] = 'no_doi_in_refer; resolved_doi=' . $resolvedDoi . '; ' . $result['fetch_log'];
return $result;
}
}
return $this->emptyResult('no_doi_and_bibliographic_search_failed');
}
/**
* 判断已入库/已清洗内容与 refer 元数据是否明显错配(用于跳过错误缓存、触发重抓)。
*/
public function storedContentMatchesRefer(array $refer, $abstract, $cleaned)
{
$text = trim((string)$abstract . "\n" . (string)$cleaned);
if ($text === '') {
return true;
}
// 子表内容已按 p_refer_id 绑定;用 refer 标题锚定,避免中文摘要因不含英文作者姓氏被误判为错配
$expectedTitle = trim((string)($refer['title'] ?? ''));
return $this->fetchedResultMatchesRefer([
'title' => $expectedTitle,
'abstract' => $text,
'raw_content' => $text,
], $refer);
}
/**
* 图书类参考文献refer_type=book 或带 ISBN 且呈教材/专著特征时,不通过 DOI 抓外部摘要。
*/
private function shouldSkipDoiFetchForRefer(array $refer)
{
if (strtolower(trim((string)($refer['refer_type'] ?? ''))) === 'book') {
return true;
}
$isbn = trim((string)($refer['isbn'] ?? ''));
if ($isbn === '') {
return false;
}
$blob = strtolower(
trim((string)($refer['joura'] ?? '')) . ' '
. trim((string)($refer['dateno'] ?? '')) . ' '
. trim((string)($refer['title'] ?? ''))
);
return preg_match('/\bed\.?|edition|publishing|press|lippincott|elsevier|springer|wiley|company|图书|教材|专著/u', $blob);
}
/**
* 相关性校对抓取:优先 refer_doi/doilink结构化字段再 refer_content原始文本可能错链
*
* @return string[]
*/
private function resolveDoiCandidatesForFetch(array $refer)
{
$result = [];
foreach (['refer_doi', 'doilink', 'doi', 'refer_content', 'refer_frag'] as $field) {
$slice = array_merge($refer, ['refer_content' => (string)($refer[$field] ?? '')]);
foreach ($this->refUtil->extractAllDoiCandidatesFromRefer($slice) as $doi) {
if (!in_array($doi, $result, true)) {
$result[] = $doi;
}
}
}
return $result;
}
/**
* 校验抓取结果标题/作者是否与 refer 行一致,防止 refer_content 错链到另一篇文献。
*/
private function fetchedResultMatchesRefer(array $result, array $refer)
{
$expectedTitle = trim((string)($refer['title'] ?? ''));
$fetchedTitle = trim((string)($result['title'] ?? ''));
$blob = strtolower(
$fetchedTitle . ' '
. trim((string)($result['abstract'] ?? '')) . ' '
. trim((string)($result['raw_content'] ?? ''))
);
$titleConfirmed = false;
if ($expectedTitle !== '' && $fetchedTitle !== '') {
if (!$this->titlesLikelyMatch($expectedTitle, $fetchedTitle)) {
return false;
}
$titleConfirmed = true;
}
$author = trim((string)($refer['author'] ?? ''));
// 标题已能确认同一文献时,不再要求摘要/正文里出现作者姓氏PubMed 摘要通常不含作者)
if ($author !== '' && !$titleConfirmed) {
$needles = $this->extractAuthorNeedles($author);
$matched = 0;
foreach ($needles as $needle) {
if ($needle !== '' && strpos($blob, $needle) !== false) {
$matched++;
}
}
if (!empty($needles) && $matched === 0) {
return false;
}
}
return true;
}
private function titlesLikelyMatch($expected, $fetched)
{
$a = $this->normalizeTitleForMatch($expected);
$b = $this->normalizeTitleForMatch($fetched);
if ($a === '' || $b === '') {
return true;
}
if ($a === $b || strpos($a, $b) !== false || strpos($b, $a) !== false) {
return true;
}
similar_text($a, $b, $pct);
return $pct >= 38;
}
private function normalizeTitleForMatch($title)
{
$title = strtolower(trim((string)$title));
$title = preg_replace('/[^a-z0-9\s]+/u', ' ', $title);
return trim(preg_replace('/\s+/u', ' ', $title));
}
/**
* @return string[]
*/
private function extractAuthorNeedles($author)
{
$author = trim((string)$author);
if ($author === '') {
return [];
}
$needles = [];
if (preg_match_all('/[A-Za-z]{3,}/', $author, $m)) {
foreach ($m[0] as $part) {
$needles[] = strtolower($part);
}
}
return array_values(array_unique($needles));
}
/**
* 抓取 + LLM 清洗(校对执行时调用)
*
* @return array 含 abstract_final, content_cleaned
*/
public function fetchAndCleanForRefer(array $refer)
{
$fetched = $this->fetchForRefer($refer);
DbReconnectHelper::ensure();
$raw = trim((string)($fetched['raw_content'] ?? ''));
if ($raw === '') {
return array_merge($fetched, [
'abstract_final' => trim((string)($fetched['abstract'] ?? '')),
'content_cleaned' => '',
'content_clean_skip'=> true,
]);
}
DbReconnectHelper::release();
$fetchedForClean = $fetched;
$clean = (new \app\common\service\ReferenceContentCleanLlmService())->clean($raw, $fetchedForClean);
DbReconnectHelper::ensure();
$abstractFinal = trim((string)($clean['abstract'] ?? ''));
if ($abstractFinal === '') {
$abstractFinal = trim((string)($fetched['abstract'] ?? ''));
}
return array_merge($fetched, [
'abstract_final' => $abstractFinal,
'content_cleaned' => trim((string)($clean['cleaned'] ?? '')),
'content_clean_skip' => !empty($clean['skipped']),
]);
}
private function fetchByDoiPipeline($doi, array $refer)
{
$doi = trim((string)$doi);
$blocks = [];
$sources = [];
$abstract = '';
$title = trim((string)($refer['title'] ?? ''));
$pmid = '';
$pmcid = '';
$journal = '';
$year = $this->extractYearFromRefer($refer);
$fetchLogs = [];
$pdfUrl = '';
$meshTerms = [];
// 0) 中华医学会期刊 DOIOpenAlex 中文摘要(可选 Yiigle 机构 API
if (CmaJournalLiteratureService::isCmaJournalDoi($doi)) {
$cmaSvc = $this->cmaJournal;
if ($this->skipYiigle) {
$cmaSvc = new CmaJournalLiteratureService(['skip_yiigle' => true]);
}
$cma = $cmaSvc->fetchByDoi($doi);
if (is_array($cma)) {
$sources = array_merge($sources, (array)($cma['sources'] ?? []));
if ($title === '' && trim((string)($cma['title'] ?? '')) !== '') {
$title = trim((string)$cma['title']);
}
if ($journal === '' && trim((string)($cma['journal'] ?? '')) !== '') {
$journal = trim((string)$cma['journal']);
}
if ($year === '' && trim((string)($cma['year'] ?? '')) !== '') {
$year = trim((string)$cma['year']);
}
if ($pmid === '' && trim((string)($cma['pmid'] ?? '')) !== '') {
$pmid = trim((string)$cma['pmid']);
}
if (trim((string)($cma['abstract'] ?? '')) !== '') {
$abstract = trim((string)$cma['abstract']);
}
foreach ((array)($cma['blocks'] ?? []) as $block) {
$block = trim((string)$block);
if ($block !== '') {
$blocks[] = $block;
}
}
$cmaContent = trim((string)($cma['content'] ?? ''));
if ($cmaContent !== '') {
$blocks[] = "=== 中华医学期刊全文 ===\n" . $this->truncate($cmaContent, 20000);
}
if (trim((string)($cma['fetch_log'] ?? '')) !== '') {
$fetchLogs[] = (string)$cma['fetch_log'];
}
}
}
// 1) Europe PMC by DOI
$epmc = $this->epmc->searchByDoi($doi);
if (is_array($epmc)) {
$sources[] = 'europe_pmc';
if ($title === '') {
$title = trim((string)($epmc['title'] ?? ''));
}
if (trim((string)($epmc['abstract'] ?? '')) !== '') {
$abstract = trim((string)$epmc['abstract']);
$blocks[] = "=== Europe PMC ===\n" . $abstract;
}
$pmid = trim((string)($epmc['pmid'] ?? ''));
$pmcid = trim((string)($epmc['pmcid'] ?? ''));
$journal = trim((string)($epmc['journal'] ?? ''));
if ($year === '' && trim((string)($epmc['year'] ?? '')) !== '') {
$year = trim((string)$epmc['year']);
}
}
// 2) PubMed metadata
$pub = $this->pubmed->fetchByDoi($doi);
if (is_array($pub)) {
$sources[] = 'pubmed';
if ($pmid === '' && trim((string)($pub['pmid'] ?? '')) !== '') {
$pmid = trim((string)$pub['pmid']);
}
if ($title === '' && trim((string)($pub['title'] ?? '')) !== '') {
$title = trim((string)$pub['title']);
}
if ($abstract === '' && trim((string)($pub['abstract'] ?? '')) !== '') {
$abstract = trim((string)$pub['abstract']);
}
if (!empty($pub['mesh_terms']) && is_array($pub['mesh_terms'])) {
$meshTerms = array_values(array_unique(array_merge($meshTerms, $pub['mesh_terms'])));
}
$pubBlock = $this->formatPubmedBlock($pub, $doi);
if ($pubBlock !== '' && $abstract === '') {
$blocks[] = $pubBlock;
} elseif ($pubBlock !== '' && !CmaJournalLiteratureService::isCmaJournalDoi($doi)) {
$blocks[] = $pubBlock;
}
}
// 3) PMC full text
if ($pmcid !== '') {
$full = $this->epmc->fetchFullTextByPmcid($pmcid);
if ($full !== '') {
$sources[] = 'pmc_fulltext';
$blocks[] = "=== PMC Full Text ({$pmcid}) ===\n" . $this->truncate($full, 20000);
}
}
// 4) Unpaywall OA PDF + PDF parse
$oaPdfUrl = $this->unpaywall->findOaPdfUrl($doi);
if ($oaPdfUrl !== '') {
$pdfUrl = $oaPdfUrl;
$pdfText = $this->downloadAndExtractPdf($oaPdfUrl);
if ($pdfText !== '') {
$sources[] = 'unpaywall_pdf';
$blocks[] = "=== OA PDF Extract ===\nSource: {$oaPdfUrl}\n" . $this->truncate($pdfText, 25000);
}
}
if ($pdfUrl === '' && $pmcid !== '') {
$pdfUrl = $this->buildPmcPdfUrl($pmcid);
}
// 5) Crossref supplement已有实质性摘要或全文时跳过避免与 PubMed 等重复)
if (!$this->shouldSkipCrossrefSupplement($abstract, $sources, $blocks)) {
$cr = $this->refUtil->fetchCrossrefAbstractByReferDoi(['refer_doi' => $doi, 'doi' => $doi]);
if (is_array($cr) && trim((string)($cr['text'] ?? '')) !== '') {
$sources[] = 'crossref';
$blocks[] = trim((string)$cr['text']);
if ($abstract === '' && !empty($cr['has_abstract'])) {
if (preg_match('/Abstract:\s*(.+)/uis', (string)$cr['text'], $m)) {
$abstract = trim($m[1]);
}
}
}
} else {
$fetchLogs[] = 'crossref=skipped_has_abstract_or_fulltext';
}
$raw = trim(implode("\n\n", array_filter($blocks)));
if ($raw === '' && $abstract !== '') {
$raw = $abstract;
}
return [
'doi' => $doi,
'pmid' => $pmid,
'pmcid' => $pmcid,
'title' => $title,
'journal' => $journal,
'year' => $year,
'abstract' => $abstract,
'raw_content' => $raw,
'pdf_url' => $pdfUrl,
'mesh_terms' => $meshTerms,
'sources' => array_values(array_unique($sources)),
'fetch_log' => trim('doi=' . $doi . '; sources=' . implode(',', $sources) . ($fetchLogs ? '; ' . implode('; ', $fetchLogs) : '')),
];
}
private function shouldSkipCrossrefSupplement($abstract, array $sources, array $blocks)
{
if (mb_strlen(trim((string)$abstract)) >= 40) {
return true;
}
if (!empty(array_intersect($sources, ['pmc_fulltext', 'unpaywall_pdf', 'cma_yiigle']))) {
return true;
}
foreach ($blocks as $block) {
if (preg_match('/===\s*(PMC Full Text|OA PDF Extract|中华医学期刊全文)/u', (string)$block)) {
return true;
}
}
return false;
}
private function buildPmcPdfUrl($pmcid)
{
$pmcid = strtoupper(trim((string)$pmcid));
if ($pmcid === '') {
return '';
}
if (strpos($pmcid, 'PMC') !== 0) {
$pmcid = 'PMC' . preg_replace('/\D/', '', $pmcid);
}
return 'https://pmc.ncbi.nlm.nih.gov/articles/' . rawurlencode($pmcid) . '/pdf/';
}
private function formatPubmedBlock(array $pub, $doi)
{
$lines = ['=== PubMed (DOI ' . $doi . ') ==='];
foreach (['title', 'journal', 'year'] as $k) {
if (!empty($pub[$k])) {
$lines[] = ucfirst($k) . ': ' . trim((string)$pub[$k]);
}
}
if (!empty($pub['publication_types'])) {
$lines[] = 'Publication Types: ' . implode('; ', (array)$pub['publication_types']);
}
if (!empty($pub['mesh_terms'])) {
$lines[] = 'MeSH: ' . implode('; ', (array)$pub['mesh_terms']);
}
if (!empty($pub['abstract'])) {
$lines[] = 'Abstract: ' . trim((string)$pub['abstract']);
}
return implode("\n", $lines);
}
private function downloadAndExtractPdf($url)
{
$url = trim((string)$url);
if ($url === '') {
return '';
}
$dir = ROOT_PATH . 'runtime' . DS . 'ref_literature_pdf';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$path = $dir . DS . date('YmdHis') . '_' . uniqid('', true) . '.pdf';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 90,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['User-Agent: TMRjournals-RefFetch/1.0'],
]);
$body = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code < 200 || $code >= 300 || strlen($body) < 1000) {
return '';
}
if (strlen($body) > 15 * 1024 * 1024) {
return '';
}
if (@file_put_contents($path, $body) === false) {
return '';
}
try {
$text = $this->extractPdfText($path);
} finally {
@unlink($path);
}
return $text;
}
private function extractPdfText($path)
{
if (!class_exists(PdfParser::class)) {
return $this->extractPdfTextByPython($path);
}
try {
$parser = new PdfParser();
$pdf = $parser->parseFile($path);
$text = $pdf->getText();
return is_string($text) ? trim($text) : '';
} catch (\Throwable $e) {
return $this->extractPdfTextByPython($path);
}
}
private function extractPdfTextByPython($path)
{
$script = ROOT_PATH . 'scripts' . DS . 'extract_pdf_text.py';
if (!is_file($script)) {
return '';
}
$cmd = 'python ' . escapeshellarg($script) . ' ' . escapeshellarg($path) . ' 2>nul';
$out = shell_exec($cmd);
return is_string($out) ? trim($out) : '';
}
private function extractYearFromRefer(array $refer)
{
$dateno = trim((string)($refer['dateno'] ?? ''));
if (preg_match('/(19|20)\d{2}/', $dateno, $m)) {
return $m[0];
}
return '';
}
private function guessTitleFromReferContent(array $refer)
{
$content = trim((string)($refer['refer_content'] ?? ''));
if ($content === '') {
return '';
}
$line = preg_split('/\n/', $content)[0] ?? $content;
$line = preg_replace('/^\[\d+\]\s*/', '', trim($line));
return mb_substr($line, 0, 300);
}
private function truncate($text, $max)
{
$text = trim((string)$text);
if ($text === '') {
return '';
}
if (mb_strlen($text) <= $max) {
return $text;
}
return mb_substr($text, 0, $max) . "\n...(truncated)";
}
private function emptyResult($reason)
{
return [
'doi' => '',
'pmid' => '',
'pmcid' => '',
'title' => '',
'journal' => '',
'year' => '',
'abstract' => '',
'raw_content' => '',
'pdf_url' => '',
'mesh_terms' => [],
'sources' => [],
'fetch_log' => (string)$reason,
];
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,834 @@
<?php
namespace app\common;
use think\Db;
/**
* 参考文献引用堆叠统计:同刊、同作者、自引(实时计算,不入库)
*/
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();
}
/**
* 按阈值规则实时统计:同作者(>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 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,
];
}
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'] ?? ''),
],
'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->identity->resolveManuscriptAuthors($pArticleId);
$ambiguousManuscriptNameKeys = $this->buildAmbiguousManuscriptNameKeys($manuscriptAuthors);
$doiCache = [];
$referMap = [];
$journalBuckets = [];
$authorBuckets = [];
$selfCitationDetails = [];
foreach ($refers as $refer) {
$refNo = intval($refer['index']) + 1;
$pReferId = intval($refer['p_refer_id']);
$referMap[$pReferId] = $refer;
$meta = $this->resolveReferMeta($refer, $doiCache);
$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;
}
$referAuthors = $this->resolveReferAuthorsWithMeta($pReferId, $meta);
$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,
'refer_map' => $referMap,
'author_identity_note' => '同作者堆叠按 citation_name空则 display_name姓名精确匹配同名即同人本文出现重名作者如两位 Lin时跳过该姓名的自引判定。优先读 t_production_article_refer_author否则解析 refer.author。',
'computed_at' => date('Y-m-d H:i:s'),
];
}
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)
{
$pReferId = intval($pReferId);
$list = [];
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) {
$name = trim((string)($row['citation_name'] ?? ''));
if ($name === '') {
$name = trim((string)($row['display_name'] ?? ''));
}
if ($name === '') {
continue;
}
$list[] = [
'name' => $name,
'orcid' => $this->cleanOrcid($row['orcid'] ?? ''),
];
}
}
if (!empty($list)) {
return $list;
}
foreach ($this->parseAuthorStringParts((string)($meta['author'] ?? '')) as $name) {
$list[] = [
'name' => $name,
'orcid' => '',
];
}
return $list;
}
/**
* @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}>
*/
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];
$list[] = [
'p_refer_id' => $pReferId,
'reference_no' => intval($refer['index'] ?? 0) + 1,
'refer_text' => $this->referSnippet($refer),
];
}
return $list;
}
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 行已有字段、Crossref、refer_frag/refer_content 解析结果
*
* @return array{
* author:string,
* joura:string,
* author_keys:string[],
* first_author_display:string,
* meta_source:string,
* resolved:bool
* }
*/
private function resolveReferMeta(array $refer, array &$doiCache)
{
$author = trim(trim((string)($refer['author'] ?? '')), '.');
$joura = trim(trim((string)($refer['joura'] ?? '')), '.');
$sources = [];
$authorKeys = [];
if ($author !== '') {
$sources[] = 'local';
$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)) {
$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;
}
/**
* @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) {
$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 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);
if ($orcid === '') {
return '';
}
$orcid = preg_replace('#^https?://orcid\.org/#i', '', $orcid);
return trim($orcid, " \t\n\r\0\x0B/");
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace app\common;
use think\Env;
/**
* Unpaywall OA PDF 定位
* @see https://unpaywall.org/products/api
*/
class UnpaywallService
{
private $email;
private $timeout = 20;
public function __construct()
{
$this->email = trim((string)Env::get('unpaywall_email', Env::get('pubmed_email', '')));
}
/**
* @return string PDF 直链,找不到返回空
*/
public function findOaPdfUrl($doi)
{
$doi = trim((string)$doi);
if ($doi === '' || $this->email === '') {
return '';
}
$url = 'https://api.unpaywall.org/v2/' . rawurlencode($doi) . '?' . http_build_query([
'email' => $this->email,
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['User-Agent: TMRjournals-Unpaywall/1.0'],
]);
$raw = curl_exec($ch);
curl_close($ch);
if (!is_string($raw) || $raw === '') {
return '';
}
$json = json_decode($raw, true);
if (!is_array($json)) {
return '';
}
$best = $json['best_oa_location'] ?? [];
if (!is_array($best)) {
return '';
}
foreach (['url_for_pdf', 'url'] as $key) {
$candidate = trim((string)($best[$key] ?? ''));
if ($candidate !== '' && $this->looksLikePdfUrl($candidate)) {
return $candidate;
}
}
return '';
}
private function looksLikePdfUrl($url)
{
if (stripos($url, '.pdf') !== false) {
return true;
}
return (bool)preg_match('#/(pdf|download|content/pdf)#i', $url);
}
}

View File

@@ -0,0 +1,358 @@
<?php
namespace app\common;
use app\common\service\LLMService;
use Smalot\PdfParser\Parser as PdfParser;
use think\Db;
use think\Env;
use think\Exception;
/**
* 从用户简历文件提取文本,调用与参考文献校对相同的 LLMService 解析用户基本信息。
*/
class UserInfoFromFileService
{
const CV_BASE_URL_DEFAULT = 'https://submission.tmrjournals.com/public/reviewer/';
/** @var LLMService */
private $llm;
public function __construct(LLMService $llm = null)
{
$this->llm = $llm ?: new LLMService();
}
/**
* 按 user_id 查 user_cv取最新一条简历解析。
*
* @param int $userId
* @return array
*/
public function parseFromUserId($userId)
{
$userId = intval($userId);
if ($userId <= 0) {
throw new Exception('user_id 无效');
}
$row = Db::name('user_cv')
->where('user_id', $userId)
->where('state', 0)
->order('ctime desc')
->find();
if (empty($row) || trim((string) $row['cv']) === '') {
throw new Exception('未找到该用户的简历user_cv');
}
$cvRel = $this->normalizeCvRelativePath($row['cv']);
$cvUrl = $this->buildCvUrl($cvRel);
$local = $this->resolveLocalCvPath($cvRel);
$temp = false;
$filePath = $local;
if ($filePath === '' || !is_file($filePath)) {
$filePath = $this->downloadCvToTemp($cvUrl, $cvRel);
$temp = true;
}
try {
$result = $this->parseFromFile($filePath);
} finally {
if ($temp && is_file($filePath)) {
@unlink($filePath);
}
}
$result['user_id'] = $userId;
$result['user_cv_id'] = intval($row['user_cv_id'] ?? 0);
$result['cv'] = $cvRel;
$result['cv_url'] = $cvUrl;
return $result;
}
/**
* @param string $filePath 服务器本地绝对路径
* @return array
*/
public function parseFromFile($filePath)
{
$filePath = trim((string) $filePath);
if ($filePath === '' || !is_file($filePath)) {
throw new Exception('文件不存在');
}
$text = trim($this->extractFileText($filePath));
if ($text === '') {
throw new Exception('未能从文件中提取到文本');
}
$text = $this->sanitizeUtf8($text);
$maxChars = max(1000, (int) Env::get('promotion.promotion_llm_max_chars', 2500));
if (mb_strlen($text) > $maxChars) {
$text = mb_substr($text, 0, $maxChars) . "\n...(truncated)";
}
$llmResult = $this->parseUserInfoWithLlm($text);
return [
'file' => basename($filePath),
'text_length' => mb_strlen($text),
'text_preview' => mb_substr($text, 0, 600),
'user_info' => $llmResult['user_info'],
'llm_raw' => $llmResult['raw'],
];
}
private function normalizeCvRelativePath($cv)
{
$cv = trim(str_replace('\\', '/', (string) $cv));
if ($cv === '') {
return '';
}
if (preg_match('#^https?://#i', $cv)) {
return $cv;
}
return ltrim($cv, '/');
}
private function buildCvUrl($cvRel)
{
if (preg_match('#^https?://#i', $cvRel)) {
return $cvRel;
}
$base = rtrim(trim((string) Env::get('reviewer.cv_base_url', self::CV_BASE_URL_DEFAULT)), '/') . '/';
return $base . $cvRel;
}
private function resolveLocalCvPath($cvRel)
{
if (preg_match('#^https?://#i', $cvRel)) {
return '';
}
$path = ROOT_PATH . 'public' . DS . 'reviewer' . DS . str_replace('/', DS, $cvRel);
return is_file($path) ? $path : '';
}
private function downloadCvToTemp($url, $cvRel)
{
$ext = strtolower(pathinfo(parse_url($cvRel, PHP_URL_PATH) ?: $cvRel, PATHINFO_EXTENSION));
if ($ext === '') {
$ext = 'dat';
}
$saveDir = ROOT_PATH . 'runtime' . DS . 'user_file_parse';
if (!is_dir($saveDir)) {
@mkdir($saveDir, 0755, true);
}
$path = $saveDir . DS . date('YmdHis') . '_' . uniqid('', true) . '.' . $ext;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 90,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false || $code < 200 || $code >= 300) {
throw new Exception('下载简历失败HTTP ' . $code . ': ' . $url . ($err !== '' ? ' ' . $err : ''));
}
if (@file_put_contents($path, $body) === false) {
throw new Exception('保存简历临时文件失败');
}
return $path;
}
private function extractFileText($filePath)
{
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
if (in_array($ext, ['txt', 'text', 'md', 'csv', 'log'], true)) {
$raw = @file_get_contents($filePath);
return is_string($raw) ? $raw : '';
}
if ($ext === 'pdf') {
return $this->extractPdfText($filePath);
}
if (in_array($ext, ['doc', 'docx'], true)) {
return $this->extractDocxText($filePath);
}
// 未识别扩展名:先尝试 Word再尝试 PDF最后按纯文本读
$text = $this->tryExtractDocxText($filePath);
if ($text !== '') {
return $text;
}
$text = $this->tryExtractPdfText($filePath);
if ($text !== '') {
return $text;
}
$raw = @file_get_contents($filePath);
if (!is_string($raw) || $raw === '') {
return '';
}
if ($this->looksLikeBinary($raw)) {
throw new Exception('不支持的简历文件格式: .' . ($ext !== '' ? $ext : 'unknown'));
}
return $raw;
}
private function extractPdfText($filePath)
{
$text = $this->tryExtractPdfText($filePath);
if ($text === '') {
throw new Exception('PDF 文本提取失败,请确认已安装 smalot/pdfparsercomposer require smalot/pdfparser');
}
return $text;
}
private function tryExtractPdfText($filePath)
{
if (!class_exists(PdfParser::class)) {
\think\Log::warning('UserInfoFromFile: smalot/pdfparser not installed');
return '';
}
try {
$parser = new PdfParser();
$pdf = $parser->parseFile($filePath);
$text = $pdf->getText();
return is_string($text) ? $text : '';
} catch (\Throwable $e) {
\think\Log::warning('UserInfoFromFile PDF extract: ' . $e->getMessage());
return '';
}
}
private function extractDocxText($filePath)
{
$text = $this->tryExtractDocxText($filePath);
if ($text === '') {
throw new Exception('Word 文档文本提取失败');
}
return $text;
}
private function tryExtractDocxText($filePath)
{
if (!function_exists('docxReader')) {
return '';
}
try {
$text = docxReader($filePath);
return is_string($text) ? $text : '';
} catch (\Throwable $e) {
\think\Log::warning('UserInfoFromFile docxReader: ' . $e->getMessage());
return '';
}
}
private function looksLikeBinary($raw)
{
if (strpos($raw, "\0") !== false) {
return true;
}
$sample = substr($raw, 0, 4096);
if ($sample === '') {
return false;
}
$nonPrintable = preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $sample);
return $nonPrintable > 10;
}
private function parseUserInfoWithLlm($text)
{
$system = <<<'PROMPT'
你是学术人员信息抽取助手。根据用户上传的简历、简介或申请表文本,提取可用于期刊投稿/审稿人档案的基本信息。
只输出一个 JSON 对象,不要 markdown不要解释。字段说明
- realname: 中文姓名;若只有拼音可填拼音;无法判断则空字符串
- email: 邮箱
- phone: 手机或电话
- orcid: ORCID 号(仅数字与连字符)
- technical: 职称/职务(如教授、副主任护师、医学博士)
- field: 主要研究领域中文13 个词用顿号分隔
- company: 当前主要任职机构/工作单位(必填优先提取;填完整机构名,如「中南大学湘雅二医院」「暨南大学」,不要只写科室;多机构取最主要或最近一条)
- department: 所在科室/部门/学院(如感染科、药学院)
- country: 国家,默认中国可填「中国」
- introduction: 100字以内中文简介职务+机构+方向)
缺失字段用空字符串 ""不要编造。机构信息请从「工作单位」「任职」「所在单位」「affiliation」等段落提取。
PROMPT;
\think\Log::info('UserInfoFromFile user head: ' . mb_substr($text, 0, 600));
$raw = $this->llm->requestChat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => "请从以下文本提取用户信息:\n\n" . $text],
], 0.1);
if ($raw === null) {
throw new Exception('大模型请求失败,请检查 .env 中 [promotion] PROMOTION_LLM_URL / PROMOTION_LLM_MODEL与参考文献校对相同');
}
$parsed = $this->llm->parseJsonResponse($raw);
if ($parsed === null) {
throw new Exception('大模型返回无法解析为 JSON' . mb_substr($raw, 0, 200));
}
return [
'user_info' => $this->normalizeUserInfo($parsed),
'raw' => $raw,
];
}
private function normalizeUserInfo(array $parsed)
{
$keys = ['realname', 'email', 'phone', 'orcid', 'technical', 'field', 'company', 'department', 'country', 'introduction'];
$out = [];
foreach ($keys as $k) {
$out[$k] = trim((string) ($parsed[$k] ?? ''));
}
// 机构:兼容大模型可能返回的别名字段
if ($out['company'] === '') {
foreach (['机构', '单位', 'institution', 'affiliation', 'organization', 'org', '工作单位', '任职单位'] as $alias) {
$val = trim((string) ($parsed[$alias] ?? ''));
if ($val !== '') {
$out['company'] = $val;
break;
}
}
}
if ($out['orcid'] !== '') {
$out['orcid'] = preg_replace('/\s+/', '', $out['orcid']);
}
// 对外同时返回 institution与 company 同值)
$out['institution'] = $out['company'];
return $out;
}
private function sanitizeUtf8($text)
{
$text = (string) $text;
if (function_exists('mb_convert_encoding')) {
$text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
}
if (function_exists('iconv')) {
$fixed = @iconv('UTF-8', 'UTF-8//IGNORE', $text);
if (is_string($fixed)) {
$text = $fixed;
}
}
return $text;
}
}

View File

@@ -21,4 +21,10 @@ class RabbitMqConfig
$rc = self::get('reference_check', []);
return is_array($rc) ? $rc : [];
}
public static function aiWritingRisk()
{
$cfg = self::get('ai_writing_risk', []);
return is_array($cfg) ? $cfg : [];
}
}

View File

@@ -49,7 +49,11 @@ class ReferenceCheckArticleWorker
if (!$this->claimBatch($batchId)) {
$batch = $this->getBatch($batchId);
if (empty($batch) || intval($batch['batch_status']) === self::BATCH_DONE) {
// 已被其他消费者领取或已结束,当前消息直接跳过,避免同批次并发重复执行
if (empty($batch)
|| intval($batch['batch_status']) === self::BATCH_RUNNING
|| intval($batch['batch_status']) === self::BATCH_DONE
|| intval($batch['batch_status']) === self::BATCH_PARTIAL_FAILED) {
return;
}
}
@@ -100,7 +104,8 @@ class ReferenceCheckArticleWorker
$now = date('Y-m-d H:i:s');
$affected = Db::name('article_reference_relevance_check_batch')
->where('id', intval($batchId))
->whereIn('batch_status', [self::BATCH_WAITING, self::BATCH_RUNNING])
// 只允许 WAITING -> RUNNING禁止已 RUNNING 的批次被重复 claim
->where('batch_status', self::BATCH_WAITING)
->update([
'batch_status' => self::BATCH_RUNNING,
'updated_at' => $now,
@@ -145,12 +150,14 @@ class ReferenceCheckArticleWorker
} catch (\Exception $e) {
$this->svc->log('ReferenceCheckArticleWorker check_id=' . $checkId . ' err=' . $e->getMessage());
DbReconnectHelper::ensure();
if ($retryCount < ReferenceRelevanceCheckService::QUEUE_MAX_RETRY) {
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_PENDING, $retryCount + 1);
return $this->processOneRow($checkId, array_merge($row, ['retry_count' => $retryCount + 1]), $skipLiteratureFetch);
}
try {
$fresh = Db::name('article_reference_relevance_check_result')->where('id', intval($checkId))->find();
if (!empty($fresh) && intval($fresh['status']) === ReferenceRelevanceCheckService::RECORD_FAILED) {
if (intval($fresh['queue_status']) !== ReferenceRelevanceCheckService::QUEUE_FAILED) {
$this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_FAILED, $retryCount);
}
return 'failed';
}
$groupRows = !empty($fresh) ? $this->svc->findCitationGroupRowsForWorker($fresh) : [];
if (!empty($groupRows)) {
$this->svc->failGroupWithQueue($groupRows, $e->getMessage(), $retryCount);

File diff suppressed because it is too large Load Diff