赛氪英语 AI 答题助手

自动解析并填写赛氪英语题目,支持单选 / 填空 / 阅读理解 / 选词填空。新增「使用说明」按钮,面向小白用户提供一步一步的简明指南。

// ==UserScript==
// @name         赛氪英语 AI 答题助手 
// @namespace    https://example.com
// @version      2.2.1
// @description  自动解析并填写赛氪英语题目,支持单选 / 填空 / 阅读理解 / 选词填空。新增「使用说明」按钮,面向小白用户提供一步一步的简明指南。
// @match        https://examzone.saikr.com/question/*
// @run-at       document-end
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @icon         https://examzone.saikr.com/favicon.ico
// @connect      *
// @license MIT
// ==/UserScript==

(function () {
  "use strict";

  /*************************************************************************
   * ⚠️  新增功能概览 (v2.2.0)
   * -----------------------------------------------------------------------
   *  1. 面板新增「使用说明」(README) 按钮:
   *     · 针对小白用户,通过 alert 弹窗提供安装、配置、答题全流程指导;
   *     · 内容浅显易懂,无需任何技术背景即可上手。
   *  2. 其余功能保持不变,与 v2.1.0 相兼容。
   * ---------------------------------------------------------------------*/

  /***** DOM 工具 *****/
  const $all = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const $one = (sel, root = document) => root.querySelector(sel);
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");

  const cleanText = (nodeOrStr) => {
    const text =
      typeof nodeOrStr === "string"
        ? nodeOrStr
        : nodeOrStr?.textContent ?? "";
    return text
      .replace(/\u00A0/g, " ")
      .replace(/\s+\n/g, "\n")
      .replace(/[ \t]+/g, " ")
      .replace(/\s*\n\s*/g, "\n")
      .trim();
  };

  const getStemText = (stemEl) => {
    if (!stemEl) return "";
    const ps = $all("p", stemEl);
    if (ps.length)
      return ps
        .map((p) => cleanText(p))
        .filter(Boolean)
        .join("\n\n");
    return cleanText(stemEl);
  };

  const extractIndexFromStem = (stemText) => {
    const m = stemText.match(/^\s*(\d+)\s*\./);
    return m ? m[1] : null;
  };

  /***** 题型解析 *****/
  function parseSingleChoice(choiceEl) {
    const stemEl = $one(".stem", choiceEl) || choiceEl;
    let stem = getStemText(stemEl);
    const idxMaybe = extractIndexFromStem(stem);
    if (idxMaybe) stem = stem.replace(/^\s*\d+\s*\.\s*/, "");

    const optionNodes = [];
    const options = [];
    const localLabels = $all(
      ".el-radio-group label, .el-radio",
      choiceEl
    );
    (localLabels.length ? localLabels : $all("label", choiceEl)).forEach((lab) => {
      const lblEl = $one(".el-radio__label", lab) || lab;
      let txt = cleanText(lblEl);
      const innerDiv = $one("div", lblEl);
      if (innerDiv) txt = cleanText(innerDiv) || txt;
      const letter = LETTERS[options.length] || "";
      if (!/^[A-Z]\./.test(txt)) txt = `${letter}. ${txt}`;
      options.push(txt);
      optionNodes.push(lab);
    });

    const setter = (letter) => {
      const idx = LETTERS.indexOf((letter || "").toUpperCase());
      if (idx < 0 || idx >= optionNodes.length) return false;
      const lab = optionNodes[idx];
      const input = $one("input[type=radio]", lab) || lab;
      lab?.click?.();
      input?.click?.();
      const inner = $one(".el-radio__inner", lab);
      inner?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
      return true;
    };

    return {
      type: "single",
      index: idxMaybe,
      stem,
      options,
      root: choiceEl,
      setAnswer: setter,
    };
  }

  function parseFill(fillEl) {
    const stemEl = $one(".stem", fillEl) || fillEl;
    let stem = getStemText(stemEl);
    const idxMaybe = extractIndexFromStem(stem);
    if (idxMaybe) stem = stem.replace(/^\s*\d+\s*\.\s*/, "");

    const inputs = $all(
      ".el-input input, input.el-input__inner, input[type=text]",
      fillEl
    );
    const setter = (values) => {
      const arr = Array.isArray(values)
        ? values
        : typeof values === "string"
        ? [values]
        : [];
      for (let i = 0; i < inputs.length; i++) {
        const v = arr[i] ?? arr[0] ?? "";
        const inp = inputs[i];
        inp.focus();
        inp.value = v;
        inp.dispatchEvent(new Event("input", { bubbles: true }));
        inp.blur();
      }
      return true;
    };
    return {
      type: "fill",
      index: idxMaybe,
      stem,
      blanks: inputs.length || 1,
      root: fillEl,
      setAnswer: setter,
    };
  }

  /**
   * Word‑Bank Cloze Parser (选词填空)
   * -----------------------------------------------------------
   * HTML 特征:
   * <div class="paper-detail-item">
   *   <div class="stem"> Directions: …fill in each blank… </div>
   *   <div class="material-detail">
   *     <div class="material-detail-item">(blank 1)</div>
   *     ...
   *   </div>
   * </div>
   */
  function parseClozeGroup(paperItem, groupIdx) {
    const stemEl = $one(".stem", paperItem);
    const passage = getStemText(stemEl)
      .replace(/Directions:/i, "")
      .trim();

    const bankItems = [];
    const blanks = [];
    const singles = $all(".material-detail-item .single-choice", paperItem);

    singles.forEach((sc, i) => {
      const q = parseSingleChoice(sc);
      blanks.push(q);
      if (i === 0) bankItems.push(...q.options);
    });

    return {
      gid: `C${groupIdx}`,
      passage,
      bank: bankItems,
      items: blanks,
      root: paperItem,
    };
  }

  /***** 阅读 / Cloze 分组解析 *****/
  function parseReadingGroup(materialWrap) {
    const parent = materialWrap.parentElement;
    const prevStem = $one(".stem", parent);
    let passage = prevStem ? getStemText(prevStem) : "";
    passage = passage.replace(/^\s*\d+\s*\.\s*/, "").trim();

    const items = [];
    const blocks = $all(".material-detail-item", materialWrap);
    for (const it of blocks) {
      const sc = $one(".single-choice", it);
      if (sc) items.push(parseSingleChoice(sc));
    }
    return { passage, items, root: materialWrap };
  }

  /***** 统一抽取入口 *****/
  function extractAll() {
    const result = { meta: { title: document.title, url: location.href }, questions: [] };
    const handled = new Set();

    /* --- Step 1: Word‑Bank Cloze (选词填空) --- */
    const paperItems = $all(".paper-detail-item");
    let clozeIdx = 0;
    for (const item of paperItems) {
      if (handled.has(item)) continue;
      const stemTxt = getStemText($one(".stem", item) || item);
      if (/fill in each blank/i.test(stemTxt) && $one(".material-detail", item)) {
        clozeIdx += 1;
        const group = parseClozeGroup(item, clozeIdx);
        $all(".single-choice", item).forEach((n) => handled.add(n));
        handled.add(item);
        group.items.forEach((sub, i) => {
          const subId = sub.index || `${group.gid}-${i + 1}`;
          result.questions.push({ ...sub, id: subId, cid: group.gid, passage: group.passage });
        });
      }
    }

    /* --- Step 2: 阅读理解(material-detail) --- */
    const readingWraps = $all(".material-detail").filter((el) => !handled.has(el));
    let readingCount = 0;
    for (const wrap of readingWraps) {
      const group = parseReadingGroup(wrap);
      readingCount += 1;
      const rid = `R${readingCount}`;
      $all(".single-choice", wrap).forEach((n) => handled.add(n));
      const prevStem = $one(".stem", wrap.parentElement);
      if (prevStem) handled.add(prevStem);
      group.items.forEach((sub, i) => {
        const subId = sub.index || `${rid}-${i + 1}`;
        result.questions.push({ ...sub, id: subId, passage: group.passage });
      });
    }

    /* --- Step 3: 单选(非阅读 / 非 cloze) --- */
    const singles = $all(".single-choice").filter((el) => !handled.has(el));
    let scCount = 0;
    for (const sc of singles) {
      const item = parseSingleChoice(sc);
      scCount += 1;
      const id = item.index || `S${scCount}`;
      result.questions.push({ ...item, id });
    }

    /* --- Step 4: 填空 --- */
    const fills = $all(".question-fill");
    let fCount = 0;
    for (const f of fills) {
      const item = parseFill(f);
      fCount += 1;
      const id = item.index || `F${fCount}`;
      result.questions.push({ ...item, id });
    }

    return result;
  }

  /***** 发送给 LLM 的精简题面 *****/
  function toLLMUnits(data, charCapPerPassage = 4000) {
    const units = [];
    data.questions.forEach((q) => {
      if (q.type === "single") {
        units.push({
          id: q.id,
          type: "single",
          stem: q.stem,
          options: q.options,
          passage: q.passage
            ? q.passage.length > charCapPerPassage
              ? q.passage.slice(0, charCapPerPassage) + " …(truncated)"
              : q.passage
            : undefined,
        });
      } else if (q.type === "fill") {
        units.push({ id: q.id, type: "fill", stem: q.stem, blanks: q.blanks });
      }
    });
    return units;
  }

  /***** OpenAI 兼容调用 & 工具函数 (保持不变) *****/
  async function openAIChat({ url, apiKey, model, messages, temperature = 0.2, timeout = 60000 }) {
    const body = JSON.stringify({ model, messages, temperature, stream: false });
    const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };

    try {
      const ctrl = new AbortController();
      const id = setTimeout(() => ctrl.abort(), timeout);
      const res = await fetch(url, { method: "POST", headers, body, signal: ctrl.signal });
      clearTimeout(id);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (e) {
      // GM_xmlhttpRequest fallback for CORS
      return await new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: "POST",
          url,
          headers,
          data: body,
          timeout,
          onload: (r) => {
            try {
              if (r.status < 200 || r.status >= 300) return reject(new Error(`HTTP ${r.status}`));
              resolve(JSON.parse(r.responseText));
            } catch (err) {
              reject(err);
            }
          },
          onerror: (err) => reject(err),
          ontimeout: () => reject(new Error("Timeout")),
        });
      });
    }
  }

  const extractContentFromResponse = (resp) => {
    if (resp?.choices?.[0]?.message?.content) return resp.choices[0].message.content;
    if (resp?.output_text) return resp.output_text;
    if (Array.isArray(resp?.output) && resp.output[0]?.content?.[0]?.text) return resp.output[0].content[0].text;
    return "";
  };

  /***** 英语专家 System Prompt *****/
  function buildMessages(units) {
    const systemPrompt = `你是中国高中英语考试专家。只基于给定题面与(可选的)passage作答,不引入外部信息。
进行“内在推理”:先在心中按顺序完成 (1) 语义契合比较 (2) 固定搭配/习语核对 (3) 语法一致性(时态、主谓、并列、介词) (4) 篇章衔接/指代自检 (5) 最终确认。
严格禁止将上述推理过程写入输出;输出只含答案,可被 JSON.parse。

输出结构(唯一且完整):
{"answers":[
  {"id":"<题目id>","choice":"A"},
  {"id":"<题目id>","fill":["答案1","答案2"]}
]}

作答规则:
1. 单选题:仅输出大写字母到 "choice"(如 "A")。优先级:语义契合 > 固定搭配/习语 > 句法完整性 > 篇章衔接。
2. 填空题:按空格数量返回 "fill" 数组;答案不加多余引号/句号/括号;大小写按英语常规。
3. 阅读题:若有 passage,先整体把握主旨语气与细节,再作答小题(推理只在心中完成,不输出)。
4. 选词填空(Word-Bank Cloze):同一词库中的每个备选词只能使用一次;若出现冲突,优先满足语义契合,其次遵循搭配/语法。
5. 若必须猜测,选择最合理项。
6. 仅输出上述 JSON 字段;不得输出理由、要点、证据或任何额外文本。
`;

    return [
      { role: "system", content: systemPrompt },
      { role: "user", content: JSON.stringify({ questions: units }, null, 2) },
    ];
  }

  /***** 写回页面(与原版一致,无改动) *****/
  async function applyAnswers(extracted, finalAnswersMap, log) {
    let ok = 0,
      fail = 0;
    const qById = {};
    extracted.questions.forEach((q) => (qById[q.id] = q));

    for (const [id, ans] of Object.entries(finalAnswersMap)) {
      const q = qById[id];
      if (!q) {
        log(`未找到题目 ${id}`);
        fail++;
        continue;
      }
      try {
        if (q.type === "single" && ans.choice) {
          const letter = (ans.choice || "").toUpperCase().trim();
          const res = q.setAnswer(letter);
          if (!res) throw new Error("选项设置失败");
          ok++;
        } else if (q.type === "fill" && ans.fill) {
          const values = Array.isArray(ans.fill) ? ans.fill : [String(ans.fill)];
          q.setAnswer(values);
          ok++;
        } else {
          throw new Error("答案结构不匹配");
        }
        await sleep(40);
      } catch (e) {
        log(`❌ ${id} 写入失败:${e.message}`);
        fail++;
      }
    }
    return { ok, fail };
  }

/***** 可拖动面板 *****/
  function initPanel() {
    if ($one('#ai-solver-panel')) return;

    const panel = document.createElement('div');
    panel.id = 'ai-solver-panel';
    panel.innerHTML = `
      <div class="ais-inner">
        <div class="ais-title" id="ais-drag-handle">赛氪英语AI一键答题</div>
        <label class="ais-lab">API URL
          <input id="ais-base" placeholder="如:https://api.openai.com/v1/chat/completions" />
        </label>
        <label class="ais-lab">Models(逗号分隔)
          <input id="ais-model" placeholder="gpt-4o,gpt-4o-mini" />
        </label>
        <label class="ais-lab">OpenAI Key
          <input id="ais-key" type="password" placeholder="sk-..." />
        </label>
        <div class="ais-row">
          <label><input type="checkbox" id="ais-dry" /> 仅试跑(不写回页面)</label>
        </div>
        <div class="ais-btns">
          <button id="ais-readme">使用说明</button>
          <button id="ais-save">保存配置</button>
          <button id="ais-test">测试API</button>
          <button id="ais-run" class="primary">开始做题</button>
        </div>
        <pre id="ais-log" class="ais-log"></pre>
      </div>
    `;
    const css = document.createElement('style');
    css.textContent = `
      #ai-solver-panel {
        position: fixed;
        right: 16px; bottom: 16px;
        z-index: 9999999;
        width: 380px;
        box-sizing: border-box;
        font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
      }
      #ai-solver-panel .ais-inner {
        background: #0b1220;
        color: #e5e7eb;
        border: 1px solid #1f2a44;
        border-radius: 14px;
        padding: 12px;
        box-shadow: 0 6px 20px rgba(0,0,0,.35);
      }
      .ais-title {
        font-weight: 700; text-align:center; margin-bottom: 8px;
        cursor: move; user-select: none; letter-spacing: .5px;
      }
      .ais-lab { display:block; font-size:12px; color:#9ca3af; margin:6px 0 2px; }
      .ais-lab input {
        width: 100%; max-width: 100%;
        padding: 9px 10px; border-radius: 10px;
        border: 1px solid #374151; background:#0f172a; color:#e5e7eb;
        box-sizing: border-box; outline: none;
      }
      .ais-lab input:focus { border-color:#2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.15); }
      .ais-row { display:flex; align-items:center; font-size:12px; color:#cbd5e1; margin-top:8px; gap:10px; }
      .ais-row label { display:flex; align-items:center; gap:6px; }
      .ais-btns { display:flex; gap:8px; margin-top:12px; flex-wrap:wrap; }
      .ais-btns button { flex:1; padding:9px 10px; border-radius:10px; border:none; cursor:pointer; background:#374151; color:#fff; transition:.15s ease; font-size:12px; }
      .ais-btns .primary { background:#2563eb; }
      .ais-btns button:hover { filter:brightness(1.06); transform: translateY(-0.5px); }
      .ais-log {
        max-height: 260px; overflow:auto; background:#0f172a;
        border: 1px solid #1f2a44; border-radius:10px;
        padding:10px; font-size:12px; color:#d1d5db; margin-top:10px; white-space:pre-wrap;
      }
      @media (max-width: 420px) {
        #ai-solver-panel { width: calc(100vw - 24px); right: 12px; left: 12px; }
      }
    `;
    document.head.appendChild(css);
    document.body.appendChild(panel);

    // 可拖动
    (function makeDraggable() {
      const handle = panel.querySelector('#ais-drag-handle');
      let dragging = false, startX=0, startY=0, startLeft=0, startTop=0;
      const onDown = (e) => {
        dragging = true;
        const rect = panel.getBoundingClientRect();
        startLeft = rect.left;
        startTop = rect.top;
        startX = e.clientX; startY = e.clientY;
        panel.style.right = 'auto'; panel.style.bottom = 'auto';
        panel.style.left = `${startLeft}px`;
        panel.style.top = `${startTop}px`;
        document.addEventListener('mousemove', onMove);
        document.addEventListener('mouseup', onUp);
      };
      const onMove = (e) => {
        if (!dragging) return;
        const dx = e.clientX - startX;
        const dy = e.clientY - startY;
        let nx = startLeft + dx;
        let ny = startTop + dy;
        const maxX = window.innerWidth - panel.offsetWidth;
        const maxY = window.innerHeight - panel.offsetHeight;
        nx = Math.max(0, Math.min(nx, maxX));
        ny = Math.max(0, Math.min(ny, maxY));
        panel.style.left = `${nx}px`;
        panel.style.top = `${ny}px`;
      };
      const onUp = () => {
        dragging = false;
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onUp);
      };
      handle.addEventListener('mousedown', onDown);
    })();

    const $ = (id) => panel.querySelector(id);
    const baseInp = $('#ais-base');
    const modelInp = $('#ais-model');
    const keyInp = $('#ais-key');
    const dryChk = $('#ais-dry');
    const logEl = $('#ais-log');

    const log = (msg) => { logEl.textContent += (msg + '\n'); logEl.scrollTop = logEl.scrollHeight; };
    const clr = () => { logEl.textContent = ''; };

    // 载入配置
    baseInp.value = GM_getValue('ais_base', 'https://api.openai.com/v1/chat/completions');
    modelInp.value = GM_getValue('ais_model', 'gpt-4o');
    keyInp.value = GM_getValue('ais_key', '');
    dryChk.checked = GM_getValue('ais_dry', false);

    $('#ais-save').addEventListener('click', () => {
      GM_setValue('ais_base', baseInp.value.trim());
      GM_setValue('ais_model', modelInp.value.trim() || 'gpt-4o');
      GM_setValue('ais_key', keyInp.value.trim());
      GM_setValue('ais_dry', dryChk.checked);
      log('✅ 配置已保存');
    });

    /* ---------- README 按钮逻辑 ---------- */
    $('#ais-readme').addEventListener('click', () => {
      alert(`【赛氪英语 AI 答题助手使用指南】\n\n1. 准备工作\n   · 安装浏览器扩展 Tampermonkey(油猴)并重启浏览器。\n   · 点击“安装脚本”按钮,将本脚本添加到 Tampermonkey。\n\n2. 打开赛氪考试页面\n   · 访问链接形如 https://examzone.saikr.com/question/...\n\n3. 打开侧边面板\n   · 页面右下角将自动出现“赛氪英语一键答题”面板;如未出现,可点击浏览器 Tampermonkey 图标并选择“打开 AI 答题面板”。\n\n4. 填写三项配置\n   · API URL:例如 https://api.openai.com/v1/chat/completions\n   · Models:例如 gpt-4o 或 gpt-4o-mini,可填多个,用逗号隔开\n   · OpenAI Key:到 OpenAI 个人中心复制,形如 sk-xxxxxxxx\n\n5. 可选设置\n   · 勾选“仅试跑”表示只获取答案不自动填入页面,可用于演示或验证。\n\n6. 测试 API\n   · 点击“测试API”验证 Key 与模型是否可用,出现绿色✅即通过。\n\n7. 开始做题\n   · 点击“开始做题”,脚本会解析当前页面题目,并调用 LLM 获得答案。\n   · 如未勾选“仅试跑”,若多模型答案一致,将直接填写到答题框。\n\n8. 小技巧\n   · 面板可按标题栏拖动到任意位置。\n   · 配置保存后会自动记忆,下次无需重复输入。\n\n祝你考试顺利!`);
    });

    /* ---------- 这里开始:测试 API 新逻辑 ---------- */
    $('#ais-test').addEventListener('click', async () => {
      clr();
      const apiURL = baseInp.value.trim();
      const apiKey = keyInp.value.trim();
      const models = (modelInp.value.trim() || 'gpt-4o')
        .split(',')
        .map(s => s.trim())
        .filter(Boolean);

      if (!apiURL || !apiKey) {
        log('❌ 请先填写 API URL 与 Key');
        return;
      }

      if (!models.length) {
        log('❌ 未提供任何模型');
        return;
      }

      const messages = [
        { role: 'system', content: 'You are a helpful assistant. Reply with exactly: OK' },
        { role: 'user', content: 'OK' }
      ];

      for (const model of models) {
        try {
          log(`⏳ 正在测试模型:${model} …`);
          const resp = await openAIChat({
            url: apiURL,
            apiKey,
            model,
            messages
          });
          const content = (extractContentFromResponse(resp) || '').trim();
          log(`  ↳ 原始响应:${JSON.stringify(resp).slice(0, 900)}${JSON.stringify(resp).length>900?'...':''}`);
          if (/^OK$/i.test(content)) {
            log(`✅ 模型 ${model} 测试通过`);
          } else {
            log(`⚠️ 模型 ${model} 可达,但返回内容非常规(期望 "OK")`);
          }
        } catch (e) {
          log(`❌ 模型 ${model} 测试失败:${e.message}`);
        }
        log(''); // 空行分隔
        await sleep(200); // 避免过快触发限速
      }
    });
    /* ---------- 这里结束:测试 API 新逻辑 ---------- */

    $('#ais-run').addEventListener('click', async () => {
      clr();
      try {
        const apiURL = baseInp.value.trim();
        const apiKey = keyInp.value.trim();
        const models = (modelInp.value.trim() || 'gpt-4o').split(',').map(s=>s.trim()).filter(Boolean);
        const dryRun = dryChk.checked;

        if (!apiURL || !apiKey) { log('❌ 请先填写 API URL 与 Key'); return; }

        const extracted = extractAll();
        const units = toLLMUnits(extracted);
        if (units.length === 0) { log('未发现可解析的题目。'); return; }
        log(`共发现题目:${units.length} 道;模型:${models.join(', ')}`);

        // 按批次 + 模型调用
        const batches = [];
        let cur=[], size=0, limit=12000;
        for (const u of units) {
          const s = JSON.stringify(u).length;
          if (size + s > limit && cur.length) { batches.push(cur); cur=[]; size=0; }
          cur.push(u); size += s;
        }
        if (cur.length) batches.push(cur);

        const answersPerModel = {};
        for (const model of models) answersPerModel[model] = {};

        for (let b=0;b<batches.length;b++) {
          const batch = batches[b];
          for (const model of models) {
            log(`🧠 模型 ${model},批次 ${b+1}/${batches.length}…`);
            const messages = buildMessages(batch);
            const resp = await openAIChat({ url: apiURL, apiKey, model, messages });
            const contentRaw = extractContentFromResponse(resp) || '';
            const content = contentRaw.replace(/^```json\s*|\s*```$/g,'').trim();
            let parsed;
            try { parsed = JSON.parse(content); }
            catch { throw new Error(`模型 ${model} 未按要求返回 JSON,可尝试换模型或重试。`); }
            const arr = Array.isArray(parsed?.answers) ? parsed.answers : [];
            arr.forEach(a => { if (a?.id) answersPerModel[model][a.id] = a; });
            await sleep(100);
          }
        }

        // 计算一致答案
        const finalAnswers = {};
        const allIds = new Set();
        models.forEach(m => Object.keys(answersPerModel[m]).forEach(id=>allIds.add(id)));

        log('\n=== 各模型答案对比 ===');
        allIds.forEach(id => {
          const perModel = models.map(m => answersPerModel[m][id]);
          const display = perModel.map((ans,mIdx)=>{
            if (!ans) return `${models[mIdx]}: -`;
            if (ans.choice) return `${models[mIdx]}: ${ans.choice}`;
            if (ans.fill) return `${models[mIdx]}: ${ans.fill.join('|')}`;
            return `${models[mIdx]}: ?`;
          }).join(' | ');
          log(`${id} => ${display}`);
          // 判断一致
          const refAns = perModel[0] && JSON.stringify({choice:perModel[0].choice,fill:perModel[0].fill});
          const same = perModel.every(a=>a && JSON.stringify({choice:a.choice,fill:a.fill})===refAns);
          if (same && perModel[0]) finalAnswers[id] = perModel[0];
        });
        log(`\n一致答案:${Object.keys(finalAnswers).length} 道。${dryRun?'(试跑模式未写入)':''}`);

        if (!dryRun) {
          const { ok, fail } = await applyAnswers(extracted, finalAnswers, log);
          log(`\n写入成功 ${ok},失败 ${fail}`);
        }
      } catch (e) {
        log('❌ 执行失败:' + (e?.message || e));
      }
    });
  }

  // 右键菜单
  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand('打开 AI 答题面板', () => initPanel());
  }

  // 自动挂载 + 监听 SPA 变化
  const mo = new MutationObserver(() => { if (!$one('#ai-solver-panel')) initPanel(); });
  mo.observe(document.documentElement, { childList:true, subtree:true });
  initPanel();
})();