参考文献作者堆叠

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

View File

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