NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name FKQuestions
// @namespace https://github.com/fastnow
// @version 1.0.0
// @description 和令人烦恼的问卷说拜拜
// @author FastNow Studio
// @homepageURL https://github.com/fastnow/UserJs
// @updateURL https://fastly.jsdelivr.net/gh/fastnow/UserJs@main/FKQuestions.user.js
// @downloadURL https://fastly.jsdelivr.net/gh/fastnow/UserJs@main/FKQuestions.user.js
// @supportURL https://github.com/fastnow/UserJs/issues
// @license BSD-3-Clause
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_notification
// @grant GM_registerMenuCommand
// @connect api.openai.com
// @connect api.deepseek.com
// @connect open.bigmodel.cn
// @connect api.anthropic.com
// @connect generativelanguage.googleapis.com
// @run-at document-start
// @noframes
// ==/UserScript==
//
// ─────────────────────────────────────────────
// 安全提示(务必先读)
// 1. @match 目前是全域。强烈建议收敛到你常用的问卷域名,例如:
// // @match *://*.wjx.cn/*
// // @match *://wj.qq.com/*
// 脚本会读取页面全部表单内容并发送到第三方 AI 服务,注入到网银/后台管理页
// 风险较高。面板「设置 → 站点作用域」可填域名白名单(逗号分隔,留空=全域)。
// 2. 若要使用「自定义端点」,需自行在头部补一行 // @connect 你的域名
// (本脚本刻意删除了 @connect *,避免密钥可被发往任意域名)。
// 3. API Key 明文存储。面板默认「仅本次会话保存」,勾选后才会落盘到
// Tampermonkey 存储。共用电脑请勿勾选。
// 4. 本脚本不做任何反检测/反风控对抗。检测到验证码、风控 SDK、陷阱题、蜜罐
// 字段等反作弊措施时,只会通知你并禁用自动填写,不会尝试绕过。
// ─────────────────────────────────────────────
(function () {
'use strict';
/* 防重复注入 */
if (window.__GAMETAME_V3__) return;
window.__GAMETAME_V3__ = true;
const NS = 'gametame_v3_';
const SCRIPT_NAME = 'FKQuestions';
const CFG = {
batchSize: 12, // 每批题目数,避免单次 prompt 超 token
timeout: 60000, // 单次请求超时(毫秒)
maxTokens: 4096,
temperature: 0.4,
scanPromptLimit: 1500000, // 反作弊扫描时读取的 HTML 字符上限
/* ── 跨页相关 ── */
/* ── 答案约束 ── */
multiMin: 2, // 多选题期望最少选几项
multiMax: 3, // 多选题期望最多选几项(超出会截断,全选一眼假)
textMaxChars: 50, // 填空题最大字数
/* ── 打字速度(会被设置面板覆盖) ──
基准值按真人中文输入速度定:普通人用输入法打字约每秒 1.5~3 字,
即每字 330~670 ms。之前 base=55 相当于每秒 18 字,明显偏快,
实测反馈就是"太快了"。这里取中位偏保守的一组。 */
typeSpeed: 'normal',
typeBaseMs: 320, // 每字基础毫秒(文本很短时的单字耗时)
typePerCharMs: 3.5, // 每多一个字,单字耗时的减少量(手速惯性)
typeJitter: 0.35, // 随机抖动比例 0~1
typeThinkMs: 900, // 题间思考基准毫秒
typeThinkPerChar: 12, // 每字追加的思考时间(读题)
typePauseChance: 0.06, // 打字过程中"卡壳"的概率
typeCharMinMs: 150, // 单字耗时下限,防止长文本快到失真
typeCharMaxMs: 700, // 单字耗时上限
crossPage: false, // 跨页自动续填(运行时由 state 覆盖)
maxPages: 20, // 单次会话最多自动推进多少页,防死循环
contentChangeNodes: 25, // DOM 累计变动多少节点后才核算内容指纹(避免频繁全量提取)
sessionTTL: 2 * 3600 * 1000, // 会话有效期 2 小时
urlPollMs: 700, // URL 轮询兜底间隔
afterNavDelay: 1200, // 检测到跳转后等待页面渲染的时间
navTimeout: 25000 // 等待跳转发生的超时(超时则判定为最后一页)
};
const Probe = {
listeners: Object.create(null),
beacons: 0,
reqs: [],
canvasReads: 0,
webdriver: false,
ready: false,
paused: false // 脚本自己建 UI 时暂停统计,避免把自己的监听算成页面的
};
const BEHAVIOR_EVENTS = [
'mousemove', 'pointermove', 'mousedown', 'mouseup', 'click',
'keydown', 'keypress', 'keyup', 'scroll', 'touchstart', 'touchmove',
'visibilitychange', 'blur', 'focus', 'copy', 'paste', 'cut',
'contextmenu', 'selectstart', 'beforeunload', 'pagehide'
];
function installProbe() {
try {
const origAdd = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type) {
try {
if (!Probe.paused && typeof type === 'string') {
Probe.listeners[type] = (Probe.listeners[type] || 0) + 1;
}
} catch (e) { /* ignore */ }
return origAdd.apply(this, arguments);
};
} catch (e) { /* ignore */ }
try {
const origBeacon = navigator.sendBeacon && navigator.sendBeacon.bind(navigator);
if (origBeacon) {
navigator.sendBeacon = function (url) {
Probe.beacons++;
Probe.reqs.push(String(url));
return origBeacon.apply(navigator, arguments);
};
}
} catch (e) { /* ignore */ }
try {
const origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input) {
try {
Probe.reqs.push(String((input && input.url) || input));
} catch (e) { /* ignore */ }
return origFetch.apply(window, arguments);
};
}
} catch (e) { /* ignore */ }
try {
const origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
try { Probe.reqs.push(String(url)); } catch (e) { /* ignore */ }
return origOpen.apply(this, arguments);
};
} catch (e) { /* ignore */ }
try {
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function () {
Probe.canvasReads++;
return origToDataURL.apply(this, arguments);
};
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
CanvasRenderingContext2D.prototype.getImageData = function () {
Probe.canvasReads++;
return origGetImageData.apply(this, arguments);
};
} catch (e) { /* ignore */ }
try {
Probe.webdriver = !!(
navigator.webdriver ||
document.documentElement.getAttribute('webdriver') ||
window.callPhantom || window.__nightmare || window.__selenium ||
window.domAutomation || window._phantom
);
} catch (e) { /* ignore */ }
Probe.ready = true;
}
installProbe();
const Store = {
get(key, def) {
try { return GM_getValue(NS + key, def); } catch (e) { return def; }
},
set(key, val) {
try { GM_setValue(NS + key, val); } catch (e) { /* ignore */ }
},
del(key) {
try { GM_deleteValue(NS + key); } catch (e) { /* ignore */ }
}
};
const state = {
provider: Store.get('provider', 'deepseek'),
model: Store.get('model', ''),
apiKey: Store.get('apiKey', ''), // 仅当 persistKey 为真时才有值
persistKey: Store.get('persistKey', false),
customBase: Store.get('customBase', ''),
mode: Store.get('mode', 'semi'), // auto | semi | manual
autoSubmit: Store.get('autoSubmit', false),
scopeWhitelist: Store.get('scopeWhitelist', ''),
crossPage: Store.get('crossPage', false), // 跨页自动续填
crossPageAuto: Store.get('crossPageAuto', false), // 跨页时不再逐页询问
crossPageNext: Store.get('crossPageNext', true), // 自动点击「下一页」
/* UI 与调优 */
sensitivity: Store.get('sensitivity', 'normal'), // loose | normal | strict
temperature: Store.get('temperature', 0.4),
widePanel: Store.get('widePanel', false),
debug: Store.get('debug', false),
excludeSelectors: Store.get('excludeSelectors', ''), // 额外排除的选择器
/* 媒体内容检测:图标密集的后台/组件库页面可能误报,*/
mediaCheck: Store.get('mediaCheck', true),
mediaMinPx: Store.get('mediaMinPx', 44), // 小于此尺寸的图形视为图标
risk: null, // 最近一次扫描结果
lastScanAt: 0
};
// 调过的参数覆盖默认值
CFG.batchSize = Store.get('batchSize', CFG.batchSize);
CFG.timeout = Store.get('timeout', CFG.timeout);
CFG.temperature = state.temperature;
CFG.multiMin = Store.get('multiMin', CFG.multiMin);
CFG.multiMax = Store.get('multiMax', CFG.multiMax);
CFG.textMaxChars = Store.get('textMaxChars', CFG.textMaxChars);
CFG.typeSpeed = Store.get('typeSpeed', CFG.typeSpeed);
CFG.typeBaseMs = Store.get('typeBaseMs', CFG.typeBaseMs);
CFG.typePerCharMs = Store.get('typePerCharMs', CFG.typePerCharMs);
CFG.typeJitter = Store.get('typeJitter', CFG.typeJitter);
CFG.typeThinkMs = Store.get('typeThinkMs', CFG.typeThinkMs);
CFG.typeThinkPerChar = Store.get('typeThinkPerChar', CFG.typeThinkPerChar);
CFG.typePauseChance = Store.get('typePauseChance', CFG.typePauseChance);
CFG.typeCharMinMs = Store.get('typeCharMinMs', CFG.typeCharMinMs);
CFG.typeCharMaxMs = Store.get('typeCharMaxMs', CFG.typeCharMaxMs);
if (!state.persistKey) state.apiKey = '';
function buildMessages(prompt) {
const sys = Persona.system();
const msgs = [];
if (sys) msgs.push({ role: 'system', content: sys });
msgs.push({ role: 'user', content: prompt });
return msgs;
}
const PROVIDERS = {
openai: {
name: 'OpenAI',
defaultModel: 'gpt-4o-mini',
models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4.1-mini', 'gpt-4.1'],
buildRequest(prompt, model, key) {
return {
url: 'https://api.openai.com/v1/chat/completions',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + key
},
body: {
model: model,
messages: buildMessages(prompt),
temperature: CFG.temperature,
max_tokens: CFG.maxTokens
}
};
},
parse(res) {
const d = JSON.parse(res.responseText);
return d.choices && d.choices[0] && d.choices[0].message
? d.choices[0].message.content : null;
}
},
deepseek: {
name: 'DeepSeek',
defaultModel: 'deepseek-chat',
models: ['deepseek-chat', 'deepseek-reasoner'],
buildRequest(prompt, model, key) {
return {
url: 'https://api.deepseek.com/v1/chat/completions',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + key
},
body: {
model: model,
messages: buildMessages(prompt),
temperature: CFG.temperature,
max_tokens: CFG.maxTokens
}
};
},
parse: function (res) { return PROVIDERS.openai.parse(res); }
},
zhipu: {
name: '智谱 AI (GLM)',
defaultModel: 'glm-4-flash',
models: ['glm-4-flash', 'glm-4-plus', 'glm-4.6', 'glm-4'],
buildRequest(prompt, model, key) {
return {
url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + key
},
body: {
model: model,
messages: buildMessages(prompt),
temperature: CFG.temperature,
max_tokens: CFG.maxTokens
}
};
},
parse: function (res) { return PROVIDERS.openai.parse(res); }
},
claude: {
name: 'Claude (Anthropic)',
defaultModel: 'claude-sonnet-4-5',
models: ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-3-5-haiku-latest'],
buildRequest(prompt, model, key) {
return {
url: 'https://api.anthropic.com/v1/messages',
headers: {
'Content-Type': 'application/json',
'x-api-key': key,
'anthropic-version': '2023-06-01',
// 浏览器直连 Anthropic 必须带此头,否则 403
'anthropic-dangerous-direct-browser-access': 'true'
},
body: {
model: model,
max_tokens: CFG.maxTokens,
system: Persona.system() || undefined,
messages: [{ role: 'user', content: prompt }]
}
};
},
parse(res) {
const d = JSON.parse(res.responseText);
if (Array.isArray(d.content)) {
return d.content.filter(c => c.type === 'text').map(c => c.text).join('');
}
return null;
}
},
gemini: {
name: 'Google Gemini',
defaultModel: 'gemini-2.5-flash',
models: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'],
buildRequest(prompt, model, key) {
return {
url: 'https://generativelanguage.googleapis.com/v1beta/models/' +
encodeURIComponent(model) + ':generateContent?key=' + encodeURIComponent(key),
headers: { 'Content-Type': 'application/json' },
body: {
systemInstruction: Persona.system()
? { parts: [{ text: Persona.system() }] }
: undefined,
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: CFG.temperature,
maxOutputTokens: CFG.maxTokens,
responseMimeType: 'application/json'
}
}
};
},
parse(res) {
const d = JSON.parse(res.responseText);
const c = d.candidates && d.candidates[0];
if (c && c.content && Array.isArray(c.content.parts)) {
return c.content.parts.map(p => p.text || '').join('');
}
return null;
}
},
custom: {
name: '自定义端点 (OpenAI 兼容)',
defaultModel: 'gpt-4o-mini',
models: [],
needsBase: true,
buildRequest(prompt, model, key, base) {
let baseUrl = String(base || '').trim().replace(/\/+$/, '');
if (!baseUrl) throw new Error('未填写自定义端点地址');
if (!/^https?:\/\//i.test(baseUrl)) baseUrl = 'https://' + baseUrl;
return {
url: baseUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + key
},
body: {
model: model,
messages: buildMessages(prompt),
temperature: CFG.temperature,
max_tokens: CFG.maxTokens
}
};
},
parse: function (res) { return PROVIDERS.openai.parse(res); }
}
};
function currentProvider() { return PROVIDERS[state.provider] || PROVIDERS.deepseek; }
function currentModel() {
return (state.model || '').trim() || currentProvider().defaultModel;
}
function esc(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function nodeText(node) {
if (!node) return '';
let t = node.innerText;
if (t === undefined || t === null) t = node.textContent;
return String(t || '').replace(/\s+/g, ' ').trim();
}
function isVisible(el) {
if (!el) return false;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
if (parseFloat(cs.opacity) === 0) return false;
const r = el.getBoundingClientRect();
return r.width > 0 || r.height > 0;
}
function isEffectivelyVisible(el) {
if (isVisible(el)) return true;
const t = (el.type || '').toLowerCase();
/* contenteditable 没有 type,靠父级判断;原生日期/滑块控件同理 */
if (t === 'radio' || t === 'checkbox' || isEditable(el)) {
let p = el.parentElement;
let guard = 0;
while (p && p !== document.body && guard++ < 3) {
if (isVisible(p)) return true;
p = p.parentElement;
}
}
return false;
}
/** 在执行期间暂停探针,避免脚本自身的监听污染统计结果 */
function withoutProbe(fn) {
Probe.paused = true;
try { return fn(); }
finally { Probe.paused = false; }
}
/** 元素自身的直接文本(不含后代元素文本) */
function ownText(el) {
let s = '';
for (let n = el.firstChild; n; n = n.nextSibling) {
if (n.nodeType === 3) s += n.nodeValue;
}
return s.replace(/\s+/g, ' ').trim();
}
/** 收集后代里符合选择器的元素自身的文本 */
function descendantOwnText(root, selector, limit) {
const out = [];
const nodes = root.querySelectorAll(selector);
for (let i = 0; i < nodes.length && out.length < (limit || 20); i++) {
const t = ownText(nodes[i]);
if (t) out.push(t);
}
return out;
}
const RectCache = {
map: null,
/** 开始一轮新的提取,清空旧缓存 */
begin() { this.map = new Map(); },
/** 结束一轮提取,释放内存(不释放会让大页面的 WeakMap/Map 长期占用) */
end() { this.map = null; },
get(el) {
if (!el) return { top: 0, left: 0, width: 0, height: 0 };
if (this.map) {
const hit = this.map.get(el);
if (hit) return hit;
}
let r;
try {
r = el.getBoundingClientRect();
} catch (e) {
r = { top: 0, left: 0, width: 0, height: 0 };
}
// 归一化出需要的字段,避免外部直接持有 DOMRect(部分环境会变)
const snap = {
top: r.top, left: r.left, right: r.right, bottom: r.bottom,
width: r.width, height: r.height
};
if (this.map) this.map.set(el, snap);
return snap;
},
/** 批量预热:一次性读完,把重排次数压到 1 次 */
prewarm(list) {
if (!list || !list.length) return;
for (let i = 0; i < list.length; i++) {
const el = list[i];
if (!el || (this.map && this.map.has(el))) continue;
let r;
try { r = el.getBoundingClientRect(); }
catch (e) { r = null; }
if (r && this.map) {
this.map.set(el, {
top: r.top, left: r.left, right: r.right, bottom: r.bottom,
width: r.width, height: r.height
});
}
}
}
};
function anchorRect(el) {
let cur = el;
let guard = 0;
/* 沿祖先链向上找时,把整条链一次性预热。
否则每层都单独读一次,一个嵌套 5 层的控件就是 5 次重排。 */
if (RectCache.map) {
const chain = [];
let c = el;
while (c && chain.length < 16) { chain.push(c); c = c.parentElement; }
RectCache.prewarm(chain);
}
while (cur && cur.getBoundingClientRect && guard++ < 15) {
const r = RectCache.get(cur);
// 1px 透明控件算"没有尺寸",继续向上找
if (r.width > 4 && r.height > 4) return r;
cur = cur.parentElement;
}
return RectCache.get(el);
}
function optionLabel(input, allowValue) {
// 填空题直接短路:它压根没有选项概念
if (allowValue === false) return '';
// 1. <label for="id">
if (input.id) {
let lbl = null;
try {
lbl = document.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(input.id) : esc(input.id)) + '"]');
} catch (e) { /* ignore */ }
const lt = nodeText(lbl);
if (lt) return lt;
}
// 2. 包裹的 <label>
const wrap = input.closest ? input.closest('label') : null;
const wt = nodeText(wrap);
if (wt) return wt;
// 3. 相邻兄弟文本
let sib = input.nextSibling;
while (sib && sib.nodeType !== 3) sib = sib.nextSibling;
if (sib && sib.nodeValue && sib.nodeValue.trim()) {
return sib.nodeValue.replace(/\s+/g, ' ').trim();
}
// 4. 父节点的直接文本
if (input.parentElement) {
const t = ownText(input.parentElement);
if (t) return t;
}
return input.value || '';
}
/** 归一化:去掉 A. / 1、/ (3分) 之类前缀,便于匹配 */
function norm(s) {
return String(s == null ? '' : s)
.replace(/^[\s\u3000]*[A-Za-z0-9一二三四五六七八九十]{1,3}\s*[\.、\).::,,]\s*/, '')
.replace(/[\s\u3000]+/g, '')
.toLowerCase();
}
function optionMatches(label, answer) {
const L = norm(label);
const A = norm(answer);
if (!L || !A) return false;
if (L === A) return true;
if (L.indexOf(A) !== -1 || A.indexOf(L) !== -1) return true;
return false;
}
/** 兼容 React/Vue 受控组件:用原生 setter 赋值再派发事件 */
function setValue(el, value) {
let setter = null;
try {
const proto = el.tagName === 'TEXTAREA'
? HTMLTextAreaElement.prototype
: (el.tagName === 'SELECT' ? HTMLSelectElement.prototype : HTMLInputElement.prototype);
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
setter = desc && desc.set;
} catch (e) { /* ignore */ }
if (setter) setter.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
const TITLE_SELECTORS = [
'.question-title', '.q-title', '.question-text', '.qtext', '.title-text',
'.field-label', '.form-label', '.item-title', '.matrix-title',
'[class*="question-title"]', '[class*="questionTitle"]',
'[class*="question-text"]', '[class*="q-title"]', '[class*="title"]',
'.label', 'legend', 'label'
].join(',');
function isInside(a, b) {
return !!(a && b && (b.contains(a) || a === b));
}
const NOISE_SELECTOR = [
'nav', 'header', 'footer', 'aside',
'[role="navigation"]', '[role="banner"]', '[role="contentinfo"]',
'[data-gt]', '[id^="gt-"]', // 脚本自己的 UI
'.gt-wrap', '.gt-panel', '.gt-fab', '.gt-awin',
'[class*="devtools"]', '[class*="dev-panel"]', '[class*="debug-panel"]',
'.dev', '#dev', '.dev-grid', '#devGrid', // 常见调试面板
'[data-testid]', '[data-test]', '[data-cy]',
'[class*="pagination"]', '[class*="pager"]'
].join(',');
/** 用户在设置里追加的排除选择器,动态拼在 NOISE_SELECTOR 之后 */
function extraNoise() {
const v = (state.excludeSelectors || '').trim();
if (!v) return '';
// 限制长度,防止手滑粘一大段把整个页面排掉
return v.slice(0, 500);
}
/** 完整的噪声选择器(内置 + 用户自定义) */
function noiseSelector() {
const ex = extraNoise();
return ex ? (NOISE_SELECTOR + ',' + ex) : NOISE_SELECTOR;
}
const QCTRL = 'input[type="radio"], input[type="checkbox"], textarea, select';
/**
* HTML 表单控件类型 → 内部处理类型。
*
* 原来只认 radio/checkbox/text/textarea/select 五种,真实问卷里
* 日期、滑块、评分、颜色、文件上传都很常见,此前会被直接忽略——
* 题目列表里少一道题,用户也看不出来。
*
* 归类原则:按「填写方式」而非「HTML 类型」分。
*/
const FIELD_KIND = {
/* 逐字输入类:模拟真人打字 */
text: 'text',
search: 'text',
email: 'text',
tel: 'text',
url: 'text',
password: 'text',
number: 'text',
textarea: 'text',
/* 选择类 */
radio: 'radio',
checkbox: 'checkbox',
select: 'select',
/* 直接赋值类:原生控件有自己的 UI(日期选择器/滑块/取色器),
逐字输入无意义,直接设 value 并派发事件即可 */
date: 'value',
datetime_local: 'value',
month: 'value',
week: 'value',
time: 'value',
range: 'value',
color: 'value',
/* 文件上传:浏览器安全策略禁止脚本设置 FileList,
任何"自动上传"都是幻觉,必须显式告知用户 */
file: 'file'
};
/** 元素是否为可编辑区域(contenteditable)。属性与状态双判,兼容实现差异 */
function isEditable(el) {
if (!el) return false;
if (el.isContentEditable === true) return true;
if (el.isContentEditable === false) return false;
if (!el.getAttribute) return false;
const a = String(el.getAttribute('contenteditable') || '').toLowerCase();
return a === 'true' || a === '' || a === '1';
}
/** 取控件的内部处理类型;未知 input 类型一律按 text 处理,比直接忽略安全 */
function fieldKind(el) {
if (!el) return 'text';
const tag = (el.tagName || '').toLowerCase();
if (tag === 'select') return 'select';
if (tag === 'textarea') return 'text';
if (tag !== 'input') {
/* 富文本:contenteditable 在问卷里常用于长文作答。
判断不能只靠 el.isContentEditable——它是可选属性,
部分环境(含部分测试环境)不实现,实测返回 undefined,
导致富文本被当成普通文本框,写入时改的是 value 而非 innerText,
结果写了等于没写。属性 + 状态双判,任一为真即算富文本。 */
let ce = el.isContentEditable;
if (ce === undefined && el.getAttribute) {
const a = String(el.getAttribute('contenteditable') || '').toLowerCase();
ce = (a === 'true' || a === '' || a === '1');
}
if (ce) return 'contenteditable';
return 'text';
}
const t = String(el.type || 'text').toLowerCase().replace(/-/g, '_');
return FIELD_KIND[t] || 'text';
}
/** 该类型是否能被自动填写 */
function isFillableKind(kind) {
return kind !== 'file';
}
/** 需要逐字输入的(其余直接赋值) */
function needsTyping(kind) {
return kind === 'text' || kind === 'contenteditable';
}
function isNoise(el) {
if (!el || !el.closest) return false;
try { return !!el.closest(noiseSelector()); } catch (e) {
// 用户自定义选择器可能写错导致抛异常,回退到内置清单
try { return !!el.closest(NOISE_SELECTOR); } catch (e2) { return false; }
}
}
/**
* 给候选容器打分:题目控件越多、不同 name 的组数越多,越像问卷主体。
* 用「组数」而不是控件数,避免一个 20 选项的多选题压过 5 道单选题。
*/
function scoreContainer(root) {
let groups = 0;
const names = new Set();
try {
root.querySelectorAll('input[type="radio"], input[type="checkbox"]')
.forEach(function (i) {
if (isNoise(i)) return;
names.add((i.type || '') + '::' + (i.name || '__noname__'));
});
groups += names.size;
root.querySelectorAll('textarea, select').forEach(function (i) {
if (!isNoise(i)) groups++;
});
} catch (e) { return 0; }
return groups;
}
/**
* 选定问卷作用域。页面上的搜索框、筛选器、评论区表单、以及本脚本自己的
* 设置面板里都有 input,直接全文档扫描必然混入大量噪声。策略:
* 1. 优先取题目控件最密集的 <form>
* 2. 没有合格 form 时,扫描常见问卷容器(main / #app / .survey 等)
* 3. 仍找不到就退回 document,但提取时会逐个元素排除噪声区
*/
function resolveScope() {
let best = null;
let bestScore = 0;
// 1. form
try {
document.querySelectorAll('form').forEach(function (f) {
if (isNoise(f)) return;
const s = scoreContainer(f);
if (s > bestScore) { bestScore = s; best = f; }
});
} catch (e) { /* ignore */ }
if (best && bestScore >= 2) return best;
// 2. 常见问卷容器
const CAND = ['main', '[role="main"]', '#app', '#root', '#content',
'[class*="survey"]', '[class*="question"]', '[class*="quiz"]',
'[class*="form"]', '.container', '.wrapper'];
try {
document.querySelectorAll(CAND.join(',')).forEach(function (c) {
if (isNoise(c)) return;
const s = scoreContainer(c);
if (s > bestScore) { bestScore = s; best = c; }
});
} catch (e) { /* ignore */ }
if (best && bestScore >= 2) return best;
return document;
}
/**
* 为一组无 name 的同类控件找一个「最小公共容器」:
* 从各自父节点往上走,取第一个至少包住 2 个同组控件的祖先。
* 注意不能用 parentElement——radio 的直接父节点往往就是它自己的 <label>,
* 那样每个选项都会被拆成独立的一组。
*/
function containerOf(inp, set) {
let node = inp.parentElement;
let guard = 0;
while (node && node !== document.body && guard++ < 8) {
let cnt = 0;
set.forEach(function (o) { if (node.contains(o)) cnt++; });
if (cnt >= 2) return node;
node = node.parentElement;
}
return inp.parentElement || document.body;
}
/** 把页面上的输入控件按「组」归类:radio/checkbox 按 name,其余各自成组 */
function collectGroups() {
const groups = [];
const byName = Object.create(null);
const noName = { radio: [], checkbox: [] };
const inputs = resolveScope().querySelectorAll([
/* 选择类 */
'input[type="radio"]', 'input[type="checkbox"]', 'select', 'textarea',
/* 文本输入类 */
'input[type="text"]', 'input[type="search"]', 'input[type="email"]',
'input[type="tel"]', 'input[type="url"]', 'input[type="password"]',
'input[type="number"]',
/* 原生 UI 控件类 */
'input[type="date"]', 'input[type="datetime-local"]', 'input[type="month"]',
'input[type="week"]', 'input[type="time"]', 'input[type="range"]',
'input[type="color"]',
/* 文件类:填不了但要采集,好明确告知用户 */
'input[type="file"]',
/* 富文本 */
'[contenteditable="true"]', '[contenteditable=""]'
].join(','));
inputs.forEach(function (inp) {
// 脚本自己的设置面板、导航栏、页脚里的控件一律跳过
if (isNoise(inp)) return;
/* 单选/多选不在这里判可见性。
自定义控件普遍用 opacity:0 + 1px 藏起原生 input(MUI/AntD 都这么做),
它们看得见也点得到,只是控件本身透明。若在这一层用 isVisible 卡掉,
整道题就没了。它们的可见性交给后面的 isEffectivelyVisible 判断。 */
const kindVis = fieldKind(inp);
if (kindVis !== 'radio' && kindVis !== 'checkbox' && !isVisible(inp)) return;
const t = (inp.type || '').toLowerCase();
const kind = fieldKind(inp);
if (kind === 'radio' || kind === 'checkbox') {
// 有 name:HTML 规范保证同名 radio/checkbox 属于同一题,直接合并,不再拆
if (inp.name) {
const key = kind + '::name::' + inp.name;
if (byName[key]) { byName[key].inputs.push(inp); return; }
const g = { type: kind, inputs: [inp] };
byName[key] = g;
groups.push(g);
} else {
noName[kind].push(inp);
}
} else {
// 其余控件各自成题。type 用内部类型,填写层据此决定策略
groups.push({ type: kind, inputs: [inp] });
}
});
// 无 name 的按 DOM 就近容器分组(典型:自定义按钮组、纯 div 实现的选择器)
['radio', 'checkbox'].forEach(function (t) {
const set = noName[t];
if (!set.length) return;
const bucket = new Map();
set.forEach(function (inp) {
const c = containerOf(inp, set);
if (!bucket.has(c)) bucket.set(c, []);
bucket.get(c).push(inp);
});
bucket.forEach(function (list) {
groups.push({ type: t, inputs: list });
});
});
return groups.filter(function (g) {
return g.inputs.some(isEffectivelyVisible);
});
}
/** 候选文本是否就是某个选项本身(这样的文本不能当题干) */
function equalsSomeOption(text, labels) {
const n = norm(text);
if (!n) return false;
return labels.some(function (l) { return norm(l) === n; });
}
/** 从控件组向上寻找题干 */
/** 收集元素及其所有祖先(含自身) */
function ancestorChain(el) {
const out = [];
let cur = el;
while (cur && out.length < 40) { out.push(cur); cur = cur.parentElement; }
return out;
}
/** 两个元素的最近公共祖先 */
function commonAncestor(a, b) {
if (!a || !b) return a || b;
const ca = ancestorChain(a);
const set = new Set(ca);
let cur = b;
let guard = 0;
while (cur && guard++ < 40) {
if (set.has(cur)) return cur;
cur = cur.parentElement;
}
return a.parentElement;
}
/** 一组元素的最近公共祖先——也就是"题目容器" */
function groupContainer(inputs) {
if (!inputs.length) return null;
let anc = inputs[0];
for (let i = 1; i < inputs.length; i++) {
anc = commonAncestor(anc, inputs[i]);
if (!anc) return inputs[0].parentElement;
}
return anc;
}
/**
* 排除法找题干:在容器内找「不含表单控件、且文本不是任何选项」的元素。
*
* 为什么必须有这条路:TITLE_SELECTORS 里除 label/legend 外全是类名匹配
* (.q-title / [class*="question-title"] …),一旦平台把类名哈希化,
* 这些选择器集体失效,只能靠 label 兜底——而 label 就是选项本身,
* 于是题干会被抓成别的题的选项文字。
* 题干的本质是"文字"而非"某种标签",所以反过来用排除法定位更稳:
* 题干不含控件,选项容器必然含控件。
*/
function titleByExclusion(container, labels, inputs) {
if (!container || !container.querySelectorAll) return null;
const all = container.querySelectorAll('*');
for (let i = 0; i < all.length; i++) {
const el = all[i];
// 题干不应与本题控件有包含关系
if (inputs.some(function (e) { return isInside(el, e) || isInside(e, el); })) continue;
// 含表单控件 → 是选项容器,不是题干
if (el.querySelector && el.querySelector('input, textarea, select')) continue;
const t = ownText(el);
if (!t || t.length < 2 || t.length > 400) continue;
if (equalsSomeOption(t, labels)) continue;
const cleaned = stripOptionNoise(t, labels);
if (cleaned.length >= 2 && !equalsSomeOption(cleaned, labels)) return cleaned;
}
return null;
}
function findTitle(group) {
const first = group.inputs[0];
const exclude = group.inputs;
const labels = group.inputs.map(optionLabel).filter(Boolean);
/* 起点必须是"所有选项的最近公共祖先",不能是第一个 input。
原因:真实表单里选项常被包 3~5 层无意义 div(组件库的做法),
加上本题开启的 DOM 嵌套混淆,从 input 爬到题目容器可能要 8 层以上;
而中途经过的容器往往同时包含【其他题目】的 label,
TITLE_SELECTORS 又含 label,于是第一个被抓到的就成了别的题的选项文字
——实测出现过题干被抓成"不便透露"(另一题的选项)。
从公共祖先出发,上一层基本就是题目容器,绕开了这条错路。 */
let node = groupContainer(group.inputs) || first;
// 单选/多选的公共祖先可能就是选项容器本身,先退一层确保能覆盖到题干
if (node && node !== first) {
const inNode = node.querySelector('input, textarea, select');
if (inNode && !exclude.some(function (e) { return isInside(inNode, e); })) {
// 容器里还有别的题的控件,说明爬过头了,退回去
}
}
/* 优先走排除法:从公共祖先逐层向上,找不含控件且非选项的文本。
这条路不依赖任何类名,混淆环境下是唯一可靠的手段。 */
{
let up = node;
for (let d = 0; up && up !== document.body && d < 6; d++) {
const t = titleByExclusion(up, labels, exclude);
if (t) return t;
up = up.parentElement;
}
}
for (let depth = 0; node && node !== document.body && depth < 12; depth++) {
// 先看祖先里有没有显式标题元素
const cands = node.querySelectorAll ? node.querySelectorAll(TITLE_SELECTORS) : [];
for (let i = 0; i < cands.length; i++) {
const c = cands[i];
if (exclude.some(function (e) { return isInside(c, e) || isInside(e, c); })) continue;
// 脚本面板、导航栏里的 label 不能当题干——它们同样匹配 TITLE_SELECTORS
if (isNoise(c)) continue;
/* 题干元素不含任何表单控件,选项容器必然含。
这条在类名被随机化时尤其关键:TITLE_SELECTORS 里除了 label/legend
其余全是类名匹配,混淆后全部落空,只剩 label 可用,
而 label 恰恰就是选项——不加这条就会把别的题的选项文字当题干。 */
if (c.querySelector && c.querySelector('input, textarea, select')) continue;
const t = nodeText(c);
if (t && t.length >= 2 && t.length <= 400) {
// 文本本身就是一个选项 —— 说明这是选项容器而非题干,跳过
if (equalsSomeOption(t, labels)) continue;
const cleaned = stripOptionNoise(t, labels);
if (cleaned.length >= 2 && !equalsSomeOption(cleaned, labels)) return cleaned;
}
}
// 再看祖先自身的直接文本
const own = ownText(node);
if (own && own.length >= 2 && own.length <= 400) {
if (equalsSomeOption(own, labels)) { node = node.parentElement; continue; }
const cleaned = stripOptionNoise(own, labels);
if (cleaned.length >= 2 && !equalsSomeOption(cleaned, labels)) return cleaned;
}
node = node.parentElement;
}
const lbl = optionLabel(first);
return lbl ? ('(未识别题干)' + lbl.slice(0, 60)) : '未命名题目';
}
function stripOptionNoise(text, labels) {
let out = String(text);
labels.forEach(function (l) {
const n = norm(l);
if (n && n.length >= 2) {
const re = new RegExp(esc(n), 'gi');
out = out.replace(re, '');
}
});
return out.replace(/\s+/g, ' ').trim() || String(text).replace(/\s+/g, ' ').trim();
}
function extractQuestions() {
const groups = collectGroups();
const questions = [];
const seenRects = [];
/* 开启布局缓存,并在进入前把所有待处理控件及其祖先链一次性读完。
这样整轮提取的强制重排次数从"每个控件 × 每层祖先"压到 1 次,
万级节点的页面上卡顿从数秒降到百毫秒级。 */
RectCache.begin();
try {
const toWarm = [];
groups.forEach(function (g) {
Array.prototype.forEach.call(g.inputs, function (inp) {
let c = inp;
let n = 0;
while (c && n++ < 16) { toWarm.push(c); c = c.parentElement; }
});
});
RectCache.prewarm(toWarm);
} catch (e) { /* 预热失败不影响主流程,退化为逐次读取 */ }
try {
groups.forEach(function (g) {
const visible = g.inputs.filter(isEffectivelyVisible);
if (!visible.length) return;
const options = [];
if (g.type === 'select') {
// 下拉框的选项在 <option> 上,不是 input 的 label
const sel = visible[0];
Array.prototype.forEach.call(sel.options || [], function (op) {
const t = nodeText(op).trim();
if (t && options.indexOf(t) === -1) options.push(t);
});
} else if (g.type === 'radio' || g.type === 'checkbox') {
// 只有选择题才有"选项"概念
visible.forEach(function (inp) {
const lbl = optionLabel(inp);
if (lbl && options.indexOf(lbl) === -1) options.push(lbl);
});
} else if (g.type === 'value') {
/* 原生日期/滑块/取色器:把取值范围作为"选项"给出,
否则 AI 不知道该返回什么格式(滑块更是完全无从下手) */
const inp = visible[0];
/* 这里必须用原生 type 判断,不能用 fieldKind 的结果——
fieldKind 已经把 date/range/color 统统归一成 'value' 了,
再拿它去比 'date' 永远不相等,格式说明一条都推不进去,
AI 就收不到任何格式约定(实测 opt0 全是 null)。 */
const rawType = String(inp.type || '').toLowerCase();
const kind = rawType;
if (kind === 'range') {
const min = inp.min !== '' ? inp.min : '0';
const max = inp.max !== '' ? inp.max : '100';
options.push('(数值范围 ' + min + ' ~ ' + max + ',请只返回一个数字)');
} else if (kind === 'color') {
options.push('(请返回十六进制颜色值,形如 #4F46E5)');
} else if (kind === 'date') {
options.push('(请返回 YYYY-MM-DD 格式的日期)');
} else if (kind === 'datetime-local') {
options.push('(请返回 YYYY-MM-DDTHH:mm 格式)');
} else if (kind === 'month') {
options.push('(请返回 YYYY-MM 格式)');
} else if (kind === 'week') {
options.push('(请返回 YYYY-Www 格式,如 2026-W35)');
} else if (kind === 'time') {
options.push('(请返回 HH:mm 格式)');
}
} else if (g.type === 'file') {
options.push('(文件上传:脚本无法自动选择文件,需你手动操作)');
}
// text / contenteditable:options 保持空数组,不读输入框残留值
/* 去重签名必须用 anchorRect,不能用 input 自身的 rect。
自定义控件普遍把原生 input 设成 opacity:0 + 1px(或 display:none),
rect 恒为 0,于是所有单选题算出同一个签名 "0:0:radio",
第一题之后同类型的题会被全部当成重复删掉——
真实环境下这等于整份问卷只剩一道单选题。 */
const r = anchorRect(visible[0]);
const sig = Math.round(r.top / 8) + ':' + Math.round(r.left / 8) + ':' + g.type;
if (seenRects.indexOf(sig) !== -1) return;
seenRects.push(sig);
const title = findTitle(g);
if (!title) return;
// 排序锚点:不能用 input 自身的 rect——自定义控件常把原生 input
// 设成 display:none 或 1px 透明,rect 全 0,这题就会被排到最前面。
// 改为向上找第一个有真实尺寸的祖先。
const anchor = anchorRect(visible[0]);
questions.push({
index: questions.length,
type: g.type, // radio|checkbox|select|text|value|file|contenteditable
answerType: g.type === 'radio' || g.type === 'select' ? 'single'
: (g.type === 'checkbox' ? 'multi'
: (g.type === 'file' ? 'file' : 'text')),
title: title,
options: options,
inputs: visible,
_top: anchor.top,
_left: anchor.left
});
});
// 按视觉顺序排序(从上到下,从左到右)
questions.sort(function (a, b) {
if (Math.abs(a._top - b._top) > 20) return a._top - b._top;
return a._left - b._left;
});
questions.forEach(function (q, i) {
q.index = i;
delete q._top; // 排序用的临时字段,不留在结果里
delete q._left;
});
return questions;
} finally {
// 无论中间是否抛错都要释放缓存,否则大页面会长期占着一份 Map
RectCache.end();
}
}
/* ═══════════════════════════════════════════════════════════════════════
6. 反作弊 / 风控检测(只读)
═══════════════════════════════════════════════════════════════════════ */
const CAPTCHA_SIGNATURES = [
{
id: 'recaptcha', name: 'reCAPTCHA', level: 'high',
re: [/recaptcha/i, /google\.com\/recaptcha/, /gstatic\.com\/recaptcha/],
win: ['grecaptcha', '___grecaptcha_cfg'],
dom: '.g-recaptcha, [data-sitekey], iframe[src*="recaptcha"]'
},
{
id: 'hcaptcha', name: 'hCaptcha', level: 'high',
re: [/hcaptcha\.com/i, /hcaptcha/i],
win: ['hcaptcha'],
dom: '.h-captcha, iframe[src*="hcaptcha"]'
},
{
id: 'turnstile', name: 'Cloudflare Turnstile', level: 'high',
re: [/challenges\.cloudflare\.com/i, /turnstile/i],
win: ['turnstile'],
dom: '.cf-turnstile, iframe[src*="challenges.cloudflare.com"]'
},
{
id: 'geetest', name: '极验 GeeTest', level: 'high',
re: [/geetest/i, /gt4?\.js/],
win: ['geetest', 'initGeetest'],
dom: '.geetest_panel, .geetest_holder'
},
{
id: 'tencent', name: '腾讯验证码 / 天御', level: 'high',
re: [/captcha\.tencent\.com/i, /turing\.captcha/i, /tdc\.js/i, /ssl\.captcha\.qq\.com/i],
win: ['TencentCaptcha'],
dom: 'iframe[src*="captcha.tencent"], #tcaptcha_iframe'
},
{
id: 'slider', name: '滑块 / 图形验证', level: 'high',
re: [/slider.?captcha/i, /滑动验证|滑块验证|请完成安全验证|向右滑动/],
win: [],
dom: '[class*="slider-captcha"], [class*="slideVerify"], [class*="captcha"]'
},
{
id: 'aliyun', name: '阿里云验证码', level: 'high',
re: [/aliyuncaptcha/i, /captcha\.aliyun\.com/i],
win: ['AliyunCaptcha'],
dom: '#aliyunCaptcha, [data-aliyun-captcha]'
}
];
const RISK_SDKS = [
{ name: 'FingerprintJS / FPJS', re: [/fingerprintjs/i, /fpjs/i, /@fingerprintjs/i], win: ['FingerprintJS', 'fpjs'], level: 'high' },
{ name: 'DataDome', re: [/datadome/i, /ddosprotection/i], win: ['ddjs'], level: 'high' },
{ name: 'PerimeterX / HUMAN', re: [/perimeterx/i, /_px(?:vid|de|hd|ck)?\b/i, /px-captcha/i], win: ['_pxAppId'], level: 'high' },
{ name: 'Akamai Bot Manager', re: [/akam/i, /_abck/i, /bm-verify/i, /sensor_data/i], win: ['bmak'], level: 'high' },
{ name: 'Kasada', re: [/kasada/i, /kpsdk/i, /ips\.js/i], win: ['KPSDK'], level: 'high' },
{ name: '数美 (Shumei)', re: [/shumei/i, /smcaptcha/i, /fm\.shumei/i], win: [], level: 'high' },
{ name: '同盾 (Tongdun)', re: [/tongdun/i, /fraudmetrix/i, /tdc\.js/i], win: [], level: 'high' },
{ name: '盾山 / 创宇 / 其他风控', re: [/dunshan/i, /yundun/i, /riskcontrol/i, /anti.?bot/i, /anti.?spam/i], win: [], level: 'medium' },
{ name: '会话录制 / 行为回放 (rrweb, Sentry Replay, FullStory, Clarity)', re: [/rrweb/i, /sentry.*replay/i, /fullstory/i, /logrocket/i, /clarity\.ms/i, /hotjar/i], win: ['rrweb', 'FullStory'], level: 'medium' },
{ name: '反调试 / debugger 陷阱', re: [/setInterval\(\s*function\s*\(\s*\)\s*\{\s*debugger/i, /debugger\s*;?\s*\}/i], win: [], level: 'low' }
];
const TRAP_RE = [
/请(?:直接|务必|一定)?选择[^。]{0,12}(?:第\s*[一二三四五六七八九十1-9]\s*(?:个|项|选项)?|A\s*选项|B\s*选项|非常不?(?:同意|满意|喜欢)|不确定)/i,
/本题(?:请|应|务必)[^。]{0,10}(?:选择|选)/i,
/(?:attention|instructional)\s*(?:check|manipulation)/i,
/测谎|陷阱题|一致性(?:检验|校验)|甄别题/i,
/请不要(?:选择|作答|填写)/i,
/为了(?:验证|确认)您?(?:是|为)?(?:真人|人类|真实用户)/i,
/select\s+(?:option\s+)?['"]?(?:three|3|strongly\s+disagree)/i
];
const TRACK_RE = new RegExp(
'(beacon|track(ing)?|analytics|collect|monitor|telemetry|heatmap|' +
'sentry|clarity|fullstory|rrweb|logrocket|hotjar|' +
'report|event\\?|pv\\.gif|ti\\.gif|log(ger)?\\.(js|php)|' +
'anti.?cheat|risk|风控|防作弊|作弊)', 'i'
);
const LIMIT_WORDS = [
/您(?:已经|已)?(?:参与|填写|提交|作答)(?:过|了)/,
/每人(?:仅|只)(?:限|能)(?:填|答|提交)?一?次/,
/同(?:一)?(?:设备|IP|手机|电脑|微信)(?:仅|只)(?:限|能)/,
/请勿重复(?:提交|填写|作答)/,
/该(?:问卷|链接)(?:已)?(?:失效|过期|关闭)/,
/one\\s+response\\s+per/i,
/already\\s+(?:responded|submitted|taken)/i
];
function grams(s) {
const g = new Set();
const str = String(s);
if (str.length <= 1) { if (str) g.add(str); return g; }
for (let i = 0; i < str.length - 1; i++) g.add(str.substr(i, 2));
return g;
}
function jaccard(a, b) {
const A = grams(a), B = grams(b);
if (!A.size || !B.size) return 0;
let inter = 0;
A.forEach(function (x) { if (B.has(x)) inter++; });
return inter / (A.size + B.size - inter);
}
function normTitle(t) {
return String(t || '')
.replace(/[((][^))]*[))]/g, '')
.replace(/[\s\u3000]+/g, '')
.toLowerCase();
}
/**
* 收集用于特征匹配的"信号面":外链资源 URL + 内联脚本内容。
* 刻意不扫正文文本——页面里只要提到 "reCAPTCHA"(帮助文档、评论区、
* 控制台输出)就会导致全文正则误报,把整份问卷误判成高风险。
*/
function collectPageSignals() {
const urls = [];
let inline = '';
try {
document.querySelectorAll('script[src], iframe[src], frame[src], link[href], img[src]')
.forEach(function (n) {
const u = n.getAttribute('src') || n.getAttribute('href') || '';
if (u) urls.push(u);
});
} catch (e) { /* ignore */ }
try {
const html = document.documentElement.outerHTML.slice(0, CFG.scanPromptLimit);
const re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(html)) !== null) inline += m[1] + '\n';
} catch (e) { /* ignore */ }
return { urls: urls, inline: inline, haystack: urls.join('\n') + '\n' + inline };
}
const RiskScanner = {
scan(questions, historyTitles) {
const findings = [];
const ctx = {
signals: collectPageSignals(),
questions: questions || [],
historyTitles: historyTitles || [],
scope: resolveScope(),
media: { media: false, interactive: false }
};
this._scanCaptcha(ctx, findings);
this._scanRiskSDK(ctx, findings);
this._scanHoneypot(ctx, findings);
this._scanBehavior(ctx, findings);
this._scanTrapQuestions(ctx, findings);
this._scanDuplicates(ctx, findings);
this._scanLimits(ctx, findings);
this._scanRandomization(ctx, findings);
this._scanAutomation(ctx, findings);
this._scanMedia(ctx, findings);
let level = 'low';
if (findings.some(f => f.level === 'high')) level = 'high';
else if (findings.some(f => f.level === 'medium')) level = 'medium';
const result = {
level: level,
findings: findings,
scannedAt: Date.now(),
url: location.href,
// 高风险:禁止自动填写,强制只给建议
forbidFill: level === 'high',
// 中风险:可填写,但禁止自动提交且必须人工复核
forbidAutoSubmit: level !== 'low',
// 含媒体/交互题:即便整体评级不是中风险,也不能全自动
hasMedia: !!(ctx.media && (ctx.media.media || ctx.media.interactive))
};
state.risk = result;
state.lastScanAt = result.scannedAt;
return result;
},
_push(findings, level, title, detail, evidence) {
findings.push({
level: level,
title: title,
detail: detail,
evidence: evidence || ''
});
},
_scanCaptcha(ctx, out) {
const hay = ctx.signals.haystack;
CAPTCHA_SIGNATURES.forEach(function (sig) {
let via = '';
if (sig.re.some(function (re) { return re.test(hay); })) via = '外链资源或内联脚本';
if (!via) {
try {
if (sig.dom && document.querySelector(sig.dom)) via = 'DOM 元素';
} catch (e) { /* ignore */ }
}
if (!via && sig.win.some(function (w) { return typeof window[w] !== 'undefined'; })) {
via = 'window 对象';
}
if (via) {
out.push({
level: sig.level,
title: '检测到验证码:' + sig.name,
detail: '来源:' + via + '。自动脚本无法通过真人验证,提交会被拦截或标记。',
evidence: sig.name
});
}
});
},
_scanRiskSDK(ctx, out) {
const hay = ctx.signals.haystack;
RISK_SDKS.forEach(function (s) {
let via = '';
if (s.re.some(function (re) { return re.test(hay); })) via = '外链资源或内联脚本';
if (!via && s.win.some(function (w) { return typeof window[w] !== 'undefined'; })) {
via = 'window 对象';
}
if (via) {
out.push({
level: s.level,
title: '检测到风控/追踪组件:' + s.name,
detail: '来源:' + via + '。该组件会采集浏览器指纹、鼠标轨迹或录屏,用于识别自动化行为。',
evidence: s.name
});
}
});
},
_scanHoneypot(ctx, out) {
const hits = [];
const nodes = document.querySelectorAll('form input, form select, form textarea');
nodes.forEach(function (el) {
const ident = ((el.name || '') + ' ' + (el.id || '') + ' ' +
(typeof el.className === 'string' ? el.className : '')).toLowerCase();
if (/(honeypot|hp_|_hp\b|trap|bot_|antispam|fax|middle_?name|contact_?url|personal_?website)/.test(ident)) {
hits.push({ el: el, kind: '蜜罐命名字段' });
return;
}
if (/(csrf|token|_tk|nonce|authenticity|__request)/.test(ident)) return;
if (el.type === 'hidden') return;
// 单选/多选用 CSS 隐藏自身、由父级 label 代理呈现,是自定义控件的
// 常见做法(按钮组、评分条),不是蜜罐。蜜罐几乎总是文本输入框,
// 靠诱使机器人填字来识别。所以只对可输入类型做「视觉隐藏」判定。
const type = (el.type || '').toLowerCase();
const isChoice = (type === 'radio' || type === 'checkbox');
if (isChoice) return;
let cs = null, r = null;
try { cs = getComputedStyle(el); r = el.getBoundingClientRect(); } catch (e) { return; }
const hidden = cs.display === 'none' || cs.visibility === 'hidden' ||
parseFloat(cs.opacity) === 0 ||
(r.width <= 1 && r.height <= 1) ||
r.right < -50 || r.bottom < -50 ||
r.left > (window.innerWidth + 100);
if (hidden) hits.push({ el: el, kind: '视觉隐藏字段' });
});
if (hits.length) {
// 按成因归类,避免同一类隐藏字段刷屏
const byKind = {};
const named = [];
hits.forEach(function (h) {
byKind[h.kind] = (byKind[h.kind] || 0) + 1;
if (h.kind === '蜜罐命名字段') named.push(h.el.name || h.el.id || '(匿名)');
});
const summary = Object.keys(byKind).map(function (k) {
return k + ' ×' + byKind[k];
}).join(',');
// 匿名字段也要能定位:补上 placeholder / class
const anon = hits.filter(function (h) {
return h.kind === '视觉隐藏字段' && !h.el.name && !h.el.id;
}).length;
out.push({
level: 'high',
title: '检测到蜜罐/隐藏陷阱字段 ×' + hits.length,
detail: summary + '。这些字段对真人不可见,自动填写一旦填了就会被判定为机器人。已自动跳过,请勿手动填。',
evidence: (named.length ? '命名字段:' + named.slice(0, 5).join('、') : '') +
(anon ? (named.length ? '\n' : '') + '另有 ' + anon + ' 个匿名隐藏输入框(多为自定义控件的原生 input,属正常现象)' : '')
});
}
},
_scanBehavior(ctx, out) {
const evt = Probe.listeners;
const behaviorCount = BEHAVIOR_EVENTS.reduce(function (sum, k) {
return sum + (evt[k] || 0);
}, 0);
const keyEvents = ['mousemove', 'pointermove', 'keydown', 'visibilitychange', 'blur', 'scroll'];
const monitored = keyEvents.filter(function (k) { return (evt[k] || 0) > 0; });
if (behaviorCount >= 6 && monitored.length >= 3) {
out.push({
level: 'medium',
title: '页面正在监听用户行为事件',
detail: '检测到 ' + behaviorCount + ' 个行为监听(' + monitored.join('、') +
')。通常用于计算填答轨迹与停留时长,瞬间完成的填写极易被标记。',
evidence: monitored.join(', ')
});
} else if (behaviorCount >= 3) {
out.push({
level: 'low',
title: '存在少量行为监听',
detail: '共 ' + behaviorCount + ' 个,可能是常规交互逻辑。',
evidence: monitored.join(', ')
});
}
const trackUrls = Probe.reqs.filter(function (u) { return TRACK_RE.test(u); });
if (trackUrls.length >= 3 || Probe.beacons >= 2 || Probe.canvasReads >= 1) {
const sample = trackUrls.slice(0, 3).map(function (u) {
return u.length > 120 ? u.slice(0, 120) + '…' : u;
});
out.push({
level: Probe.canvasReads >= 1 ? 'high' : 'medium',
title: '检测到数据上报行为',
detail: '页面已发起 ' + Probe.reqs.length + ' 次请求,其中疑似埋点/上报 ' + trackUrls.length +
' 次;sendBeacon ' + Probe.beacons + ' 次;canvas 指纹读取 ' + Probe.canvasReads + ' 次。',
evidence: sample.join('\n')
});
}
},
_scanTrapQuestions(ctx, out) {
const hits = [];
ctx.questions.forEach(function (q) {
const t = String(q.title || '');
if (TRAP_RE.some(function (re) { return re.test(t); })) {
hits.push({ q: q, kind: '指令型 / 注意力检验题' });
}
// 选项本身像指令
q.options.forEach(function (o) {
if (TRAP_RE.some(function (re) { return re.test(o); })) {
hits.push({ q: q, kind: '选项含指令:' + o.slice(0, 30) });
}
});
});
if (hits.length) {
out.push({
level: 'high',
title: '疑似陷阱题(注意力检验) ×' + hits.length,
detail: '这类题目要求按字面指令作答(如"请选择第三项")。AI 会按语义理解作答,必然答错并被判定无效。',
evidence: hits.slice(0, 5).map(function (h) {
return '第' + (h.q.index + 1) + '题:' + h.kind;
}).join('\n')
});
}
},
/**
* 判断题干是否可信。题干提取失败时会兜底成「(未识别题干)xx」
* 或「未命名题目」,若拿这些兜底值去做相似度比对,所有失败题目都会
* 两两判为 1.00,瞬间刷出十几条误报——必须先把它们排除掉。
*/
_titleTrustworthy(t) {
if (!t) return false;
const s = String(t);
if (/^(未识别题干)/.test(s) || /^未命名题目$/.test(s)) return false;
const n = normTitle(s);
// 太短的标题(去掉括号后不足 6 字)区分度不够,容易误判
if (n.length < 6) return false;
// 纯数字/纯符号
if (!/[一-龥a-zA-Z]/.test(n)) return false;
return true;
},
_scanDuplicates(ctx, out) {
const qs = ctx.questions;
const self = this;
const pairs = [];
const usable = [];
qs.forEach(function (q, i) {
if (self._titleTrustworthy(q.title)) usable.push({ idx: i, raw: q.title, n: normTitle(q.title) });
});
// 同页内的相似题
for (let i = 0; i < usable.length; i++) {
for (let j = i + 1; j < usable.length; j++) {
const sim = jaccard(usable[i].n, usable[j].n);
if (sim >= 0.82) {
pairs.push({
ai: usable[i].idx, bi: usable[j].idx,
a: usable[i].raw, b: usable[j].raw,
sim: sim, cross: false
});
}
}
}
// 与前面几页的相似题(跨页一致性校验,单页扫描看不见)
const hist = ctx.historyTitles || [];
usable.forEach(function (u) {
hist.forEach(function (ht) {
if (!self._titleTrustworthy(ht)) return;
const sim = jaccard(u.n, normTitle(ht));
if (sim >= 0.82) pairs.push({ ai: u.idx, bi: -1, a: u.raw, b: ht, sim: sim, cross: true });
});
});
if (!pairs.length) return;
// 题干完全一样(sim≈1)多半是提取重复了同一个容器,
// 和「问卷方设置的一致性校验题」不是一回事,要分开说清楚
const exact = pairs.filter(function (p) { return p.sim >= 0.995; });
const fuzzy = pairs.filter(function (p) { return p.sim < 0.995; });
if (exact.length) {
out.push({
level: 'low',
title: '题干重复 ×' + exact.length + '(疑似提取重复,非一致性校验)',
detail: '有若干题干面一致,通常是同一个容器被重复计入,或页面本身有相同文案。不影响作答,但可能造成重复填写。',
evidence: exact.slice(0, 3).map(function (p) {
return '「' + String(p.a).slice(0, 30) + '」出现多次';
}).join('\n')
});
}
if (fuzzy.length) {
const cross = fuzzy.filter(function (p) { return p.cross; });
out.push({
level: 'medium',
title: '检测到相似题 ×' + fuzzy.length + (cross.length ? '(其中跨页 ' + cross.length + ' 对)' : ''),
detail: '常用于一致性校验(前后回答是否矛盾)。AI 分两次独立作答,答案可能不一致而被判无效。' +
(cross.length ? '跨页重复只有在连续作答时才能发现。' : ''),
evidence: fuzzy.slice(0, 5).map(function (p) {
return (p.cross ? '与前面某页' : '第' + (p.ai + 1) + '题') +
' ↔ 「' + String(p.b).slice(0, 26) + '」(相似度 ' + p.sim.toFixed(2) + ')';
}).join('\n')
});
}
},
_scanLimits(ctx, out) {
let text = '';
try { text = nodeText(document.body).slice(0, 20000); } catch (e) { /* ignore */ }
const hit = LIMIT_WORDS.filter(function (re) { return re.test(text); });
if (hit.length) {
out.push({
level: 'medium',
title: '页面含参与次数/身份限制提示',
detail: '可能存在 IP、账号或设备维度的去重,重复提交会被过滤。',
evidence: (text.match(/.{0,40}(?:仅限|只能|已经|请勿重复|失效|过期).{0,40}/) || [''])[0]
});
}
if (/[?&](token|sid|uuid|resp|uid|invite|hash)=/i.test(location.search) ||
/\/s\/[A-Za-z0-9]{8,}/.test(location.pathname)) {
out.push({
level: 'medium',
title: '链接含一次性标识',
detail: 'URL 带 token / sid / 短码,说明该问卷按人发放并可追溯到具体答卷人。',
evidence: location.href.slice(0, 160)
});
}
const cookie = document.cookie || '';
if (/submitted|answered|responded|done|finished|wjx|survey_/i.test(cookie)) {
out.push({
level: 'low',
title: '本地 Cookie 存在作答痕迹',
detail: '浏览器已留有该问卷的访问/作答记录。',
evidence: cookie.replace(/=[^;]+/g, '=***').slice(0, 200)
});
}
},
_scanRandomization(ctx, out) {
let shuffle = false;
try {
shuffle = !!document.querySelector('[data-random], [data-shuffle], [class*="shuffle"], [class*="random"]');
} catch (e) { /* ignore */ }
if (!shuffle && /shuffle|randomize|Math\.random\(\)\s*-\s*0?\.5/.test(ctx.signals.haystack)) shuffle = true;
if (shuffle) {
out.push({
level: 'medium',
title: '题目/选项可能随机化',
detail: '选项顺序被打乱时,AI 按语义返回的答案需要与当前页面的真实顺序重新匹配,错位风险上升。',
evidence: 'data-random / shuffle 标记'
});
}
},
/**
* 检测题目里包含 AI 无法感知的内容。
*
* 这是最容易被忽略的一类问题:脚本能提取题干文字,也能把选项发给 AI,
* 但如果题目本身是"看图选择""听音判断""看视频回答",AI 拿到的只有
* 一句"请根据下图作答"——它看不见图,只能瞎猜,猜出来的答案必错,
* 而且错得毫无痕迹,用户根本发现不了。
*
* 所以这类题目不该继续全自动,必须降级并明确告知用户"这里需要你自己看"。
*/
/**
* 判断一个 img/svg/canvas 是否为装饰性图标,而非真正的题目内容。
*
* 为什么需要:后台管理系统、组件库的菜单栏里动辄几十个 16px 的 SVG 图标,
* 若一律算"媒体内容",脚本会在几乎所有这类页面上误降级为半自动,
* 全自动形同虚设。真正的题目配图(截图、示意图、图表)尺寸明显更大。
*
* 三条判据,命中任意一条即视为装饰:
* 1. 明确标注 aria-hidden / role=presentation —— 辅助技术都会跳过它
* 2. 尺寸小于阈值(默认 44px,约等于最小可点击图标的两倍)
* 3. 位于 button / a 内部 —— 典型的按钮图标
*/
isDecorativeMedia(el, minPx) {
if (!el) return true;
const MIN = (typeof minPx === 'number' && minPx >= 16) ? minPx : 44;
try {
const ah = el.getAttribute && el.getAttribute('aria-hidden');
if (ah && String(ah).toLowerCase() === 'true') return true;
const role = el.getAttribute && el.getAttribute('role');
if (role && String(role).toLowerCase() === 'presentation') return true;
// 按钮/链接里的图形基本都是图标
if (el.closest && el.closest('button, a, [role="button"]')) return true;
const r = RectCache.get(el);
if (r.width && r.height && (r.width < MIN || r.height < MIN)) return true;
// 取不到尺寸时按属性兜底:有 alt 且非空的通常是内容图
const alt = el.getAttribute && el.getAttribute('alt');
if (alt && String(alt).trim().length > 0) return false;
} catch (e) { /* ignore */ }
return false;
},
_scanMedia(ctx, out) {
// 用户可在「风险」页关闭;图标密集的后台系统页面关掉可避免误降级
if (state.mediaCheck === false) { ctx.media = { media: false, interactive: false }; return; }
const qs = ctx.questions || [];
const found = [];
const MIN_PX = (typeof state.mediaMinPx === 'number' && state.mediaMinPx >= 16)
? state.mediaMinPx : 44;
/* 媒体类:AI 看不见/听不见。
decorative 标记该项通常是装饰性图标(菜单图标、按钮图标等),
需要按尺寸二次过滤,否则后台管理系统这类图标密集的页面会满屏误报。 */
const MEDIA = [
{ sel: 'img', key: 'image', name: '图片', decorative: true },
{ sel: 'video', key: 'video', name: '视频', decorative: false },
{ sel: 'audio', key: 'audio', name: '音频', decorative: false },
{ sel: 'canvas', key: 'canvas', name: '画布', decorative: true },
{ sel: 'iframe', key: 'iframe', name: '嵌入内容', decorative: false },
{ sel: 'svg', key: 'svg', name: '矢量图', decorative: true },
{ sel: 'object, embed', key: 'embed', name: '插件对象', decorative: false }
];
qs.forEach(function (q) {
const box = q.inputs && q.inputs[0]
? (q.inputs[0].closest('[class*="q"], li, tr, fieldset') || q.inputs[0].parentElement)
: null;
if (!box) return;
/* 只扫题目容器本身。
之前用 box.parentElement,会把整页容器都卷进来,
导航栏、页脚、侧边栏的图标全算到题目头上,误报率极高。 */
const scope = box;
MEDIA.forEach(function (m) {
let nodes = [];
try { nodes = Array.prototype.slice.call(scope.querySelectorAll(m.sel)); }
catch (e) { return; }
if (!nodes.length) return;
// 装饰性类型按尺寸与语义过滤掉图标
const real = m.decorative
? nodes.filter(function (n) { return !RiskScanner.isDecorativeMedia(n, MIN_PX); })
: nodes;
if (!real.length) return;
found.push({
kind: m.key, name: m.name,
title: String(q.title).slice(0, 40),
count: real.length
});
});
});
/* 需要真人操作的题型:脚本填不了,AI 也答不了 */
const INTERACTIVE = [
{ sel: 'input[type=file]', name: '文件上传' },
{ sel: 'input[type=range]', name: '滑块评分' },
{ sel: '[class*="sortable"], [draggable=true]', name: '拖拽排序题' },
{ sel: '[class*="matrix"], table input[type=radio]', name: '矩阵量表题' },
{ sel: '[class*="signature"], [class*="sign-canvas"]', name: '手写签名' },
{ sel: '[class*="rate"], [class*="star"]', name: '星级评分' }
];
const inter = [];
const scopeRoot = ctx.scope || document;
INTERACTIVE.forEach(function (m) {
let n = 0;
try { n = scopeRoot.querySelectorAll(m.sel).length; } catch (e) { return; }
if (n > 0) inter.push({ name: m.name, count: n });
});
if (found.length) {
const kinds = {};
found.forEach(function (f) { kinds[f.name] = (kinds[f.name] || 0) + 1; });
const summary = Object.keys(kinds).map(function (k) {
return k + ' ×' + kinds[k];
}).join('、');
this._push(out, 'medium',
'题目含 AI 无法感知的媒体内容',
'检测到 ' + summary +
'。AI 只能读到文字,看不见图片/视频内容,这类题目它会凭空猜测,' +
'答案必然不可靠。已降级为「半自动」,这些题目请你自己看过后作答。',
found.map(function (f) {
return f.title + ' → ' + f.name + (f.count > 1 ? ' ×' + f.count : '');
}).join('\n'));
}
if (inter.length) {
this._push(out, 'medium',
'存在需真人操作的题型',
'检测到 ' + inter.map(function (i) { return i.name; }).join('、') +
'。这类控件无法用文字答案自动填写(文件上传受浏览器安全限制,' +
'滑块/排序/签名需要真实交互动作)。已降级为「半自动」。',
inter.map(function (i) { return i.name + ' ×' + i.count; }).join('\n'));
}
/* 记录到 ctx,供策略层判断是否强制降级 */
ctx.media = { media: found.length > 0, interactive: inter.length > 0 };
},
_scanAutomation(ctx, out) {
if (Probe.webdriver) {
out.push({
level: 'high',
title: '当前环境为自动化浏览器',
detail: 'navigator.webdriver 为真,说明运行在 Selenium / Puppeteer 等被无头特征标记的会话中,几乎必然被风控识别。',
evidence: 'navigator.webdriver = true'
});
}
if (/cdc_|__nightmare|__puppeteer|__playwright/i.test(ctx.signals.inline)) {
out.push({
level: 'high',
title: '页面内置自动化工具特征检测',
detail: '页面代码包含对 Selenium / Puppeteer / Playwright 特征的检查逻辑。',
evidence: 'cdc_ / __puppeteer 等特征字符串'
});
}
}
};
/* ═══════════════════════════════════════════════════════════════════════
配置导出 / 导入
换浏览器或换机器时,Key、人设、速度参数、白名单要重填一遍很痛苦。
导出的是明文,含 API Key,所以弹窗标题里就写明了"请妥善保管"。
═══════════════════════════════════════════════════════════════════════ */
const EXPORT_KEYS = [
'provider', 'model', 'baseUrl', 'apiKey', 'persistKey',
'mode', 'autoSubmit', 'crossPage', 'sensitivity', 'widePanel', 'debug',
'persona', 'customSystemPrompt', 'excludeSelectors', 'scopeSelector',
'multiMin', 'multiMax', 'textMaxChars', 'batchSize', 'timeout', 'temperature',
'scopeWhitelist',
'typeSpeed', 'typeBaseMs', 'typePerCharMs', 'typeJitter', 'typeThinkMs',
'typeThinkPerChar', 'typePauseChance', 'typeCharMinMs', 'typeCharMaxMs'
];
function exportConfig() {
const out = { _format: 'gametame-config', _version: SCRIPT_NAME, _at: Date.now() };
EXPORT_KEYS.forEach(function (k) {
const v = Store.get(k, undefined);
if (v !== undefined && v !== '') out[k] = v;
});
return out;
}
function importConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('配置为空');
let n = 0;
EXPORT_KEYS.forEach(function (k) {
if (k in cfg) { Store.set(k, cfg[k]); n++; }
});
if ('scopeWhitelist' in cfg) { Store.set('scopeWhitelist', cfg.scopeWhitelist); n++; }
return n;
}
/** 依据风险等级决定实际执行策略 */
function resolvePolicy(risk) {
const policy = {
mode: state.mode,
autoSubmit: state.autoSubmit,
downgraded: false,
blocked: false,
reasons: []
};
if (!risk) return policy;
if (risk.forbidFill) {
policy.mode = 'manual';
policy.autoSubmit = false;
policy.blocked = true;
policy.downgraded = true;
policy.reasons.push('检测到高风险反作弊措施,已强制切换为「仅生成建议」,不会自动填写或提交');
} else if (risk.hasMedia && policy.mode === 'auto') {
/* 含图片/视频等 AI 看不见的内容时,全自动毫无意义:
AI 只能凭空猜,猜完还照填不误,用户事后也看不出哪里错了。
强制降到半自动,让用户至少过一眼。 */
policy.mode = 'semi';
policy.downgraded = true;
policy.reasons.push('题目含图片/视频等 AI 无法感知的内容,已从「全自动」降级为「半自动」');
}
if (risk.forbidAutoSubmit) {
if (policy.mode === 'auto') {
policy.mode = 'semi';
policy.downgraded = true;
policy.reasons.push('检测到中风险反作弊措施,已从「全自动」降级为「半自动(人工复核后填写)」');
}
if (policy.autoSubmit) {
policy.autoSubmit = false;
policy.downgraded = true;
policy.reasons.push('存在反作弊措施,已禁用「自动提交」,需你手动点击提交');
}
}
return policy;
}
/* ═══════════════════════════════════════════════════════════════════════
6.5 跨页处理
───────────────────────────────────────────────────────────────────────
多页问卷有三种换页方式,脚本都要能跟上:
A. 整页跳转(location.href / <a>)—— 脚本会重新注入,靠持久化状态续接
B. SPA pushState —— 脚本不重载,靠 URLWatcher 捕获
C. 前进/后退(popstate / hashchange)—— 同上
关键点:内存中的 questions/answers 在跳转后必然丢失,所以「做到第几页、
哪些页已完成」必须落到 GM 存储里,并在新页面重新提取题目。
═══════════════════════════════════════════════════════════════════════ */
/**
* 页面指纹:只用 pathname + 关键 query 参数,忽略 token/sid/t 这类
* 每次都会变的一次性参数,否则同一个问卷页会被判成不同页面。
*/
function pageKey(url) {
let u;
try { u = new URL(url, location.href); } catch (e) { u = location; }
const IGNORE = /^(token|sid|uid|uuid|_t|t|ts|nonce|rand|r|v|hash|sign|session)$/i;
const keep = [];
try {
u.searchParams.forEach(function (v, k) {
if (!IGNORE.test(k)) keep.push(k + '=' + v);
});
} catch (e) { /* ignore */ }
return u.origin + u.pathname + (keep.length ? '?' + keep.sort().join('&') : '');
}
/**
* 页面内容指纹:题目数量 + 前若干道题干的摘要。
*
* 存在意义:少数 SPA 换页时不改 URL,只通过 AJAX 整体重绘内容
* (典型是内部系统自研的表单向导)。这种情况下纯 URL 的 pageKey 不变,
* 会被 pendingValid() 判成"原地刷新、跳转无效",于是超时后手动续填,
* 把上一页的答案重新填一遍——后页覆盖前页数据,问卷逻辑被打乱。
* 加上内容指纹后,即便 URL 没变,只要题目变了就能识别出"确实换页了"。
*/
function contentFingerprint() {
try {
const qs = extractQuestions();
if (!qs.length) return 'empty';
const head = qs.slice(0, 3).map(function (q) {
return String(q.title || '').slice(0, 24);
}).join('|');
return qs.length + ':' + head;
} catch (e) {
return 'err';
}
}
/** URL + 内容指纹,用于判断"是否真的换页了" */
function pageId() {
return pageKey(location.href) + '#' + contentFingerprint();
}
/* ═══════════════════════════════════════════════════════════════════════
Core —— 页面理解层门面
─────────────────────────────────────────────────────────────────────────
脚本五千多行,UI 构建与页面解析原本混在同一作用域,问卷平台一改 DOM
结构,就得在成片的 UI 代码里翻找提取逻辑,维护成本很高。
这里把"理解页面"的能力集中到一个稳定的门面上:
· UI 与业务流程一律通过 Core.xxx 调用,不直接依赖内部函数名
· 平台适配(改选择器、加控件类型、调标题策略)只动 Core 内部
· 门面签名保持稳定,内部实现可随时替换而不影响调用方
分层:
UI / 流程编排 → Core 门面 → 具体解析实现(extractQuestions 等)
═══════════════════════════════════════════════════════════════════════ */
const Core = {
/* ── 题目提取 ── */
/** 提取当前页所有可作答题目 */
questions() { return extractQuestions(); },
/** 控件归类(供调试与平台适配排查) */
groups() { return collectGroups(); },
/** 元素 → 内部处理类型(radio/checkbox/select/text/value/file/contenteditable) */
kind: fieldKind,
/** 选项文字;allowValue=false 时禁止用输入框残留值兜底 */
label: optionLabel,
/** 题目容器(所有选项的最近公共祖先) */
container: groupContainer,
/** 题干(优先排除法,不依赖类名) */
title: function (group) { return findTitle(group); },
/** 排除法找题干,类名被混淆时的兜底路径 */
titleByExclusion: function (c, labels, inputs) { return titleByExclusion(c, labels, inputs); },
/** 视觉排序锚点(隐藏控件需向上找有尺寸的祖先) */
anchor: anchorRect,
/** 布局缓存控制:批量提取前 begin,结束后 end */
rect: RectCache,
/* ── 范围与过滤 ── */
scope: resolveScope,
/** 是否为蜜罐/隐藏陷阱字段 */
isHidden: isHiddenField,
/** 元素是否可编辑(contenteditable,兼容属性/状态实现差异) */
isEditable: isEditable,
/* ── 页面标识 ── */
pageKey: pageKey,
pageId: pageId,
contentFingerprint: contentFingerprint,
/* ── 模块版本,便于排查"改了没生效" ── */
version: SCRIPT_NAME
};
const Session = {
data: null,
load() {
const d = Store.get('session', null);
if (!d || typeof d !== 'object') { this.data = null; return null; }
if (!d.startedAt || Date.now() - d.startedAt > CFG.sessionTTL) {
this.clear();
return null;
}
// 换了站点/路径前缀就作废,避免把 A 问卷的进度带到 B 问卷
if (d.origin !== location.origin) { this.clear(); return null; }
this.data = d;
return d;
},
start() {
this.data = {
startedAt: Date.now(),
origin: location.origin,
entryPath: location.pathname,
pages: {}, // { pageKey: { ts, filled, total, title } }
order: [], // 已处理页面的顺序
pending: false, // 是否有「待继续」的跨页任务(整页跳转后靠它续接)
autoConfirm: false, // 跨页时是否跳过逐页确认
lastPageKey: '',
finished: false
};
this.save();
return this.data;
},
save() {
if (this.data) Store.set('session', this.data);
},
clear() {
this.data = null;
Store.del('session');
},
/** 记录某页已处理,返回是否为重复页(用于防止来回跳造成死循环) */
markPage(key, info) {
if (!this.data) return false;
const dup = Object.prototype.hasOwnProperty.call(this.data.pages, key);
this.data.pages[key] = Object.assign({ ts: Date.now() }, info || {});
if (!dup) this.data.order.push(key);
// 累计题目标题,供跨页一致性校验(重复题常常分散在不同页)
if (info && Array.isArray(info.titles) && info.titles.length) {
this.data.titles = (this.data.titles || []).concat(info.titles);
}
this.data.lastPageKey = key;
this.data.pending = false;
this.save();
return dup;
},
pageCount() {
return this.data ? this.data.order.length : 0;
},
isDone(key) {
return !!(this.data && this.data.pages[key]);
},
setPending(v, autoConfirm) {
if (!this.data) return;
this.data.pending = !!v;
this.data.pendingAt = Date.now();
// 记录发起换页时所在的页面,用于区分「真跳转」和「原地刷新」
this.data.pendingFromKey = pageKey(location.href);
try { this.data.pendingFromId = pageId(); } catch (e) { /* ignore */ }
if (autoConfirm !== undefined) this.data.autoConfirm = !!autoConfirm;
this.save();
},
/** pending 是否仍然有效(防止刷新页面后误触发自动续填) */
pendingValid() {
if (!this.data || !this.data.pending) return false;
if (Date.now() - (this.data.pendingAt || 0) > CFG.navTimeout + 15000) return false;
/* 判断是否真的换页:URL 或内容任一变化即可。
只看 URL 会把"URL 不变但内容整体重绘"的 SPA 误判为原地刷新,
导致超时后重复填写同一页。 */
const from = this.data.pendingFromKey;
if (!from) return true;
const nowUrl = pageKey(location.href);
const nowId = pageId();
if (from !== nowUrl) return true; // URL 变了,确实换页
if (this.data.pendingFromId && this.data.pendingFromId !== nowId) return true; // URL 没变但内容变了
return false;
},
summary() {
if (!this.data) return '';
const n = this.data.order.length;
let filled = 0, total = 0;
Object.keys(this.data.pages).forEach(function (k) {
const p = Session.data.pages[k];
filled += (p.filled || 0);
total += (p.total || 0);
});
return '已完成 ' + n + ' 页,累计填写 ' + filled + '/' + total + ' 题';
}
};
/** URL 变化监听:hook History API + 事件 + 轮询兜底 */
const URLWatcher = {
last: '',
timer: null,
handler: null,
install(handler) {
this.handler = handler;
this.last = location.href;
/* 除了 URL,还要监测"URL 不变但内容整体重绘"的纯 AJAX 换页
(内部系统自研向导的常见做法)。用 MutationObserver 感知 DOM 变动,
只有累计变动达到规模才去算内容指纹——避免每次微调都做一次全量提取。 */
let domChanges = 0;
let lastFp = null;
const maybeContentChanged = function () {
if (!state.crossPage) return false;
if (!Session.data || !Session.data.pending) return false;
domChanges++;
// 少量节点变动属正常(loading、计数刷新),超过阈值才值得核算
if (domChanges < CFG.contentChangeNodes) return false;
domChanges = 0;
let fp;
try { fp = contentFingerprint(); } catch (e) { return false; }
if (lastFp !== null && fp !== lastFp) {
lastFp = fp;
return true;
}
lastFp = fp;
return false;
};
const check = function () {
if (location.href !== URLWatcher.last) {
const from = URLWatcher.last;
URLWatcher.last = location.href;
try { URLWatcher.handler && URLWatcher.handler(from, location.href); }
catch (e) { console.error('[Gametame] URL 变化处理失败', e); }
return;
}
// URL 未变,检查内容是否整体重绘
if (maybeContentChanged()) {
try { URLWatcher.handler && URLWatcher.handler(location.href, location.href + '#content'); }
catch (e) { console.error('[Gametame] 内容变化处理失败', e); }
}
};
/* MutationObserver 只做"计数",不做任何 DOM 读取,
避免在每个 mutation 回调里触发同步布局。 */
try {
const MO = window.MutationObserver;
if (MO && document.body) {
this.mo = new MO(function (records) {
for (let i = 0; i < records.length; i++) {
domChanges += (records[i].addedNodes ? records[i].addedNodes.length : 0)
+ (records[i].removedNodes ? records[i].removedNodes.length : 0);
}
});
this.mo.observe(document.body, { childList: true, subtree: true });
}
} catch (e) { /* ignore */ }
try {
const ps = history.pushState;
const rs = history.replaceState;
history.pushState = function () {
const r = ps.apply(this, arguments);
setTimeout(check, 0);
return r;
};
history.replaceState = function () {
const r = rs.apply(this, arguments);
setTimeout(check, 0);
return r;
};
} catch (e) { /* ignore */ }
window.addEventListener('popstate', function () { setTimeout(check, 0); });
window.addEventListener('hashchange', function () { setTimeout(check, 0); });
// 兜底:某些站点用 location.href 直接赋值,且不触发任何可 hook 的调用
this.timer = setInterval(check, CFG.urlPollMs);
// 整页离开前留个标记,方便新页面判断是否「同一份问卷的下一页」
window.addEventListener('pagehide', function () {
try {
if (Session.data && Session.data.pending) Session.save();
} catch (e) { /* ignore */ }
});
}
};
/** 等待 DOM 稳定(题目的数量不再变化,适配懒加载/分步渲染) */
function waitForStableQuestions(timeoutMs) {
return new Promise(function (resolve) {
const deadline = Date.now() + (timeoutMs || 6000);
let lastCount = -1;
let stableTicks = 0;
const t = setInterval(function () {
let n = 0;
try { n = extractQuestions().length; } catch (e) { n = 0; }
if (n > 0 && n === lastCount) stableTicks++;
else stableTicks = 0;
lastCount = n;
if (stableTicks >= 2 || Date.now() > deadline) {
clearInterval(t);
resolve(n);
}
}, 350);
});
}
/**
* 找「下一页」按钮。要能区分下一页和提交:
* 提交按钮优先由 findSubmitButton 命中,这里命中后还会再排除一次。
*/
function findNextButton() {
const submitBtn = findSubmitButton();
const sels = [
'a[class*="next"]', 'button[class*="next"]', '[id*="next"]',
'a[class*="Next"]', 'button[class*="Next"]',
'[class*="next-page"]', '[class*="nextPage"]',
'.pagination a', '.pager a', '.page-nav a'
];
const NEXT_RE = /^(下一页|下页|下一题|下题|下一步|继续|后一页|next|continue)$/i;
const CONTAIN_RE = /下一页|下页|下一题|下题|下一步|继续填写|next\s*(page|step)?/i;
const SUBMIT_RE = /提交|完成|发送|交卷|submit|finish|done/i;
const candidates = [];
// 1. 结构选择器(最可靠)
sels.forEach(function (s) {
try {
document.querySelectorAll(s).forEach(function (el) {
if (isVisible(el)) candidates.push({ el: el, score: 10 });
});
} catch (e) { /* ignore */ }
});
// 2. 文案匹配(兜底,覆盖自定义类名)
['a', 'button', 'input[type="button"]', 'div[role="button"]', 'span[role="button"]'].forEach(function (s) {
try {
document.querySelectorAll(s).forEach(function (el) {
if (!isVisible(el)) return;
const t = (nodeText(el) || el.value || '').replace(/\s+/g, '');
if (!t || t.length > 12) return;
if (SUBMIT_RE.test(t)) return; // 排除提交
if (NEXT_RE.test(t)) candidates.push({ el: el, score: 8 });
else if (CONTAIN_RE.test(t)) candidates.push({ el: el, score: 5 });
});
} catch (e) { /* ignore */ }
});
if (!candidates.length) return null;
candidates.sort(function (a, b) { return b.score - a.score; });
// 排除掉被识别为提交的那个元素
for (let i = 0; i < candidates.length; i++) {
if (candidates[i].el !== submitBtn) return candidates[i].el;
}
return null;
}
/* ═══════════════════════════════════════════════════════════════════════
7. AI 请求
═══════════════════════════════════════════════════════════════════════ */
function extractJSON(text) {
if (!text) return null;
let s = String(text).trim();
// 剥离 ```json ... ``` 围栏
const fence = s.match(/```(?:json|JSON)?\s*([\s\S]*?)```/);
if (fence) s = fence[1].trim();
// 截取第一个 { 到最后一个 }
const a = s.indexOf('{');
const b = s.lastIndexOf('}');
if (a !== -1 && b > a) s = s.slice(a, b + 1);
const attempts = [s, s.replace(/,\s*([}\]])/g, '$1'), s.replace(/[\u201C\u201D]/g, '"')];
for (let i = 0; i < attempts.length; i++) {
try { return JSON.parse(attempts[i]); } catch (e) { /* try next */ }
}
return null;
}
function requestAI(provider, model, key, prompt) {
return new Promise(function (resolve, reject) {
let req;
try {
req = provider.buildRequest(prompt, model, key, state.customBase);
} catch (err) {
Log.error('请求构造失败', err.message);
reject(new Error('请求构造失败:' + err.message));
return;
}
const t0 = Date.now();
const reqUrl = String(req.url || '');
const safeUrl = reqUrl.replace(/([?&](?:key|api[-_]?key)=)[^&]*/i, '$1***');
Log.add('ai', '→ 发起请求 ' + provider.name,
model + ' ' + safeUrl + ' 超时 ' + Math.round(CFG.timeout / 1000) + 's',
{ promptChars: prompt.length });
GM_xmlhttpRequest({
method: req.method || 'POST',
url: req.url,
headers: req.headers,
data: JSON.stringify(req.body),
timeout: CFG.timeout,
onload: function (res) {
const ms = Date.now() - t0;
if (res.status < 200 || res.status >= 300) {
const body = String(res.responseText || '').slice(0, 300);
let msg = 'HTTP ' + res.status;
if (res.status === 401 || res.status === 403) msg += ':API Key 无效或无权限';
else if (res.status === 404) msg += ':接口或模型名不存在,请检查模型名称';
else if (res.status === 429) msg += ':请求过于频繁或额度不足';
else if (res.status >= 500) msg += ':服务商端异常,请稍后重试';
Log.error('← 请求失败 HTTP ' + res.status, msg + '(' + ms + 'ms)', body);
reject(new Error(msg + (body ? '\n' + body : '')));
return;
}
let content = null;
try {
content = provider.parse(res);
} catch (e) {
Log.error('响应解析失败', e.message + '(' + ms + 'ms)',
String(res.responseText || '').slice(0, 400));
reject(new Error('响应解析失败:' + e.message));
return;
}
const data = extractJSON(content);
if (!data) {
Log.error('AI 未返回可解析 JSON', '耗时 ' + ms + 'ms',
String(content || '').slice(0, 400));
reject(new Error('AI 未返回可解析的 JSON。原始内容前 200 字:\n' +
String(content || '').slice(0, 200)));
return;
}
Log.ok('← 请求成功', provider.name + ' / ' + model + '(' + ms + 'ms)',
String(content || '').slice(0, 600));
resolve(data);
},
onerror: function (err) {
const m = '请求失败:可能是网络不通、CORS 或该域名未在 @connect 中声明。' +
(err && err.error ? '(' + err.error + ')' : '');
Log.error('← 网络错误', m);
reject(new Error(m));
},
ontimeout: function () {
const m = '请求超时(' + Math.round(CFG.timeout / 1000) + ' 秒)';
Log.error('← ' + m, provider.name + ' / ' + model);
reject(new Error(m));
},
onabort: function () {
Log.warn('← 请求被中止', '');
reject(new Error('请求被中止'));
}
});
});
}
/* ═══════════════════════════════════════════════════════════════════════
7.5 人设与提示词
───────────────────────────────────────────────────────────────────────
没有固定人设时,同一份问卷每次问出来的答案风格都不一样,前后页还可能
自相矛盾(前页说"每天用 3 小时",后页说"基本不用")。给模型一个稳定
的人设,答案才有连贯性,也不容易被问卷的一致性校验题抓到。
═══════════════════════════════════════════════════════════════════════ */
const PERSONAS = {
none: {
name: '不指定(默认)',
desc: '让模型自由发挥',
text: ''
},
student: {
name: '大学生',
desc: '20 岁上下,理工科,用 AI 写作业和查资料',
text: '你是一名 20 岁左右的理工科大学生,日常用 AI 辅助写代码、查资料、整理笔记。' +
'消费能力有限,对价格敏感,看重实用性但不苛求完美。'
},
office: {
name: '职场白领',
desc: '28 岁左右,互联网行业,用 AI 提升效率',
text: '你是一名 28 岁左右的互联网公司职员,日常用 AI 写文档、做汇报、处理数据。' +
'工作节奏快,最看重响应速度和准确性,对隐私有一定顾虑。'
},
designer: {
name: '设计师',
desc: '30 岁左右,创意行业,用 AI 做图和找灵感',
text: '你是一名 30 岁左右的设计师,用 AI 生成素材、找配色灵感、做方案初稿。' +
'审美要求高,认为 AI 目前还替代不了创意决策,但对效率提升认可。'
},
teacher: {
name: '教师',
desc: '35 岁左右,教育行业,谨慎使用 AI',
text: '你是一名 35 岁左右的中学教师,偶尔用 AI 出题、写教案、查教学资料。' +
'对 AI 的准确性要求很高,担心学生过度依赖,态度审慎但愿意尝试。'
},
dev: {
name: '程序员',
desc: '27 岁左右,后端开发,重度使用 AI',
text: '你是一名 27 岁左右的后端工程师,每天用 AI 写代码、查 bug、做代码审查。' +
'技术判断力强,能明确指出 AI 在复杂逻辑和最新 API 上的短板。'
},
casual: {
name: '普通用户',
desc: '40 岁左右,非技术背景,偶尔用 AI',
text: '你是一名 40 岁左右的普通用户,非技术背景,偶尔用 AI 查生活常识、写简单的文字。' +
'表达朴素口语化,不太懂技术细节,对 AI 的评价比较感性。'
}
};
const Persona = {
id: Store.get('persona', 'none'),
customPrompt: Store.get('customSystemPrompt', ''),
/** 组装最终 system 提示词 */
system() {
const parts = [];
const p = PERSONAS[this.id];
if (p && p.text) parts.push(p.text);
const custom = (this.customPrompt || '').trim();
if (custom) parts.push(custom);
return parts.join('\n\n');
},
/** 人设约束会追加到 user prompt 里,双重保险(弱模型常忽略 system) */
userHint() {
const p = PERSONAS[this.id];
if (!p || !p.text) return '';
return '【人设】' + p.text + ' 请始终以这个人设的口吻和立场作答。';
},
hasCustom() { return !!(this.customPrompt || '').trim(); }
};
/**
* 构建提示词。
*
* 这一版重点修掉两个实测出来的坑:
*
* 1. 上一版写了「若确实无法判断,填空写『暂无』」,结果 glm-4-flash 这类
* 弱模型把这条退路当默认答案——明明有 A/B/C/D/E 五个清晰选项,它照样
* 回「暂无」,然后脚本拿它去匹配选项,整页全废。所以退路必须堵死:
* 任何情况都要给出一个真实选项,不许交白卷。
*
* 2. 上一版写「可全不选」,模型走向另一个极端——六个选项全选。真人做多选
* 一般只挑 2~3 项,全选一眼假。所以明确给出期望数量,并在代码里兜底截断。
*
* 另外 value 必须完全取自 options 原文这条,用一个正例 + 一个反例说明,
* 比单纯用文字描述有效得多。
*/
function buildPrompt(batch, questions) {
const items = batch.map(function (q) {
const item = {
index: q.index,
type: q.answerType, // single | multi | text
title: q.title
};
/* 原来只在「不是文本题」时带 options,导致原生控件(日期/滑块/取色器)
的格式说明被丢掉——AI 收不到任何格式提示,只能瞎返回一句中文,
然后浏览器把非法值直接清空,控件看起来就是"填了但没生效"。
value 类虽然 answerType 是 text,但它的 options 装的是格式约定,
必须一起发出去。 */
if (q.options && q.options.length &&
(q.answerType !== 'text' || q.type === 'value')) {
item.options = q.options;
}
if (q.type === 'value') item.formatHint = true; // 提示这是格式约束而非选项
if (q.answerType === 'text') item.maxLength = CFG.textMaxChars;
return item;
});
const lines = [];
lines.push('你是问卷填写助手。下面是一份问卷的题目,请逐题作答。');
lines.push('');
const hint = Persona.userHint();
if (hint) { lines.push(hint); lines.push(''); }
lines.push('【最重要】');
lines.push('绝不允许交白卷。每一道题都必须给出具体答案,');
lines.push('严禁输出"暂无""无""不知道""不确定""N/A""以上都不是"这类占位内容——');
lines.push('选择题必须选一个真实选项,填空题必须写出具体内容。');
lines.push('');
lines.push('【输出格式】');
lines.push('只输出一个 JSON 对象,不要解释、不要 markdown 代码围栏、不要多余文字。');
lines.push('{"answers":[{"index":<题号>,"value":<答案>}]}');
lines.push('');
lines.push('【各题型要求】');
lines.push('1. type="single" 单选:value 是字符串,必须完全等于 options 中的某一项原文。');
lines.push('2. type="multi" 多选:value 是字符串数组,每项必须完全取自 options 原文。');
lines.push(' 只选你真正认同的 ' + CFG.multiMin + '~' + CFG.multiMax + ' 项,不要全选,也不要不选。');
lines.push('3. type="text" 填空:value 是字符串,' + CFG.textMaxChars + ' 字以内,');
lines.push(' 内容具体、口语化,像真人随手写的一句话,不要写成报告腔。');
lines.push('4. 所有题目都要出现在 answers 里,index 必须与题目一致,不得遗漏或改动。');
lines.push('');
lines.push('【正确示例】');
lines.push('题目:{"index":0,"type":"single","title":"您最常用的 AI 工具是?","options":["ChatGPT","Claude","DeepSeek"]}');
lines.push('正确:{"index":0,"value":"DeepSeek"}');
lines.push('错误:{"index":0,"value":"DeepSeek 比较好用"} ← 不要自己加话,必须原文');
lines.push('错误:{"index":0,"value":"暂无"} ← 严禁占位');
lines.push('');
lines.push('【原生控件题】');
lines.push('若某题的 options 只有一项且以「(」开头,那是格式约定,不是可选项。');
lines.push('例如 options 为「(请返回 YYYY-MM-DD 格式的日期)」时,');
lines.push('应返回 {"index":0,"value":"2024-06-01"}——严格按约定格式,不要写别的。');
lines.push('');
lines.push('【题目】');
lines.push(JSON.stringify(items, null, 2));
return lines.join('\n');
}
/* ── 答案校验:模型返回的东西不能直接信,要过一遍 ── */
/** 占位/无效答案:模型偷懒时会返回这些,必须挡掉 */
const PLACEHOLDER_RE = /^\s*(暂无|无|不知道|不确定|不清楚|没有|同上|略|n\/?a|none|null|undefined|未知|其他)?\s*$/i;
function isPlaceholder(v) {
if (v === undefined || v === null) return true;
if (Array.isArray(v)) return v.length === 0 || v.every(isPlaceholder);
const s = String(v).trim();
if (!s) return true;
return PLACEHOLDER_RE.test(s);
}
/**
* 答案后处理。做三件事:
* 1. 互斥校验:AI 给的选项不在 options 里 → 记为失败,绝不乱点
* 2. 占位符拦截:「暂无」之类挡掉,填空题尤其不能写入
* 3. 多选题数量截断:全选一眼假,截到配置上限
*/
function sanitizeAnswers(questions, answers) {
const report = { dropped: [], clipped: [], placeholder: [] };
questions.forEach(function (q, i) {
const a = answers[i];
if (a === undefined || a === null) return;
/* 这几类不参与"选项匹配"校验:
· file —— 本就没有文本答案,拦截只是制造噪音
· value —— options 里放的是格式说明(如"请返回 YYYY-MM-DD"),
不是可选项。答案当然和它对不上,照旧匹配会把
AI 给的正确日期直接判无效清空,控件就填不进去。
· contenteditable —— 同 text,走占位符检查即可 */
if (q.type === 'file' || q.type === 'value') return;
const isText = (q.type === 'text' || q.type === 'textarea' ||
q.type === 'select' || q.type === 'contenteditable');
// 填空题:占位符直接判无效
if (isText) {
if (isPlaceholder(a)) {
report.placeholder.push({ index: i, title: q.title, value: String(a) });
answers[i] = undefined;
}
return;
}
// 选择题
const opts = q.options || [];
if (!opts.length) return;
if (Array.isArray(a)) {
// 先过滤掉不在选项里的
const valid = a.filter(function (v) {
return opts.some(function (o) { return optionMatches(o, v); });
});
if (valid.length !== a.length) {
report.dropped.push({
index: i, title: q.title,
bad: a.filter(function (v) {
return !opts.some(function (o) { return optionMatches(o, v); });
})
});
}
// 再截断数量(全选或过多都不可信)
if (valid.length > CFG.multiMax) {
report.clipped.push({ index: i, title: q.title, from: valid.length });
valid.length = CFG.multiMax;
}
answers[i] = valid.length ? valid : undefined;
} else {
// 单选:不匹配就判无效,绝不退化成"点第一个"
if (isPlaceholder(a) ||
!opts.some(function (o) { return optionMatches(o, a); })) {
report.dropped.push({ index: i, title: q.title, bad: [a] });
answers[i] = undefined;
}
}
});
return report;
}
function normalizeAnswers(raw, batch) {
let arr = null;
if (Array.isArray(raw)) arr = raw;
else if (raw && Array.isArray(raw.answers)) arr = raw.answers;
if (!arr) return null;
const map = new Map();
arr.forEach(function (item, i) {
let idx = i;
let val = item;
if (item && typeof item === 'object') {
if (item.index !== undefined) idx = Number(item.index);
val = (item.value !== undefined ? item.value
: (item.answer !== undefined ? item.answer
: (item.choice !== undefined ? item.choice : item.text)));
}
if (map.has(idx)) return;
map.set(idx, val);
});
return batch.map(function (q) {
return map.has(q.index) ? map.get(q.index) : undefined;
});
}
async function answerAll(questions, onProgress) {
const provider = currentProvider();
const model = currentModel();
const key = (state.apiKey || '').trim();
const results = new Array(questions.length).fill(undefined);
const batches = [];
for (let i = 0; i < questions.length; i += CFG.batchSize) {
batches.push(questions.slice(i, i + CFG.batchSize));
}
Log.info('开始作答', provider.name + ' / ' + model + ',共 ' + questions.length +
' 题,分 ' + batches.length + ' 批,每批上限 ' + CFG.batchSize + ' 题');
// 逐题记录提取到的题目与选项,方便核对"AI 到底看到了什么"
questions.forEach(function (q, i) {
Log.add('info', '第 ' + (i + 1) + ' 题 [' + q.type + '/' + q.answerType + ']',
String(q.title).slice(0, 80),
q.options && q.options.length ? q.options : null);
});
for (let b = 0; b < batches.length; b++) {
const batch = batches[b];
if (onProgress) {
onProgress('正在请求 AI(第 ' + (b + 1) + '/' + batches.length + ' 批,共 ' +
questions.length + ' 题)…');
}
const raw = await requestAI(provider, model, key, buildPrompt(batch, questions));
const norm = normalizeAnswers(raw, batch);
if (!norm) {
Log.error('第 ' + (b + 1) + ' 批结果无法解析', '', raw);
throw new Error('第 ' + (b + 1) + ' 批结果无法解析');
}
// 记录 AI 给的答案
Log.add('ai', 'AI 返回第 ' + (b + 1) + ' 批答案(' + batch.length + ' 题)',
batch.map(function (q, i) {
const v = norm[i];
return ' · 第 ' + (q.index + 1) + '题 → ' +
(v === undefined ? '(无)'
: String(Array.isArray(v) ? v.join('、') : v).slice(0, 50));
}).join('\n'));
norm.forEach(function (v, i) {
results[batch[0].index + i] = v;
});
}
Log.ok('全部批次完成', '共 ' + batches.length + ' 批');
return results;
}
/* ═══════════════════════════════════════════════════════════════════════
8. 拟人化输入
───────────────────────────────────────────────────────────────────────
一次性 setValue 把整段文字塞进去,在很多页面上会出问题:
· React/Vue 受控组件只认逐次的 input 事件序列,一次性赋值可能丢失
· 有字数统计、实时校验、防抖保存的输入框,需要看到中间状态
· 页面可能监听了 keydown/keyup,一次性赋值完全没有这些事件
逐字输入更慢,但更不容易出错,也更接近真人的操作痕迹。
═══════════════════════════════════════════════════════════════════════ */
/** 只派发 input 的赋值——逐字输入时每帧都派发 change 太吵了 */
function setValueTyped(el, value) {
let setter = null;
try {
const proto = el.tagName === 'TEXTAREA'
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
setter = desc && desc.set;
} catch (e) { /* ignore */ }
if (setter) setter.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* 打字速度档位。
* 这里只定义「倍率与各档的曲线参数」,实际每字耗时会根据当前题的字数
* 动态计算——见 Human.perChar()。
*/
const TYPE_SPEEDS = {
off: { mult: 0, label: '关闭(瞬间填入)' },
fast: { mult: 0.45, label: '快速' },
normal: { mult: 1, label: '正常' },
slow: { mult: 1.9, label: '慢速(更像真人)' }
};
const Human = {
speed: CFG.typeSpeed || 'normal',
token: 0, // 中止令牌:递增即让进行中的打字循环退出
get mult() {
return (TYPE_SPEEDS[this.speed] || TYPE_SPEEDS.normal).mult;
},
/** 关闭档位:不做任何拟人延迟 */
get enabled() { return this.mult > 0; },
rand(min, max) { return min + Math.random() * (max - min); },
/**
* 按文本长度算每字耗时。
* 真人打长句时会有"手速惯性"——越打越快,所以字数越多单字耗时越短;
* 但不能无限快,用 typeCharMinMs 兜底,否则长答案会快到失真。
* @param {number} len 待输入文本长度
* @returns {number} 单字毫秒
*/
perChar(len) {
const n = Math.max(1, len || 1);
// 基础耗时随长度递减
let ms = CFG.typeBaseMs - (n - 1) * CFG.typePerCharMs;
ms = Math.max(CFG.typeCharMinMs, Math.min(CFG.typeCharMaxMs, ms));
ms *= this.mult;
// 抖动:让节奏不规则,机械的匀速最假
const j = CFG.typeJitter;
ms *= this.rand(1 - j, 1 + j);
return Math.max(4, ms);
},
/**
* 题间思考耗时。文本越长,读题和理解的时间越久。
*/
thinkTime(len) {
const n = Math.max(1, len || 1);
let ms = CFG.typeThinkMs + n * CFG.typeThinkPerChar;
ms *= this.mult;
ms *= this.rand(0.7, 1.35);
return Math.max(0, ms);
},
/** 中止所有进行中的输入 */
abort() { this.token++; },
/** 该令牌是否已失效(即任务已被中止) */
stale(t) { return t !== this.token; },
sleep(ms) {
return new Promise(function (r) { setTimeout(r, ms); });
},
rand(min, max) { return min + Math.random() * (max - min); },
/** 新起一轮填写,返回本次的令牌 */
begin() { return this.token; },
/** 模拟鼠标移入并聚焦 */
async focusEl(el, token) {
if (this.stale(token)) return false;
try {
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
} catch (e) { /* ignore */ }
try { el.focus(); } catch (e) { /* ignore */ }
try {
el.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
} catch (e) { /* ignore */ }
if (this.enabled) {
await this.sleep(this.rand(60, 180));
}
return !this.stale(token);
},
/**
* 逐字输入。关掉速度时退化为一次性 setValue(更快,但少了中间态)。
* @returns {Promise<boolean>} 是否完整输入完毕(false = 被中止)
*/
async typeInto(el, text, token) {
if (this.stale(token)) return false;
const str = String(text === undefined || text === null ? '' : text);
if (!str) return true;
// 关闭档:一次性赋值,不做拟人
if (!this.enabled) {
setValue(el, str);
return true;
}
// Array.from 而非 split(''):正确处理 emoji 等代理对
const chars = Array.from(str);
const len = chars.length;
// 已有内容则先清掉(真人一般是全选后重输)
if (el.value) {
setValueTyped(el, '');
await this.sleep(this.rand(60, 160));
}
let typed = '';
for (let i = 0; i < len; i++) {
if (this.stale(token)) return false;
typed += chars[i];
setValueTyped(el, typed);
let d = this.perChar(len);
// 偶尔卡壳,模拟思考或打错字
if (Math.random() < CFG.typePauseChance) d += this.rand(150, 420);
// 标点后稍作停顿
if (/[,。!?、;:,.!?;:]/.test(chars[i])) d += this.rand(60, 180);
await this.sleep(d);
}
// 最后补一次 change,很多校验逻辑挂在这个事件上
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
},
/**
* 富文本(contenteditable)逐字输入。
* 与 input 的区别:没有 value 属性,得改 innerText;
* React/Vue 的富文本编辑器通常监听 input 事件并读 innerHTML/textContent。
*/
async typeIntoEditable(el, text, token) {
if (this.stale(token)) return false;
const str = String(text === undefined || text === null ? '' : text);
if (!this.enabled) {
el.innerText = str;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
try { el.focus(); } catch (e) { /* ignore */ }
const chars = Array.from(str);
const len = chars.length;
let typed = '';
for (let i = 0; i < len; i++) {
if (this.stale(token)) return false;
typed += chars[i];
el.innerText = typed;
el.dispatchEvent(new Event('input', { bubbles: true }));
let d = this.perChar(len);
if (/[,。!?、;:,.!?;:]/.test(chars[i])) d += this.rand(60, 180);
await this.sleep(d);
}
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
},
/** 点击选择(单选/多选),带随机前后延迟 */
async clickEl(el, token) {
if (this.stale(token)) return false;
if (this.enabled) await this.sleep(this.rand(50, 150));
try {
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
} catch (e) { /* ignore */ }
let ok = false;
try { el.click(); } catch (e) { /* ignore */ }
if (!el.checked) {
try {
el.checked = true;
el.dispatchEvent(new Event('change', { bubbles: true }));
} catch (e) { /* ignore */ }
}
ok = !!el.checked;
if (this.enabled) await this.sleep(this.rand(40, 130));
return ok && !this.stale(token);
},
/** 选择下拉项 */
async selectOption(sel, value, token) {
if (this.stale(token)) return false;
try { sel.focus(); } catch (e) { /* ignore */ }
setValue(sel, value);
if (this.enabled) await this.sleep(this.rand(80, 200));
return !this.stale(token);
},
/**
* 两道题之间的思考间隔。
* 传入下一题的答案长度,答案越长说明题越复杂,停顿也该更久。
*/
async think(token, nextLen) {
if (this.stale(token)) return false;
if (!this.enabled) return true;
await this.sleep(this.thinkTime(nextLen || 0));
return !this.stale(token);
}
};
/* ═══════════════════════════════════════════════════════════════════════
8. 填写(按 DOM 控件类型分支;跳过蜜罐/隐藏字段)
═══════════════════════════════════════════════════════════════════════ */
function isHiddenField(el) {
if (el.type === 'hidden') return true;
return !isEffectivelyVisible(el);
}
function selectOne(input) {
if (isHiddenField(input)) return false;
try { input.click(); } catch (e) { /* ignore */ }
if (input.checked) {
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new Event('click', { bubbles: true }));
return true;
}
try {
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
return input.checked;
} catch (e) { return false; }
}
/**
* 填写。改为异步逐题、逐字进行,不再一次性全部写入。
* opts.onProgress(done, total, title) 用于面板显示进度。
*/
async function fillAnswers(questions, answers, opts) {
opts = opts || {};
const report = { filled: 0, failed: [], sanitized: null };
const token = opts.token !== undefined ? opts.token : Human.begin();
const total = questions.length;
// 先过一遍校验:挡掉占位符、剔除不在选项里的答案、截断多选全选
const safeAnswers = answers.slice();
const san = sanitizeAnswers(questions, safeAnswers);
report.sanitized = san;
if (san.placeholder.length) {
Log.warn('拦截占位答案 ×' + san.placeholder.length,
san.placeholder.map(function (p) {
return '第 ' + (p.index + 1) + '题「' + String(p.value).slice(0, 12) + '」';
}).join('、'));
}
if (san.dropped.length) {
Log.warn('剔除无效选项 ×' + san.dropped.length,
san.dropped.map(function (d) {
return '第 ' + (d.index + 1) + '题(AI 给了选项外的答案)';
}).join('、'));
}
if (san.clipped.length) {
Log.info('多选题数量截断 ×' + san.clipped.length,
'限制在 ' + CFG.multiMax + ' 项以内,避免全选');
}
for (let i = 0; i < questions.length; i++) {
if (Human.stale(token)) {
Log.warn('填写已中止', '在第 ' + (i + 1) + ' 题前收到中止信号');
report.aborted = true;
break;
}
const q = questions[i];
const answer = safeAnswers[i];
if (opts.onProgress) {
try { opts.onProgress(i, total, q.title); } catch (e) { /* ignore */ }
}
if (answer === undefined || answer === null) {
report.failed.push({ index: i, title: q.title, reason: 'AI 未返回答案' });
Log.warn('✗ 第 ' + (i + 1) + ' 题无答案', String(q.title).slice(0, 50));
continue;
}
const usable = q.inputs.filter(function (inp) { return !isHiddenField(inp); });
if (!usable.length) {
report.failed.push({ index: i, title: q.title, reason: '字段为隐藏/蜜罐,已跳过' });
Log.info('⊘ 第 ' + (i + 1) + ' 题跳过', '字段为隐藏/蜜罐');
continue;
}
let ok = false;
if (q.type === 'radio' || q.type === 'checkbox') {
const wanted = Array.isArray(answer) ? answer : [answer];
const matched = [];
usable.forEach(function (inp) {
const lbl = optionLabel(inp);
if (wanted.some(function (w) { return optionMatches(lbl, w); })) matched.push(inp);
});
if (!matched.length) {
report.failed.push({
index: i,
title: q.title,
reason: '无选项匹配答案「' + String(answer).slice(0, 40) + '」'
});
Log.warn('✗ 第 ' + (i + 1) + ' 题无匹配选项',
String(q.title).slice(0, 40) + ' → ' + String(answer).slice(0, 40));
continue;
}
// 多选题每个选项之间也留出间隔,不要一瞬间全勾上
if (q.type === 'radio') {
ok = await Human.clickEl(matched[0], token);
} else {
let n = 0;
for (let k = 0; k < matched.length; k++) {
if (Human.stale(token)) break;
if (await Human.clickEl(matched[k], token)) n++;
}
ok = n > 0;
}
} else if (q.type === 'file') {
/* 文件上传:浏览器禁止脚本给 input[type=file] 赋值(安全策略),
任何声称能自动上传的方案要么是伪协议要么需要扩展权限。
如实告知,不要假装成功。 */
report.failed.push({
index: i, title: q.title,
reason: '文件上传需你手动选择,脚本无法代为操作'
});
Log.warn('⚠ 第 ' + (i + 1) + ' 题需手动上传',
String(q.title).slice(0, 40) + ' — 浏览器安全策略禁止脚本设置文件');
continue;
} else if (q.type === 'value') {
/* 原生 UI 控件:直接赋值。
逐字输入对日期选择器/滑块没有意义——它们根本不接受字符输入,
硬逐字只会让浏览器忽略或触发校验错误。 */
const el = usable[0];
const raw = Array.isArray(answer) ? answer[0] : answer;
const v = String(raw === undefined || raw === null ? '' : raw).trim();
if (!v) {
report.failed.push({ index: i, title: q.title, reason: '控件无有效值' });
continue;
}
const kind = fieldKind(el);
const before = el.value;
if (kind === 'range') {
const num = parseFloat(v.replace(/[^0-9.\-]/g, ''));
if (isNaN(num)) {
report.failed.push({ index: i, title: q.title, reason: '滑块需要数字,收到「' + v.slice(0, 20) + '」' });
Log.warn('✗ 第 ' + (i + 1) + ' 题滑块值无效', v.slice(0, 30));
continue;
}
const lo = el.min !== '' ? parseFloat(el.min) : 0;
const hi = el.max !== '' ? parseFloat(el.max) : 100;
el.value = String(Math.max(lo, Math.min(hi, num)));
} else if (kind === 'color') {
const c = v.trim();
el.value = /^#[0-9a-fA-F]{6}$/.test(c) ? c
: (/^#[0-9a-fA-F]{3}$/.test(c) ? c : '#4F46E5');
} else {
el.value = v;
}
// 原生控件同样要派发事件,否则框架拿不到变更
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
ok = (el.value !== before) || el.value === v;
if (!ok) {
report.failed.push({ index: i, title: q.title, reason: '控件赋值未生效' });
Log.warn('✗ 第 ' + (i + 1) + ' 题控件赋值失败', String(q.title).slice(0, 40));
continue;
}
} else if (q.type === 'contenteditable') {
/* 富文本:没有 value,改 innerText + 逐字输入 */
const el = usable[0];
const text = Array.isArray(answer) ? answer.join(';') : String(answer);
await Human.focusEl(el, token);
ok = await Human.typeIntoEditable(el, text, token);
if (!ok) {
report.failed.push({ index: i, title: q.title, reason: '富文本写入失败' });
continue;
}
} else if (q.type === 'select') {
const sel = usable[0];
const want = Array.isArray(answer) ? answer[0] : answer;
let target = null;
const optList = Array.prototype.slice.call(sel.options || []);
for (let k = 0; k < optList.length; k++) {
if (optionMatches(optList[k].text, want) || norm(optList[k].value) === norm(want)) {
target = optList[k];
break;
}
}
if (target) ok = await Human.selectOption(sel, target.value, token);
else {
report.failed.push({ index: i, title: q.title, reason: '下拉无匹配选项' });
Log.warn('✗ 第 ' + (i + 1) + ' 题下拉无匹配', String(q.title).slice(0, 40));
continue;
}
} else {
const el = usable[0];
const text = Array.isArray(answer) ? answer.join(';') : String(answer);
const maxLen = el.maxLength && el.maxLength > 0 ? el.maxLength : 0;
const final = maxLen ? text.slice(0, maxLen) : text;
// 先聚焦,再逐字输入
await Human.focusEl(el, token);
ok = await Human.typeInto(el, final, token);
if (ok && el.value !== final) {
// 逐字输入可能被 maxLength 或框架拦截,兜底一次
setValue(el, final);
}
}
if (Human.stale(token)) { report.aborted = true; break; }
if (ok) {
report.filled++;
Log.add('fill', '✓ 第 ' + (i + 1) + ' 题已填写',
String(q.title).slice(0, 50) + ' → ' +
String(Array.isArray(answer) ? answer.join('、') : answer).slice(0, 60));
} else {
report.failed.push({ index: i, title: q.title, reason: '写入失败(可能是受控组件)' });
Log.warn('✗ 第 ' + (i + 1) + ' 题写入失败', String(q.title).slice(0, 50));
}
// 题间思考间隔(最后一题不用等);按下一题答案长度估算读题时间
if (i < questions.length - 1) {
const nxt = safeAnswers[i + 1];
const nlen = Array.isArray(nxt) ? nxt.join('').length
: (nxt === undefined || nxt === null ? 0 : String(nxt).length);
const alive = await Human.think(token, nlen);
if (!alive) { report.aborted = true; break; }
}
}
if (opts.onProgress) {
try { opts.onProgress(total, total, ''); } catch (e) { /* ignore */ }
}
// 汇总:失败的题必须列出来,否则用户只知道"失败了 3 题"却不知道是哪 3 题
const s = report.sanitized || {};
const guarded = (s.placeholder || []).length + (s.dropped || []).length;
if (guarded) {
Log.info('校验拦截了 ' + guarded + ' 条无效答案',
'这些题未填写。通常是模型返回了占位内容或选项外的答案,可在「AI」页更换模型或调低随机性。');
}
Log.add(report.failed.length ? 'warn' : 'ok',
'填写完成:成功 ' + report.filled + ' / ' + total,
report.failed.length
? '失败题目:\n' + report.failed.map(function (f) {
return ' · 第 ' + (f.index + 1) + '题 「' +
String(f.title).slice(0, 30) + '」— ' + f.reason;
}).join('\n')
: '全部题目均已成功写入');
return report;
}
function findSubmitButton() {
const sels = [
'button[type="submit"]',
'input[type="submit"]',
'form button:not([type="button"]):not([type="reset"])',
'[class*="submit"]:not([class*="success"]):not([class*="result"])',
'[id*="submit"]'
];
for (let i = 0; i < sels.length; i++) {
const nodes = document.querySelectorAll(sels[i]);
for (let j = 0; j < nodes.length; j++) {
const el = nodes[j];
if (!isVisible(el)) continue;
const t = (nodeText(el) || el.value || '').replace(/\s+/g, '');
if (/提交|提交问卷|submit|完成|发送/i.test(t)) return el;
}
}
return null;
}
/* ═══════════════════════════════════════════════════════════════════════
9. UI(Shadow DOM 隔离,避免 ID/样式冲突,全部用 textContent 防 XSS)
═══════════════════════════════════════════════════════════════════════ */
const ui = {};
const CSS = [
':host{all:initial}',
'.gt-wrap{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;',
'font-size:13px;color:#1f2933;line-height:1.5}',
/* ── 悬浮球 ── */
'.gt-fab{position:fixed;width:46px;height:46px;border-radius:50%;background:#2563eb;color:#fff;',
'font-size:21px;display:flex;align-items:center;justify-content:center;cursor:grab;',
'box-shadow:0 4px 14px rgba(0,0,0,.28);user-select:none;-webkit-user-select:none;z-index:2147483000;',
'touch-action:none;transition:background .15s}',
'.gt-fab:hover{background:#1d4ed8}',
'.gt-fab.dragging{cursor:grabbing;box-shadow:0 8px 22px rgba(0,0,0,.36);transform:scale(1.06)}',
'.gt-fab.risk-high{background:#dc2626}',
'.gt-fab.risk-high:hover{background:#b91c1c}',
'.gt-fab.risk-medium{background:#d97706}',
'.gt-fab.risk-medium:hover{background:#b45309}',
'.gt-badge{position:absolute;top:-3px;right:-3px;min-width:18px;height:18px;padding:0 5px;',
'border-radius:9px;background:#dc2626;color:#fff;font-size:11px;line-height:18px;',
'text-align:center;font-weight:600;border:2px solid #fff}',
'.gt-awin{position:fixed;width:340px;height:320px;background:#fff;border-radius:12px;',
'box-shadow:0 10px 34px rgba(0,0,0,.26);display:none;flex-direction:column;',
'overflow:hidden;z-index:2147483002;border:1px solid #e4e7eb}',
'.gt-awin .aw-hd{display:flex;align-items:center;gap:8px;padding:9px 11px;background:#f0fdf4;',
'border-bottom:1px solid #bbf7d0;cursor:grab;user-select:none;-webkit-user-select:none;flex-shrink:0;',
'touch-action:none}',
'.gt-awin .aw-hd .t{font-weight:600;font-size:13px;color:#166534;flex:1;',
'white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.gt-awin .aw-hd .m{width:22px;height:22px;border:none;background:transparent;cursor:pointer;',
'border-radius:4px;color:#166534;font-size:13px;line-height:1;flex-shrink:0}',
'.gt-awin .aw-hd .m:hover{background:#dcfce7}',
'.gt-awin .aw-bd{flex:1;min-height:0;overflow-y:auto;padding:10px 11px;font-size:12.5px}',
'.gt-awin .aw-warn{padding:7px 9px;margin-bottom:9px;border-radius:6px;font-size:11.5px;',
'background:#fef2f2;color:#991b1b;border:1px solid #fecaca;line-height:1.5}',
'.gt-awin .aw-item{display:flex;gap:8px;margin-bottom:8px;padding:8px 9px;background:#f8fafc;',
'border-radius:7px;border-left:3px solid #2563eb}',
'.gt-awin .aw-item .ix{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#2563eb;',
'color:#fff;font-size:11px;line-height:18px;text-align:center;font-weight:600}',
'.gt-awin .aw-item .bd{flex:1;min-width:0}',
'.gt-awin .aw-item .qt{font-weight:600;color:#1f2933;margin-bottom:3px;word-break:break-word;line-height:1.45}',
'.gt-awin .aw-item .av{color:#065f46;word-break:break-word;line-height:1.5;white-space:pre-wrap}',
'.gt-awin .aw-item .av.none{color:#dc2626}',
'.gt-awin .aw-item .cp{flex-shrink:0;align-self:flex-start;border:none;background:transparent;',
'cursor:pointer;color:#2563eb;font-size:11px;padding:1px 5px;border-radius:4px}',
'.gt-awin .aw-item .cp:hover{background:#eff6ff}',
'.gt-awin .aw-ft{display:flex;gap:8px;padding:9px 11px;border-top:1px solid #e4e7eb;flex-shrink:0}',
'.gt-awin .aw-tip{padding:5px 11px;font-size:10.5px;color:#9aa5b1;background:#f8fafc;',
'border-top:1px solid #eef1f4;flex-shrink:0}',
/* ── 面板 ──
82vh 在笔记本上几乎顶满屏,挡住题目本身。收到 62vh,
内容超长由 .gt-panes 内部滚动,不撑高面板。 */
'.gt-panel{position:fixed;width:320px;background:#fff;border-radius:12px;',
'box-shadow:0 10px 34px rgba(0,0,0,.26);display:none;flex-direction:column;',
'overflow:hidden;z-index:2147483001;max-height:62vh}',
'.gt-panel.wide{width:400px}',
/* ── 页脚版权 ── */
'.gt-foot{padding:7px 12px;background:#f8fafc;border-top:1px solid #e4e7eb;',
'font-size:10.5px;color:#9aa5b1;text-align:center;flex-shrink:0;line-height:1.5;',
'user-select:none;-webkit-user-select:none}',
'.gt-foot a{color:#2563eb;text-decoration:none}',
'.gt-foot a:hover{text-decoration:underline}',
/* ── 特殊内容提醒条 ── */
'.gt-media{display:flex;align-items:flex-start;gap:7px;padding:8px 10px;border-radius:7px;',
'background:#fffbeb;border:1px solid #fde68a;color:#92400e;font-size:11.5px;',
'margin-bottom:10px;line-height:1.5}',
'.gt-media .ic{flex-shrink:0;font-size:13px}',
/* ── 占位:下面原来那两条 .gt-panel 规则由本段覆盖,需移除 ── */
'.gt-head{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#f8fafc;',
'border-bottom:1px solid #e4e7eb;cursor:grab;user-select:none;-webkit-user-select:none;flex-shrink:0;',
'touch-action:none}',
'.gt-head.dragging{cursor:grabbing}',
'.gt-head .ttl{font-weight:600;font-size:13.5px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.gt-head .mini{width:24px;height:24px;border:none;background:transparent;cursor:pointer;',
'border-radius:5px;color:#52606d;font-size:14px;line-height:1;flex-shrink:0}',
'.gt-head .mini:hover{background:#e4e7eb}',
/* ── Tab ── */
'.gt-tabs{display:flex;gap:2px;padding:6px 6px 0;background:#f8fafc;',
'border-bottom:1px solid #e4e7eb;overflow-x:auto;flex-shrink:0}',
'.gt-tab{flex:1;min-width:52px;padding:6px 4px;border:none;background:transparent;cursor:pointer;',
'font-size:12px;color:#52606d;border-radius:6px 6px 0 0;white-space:nowrap;',
'border-bottom:2px solid transparent;margin-bottom:-1px}',
'.gt-tab:hover{background:#eef2f7;color:#1f2933}',
'.gt-tab.active{color:#2563eb;font-weight:600;border-bottom-color:#2563eb;background:#fff}',
'.gt-tab .dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#dc2626;',
'margin-left:3px;vertical-align:middle}',
'.gt-panes{overflow-y:auto;padding:12px;flex:1;min-height:0}',
'.gt-pane{display:none}.gt-pane.active{display:block}',
/* ── 表单 ── */
'.gt-field{margin-bottom:11px}',
'.gt-field>label{display:block;font-size:11.5px;color:#52606d;margin-bottom:3px;font-weight:500}',
'.gt-field input[type=text],.gt-field input[type=password],.gt-field select{width:100%;',
'box-sizing:border-box;padding:6px 8px;border:1px solid #cbd2d9;border-radius:6px;',
'font-size:13px;background:#fff;font-family:inherit}',
'.gt-field input:focus,.gt-field select:focus{outline:none;border-color:#2563eb}',
'.gt-check{display:flex;align-items:flex-start;gap:6px;font-size:12px;color:#3e4c59;',
'cursor:pointer;padding:3px 0;line-height:1.4}',
'.gt-check input{margin-top:2px;flex-shrink:0}',
'.gt-group{margin-bottom:14px}',
'.gt-group>.gt-gt{font-size:11px;font-weight:600;color:#7b8794;text-transform:uppercase;',
'letter-spacing:.4px;margin-bottom:6px;padding-bottom:4px;border-bottom:1px solid #eef1f4}',
'.gt-hint{font-size:11px;color:#7b8794;margin-top:4px;word-break:break-word;line-height:1.45}',
'.gt-row2{display:flex;gap:8px}.gt-row2>*{flex:1}',
/* ── 按钮 ── */
'.gt-btn{width:100%;padding:8px;border:none;border-radius:7px;background:#2563eb;color:#fff;',
'font-size:13px;cursor:pointer;font-family:inherit;margin-top:6px}',
'.gt-btn:hover{filter:brightness(.94)}',
'.gt-btn:disabled{opacity:.5;cursor:not-allowed}',
'.gt-btn.ok{background:#16a34a}',
'.gt-btn.warn{background:#d97706}',
'.gt-btn.danger{background:#dc2626}',
'.gt-btn.ghost{background:#e4e7eb;color:#3e4c59}',
'.gt-btn.sm{padding:6px;font-size:12px}',
'.gt-btnrow{display:flex;gap:8px}.gt-btnrow .gt-btn{margin-top:6px}',
/* ── 风险摘要 ── */
'.gt-sum{display:flex;align-items:center;gap:10px;padding:11px 12px;border-radius:9px;margin-bottom:11px}',
'.gt-sum.low{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0}',
'.gt-sum.medium{background:#fffbeb;color:#92400e;border:1px solid #fde68a}',
'.gt-sum.high{background:#fef2f2;color:#991b1b;border:1px solid #fecaca}',
'.gt-sum .ico{font-size:22px;flex-shrink:0}',
'.gt-sum .txt{flex:1;min-width:0}',
'.gt-sum .lv{font-weight:600;font-size:13.5px}',
'.gt-sum .ds{font-size:11.5px;opacity:.9;line-height:1.4;margin-top:2px}',
/* ── 可折叠分组 ── */
'.gt-acc{border:1px solid #e4e7eb;border-radius:8px;margin-bottom:7px;overflow:hidden}',
'.gt-acc>.hd{display:flex;align-items:center;gap:7px;padding:7px 9px;cursor:pointer;',
'background:#f8fafc;font-size:12px;user-select:none;-webkit-user-select:none}',
'.gt-acc>.hd:hover{background:#eef2f7}',
'.gt-acc>.hd .arw{font-size:9px;color:#7b8794;transition:transform .15s;flex-shrink:0}',
'.gt-acc.open>.hd .arw{transform:rotate(90deg)}',
'.gt-acc>.hd .nm{flex:1;font-weight:600}',
'.gt-acc>.bd{display:none;padding:2px 0}',
'.gt-acc.open>.bd{display:block}',
'.gt-chip{display:inline-block;padding:1px 7px;border-radius:10px;font-size:10.5px;font-weight:600}',
'.gt-chip.high{background:#fee2e2;color:#991b1b}',
'.gt-chip.medium{background:#fef3c7;color:#92400e}',
'.gt-chip.low{background:#d1fae5;color:#065f46}',
/* ── 单条发现 ── */
'.gt-f{border-bottom:1px solid #f0f3f6;padding:7px 9px}',
'.gt-f:last-child{border-bottom:none}',
'.gt-f>.hd{display:flex;align-items:flex-start;gap:6px;cursor:pointer;user-select:none;-webkit-user-select:none}',
'.gt-f>.hd:hover .nm{color:#2563eb}',
'.gt-f .arw2{font-size:9px;color:#9aa5b1;margin-top:4px;transition:transform .15s;flex-shrink:0}',
'.gt-f.open .arw2{transform:rotate(90deg)}',
'.gt-f .nm{flex:1;font-size:12px;font-weight:500;line-height:1.45}',
'.gt-f>.bd{display:none;padding:5px 0 2px 15px}',
'.gt-f.open>.bd{display:block}',
'.gt-f .dt{font-size:11.5px;color:#52606d;line-height:1.5}',
'.gt-f .ev{margin-top:5px;padding:6px 8px;background:#f5f7fa;border-radius:5px;',
'font-size:11px;color:#52606d;white-space:pre-wrap;word-break:break-all;',
'font-family:ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.45}',
/* ── Toast ── */
'.gt-toast{position:fixed;right:18px;bottom:18px;max-width:340px;background:#1f2933;color:#fff;',
'padding:11px 13px;border-radius:8px;font-size:12.5px;box-shadow:0 6px 20px rgba(0,0,0,.3);',
'opacity:0;transform:translateY(8px);transition:all .22s ease;pointer-events:none;z-index:2147483002}',
'.gt-toast.show{opacity:1;transform:translateY(0);pointer-events:auto}',
'.gt-toast.high{background:#b91c1c}',
'.gt-toast.medium{background:#b45309}',
'.gt-toast .tt{font-weight:600;margin-bottom:3px}',
/* ── 弹窗 ── */
'.gt-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);display:flex;align-items:center;',
'justify-content:center;z-index:2147483003;padding:16px}',
'.gt-modal .box{background:#fff;border-radius:12px;width:100%;max-width:580px;max-height:82vh;',
'overflow:auto;padding:16px;display:flex;flex-direction:column}',
'.gt-modal h3{margin:0 0 10px;font-size:16px}',
'.gt-modal pre{background:#f5f7fa;border:1px solid #e4e7eb;border-radius:6px;padding:10px;',
'max-height:340px;overflow:auto;white-space:pre-wrap;word-break:break-all;font-size:12px}',
'.gt-modal .actions{display:flex;gap:8px;margin-top:12px}',
'.gt-modal .actions .gt-btn{margin-top:0}',
'.gt-list{margin:6px 0 0;padding-left:16px;font-size:12px}',
'.gt-empty{text-align:center;color:#9aa5b1;font-size:12px;padding:18px 8px}',
/* ── 执行日志 ── */
'.gt-logbox{max-height:280px;overflow-y:auto;border:1px solid #e4e7eb;border-radius:7px;',
'background:#fbfcfd;margin-top:6px}',
'.gt-log{display:flex;gap:6px;padding:5px 8px;border-bottom:1px solid #f0f3f6;',
'font-size:11px;line-height:1.45;align-items:flex-start}',
'.gt-log:last-child{border-bottom:none}',
'.gt-log .ts{color:#9aa5b1;flex-shrink:0;font-family:ui-monospace,Menlo,monospace;font-size:10px;',
'padding-top:1px}',
'.gt-log .ic{flex-shrink:0;width:13px;text-align:center;font-weight:700;color:#7b8794}',
'.gt-log .bd{flex:1;min-width:0}',
'.gt-log .ti{font-weight:600;color:#3e4c59;word-break:break-word}',
'.gt-log .de{color:#7b8794;white-space:pre-wrap;word-break:break-word;margin-top:2px}',
'.gt-log .da{margin-top:3px}',
'.gt-log .da summary{cursor:pointer;color:#2563eb;font-size:10.5px;user-select:none}',
'.gt-log .da pre{background:#f5f7fa;border:1px solid #e4e7eb;border-radius:4px;padding:5px;',
'margin:3px 0 0;max-height:150px;overflow:auto;white-space:pre-wrap;word-break:break-all;',
'font-size:10px;color:#52606d;font-family:ui-monospace,Menlo,monospace}',
".gt-log.ok .ic{color:#16a34a}.gt-log.ok .ti{color:#15803d}",
".gt-log.warn .ic{color:#d97706}.gt-log.warn .ti{color:#b45309}",
".gt-log.err .ic{color:#dc2626}.gt-log.err .ti{color:#b91c1c}",
".gt-log.ai .ic{color:#7c3aed}.gt-log.ai .ti{color:#6d28d9}",
".gt-log.fill .ic{color:#0891b2}.gt-log.fill .ti{color:#0e7490}",
".gt-log.answer .ic{color:#059669}.gt-log.answer .ti{color:#047857}",
".gt-log.answer .ms{color:#065f46;white-space:pre-wrap;font-weight:500}"
].join('\n');
function el(tag, cls, text) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined && text !== null) n.textContent = String(text);
return n;
}
/* ───────────────────────────────────────────────────────────────
拖拽:区分「点击」与「拖拽」,拖出视口时自动贴边
─────────────────────────────────────────────────────────────── */
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
/**
* 拖拽。三条关键设计:
* 1. pointermove / pointerup 挂在 document 上——不依赖 setPointerCapture,
* 指针快速甩出元素范围也不会丢失,拖拽手感稳定。
* 2. 启动时排除 button——面板头部的「最小化 / 关闭」按钮就长在把手里,
* 不排除的话点按钮也会启动拖拽,按钮就点不动了。
* 3. 只在真正开始移动后才 preventDefault——pointerdown 上直接拦截会
* 连带干掉后续合成的兼容性鼠标事件,按钮同样会失效。
* 点击与拖拽靠 4px 位移阈值区分,未超阈值则视为点击并回调 onClick。
*/
function makeDraggable(handle, target, storeKey, onClick) {
let sx = 0, sy = 0, ox = 0, oy = 0, moved = false, dragging = false;
let suppressClick = false; // 拖拽结束后抑制合成 click
// handle 与 target 相同时整个元素都能拖(悬浮球);
// 不同时只有落在把手区域内才启动(面板头部)。
const wholeHandle = (handle === target);
function hit(e) {
if (e.button !== undefined && e.button !== 0) return false;
// 把手上的按钮不参与拖拽(最小化/关闭)
if (e.target && e.target.closest && e.target.closest('button')) return false;
if (wholeHandle) return true;
/* 判定"是否落在把手上"必须看 handle 本身,不能写死某个类名。
之前写的是 .gt-head,而 AI 答案窗的把手叫 .aw-hd——
永远匹配不上,于是答案窗的拖拽从头到尾就没启动过。
用 contains 判断与具体类名解耦,新增任何可拖窗口都自动生效。 */
return !!(handle && handle.contains && e.target && handle.contains(e.target));
}
// touchstart 兜底:部分移动浏览器对 pointer 事件支持不完整,
// 显式 preventDefault 也能阻止触摸拖动时页面跟着滚动
target.addEventListener('touchstart', function (e) {
if (!hit(e)) return;
if (e.touches && e.touches.length > 1) { dragging = false; return; } // 多指手势不拦截
const t = e.touches[0];
const r = target.getBoundingClientRect();
target.style.left = r.left + 'px';
target.style.top = r.top + 'px';
target.style.right = 'auto';
target.style.bottom = 'auto';
sx = t.clientX; sy = t.clientY;
ox = r.left; oy = r.top;
moved = false; dragging = true;
handle.classList.add('dragging');
target.style.transition = 'none';
// 非 passive 才能阻止滚动
if (e.cancelable) e.preventDefault();
}, { passive: false });
target.addEventListener('touchmove', function (e) {
if (!dragging) return;
const t = e.touches && e.touches[0];
if (!t) return;
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (!moved && Math.abs(dx) + Math.abs(dy) > 4) moved = true;
if (!moved) return;
if (e.cancelable) e.preventDefault();
const r = target.getBoundingClientRect();
target.style.left = clamp(ox + dx, 4, Math.max(4, window.innerWidth - r.width - 4)) + 'px';
target.style.top = clamp(oy + dy, 4, Math.max(4, window.innerHeight - r.height - 4)) + 'px';
}, { passive: false });
target.addEventListener('touchend', function (e) {
if (!dragging) return;
dragging = false;
handle.classList.remove('dragging');
if (moved) {
const r = target.getBoundingClientRect();
Store.set(storeKey, { left: Math.round(r.left), top: Math.round(r.top) });
// 触摸拖拽后抑制随后的合成 click,避免误触发 onClick
suppressClick = true;
setTimeout(function () { suppressClick = false; }, 180);
} else if (onClick) {
onClick(e);
}
});
target.addEventListener('pointerdown', function (e) {
if (!hit(e)) return;
const r = target.getBoundingClientRect();
// 统一用 left/top 定位,先固化当前位置
target.style.left = r.left + 'px';
target.style.top = r.top + 'px';
target.style.right = 'auto';
target.style.bottom = 'auto';
sx = e.clientX; sy = e.clientY;
ox = r.left; oy = r.top;
moved = false; dragging = true;
handle.classList.add('dragging');
target.style.transition = 'none';
// 指针捕获:即使指针甩出元素范围,事件也会路由回 handle
try { handle.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
});
function onMove(e) {
if (!dragging) return;
const dx = e.clientX - sx;
const dy = e.clientY - sy;
if (!moved && Math.abs(dx) + Math.abs(dy) > 4) moved = true;
if (!moved) return;
e.preventDefault(); // 拖动中才阻止,避免选中文字
const r = target.getBoundingClientRect();
const nx = clamp(ox + dx, 4, Math.max(4, window.innerWidth - r.width - 4));
const ny = clamp(oy + dy, 4, Math.max(4, window.innerHeight - r.height - 4));
target.style.left = nx + 'px';
target.style.top = ny + 'px';
}
function finish(e) {
if (!dragging) return;
dragging = false; // 先置位,重复到达的同一事件会被忽略
handle.classList.remove('dragging');
try { handle.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
if (moved) {
const r = target.getBoundingClientRect();
Store.set(storeKey, { left: Math.round(r.left), top: Math.round(r.top) });
suppressClick = true;
setTimeout(function () { suppressClick = false; }, 180);
} else if (onClick && !suppressClick) {
onClick(e);
}
}
// 拖拽刚结束时,浏览器合成的 click 不应触发 onClick(否则拖一下就开面板)
target.addEventListener('click', function (e) {
if (suppressClick) { e.stopPropagation(); e.preventDefault(); }
}, true);
// move/up 同时挂 document 与 handle 本身:
// document 覆盖指针甩出元素的情况(原生事件 composed:true,能穿透 Shadow DOM);
// handle 兜住指针捕获生效或事件不冒泡的环境。dragging 标志保证只处理一次。
[document, target].forEach(function (node) {
node.addEventListener('pointermove', onMove);
node.addEventListener('pointerup', finish);
node.addEventListener('pointercancel', finish);
});
}
function applyStoredPos(node, storeKey, fallback) {
const p = Store.get(storeKey, null);
if (p && typeof p.left === 'number' && typeof p.top === 'number') {
const r = node.getBoundingClientRect();
const w = r.width || fallback.w;
const h = r.height || fallback.h;
node.style.left = clamp(p.left, 4, Math.max(4, window.innerWidth - w - 4)) + 'px';
node.style.top = clamp(p.top, 4, Math.max(4, window.innerHeight - h - 4)) + 'px';
node.style.right = 'auto';
node.style.bottom = 'auto';
} else {
node.style.right = fallback.right;
node.style.bottom = fallback.bottom;
}
}
/* ───────────────────────────────────────────────────────────────
可折叠块
─────────────────────────────────────────────────────────────── */
function makeAccordion(titleNode, bodyNode, open, cls) {
const wrap = el('div', 'gt-acc' + (open ? ' open' : ''));
if (cls) wrap.classList.add(cls);
const hd = el('div', 'hd');
const arw = el('span', 'arw', '▶');
hd.appendChild(arw);
const nm = el('span', 'nm');
nm.appendChild(titleNode);
hd.appendChild(nm);
const bd = el('div', 'bd');
bd.appendChild(bodyNode);
wrap.appendChild(hd);
wrap.appendChild(bd);
hd.addEventListener('click', function (e) {
e.stopPropagation();
wrap.classList.toggle('open');
});
return wrap;
}
/** 单条发现:标题可点,详情与证据默认收起 */
function makeFinding(f) {
const item = el('div', 'gt-f');
const hd = el('div', 'hd');
hd.appendChild(el('span', 'arw2', '▶'));
const nm = el('span', 'nm', f.title);
hd.appendChild(nm);
item.appendChild(hd);
const bd = el('div', 'bd');
if (f.detail) bd.appendChild(el('div', 'dt', f.detail));
if (f.evidence) bd.appendChild(el('div', 'ev', String(f.evidence)));
item.appendChild(bd);
hd.addEventListener('click', function (e) {
e.stopPropagation();
item.classList.toggle('open');
});
return item;
}
/* ───────────────────────────────────────────────────────────────
面板
─────────────────────────────────────────────────────────────── */
function buildUI() {
return withoutProbe(function () {
const host = document.createElement('div');
host.setAttribute('data-gt', 'v3');
host.style.cssText = 'all:initial;position:fixed;z-index:2147483000;';
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = CSS;
root.appendChild(style);
const wrap = el('div', 'gt-wrap');
root.appendChild(wrap);
/* ═══════ 悬浮球 ═══════ */
const fab = el('div', 'gt-fab', '⚙');
fab.title = SCRIPT_NAME + '\n单击打开面板 · 拖动可移动位置';
const badge = el('div', 'gt-badge', '');
badge.style.display = 'none';
fab.appendChild(badge);
wrap.appendChild(fab);
applyStoredPos(fab, 'fabPos', { right: '18px', bottom: '78px', w: 46, h: 46 });
/* ═══════ 面板 ═══════ */
const panel = el('div', 'gt-panel');
if (state.widePanel) panel.classList.add('wide');
wrap.appendChild(panel);
/* 头部(拖拽把手) */
const head = el('div', 'gt-head');
const headTtl = el('div', 'ttl', 'FKQuestions v1.0.0');
head.appendChild(headTtl);
const btnCollapse = el('button', 'gt-head-mini', '—');
btnCollapse.className = 'mini';
btnCollapse.title = '收起面板';
head.appendChild(btnCollapse);
const btnClose = el('button', 'mini', '✕');
btnClose.className = 'mini';
btnClose.title = '关闭面板';
head.appendChild(btnClose);
panel.appendChild(head);
/* Tab 栏 */
const tabsBar = el('div', 'gt-tabs');
const panes = el('div', 'gt-panes');
const TABS = [
{ id: 'fill', label: '填写' },
{ id: 'ai', label: 'AI' },
{ id: 'cross', label: '跨页' },
{ id: 'risk', label: '风险' },
{ id: 'set', label: '设置' }
];
const tabBtns = {};
const tabPanes = {};
TABS.forEach(function (t) {
const b = el('button', 'gt-tab', t.label);
b.addEventListener('click', function () { switchTab(t.id); });
tabsBar.appendChild(b);
tabBtns[t.id] = b;
const pn = el('div', 'gt-pane');
tabPanes[t.id] = pn;
panes.appendChild(pn);
});
panel.appendChild(tabsBar);
panel.appendChild(panes);
const foot = el('div', 'gt-foot');
foot.innerHTML = '';
foot.appendChild(document.createTextNode('© ' + new Date().getFullYear() + ' '));
const brand = document.createElement('b');
brand.textContent = 'FastNow Studio';
foot.appendChild(brand);
foot.appendChild(document.createTextNode(' | BSD 3-Clause'));
panel.appendChild(foot);
applyStoredPos(panel, 'panelPos', { right: '18px', bottom: '132px', w: 320, h: 420 });
let activeTab = Store.get('tab', 'fill');
function switchTab(id) {
activeTab = id;
Store.set('tab', id);
TABS.forEach(function (t) {
tabBtns[t.id].classList.toggle('active', t.id === id);
tabPanes[t.id].classList.toggle('active', t.id === id);
});
}
/* ─────────── Tab: 填写 ─────────── */
const pFill = tabPanes.fill;
const gMode = el('div', 'gt-group');
gMode.appendChild(el('div', 'gt-gt', '执行方式'));
const fMode = el('div', 'gt-field');
fMode.appendChild(el('label', null, '执行模式'));
const selMode = document.createElement('select');
[['auto', '全自动(直接填写)'],
['semi', '半自动(先给我看,确认后填写)'],
['manual', '仅生成建议(不碰页面)']].forEach(function (p) {
const o = document.createElement('option');
o.value = p[0]; o.textContent = p[1];
selMode.appendChild(o);
});
selMode.value = state.mode;
fMode.appendChild(selMode);
gMode.appendChild(fMode);
const labAuto = el('label', 'gt-check');
const chkAuto = document.createElement('input');
chkAuto.type = 'checkbox';
chkAuto.checked = !!state.autoSubmit;
labAuto.appendChild(chkAuto);
labAuto.appendChild(document.createTextNode('填写后自动提交(有反作弊时会被强制关闭)'));
gMode.appendChild(labAuto);
pFill.appendChild(gMode);
/* ── 打字速度 ── */
const gType = el('div', 'gt-group');
gType.appendChild(el('div', 'gt-gt', '填入速度'));
const fSpeedSel = el('div', 'gt-field');
fSpeedSel.appendChild(el('label', null, '速度档位'));
const selSpeed = document.createElement('select');
Object.keys(TYPE_SPEEDS).forEach(function (k) {
const o = document.createElement('option');
o.value = k; o.textContent = TYPE_SPEEDS[k].label;
selSpeed.appendChild(o);
});
selSpeed.value = Human.speed;
fSpeedSel.appendChild(selSpeed);
const hintSpeed = el('div', 'gt-hint',
'逐字输入,每题之间留随机间隔。慢速更像真人,但耗时明显更长;' +
'部分带实时校验的输入框必须用非「关闭」速度才能正确填写。');
fSpeedSel.appendChild(hintSpeed);
gType.appendChild(fSpeedSel);
// 详细参数折叠
const spdBody = el('div', '');
const mkRange = function (label, key, min, max, step, unit, hint) {
const f = el('div', 'gt-field');
const lb = el('label', null, label + ':' + CFG[key] + (unit || ''));
f.appendChild(lb);
const r = document.createElement('input');
r.type = 'range';
r.min = String(min); r.max = String(max); r.step = String(step);
r.value = String(CFG[key]);
r.style.width = '100%';
f.appendChild(r);
r.addEventListener('input', function () {
CFG[key] = parseFloat(r.value);
lb.textContent = label + ':' + CFG[key] + (unit || '');
});
r.addEventListener('change', function () { Store.set(key, CFG[key]); });
if (hint) f.appendChild(el('div', 'gt-hint', hint));
return f;
};
spdBody.appendChild(mkRange('每字基础耗时', 'typeBaseMs', 20, 1200, 10, ' ms',
'文本很短时的单字耗时。真人中文输入约 330~670 ms/字'));
spdBody.appendChild(mkRange('长度递减系数', 'typePerCharMs', 0, 10, 0.5, ' ms/字',
'文本每多一个字,单字耗时的减少量——模拟打长句时的手速惯性'));
spdBody.appendChild(mkRange('单字耗时下限', 'typeCharMinMs', 20, 800, 10, ' ms',
'兜底值,防止长文本快到失真'));
spdBody.appendChild(mkRange('单字耗时上限', 'typeCharMaxMs', 100, 2000, 50, ' ms'));
spdBody.appendChild(mkRange('随机抖动', 'typeJitter', 0, 0.9, 0.05, '',
'节奏不规则才像真人,匀速最假'));
spdBody.appendChild(mkRange('题间思考基准', 'typeThinkMs', 0, 3000, 50, ' ms'));
spdBody.appendChild(mkRange('读题耗时系数', 'typeThinkPerChar', 0, 40, 1, ' ms/字',
'答案越长说明题越复杂,停顿越久'));
spdBody.appendChild(mkRange('卡壳概率', 'typePauseChance', 0, 0.3, 0.01, ''));
const accSpeed = makeAccordion(
(function () {
const fr = document.createDocumentFragment();
fr.appendChild(document.createTextNode('详细速度参数'));
return fr;
})(),
spdBody, false);
gType.appendChild(accSpeed);
pFill.appendChild(gType);
const btnRun = el('button', 'gt-btn ok', '🚀 执行');
pFill.appendChild(btnRun);
const statusLine = el('div', 'gt-hint', '');
pFill.appendChild(statusLine);
/* 「预览题目」:不消耗 API 额度,先看脚本提取到了什么。
题目提取错是最常见的问题(多提、漏提、题干抓错),
以前只能等 AI 返回一堆对不上的答案才发现,白白烧掉一次请求。 */
const btnPreview = el('button', 'gt-btn ghost sm', '🔍 预览提取到的题目');
pFill.appendChild(btnPreview);
// 执行完想看细节时,不用自己去设置页找
const btnGotoLog = el('button', 'gt-btn ghost sm', '📜 查看执行日志');
pFill.appendChild(btnGotoLog);
/* 填错了想重来时,逐项手动清空很痛苦 */
const btnClear = el('button', 'gt-btn ghost sm', '🧹 清空本页已填内容');
pFill.appendChild(btnClear);
/* ─────────── Tab: AI ─────────── */
const pAI = tabPanes.ai;
const gProv = el('div', 'gt-group');
gProv.appendChild(el('div', 'gt-gt', '服务商'));
const fProv = el('div', 'gt-field');
fProv.appendChild(el('label', null, 'AI 提供商'));
const selProvider = document.createElement('select');
Object.keys(PROVIDERS).forEach(function (k) {
const o = document.createElement('option');
o.value = k; o.textContent = PROVIDERS[k].name;
selProvider.appendChild(o);
});
selProvider.value = state.provider;
fProv.appendChild(selProvider);
gProv.appendChild(fProv);
const fBase = el('div', 'gt-field');
fBase.appendChild(el('label', null, '自定义端点(OpenAI 兼容)'));
const inpBase = document.createElement('input');
inpBase.type = 'text';
inpBase.placeholder = 'https://…/v1/chat/completions';
inpBase.value = state.customBase || '';
fBase.appendChild(inpBase);
const hintBase = el('div', 'gt-hint', '用自定义端点需在脚本头部补一行 // @connect 你的域名');
fBase.appendChild(hintBase);
gProv.appendChild(fBase);
pAI.appendChild(gProv);
const gModel = el('div', 'gt-group');
gModel.appendChild(el('div', 'gt-gt', '模型与密钥'));
const fModel = el('div', 'gt-field');
fModel.appendChild(el('label', null, '模型'));
const inpModel = document.createElement('input');
inpModel.type = 'text';
inpModel.value = currentModel();
const listId = 'gt-mdl-' + Math.random().toString(36).slice(2, 8);
const dl = document.createElement('datalist');
dl.id = listId;
inpModel.setAttribute('list', listId);
fModel.appendChild(inpModel);
fModel.appendChild(dl);
gModel.appendChild(fModel);
const fKey = el('div', 'gt-field');
fKey.appendChild(el('label', null, 'API Key'));
const inpKey = document.createElement('input');
inpKey.type = 'password';
inpKey.placeholder = '粘贴 API Key';
inpKey.value = state.apiKey || '';
fKey.appendChild(inpKey);
const hintKey = el('div', 'gt-hint', '');
fKey.appendChild(hintKey);
gModel.appendChild(fKey);
const labPersist = el('label', 'gt-check');
const chkPersist = document.createElement('input');
chkPersist.type = 'checkbox';
chkPersist.checked = !!state.persistKey;
labPersist.appendChild(chkPersist);
labPersist.appendChild(document.createTextNode('保存到本地(明文存储,共用电脑勿勾)'));
gModel.appendChild(labPersist);
pAI.appendChild(gModel);
/* ── 人设与提示词 ── */
const gPersona = el('div', 'gt-group');
gPersona.appendChild(el('div', 'gt-gt', '人设与提示词'));
const fPersona = el('div', 'gt-field');
fPersona.appendChild(el('label', null, '虚拟人设'));
const selPersona = document.createElement('select');
Object.keys(PERSONAS).forEach(function (k) {
const o = document.createElement('option');
o.value = k;
o.textContent = PERSONAS[k].name + ' — ' + PERSONAS[k].desc;
selPersona.appendChild(o);
});
selPersona.value = Persona.id;
fPersona.appendChild(selPersona);
const hintPersona = el('div', 'gt-hint',
'给模型一个固定身份,答案风格才稳定,前后页也不容易自相矛盾。');
fPersona.appendChild(hintPersona);
gPersona.appendChild(fPersona);
const fSys = el('div', 'gt-field');
fSys.appendChild(el('label', null, '自定义系统提示词(可选)'));
const inpSys = document.createElement('textarea');
inpSys.rows = 3;
inpSys.style.cssText = 'width:100%;box-sizing:border-box;padding:6px 8px;' +
'border:1px solid #cbd2d9;border-radius:6px;font-size:12px;font-family:inherit;resize:vertical';
inpSys.placeholder = '例如:请用简洁口语作答,避免书面语;对价格类问题偏向保守…';
inpSys.value = Persona.customPrompt || '';
fSys.appendChild(inpSys);
const hintSys = el('div', 'gt-hint',
'会追加在人设之后。人设也会同时写进用户提示词——弱模型常忽略系统指令,双保险。');
fSys.appendChild(hintSys);
gPersona.appendChild(fSys);
pAI.appendChild(gPersona);
/* ── 答案约束 ── */
const gAns = el('div', 'gt-group');
gAns.appendChild(el('div', 'gt-gt', '答案约束'));
const mkNum = function (label, key, min, max, hint) {
const f = el('div', 'gt-field');
f.appendChild(el('label', null, label));
const i = document.createElement('input');
i.type = 'number';
i.min = String(min); i.max = String(max);
i.value = String(CFG[key]);
f.appendChild(i);
i.addEventListener('change', function () {
let v = parseInt(i.value, 10);
if (isNaN(v)) v = CFG[key];
v = Math.max(min, Math.min(max, v));
i.value = String(v);
CFG[key] = v;
Store.set(key, v);
});
if (hint) f.appendChild(el('div', 'gt-hint', hint));
return f;
};
const rowMM = el('div', 'gt-row2');
const fMin = mkNum('多选最少项', 'multiMin', 1, 10, null);
const fMax = mkNum('多选最多项', 'multiMax', 1, 20, null);
rowMM.appendChild(fMin);
rowMM.appendChild(fMax);
gAns.appendChild(rowMM);
gAns.appendChild(el('div', 'gt-hint',
'模型常把多选题全选,一眼假。超出上限会自动截断。'));
gAns.appendChild(mkNum('填空最大字数', 'textMaxChars', 10, 500,
'同时用于限制 AI 输出长度'));
pAI.appendChild(gAns);
const gAdv = el('div', 'gt-group');
gAdv.appendChild(el('div', 'gt-gt', '高级'));
const fTemp = el('div', 'gt-field');
fTemp.appendChild(el('label', null, '随机性 temperature:' + state.temperature));
const rngTemp = document.createElement('input');
rngTemp.type = 'range';
rngTemp.min = '0'; rngTemp.max = '1'; rngTemp.step = '0.1';
rngTemp.value = String(state.temperature);
rngTemp.style.width = '100%';
fTemp.appendChild(rngTemp);
gAdv.appendChild(fTemp);
const fBatch = el('div', 'gt-field');
fBatch.appendChild(el('label', null, '每批题目数:' + CFG.batchSize));
const rngBatch = document.createElement('input');
rngBatch.type = 'range';
rngBatch.min = '4'; rngBatch.max = '25'; rngBatch.step = '1';
rngBatch.value = String(CFG.batchSize);
rngBatch.style.width = '100%';
fBatch.appendChild(rngBatch);
const hintBatch = el('div', 'gt-hint', '题目多时调小,可降低单次请求超时概率');
fBatch.appendChild(hintBatch);
gAdv.appendChild(fBatch);
const fTimeout = el('div', 'gt-field');
fTimeout.appendChild(el('label', null, '请求超时(秒):' + Math.round(CFG.timeout / 1000)));
const rngTimeout = document.createElement('input');
rngTimeout.type = 'range';
rngTimeout.min = '10'; rngTimeout.max = '120'; rngTimeout.step = '5';
rngTimeout.value = String(Math.round(CFG.timeout / 1000));
rngTimeout.style.width = '100%';
fTimeout.appendChild(rngTimeout);
gAdv.appendChild(fTimeout);
pAI.appendChild(gAdv);
const btnTest = el('button', 'gt-btn ghost sm', '🔌 测试连接');
pAI.appendChild(btnTest);
/* ─────────── Tab: 跨页 ─────────── */
const pCross = tabPanes.cross;
const hintCross = el('div', 'gt-hint',
'多页问卷开启后:填完本页自动翻到下一页继续,直到最后一页。支持整页跳转与 SPA 换页。');
pCross.appendChild(hintCross);
const gCross = el('div', 'gt-group');
gCross.style.marginTop = '8px';
const labCross = el('label', 'gt-check');
const chkCross = document.createElement('input');
chkCross.type = 'checkbox';
chkCross.checked = !!state.crossPage;
labCross.appendChild(chkCross);
labCross.appendChild(document.createTextNode('启用跨页自动续填'));
gCross.appendChild(labCross);
const labCrossAuto = el('label', 'gt-check');
const chkCrossAuto = document.createElement('input');
chkCrossAuto.type = 'checkbox';
chkCrossAuto.checked = !!state.crossPageAuto;
labCrossAuto.appendChild(chkCrossAuto);
labCrossAuto.appendChild(document.createTextNode('后续页面不再逐页弹窗确认'));
gCross.appendChild(labCrossAuto);
const labCrossNext = el('label', 'gt-check');
const chkCrossNext = document.createElement('input');
chkCrossNext.type = 'checkbox';
chkCrossNext.checked = !!state.crossPageNext;
labCrossNext.appendChild(chkCrossNext);
labCrossNext.appendChild(document.createTextNode('自动点击「下一页」按钮'));
gCross.appendChild(labCrossNext);
pCross.appendChild(gCross);
const sessBox = el('div', '');
pCross.appendChild(sessBox);
const btnSession = el('button', 'gt-btn danger', '🛑 中止跨页任务');
pCross.appendChild(btnSession);
function syncCrossUI() {
const on = chkCross.checked;
[labCrossAuto, labCrossNext, btnSession].forEach(function (n) {
n.style.display = on ? 'flex' : 'none';
});
if (btnSession) btnSession.style.display = on ? 'block' : 'none';
}
/* ─────────── Tab: 风险 ─────────── */
const pRisk = tabPanes.risk;
const riskBox = el('div', '');
pRisk.appendChild(riskBox);
const btnRescan = el('button', 'gt-btn ghost', '🔍 重新扫描');
pRisk.appendChild(btnRescan);
const gSens = el('div', 'gt-group');
gSens.style.marginTop = '10px';
const fSens = el('div', 'gt-field');
fSens.appendChild(el('label', null, '扫描敏感度'));
const selSens = document.createElement('select');
[['loose', '宽松:只报高风险'],
['normal', '标准:报高 + 中风险'],
['strict', '严格:全部列出(含提示)']].forEach(function (p) {
const o = document.createElement('option');
o.value = p[0]; o.textContent = p[1];
selSens.appendChild(o);
});
selSens.value = state.sensitivity;
fSens.appendChild(selSens);
const hintSens = el('div', 'gt-hint', '觉得警告太多可以调宽松。注意:放宽只影响显示,不会改变脚本是否自动填写的判定。');
fSens.appendChild(hintSens);
gSens.appendChild(fSens);
const labMedia = el('label', 'gt-check');
const chkMedia = document.createElement('input');
chkMedia.type = 'checkbox';
chkMedia.checked = !!state.mediaCheck;
labMedia.appendChild(chkMedia);
labMedia.appendChild(document.createTextNode('检测图片/视频等 AI 看不到内容的题目'));
gSens.appendChild(labMedia);
const fMedia = el('div', 'gt-field');
fMedia.appendChild(el('label', null, '图形视为「图标」的尺寸上限(px)'));
const inpMedia = document.createElement('input');
inpMedia.type = 'number';
inpMedia.min = '16'; inpMedia.max = '200'; inpMedia.step = '4';
inpMedia.value = String(state.mediaMinPx);
fMedia.appendChild(inpMedia);
const hintMedia = el('div', 'gt-hint',
'小于此尺寸的 img / svg / canvas 会被当作装饰性图标忽略。' +
'若你的页面图标较大导致误报,把这个值调大;若题目配图被漏检,调小。');
fMedia.appendChild(hintMedia);
gSens.appendChild(fMedia);
pRisk.appendChild(gSens);
/* ─────────── Tab: 设置 ─────────── */
const pSet = tabPanes.set;
const gScope = el('div', 'gt-group');
gScope.appendChild(el('div', 'gt-gt', '作用范围'));
const fScope = el('div', 'gt-field');
fScope.appendChild(el('label', null, '站点白名单(逗号分隔,留空=所有站点)'));
const inpScope = document.createElement('input');
inpScope.type = 'text';
inpScope.placeholder = 'wjx.cn, wj.qq.com';
inpScope.value = state.scopeWhitelist || '';
fScope.appendChild(inpScope);
const hintScope = el('div', 'gt-hint', '强烈建议填写。脚本会读取页面表单内容并发送给 AI 服务商。');
fScope.appendChild(hintScope);
gScope.appendChild(fScope);
pSet.appendChild(gScope);
const gExclude = el('div', 'gt-group');
gExclude.appendChild(el('div', 'gt-gt', '题目提取'));
const fExclude = el('div', 'gt-field');
fExclude.appendChild(el('label', null, '额外排除的选择器'));
const inpExclude = document.createElement('input');
inpExclude.type = 'text';
inpExclude.placeholder = '.my-toolbar, #debugBox';
inpExclude.value = state.excludeSelectors || '';
fExclude.appendChild(inpExclude);
fExclude.appendChild(el('div', 'gt-hint',
'页面上的调试面板、工具栏也会被当成题目。用 CSS 选择器排除它们,逗号分隔。' +
'已内置常见调试面板、导航与页脚。'));
gExclude.appendChild(fExclude);
pSet.appendChild(gExclude);
const gUI = el('div', 'gt-group');
gUI.appendChild(el('div', 'gt-gt', '界面'));
const labWide = el('label', 'gt-check');
const chkWide = document.createElement('input');
chkWide.type = 'checkbox';
chkWide.checked = !!state.widePanel;
labWide.appendChild(chkWide);
labWide.appendChild(document.createTextNode('宽屏面板'));
gUI.appendChild(labWide);
const labDebug = el('label', 'gt-check');
const chkDebug = document.createElement('input');
chkDebug.type = 'checkbox';
chkDebug.checked = !!state.debug;
labDebug.appendChild(chkDebug);
labDebug.appendChild(document.createTextNode('调试模式(控制台输出详细日志)'));
gUI.appendChild(labDebug);
const btnResetPos = el('button', 'gt-btn ghost sm', '↩ 重置悬浮球与面板位置');
gUI.appendChild(btnResetPos);
/* ── 配置导出 / 导入 ──
换浏览器、换机器时,Key、人设、速度参数、白名单全得重填一遍,
很烦。导出成一段文本,新环境粘贴即可。 */
const btnExport = el('button', 'gt-btn ghost sm', '📤 导出配置(含 Key)');
gUI.appendChild(btnExport);
const btnImport = el('button', 'gt-btn ghost sm', '📥 导入配置');
gUI.appendChild(btnImport);
const hotkeyHint = el('div', 'gt-hint',
'快捷键:Alt+Q 开关面板 · Alt+W 立即执行 · Alt+E 预览题目(输入框内不触发)');
gUI.appendChild(hotkeyHint);
pSet.appendChild(gUI);
/* ── 执行日志 ── */
const gLog = el('div', 'gt-group');
const logHead = el('div', 'gt-gt');
logHead.style.cssText += ';display:flex;align-items:center;justify-content:space-between';
const logHeadTxt = el('span', null, '执行日志');
const logCount = el('span', null, '0 条');
logCount.style.cssText = 'font-weight:400;text-transform:none;letter-spacing:0';
logHead.appendChild(logHeadTxt);
logHead.appendChild(logCount);
gLog.appendChild(logHead);
const logHint = el('div', 'gt-hint',
'记录题目提取、AI 请求、答案匹配与填写结果。日志只存内存,刷新即清空,不会写入本地存储。');
gLog.appendChild(logHint);
const logBox = el('div', 'gt-logbox');
const logEmpty = el('div', 'gt-empty', '暂无日志。点「🚀 执行」后这里会显示完整过程。');
logBox.appendChild(logEmpty);
gLog.appendChild(logBox);
const labAutoScroll = el('label', 'gt-check');
const chkAutoScroll = document.createElement('input');
chkAutoScroll.type = 'checkbox';
chkAutoScroll.checked = true;
labAutoScroll.appendChild(chkAutoScroll);
labAutoScroll.appendChild(document.createTextNode('自动滚动到最新'));
gLog.appendChild(labAutoScroll);
const btnRow = el('div', 'gt-btnrow');
const btnCopyLog = el('button', 'gt-btn ghost sm', '📋 复制日志');
const btnClearLog = el('button', 'gt-btn ghost sm', '🗑 清空');
btnRow.appendChild(btnCopyLog);
btnRow.appendChild(btnClearLog);
gLog.appendChild(btnRow);
pSet.appendChild(gLog);
const gDanger = el('div', 'gt-group');
gDanger.appendChild(el('div', 'gt-gt', '数据'));
const btnClearSession = el('button', 'gt-btn ghost sm', '🗑 清除跨页进度');
gDanger.appendChild(btnClearSession);
const btnResetAll = el('button', 'gt-btn danger sm', '⚠ 清除全部配置(含 Key)');
gDanger.appendChild(btnResetAll);
pSet.appendChild(gDanger);
/* ═══════ Toast ═══════ */
// 变量名不能叫 toast:会遮蔽外层同名函数,导致面板内的 toast() 调用全部报错
const toastEl = el('div', 'gt-toast');
wrap.appendChild(toastEl);
/* ═══════ 引用收集 ═══════ */
Object.assign(ui, {
host, root, fab, badge, panel, riskBox,
selProvider, inpBase, inpModel, dl, inpKey, hintKey, chkPersist,
selPersona, inpSys, selSpeed,
selMode, chkAuto, inpScope, btnRescan, btnRun, btnClose, toast: toastEl,
chkCross, chkCrossAuto, chkCrossNext, sessBox, statusLine,
logBox, logCount, logEmpty, chkAutoScroll,
setPanel: null,
tabBtns, switchTab, syncCrossUI, setStatus: function (t) {
statusLine.textContent = t || '';
}
});
/* ═══════ 事件 ═══════ */
let visible = false;
function setPanel(v) {
visible = v;
panel.style.display = v ? 'flex' : 'none';
if (v) {
// 展开时确保没跑出视口
const r = panel.getBoundingClientRect();
if (r.right > window.innerWidth || r.bottom > window.innerHeight || r.top < 0) {
applyStoredPos(panel, 'panelPos', { right: '18px', bottom: '132px', w: 320, h: 420 });
}
}
}
makeDraggable(fab, fab, 'fabPos', function () { setPanel(!visible); });
makeDraggable(head, panel, 'panelPos', null);
btnClose.addEventListener('click', function () { setPanel(false); });
btnCollapse.addEventListener('click', function () { setPanel(false); });
panel.addEventListener('click', function (e) { e.stopPropagation(); });
/**
* 点击面板外部才关闭。
* 这里必须用 composedPath() 而不是 e.target:面板在 Shadow DOM 内,
* 事件冒泡到 document 时 target 会被 retarget 成 host 元素,
* 直接比较 target === fab 永远不成立。
* 更关键的是时序——pointerup 里刚把面板打开,浏览器紧接着合成 click
* 并冒泡到这里,若不做排除就会「刚打开立刻被关掉」。
*/
document.addEventListener('click', function (e) {
if (!visible) return;
const path = e.composedPath ? e.composedPath() : [];
for (let i = 0; i < path.length; i++) {
if (path[i] === fab || path[i] === panel) return;
}
setPanel(false);
});
function syncProviderUI() {
const p = currentProvider();
fBase.style.display = p.needsBase ? 'block' : 'none';
dl.innerHTML = '';
(p.models || []).forEach(function (m) {
const o = document.createElement('option');
o.value = m;
dl.appendChild(o);
});
if (!state.model) inpModel.value = p.defaultModel;
}
selProvider.addEventListener('change', function () {
state.provider = selProvider.value;
state.model = '';
Store.set('provider', state.provider);
Store.set('model', '');
syncProviderUI();
});
inpModel.addEventListener('change', function () {
state.model = inpModel.value.trim();
Store.set('model', state.model);
});
inpBase.addEventListener('change', function () {
state.customBase = inpBase.value.trim();
Store.set('customBase', state.customBase);
});
inpKey.addEventListener('change', function () {
state.apiKey = inpKey.value.trim();
if (state.persistKey) Store.set('apiKey', state.apiKey);
else Store.del('apiKey');
});
chkPersist.addEventListener('change', function () {
state.persistKey = chkPersist.checked;
Store.set('persistKey', state.persistKey);
if (state.persistKey) Store.set('apiKey', state.apiKey);
else Store.del('apiKey');
hintKey.textContent = state.persistKey
? '⚠️ Key 已明文写入本地存储'
: '仅保存在内存中,刷新页面后需重新填写';
});
rngTemp.addEventListener('input', function () {
state.temperature = parseFloat(rngTemp.value);
CFG.temperature = state.temperature;
fTemp.querySelector('label').textContent = '随机性 temperature:' + state.temperature.toFixed(1);
});
rngTemp.addEventListener('change', function () {
Store.set('temperature', state.temperature);
});
rngBatch.addEventListener('input', function () {
CFG.batchSize = parseInt(rngBatch.value, 10);
fBatch.querySelector('label').textContent = '每批题目数:' + CFG.batchSize;
});
rngBatch.addEventListener('change', function () {
Store.set('batchSize', CFG.batchSize);
});
rngTimeout.addEventListener('input', function () {
CFG.timeout = parseInt(rngTimeout.value, 10) * 1000;
fTimeout.querySelector('label').textContent = '请求超时(秒):' + Math.round(CFG.timeout / 1000);
});
rngTimeout.addEventListener('change', function () {
Store.set('timeout', CFG.timeout);
});
selMode.addEventListener('change', function () {
state.mode = selMode.value;
Store.set('mode', state.mode);
});
chkAuto.addEventListener('change', function () {
state.autoSubmit = chkAuto.checked;
Store.set('autoSubmit', state.autoSubmit);
});
ui.syncAutoSubmitUI = function (policy) {
if (!chkAuto || !labAuto) return;
const blockedByRisk = !!(policy && policy.forbidAutoSubmit);
const hardBlocked = !!(policy && policy.blocked);
if (hardBlocked) {
chkAuto.checked = false;
chkAuto.disabled = true;
labAuto.style.opacity = '.5';
labAuto.title = '检测到高风险反作弊,本页禁止自动填写与提交';
if (!labAuto.querySelector('.as-note')) {
const n = el('span', 'as-note', ' ⛔ 高风险已禁用');
n.style.cssText = 'color:#b91c1c;font-size:11px';
labAuto.appendChild(n);
}
} else if (blockedByRisk) {
chkAuto.checked = false;
chkAuto.disabled = true;
labAuto.style.opacity = '.5';
labAuto.title = '检测到反作弊措施,自动提交已强制关闭,需你手动点击提交';
if (!labAuto.querySelector('.as-note')) {
const n = el('span', 'as-note', ' ⚠ 有反作弊,已强制关闭');
n.style.cssText = 'color:#b45309;font-size:11px';
labAuto.appendChild(n);
}
} else {
chkAuto.disabled = false;
chkAuto.checked = !!state.autoSubmit;
labAuto.style.opacity = '';
labAuto.title = '';
const n = labAuto.querySelector('.as-note');
if (n) n.remove();
}
};
inpScope.addEventListener('change', function () {
state.scopeWhitelist = inpScope.value.trim();
Store.set('scopeWhitelist', state.scopeWhitelist);
});
chkCross.addEventListener('change', function () {
state.crossPage = chkCross.checked;
Store.set('crossPage', state.crossPage);
syncCrossUI();
if (!state.crossPage) Session.clear();
renderSessionBar(sessBox);
});
chkCrossAuto.addEventListener('change', function () {
state.crossPageAuto = chkCrossAuto.checked;
Store.set('crossPageAuto', state.crossPageAuto);
});
chkCrossNext.addEventListener('change', function () {
state.crossPageNext = chkCrossNext.checked;
Store.set('crossPageNext', state.crossPageNext);
});
btnSession.addEventListener('click', function () { abortCrossPage(); });
chkMedia.addEventListener('change', function () {
state.mediaCheck = chkMedia.checked;
Store.set('mediaCheck', state.mediaCheck);
toast('low', '已' + (state.mediaCheck ? '开启' : '关闭'),
state.mediaCheck ? '将检测媒体类题目并降级' : '不再检测媒体内容,也不会因此降级');
});
inpMedia.addEventListener('change', function () {
const v = parseInt(inpMedia.value, 10);
if (isNaN(v) || v < 16 || v > 200) { inpMedia.value = String(state.mediaMinPx); return; }
state.mediaMinPx = v;
Store.set('mediaMinPx', v);
toast('low', '已保存', '图标判定阈值:' + v + ' px');
});
selSens.addEventListener('change', function () {
state.sensitivity = selSens.value;
Store.set('sensitivity', state.sensitivity);
runScan(true);
});
chkWide.addEventListener('change', function () {
state.widePanel = chkWide.checked;
Store.set('widePanel', state.widePanel);
panel.classList.toggle('wide', state.widePanel);
});
chkDebug.addEventListener('change', function () {
state.debug = chkDebug.checked;
Store.set('debug', state.debug);
if (state.debug) exposeDebug();
else { try { delete window.__gametame; } catch (e) { /* ignore */ } }
toast('low', '调试模式', state.debug
? '已开启。内部状态挂载到 window.__gametame,可用 __gametame.speedTable() 查看速度曲线'
: '已关闭');
});
inpExclude.addEventListener('change', function () {
state.excludeSelectors = inpExclude.value.trim();
Store.set('excludeSelectors', state.excludeSelectors);
toast('low', '已保存', state.excludeSelectors
? '已排除:' + state.excludeSelectors.slice(0, 40)
: '已清空额外排除项');
if (runScan) runScan(true);
});
btnExport.addEventListener('click', function () {
const cfg = exportConfig();
const txt = JSON.stringify(cfg, null, 2);
openModal('配置已导出(含 API Key,请妥善保管)', txt);
Log.info('配置已导出', '共 ' + Object.keys(cfg).length + ' 项');
});
btnImport.addEventListener('click', function () {
const input = prompt('请粘贴之前导出的配置 JSON:');
if (!input) return;
try {
const cfg = JSON.parse(input.trim());
const n = importConfig(cfg);
toast('low', '导入成功', '已恢复 ' + n + ' 项配置,部分设置刷新后生效');
Log.info('配置已导入', '恢复 ' + n + ' 项');
setTimeout(function () { location.reload(); }, 900);
} catch (e) {
toast('medium', '导入失败', '不是有效的配置 JSON:' + String(e.message).slice(0, 60));
}
});
btnResetPos.addEventListener('click', function () {
Store.del('fabPos');
Store.del('panelPos');
fab.style.right = '18px';
fab.style.bottom = '78px';
fab.style.left = 'auto';
fab.style.top = 'auto';
panel.style.right = '18px';
panel.style.bottom = '132px';
panel.style.left = 'auto';
panel.style.top = 'auto';
Store.del('awinPos');
if (ui.awin) {
ui.awin.style.right = '18px';
ui.awin.style.bottom = '200px';
ui.awin.style.left = 'auto';
ui.awin.style.top = 'auto';
}
toast('low', '已重置', '悬浮球与面板回到默认位置');
});
btnClearSession.addEventListener('click', function () {
Session.clear();
renderSessionBar(sessBox);
toast('low', '已清除', '跨页进度已删除');
});
/* ── 日志渲染 ── */
const KIND_META = {
info: { ico: '•', cls: '' },
ok: { ico: '✓', cls: 'ok' },
warn: { ico: '!', cls: 'warn' },
error: { ico: '✕', cls: 'err' },
ai: { ico: '⇄', cls: 'ai' },
fill: { ico: '✎', cls: 'fill' },
answer:{ ico: '★', cls: 'answer' }
};
let logRendered = 0;
ui.renderLog = function () {
if (!ui.logBox) return;
const items = Log.items;
ui.logCount.textContent = items.length + ' 条';
if (!items.length) {
ui.logBox.innerHTML = '';
ui.logEmpty.textContent = '暂无日志。点「🚀 执行」后这里会显示完整过程。';
ui.logBox.appendChild(ui.logEmpty);
logRendered = 0;
return;
}
// 增量渲染,避免每次全量重建导致闪烁
for (let i = logRendered; i < items.length; i++) {
const it = items[i];
if (logRendered === 0 && ui.logBox.firstChild === ui.logEmpty) {
ui.logBox.removeChild(ui.logEmpty);
}
const m = KIND_META[it.kind] || KIND_META.info;
const row = el('div', 'gt-log ' + m.cls);
const ts = new Date(it.t).toLocaleTimeString();
row.appendChild(el('span', 'ts', ts));
row.appendChild(el('span', 'ic', m.ico));
const body = el('div', 'bd');
body.appendChild(el('span', 'ti', it.title));
if (it.detail) {
const det = el('div', 'de', it.detail);
body.appendChild(det);
}
// 附加数据(选项列表、AI 返回的原文等)默认折叠
if (it.data !== null && it.data !== undefined) {
const det = el('details', 'da');
det.appendChild(el('summary', null, '查看数据'));
let txt;
try {
txt = typeof it.data === 'string' ? it.data
: JSON.stringify(it.data, null, 2);
} catch (e) { txt = String(it.data); }
det.appendChild(el('pre', null, String(txt).slice(0, 2000)));
body.appendChild(det);
}
row.appendChild(body);
ui.logBox.appendChild(row);
}
logRendered = items.length;
if (ui.chkAutoScroll && ui.chkAutoScroll.checked) {
ui.logBox.scrollTop = ui.logBox.scrollHeight;
}
};
btnCopyLog.addEventListener('click', function () {
const txt = Log.toText();
if (!txt) { toast('low', '日志为空', '还没有可复制的记录'); return; }
copyText(txt).then(function (ok) {
toast(ok ? 'low' : 'medium', ok ? '已复制' : '复制失败',
ok ? Log.items.length + ' 条日志已复制到剪贴板'
: '浏览器拒绝了剪贴板访问,请手动选中复制');
});
});
btnClearLog.addEventListener('click', function () {
Log.clear();
logRendered = 0;
toast('low', '已清空', '执行日志已清除');
});
btnResetAll.addEventListener('click', function () {
if (!confirm('将清除所有本地配置(包括 API Key),确定继续?')) return;
['provider', 'model', 'apiKey', 'persistKey', 'customBase', 'mode',
'autoSubmit', 'scopeWhitelist', 'crossPage', 'crossPageAuto',
'crossPageNext', 'sensitivity', 'temperature', 'batchSize',
'timeout', 'widePanel', 'debug', 'tab', 'typeSpeed',
'persona', 'customSystemPrompt', 'excludeSelectors',
'multiMin', 'multiMax', 'textMaxChars',
'typeBaseMs', 'typePerCharMs', 'typeJitter', 'typeThinkMs',
'typeThinkPerChar', 'typePauseChance', 'typeCharMinMs', 'typeCharMaxMs',
'fabPos', 'panelPos', 'awinPos'].forEach(function (k) { Store.del(k); });
Session.clear();
toast('low', '已清除', '全部配置已删除,刷新页面后生效');
});
btnGotoLog.addEventListener('click', function () {
switchTab('set');
// 滚到日志分组
setTimeout(function () {
try { gLog.scrollIntoView({ block: 'start' }); } catch (e) { /* ignore */ }
}, 30);
});
selPersona.addEventListener('change', function () {
Persona.id = selPersona.value;
Store.set('persona', Persona.id);
const p = PERSONAS[Persona.id];
toast('low', '人设已切换', p ? p.name : '默认');
});
inpSys.addEventListener('change', function () {
Persona.customPrompt = inpSys.value;
Store.set('customSystemPrompt', Persona.customPrompt);
toast('low', '已保存', Persona.customPrompt ? '自定义提示词已生效' : '已清空自定义提示词');
});
selSpeed.addEventListener('change', function () {
Human.speed = selSpeed.value;
CFG.typeSpeed = selSpeed.value;
Store.set('typeSpeed', Human.speed);
toast('low', '已切换', '填入速度:' + TYPE_SPEEDS[Human.speed].label);
});
btnTest.addEventListener('click', function () { testConnection(); });
btnRescan.addEventListener('click', function () { runScan(true); });
btnRun.addEventListener('click', function () { runOnce(); });
btnPreview.addEventListener('click', function () {
const qs = extractQuestions();
if (!qs.length) {
toast('medium', '未提取到题目', '当前页面没有识别到可填写的控件');
return;
}
openModal('提取到的题目(' + qs.length + ' 道)',
qs.map(function (q, i) {
return (i + 1) + '. [' + q.type + '] ' + q.title +
(q.options && q.options.length ? '\n 选项:' + q.options.join(' / ') : '');
}).join('\n'));
Log.info('预览题目', '共 ' + qs.length + ' 道,未发起 AI 请求');
});
btnClear.addEventListener('click', function () {
if (!confirm('确定清空本页所有已填写的内容?此操作不可撤销。')) return;
const qs = extractQuestions();
let n = 0;
qs.forEach(function (q) {
q.inputs.forEach(function (inp) {
if (isHiddenField(inp)) return;
try {
if (inp.type === 'radio' || inp.type === 'checkbox') {
if (inp.checked) { inp.checked = false; n++; }
} else if (inp.tagName === 'SELECT') {
if (inp.selectedIndex > 0) { inp.selectedIndex = 0; n++; }
} else if (isEditable(inp)) {
if (inp.innerText) { inp.innerText = ''; n++; }
} else if (inp.value !== '') {
inp.value = ''; n++;
}
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new Event('change', { bubbles: true }));
} catch (e) { /* ignore */ }
});
});
toast('low', '已清空', '共重置 ' + n + ' 个控件');
Log.info('清空本页填写', '重置 ' + n + ' 个控件');
});
syncProviderUI();
syncCrossUI();
hintKey.textContent = state.persistKey
? '⚠️ Key 已明文写入本地存储'
: '仅保存在内存中,刷新页面后需重新填写';
switchTab(activeTab);
renderSessionBar(sessBox);
if (document.body) document.body.appendChild(host);
else document.documentElement.appendChild(host);
buildAnswerWindow(wrap);
ui.setPanel = setPanel;
/* 快捷键挂到这里,直接复用已绑定好的处理函数,不用重复实现 */
installHotkeys({
togglePanel: function () { setPanel(!visible); },
run: function () { runOnce(); },
preview: function () {
const qs = extractQuestions();
if (!qs.length) { toast('medium', '未提取到题目', '当前页面没有可填写的控件'); return; }
openModal('提取到的题目(' + qs.length + ' 道)',
qs.map(function (q, i) {
return (i + 1) + '. [' + q.type + '] ' + q.title +
(q.options && q.options.length ? '\n 选项:' + q.options.join(' / ') : '');
}).join('\n'));
Log.info('预览题目(快捷键)', '共 ' + qs.length + ' 道,未发起 AI 请求');
}
});
});
}
/**
* 调试接口。仅在设置里开启「调试模式」后挂载,默认完全不暴露内部状态。
* 排查"为什么这题没填上""打字速度到底多少"这类问题时,比翻控制台日志快。
*/
function exposeDebug() {
try {
window.__gametame = {
version: SCRIPT_NAME,
CFG: CFG,
state: state,
Human: Human,
Persona: Persona,
PERSONAS: PERSONAS,
Log: Log,
Session: Session,
extractQuestions: extractQuestions,
groupContainer: groupContainer,
fieldKind: fieldKind,
isEditable: isEditable,
openModal: openModal,
Core: Core,
RectCache: RectCache,
contentFingerprint: contentFingerprint,
pageId: pageId,
titleByExclusion: titleByExclusion,
findTitle: findTitle,
anchorRect: anchorRect,
runScan: runScan,
fillAnswers: fillAnswers,
sanitizeAnswers: sanitizeAnswers,
buildPrompt: buildPrompt,
/** 采样当前速度档位下不同文本长度的单字耗时,便于调参 */
speedTable: function (lens) {
const out = {};
(lens || [2, 5, 10, 20, 40, 80]).forEach(function (n) {
let sum = 0;
for (let i = 0; i < 50; i++) sum += Human.perChar(n);
out[n] = Math.round(sum / 50 * 10) / 10;
});
return out;
}
};
console.log('[Gametame] 调试接口已挂载到 window.__gametame');
} catch (e) { /* ignore */ }
}
/**
* AI 答案独立悬浮窗。
* 之前塞在设置面板的「填写」页里,问题是:答案要盯着抄,面板却挡在右下角,
* 看答案和看题目得来回切。独立窗口可以拖到题目旁边,对照着填。
*/
function buildAnswerWindow(host) {
const win = el('div', 'gt-awin');
win.setAttribute('data-gt-awin', '1');
// 标题栏(拖拽把手)
const hd = el('div', 'aw-hd');
const ttl = el('div', 't', 'AI 建议答案');
hd.appendChild(ttl);
const btnMin = el('button', 'm', '—');
btnMin.title = '收起';
hd.appendChild(btnMin);
const btnClose = el('button', 'm', '✕');
btnClose.title = '关闭';
hd.appendChild(btnClose);
win.appendChild(hd);
// 内容区
const bd = el('div', 'aw-bd');
win.appendChild(bd);
// 底部操作
const ft = el('div', 'aw-ft');
const btnCopyAll = el('button', 'gt-btn ghost sm', '📋 复制全部');
const btnFill = el('button', 'gt-btn ok sm', '✍ 填入页面');
ft.appendChild(btnCopyAll);
ft.appendChild(btnFill);
win.appendChild(ft);
const tip = el('div', 'aw-tip', '拖动标题栏可移动窗口');
win.appendChild(tip);
host.appendChild(win);
// 拖拽(标题栏为把手,触摸同样可用)
makeDraggable(hd, win, 'awinPos', null);
applyStoredPos(win, 'awinPos', { right: '18px', bottom: '200px', w: 340, h: 300 });
btnClose.addEventListener('click', function () {
win.style.display = 'none';
});
// 收起:只留标题栏
btnMin.addEventListener('click', function () {
const collapsed = bd.style.display === 'none';
bd.style.display = collapsed ? 'block' : 'none';
ft.style.display = collapsed ? 'flex' : 'none';
tip.style.display = collapsed ? 'block' : 'none';
btnMin.textContent = collapsed ? '—' : '□';
});
win.addEventListener('click', function (e) { e.stopPropagation(); });
Object.assign(ui, { awin: win, awinBody: bd, awinTitle: ttl, awinFill: btnFill });
ui.awinFillHandler = null;
btnFill.addEventListener('click', function () {
if (ui.awinFillHandler) ui.awinFillHandler();
});
return win;
}
/** 把答案渲染到独立悬浮窗 */
function renderAnswers(questions, answers, policy) {
if (!ui.awin || !ui.awinBody) return;
const bd = ui.awinBody;
bd.innerHTML = '';
const lines = [];
// 提示条
if (policy && policy.blocked) {
bd.appendChild(el('div', 'aw-warn',
'⛔ 检测到高风险反作弊,未自动填写。以下答案仅供你手动作答时参考。'));
} else if (policy && policy.mode === 'manual') {
bd.appendChild(el('div', 'aw-warn',
'当前为「仅生成建议」模式,页面未被修改。对照下方答案手动作答即可。'));
}
questions.forEach(function (q, i) {
const a = answers[i];
const av = (a === undefined || a === null) ? '(无)'
: (Array.isArray(a) ? a.join(' + ') : String(a));
lines.push('[' + (i + 1) + '] ' + q.title + '\n → ' + av);
const row = el('div', 'aw-item');
row.appendChild(el('span', 'ix', String(i + 1)));
const body = el('div', 'bd');
body.appendChild(el('div', 'qt', String(q.title)));
body.appendChild(el('div', 'av', av));
row.appendChild(body);
const cp = el('button', 'cp', '复制');
cp.addEventListener('click', function (e) {
e.stopPropagation();
copyText(av).then(function (ok) {
toast(ok ? 'low' : 'medium', ok ? '已复制' : '复制失败',
ok ? '第 ' + (i + 1) + ' 题答案已复制' : '请手动选中复制');
});
});
row.appendChild(cp);
bd.appendChild(row);
});
if (ui.awinTitle) {
ui.awinTitle.textContent = 'AI 建议答案(' + questions.length + ' 题)';
}
if (ui.awinFill) {
if (policy && (policy.blocked || policy.mode === 'manual')) {
ui.awinFill.style.display = 'none';
} else {
ui.awinFill.style.display = 'block';
}
}
// 复制全部
const oldBtn = bd.querySelector('[data-copyall]');
if (oldBtn) oldBtn.remove();
ui.awin.style.display = 'flex';
Log.info('答案已显示在独立窗口', questions.length + ' 题');
questions.forEach(function (q, i) {
const a = answers[i];
const av = (a === undefined || a === null) ? '(无)'
: (Array.isArray(a) ? a.join(' + ') : String(a));
Log.add('answer',
'第 ' + (i + 1) + ' 题 · ' + String(q.title).slice(0, 40),
av, { title: q.title, answer: a, options: q.options || [] });
});
}
/** 绑定「复制全部」,避免重复绑定旧闭包 */
function bindCopyAll(questions, answers) {
if (!ui.awin) return;
const btn = ui.awin.querySelector('.aw-ft .gt-btn');
if (!btn) return;
const fresh = btn.cloneNode(true);
fresh.addEventListener('click', function () {
const lines = questions.map(function (q, i) {
const a = answers[i];
const av = (a === undefined || a === null) ? '(无)'
: (Array.isArray(a) ? a.join(' + ') : String(a));
return '[' + (i + 1) + '] ' + q.title + '\n → ' + av;
});
copyText(lines.join('\n')).then(function (ok) {
toast(ok ? 'low' : 'medium', ok ? '已复制' : '复制失败',
ok ? questions.length + ' 题答案已复制' : '请手动选中复制');
});
});
btn.parentNode.replaceChild(fresh, btn);
}
/* Toast / 通知 */
let toastTimer = null;
function toast(level, title, msg, ms) {
if (!ui.toast) return;
const t = ui.toast;
t.className = 'gt-toast show ' + (level === 'high' ? 'high' : (level === 'medium' ? 'medium' : ''));
t.innerHTML = '';
t.appendChild(el('div', 'tt', title));
if (msg) t.appendChild(el('div', null, msg));
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
t.className = 'gt-toast';
}, ms || (level === 'low' ? 4000 : 8000));
}
/**
* @param level 通知级别 low/medium/high,决定 toast 配色与是否弹桌面通知
* @param logKind 可选,单独指定日志级别。
* 默认按 level 映射,但"检测到高风险"这类提示虽然用 high 配色引起注意,
* 本身并不是脚本出错,不该在日志里标成 ERROR 误导排查。
*/
function notify(level, title, msg, logKind) {
Log.add(logKind || (level === 'high' ? 'error' : (level === 'medium' ? 'warn' : 'info')),
title, msg);
toast(level, title, msg);
if (level !== 'low') {
try {
if (typeof GM_notification === 'function') {
GM_notification({
title: SCRIPT_NAME + ':' + title,
text: String(msg || '').slice(0, 500),
timeout: 10000
});
}
} catch (e) { /* ignore */ }
}
}
/** 复制文本:优先异步剪贴板,失败回退到 execCommand */
function copyText(text) {
return new Promise(function (resolve) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () { resolve(true); },
function () { resolve(fallbackCopy(text)); }
);
return;
}
} catch (e) { /* ignore */ }
resolve(fallbackCopy(text));
});
}
function fallbackCopy(text) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
}
/* ═══════════════════════════════════════════════════════════════════════
执行日志
───────────────────────────────────────────────────────────────────────
AI 答题是个黑盒:题目提取了几道、发给模型的 prompt 长什么样、
模型回了什么、哪道题没匹配上——这些不出日志就没法排查。
日志只存内存,不落盘(避免把题目文本和 Key 留在本地存储里)。
═══════════════════════════════════════════════════════════════════════ */
const LOG_MAX = 500;
const Log = {
items: [],
seq: 0,
add(kind, title, detail, data) {
try {
this.items.push({
n: ++this.seq,
t: Date.now(),
kind: kind || 'info', // info | ok | warn | error | ai | fill
title: String(title || ''),
detail: detail === undefined || detail === null ? '' : String(detail),
data: data === undefined ? null : data
});
if (this.items.length > LOG_MAX) this.items.shift();
// 同步到控制台,方便用 DevTools 排查
const tag = '[Gametame]';
const line = tag + ' [' + (kind || 'info').toUpperCase() + '] ' +
this.items[this.items.length - 1].title +
(this.items[this.items.length - 1].detail
? ' — ' + this.items[this.items.length - 1].detail : '');
if (kind === 'error') console.error(line, data || '');
else if (kind === 'warn') console.warn(line, data || '');
else console.log(line, data || '');
if (ui.renderLog) ui.renderLog();
} catch (e) { /* 日志本身绝不能影响主流程 */ }
},
info(t, d, data) { this.add('info', t, d, data); },
ok(t, d, data) { this.add('ok', t, d, data); },
warn(t, d, data) { this.add('warn', t, d, data); },
error(t, d, data) { this.add('error', t, d, data); },
clear() { this.items = []; this.seq = 0; if (ui.renderLog) ui.renderLog(); },
/** 导出为纯文本,便于复制排查 */
toText() {
return this.items.map(function (i) {
const ts = new Date(i.t).toLocaleTimeString();
let s = '[' + ts + '] [' + i.kind.toUpperCase() + '] ' + i.title;
if (i.detail) s += '\n ' + i.detail.split('\n').join('\n ');
if (i.data !== null && i.data !== undefined) {
try {
s += '\n ' + JSON.stringify(i.data, null, 2)
.split('\n').join('\n ');
} catch (e) { /* ignore */ }
}
return s;
}).join('\n');
}
};
/* 风险面板渲染 */
/**
* 渲染扫描结果。
* 设计原则:默认只给结论,细节按需展开——十几条告警平铺出来没人会看。
* · 顶部一张摘要卡:等级 + 一句话结论
* · 下面按等级分三组折叠,高风险默认展开,其余收起
* · 每条只显示标题,点开才显示说明与证据
*/
function renderRisk(risk) {
if (!ui.riskBox) return;
const box = ui.riskBox;
box.innerHTML = '';
// 敏感度只影响显示,不影响 forbidFill / forbidAutoSubmit 的判定
const shown = filterBySensitivity(risk.findings, state.sensitivity);
const shownHigh = shown.filter(function (f) { return f.level === 'high'; }).length;
const META = {
low: { ico: '✅', lv: '未发现明显反作弊', cls: 'low',
ds: '仍建议人工核对答案,问卷平台可能还有服务端校验。' },
medium: { ico: '⚠️', lv: '中风险', cls: 'medium',
ds: '存在反作弊措施。可填写,但已禁用自动提交,需人工复核后手动提交。' },
high: { ico: '⛔', lv: '高风险', cls: 'high',
ds: '已强制切换为「仅生成建议」,不会自动填写或提交。' }
};
const m = META[risk.level] || META.low;
const sum = el('div', 'gt-sum ' + m.cls);
sum.appendChild(el('div', 'ico', m.ico));
const txt = el('div', 'txt');
txt.appendChild(el('div', 'lv', m.lv));
txt.appendChild(el('div', 'ds', m.ds));
sum.appendChild(txt);
box.appendChild(sum);
if (!shown.length) {
box.appendChild(el('div', 'gt-empty',
state.sensitivity === 'loose' && risk.findings.length
? '当前敏感度为「宽松」,已隐藏 ' + risk.findings.length + ' 项中低风险提示'
: '本次扫描未发现反作弊特征'));
} else {
box.appendChild(buildGroup('high', '高风险', shown, risk.level === 'high'));
box.appendChild(buildGroup('medium', '中风险', shown, risk.level === 'medium'));
box.appendChild(buildGroup('low', '提示', shown, false));
}
// 跨页时把风险也放进「跨页」Tab,避免来回切
renderSessionBar(ui.sessBox);
renderSessionBar(box);
// 自动提交开关随风险联动置灰
if (ui.syncAutoSubmitUI) {
ui.syncAutoSubmitUI({
forbidAutoSubmit: !!risk.forbidAutoSubmit,
blocked: !!risk.forbidFill
});
}
if (ui.btnRescan) {
ui.btnRescan.textContent = '🔍 重新扫描(' + new Date(risk.scannedAt).toLocaleTimeString() + ')';
}
// 徽章只数高风险,中风险不打扰
if (ui.badge) {
if (shownHigh) {
ui.badge.style.display = 'block';
ui.badge.textContent = String(shownHigh);
} else {
ui.badge.style.display = 'none';
}
}
if (ui.fab) {
ui.fab.className = 'gt-fab' + (risk.level === 'low' ? '' : ' risk-' + risk.level);
}
// 风险 Tab 上标个红点
if (ui.tabBtns && ui.tabBtns.risk) {
const existed = ui.tabBtns.risk.querySelector('.dot');
if (shownHigh && !existed) {
const d = el('span', 'dot');
ui.tabBtns.risk.appendChild(d);
} else if (!shownHigh && existed) {
existed.remove();
}
}
}
/** 按敏感度过滤要显示的条目 */
function filterBySensitivity(findings, sens) {
if (sens === 'strict') return findings;
if (sens === 'loose') return findings.filter(function (f) { return f.level === 'high'; });
return findings.filter(function (f) { return f.level === 'high' || f.level === 'medium'; });
}
/** 一个等级分组(默认折叠,高风险组默认展开) */
function buildGroup(level, name, findings, open) {
const list = findings.filter(function (f) { return f.level === level; });
if (!list.length) return document.createDocumentFragment();
const body = document.createElement('div');
list.forEach(function (f) { body.appendChild(makeFinding(f)); });
const titleNode = document.createDocumentFragment();
titleNode.appendChild(document.createTextNode(name + ' '));
const chip = el('span', 'gt-chip ' + level, String(list.length));
titleNode.appendChild(chip);
return makeAccordion(titleNode, body, !!open, 'gt-risk-acc');
}
/** 跨页进度条(跨页 Tab 与风险 Tab 共用) */
function renderSessionBar(box) {
if (!box) return;
const d = Session.data;
// 清掉旧的,避免重复追加
const old = box.querySelector('[data-sess]');
if (old) old.remove();
if (!d || !state.crossPage || !d.order.length) return;
const wrapEl = el('div', '');
wrapEl.setAttribute('data-sess', '1');
wrapEl.style.cssText = 'margin-top:8px;padding-top:8px;border-top:1px dashed #e4e7eb';
wrapEl.appendChild(el('div', 'gt-hint', '跨页进度:' + Session.summary()));
if (d.pending) wrapEl.appendChild(el('div', 'gt-hint', '等待下一页…'));
box.appendChild(wrapEl);
}
/* ═══════════════════════════════════════════════════════════════════════
10. 主流程
═══════════════════════════════════════════════════════════════════════ */
function inScope() {
const wl = (state.scopeWhitelist || '').trim();
if (!wl) return true;
const host = location.hostname.toLowerCase();
return wl.split(/[,,\s]+/).filter(Boolean).some(function (d) {
d = d.toLowerCase().replace(/^\*\./, '');
return host === d || host.endsWith('.' + d) || host.endsWith(d);
});
}
let lastRiskLevel = null;
function runScan(manual) {
const questions = extractQuestions();
const risk = RiskScanner.scan(questions);
renderRisk(risk);
const levelChanged = lastRiskLevel !== risk.level;
lastRiskLevel = risk.level;
if (manual || levelChanged) {
if (risk.level === 'high') {
notify('high', '检测到高风险反作弊',
'共 ' + risk.findings.length + ' 项:' +
risk.findings.slice(0, 3).map(function (f) { return f.title; }).join(';') +
'\n已强制切换为「仅生成建议」,不会自动填写或提交。');
} else if (risk.level === 'medium') {
notify('medium', '检测到中风险反作弊',
'共 ' + risk.findings.length + ' 项:' +
risk.findings.slice(0, 3).map(function (f) { return f.title; }).join(';') +
'\n已禁用自动提交,需人工复核后手动提交。');
} else if (manual) {
notify('low', '扫描完成', '未发现明显反作弊措施。');
}
}
return risk;
}
/**
* 通用信息弹窗。
* 走 Shadow DOM + textContent,和审查弹窗同一套机制:
* 内容一律不当 HTML 解析,页面上的恶意文本也注入不进来。
*/
function openModal(title, bodyText) {
return withoutProbe(function () {
const host = document.createElement('div');
host.setAttribute('data-gt', 'v3-modal');
host.style.cssText = 'all:initial;position:fixed;z-index:2147483003;';
const root = host.attachShadow({ mode: 'open' });
const st = document.createElement('style');
st.textContent = CSS;
root.appendChild(st);
const wrap = el('div', 'gt-wrap');
root.appendChild(wrap);
const overlay = el('div', 'gt-modal');
wrap.appendChild(overlay);
const box = el('div', 'box');
overlay.appendChild(box);
box.appendChild(el('h3', null, String(title)));
const pre = document.createElement('pre');
pre.textContent = String(bodyText || '');
box.appendChild(pre);
const btnRow = el('div', 'gt-btnrow');
const btnCopy = el('button', 'gt-btn ghost', '复制');
const btnClose = el('button', 'gt-btn', '关闭');
btnRow.appendChild(btnCopy);
btnRow.appendChild(btnClose);
box.appendChild(btnRow);
function close() {
try { host.remove(); } catch (e) { /* ignore */ }
}
btnClose.addEventListener('click', close);
overlay.addEventListener('click', function (e) {
if (e.target === overlay) close();
});
btnCopy.addEventListener('click', function () {
copyText(String(bodyText || '')).then(function (ok) {
toast(ok ? 'low' : 'medium', ok ? '已复制' : '复制失败',
ok ? '内容已复制到剪贴板' : '请手动选中复制');
});
});
document.body.appendChild(host);
return host;
});
}
function showReview(questions, answers, policy, onConfirm) {
return withoutProbe(function () {
const host = document.createElement('div');
host.setAttribute('data-gt', 'v3-review');
host.style.cssText = 'all:initial;position:fixed;z-index:2147483003;';
const root = host.attachShadow({ mode: 'open' });
const st = document.createElement('style');
st.textContent = CSS;
root.appendChild(st);
const wrap = el('div', 'gt-wrap');
root.appendChild(wrap);
const overlay = el('div', 'gt-modal');
wrap.appendChild(overlay);
const box = el('div', 'box');
overlay.appendChild(box);
box.appendChild(el('h3', null, '审查 AI 答案(' + answers.length + ' 题)'));
if (policy.reasons.length) {
const warn = el('div', 'gt-risk ' + (policy.blocked ? 'high' : 'medium'));
warn.appendChild(el('div', 't', '已自动降低自动化程度'));
const ul = document.createElement('ul');
policy.reasons.forEach(function (r) {
ul.appendChild(el('li', null, r));
});
warn.appendChild(ul);
box.appendChild(warn);
}
// 用 textContent 渲染,杜绝存储型 XSS
const pre = document.createElement('pre');
const lines = questions.map(function (q, i) {
const a = answers[i];
const av = a === undefined ? '(无)'
: (Array.isArray(a) ? a.join(' + ') : String(a));
return '[' + (i + 1) + '] ' + q.title + '\n → ' + av;
});
pre.textContent = lines.join('\n');
box.appendChild(pre);
const actions = el('div', 'actions');
const okBtn = el('button', 'gt-btn ok', policy.blocked ? '我知道了' : '确认填写');
const cancelBtn = el('button', 'gt-btn ghost', '取消');
actions.appendChild(okBtn);
actions.appendChild(cancelBtn);
box.appendChild(actions);
document.documentElement.appendChild(host);
function close() {
if (host.parentElement) host.parentElement.removeChild(host);
}
okBtn.addEventListener('click', function () { close(); onConfirm(true); });
cancelBtn.addEventListener('click', function () { close(); onConfirm(false); });
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); onConfirm(false); }
});
});
}
function showResult(report) {
return withoutProbe(function () {
const host = document.createElement('div');
host.setAttribute('data-gt', 'v3-result');
host.style.cssText = 'all:initial;position:fixed;z-index:2147483003;';
const root = host.attachShadow({ mode: 'open' });
const st = document.createElement('style');
st.textContent = CSS;
root.appendChild(st);
const wrap = el('div', 'gt-wrap');
root.appendChild(wrap);
const overlay = el('div', 'gt-modal');
wrap.appendChild(overlay);
const box = el('div', 'box');
overlay.appendChild(box);
box.appendChild(el('h3', null, '填写结果'));
box.appendChild(el('div', null, '成功填写 ' + report.filled + ' 题。'));
if (report.failed.length) {
box.appendChild(el('div', 'gt-risk medium', '有 ' + report.failed.length + ' 题未能自动填写,需要你手动处理:'));
const ul = el('ul', 'gt-list');
report.failed.slice(0, 30).forEach(function (f) {
ul.appendChild(el('li', null,
'第 ' + (f.index + 1) + '题:' + String(f.title).slice(0, 40) +
'(' + f.reason + ')'));
});
box.appendChild(ul);
}
const actions = el('div', 'actions');
const closeBtn = el('button', 'gt-btn', '关闭');
actions.appendChild(closeBtn);
box.appendChild(actions);
document.documentElement.appendChild(host);
function close() { if (host.parentElement) host.parentElement.removeChild(host); }
closeBtn.addEventListener('click', close);
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
});
}
let running = false;
/**
* 处理「单页」:提取 → 扫描 → 问 AI → 按策略填写 → 返回报告
* @param {Object} opts { autoConfirm: 跳过确认弹窗, questions: 复用已提取的题目 }
*/
async function runPage(opts) {
opts = opts || {};
const questions = opts.questions && opts.questions.length
? opts.questions
: extractQuestions();
if (!questions.length) {
notify('medium', '未提取到题目',
'页面可能尚未加载完成,或题目在 iframe / 动态渲染容器内,脚本暂无法处理。');
return null;
}
// 跨页场景把前面几页的题干一起送去比对,才能发现跨页的一致性校验题
const histTitles = (state.crossPage && Session.data) ? (Session.data.titles || []) : [];
const risk = RiskScanner.scan(questions, histTitles);
renderRisk(risk);
lastRiskLevel = risk.level;
const policy = resolvePolicy(risk);
if (policy.downgraded && !opts.silentDowngrade) {
notify(risk.level, '已根据反作弊检测结果自动降级', policy.reasons.join(';'));
}
const answers = await answerAll(questions, opts.onProgress || function () {});
// 高风险 / 仅建议模式:不碰页面,答案直接渲染到面板(不再弹窗)
if (policy.mode === 'manual') {
Log.info('仅建议模式', '共 ' + questions.length + ' 题,未修改页面');
renderAnswers(questions, answers, policy);
bindCopyAll(questions, answers);
notify(risk.level === 'high' ? 'high' : 'low',
'AI 答案已生成(仅建议)',
'共 ' + questions.length + ' 题,已在独立答案窗口列出,可逐条或全部复制。',
'info');
return { filled: 0, failed: [], blocked: true, questions: questions, answers: answers, policy: policy };
}
// 填写进度回调:面板上实时显示"第 N/M 题"
const fillOpts = {
token: Human.begin(),
onProgress: function (done, total, title) {
if (ui.setStatus) {
ui.setStatus(done >= total ? '' : '正在填写 ' + done + '/' + total +
(title ? ':' + String(title).slice(0, 22) : ''));
}
}
};
// 半自动:需要人工确认(跨页续填时可跳过)
if (policy.mode === 'semi' && !opts.autoConfirm) {
const go = await new Promise(function (resolve) {
showReview(questions, answers, policy, function (confirmed) {
resolve(!!confirmed);
});
});
if (!go) {
notify('low', '已取消', '未修改页面。');
return null;
}
}
const report = await fillAnswers(questions, answers, fillOpts);
Object.assign(report, { questions: questions, answers: answers, policy: policy });
return report;
}
/** 填写完当前页后,判断是否还有下一页并推进 */
async function advanceOrFinish(report, key) {
const policy = report.policy;
const crossOn = state.crossPage && !policy.blocked;
if (!crossOn) {
// 单页模式:常规结束流程
showResult(report);
notify('low', '填写完成',
'成功 ' + report.filled + ' 题,失败 ' + report.failed.length + ' 题。');
maybeAutoSubmit(policy);
return;
}
const dup = Session.markPage(key, {
filled: report.filled,
total: (report.filled + report.failed.length),
title: document.title.slice(0, 60),
titles: (report.questions || []).map(function (q) { return q.title; })
});
// 循环保护
if (dup) {
notify('medium', '检测到重复页面,已停止跨页',
'该页面此前已处理过,继续推进可能陷入死循环。' + Session.summary());
showResult(report);
maybeAutoSubmit(policy);
return;
}
if (Session.pageCount() > CFG.maxPages) {
notify('medium', '已达跨页上限', '最多自动推进 ' + CFG.maxPages + ' 页,已停止。' + Session.summary());
showResult(report);
maybeAutoSubmit(policy);
return;
}
const nextBtn = state.crossPageNext ? findNextButton() : null;
const submitBtn = findSubmitButton();
// 没有下一页 → 说明这是最后一页
if (!nextBtn) {
Session.data.finished = true;
Session.save();
showResult(report);
notify('low', '所有页面填写完成', Session.summary() + '。已到最后一页。');
maybeAutoSubmit(policy);
return;
}
notify('low', '本页完成,准备进入下一页',
Session.summary() + '。' + (state.crossPageNext ? '即将自动点击「下一页」' : '请手动点击「下一页」后脚本会继续'));
if (!state.crossPageNext) {
// 不自动点击:标记待继续,手动翻页后由 URLWatcher / 重新注入接管
Session.setPending(true, state.crossPageAuto);
showResult(report);
return;
}
// 标记 pending:无论整页跳转还是 SPA,后续都由 pending 驱动续接。
// 这里刻意不主动等待或调用下一页的处理逻辑——换页检测统一交给
// URLWatcher(SPA)和新页面的 boot(整页跳转),避免两处同时驱动造成重复填写。
Session.setPending(true, state.crossPageAuto);
const fromHref = location.href;
try { nextBtn.click(); } catch (e) {
try { nextBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); } catch (e2) {}
}
// 兜底:一段时间后若 pending 仍在且 URL 没变,说明点击没生效
setTimeout(function () {
try {
const d = Session.data;
if (!d || !d.pending) return; // 已成功换页,交给续接流程
if (location.href !== fromHref) return; // URL 已变但续接还没跑
Session.setPending(false);
notify('medium', '未检测到页面跳转',
'「下一页」按钮可能未生效,或该站点使用了脚本无法识别的换页方式。' +
Session.summary());
showResult(report);
maybeAutoSubmit(policy);
} catch (e) { /* ignore */ }
}, CFG.navTimeout);
void submitBtn;
}
function maybeAutoSubmit(policy) {
if (!policy.autoSubmit || policy.blocked) return;
const btn = findSubmitButton();
if (!btn) {
notify('low', '未找到提交按钮', '请手动提交。');
return;
}
notify('medium', '即将自动提交', '3 秒后点击提交按钮,请确认无误。');
setTimeout(function () {
try { btn.click(); } catch (e) { /* ignore */ }
}, 3000);
}
/** 跨页续填入口:由 URLWatcher 或新页面注入时调用 */
async function continueCrossPage() {
if (running) return;
const sess = Session.load();
if (!sess) return;
const key = pageKey(location.href);
if (Session.isDone(key)) {
notify('low', '当前页面已处理过', '跳过,避免重复填写。' + Session.summary());
return;
}
if (Session.pageCount() >= CFG.maxPages) {
notify('medium', '已达跨页上限', '最多自动推进 ' + CFG.maxPages + ' 页。');
Session.setPending(false);
return;
}
running = true;
setBusy(true);
try {
const n = await waitForStableQuestions(6000);
if (!n) {
notify('low', '新页面未检测到题目', '可能已到完成页,跨页任务结束。' + Session.summary());
Session.setPending(false);
return;
}
const report = await runPage({
autoConfirm: !!(sess.autoConfirm),
silentDowngrade: true
});
if (!report) { Session.setPending(false); return; }
if (report.blocked) {
notify('high', '跨页任务中断',
'新页面检测到高风险反作弊,已停止自动填写。' + Session.summary(), 'warn');
Session.setPending(false);
return;
}
await advanceOrFinish(report, key);
} catch (err) {
Session.setPending(false);
notify('high', '跨页执行失败', String(err && err.message ? err.message : err).slice(0, 300));
console.error('[Gametame] 跨页失败', err);
} finally {
running = false;
setBusy(false);
}
}
function setBusy(busy) {
const btn = ui.btnRun;
if (!btn) return;
if (busy) {
if (!btn.dataset.old) btn.dataset.old = btn.textContent;
btn.textContent = '处理中…';
btn.disabled = true;
} else {
btn.textContent = btn.dataset.old || '执行';
btn.disabled = false;
}
}
async function runOnce() {
if (running) return;
Human.begin(); // 新一轮填写,使上一轮残留的中止状态失效
if (!inScope()) {
notify('medium', '当前站点不在白名单',
location.hostname + ' 未匹配白名单,已拒绝执行。可在面板中修改白名单。');
return;
}
if (!(state.apiKey || '').trim()) {
notify('medium', '缺少 API Key', '请先在面板中填入 API Key。');
if (ui.setPanel) ui.setPanel(true);
return;
}
// 手动点击 = 开一次新的跨页会话
if (state.crossPage) {
Session.start();
Session.data.autoConfirm = !!state.crossPageAuto;
Session.save();
} else {
Session.clear();
}
running = true;
setBusy(true);
try {
const key = pageKey(location.href);
const report = await runPage({
onProgress: function (msg) {
if (ui.btnRun) ui.btnRun.textContent = msg;
}
});
if (!report) return;
if (report.blocked) {
Session.setPending(false);
return;
}
await advanceOrFinish(report, key);
} catch (err) {
notify('high', '执行失败', String(err && err.message ? err.message : err).slice(0, 400));
console.error('[Gametame] ', err);
} finally {
running = false;
setBusy(false);
}
}
/** 中止当前跨页任务(菜单命令用) */
function abortCrossPage() {
Session.clear();
Human.abort(); // 中止进行中的逐字输入
running = false;
setBusy(false);
renderSessionBar(ui.sessBox);
renderSessionBar(ui.riskBox);
notify('low', '已中止', '跨页任务已停止,本地进度已清除。');
}
/** 用一道最小题目验证 Key / 模型 / 端点是否可用 */
function testConnection() {
const provider = currentProvider();
const key = (state.apiKey || '').trim();
if (!key) {
notify('medium', '缺少 API Key', '请先在「AI」标签页填入 Key。');
if (ui.switchTab) ui.switchTab('ai');
return;
}
const btn = ui.root && ui.root.querySelector ? null : null;
notify('low', '正在测试…', provider.name + ' / ' + currentModel());
requestAI(provider, currentModel(), key,
'请只回复两个字:OK')
.then(function () {
notify('low', '连接正常',
provider.name + '(' + currentModel() + ')可正常调用。');
})
.catch(function (err) {
notify('high', '连接失败', String(err && err.message ? err.message : err).slice(0, 300));
});
void btn;
}
/* ═══════════════════════════════════════════════════════════════════════
11. 启动
═══════════════════════════════════════════════════════════════════════ */
/**
* 全局快捷键。
* 面板被拖到屏幕角落或收起时,用键盘比找悬浮球快。
* 注意避开浏览器保留组合(Alt+Q 在部分环境会触发关机提示,故改用 Shift+Alt)。
*/
function installHotkeys(handlers) {
document.addEventListener('keydown', function (e) {
// 正在输入时不拦截,否则打字会触发快捷键
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (!e.altKey || e.ctrlKey || e.metaKey) return;
const k = String(e.key || '').toLowerCase();
if (k === 'q') { e.preventDefault(); handlers.togglePanel(); }
else if (k === 'w') { e.preventDefault(); handlers.run(); }
else if (k === 'e') { e.preventDefault(); handlers.preview(); }
});
}
function boot() {
try {
if (document.body) buildUI();
else return;
} catch (e) {
console.error('[Gametame] UI 初始化失败', e);
return;
}
Session.load();
if (state.debug) exposeDebug();
/* ── 路径 A:整页跳转后的续接 ──
上一次脚本在销毁前把 pending 写进了存储,新页面读到了就自动接着跑。 */
if (state.crossPage && Session.pendingValid() && inScope()) {
Session.setPending(false);
notify('low', '检测到跨页任务,正在继续', '页面已跳转,等待渲染完成后自动填写本页…');
setTimeout(function () {
continueCrossPage().catch(function (e) {
console.error('[Gametame] 续接失败', e);
});
}, CFG.afterNavDelay);
} else if (Session.data && Session.data.pending) {
// pending 已过期(例如手动刷新),清掉避免误触发
Session.setPending(false);
}
/* ── 路径 B:SPA 换页 ── */
URLWatcher.install(function (from, to) {
if (!state.crossPage) return;
if (pageKey(from) === pageKey(to)) return; // 只是参数变化,不算换页
if (!Session.data) return;
if (!Session.data.pending) return;
Session.setPending(false);
notify('low', '检测到页面切换(SPA)', '正在处理新页面…');
setTimeout(function () {
continueCrossPage().catch(function (e) {
console.error('[Gametame] SPA 续接失败', e);
});
}, CFG.afterNavDelay);
});
// 首屏自动扫描(延迟,等问卷平台渲染完成)
setTimeout(function () {
try {
if (!inScope()) return;
const risk = runScan(false);
if (risk.level !== 'low' && ui.setPanel) {
// 有风险时自动展开面板,确保能看到
ui.setPanel(true);
}
// 检测到多页问卷但未开启跨页 → 提示一次,省得每页手点
if (!state.crossPage && Session.pageCount() === 0 && findNextButton()) {
notify('low', '检测到多页问卷',
'到「跨页」标签页开启自动续填,填完本页会自动进入下一页继续,无需逐页点击执行。');
}
} catch (e) {
console.error('[Gametame] 首次扫描失败', e);
}
}, 1500);
try {
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('重新扫描反作弊', function () { runScan(true); });
GM_registerMenuCommand('执行问卷填写', function () { runOnce(); });
GM_registerMenuCommand('中止跨页任务', function () { abortCrossPage(); });
GM_registerMenuCommand('查看跨页进度', function () {
Session.load();
notify('low', '跨页进度',
Session.data ? Session.summary() : '当前没有进行中的跨页任务。');
});
GM_registerMenuCommand('清除本地配置(含 Key)', function () {
['provider', 'model', 'apiKey', 'persistKey', 'customBase', 'mode',
'autoSubmit', 'scopeWhitelist', 'crossPage', 'crossPageAuto',
'crossPageNext'].forEach(function (k) { Store.del(k); });
Session.clear();
notify('low', '已清除', '本地保存的配置已删除,刷新页面后生效。');
});
}
} catch (e) { /* ignore */ }
console.log(SCRIPT_NAME + ' 已启动。点击右下角齿轮打开面板。');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();