审稿人证书生成

This commit is contained in:
2026-07-10 09:12:09 +08:00
parent 262e634f85
commit 3448ffe961
29 changed files with 7825 additions and 492 deletions

View File

@@ -0,0 +1,542 @@
import { htmlToPlainText } from '@/utils/manuscriptCitationRelevance';
import { parseBookReferenceContent } from '@/utils/parseBookReference';
/** 导入时去掉 (Chinese) 并规整空格 */
export function normalizeImportedReferenceText(text) {
return String(text || '')
.replace(/\(\s*Chinese\s*\)/gi, '')
.replace(/\u00a0/g, ' ')
.replace(/\s+\./g, '.')
.replace(/\s+/g, ' ')
.trim();
}
function sanitizeImportedReferenceHtml(html) {
return String(html || '')
.replace(/\(\s*Chinese\s*\)/gi, '')
.replace(/\(\s*Chinese\s*\)\s*\./gi, '.')
.replace(/\u00a0/g, ' ')
.replace(/\s+\./g, '.')
.replace(/\s{2,}/g, ' ')
.trim();
}
export function normalizeImportedReferenceFields(fields) {
if (!fields) return fields;
const keys = ['author', 'title', 'joura', 'dateno', 'doilink', 'doi', 'isbn', 'content'];
const out = Object.assign({}, fields);
keys.forEach(function (key) {
if (out[key] != null && out[key] !== '') {
out[key] = normalizeImportedReferenceText(out[key]);
}
});
return swapMisassignedYearAndSource(out);
}
/** 年份误写入 joura、来源误写入 dateno 时纠正web/news 引用常见) */
function swapMisassignedYearAndSource(fields) {
if (!fields) return fields;
const joura = String(fields.joura || '').trim();
const dateno = String(fields.dateno || '').trim();
if (
looksLikePlainYear(joura) &&
dateno &&
!looksLikePlainYear(dateno) &&
!looksLikeDateno(dateno)
) {
return Object.assign({}, fields, {
joura: dateno,
dateno: normalizePlainYearDateno(joura)
});
}
return fields;
}
function splitAtFirstPeriod(text) {
const raw = String(text || '').trim();
if (!raw) return { before: '', after: '' };
const match = raw.match(/\s*\.\s+/);
if (!match || match.index == null) {
return { before: raw, after: '' };
}
return {
before: raw.slice(0, match.index).trim(),
after: raw.slice(match.index + match[0].length).trim()
};
}
function withAuthorPeriod(name) {
const t = String(name || '').trim();
if (!t) return '';
return t.endsWith('.') ? t : t + '.';
}
function trimField(text) {
return normalizeImportedReferenceText(
String(text || '')
.replace(/\s+\.\s*$/, '')
.replace(/\.\s*$/, '')
);
}
function stripLeadingReferenceNumber(text) {
return String(text || '')
.replace(/^\s*(?:<[^>]+>\s*)*\[\d+\]\s*/i, '')
.replace(/^\s*(?:<[^>]+>\s*)*\d+\.\s*/i, '')
.trim();
}
function fixMissingSpaceAfterPeriod(plain) {
return String(plain || '').replace(/([A-Za-z)\]])\.([A-Za-z])/g, '$1. $2');
}
function looksLikeDateno(text) {
const value = String(text || '').trim();
return /^\d{4}[;:]\d+(?:\([^)]*\))?(?::[\w\-—eE.]+(?:[\-–—][\w\-—eE.]+)?)?\.?$/.test(value);
}
function looksLikePlainYear(text) {
return /^\d{4}\.?$/.test(String(text || '').trim());
}
function normalizePlainYearDateno(text) {
return String(text || '').trim().replace(/\.$/, '');
}
/** 提取末尾纯年份(如 web/news 引用Source. 2025. */
function extractPlainYearFromTail(plain) {
const text = String(plain || '').trim();
const match = text.match(/(?:^|\.\s+)(\d{4})\.\s*$/);
if (!match) {
return { body: text, dateno: '' };
}
const year = match[1];
let body = text.slice(0, match.index).trim().replace(/\.\s*$/, '');
return {
body: body,
dateno: normalizePlainYearDateno(year)
};
}
function normalizeDatenoField(dateno) {
return normalizeImportedReferenceText(String(dateno || '').trim()).replace(/^(\d{4}):/, '$1;');
}
function extractDoilink(html) {
const raw = String(html || '');
let body = raw.trim();
let doilink = '';
const availMatch = raw.match(/Available\s+at:\s*([\s\S]*)$/i);
if (availMatch) {
const tail = availMatch[1];
const anchorMatch = tail.match(/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>/i);
doilink = anchorMatch
? anchorMatch[1].trim()
: htmlToPlainText(tail).replace(/\s+/g, ' ').trim();
body = raw.slice(0, availMatch.index).trim();
} else {
const anchorMatch = raw.match(/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>/i);
if (anchorMatch) {
doilink = anchorMatch[1].trim();
body = raw.replace(anchorMatch[0], '').trim();
} else {
const urlMatch = raw.match(/(https?:\/\/(?:dx\.)?doi\.org\/[^\s<]+|10\.\d{4,9}\/[^\s<]+)\s*$/i);
if (urlMatch) {
doilink = htmlToPlainText(urlMatch[1]).replace(/\s+/g, ' ').trim();
body = raw.slice(0, urlMatch.index).trim();
}
}
}
if (doilink && !/^https?:\/\//i.test(doilink)) {
doilink = 'https://doi.org/' + doilink.replace(/^https?:\/\/doi\.org\//i, '');
}
return { body: body, doilink: doilink };
}
function mergeAdjacentItalicHtml(html) {
let result = String(html || '');
let prev = '';
while (prev !== result) {
prev = result;
result = result.replace(/<\/(?:i|em)>\s*<(?:i|em)(?:\s[^>]*)?>/gi, '');
}
return result;
}
function extractAllItalicTexts(html) {
const merged = mergeAdjacentItalicHtml(html);
const texts = [];
const re = /<(?:i|em)\b[^>]*>([\s\S]*?)<\/(?:i|em)>/gi;
let match;
while ((match = re.exec(merged)) !== null) {
const text = htmlToPlainText(match[1]).replace(/\s+/g, ' ').trim();
if (text) {
texts.push({ text: text, index: match.index, length: match[0].length });
}
}
return { merged: merged, texts: texts };
}
function cleanJournalName(name) {
return trimField(String(name || ''))
.replace(/\s*\.?\s*-+\.?\s*$/g, '')
.replace(/\s*\.\s*$/g, '')
.trim();
}
function extractJournalFromPlainBeforeDateno(plain) {
const text = String(plain || '').trim();
const patterns = [
/\.\s*([^.\d][^.]*?)\.\s*(\d{4}[;:]\d+(?:\([^)]*\))?:)/,
/\.\s*([^.\d][^.]+?)\s+(\d{4}[;:]\d+(?:\([^)]*\))?:)/,
/\s([A-Za-z][A-Za-z0-9 .&'-]{1,80})\.\s*(\d{4}[;:]\d+(?:\([^)]*\))?:)/,
/\.\s*([^.\d][^.]*?)\.\s*(\d{4}[;:]\d+(?:\([^)]*\))?)\.?\s*$/,
/\s([A-Za-z][A-Za-z0-9 .&'-]{1,80})\.\s*(\d{4}[;:]\d+(?:\([^)]*\))?)\.?\s*$/
];
for (let i = 0; i < patterns.length; i++) {
const match = text.match(patterns[i]);
if (match && match[1] && !looksLikeDateno(match[1])) {
return cleanJournalName(match[1]);
}
}
return '';
}
function extractJouraFromHead(head) {
const parts = String(head || '')
.split(/\.\s+/)
.map(function (part) {
return part.trim();
})
.filter(Boolean);
if (parts.length < 2) {
return { joura: '', rest: String(head || '').trim(), plainYear: '' };
}
while (parts.length && looksLikeDateno(parts[parts.length - 1])) {
parts.pop();
}
let plainYear = '';
if (parts.length && looksLikePlainYear(parts[parts.length - 1])) {
plainYear = normalizePlainYearDateno(parts.pop());
}
if (parts.length < 2) {
return { joura: '', rest: parts.join('. '), plainYear: plainYear };
}
let joura = cleanJournalName(parts.pop());
if (!joura || looksLikeDateno(joura) || looksLikePlainYear(joura)) {
if (joura && looksLikePlainYear(joura) && !plainYear) {
plainYear = normalizePlainYearDateno(joura);
}
return { joura: '', rest: parts.join('. '), plainYear: plainYear };
}
return { joura: joura, rest: parts.join('. '), plainYear: plainYear };
}
function extractItalicJournal(html) {
const raw = String(html || '');
const italicInfo = extractAllItalicTexts(raw);
if (!italicInfo.texts.length) {
return { body: raw.trim(), joura: '' };
}
const plainForPos = htmlToPlainText(italicInfo.merged).replace(/\s+/g, ' ').trim();
const datenoMatch = plainForPos.match(/\d{4}[;:]\d+(?:\([^)]*\))?:/);
const datenoPos = datenoMatch ? datenoMatch.index : plainForPos.length;
let joura = '';
for (let i = italicInfo.texts.length - 1; i >= 0; i--) {
const item = italicInfo.texts[i];
const pos = htmlToPlainText(italicInfo.merged.slice(0, item.index)).replace(/\s+/g, ' ').length;
if (pos <= datenoPos && item.text.length <= 120) {
joura = item.text;
break;
}
}
if (!joura) {
joura = italicInfo.texts[italicInfo.texts.length - 1].text;
}
joura = cleanJournalName(joura);
const body = italicInfo.merged
.replace(/<(?:i|em)\b[^>]*>([\s\S]*?)<\/(?:i|em)>/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return { body: body, joura: joura };
}
function extractDateno(plain) {
const text = String(plain || '').trim();
const patterns = [
/(\d{4}[;:]\d+(?:\([^)]*\))?:[\w\-—eE.]+(?:[\-–—][\w\-—eE.]+)?)\.?\s*$/,
/(\d{4}[;:]\d+(?:\([^)]*\))?:[\w\-—eE]+)\.?\s*$/,
/(\d{4}[;:]\d+(?:\([^)]*\))?)\.?\s*$/
];
for (let i = 0; i < patterns.length; i++) {
const match = text.match(patterns[i]);
if (match) {
return {
body: text.slice(0, match.index).trim().replace(/\.\s*$/, ''),
dateno: normalizeDatenoField(match[1])
};
}
}
return { body: text, dateno: '' };
}
function parseJournalReferenceContent(content) {
let html = stripLeadingReferenceNumber(String(content || '').trim());
if (!html) return null;
const linkPart = extractDoilink(html);
html = linkPart.body;
const italicPart = extractItalicJournal(html);
html = italicPart.body;
let plain = fixMissingSpaceAfterPeriod(normalizeImportedReferenceText(htmlToPlainText(html)));
plain = plain.replace(/\.\s*$/, '');
const datenoPart = extractDateno(plain);
let dateno = datenoPart.dateno;
let head = datenoPart.body;
if (!dateno) {
const plainYearPart = extractPlainYearFromTail(head || plain);
if (plainYearPart.dateno) {
dateno = plainYearPart.dateno;
head = plainYearPart.body;
}
}
let joura = cleanJournalName(italicPart.joura || '');
if (looksLikeDateno(joura) || looksLikePlainYear(joura)) {
if (looksLikePlainYear(joura) && !dateno) {
dateno = normalizePlainYearDateno(joura);
}
joura = '';
}
if (!joura) {
joura = extractJournalFromPlainBeforeDateno(head || plain);
}
const jouraFromHead = extractJouraFromHead(head);
if (!joura && jouraFromHead.joura) {
joura = jouraFromHead.joura;
head = jouraFromHead.rest;
} else if (joura && head) {
const jouraEsc = joura.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
head = head
.replace(new RegExp('\\.\s*' + jouraEsc + '\.?\s*$', 'i'), '')
.replace(/\s*\.?\s*-+\.?\s*$/g, '')
.trim();
}
if (!dateno && jouraFromHead.plainYear) {
dateno = jouraFromHead.plainYear;
}
if (looksLikeDateno(joura) && !dateno) {
dateno = normalizeDatenoField(joura);
const recovered = extractJouraFromHead(head);
joura = extractJournalFromPlainBeforeDateno(head) || recovered.joura;
head = recovered.rest;
if (!dateno && recovered.plainYear) {
dateno = recovered.plainYear;
}
}
if (!dateno) {
const trailingYear = extractPlainYearFromTail(head);
if (trailingYear.dateno) {
dateno = trailingYear.dateno;
head = trailingYear.body;
}
}
joura = cleanJournalName(joura);
const authorSplit = splitAtFirstPeriod(head);
const author = withAuthorPeriod(authorSplit.before);
const title = trimField(authorSplit.after);
if (!author && !title && !joura && !dateno && !linkPart.doilink) {
return null;
}
return {
refer_type: 'journal',
author,
title,
joura,
dateno: dateno,
doilink: linkPart.doilink,
doi: linkPart.doilink ? linkPart.doilink.replace(/^https?:\/\/doi\.org\//i, '').toLowerCase() : '',
isbn: '',
content: ''
};
}
function hasReferenceDoiLink(fields, rawHtml) {
const link = String((fields && (fields.doilink || fields.doi)) || '').trim();
if (link) {
if (/^https?:\/\/(?:dx\.)?doi\.org\//i.test(link)) {
return true;
}
if (/^doi:/i.test(link)) {
return true;
}
if (/^10\.\d{4,9}\//i.test(link)) {
return true;
}
}
return /doi\.org\/10\.\d{4,9}\//i.test(String(rawHtml || ''));
}
function hasReferenceIsbn(fields, rawHtml) {
const isbn = String((fields && fields.isbn) || '').trim();
if (isbn) {
return true;
}
return /ISBN\s*:/i.test(String(rawHtml || ''));
}
function buildReferenceContentFromPartialFields(fields) {
const parts = [];
if (fields && fields.author) {
parts.push(String(fields.author).trim());
}
if (fields && fields.title) {
parts.push(String(fields.title).trim());
}
if (fields && fields.joura) {
parts.push(String(fields.joura).trim());
}
if (fields && fields.dateno) {
parts.push(String(fields.dateno).trim());
}
let content = parts
.filter(Boolean)
.join(' ')
.replace(/\s+\./g, '.')
.replace(/\s+/g, ' ')
.trim();
const link = String((fields && fields.doilink) || '').trim();
if (link && !/Available at:/i.test(content)) {
content = content + '. Available at: ' + link;
}
return normalizeImportedReferenceText(content);
}
/** 无 DOI 且无 ISBN 的条目归为 other有 DOI 为 journal有 ISBN 为 book */
export function finalizeReferenceFields(fields, html) {
const base = Object.assign({}, fields || {});
const rawHtml = String(html || '').trim();
const plainFromHtml = normalizeImportedReferenceText(htmlToPlainText(rawHtml));
if (hasReferenceIsbn(base, rawHtml)) {
return normalizeImportedReferenceFields(
Object.assign({}, base, {
refer_type: 'book'
})
);
}
if (hasReferenceDoiLink(base, rawHtml)) {
return normalizeImportedReferenceFields(
Object.assign({}, base, {
refer_type: 'journal'
})
);
}
const content =
base.content ||
plainFromHtml ||
buildReferenceContentFromPartialFields(base);
return normalizeImportedReferenceFields({
refer_type: 'other',
author: '',
title: '',
joura: '',
dateno: '',
doilink: base.doilink || '',
doi: base.doi || '',
isbn: '',
content: content
});
}
/** 将单条参考文献 HTML/文本拆解为结构化字段 */
export function parseReferenceFieldsFromHtml(html, referTypeHint) {
const raw = sanitizeImportedReferenceHtml(stripLeadingReferenceNumber(String(html || '').trim()));
if (!raw) return null;
const hinted = String(referTypeHint || '').toLowerCase();
if (hinted === 'book' || /ISBN\s*:/i.test(raw)) {
const plain = normalizeImportedReferenceText(htmlToPlainText(raw));
const parsed = parseBookReferenceContent(plain);
if (parsed && parsed.isbn) {
return finalizeReferenceFields(
{
refer_type: 'book',
author: parsed.author || '',
title: parsed.title || '',
joura: '',
dateno: parsed.dateno || '',
doilink: '',
doi: '',
isbn: parsed.isbn || '',
content: ''
},
raw
);
}
}
const journalParsed = parseJournalReferenceContent(raw);
if (journalParsed) {
return finalizeReferenceFields(journalParsed, raw);
}
return finalizeReferenceFields(
{
refer_type: hinted || 'other',
author: '',
title: '',
joura: '',
dateno: '',
doilink: '',
doi: '',
isbn: '',
content: normalizeImportedReferenceText(htmlToPlainText(raw))
},
raw
);
}
export function applyDealContentToFields(fields, dealData) {
const base = fields || {};
const data = dealData || {};
if (!data || typeof data !== 'object') return normalizeImportedReferenceFields(base);
return normalizeImportedReferenceFields({
refer_type: base.refer_type || 'journal',
author: data.author || base.author || '',
title: data.title || base.title || '',
joura: data.joura || base.joura || '',
dateno: data.dateno || base.dateno || '',
doilink: data.doilink || data.doi || base.doilink || '',
doi: data.doi || data.doilink || base.doi || '',
isbn: data.isbn || base.isbn || '',
content: base.content || ''
});
}