参考文献作者堆叠
参考文献相关性检测 作者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>
|
||||
|
||||
Reference in New Issue
Block a user