285 lines
7.9 KiB
JavaScript
285 lines
7.9 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
export const REQUIRED_FIELDS = [
|
|
'bookSourceName',
|
|
'bookSourceUrl',
|
|
'bookSourceType',
|
|
];
|
|
|
|
export const OPTIONAL_TYPE_CHECKS = {
|
|
bookSourceGroup: ['string', 'null'],
|
|
bookUrlPattern: ['string', 'null'],
|
|
loginUrl: ['string', 'null'],
|
|
searchUrl: ['string', 'null'],
|
|
exploreUrl: ['string', 'null'],
|
|
weight: ['number', 'null'],
|
|
customOrder: ['number', 'null'],
|
|
enabled: ['boolean', 'null'],
|
|
enabledExplore: ['boolean', 'null'],
|
|
lastUpdateTime: ['number', 'null'],
|
|
};
|
|
|
|
export const RULE_FIELDS = {
|
|
ruleSearch: ['bookList', 'name', 'author', 'coverUrl', 'bookUrl'],
|
|
ruleExplore: ['bookList', 'name', 'author', 'coverUrl', 'bookUrl'],
|
|
ruleBookInfo: ['name', 'author', 'coverUrl', 'intro', 'tocUrl'],
|
|
ruleToc: ['chapterList', 'chapterName', 'chapterUrl'],
|
|
ruleContent: ['content'],
|
|
};
|
|
|
|
const PLACEHOLDER_TEXT = new Set([
|
|
'书籍列表规则',
|
|
'书名规则',
|
|
'作者规则',
|
|
'封面规则',
|
|
'详情页URL规则',
|
|
'简介规则',
|
|
'分类规则',
|
|
'最新章节规则',
|
|
'字数规则',
|
|
'目录URL规则',
|
|
'允许修改书名作者规则',
|
|
'章节列表规则',
|
|
'章节名称规则',
|
|
'章节URL规则',
|
|
'VIP标识规则',
|
|
'更新时间规则',
|
|
'目录下一页规则',
|
|
'正文内容规则',
|
|
'正文下一页规则',
|
|
'WebView JavaScript',
|
|
'资源正则规则',
|
|
]);
|
|
|
|
export async function loadSourceFile(filePath) {
|
|
const absolutePath = path.resolve(filePath);
|
|
const raw = await fs.readFile(absolutePath, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
return {
|
|
absolutePath,
|
|
raw,
|
|
parsed,
|
|
sources: Array.isArray(parsed) ? parsed : [parsed],
|
|
};
|
|
}
|
|
|
|
export function buildSearchPreview(searchUrl, keyword = '测试', page = '1') {
|
|
if (!searchUrl || typeof searchUrl !== 'string') {
|
|
return '';
|
|
}
|
|
|
|
return searchUrl
|
|
.replaceAll('{{key}}', keyword)
|
|
.replaceAll('{{page}}', page);
|
|
}
|
|
|
|
export function validateSourceObject(source) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
for (const field of REQUIRED_FIELDS) {
|
|
if (!(field in source)) {
|
|
errors.push(`缺少必填字段: ${field}`);
|
|
continue;
|
|
}
|
|
|
|
if (source[field] === '' || source[field] === null || source[field] === undefined) {
|
|
warnings.push(`字段为空: ${field}`);
|
|
}
|
|
}
|
|
|
|
if ('bookSourceType' in source && source.bookSourceType !== null && typeof source.bookSourceType !== 'number') {
|
|
errors.push('字段类型错误: bookSourceType 应为 number');
|
|
}
|
|
|
|
for (const [field, acceptedTypes] of Object.entries(OPTIONAL_TYPE_CHECKS)) {
|
|
if (!(field in source)) {
|
|
continue;
|
|
}
|
|
|
|
const value = source[field];
|
|
const valueType = value === null ? 'null' : typeof value;
|
|
if (!acceptedTypes.includes(valueType)) {
|
|
errors.push(`字段类型错误: ${field} 应为 ${acceptedTypes.join(' 或 ')}`);
|
|
}
|
|
}
|
|
|
|
for (const [ruleGroup, expectedFields] of Object.entries(RULE_FIELDS)) {
|
|
if (!(ruleGroup in source) || source[ruleGroup] === '' || source[ruleGroup] === null) {
|
|
continue;
|
|
}
|
|
|
|
if (typeof source[ruleGroup] !== 'object' || Array.isArray(source[ruleGroup])) {
|
|
errors.push(`${ruleGroup} 应为对象`);
|
|
continue;
|
|
}
|
|
|
|
if (Object.keys(source[ruleGroup]).length === 0) {
|
|
continue;
|
|
}
|
|
|
|
for (const field of expectedFields) {
|
|
if (!(field in source[ruleGroup])) {
|
|
warnings.push(`${ruleGroup} 缺少推荐字段: ${field}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const field of ['bookSourceUrl', 'searchUrl', 'exploreUrl', 'loginUrl']) {
|
|
if (!(field in source) || !source[field]) {
|
|
continue;
|
|
}
|
|
|
|
if (typeof source[field] !== 'string') {
|
|
continue;
|
|
}
|
|
|
|
if (!source[field].startsWith('http://') && !source[field].startsWith('https://') && !source[field].startsWith('/')) {
|
|
warnings.push(`${field} 看起来不是标准 URL 或相对路径: ${source[field]}`);
|
|
}
|
|
}
|
|
|
|
if (typeof source.searchUrl === 'string' && source.searchUrl && !source.searchUrl.includes('{{key}}')) {
|
|
warnings.push('searchUrl 中可能缺少 {{key}} 变量');
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
stats: {
|
|
sourceName: source.bookSourceName ?? '未知',
|
|
sourceUrl: source.bookSourceUrl ?? '未知',
|
|
configuredRuleGroups: Object.keys(RULE_FIELDS).filter((group) => group in source).length,
|
|
totalRuleGroups: Object.keys(RULE_FIELDS).length,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function auditSourceRules(source) {
|
|
const sections = {};
|
|
|
|
for (const [sectionName, sectionValue] of Object.entries(source)) {
|
|
if (!sectionName.startsWith('rule')) {
|
|
continue;
|
|
}
|
|
|
|
if (!sectionValue || typeof sectionValue !== 'object' || Array.isArray(sectionValue)) {
|
|
sections[sectionName] = {
|
|
totalFields: 0,
|
|
placeholderFields: [],
|
|
riskyFields: [],
|
|
notes: ['该规则组为空或不是对象'],
|
|
};
|
|
continue;
|
|
}
|
|
|
|
const placeholderFields = [];
|
|
const riskyFields = [];
|
|
|
|
for (const [fieldName, fieldValue] of Object.entries(sectionValue)) {
|
|
if (typeof fieldValue !== 'string') {
|
|
continue;
|
|
}
|
|
|
|
const normalized = fieldValue.trim();
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
|
|
if (PLACEHOLDER_TEXT.has(normalized)) {
|
|
placeholderFields.push(fieldName);
|
|
}
|
|
|
|
if (
|
|
normalized.includes('<js>') ||
|
|
normalized.startsWith('@js:') ||
|
|
normalized.includes('##') ||
|
|
normalized.startsWith(':')
|
|
) {
|
|
riskyFields.push(fieldName);
|
|
}
|
|
}
|
|
|
|
const notes = [];
|
|
if (placeholderFields.length > 0) {
|
|
notes.push('存在占位规则,表示这些字段还没有被真实规则替换');
|
|
}
|
|
if (riskyFields.length > 0) {
|
|
notes.push('存在 JS 或正则类规则,建议结合真实页面和接口再次人工确认');
|
|
}
|
|
|
|
sections[sectionName] = {
|
|
totalFields: Object.keys(sectionValue).length,
|
|
placeholderFields,
|
|
riskyFields,
|
|
notes,
|
|
};
|
|
}
|
|
|
|
return {
|
|
searchPreview: buildSearchPreview(source.searchUrl),
|
|
loginConfigured: Boolean(source.loginUrl),
|
|
sections,
|
|
};
|
|
}
|
|
|
|
export function formatValidationReport(report, index = null) {
|
|
const lines = [];
|
|
if (index !== null) {
|
|
lines.push(`书源 #${index + 1}`);
|
|
}
|
|
|
|
if (report.errors.length > 0) {
|
|
lines.push('错误:');
|
|
for (const error of report.errors) {
|
|
lines.push(` - ${error}`);
|
|
}
|
|
}
|
|
|
|
if (report.warnings.length > 0) {
|
|
lines.push('警告:');
|
|
for (const warning of report.warnings) {
|
|
lines.push(` - ${warning}`);
|
|
}
|
|
}
|
|
|
|
if (report.errors.length === 0 && report.warnings.length === 0) {
|
|
lines.push('验证通过');
|
|
}
|
|
|
|
lines.push(`名称: ${report.stats.sourceName}`);
|
|
lines.push(`URL: ${report.stats.sourceUrl}`);
|
|
lines.push(`规则组: ${report.stats.configuredRuleGroups}/${report.stats.totalRuleGroups}`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
export function formatAuditReport(source, audit) {
|
|
const lines = [];
|
|
lines.push(`书源: ${source.bookSourceName ?? '未知'}`);
|
|
lines.push(`站点: ${source.bookSourceUrl ?? '未知'}`);
|
|
lines.push(`登录配置: ${audit.loginConfigured ? '已配置 loginUrl' : '未配置 loginUrl'}`);
|
|
|
|
if (audit.searchPreview) {
|
|
lines.push(`搜索预览: ${audit.searchPreview}`);
|
|
}
|
|
|
|
for (const [sectionName, section] of Object.entries(audit.sections)) {
|
|
lines.push('');
|
|
lines.push(`${sectionName}:`);
|
|
lines.push(` 字段数: ${section.totalFields}`);
|
|
lines.push(` 占位字段: ${section.placeholderFields.length > 0 ? section.placeholderFields.join(', ') : '无'}`);
|
|
lines.push(` 高风险字段: ${section.riskyFields.length > 0 ? section.riskyFields.join(', ') : '无'}`);
|
|
|
|
for (const note of section.notes) {
|
|
lines.push(` 说明: ${note}`);
|
|
}
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('提示: 这个脚本只做规则审计和预览,不模拟 Legado 的完整解析结果。');
|
|
lines.push('提示: 真实是否可用,应结合浏览器页面、网络请求和 AI 对站点结构的分析来判断。');
|
|
return lines.join('\n');
|
|
}
|