参考文献作者堆叠
参考文献相关性检测 作者ai写作辅助检测工作
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
329
application/common/CmaJournalLiteratureService.php
Normal file
329
application/common/CmaJournalLiteratureService.php
Normal file
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
namespace app\common;
|
||||
|
||||
use think\Env;
|
||||
|
||||
/**
|
||||
* 中华医学会期刊 DOI(cma.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));
|
||||
}
|
||||
}
|
||||
55
application/common/DbReconnectHelper.php
Normal file
55
application/common/DbReconnectHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
120
application/common/EuropePmcService.php
Normal file
120
application/common/EuropePmcService.php
Normal 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 : '';
|
||||
}
|
||||
}
|
||||
512
application/common/Journal.php
Normal file
512
application/common/Journal.php
Normal 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];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
171
application/common/ProductionArticleReferLiteratureService.php
Normal file
171
application/common/ProductionArticleReferLiteratureService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
283
application/common/ReferenceAuthorIdentityService.php
Normal file
283
application/common/ReferenceAuthorIdentityService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤仅标点、过短或无字母/汉字的上下文(如去掉标签后只剩 ".")
|
||||
*/
|
||||
|
||||
622
application/common/ReferenceLiteratureFetchService.php
Normal file
622
application/common/ReferenceLiteratureFetchService.php
Normal 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) 中华医学会期刊 DOI:OpenAlex 中文摘要(可选 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
520
application/common/ReferenceReferAuthorService.php
Normal file
520
application/common/ReferenceReferAuthorService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
2415
application/common/ReferenceRelevanceCheckService.php
Normal file
2415
application/common/ReferenceRelevanceCheckService.php
Normal file
File diff suppressed because it is too large
Load Diff
834
application/common/ReferenceStackingStatsService.php
Normal file
834
application/common/ReferenceStackingStatsService.php
Normal 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/");
|
||||
}
|
||||
}
|
||||
76
application/common/UnpaywallService.php
Normal file
76
application/common/UnpaywallService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
358
application/common/UserInfoFromFileService.php
Normal file
358
application/common/UserInfoFromFileService.php
Normal 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/pdfparser:composer 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: 主要研究领域,中文,1~3 个词用顿号分隔
|
||||
- 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;
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user