PageSourceSearch

https://sealray-caichuchu-backend.hf.space/agent/chef-guides.js?v=20260802b

js sealray-caichuchu-backend.hf.space collected 2026-10-03 17:21:30 UTC 52,453 bytes, 1,163 lines download raw bytes

1/* ============================================
2   chef-guides.js — 大厨烹饪指南
3   菜谱页点击大厨头像时展示:
4   1. 通用技巧(按菜谱做法+核心食材匹配)
5   2. 本菜详细做法(data/guides/recipes/{菜名}.md)
6   ============================================ */
7
8const ChefGuides = {
9  _generalMd: null,
10  _sections: null,
11  _index: null,
12  _recipeCache: {},
13  _annotate: false,
14  _termRegex: null,
15
16  // 关键烹饪术语(详细做法中会被"画线"标注,像教科书笔记)
17  _getTermRegex() {
18    if (this._termRegex) return this._termRegex;
19    const terms = [
20      "热锅凉油", "中小火", "中大火", "炒糖色",
21      "焯水", "腌制", "去腥", "去膻", "飞水",
22      "大火", "小火", "中火", "火候", "油温",
23      "勾芡", "芡汁", "复炸", "上浆", "挂糊",
24      "收汁", "爆香", "定型", "翻面",
25      "滑嫩", "酥烂", "焦黄", "焦脆",
26    ];
27    terms.sort((a, b) => b.length - a.length);
28    this._termRegex = new RegExp("(" + terms.join("|") + ")", "g");
29    return this._termRegex;
30  },
31
32  // 加载并缓存通用技巧 markdown
33  async _loadGeneral() {
34    if (this._generalMd) return this._generalMd;
35    const resp = await fetch("data/guides/general-techniques.md");
36    if (!resp.ok) throw new Error("通用技巧加载失败");
37    this._generalMd = await resp.text();
38    return this._generalMd;
39  },
40
41  // 加载菜谱 id → 指南文件映射
42  async _loadIndex() {
43    if (this._index) return this._index;
44    const resp = await fetch("data/guides/index.json");
45    if (!resp.ok) throw new Error("指南索引加载失败");
46    this._index = await resp.json();
47    return this._index;
48  },
49
50  // 把通用技巧 markdown 按二级标题切分为章节
51  _parseSections() {
52    if (this._sections) return this._sections;
53    const md = this._generalMd || "";
54    const sections = [];
55    const parts = md.split(/^## /m);
56    for (let i = 1; i < parts.length; i++) {
57      const nl = parts[i].indexOf("\n");
58      const title = parts[i].slice(0, nl).trim();
59      const body = parts[i].slice(nl + 1).trim();
60      sections.push({ title, body });
61    }
62    this._sections = sections;
63    return sections;
64  },
65
66  // 根据菜谱做法 + 食材匹配通用章节
67  _matchSections(recipe) {
68    const sections = this._parseSections();
69    const matched = [];
70
71    // 1. 技法章节:按 methodLabel 精确匹配
72    const methodLabel = recipe.methodLabel || "";
73    if (methodLabel) {
74      const sec = sections.find((s) => s.title === `技法:${methodLabel}`);
75      if (sec) matched.push(sec);
76    }
77
78    // 2. 食材处理章节:按核心食材关键词匹配
79    const text = [
80      recipe.title || "",
81      ...(recipe.coreIngredients || []),
82      ...(recipe.requiredIngredients || []),
83    ].join(" ");
84
85    const ingredientRules = [
86      { section: "食材处理:猪肉", re: /猪肉|猪骨|猪排|猪里脊|猪肘|猪蹄|猪五花|猪腩|猪颈|猪舌|猪耳|猪肠|猪肝|猪腰|猪心|猪肺|猪板油|猪网油|五花|排骨|里脊|肉末|肉馅|肘子|蹄髈|蹄花|培根|火腿|腊肠|午餐肉|腊肉|咸肉/ },
87      { section: "食材处理:牛肉", re: /牛肉|牛腩|肥牛|牛排|牛腱|牛里脊|牛百叶|牛肚|牛筋|牛尾|牛骨|牛舌|牛杂/ },
88      { section: "食材处理:鸡肉", re: /鸡(?!蛋|精|汤|油)|鸡翅|鸡腿|鸡胸|鸡爪|鸡胗|鸡肝|鸡心|鸡架|土鸡|三黄鸡|乌鸡|童子鸡|老母鸡/ },
89      { section: "食材处理:鱼虾水产", re: /鱼(?!露)|虾|蟹|贝|蛤|蛏|鱿|海参|鳕|鲈|桂鱼|鲫|鲤|鳊|翘嘴|生蚝|小龙虾|田螺|墨鱼|章鱼|鱿鱼|带鱼|黄花鱼|多宝鱼|石斑|鲑鱼|三文鱼|金枪鱼|鳗鱼|泥鳅|黄鳝|甲鱼|龟|牛蛙|田鸡/ },
90      { section: "食材处理:蔬菜", re: /青菜|白菜|菠菜|芹菜|韭菜|生菜|油麦菜|空心菜|苋菜|芥蓝|菜心|西兰花|花菜|卷心菜|紫甘蓝|娃娃菜|茼蒿|香菜|香椿|芽菜|豆芽|豆角|四季豆|豇豆|荷兰豆|毛豆|蚕豆|豌豆|黄瓜|冬瓜|南瓜|丝瓜|苦瓜|茄子|番茄|西红柿|土豆|山药|芋头|红薯|紫薯|莲藕|萝卜|胡萝卜|白萝卜|青萝卜|竹笋|芦笋|莴笋|茭白|玉米|青椒|红椒|彩椒|尖椒|线椒|螺丝椒|洋葱|大葱|小葱|蒜苗|蒜苔|韭黄|菌|菇|香菇|平菇|金针菇|杏鲍菇|口蘑|木耳|银耳|海带|紫菜|豆腐|豆干|豆皮|腐竹|千张|素鸡/ },
91      { section: "食材处理:米饭面食", re: /米饭|炒饭|烩饭|焗饭|煲仔饭|粥|面条|拉面|刀削面|手擀面|挂面|意面|米粉|河粉|粉丝|粉条|凉粉|凉皮|米线|年糕|馒头|包子|饺子|馄饨|汤圆|烧卖|花卷|烙饼|煎饼|馕|吐司|面包|燕麦|糯米|粽子|汤圆|麻团|油条|烧饼|锅盔/ },
92    ];
93    for (const rule of ingredientRules) {
94      if (rule.re.test(text)) {
95        const sec = sections.find((s) => s.title === rule.section);
96        if (sec) matched.push(sec);
97      }
98    }
99
100    // 3. 油温与勾芡:炒/炸/烧/煎类菜谱附加
101    if (["炒", "炸", "烧", "煎"].includes(methodLabel)) {
102      const sec = sections.find((s) => s.title.startsWith("通用:油温与勾芡"));
103      if (sec) matched.push(sec);
104    }
105
106    return matched;
107  },
108
109  // 轻量 markdown → HTML(标题/加粗/列表/表格/引用/分隔线/段落)
110  renderMarkdown(md) {
111    const esc = (s) =>
112      s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
113    const inline = (s) => {
114      let out = esc(s)
115        .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
116        .replace(/`([^`]+)`/g, "<code>$1</code>");
117      if (this._annotate) {
118        out = out.replace(this._getTermRegex(), '<u class="cg-mark">$1</u>');
119      }
120      return out;
121    };
122
123    const lines = md.split("\n");
124    let html = "";
125    let i = 0;
126    while (i < lines.length) {
127      const line = lines[i];
128      const trimmed = line.trim();
129
130      // 空行
131      if (!trimmed) { i++; continue; }
132
133      // 分隔线
134      if (/^-{3,}$/.test(trimmed)) { html += "<hr>"; i++; continue; }
135
136      // 标题
137      const h = trimmed.match(/^(#{1,4})\s+(.*)$/);
138      if (h) {
139        const level = h[1].length + 2; // # → h3, ## → h4, ### → h5
140        html += `<h${level}>${inline(h[2])}</h${level}>`;
141        i++; continue;
142      }
143
144      // 表格
145      if (trimmed.startsWith("|")) {
146        const rows = [];
147        while (i < lines.length && lines[i].trim().startsWith("|")) {
148          rows.push(lines[i].trim());
149          i++;
150        }
151        const cells = (r) =>
152          r.replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
153        if (rows.length >= 2) {
154          html += "<table><thead><tr>";
155          for (const c of cells(rows[0])) html += `<th>${inline(c)}</th>`;
156          html += "</tr></thead><tbody>";
157          for (let r = 2; r < rows.length; r++) { // 跳过分隔行
158            html += "<tr>";
159            for (const c of cells(rows[r])) html += `<td>${inline(c)}</td>`;
160            html += "</tr>";
161          }
162          html += "</tbody></table>";
163        }
164        continue;
165      }
166
167      // 无序列表
168      if (/^[-*]\s+/.test(trimmed)) {
169        html += "<ul>";
170        while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
171          html += `<li>${inline(lines[i].trim().replace(/^[-*]\s+/, ""))}</li>`;
172          i++;
173        }
174        html += "</ul>";
175        continue;
176      }
177
178      // 有序列表(支持嵌套无序列表,保持编号连续)
179      if (/^\d+[.、]\s*/.test(trimmed)) {
180        html += "<ol>";
181        while (i < lines.length) {
182          const rawLine = lines[i];
183          const t = rawLine.trim();
184          // 有序条目
185          if (/^\d+[.、]\s*/.test(t)) {
186            html += `<li>${inline(t.replace(/^\d+[.、]\s*/, ""))}`;
187            // 收集后面的缩进项(子列表)
188            let j = i + 1;
189            let subItems = [];
190            while (j < lines.length) {
191              const nextRaw = lines[j];
192              const nextTrimmed = nextRaw.trim();
193              if (!nextTrimmed) { j++; continue; }
194              // 行首有空白 + 以 -/* 开头 → 子列表项
195              if (/^\s+[-*]\s+/.test(nextRaw)) {
196                subItems.push(nextTrimmed.replace(/^[-*]\s+/, ""));
197                j++;
198                continue;
199              }
200              break;
201            }
202            if (subItems.length > 0) {
203              html += "<ul>" + subItems.map(s => `<li>${inline(s)}</li>`).join("") + "</ul>";
204            }
205            html += "</li>";
206            i = j;
207            continue;
208          }
209          // 空行:跳过,但不结束列表
210          if (!t) {
211            i++;
212            continue;
213          }
214          // 非缩进的顶层 - 列表项(不应出现在有序列表中间,结束)
215          if (/^[-*]\s+/.test(t) && !/^\s+/.test(rawLine)) {
216            break;
217          }
218          break;
219        }
220        html += "</ol>";
221        continue;
222      }
223
224      // 引用
225      if (trimmed.startsWith(">")) {
226        html += `<blockquote>${inline(trimmed.replace(/^>\s*/, ""))}</blockquote>`;
227        i++; continue;
228      }
229
230      // 普通段落
231      html += `<p>${inline(trimmed)}</p>`;
232      i++;
233    }
234    return html;
235  },
236
237  /**
238   * 将AI生成的3+2+1格式大厨总结渲染为美化卡片样式
239   * 三个区块:💡要点(绿)/⚠️坑点(橙)/✨升级(紫)
240   */
241  renderAISummaryCards(md) {
242    if (!md || !md.trim()) return '';
243
244    const esc = (s) =>
245      s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
246    const inlineBold = (s) => esc(s).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
247
248    // 解析markdown为结构化区块
249    const sections = [];
250    const lines = md.split('\n').map(l => l.trim()).filter(l => l);
251    let currentSection = null;
252
253    for (const line of lines) {
254      const h2Match = line.match(/^##\s+(.+)$/);
255      if (h2Match) {
256        const title = h2Match[1].trim();
257        let type = 'key', icon = '💡', color = '#3cb371', lightColor = '#e8f5e9';
258        if (title.includes('失败') || title.includes('坑')) {
259          type = 'pitfall'; icon = '⚠️'; color = '#e67e22'; lightColor = '#fff3e0';
260        } else if (title.includes('升级') || title.includes('进阶')) {
261          type = 'upgrade'; icon = '✨'; color = '#9b59b6'; lightColor = '#f3e5f5';
262        }
263        currentSection = { title, type, icon, color, lightColor, items: [] };
264        sections.push(currentSection);
265        continue;
266      }
267      const numMatch = line.match(/^(\d+)[.、))]\s*(.+)$/);
268      if (numMatch && currentSection) {
269        currentSection.items.push(inlineBold(numMatch[2].trim()));
270        continue;
271      }
272      const bulletMatch = line.match(/^[-*]\s+(.+)$/);
273      if (bulletMatch && currentSection) {
274        currentSection.items.push(inlineBold(bulletMatch[1].trim()));
275      }
276    }
277
278    if (sections.length === 0) {
279      // 如果解析失败,退回普通markdown渲染
280      return `<div class="cg-ai-summary-content">${this.renderMarkdown(md)}</div>`;
281    }
282
283    // 统计数量
284    const keyCount = sections.find(s => s.type === 'key')?.items.length || 0;
285    const pitCount = sections.find(s => s.type === 'pitfall')?.items.length || 0;
286    const upCount = sections.find(s => s.type === 'upgrade')?.items.length || 0;
287
288    let html = '';
289
290    // 大厨标签
291    html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:12px;padding-left:4px;">
292      <span style="font-size:16px;">👨‍🍳</span>
293      <span style="font-size:15px;font-weight:600;color:#3cb371;">默认大厨总结</span>
294      <span style="margin-left:4px;padding:2px 6px;border-radius:4px;background:rgba(60,179,113,0.15);color:#3cb371;font-size:11px;font-weight:500;">AI生成</span>
295    </div>`;
296
297    // 渲染每个区块
298    for (const sec of sections) {
299      html += `<div style="margin-bottom:16px;">
300        <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;padding-left:4px;">
301          <div style="width:28px;height:28px;border-radius:50%;background:${sec.lightColor};display:flex;align-items:center;justify-content:center;font-size:14px;">${sec.icon}</div>
302          <div style="font-size:16px;font-weight:700;color:${sec.color};">${sec.title}</div>
303        </div>
304        <div style="display:flex;flex-direction:column;gap:8px;">`;
305
306      sec.items.forEach((item, idx) => {
307        html += `<div style="display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border-radius:12px;background:${sec.lightColor}80;">
308          <div style="flex-shrink:0;width:24px;height:24px;border-radius:50%;background:${sec.color};color:white;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">${idx + 1}</div>
309          <div style="font-size:14px;line-height:1.6;color:#333;flex:1;">${item}</div>
310        </div>`;
311      });
312
313      html += `</div></div>`;
314    }
315
316    return html;
317  },
318
319  // 加载单个菜谱的详细指南(带缓存)
320  async _loadRecipeGuide(file) {
321    if (this._recipeCache[file]) return this._recipeCache[file];
322    const resp = await fetch(`data/guides/recipes/${encodeURIComponent(file)}`);
323    if (!resp.ok) return null;
324    const md = await resp.text();
325    this._recipeCache[file] = md;
326    return md;
327  },
328
329  // 构建面板 HTML:通用技法 + 菜谱笔记(原步骤 + markdown 标注)
330  async buildGuideHTML(recipeTitle) {
331    try {
332      await Promise.all([this._loadGeneral(), this._loadIndex()]);
333
334      const recipe =
335        (typeof allRecipes !== "undefined" &&
336          allRecipes.find((r) => r.title === recipeTitle)) ||
337        { title: recipeTitle };
338
339      // 获取原菜谱步骤(优先 allRecipes,其次 DOM)
340      let steps = recipe.steps || [];
341      if (steps.length === 0 && window.ChefAgent) {
342        const domRecipe = ChefAgent.readCurrentRecipe();
343        if (domRecipe) steps = domRecipe.steps || [];
344      }
345
346      let html = "";
347
348      // 头部
349      html += `<div class="cg-notebook-header">
350        <span class="cg-notebook-icon">📝</span>
351        <div>
352          <div class="cg-notebook-title">${recipe.title} · 做菜笔记</div>
353          <div class="cg-notebook-sub">原菜谱步骤 + 大厨标注笔记</div>
354        </div>
355      </div>`;
356
357      // 做菜记忆
358      if (window.ChefMemory) {
359        const cooked = ChefMemory.getCookedRecipes();
360        const mem = cooked.find((r) => r.title === recipe.title);
361        if (mem) {
362          const date = new Date(mem.lastCooked).toLocaleDateString();
363          html += `<div class="cg-memory-note">
364            <span class="cg-memory-icon">🧠</span>
365            <span>你做过这道菜 ${mem.count} 次,上次 ${date}。</span>
366          </div>`;
367        }
368      }
369
370      // 通用技法(3 维度批注)
371      const sections = this._matchSections(recipe);
372      const ingredientSecs = sections.filter((s) => s.title.startsWith("食材处理"));
373      const techniqueSecs = sections.filter((s) => s.title.startsWith("技法"));
374      const generalSecs = sections.filter((s) => s.title.startsWith("通用"));
375      this._annotate = false;
376      if (ingredientSecs.length || techniqueSecs.length || generalSecs.length) {
377        html += `<div class="cg-annotations">
378          <div class="cg-annotations-label">📌 通用技法</div>`;
379        for (const sec of ingredientSecs)
380          html += this._renderAnnotationCard("🥩", sec.title.replace("食材处理:", ""), sec.body, "ingredient");
381        for (const sec of techniqueSecs)
382          html += this._renderAnnotationCard("🔥", sec.title.replace("技法:", ""), sec.body, "technique");
383        for (const sec of generalSecs)
384          html += this._renderAnnotationCard("💧", sec.title.replace("通用:", ""), sec.body, "general");
385        html += `</div>`;
386      }
387
388      // 菜谱笔记:原菜谱步骤(书本)+ markdown 标注(笔记)
389      const entry = this._index[recipe.id] ||
390        Object.values(this._index).find((e) => e.title === recipeTitle);
391      if (entry && steps.length > 0) {
392        const md = await this._loadRecipeGuide(entry.file);
393        if (md) {
394          const parsed = this._parseRecipeMarkdown(md);
395          const pool = [
396            ...parsed.prep.map((t) => ({ text: t, section: "食材准备" })),
397            ...parsed.steps.map((t) => ({ text: t, section: "详细做法" })),
398            ...parsed.tips.map((t) => ({ text: t, section: "关键技巧" })),
399            ...parsed.problems.map((t) => ({ text: t, section: "常见问题" })),
400          ];
401
402          // 全局匹配:按关键词重合度评分,贪心分配
403          const allPairs = [];
404          for (let si = 0; si < steps.length; si++) {
405            const stepKw = new Set(this._extractKeywords(steps[si]));
406            if (stepKw.size === 0) continue;
407            for (let pi = 0; pi < pool.length; pi++) {
408              const noteKw = this._extractKeywords(pool[pi].text);
409              const overlap = noteKw.filter((k) => stepKw.has(k));
410              if (overlap.length > 0)
411                allPairs.push({ si, pi, score: overlap.length });
412            }
413          }
414          allPairs.sort((a, b) => b.score - a.score);
415
416          const stepNotes = steps.map(() => []);
417          const usedIndices = new Set();
418          const maxPerStep = 3;
419          for (const pair of allPairs) {
420            if (usedIndices.has(pair.pi)) continue;
421            if (stepNotes[pair.si].length >= maxPerStep) continue;
422            stepNotes[pair.si].push(pool[pair.pi]);
423            usedIndices.add(pair.pi);
424          }
425
426          // 渲染:原步骤(带红色下划线锚点)+ 绿色引线批注
427          html += `<div class="cg-recipe-notes">
428            <div class="cg-recipe-notes-label">烹饪步骤</div>`;
429          for (let si = 0; si < steps.length; si++) {
430            const notes = stepNotes[si];
431            // 为步骤文本中的锚点词加红色下划线
432            const highlightedStep = this._highlightStepText(steps[si], notes);
433            html += `<div class="cg-step-block">
434              <div class="cg-step-text">
435                <span class="cg-step-num">${si + 1}</span>
436                <span class="cg-step-content">${highlightedStep}</span>
437              </div>`;
438            if (notes.length > 0) {
439              html += `<div class="cg-notes-wrapper">`;
440              for (const note of notes) {
441                html += `<div class="cg-annotation-bubble">
442                  <span class="cg-anno-dot"></span>
443                  <span class="cg-step-note-tag">${note.section}</span>
444                  <span class="cg-anno-text">${this.renderMarkdownInline(note.text)}</span>
445                </div>`;
446              }
447              html += `</div>`;
448            }
449            html += `</div>`;
450          }
451          html += `</div>`;
452
453          // 补充笔记
454          const remaining = pool.filter((_, idx) => !usedIndices.has(idx)).slice(0, 8);
455          if (remaining.length > 0) {
456            html += `<div class="cg-supplementary">
457              <div class="cg-supplementary-label">附加内容</div>`;
458            for (const note of remaining) {
459              html += `<div class="cg-supplementary-note">
460                <span class="cg-step-note-tag">${note.section}</span>
461                <span>${this.renderMarkdownInline(note.text)}</span>
462              </div>`;
463            }
464            html += `</div>`;
465          }
466          // B站视频链接
467          const biliKw = recipeTitle;
468          const safeBiliKw = JSON.stringify(biliKw);
469          html += `<a class="cg-bili-link" href="javascript:void(0)" onclick='openBiliSearch(${safeBiliKw});return false;'>
470            <svg class="cg-bili-icon" viewBox="0 0 48 48" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
471              <path d="M14 11L9 4M34 11l5-7" stroke="#00AEEC" stroke-width="3" stroke-linecap="round" fill="none"/>
472              <rect x="5" y="11" width="38" height="29" rx="8" fill="#00AEEC"/>
473              <rect x="10" y="17" width="28" height="17" rx="4" fill="#fff"/>
474              <ellipse cx="19" cy="25.5" rx="3" ry="3.5" fill="#00AEEC"/>
475              <ellipse cx="29" cy="25.5" rx="3" ry="3.5" fill="#00AEEC"/>
476            </svg>
477            <span>在B站看${recipeTitle}视频教学</span>
478            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M7 7h10v10"/></svg>
479          </a>`;
480        }
481      }
482
483      return html || `<div class="chef-guide-empty">暂无这道菜的烹饪指南</div>`;
484    } catch (e) {
485      return `<div class="chef-guide-empty">烹饪指南加载失败:${e.message}</div>`;
486    }
487  },
488
489  // 渲染单张批注卡片(左侧色条 + 图标 + 标题 + 正文)
490  _renderAnnotationCard(icon, name, body, type) {
491    return `<div class="cg-anno-card ${type}">
492      <div class="cg-anno-card-head"><span>${icon}</span><span>${name}</span></div>
493      <div class="cg-anno-card-body">${this.renderMarkdown(body)}</div>
494    </div>`;
495  },
496
497  // HTML 转义
498  _escapeHtml(s) {
499    return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
500  },
501
502  // 找步骤文本中的锚点词:从笔记中找在步骤里出现的最长有意义短语
503  _findAnchorPhrase(stepText, noteText) {
504    const badTail = new Set([
505      "朝","让","用","在","把","从","向","到","给","对","为","与","和","等",
506      "着","过","的","了","一","不","也","就","才","很","最","再","又","还",
507      "将","被","比","按","照","据","沿","顺","经","靠",
508      "轻","轻","大","小","多","少","好","快","慢","高","低","长","短","厚","薄",
509      "太","非","未","已","正","刚","恰","稍","略","些","点",
510    ]);
511    const badHead = new Set(["的","了","在","是","和","与","或","而","但","如","以","为","对","从","到","被","把","将","让","向","朝","给","跟","比","按","照"]);
512    // 把笔记按标点/空格切分,提取所有中文片段
513    const segments = noteText.split(/[,。;:!?、\s\d\.a-zA-Z,;:!?""'()()\[\]【】%℃~\-—…·]+/).filter(Boolean);
514    const candidates = new Set();
515    for (const seg of segments) {
516      const chinese = seg.replace(/[^\u4e00-\u9fa5]/g, "");
517      if (chinese.length < 2) continue;
518      // 取 2-5 字的子串(避免过长标注)
519      for (let len = Math.min(5, chinese.length); len >= 2; len--) {
520        for (let i = 0; i <= chinese.length - len; i++) {
521          const sub = chinese.slice(i, i + len);
522          if (!badTail.has(sub[sub.length - 1]) && !badHead.has(sub[0])) {
523            candidates.add(sub);
524          }
525        }
526      }
527    }
528    // 按长度降序,找第一个在步骤中出现的
529    const sorted = [...candidates].sort((a, b) => b.length - a.length);
530    for (const cand of sorted) {
531      if (stepText.includes(cand)) return cand;
532    }
533    return "";
534  },
535
536  // 在步骤文本中给锚点词加红色下划线(合并重叠/相邻的锚点)
537  _highlightStepText(stepText, notes) {
538    const anchors = [];
539    for (const note of notes) {
540      const anchor = this._findAnchorPhrase(stepText, note.text);
541      if (anchor && anchor.length >= 2) anchors.push(anchor);
542    }
543    if (anchors.length === 0) return this._escapeHtml(stepText);
544    // 在原文本中找到每个锚点的位置(每个锚点只取第一次出现)
545    const positions = [];
546    const used = new Set();
547    for (const a of anchors) {
548      const idx = stepText.indexOf(a);
549      if (idx !== -1 && !used.has(a)) {
550        positions.push({ start: idx, end: idx + a.length });
551        used.add(a);
552      }
553    }
554    if (positions.length === 0) return this._escapeHtml(stepText);
555    // 按起始位置排序,合并重叠/相邻但不跨标点的区间
556    positions.sort((a, b) => a.start - b.start);
557    const merged = [positions[0]];
558    const punctRE = /[,。;:!?、,;:!?""'()()\[\]【】\s]/;
559    for (let i = 1; i < positions.length; i++) {
560      const last = merged[merged.length - 1];
561      const cur = positions[i];
562      // 检查间隔中是否有标点
563      const gap = stepText.slice(last.end, cur.start);
564      if (cur.start <= last.end + 2 && !punctRE.test(gap)) {
565        last.end = Math.max(last.end, cur.end);
566      } else {
567        merged.push(cur);
568      }
569    }
570    // 从后往前切片,转义各段再拼接 u 标签
571    let result = "";
572    let cursor = stepText.length;
573    for (let i = merged.length - 1; i >= 0; i--) {
574      const { start, end } = merged[i];
575      const after = stepText.slice(end, cursor);   // 锚点后面的文本
576      const marked = stepText.slice(start, end);   // 锚点本身
577      result =
578        '<u class="cg-underline">' + this._escapeHtml(marked) + "</u>" +
579        this._escapeHtml(after) +
580        result;
581      cursor = start;
582    }
583    result = this._escapeHtml(stepText.slice(0, cursor)) + result;
584    return result;
585  },
586
587  // 内联 markdown 渲染(用于批注气泡内,不产生块级元素)
588  renderMarkdownInline(text) {
589    const esc = (s) =>
590      s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
591    let out = esc(text);
592    out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
593    out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
594    // 把冒号后的内容略做分段处理
595    out = out.replace(/:/g, ":");
596    return out;
597  },
598
599  // 解析菜谱 markdown,拆分为各章节的笔记单元
600  _parseRecipeMarkdown(md) {
601    const result = { prep: [], steps: [], tips: [], problems: [], variations: [] };
602    const body = md.replace(/^#\s+.*\n/, "");
603    const sections = body.split(/^## /m);
604    for (let i = 1; i < sections.length; i++) {
605      const nl = sections[i].indexOf("\n");
606      const title = sections[i].slice(0, nl).trim();
607      const content = sections[i].slice(nl + 1).trim();
608      let bucket = null;
609      if (/食材|准备|处理/.test(title)) bucket = result.prep;
610      else if (/详细做法|做法|步骤/.test(title)) bucket = result.steps;
611      else if (/关键技巧|技巧|要点/.test(title)) bucket = result.tips;
612      else if (/常见问题|问题|注意/.test(title)) bucket = result.problems;
613      else if (/变化做法|变化|延伸|拓展/.test(title)) bucket = result.variations;
614      if (!bucket) continue;
615
616      const lines = content.split("\n");
617      let current = "";
618      for (const line of lines) {
619        const trimmed = line.trim();
620        if (!trimmed) { if (current) { bucket.push(current); current = ""; } continue; }
621        if (/^\*\*.+\*\*$/.test(trimmed)) { if (current) { bucket.push(current); current = ""; } continue; }
622        if (/^[-*]\s+/.test(trimmed) || /^\d+[.、]\s*/.test(trimmed)) {
623          if (current) { bucket.push(current); }
624          current = trimmed.replace(/^[-*]\s+/, "").replace(/^\d+[.、]\s*/, "");
625        } else {
626          current = current ? current + " " + trimmed : trimmed;
627        }
628      }
629      if (current) { bucket.push(current); }
630    }
631    return result;
632  },
633
634  _notesActive: false,
635  _originalStepHtml: null,
636
637  // 共享逻辑:为步骤数组匹配笔记,返回 stepNotes 二维数组
638  async _matchNotesToSteps(steps, recipe) {
639    await Promise.all([this._loadGeneral(), this._loadIndex()]);
640    const entry = this._index[recipe.id] ||
641      Object.values(this._index).find((e) => e.title === recipe.title);
642    if (!entry) return steps.map(() => []);
643    const md = await this._loadRecipeGuide(entry.file);
644    if (!md) return steps.map(() => []);
645    const parsed = this._parseRecipeMarkdown(md);
646    const pool = [
647      ...parsed.prep.map((t) => ({ text: t, section: "食材准备" })),
648      ...parsed.steps.map((t) => ({ text: t, section: "详细做法" })),
649      ...parsed.tips.map((t) => ({ text: t, section: "关键技巧" })),
650      ...parsed.problems.map((t) => ({ text: t, section: "常见问题" })),
651    ];
652    const allPairs = [];
653    for (let si = 0; si < steps.length; si++) {
654      const stepKw = new Set(this._extractKeywords(steps[si]));
655      if (stepKw.size === 0) continue;
656      for (let pi = 0; pi < pool.length; pi++) {
657        const noteKw = this._extractKeywords(pool[pi].text);
658        const overlap = noteKw.filter((k) => stepKw.has(k));
659        if (overlap.length > 0)
660          allPairs.push({ si, pi, score: overlap.length });
661      }
662    }
663    allPairs.sort((a, b) => b.score - a.score);
664    const stepNotes = steps.map(() => []);
665    const usedIndices = new Set();
666    const maxPerStep = 3;
667    for (const pair of allPairs) {
668      if (usedIndices.has(pair.pi)) continue;
669      if (stepNotes[pair.si].length >= maxPerStep) continue;
670      stepNotes[pair.si].push(pool[pair.pi]);
671      usedIndices.add(pair.pi);
672    }
673    return stepNotes;
674  },
675
676  // 构建「大厨总结」面板 HTML(默认厨师AI总结 + 自定义厨师总结)
677  async buildSummaryHTML(recipeTitle) {
678    // 检查是否有启用的厨师
679    const enabledChefs = ChefManager.getEnabled();
680    if (enabledChefs.length === 0) {
681      return `<div class="chef-guide-empty">当前大厨功能还未启用</div>`;
682    }
683    try {
684      const recipe =
685        (typeof allRecipes !== "undefined" &&
686          allRecipes.find((r) => r.title === recipeTitle)) ||
687        { title: recipeTitle };
688      let html = "";
689      html += `<div class="cg-notebook-header">
690        <span class="cg-notebook-icon">📌</span>
691        <div>
692          <div class="cg-notebook-title">${recipe.title} · 大厨总结</div>
693          <div class="cg-notebook-sub">3个要点 + 2个坑 + 1个升级</div>
694        </div>
695      </div>`;
696      if (window.ChefMemory) {
697        const cooked = ChefMemory.getCookedRecipes();
698        const mem = cooked.find((r) => r.title === recipe.title);
699        if (mem) {
700          const date = new Date(mem.lastCooked).toLocaleDateString();
701          html += `<div class="cg-memory-note">
702            <span class="cg-memory-icon">🧠</span>
703            <span>你做过这道菜 ${mem.count} 次,上次 ${date}。</span>
704          </div>`;
705        }
706      }
707
708      let hasContent = false;
709
710      // 1. 默认大厨总结:AI针对该菜谱生成(3+2+1格式),带缓存
711      if (ChefManager.isEnabled(ChefManager.DEFAULT_CHEF_ID)) {
712        hasContent = true;
713        const cacheKey = `chef_summary_${recipe.id || recipe.title}`;
714        let cached = null;
715        try {
716          cached = JSON.parse(localStorage.getItem(cacheKey) || "null");
717        } catch (e) { cached = null; }
718
719        if (cached && cached.text) {
720          // 解析统计数量用于副标题
721          const keyMatch = cached.text.match(/##\s*最重要的[\d个]+点[\s\S]*?(?=##|$)/);
722          const pitMatch = cached.text.match(/##\s*最容易失败的[\d个]+点[\s\S]*?(?=##|$)/);
723          const upMatch = cached.text.match(/##\s*下次可以升级的[\d个]+点[\s\S]*?(?=##|$)/);
724          const keyN = keyMatch ? (keyMatch[0].match(/^\d+[.、]/gm) || []).length : 3;
725          const pitN = pitMatch ? (pitMatch[0].match(/^\d+[.、]/gm) || []).length : 2;
726          const upN = upMatch ? (upMatch[0].match(/^\d+[.、]/gm) || []).length : 1;
727          // 更新副标题
728          const subEl = document.querySelector('.cg-notebook-sub');
729          if (subEl) subEl.textContent = `${keyN}个要点 + ${pitN}个坑 + ${upN}个升级`;
730
731          html += `<div class="cg-ai-summary" style="margin-top:12px;">
732            ${this.renderAISummaryCards(cached.text)}
733          </div>`;
734        } else {
735          // 显示加载中,同时触发异步生成
736          const loadingId = `ai_summary_loading_${Date.now()}`;
737          html += `<div class="cg-ai-summary" id="${loadingId}" style="margin-top:12px;">
738            <div style="display:flex;align-items:center;gap:6px;margin-bottom:12px;padding-left:4px;">
739              <span style="font-size:16px;">👨‍🍳</span>
740              <span style="font-size:15px;font-weight:600;color:#3cb371;">默认大厨总结</span>
741            </div>
742            <div style="color:var(--text-muted);font-size:14px;display:flex;align-items:center;gap:8px;padding:16px;background:#f8f9fa;border-radius:12px;">
743              <span class="loading-spinner" style="width:16px;height:16px;border-width:2px;border-color:#3cb371;border-top-color:transparent;"></span>
744              大厨正在总结这道菜的要点...
745            </div>
746          </div>`;
747          // 异步生成AI总结
748          setTimeout(async () => {
749            try {
750              const ingredients = (recipe.coreIngredients || recipe.ingredients || []).join("、");
751              const steps = (recipe.steps || []).join(";");
752              const prompt = `你是一位经验丰富的中餐大厨,现在要给用户总结「${recipe.title}」这道菜的关键要点。
753
754这道菜的食材:${ingredients}
755这道菜的步骤:${steps}
756
757请严格按照以下格式输出总结,只针对这道菜,不要输出无关内容(不要提其他菜、不要提叶菜/土豆/勾芡/上浆等不相关技法,除非这道菜确实用到):
758
759## 最重要的3个点
7601. (第一点,一句话说清楚核心关键)
7612. (第二点)
7623. (第三点)
763
764## 最容易失败的2个点
7651. (第一个坑,具体说是什么问题以及如何避免)
7662. (第二个坑)
767
768## 下次可以升级的1个点
7691. (具体可操作的一个改进方向,比如调味、火候、搭配等,不要泛泛而谈)
770
771要求:语言简洁接地气,像大厨说话,每点不要超过2行。`;
772              const aiText = await callAI(prompt, "chef_summary");
773              // 缓存结果
774              localStorage.setItem(cacheKey, JSON.stringify({ text: aiText, time: Date.now() }));
775              // 更新副标题统计
776              const keyMatch = aiText.match(/##\s*最重要的[\d个]+点[\s\S]*?(?=##|$)/);
777              const pitMatch = aiText.match(/##\s*最容易失败的[\d个]+点[\s\S]*?(?=##|$)/);
778              const upMatch = aiText.match(/##\s*下次可以升级的[\d个]+点[\s\S]*?(?=##|$)/);
779              const keyN = keyMatch ? (keyMatch[0].match(/^\d+[.、]/gm) || []).length : 3;
780              const pitN = pitMatch ? (pitMatch[0].match(/^\d+[.、]/gm) || []).length : 2;
781              const upN = upMatch ? (upMatch[0].match(/^\d+[.、]/gm) || []).length : 1;
782              const subEl = document.querySelector('.cg-notebook-sub');
783              if (subEl) subEl.textContent = `${keyN}个要点 + ${pitN}个坑 + ${upN}个升级`;
784              // 更新DOM
785              const el = document.getElementById(loadingId);
786              if (el) {
787                el.innerHTML = this.renderAISummaryCards(aiText);
788              }
789            } catch (e) {
790              console.error("AI总结生成失败", e);
791              const el = document.getElementById(loadingId);
792              if (el) {
793                el.innerHTML = `<div style="display:flex;align-items:center;gap:6px;margin-bottom:12px;padding-left:4px;">
794                  <span style="font-size:16px;">👨‍🍳</span>
795                  <span style="font-size:15px;font-weight:600;color:#3cb371;">默认大厨总结</span>
796                </div>
797                <div style="color:#999;font-size:13px;padding:12px;background:#fff5f5;border-radius:8px;">总结生成失败,请稍后重试</div>
798                <button onclick="this.parentElement.remove();ChefGuides.buildSummaryHTML('${recipeTitle}').then(html=>{this.closest('.chef-guide-section').querySelector('.chef-guide-content').innerHTML=html;})" style="margin-top:8px;padding:6px 14px;border:1px solid #3cb371;border-radius:8px;background:white;color:#3cb371;font-size:12px;cursor:pointer;">重试</button>`;
799              }
800            }
801          }, 100);
802        }
803      }
804
805      // 2. 自定义大厨总结
806      const customSummaries = ChefManager.getEnabledChefSummariesForRecipe(recipeTitle);
807      for (const { chef, summary } of customSummaries) {
808        if (!chef.isDefault && summary) {
809          hasContent = true;
810          const avatarHtml = chef.avatar
811            ? `<img src="${chef.avatar}" style="width:22px;height:22px;border-radius:50%;object-fit:cover;vertical-align:middle;" />`
812            : "🧑‍🍳";
813          html += `<div class="cg-custom-summary-block" style="margin-top:20px;padding-top:16px;border-top:1px dashed #e0e0d8;">`;
814          html += `<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-left:4px;">
815            ${avatarHtml}
816            <span style="font-size:15px;font-weight:600;color:${chef.color};">${chef.name}的总结</span>
817          </div>`;
818          // 润色后内容
819          html += `<div class="cg-custom-note-content" style="padding:0;background:transparent;">${this.renderMarkdown(this._fillEmptySections(summary))}</div>`;
820          // 原始内容(润色前)
821          const note = chef.recipes.find(r => r.title === recipeTitle);
822          if (note && note.rawSummary && note.rawSummary.trim() && note.rawSummary.trim() !== summary.trim()) {
823            html += `<div class="cg-raw-content-block" style="margin-top:12px;padding:12px;background:#f5f5f0;border-radius:10px;">`;
824            html += `<div class="cg-raw-content-label" style="font-size:12px;color:#999;margin-bottom:6px;">📝 原始输入</div>`;
825            html += `<div class="cg-raw-content-text" style="font-size:13px;color:#888;line-height:1.6;">${this.renderMarkdown(this._fillEmptySections(note.rawSummary))}</div>`;
826            html += `</div>`;
827          }
828          html += `</div>`;
829        }
830      }
831
832      if (!hasContent) {
833        html += `<div class="chef-guide-empty">暂无这道菜的大厨总结</div>`;
834      }
835      return html;
836    } catch (e) {
837      return `<div class="chef-guide-empty">大厨总结加载失败:${e.message}</div>`;
838    }
839  },
840
841  // 「大厨笔记」:在原菜谱页面注入所有启用厨师的笔记(默认绿色 + 自定义彩色)
842  async injectNotesToPage(recipeTitle) {
843    // 检查是否有启用的厨师
844    const enabledChefs = ChefManager.getEnabled();
845    if (enabledChefs.length === 0) return false;
846
847    if (this._notesActive) this.clearNotesFromPage();
848    const recipe =
849      (typeof allRecipes !== "undefined" &&
850        allRecipes.find((r) => r.title === recipeTitle)) ||
851      { title: recipeTitle };
852    const stepItems = document.querySelectorAll(".recipe-detail .step-item, .recipe-detail-body .step-item");
853    if (stepItems.length === 0) return false;
854    const steps = [];
855    stepItems.forEach((item) => {
856      const textEl = item.querySelector(".step-text");
857      steps.push(textEl ? textEl.textContent.trim() : "");
858    });
859
860    // 保存原始HTML用于恢复
861    this._originalStepHtml = [];
862    stepItems.forEach((item) => {
863      const textEl = item.querySelector(".step-text");
864      if (!textEl) return;
865      this._originalStepHtml.push({ item, textEl, originalHtml: textEl.innerHTML, afterEls: [] });
866    });
867
868    let hasAnyNotes = false;
869
870    // 1. 默认大厨笔记(跟随大厨颜色,从markdown文件加载)
871    if (ChefManager.isEnabled(ChefManager.DEFAULT_CHEF_ID)) {
872      const defaultChef = ChefManager.getDefault();
873      const defaultColor = (defaultChef && defaultChef.color) || "#3cb371";
874      const stepNotes = await this._matchNotesToSteps(steps, recipe);
875      stepItems.forEach((item, si) => {
876        const textEl = item.querySelector(".step-text");
877        if (!textEl) return;
878        const notes = stepNotes[si];
879        if (notes && notes.length > 0) {
880          hasAnyNotes = true;
881          // 高亮步骤文本
882          const currentHtml = textEl.innerHTML;
883          textEl.innerHTML = this._highlightStepText(steps[si], notes);
884          // 构建批注容器(跟随大厨颜色)
885          const wrapper = this._buildNotesWrapper(notes, defaultColor);
886          const notesContainer = document.createElement("div");
887          notesContainer.className = "cg-page-notes-container";
888          notesContainer.appendChild(wrapper);
889          item.parentNode.insertBefore(notesContainer, item.nextSibling);
890          const entry = this._originalStepHtml.find(e => e.item === item);
891          if (entry) entry.afterEls.push(notesContainer);
892        }
893      });
894    }
895
896    // 2. 自定义大厨笔记(各厨师自有颜色,从用户输入内容匹配)
897    const customNotes = ChefManager.getEnabledChefNotesForRecipe(recipeTitle);
898    console.log("[ChefGuides] 自定义大厨笔记:", customNotes.length, "条", "for recipe:", recipeTitle);
899    for (const { chef, note } of customNotes) {
900      if (!chef.isDefault && note.content) {
901        console.log("[ChefGuides] 处理厨师:", chef.name, "content长度:", note.content.length, "content前80字:", note.content.substring(0, 80));
902        const customStepNotes = this._matchCustomNotesToSteps(steps, note.content);
903        const totalMatches = customStepNotes.reduce((s, n) => s + n.length, 0);
904        console.log("[ChefGuides] 匹配结果:", totalMatches, "条笔记");
905        stepItems.forEach((item, si) => {
906          const textEl = item.querySelector(".step-text");
907          if (!textEl) return;
908          const notes = customStepNotes[si];
909          if (notes && notes.length > 0) {
910            hasAnyNotes = true;
911            // 如果默认厨师没有高亮过,需要高亮
912            const currentText = textEl.textContent.trim();
913            if (!textEl.querySelector("u.cg-underline")) {
914              textEl.innerHTML = this._highlightStepText(currentText, notes);
915            }
916            // 构建批注容器(厨师自有颜色)
917            const wrapper = this._buildNotesWrapper(notes, chef.color, chef.name);
918            const notesContainer = document.createElement("div");
919            notesContainer.className = "cg-page-notes-container";
920            notesContainer.appendChild(wrapper);
921            item.parentNode.insertBefore(notesContainer, item.nextSibling);
922            const entry = this._originalStepHtml.find(e => e.item === item);
923            if (entry) entry.afterEls.push(notesContainer);
924          }
925        });
926
927        // 原始内容只显示一次,放在最后一个步骤后面
928        const hasRaw = note.rawContent && note.rawContent.trim() && note.rawContent.trim() !== note.content.trim();
929        if (hasRaw && stepItems.length > 0) {
930          const lastItem = stepItems[stepItems.length - 1];
931          const rawBlock = document.createElement("div");
932          rawBlock.className = "cg-page-notes-container chef-notes-custom";
933          rawBlock.style.setProperty("--cg-color", chef.color);
934          const rawInner = document.createElement("div");
935          rawInner.className = "cg-page-notes-wrapper";
936          rawInner.style.setProperty("--cg-color", chef.color);
937          rawInner.innerHTML =
938            '<div class="cg-page-annotation-bubble" style="color:' + chef.color + '">' +
939            '<span class="cg-step-note-tag">' + chef.name + ' · 原始输入</span>' +
940            '<div class="cg-raw-content-text">' + this.renderMarkdown(this._fillEmptySections(note.rawContent)) + '</div>' +
941            '</div>';
942          rawBlock.appendChild(rawInner);
943          // 找到最后一个步骤的下一个兄弟元素,插入到它后面
944          const afterLast = lastItem.nextElementSibling;
945          if (afterLast) {
946            lastItem.parentNode.insertBefore(rawBlock, afterLast);
947          } else {
948            lastItem.parentNode.appendChild(rawBlock);
949          }
950          const entry = this._originalStepHtml.find(e => e.item === lastItem);
951          if (entry) entry.afterEls.push(rawBlock);
952        }
953      }
954    }
955
956    this._notesActive = true;
957    document.body.classList.add("chef-notes-active");
958    return hasAnyNotes;
959  },
960
961  // 构建批注容器(支持自定义颜色和厨师名)
962  _buildNotesWrapper(notes, color, chefName) {
963    const wrapper = document.createElement("div");
964    wrapper.className = "cg-page-notes-wrapper";
965    if (chefName) wrapper.classList.add("cg-page-notes-custom");
966    // 始终设置 --cg-color,使默认大厨笔记也能跟随大厨颜色
967    wrapper.style.setProperty("--cg-color", color);
968    for (const note of notes) {
969      const bubble = document.createElement("div");
970      bubble.className = "cg-page-annotation-bubble";
971      bubble.style.setProperty("--cg-color", color);
972      const tagText = chefName ? `${chefName}·${note.section}` : note.section;
973      bubble.innerHTML =
974        '<span class="cg-anno-dot"></span>' +
975        '<span class="cg-step-note-tag">' + tagText + "</span>" +
976        '<span class="cg-anno-text">' + this.renderMarkdownInline(note.text) + "</span>";
977      wrapper.appendChild(bubble);
978    }
979    return wrapper;
980  },
981
982  // 填充空标题部分为"暂无"(用于自定义大厨内容渲染)
983  _fillEmptySections(md) {
984    const lines = md.split("\n");
985    const result = [];
986    for (let i = 0; i < lines.length; i++) {
987      result.push(lines[i]);
988      // 如果是 ## 标题行,检查后面是否有内容
989      if (/^##\s+/.test(lines[i].trim())) {
990        let j = i + 1;
991        let hasContent = false;
992        while (j < lines.length && !/^##\s+/.test(lines[j].trim())) {
993          if (lines[j].trim()) { hasContent = true; break; }
994          j++;
995        }
996        if (!hasContent) {
997          result.push("暂无");
998        }
999      }
1000    }
1001    return result.join("\n");
1002  },
1003
1004  // 匹配自定义厨师笔记到步骤(从用户输入的markdown文本)
1005  // 分类步骤类型:prep=备料, cook=烹饪, other=其他
1006  _classifyStepType(stepText) {
1007    const prepKw = /切|丝|片|丁|块|条|末|腌|渍|泡|洗|净|剥|削|剁|碎|抹|涂|裹|沾|蘸|拌|绞|撕|择|理|浸泡|焯水|去皮|去骨|去腥/;
1008    const cookKw = /炒|煎|炸|蒸|煮|炖|焖|烧|烤|扒|焗|爆|煸|熘|烩|焖|熬|汆|烫|焯|煸|锅|油|火|大火|小火|中火|热锅|温油|爆香|翻炒|收汁|勾芡|淋油|出锅|装盘/;
1009    if (prepKw.test(stepText) && !cookKw.test(stepText)) return "prep";
1010    if (cookKw.test(stepText)) return "cook";
1011    return "other";
1012  },
1013
1014  _matchCustomNotesToSteps(steps, noteContent) {
1015    const parsed = this._parseRecipeMarkdown(noteContent);
1016    let pool = [
1017      ...parsed.prep.map((t) => ({ text: t, section: "食材准备" })),
1018      ...parsed.steps.map((t) => ({ text: t, section: "详细做法" })),
1019      ...parsed.tips.map((t) => ({ text: t, section: "关键技巧" })),
1020      ...parsed.problems.map((t) => ({ text: t, section: "常见问题" })),
1021    ];
1022
1023    // 回退:如果 markdown 解析结果为空(纯文本内容未经AI处理),
1024    // 按行分割,每行作为一条笔记
1025    if (pool.length === 0) {
1026      const lines = noteContent.split("\n")
1027        .map((l) => l.trim())
1028        .filter((l) => l && !/^#{1,6}\s/.test(l) && l.length > 3);
1029      pool = lines.map((text) => ({ text, section: "笔记" }));
1030    }
1031
1032    // 最终仍为空则返回空
1033    if (pool.length === 0) return steps.map(() => []);
1034
1035    // 分类每个步骤的类型
1036    const stepTypes = steps.map((s) => this._classifyStepType(s));
1037    const firstPrepIdx = stepTypes.indexOf("prep");
1038    const firstCookIdx = stepTypes.indexOf("cook");
1039    const lastStepIdx = steps.length - 1;
1040
1041    // 分区匹配:不同section的笔记只匹配对应类型的步骤
1042    const stepNotes = steps.map(() => []);
1043    const usedIndices = new Set();
1044    const maxPerStep = 3;
1045
1046    // 按section分组
1047    const sections = ["食材准备", "详细做法", "关键技巧", "常见问题", "笔记"];
1048    for (const section of sections) {
1049      const sectionNotes = pool
1050        .map((n, idx) => ({ ...n, idx }))
1051        .filter((n) => n.section === section && !usedIndices.has(n.idx));
1052      if (sectionNotes.length === 0) continue;
1053
1054      // 确定该section可匹配的步骤范围
1055      let candidateSteps;
1056      if (section === "食材准备") {
1057        candidateSteps = firstPrepIdx >= 0 ? [firstPrepIdx] : [0];
1058      } else if (section === "详细做法") {
1059        candidateSteps = firstCookIdx >= 0 ? stepTypes.map((t, i) => t === "cook" ? i : -1).filter(i => i >= 0) : [lastStepIdx];
1060      } else {
1061        // 关键技巧/常见问题/笔记 → 匹配所有步骤
1062        candidateSteps = steps.map((_, i) => i);
1063      }
1064
1065      // 先尝试关键词匹配
1066      const pairs = [];
1067      for (const si of candidateSteps) {
1068        const stepKw = new Set(this._extractKeywords(steps[si]));
1069        if (stepKw.size === 0) continue;
1070        for (const n of sectionNotes) {
1071          const noteKw = this._extractKeywords(n.text);
1072          const overlap = noteKw.filter((k) => stepKw.has(k));
1073          if (overlap.length > 0)
1074            pairs.push({ si, note: n, score: overlap.length });
1075        }
1076      }
1077
1078      pairs.sort((a, b) => b.score - a.score);
1079      for (const pair of pairs) {
1080        if (usedIndices.has(pair.note.idx)) continue;
1081        if (stepNotes[pair.si].length >= maxPerStep) continue;
1082        stepNotes[pair.si].push(pair.note);
1083        usedIndices.add(pair.note.idx);
1084      }
1085
1086      // 未匹配的笔记:分配到该section的默认步骤
1087      for (const n of sectionNotes) {
1088        if (usedIndices.has(n.idx)) continue;
1089        let targetIdx;
1090        if (section === "食材准备") {
1091          targetIdx = firstPrepIdx >= 0 ? firstPrepIdx : 0;
1092        } else if (section === "详细做法") {
1093          targetIdx = firstCookIdx >= 0 ? firstCookIdx : lastStepIdx;
1094        } else {
1095          targetIdx = lastStepIdx;
1096        }
1097        if (stepNotes[targetIdx].length < maxPerStep) {
1098          stepNotes[targetIdx].push(n);
1099          usedIndices.add(n.idx);
1100        }
1101      }
1102    }
1103
1104    return stepNotes;
1105  },
1106
1107  // 清除页面上的大厨笔记,恢复原样
1108  clearNotesFromPage() {
1109    if (!this._notesActive || !this._originalStepHtml) return;
1110    for (const { textEl, originalHtml, afterEls } of this._originalStepHtml) {
1111      if (textEl) textEl.innerHTML = originalHtml;
1112      if (afterEls) {
1113        for (const el of afterEls) {
1114          if (el && el.parentNode) el.parentNode.removeChild(el);
1115        }
1116      }
1117    }
1118    this._originalStepHtml = null;
1119    this._notesActive = false;
1120    document.body.classList.remove("chef-notes-active");
1121  },
1122
1123  // 当前是否处于笔记模式
1124  isNotesActive() {
1125    return this._notesActive;
1126  },
1127
1128  // 提取中文关键词:2-gram 滑动窗口(中文无空格,需拆成二元词组)
1129  _extractKeywords(text) {
1130    const chinese = text.replace(/[^\u4e00-\u9fa5]/g, "");
1131    const stop = new Set([
1132      "备用","然后","这一","不要","可以","一下","轻轻","就是","这样",
1133      "一个","准备","进行","直接","同时","最后","首先","已经","时候",
1134      "时间","适量","少许","大约","需要","主要","方便","放入","锅中",
1135      "均匀","分钟","左右","即可","不够","记得","另外","表面","出来",
1136      "接着","没有","不能","还是","或者","比如","例如","现在","开始",
1137      "一直","部分","里面","上面","下面","前面","后面","中间","一次",
1138      "以后","以前","之前","之后","因为","所以","但是","不过","如果",
1139      "尽量","最好","建议","推荐","比较","非常","特别","稍微","略微",
1140      // 常见非烹饪 2-gram,容易产生误匹配
1141      "不超","超过","不到","不可","不太","不会","不一","不足",
1142      "大小","多少","以下","以上","以外","以内","此时","此刻",
1143      "这种","那种","各种","这种","那种","什么","怎么","为什么",
1144      "为了","什么","如何","是否","是否","应该","应当","必须",
1145      "可以","能够","使得","使用","利用","通过","这种","方式",
1146      "时候","情况","问题","方面","方法","过程","结果","效果",
1147      "导致","引起","造成","出现","发生","变成","成为","形成",
1148      "同样","不同","相同","类似","大概","也许","可能","似乎",
1149      "得到","做到","完成","结束","继续","停止","开始","保持",
1150      "加入","倒入","放入","盛盘","出锅","关火","开火", // 动作太泛,所有步骤都有
1151    ]);
1152    const terms = new Set();
1153    for (let i = 0; i < chinese.length - 1; i++) {
1154      const t = chinese.slice(i, i + 2);
1155      if (!stop.has(t)) terms.add(t);
1156    }
1157    return [...terms];
1158  },
1159};
1160
1161if (typeof window !== "undefined") {
1162  window.ChefGuides = ChefGuides;
1163}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.