Files
HowToCook/.github/manual_lint.js
Anduin Xue 539a2b7a9a AI 批量重新生成所有菜谱描述,规范化图片alt文本
- 363 个菜谱全部重新生成描述(30-300字,覆盖特点/营养/难度/时长)
- 134 张图片 alt 规范化:菜名-预览图-N 格式
- 新增 lint 规则:描述质量检查、图片 alt 文本规范
- 修复所有手动 lint / textlint / markdownlint 错误

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 10:20:23 +00:00

332 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const util = require("util");
const glob = util.promisify(require('glob'));
const fs = require("fs").promises;
const path = require('path');
const fsSyncAccess = require("fs");
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
// glob 模式,定位菜谱 Markdown 文件和所有文件
const DISHES_GLOB = path.resolve(__dirname, '../dishes/**/*.md');
const ALL_FILES_GLOB = path.resolve(__dirname, '../dishes/**/*');
// 工具函数:获取文件状态,包括大小
async function getFileStats(filePath) {
try {
const stats = await fs.stat(filePath);
return stats;
} catch (err) {
console.error(`检查文件状态时出错: ${filePath} -> ${err.message}`);
return null;
}
}
// 工具函数:读取文件内容并按行返回
async function readLines(filePath) {
const content = await fs.readFile(filePath, 'utf8');
return content.split('\n').map(line => line.trim());
}
// 校验函数集合
const validators = [
async (filePath, lines, errors) => {
const filenameWithoutExt = path.parse(filePath).name; // .name 是不带扩展名的文件名
if (filenameWithoutExt.includes(' ')) {
errors.push(`文件 ${filePath} 不符合仓库的规范!文件名不能包含空格! (当前文件名: ${filenameWithoutExt})`);
}
},
async (filePath, lines, errors) => {
const filenameWithoutExt = path.parse(filePath).name;
const expectedMainTitle = `# ${filenameWithoutExt}的做法`;
const titles = lines.filter(l => l.startsWith('#'));
if (!titles.length || titles[0] !== expectedMainTitle) {
errors.push(`文件 ${filePath} 不符合仓库的规范!它的大标题应该是: "${expectedMainTitle}"! 而它现在是 "${titles[0] || '未找到主标题'}"!`);
return;
}
const sections = lines.filter(l => l.startsWith('## '));
const requiredSections = ['## 必备原料和工具', '## 计算', '## 操作', '## 附加内容'];
if (sections.length !== requiredSections.length) {
errors.push(`文件 ${filePath} 不符合仓库的规范!它并不是四个二级标题的格式 (应为 ${requiredSections.length} 个,实际 ${sections.length} 个)。请从示例菜模板中创建菜谱!请不要破坏模板的格式!`);
return;
}
requiredSections.forEach((sec, idx) => {
if (sections[idx] !== sec) {
let titleName = "";
if (idx === 0) titleName = "第一个";
else if (idx === 1) titleName = "第二个";
else if (idx === 2) titleName = "第三个";
else if (idx === 3) titleName = "第四个";
errors.push(`文件 ${filePath} 不符合仓库的规范!${titleName}标题不是 ${sec}! (当前为: "${sections[idx] || '未找到'}")`);
}
});
// 检查烹饪难度和卡路里
const mainTitleIndex = titles.length > 0 ? lines.indexOf(titles[0]) : -1;
const firstSecondTitleIndex = sections.length > 0 ? lines.indexOf(sections[0]) : -1;
if (mainTitleIndex >= 0 && firstSecondTitleIndex >= 0 && mainTitleIndex < firstSecondTitleIndex) {
const contentBetweenTitles = lines.slice(mainTitleIndex + 1, firstSecondTitleIndex);
let hasDifficultyLine = false;
let hasCalorieLine = false;
const difficultyPatternGeneral = /^预估烹饪难度:\s*(★*)\s*$/;
const difficultyPatternStrict = /^预估烹饪难度:\s*★{1,5}\s*$/;
const caloriePattern = /^预估卡路里:\s*\d+\s*大卡$/;
for (const line of contentBetweenTitles) {
if (difficultyPatternGeneral.test(line)) {
hasDifficultyLine = true;
if (!difficultyPatternStrict.test(line)) {
const starMatch = line.match(/★/g);
const starCount = starMatch ? starMatch.length : 0;
errors.push(`文件 ${filePath} 不符合仓库的规范烹饪难度的星星数量必须在1-5颗之间(当前为 ${starCount} 颗)`);
}
}
if (caloriePattern.test(line)) {
hasCalorieLine = true;
}
}
if (!hasDifficultyLine) {
errors.push(`文件 ${filePath} 不符合仓库的规范!在大标题和第一个二级标题之间必须包含"预估烹饪难度:★★"格式的难度评级星星数量必须在1-5颗之间`);
}
if (!hasCalorieLine) {
errors.push(`文件 ${filePath} 不符合仓库的规范!在大标题和第一个二级标题之间必须包含"预估卡路里XXX大卡"。`);
}
} else if (mainTitleIndex === -1 || firstSecondTitleIndex === -1) {
errors.push(`文件 ${filePath} 结构错误,无法定位烹饪难度区域。`);
}
},
// 检查 操作 章节必须使用有序列表
async (filePath, lines, errors) => {
const opTitleIdx = lines.findIndex(l => l.startsWith('## 操作'));
if (opTitleIdx === -1) return;
const rawContent = await fs.readFile(filePath, 'utf8');
const rawLines = rawContent.split('\n');
const nextH2 = rawLines.findIndex((l, i) => i > opTitleIdx && l.startsWith('## '));
const endIdx = nextH2 === -1 ? rawLines.length : nextH2;
for (let i = opTitleIdx + 1; i < endIdx; i++) {
const rawLine = rawLines[i];
if (/^[-*+] /.test(rawLine)) {
errors.push(`文件 ${filePath} 不符合仓库的规范!「操作」章节必须使用有序列表 (1. 2. 3.),而不是 "${rawLine.slice(0, 2)}"`);
}
}
},
// 检查菜谱中不得出现 HTML 注释(示例菜模板除外)
async (filePath, lines, errors) => {
if (filePath.includes('template/示例菜')) return;
const rawContent = await fs.readFile(filePath, 'utf8');
if (rawContent.includes('<!--')) {
errors.push(`文件 ${filePath} 不符合仓库的规范!菜谱中不得出现 HTML 注释(<!-- -->)。请删除所有注释。`);
}
},
async (filePath, lines, errors) => {
const count = keyword => lines.filter(l => l.includes(keyword)).length;
if (count('勺') > count('勺子') + count('炒勺') + count('漏勺') + count('吧勺') + count('挂勺')) {
errors.push(`文件 ${filePath} 不符合仓库的规范!勺 不是一个精准的单位!`);
}
if (count(' 杯') > count('杯子')) {
errors.push(`文件 ${filePath} 不符合仓库的规范!杯 不是一个精准的单位!`);
}
['适量', '少许'].forEach(w => {
if (count(w) > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!${w} 不是一个精准的描述!请给出克 g 或毫升 ml。`);
}
});
if (count('min') > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范min 这个词汇有多重含义。建议改成中文"分钟"。`);
}
if (count('左右') > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!左右 不是一个能够明确定量的标准! 如果是在描述一个模糊物体的特征,请使用 '大约'。例如大约1kg`);
}
['你', '我'].forEach(pronoun => {
if (count(pronoun) > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!请不要出现人称代词。`);
}
});
},
async (filePath, lines, errors) => {
const hasPortion = lines.some(l => l.includes('份数'));
const hasTotal = lines.some(l => l.includes('总量'));
const hasTemplateLine = lines.some(l => l.includes('每次制作前需要确定计划做几份。一份正好够'));
if (hasPortion && (!hasTotal || !hasTemplateLine)) {
errors.push(`文件 ${filePath} 不符合仓库的规范!它使用份数作为基础,这种情况下一般是一次制作,制作多份的情况。请标明:总量 并写明 '每次制作前需要确定计划做几份。一份正好够 几 个人食用。'。`);
}
if (lines.some(l => l.includes('每人') || l.includes('人数'))) {
errors.push(`文件 ${filePath} 不符合仓库的规范!请基于每道菜\\每份为基准。不要基于人数。人数是一个可能会导致在应用中发生问题的单位。如果需要面向大量的人食用,请标明一个人需要几份。`);
}
},
async (filePath, lines, errors) => {
const footer = '如果您遵循本指南的制作流程而发现有问题或可以改进的流程,请提出 Issue 或 Pull request 。';
if (!lines.includes(footer)) {
errors.push(`文件 ${filePath} 不符合仓库的规范! 它没有包含必需的附加内容!,需要在最后一行添加模板中的【${footer}`);
}
},
// 检查菜谱描述30-300字需包含特点、营养价值、难度、制作时长
async (filePath, lines, errors) => {
if (filePath.includes('template/示例菜')) return;
const titles = lines.filter(l => l.startsWith('#'));
const mainTitleIndex = titles.length > 0 ? lines.indexOf(titles[0]) : -1;
const sections = lines.filter(l => l.startsWith('## '));
const firstSecondTitleIndex = sections.length > 0 ? lines.indexOf(sections[0]) : -1;
if (mainTitleIndex < 0 || firstSecondTitleIndex < 0) return;
const contentBetweenTitles = lines.slice(mainTitleIndex + 1, firstSecondTitleIndex);
const descriptionLines = contentBetweenTitles.filter(line => {
if (line === '') return false;
if (/^!\[.*\]\(.*\)$/.test(line)) return false;
if (/^预估烹饪难度:/.test(line)) return false;
if (/^预估卡路里:/.test(line)) return false;
return true;
});
const description = descriptionLines.join('').trim();
if (description.length === 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!缺少菜谱描述。请在主标题和"预估烹饪难度"之间添加一段30-300字的菜谱介绍包含菜品特点、营养价值、难度和制作时长。`);
} else if (description.length < 30) {
errors.push(`文件 ${filePath} 不符合仓库的规范!菜谱描述太短(当前 ${description.length} 字),至少需要 30 字。请补充菜品特点、营养价值、难度和制作时长。`);
} else if (description.length > 300) {
errors.push(`文件 ${filePath} 不符合仓库的规范!菜谱描述太长(当前 ${description.length} 字),最多 300 字。请精简描述。`);
}
},
// 检查图片 alt 文本质量
async (filePath, lines, errors) => {
if (filePath.includes('template/示例菜')) return;
const content = lines.join('\n');
const altRegex = /!\[([^\]]*)\]\([^)]+\.(?:jpg|jpeg|png|gif|webp|svg)\)/gi;
let match;
while ((match = altRegex.exec(content)) !== null) {
const alt = match[1];
const prefix = alt.slice(0, 20);
if (alt === '示例菜成品') {
errors.push(`文件 ${filePath} 不符合仓库的规范!图片 alt 文本不能为"示例菜成品",请替换为实际菜名!`);
} else if (/^[a-zA-Z0-9_-]+$/.test(alt) && alt.length < 20) {
errors.push(`文件 ${filePath} 不符合仓库的规范!图片 alt 文本"${alt}"看起来是拼音或英文缩写,应使用中文菜名!`);
} else if (alt === '成品' || alt === '效果图' || alt === '摆盘') {
errors.push(`文件 ${filePath} 不符合仓库的规范!图片 alt 文本"${alt}"过于泛泛,应包含具体菜名!`);
}
}
},
// 检查图片引用是否存在
async (filePath, lines, errors) => {
const fileDir = path.dirname(filePath);
const content = lines.join('\n');
// 匹配 ![alt](path) 和 [text](path) 的图片引用
// 支持相对路径和 URL
const imageRegex = /\[([^\]]*)\]\(([^)]+\.(?:jpg|jpeg|png|gif|webp|svg))\)/gi;
let match;
const imageRefs = new Set();
while ((match = imageRegex.exec(content)) !== null) {
imageRefs.add(match[2]);
}
// 检查每个引用的图片是否存在
for (const imagePath of imageRefs) {
// 跳过 URLhttp/https/ftp
if (imagePath.startsWith('http://') || imagePath.startsWith('https://') || imagePath.startsWith('ftp://')) {
continue;
}
// 解析相对路径
let fullImagePath;
if (imagePath.startsWith('/')) {
// 绝对路径相对于repo根目录
fullImagePath = path.resolve(__dirname, '../../', imagePath);
} else if (imagePath.includes('..')) {
// 相对路径(包含 ..
fullImagePath = path.resolve(fileDir, imagePath);
} else {
// 相对路径(同目录或子目录)
fullImagePath = path.resolve(fileDir, imagePath);
}
try {
fsSyncAccess.accessSync(fullImagePath);
} catch (err) {
errors.push(`文件 ${filePath} 引用了不存在的图片: ${imagePath}`);
}
}
}
];
async function main() {
const errors = [];
// 获取所有文件和 Markdown 文件路径
const allPaths = await glob(ALL_FILES_GLOB);
const mdPaths = await glob(DISHES_GLOB);
// 检查文件大小和扩展名
for (const p of allPaths) {
const stats = await getFileStats(p);
if (!stats) { // 如果获取状态失败,跳过后续检查
errors.push(`无法获取文件状态: ${p},跳过此文件的检查。`);
continue;
}
if (stats.size > MAX_FILE_SIZE) {
errors.push(`文件 ${p} 超过了1MB大小限制 (${(stats.size/1048576).toFixed(2)}MB)! 请压缩图片或分割文件。`);
}
// 检查扩展名
if (stats.isFile()) {
const ext = path.extname(p);
if (!ext) {
errors.push(`文件 ${p} 不符合仓库的规范!文件必须有扩展名!`);
}
}
}
// 对 Markdown 文件逐项校验内容
for (const p of mdPaths) {
const lines = await readLines(p);
for (const validate of validators) {
await validate(p, lines, errors);
}
}
// 输出错误并退出
if (errors.length) {
errors.forEach(e => console.error(e + "\n"));
const message = `Found ${errors.length} errors! Please fix!`;
throw new Error(message);
} else {
console.log("所有检查已通过!没有发现错误。");
}
}
main().catch(err => {
console.error("\n" + err.message);
process.exit(1);
});