/** * recipe-core.ts — 尚城幼儿园食谱转换核心库 * * 职责: * 1. parseDocxToRecipe: docx(mammoth) → 结构化食谱 JSON(支持常规宽表/转置宽表/长表/分段文本) * 2. fillTemplate: 食谱 JSON + 135编辑器模板HTML → 按原样式填充后的完整 HTML * (只改文本,不改任何 style/图片/结构;条目不足克隆模板条目,多余删除) * * 逻辑移植自已验证的 tools/parse_recipe.mjs 与 tools/fill_135editor.mjs。 */ import { JSDOM } from "jsdom"; // ---------- 类型 ---------- export interface RecipeItem { zh: string; en: string } export interface RecipeMeal { meal: string; items: RecipeItem[] } export interface RecipeDay { day: string; meals: RecipeMeal[] } export interface Recipe { days: RecipeDay[] } export interface MealFillInfo { day: string; meal: string; replaced: number; added: number; removed: number; oldCount: number; newCount: number; } export interface FillReport { filled: MealFillInfo[]; warnings: string[]; repaired: { day: string; meal: string; text: string }[]; mismatch: string[]; /** 模板中存在、但 docx 中没有的星期 → 已从输出中整段移除 */ removedDays: string[]; verification: { stylesAdded: number; stylesRemoved: number; styleSampleAdded: [string, number][]; styleSampleRemoved: [string, number][]; imgsUnchanged: boolean; sections: string; elements: string; }; } // ---------- docx 解析 ---------- const DAY_RE = /^星期[一二三四五六日]$/; const DAY_ALIASES: Record = { 周一: "星期一", 周二: "星期二", 周三: "星期三", 周四: "星期四", 周五: "星期五", 周六: "星期六", 周日: "星期日", 周天: "星期日", 礼拜一: "星期一", 礼拜二: "星期二", 礼拜三: "星期三", 礼拜四: "星期四", 礼拜五: "星期五", 礼拜六: "星期六", 礼拜日: "星期日", 礼拜天: "星期日", }; const MEAL_ALIASES: Record = { 早餐: "早餐", 早点: "早点", 水果: "早点", 果点: "早点", 午餐: "午餐", 中餐: "午餐", 午饭: "午餐", 体弱儿餐: "体弱儿餐", 体弱儿: "体弱儿餐", 特殊餐: "体弱儿餐", 午点: "午点", 午睡后: "午点", 点心: "午点", 下午点心: "午点", 下午茶: "午点", 晚餐: "晚餐", 晚点: "晚点", }; const SEP_RE = /[;;\n、]+/; const CJK_RE = /[\u4e00-\u9fff]/; const LATIN_RE = /[A-Za-z]/; const norm = (s: unknown) => String(s ?? "").replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim(); const normKey = (s: unknown) => String(s ?? "").replace(/\s+/g, ""); function canonDay(s: unknown): string | null { const t = normKey(s); const m = t.match(/星期[一二三四五六日]/); if (m) return m[0]; if (DAY_RE.test(t)) return t; const w = t.match(/周[一二三四五六日天]/); if (w) return w[0] === "周天" ? "星期日" : w[0].replace("周", "星期"); return DAY_ALIASES[t] ?? null; } function canonMeal(s: unknown): string | null { const t = normKey(s); if (MEAL_ALIASES[t]) return MEAL_ALIASES[t]; // 关键词按长短优先, 避免 "早点/水果餐" 被 "早" 误判为早餐 if (t.includes("体弱")) return "体弱儿餐"; if (t.includes("早点") || t.includes("水果")) return "早点"; if (t.includes("早餐")) return "早餐"; if (t.includes("午点") || t.includes("点心")) return "午点"; if (t.includes("午餐") || t.includes("中餐")) return "午餐"; if (t.includes("晚点")) return "晚点"; if (t.includes("晚餐")) return "晚餐"; if (t.includes("早")) return "早餐"; if (t.includes("午")) return "午餐"; if (t.includes("晚")) return "晚餐"; return null; } function ensureDay(rec: Recipe, day: string): RecipeDay { let d = rec.days.find((x) => x.day === day); if (!d) { d = { day, meals: [] }; rec.days.push(d); } return d; } function ensureMeal(d: RecipeDay, meal: string): RecipeMeal { let m = d.meals.find((x) => x.meal === meal); if (!m) { m = { meal, items: [] }; d.meals.push(m); } return m; } function addItem(m: RecipeMeal, zh: string, en: string) { const z = norm(zh), e = norm(en); if (!z && !e) return; m.items.push({ zh: z, en: e }); } /** 格子→菜品列表(中英交替行: 中文行=菜名, 紧跟英文行=其英文名)。必须接收原始文本(含换行)。 */ function splitCell(cell: string): { zhList: string[]; enList: string[] } { const raw = String(cell ?? "").replace(/\r/g, ""); const parts = raw.split(SEP_RE).map(norm).filter(Boolean); const items: { zh: string; en: string }[] = []; for (const p of parts) { if (CJK_RE.test(p)) items.push({ zh: p, en: "" }); else if (LATIN_RE.test(p)) { if (items.length) items[items.length - 1].en = p; else items.push({ zh: "", en: p }); } else items.push({ zh: p, en: "" }); } if (!items.some((x) => x.zh)) return { zhList: [], enList: items.map((x) => x.en) }; return { zhList: items.map((x) => x.zh).filter(Boolean), enList: items.map((x) => x.en).filter(Boolean) }; } /** B1 常规宽表: 表头含≥2个星期列, 第一列是餐次 */ function parseWideB1(rows: string[][]): Recipe | null { const rec: Recipe = { days: [] }; let header: string[] | null = null; let dayCols: (string | null)[] = []; for (const row of rows) { const days = row.map(normKey).map(canonDay); if (days.filter(Boolean).length >= 2) { header = row; dayCols = days; break; } } if (!header) return null; let curMeal: string | null = null; for (const row of rows) { const mealKey = canonMeal(row[0] ?? ""); if (mealKey) curMeal = mealKey; if (!curMeal) continue; for (let ci = 0; ci < header.length; ci++) { const day = dayCols[ci]; if (!day || !(row[ci] ?? "").trim()) continue; const { zhList, enList } = splitCell(row[ci]); if (!zhList.length && !enList.length) continue; const m = ensureMeal(ensureDay(rec, day), curMeal); const n = Math.max(zhList.length, enList.length); for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? ""); } } return rec.days.length ? rec : null; } /** B2 转置宽表: 星期在第一列, 餐次在表头行(用户真实文件即此格式) */ function parseWideB2(rows: string[][]): Recipe | null { const rec: Recipe = { days: [] }; for (let ri = 0; ri < rows.length; ri++) { const cells = rows[ri].map(normKey); const mealCols = cells .map((c, i) => (canonMeal(c) && i > 0 ? { meal: canonMeal(c)!, col: i } : null)) .filter((x): x is { meal: string; col: number } => !!x); if (mealCols.length < 2) continue; for (let rj = ri + 1; rj < rows.length; rj++) { const day = canonDay(rows[rj][0] ?? ""); if (!day) continue; for (const { meal, col } of mealCols) { if (!(rows[rj][col] ?? "").trim()) continue; const { zhList, enList } = splitCell(rows[rj][col]); if (!zhList.length && !enList.length) continue; const m = ensureMeal(ensureDay(rec, day), meal); const n = Math.max(zhList.length, enList.length); for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? ""); } } if (rec.days.length) return rec; } return null; } /** 长表: 列 = 星期|餐次|菜品|英文, 空星期/餐次沿用上一行(兼容合并单元格) */ function parseLongRows(rows: string[][]): Recipe { const rec: Recipe = { days: [] }; let curDay: string | null = null, curMeal: string | null = null; for (const row of rows) { if (!row.some((c) => norm(c))) continue; let dayIdx = -1, mealIdx = -1; for (let i = 0; i < row.length; i++) { if (canonDay(row[i]) && dayIdx < 0) dayIdx = i; if (canonMeal(row[i]) && mealIdx < 0 && i !== dayIdx) mealIdx = i; } if (dayIdx >= 0) curDay = canonDay(row[dayIdx]); if (mealIdx >= 0) curMeal = canonMeal(row[mealIdx]); if (!curDay || !curMeal) continue; const zhCells: string[] = [], enCells: string[] = []; for (let i = 0; i < row.length; i++) { if (i === dayIdx || i === mealIdx || !norm(row[i])) continue; (CJK_RE.test(row[i]) ? zhCells : enCells).push(row[i]); } const m = ensureMeal(ensureDay(rec, curDay), curMeal); const zhParts = zhCells.flatMap((c) => splitCell(c).zhList); let enParts = enCells.map(norm).filter(Boolean); if (!enParts.length) enParts = zhCells.flatMap((c) => splitCell(c).enList); const n = Math.max(zhParts.length, enParts.length); for (let i = 0; i < n; i++) addItem(m, zhParts[i] ?? "", enParts[i] ?? ""); } return rec; } /** 分段文本: 【星期一】/ 早餐:菜;菜 */ function parseTextLines(lines: string[]): Recipe { const rec: Recipe = { days: [] }; let curDay: string | null = null, curMeal: string | null = null; for (const raw0 of lines) { const raw = raw0.replace(/\r/g, "").trim(); if (!raw) continue; const dayM = raw.match(/^[【[]?\s*(星期[一二三四五六日])\s*[】\]]?$/); if (dayM) { curDay = dayM[1]; curMeal = null; continue; } const mealM = raw.match(/^(早餐|早点|水果|午餐|中餐|体弱儿餐|午点|点心|晚餐|晚点)\s*[::]\s*(.*)$/); if (mealM) { curMeal = canonMeal(mealM[1]); if (curDay && curMeal) { const m = ensureMeal(ensureDay(rec, curDay), curMeal); const { zhList, enList } = splitCell(mealM[2]); const n = Math.max(zhList.length, enList.length); for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? ""); } continue; } if (curDay && curMeal && (CJK_RE.test(raw) || LATIN_RE.test(raw))) { const m = ensureMeal(ensureDay(rec, curDay), curMeal); const { zhList, enList } = splitCell(raw); const n = Math.max(zhList.length, enList.length); for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? ""); } } return rec; } /** mammoth HTML → 表格行(单元格内多个

按行还原) */ function htmlToRows(html: string): string[][] { const rows: string[][] = []; const tableRe = /]*>([\s\S]*?)<\/tr>/g; let mt: RegExpExecArray | null; while ((mt = tableRe.exec(html))) { const cells = [...mt[1].matchAll(/]*>([\s\S]*?)<\/t[dh]>/g)].map((c) => (c[1] ?? "") .replace(//gi, "\n") .replace(/<\/p>/gi, "\n") .replace(/<[^>]+>/g, "") .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " "), ); rows.push(cells); } return rows; } export async function parseDocxToRecipe(buf: Buffer): Promise { const mammoth = await import("mammoth"); const { value: html } = await mammoth.convertToHtml({ buffer: buf }); const rows = htmlToRows(html); if (rows.length) { const rec = parseWideB1(rows) ?? parseWideB2(rows) ?? parseLongRows(rows); if (rec.days.length) return rec; } return parseTextLines(html.replace(/<[^>]+>/g, "\n").split("\n")); } // ---------- 模板填充(逻辑与 fill_135editor.mjs 页面脚本一致, DOM 换成 jsdom) ---------- const MEAL_KEY: Record = { 早餐: "breakfast", 早点: "fruit", 水果: "fruit", 午餐: "lunch", 中餐: "lunch", 体弱儿餐: "special", 午点: "snack", 点心: "snack", 晚餐: "dinner", 晚点: "dinner", }; const MEAL_KEY_TO_LABEL: Record = { breakfast: "早餐", fruit: "早点", lunch: "午餐", special: "体弱儿餐", snack: "午点", dinner: "晚餐", }; const DAY_SET = new Set(["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]); interface El { tagName: string; children: HTMLElement[]; childNodes: ChildNode[]; textContent: string; parentElement: HTMLElement | null; nextElementSibling: HTMLElement | null; getAttribute(n: string): string | null; cloneNode(deep: boolean): any; querySelectorAll(s: string): any[]; remove(): void; insertBefore(n: Node, r: Node | null): Node; parentNode: HTMLElement | null; } interface Item { zhEl: HTMLElement; zhCont: HTMLElement[]; enEl: HTMLElement | null; enCont: HTMLElement[]; zhBlock: HTMLElement | null; enBlock: HTMLElement | null; ca: HTMLElement | null; valid: boolean; } interface MealCol { key: string; items: Item[] } interface DayCol { day: string; meals: MealCol[]; titleEl: HTMLElement | null } const leafText = (el: HTMLElement) => (el.textContent ?? "").replace(/\u00a0/g, " ").trim(); function collect(body: HTMLElement): DayCol[] { const days: DayCol[] = []; let curDay: DayCol | null = null, curMeal: MealCol | null = null, curItem: Item | null = null, lastKind: string | null = null; const getDay = (t: string, el?: HTMLElement) => { let d = days.find((x) => x.day === t); if (!d) { d = { day: t, meals: [], titleEl: el ?? null }; days.push(d); } if (el && !d.titleEl) d.titleEl = el; return d; }; const getMeal = (d: DayCol, key: string) => { let m = d.meals.find((x) => x.key === key); if (!m) { m = { key, items: [] }; d.meals.push(m); } return m; }; for (const el of body.querySelectorAll("*") as unknown as HTMLElement[]) { if (el.children.length) continue; const t = leafText(el); if (!t) continue; if (DAY_SET.has(t)) { curDay = getDay(t, el); curMeal = null; curItem = null; lastKind = null; continue; } const mk = MEAL_KEY[t]; if (mk) { if (!curDay) continue; curMeal = getMeal(curDay, mk); curItem = null; lastKind = null; continue; } if (!curDay || !curMeal) continue; if (CJK_RE.test(t)) { if (lastKind === "zh" && curItem) curItem.zhCont.push(el); else { curItem = { zhEl: el, zhCont: [], enEl: null, enCont: [], zhBlock: null, enBlock: null, ca: null, valid: false }; curMeal.items.push(curItem); lastKind = "zh"; } } else if (LATIN_RE.test(t)) { if (curItem && lastKind === "zh" && !curItem.enEl) { curItem.enEl = el; lastKind = "en"; } else if (curItem && lastKind === "en") curItem.enCont.push(el); } } // 配对: 中英块的最近公共祖先下必须是相邻兄弟 const pathTo = (el: HTMLElement): HTMLElement[] => { const p: HTMLElement[] = []; let n: HTMLElement | null = el; while (n && n !== body) { p.unshift(n); n = n.parentElement; } return p; }; for (const d of days) for (const m of d.meals) for (const it of m.items) { it.valid = false; if (!it.enEl) continue; const zp = pathTo(it.zhEl), ep = pathTo(it.enEl); let ca: HTMLElement | null = null; for (let i = 0; i < Math.min(zp.length, ep.length); i++) { if (zp[i] === ep[i]) ca = zp[i]; else break; } if (!ca) continue; const zb = zp[zp.indexOf(ca) + 1], eb = ep[ep.indexOf(ca) + 1]; if (!zb || !eb) continue; if (zb.nextElementSibling !== eb) continue; it.zhBlock = zb; it.enBlock = eb; it.ca = ca; it.valid = true; } return days; } function daysToRecipe(days: DayCol[]): Recipe { return { days: days.map((d) => ({ day: d.day, meals: d.meals.map((m) => ({ meal: MEAL_KEY_TO_LABEL[m.key], items: m.items.filter((it) => it.valid).map((it) => ({ zh: [it.zhEl, ...it.zhCont].map(leafText).join(""), en: it.enEl && it.enCont.length ? [it.enEl, ...it.enCont].map(leafText).join("") : (it.enEl ? leafText(it.enEl) : ""), })), })), })), }; } interface Snapshot { styles: Map; imgs: string[]; sections: number; elements: number } function snapshot(body: HTMLElement): Snapshot { const styles = new Map(); let sections = 0, elements = 0; for (const el of body.querySelectorAll("*") as unknown as HTMLElement[]) { elements++; if (el.tagName === "SECTION") sections++; const st = el.getAttribute("style") || ""; if (st) styles.set(st, (styles.get(st) ?? 0) + 1); } const imgs = [...body.querySelectorAll("img")].map((im: any) => im.getAttribute("src") || ""); return { styles, imgs, sections, elements }; } function diffSnapshot(a: Snapshot, b: Snapshot) { const extras: [string, number][] = [], missing: [string, number][] = []; for (const [k, v] of b.styles) { const dv = v - (a.styles.get(k) ?? 0); if (dv > 0) extras.push([k, dv]); } for (const [k, v] of a.styles) { const dv = (b.styles.get(k) ?? 0) - v; if (dv > 0) missing.push([k, dv]); } const ia = [...a.imgs].sort().join("|"), ib = [...b.imgs].sort().join("|"); return { stylesAdded: extras.length, stylesRemoved: missing.length, styleSampleAdded: extras.slice(0, 3), styleSampleRemoved: missing.slice(0, 3), imgsUnchanged: ia === ib, sections: `${a.sections} -> ${b.sections}`, elements: `${a.elements} -> ${b.elements}`, }; } /** 块内文本统一设置: 兼容 元素叶子 / 块直接文本节点 / 空块;不触碰style与结构 */ function setBlockText(block: HTMLElement, txt: string, wantCJK: boolean) { for (const n of [...block.childNodes]) if (n.nodeType === 3) n.textContent = ""; const leaves = [...block.querySelectorAll("*")].filter((el: any) => el.children.length === 0) as HTMLElement[]; if (leaves.length) { const main = leaves.find((el) => (wantCJK ? CJK_RE.test(leafText(el)) : LATIN_RE.test(leafText(el)))) ?? leaves[0]; for (const el of leaves) if (el !== main) (el as HTMLElement).textContent = ""; main.textContent = txt; return; } const dts = [...block.childNodes].filter((n) => n.nodeType === 3); if (dts.length) { dts[0].textContent = txt; return; } block.textContent = txt; } /** 归一化修复: 列表容器里的裸文本菜名包进模板自身样式块(视觉不变) */ function normalizeBareText(days: DayCol[]): { day: string; meal: string; text: string }[] { const repaired: { day: string; meal: string; text: string }[] = []; for (const d of days) for (const m of d.meals) { const valid = m.items.filter((it) => it.valid); if (!valid.length) continue; const model = valid[valid.length - 1]; const containers = new Set(valid.map((it) => it.ca).filter(Boolean) as HTMLElement[]); for (const ca of containers) { for (const node of [...ca.childNodes]) { if (node.nodeType !== 3) continue; const t = (node.textContent ?? "").replace(/\u00a0/g, " ").trim(); if (!t || t.length > 60) continue; const isZh = CJK_RE.test(t), isEn = LATIN_RE.test(t); if (!isZh && !isEn) continue; if (isZh && model.zhBlock && model.enBlock) { const zc = model.zhBlock.cloneNode(true) as HTMLElement; const ec = model.enBlock.cloneNode(true) as HTMLElement; setBlockText(zc, t, true); setBlockText(ec, "", false); ca.insertBefore(zc, node); (zc.parentNode as HTMLElement).insertBefore(ec, zc.nextSibling); } else if (model.enBlock) { const ec = model.enBlock.cloneNode(true) as HTMLElement; setBlockText(ec, t, false); ca.insertBefore(ec, node); } ca.removeChild(node); repaired.push({ day: d.day, meal: MEAL_KEY_TO_LABEL[m.key], text: t }); } } } return repaired; } /** * 移除模板中存在、但 docx 食谱里没有的"天"区块(标题块+全部餐次块,含中间隔断)。 * 算法: 对相邻两天标题叶子求最近公共祖先(LCA),在该层级删除 [当天子块, 下一天子块) 区间; * 对最后一个模板天, 以该天最后一个有效条目的英文块为终点(含端点)删除。 * 只删区块节点, 不触碰任何保留内容的 style 与结构。 */ function removeMissingDays(body: HTMLElement, dayCols: DayCol[], recipe: Recipe): string[] { const wanted = new Set((recipe.days ?? []).map((d) => d.day)); const removed: string[] = []; const leaves = Array.from(body.querySelectorAll("*")).filter( (el) => el.children.length === 0 && el.textContent?.trim(), ); /** * 逐节点精确删除文档序区间 [a, b) (includeB 时含 b)。 * 规则: 节点属于 a 的祖先链时不可整体删除(内部可能含 a 之前需保留的内容, 如相邻天共用容器), * 只前进到其 nextSibling(必然位于 a 之后, 可删)或爬升; * 其余节点整体位于 a 之后, 直接删除, 无 nextSibling 时爬升(父节点按祖先链判断再决定); * 前进进入的节点若包含 b 则下钻。保证不误删保留天。 */ const removeBetween = (a: HTMLElement, b: HTMLElement | null, includeB: boolean) => { if (!a || !a.parentNode || (b && a.contains(b))) return; const ancestors = new Set(); for (let n: Node | null = a.parentElement; n; n = n.parentElement) ancestors.add(n); let cur: Node | null = a; let guard = 0; while (cur && guard++ < 500000) { if (cur === body) return; if (b && cur === b) { if (includeB) cur.parentNode?.removeChild(cur); return; } const el = cur as HTMLElement; const isAncestor = ancestors.has(cur); if (!isAncestor && b && typeof el.contains === "function" && el.contains(b)) { cur = el.firstChild; // 整体位于 a 之后且含 b: 下钻 continue; } if (isAncestor) { cur = cur.nextSibling ?? cur.parentNode; // 祖先不删: 前进(在 a 之后)或爬升 continue; } const nx = cur.nextSibling; const parent = cur.parentNode; cur.parentNode?.removeChild(cur); cur = nx ?? parent; } }; for (let i = 0; i < dayCols.length; i++) { const d = dayCols[i]; if (wanted.has(d.day)) continue; const titleEl = d.titleEl; if (!titleEl || !body.contains(titleEl)) continue; // 区间终点: 优先用下一个模板天的标题(排除); 最后一个天用尾部标记(排除), 兜底用最后条目的英文块(含端点) const nextTitle = dayCols[i + 1]?.titleEl ?? null; let endLeaf: HTMLElement | null = nextTitle && nextTitle !== titleEl && body.contains(nextTitle) ? nextTitle : null; let includeEnd = false; if (!endLeaf) { const tail = leaves.find((el) => { const t = el.textContent!.trim(); return (t === "三审三校" || t.startsWith("温馨提示")) && el !== titleEl && !titleEl.contains(el); }); if (tail && body.contains(tail)) { endLeaf = tail; includeEnd = false; } else { const items = d.meals.flatMap((m) => m.items.filter((it) => it.valid)); endLeaf = items.length ? (items[items.length - 1].enBlock ?? null) : null; includeEnd = !!endLeaf; } } removeBetween(titleEl, endLeaf, includeEnd); removed.push(d.day); } return removed; } export function fillTemplate(templateHtml: string, recipe: Recipe): { html: string; report: FillReport } { const dom = new JSDOM(`${templateHtml}`, { url: "https://www.135editor.com/" }); const body = dom.window.document.body as unknown as HTMLElement; const days = collect(body); const repaired = normalizeBareText(days); const dayCols = repaired.length ? collect(body) : days; // 先移除 docx 中缺失的星期(如周五缺席则整段删除周五), 再重新收集(删除后节点引用会失效)并做基线快照与填充 const removedDays = removeMissingDays(body, dayCols, recipe); const templateDayNames = new Set(dayCols.map((d) => d.day)); const fillCols = removedDays.length ? collect(body) : dayCols; const baseline = snapshot(body); const warnings: string[] = []; const filled: MealFillInfo[] = []; const joinLeaves = (els: HTMLElement[]) => els.map(leafText).join(""); for (const rd of recipe.days ?? []) { const d = fillCols.find((x) => x.day === rd.day); if (!d) { warnings.push(`编辑器模板中找不到 ${rd.day}`); continue; } for (const rm of rd.meals ?? []) { const mk = MEAL_KEY[rm.meal]; if (!mk) { warnings.push(`未知餐次: ${rm.meal}`); continue; } const m = d.meals.find((x) => x.key === mk); if (!m) { warnings.push(`${rd.day} 模板中无 ${rm.meal}`); continue; } const oldItems = m.items.filter((it) => it.valid); if (!oldItems.length) { warnings.push(`${rd.day} ${rm.meal} 模板无可克隆条目`); continue; } const newItems = rm.items ?? []; let added = 0, removed = 0, replaced = 0; // 1) 就地替换(文本未变不碰叶子, 保持字节分布) for (let i = 0; i < Math.min(oldItems.length, newItems.length); i++) { const it = oldItems[i], ni = newItems[i]; if (joinLeaves([it.zhEl, ...it.zhCont]) !== (ni.zh ?? "")) { it.zhEl.textContent = ni.zh ?? ""; for (const el of it.zhCont) el.textContent = ""; } const enNow = it.enEl ? joinLeaves([it.enEl, ...it.enCont]) : ""; if (enNow !== (ni.en ?? "")) { if (it.enEl) it.enEl.textContent = ni.en ?? ""; for (const el of it.enCont) el.textContent = ""; } replaced++; } // 2) 锚点递进克隆补足 const model = oldItems[oldItems.length - 1]; let anchorEn = model.enBlock!; for (let i = oldItems.length; i < newItems.length; i++) { const zc = model.zhBlock!.cloneNode(true) as HTMLElement; const ec = model.enBlock!.cloneNode(true) as HTMLElement; const parent = anchorEn.parentNode!; parent.insertBefore(zc, anchorEn.nextSibling); parent.insertBefore(ec, zc.nextSibling); setBlockText(zc, newItems[i].zh ?? "", true); setBlockText(ec, newItems[i].en ?? "", false); anchorEn = ec; added++; } // 3) 从后往前删除多余 for (let i = oldItems.length - 1; i >= newItems.length; i--) { const it = oldItems[i]; it.zhBlock?.remove(); it.enBlock?.remove(); for (const el of [...it.zhCont, ...it.enCont]) el.textContent = ""; removed++; } filled.push({ day: rd.day, meal: rm.meal, replaced, added, removed, oldCount: oldItems.length, newCount: newItems.length }); } } // 校验: 重新收集逐项比对 const after = snapshot(body); const recipe2 = daysToRecipe(collect(body)); const mismatch: string[] = []; for (const rd of recipe.days ?? []) { if (!templateDayNames.has(rd.day)) continue; // 模板本来就没有该天(如周末), 已在 warnings 提示, 不算 mismatch const d2 = recipe2.days.find((x) => x.day === rd.day); if (!d2) { mismatch.push(`${rd.day} 未收集到`); continue; } for (const rm of rd.meals ?? []) { const m2 = d2.meals.find((x) => x.meal === rm.meal); const exp = rm.items ?? []; const got = m2?.items ?? []; if (got.length !== exp.length) { mismatch.push(`${rd.day} ${rm.meal} 条数 ${exp.length}→${got.length}`); continue; } for (let i = 0; i < exp.length; i++) { if ((got[i].zh ?? "") !== (exp[i].zh ?? "") || (got[i].en ?? "") !== (exp[i].en ?? "")) { mismatch.push(`${rd.day} ${rm.meal} #${i + 1} 文本不符`); break; } } } } const html = (body as any).innerHTML as string; return { html, report: { filled, warnings, repaired, mismatch, removedDays, verification: diffSnapshot(baseline, after) }, }; } /** 提取模板当前食谱(可用于基线对照/测试) */ export function extractTemplateRecipe(templateHtml: string): Recipe { const dom = new JSDOM(`${templateHtml}`, { url: "https://www.135editor.com/" }); const body = dom.window.document.body as unknown as HTMLElement; return daysToRecipe(collect(body)); }