// ==UserScript==
// @name 问卷星答题配置助手(增强版)
// @namespace https://wjx.panel.local
// @version 0.1.0
// @description 问卷星填写辅助工具:识别常见题型,按比例或次数配置选项,支持评分星级、关联题、多页填写、预览、配置导入导出和失败恢复。自动批量提交可能触发网站风控,请仅在获得授权的问卷中使用。
// @author 星夜
// @original-author x7R2p9、yangwenren
// @original-license MIT
// @original-script https://scriptcat.org/zh-CN/script-show-page/6321
// @original-script https://greasyfork.org/zh-CN/scripts/569375-%E9%80%9F%E6%98%9F-%E9%97%AE%E5%8D%B7%E6%98%9F%E8%87%AA%E5%8A%A8%E7%AD%94%E9%A2%98%E5%8A%A9%E6%89%8B-%E5%8F%AF%E8%A7%86%E5%8C%96%E6%AF%94%E4%BE%8B%E9%85%8D%E7%BD%AE-%E7%A0%B4%E8%A7%A3%E5%A4%8D%E5%88%B6%E9%99%90%E5%88%B6-%E6%89%B9%E9%87%8F%E5%BE%AA%E7%8E%AF%E6%8F%90%E4%BA%A4-%E8%BE%85%E5%8A%A9%E7%BB%95%E8%BF%87%E9%AA%8C%E8%AF%81
// @match https://*.wjx.top/*
// @match https://*.wjx.cn/*
// @match https://*.wjx.com/*
// @grant none
// @run-at document-end
// @license MIT
// @tag 问卷星
// @tag 自动填写
// @tag 配置工具
// @tag 批量提交
// @antifeature 自动批量提交
// @antifeature 生成式随机答案可能造成低质量或重复回答
// ==/UserScript==
(function() {
'use strict';
const PANEL_ID = 'wjx-commercial-panel';
const STORAGE_PREFIX = 'WJX_CN_PANEL_';
const REMAINING_COUNT_KEY = 'WJX_REMAINING_COUNT';
const TOTAL_COUNT_KEY = 'WJX_TOTAL_COUNT';
const SURVEY_URL_KEY = 'WJX_SURVEY_URL';
const PANEL_COLLAPSED_KEY = 'WJX_PANEL_COLLAPSED_BEFORE_AUTOMATION';
const QUOTA_STATE_KEY = 'WJX_COUNT_QUOTA_STATE';
const AUTOMATION_STOPPED_KEY = 'WJX_AUTOMATION_STOPPED';
const CONFIG_EXPORT_VERSION = 2;
const MAX_AUTO_PAGES = 80;
const PLACEHOLDER_OPTION_PATTERN = /请选择|请选择项|请选取|please\s*select/i;
const QUESTION_NODE_SELECTORS = [
'.field.ui-field-contain',
'.div_question',
'.div_table_radio_question',
'.ui-field-contain'
];
const DEFAULT_TEXT_LIBRARY = [
'很好', '满意', '支持', '体验不错', '整体不错',
'符合预期', '使用方便', '界面友好', '响应及时',
'比较顺畅', '总体可以', '没什么问题', '值得推荐',
'功能齐全', '运行稳定', '体验良好', '界面清晰',
'操作简单', '服务周到', '效果不错'
];
function logWarning(label, error) {
try {
console.warn(`[问卷星助手] ${label}:`, error);
} catch (e) {}
}
const TYPE_LABELS = {
radio: '单选题',
checkbox: '多选题',
dropdown: '下拉题',
text: '填空题',
rating: '评分题',
matrix: '矩阵题',
slide: '滑块题',
location: '地区题',
sorting: '排序题',
unknown: '未知题型'
};
const State = {
surveyKey: '',
config: {
targetCount: 1,
distributionMode: 'ratio',
speedProfile: 'normal',
questions: []
}
};
const STYLE_TEXT = `
:root {
--wjx-bg: #f5f9ff;
--wjx-surface: #ffffff;
--wjx-surface-strong: #ffffff;
--wjx-primary: #0064ff;
--wjx-primary-deep: #0052d9;
--wjx-primary-soft: #edf4ff;
--wjx-line: #d7e6ff;
--wjx-text: #1f2d3d;
--wjx-subtle: #5f6f82;
--wjx-shadow: 0 10px 28px rgba(23, 80, 179, 0.10);
}
#${PANEL_ID} {
position: fixed;
top: 16px;
right: 16px;
width: 402px;
min-width: 320px;
max-width: min(560px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
z-index: 2147483647;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 10px;
border: 1px solid var(--wjx-line);
background: var(--wjx-surface);
color: var(--wjx-text);
box-shadow: var(--wjx-shadow);
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
#${PANEL_ID}.is-collapsed {
width: 240px;
}
#${PANEL_ID}.is-automation-hidden {
display: none !important;
}
.wjx-head {
padding: 16px;
background: linear-gradient(180deg, #f8fbff, #edf4ff);
color: var(--wjx-text);
border-bottom: 1px solid var(--wjx-line);
border-top: 3px solid var(--wjx-primary);
}
.wjx-head-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.wjx-title {
margin: 0;
font-size: 18px;
font-weight: 700;
letter-spacing: 0;
}
.wjx-author {
margin-top: 4px;
font-size: 12px;
font-weight: 700;
color: var(--wjx-subtle);
}
.wjx-toggle {
border: 1px solid #b9d2ff;
border-radius: 6px;
padding: 6px 12px;
font-size: 11px;
font-weight: 700;
color: var(--wjx-primary-deep);
background: #ffffff;
cursor: pointer;
flex-shrink: 0;
}
.wjx-body {
padding: 16px;
overflow-y: auto;
background: var(--wjx-bg);
}
.wjx-section {
margin-bottom: 14px;
padding: 14px;
border-radius: 8px;
background: var(--wjx-surface);
border: 1px solid var(--wjx-line);
}
.wjx-section-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 700;
color: var(--wjx-primary-deep);
}
.wjx-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.wjx-metric {
padding: 12px;
border-radius: 8px;
background: var(--wjx-surface-strong);
border: 1px solid var(--wjx-line);
}
.wjx-metric span {
display: block;
}
.wjx-metric-label {
font-size: 11px;
color: var(--wjx-subtle);
margin-bottom: 4px;
}
.wjx-metric-value {
font-size: 18px;
font-weight: 800;
}
.wjx-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.wjx-field label {
font-size: 12px;
font-weight: 700;
}
.wjx-input,
.wjx-textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid #c9dbff;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
color: var(--wjx-text);
background: #ffffff;
outline: none;
}
.wjx-input:focus,
.wjx-textarea:focus {
border-color: #7fb0ff;
box-shadow: 0 0 0 3px rgba(0,100,255,0.10);
}
.wjx-textarea {
min-height: 88px;
resize: vertical;
line-height: 1.5;
}
.wjx-mode-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.wjx-mode-btn {
min-height: 36px;
border-radius: 6px;
padding: 0 10px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
color: var(--wjx-text);
background: #ffffff;
border: 1px solid #c9dbff;
}
.wjx-mode-btn.is-active {
color: #ffffff;
background: var(--wjx-primary);
border-color: var(--wjx-primary);
}
.wjx-mode-hint {
margin: 0 0 10px;
font-size: 11px;
line-height: 1.5;
color: var(--wjx-subtle);
}
.wjx-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.wjx-btn {
min-height: 40px;
border-radius: 6px;
padding: 0 12px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.wjx-btn:hover {
filter: none;
}
.wjx-btn-primary {
color: #ffffff;
background: var(--wjx-primary);
border: 1px solid var(--wjx-primary);
}
.wjx-btn-secondary {
color: var(--wjx-text);
background: #ffffff;
border: 1px solid #c9dbff;
}
.wjx-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.wjx-card {
padding: 14px;
border-radius: 8px;
background: var(--wjx-surface-strong);
border: 1px solid var(--wjx-line);
}
.wjx-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
}
.wjx-card-title {
font-size: 13px;
font-weight: 800;
line-height: 1.5;
}
.wjx-card-type {
flex-shrink: 0;
padding: 5px 9px;
border-radius: 6px;
background: var(--wjx-primary-soft);
color: var(--wjx-primary-deep);
font-size: 11px;
font-weight: 700;
border: 1px solid #cfe0ff;
}
.wjx-card-meta {
margin-bottom: 10px;
color: var(--wjx-subtle);
font-size: 11px;
line-height: 1.45;
}
.wjx-count-hint {
margin: 0 0 8px;
font-size: 12px;
font-weight: 700;
color: var(--wjx-primary-deep);
line-height: 1.45;
}
.wjx-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.wjx-grid-item {
padding: 8px;
border-radius: 6px;
background: #f7fbff;
border: 1px solid #deebff;
}
.wjx-grid-item span {
display: block;
margin-bottom: 6px;
font-size: 11px;
color: var(--wjx-subtle);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wjx-grid-item input {
width: 100%;
box-sizing: border-box;
border: 1px solid #c9dbff;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
background: #ffffff;
}
.wjx-tools {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 10px;
}
.wjx-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 8px;
margin-bottom: 10px;
}
.wjx-save-status {
min-height: 18px;
margin-top: 8px;
font-size: 11px;
color: var(--wjx-subtle);
}
.wjx-save-status.is-saved { color: #17864b; }
.wjx-save-status.is-error { color: #d4380d; }
.wjx-weight-bar-wrap {
height: 4px;
margin-top: 6px;
overflow: hidden;
resize: horizontal;
background: #e8f1ff;
border-radius: 2px;
}
.wjx-weight-bar {
width: 0;
height: 100%;
background: var(--wjx-primary);
transition: width 0.18s ease;
}
.wjx-weight-summary {
margin-top: 8px;
font-size: 11px;
color: var(--wjx-subtle);
}
.wjx-card.is-disabled { opacity: 0.58; }
.wjx-card.is-filtered { display: none; }
.wjx-card-error { border-color: #ff9c9c; }
.wjx-preview-highlight {
outline: 3px solid #ffb020 !important;
outline-offset: 3px;
background-color: rgba(255, 176, 32, 0.08) !important;
}
.wjx-progress-actions {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
.wjx-progress-pause,
.wjx-progress-skip {
min-height: 34px;
padding: 0 12px;
border-radius: 6px;
border: 1px solid #b9d2ff;
background: #f5f9ff;
color: var(--wjx-primary-deep);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.wjx-chip {
border: 1px solid #cfe0ff;
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
font-weight: 700;
color: var(--wjx-primary-deep);
background: var(--wjx-primary-soft);
cursor: pointer;
}
.wjx-empty {
text-align: center;
padding: 24px 16px;
color: var(--wjx-subtle);
line-height: 1.7;
}
.wjx-toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 2147483647;
max-width: 320px;
padding: 12px 14px;
border-radius: 8px;
color: #ffffff;
background: rgba(0, 82, 217, 0.94);
box-shadow: 0 12px 28px rgba(0, 82, 217, 0.20);
font-size: 13px;
line-height: 1.5;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.18s ease, transform 0.18s ease;
pointer-events: none;
}
.wjx-toast.is-actionable {
max-width: 300px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.6;
background: rgba(19, 50, 104, 0.96);
box-shadow: 0 10px 24px rgba(19, 50, 104, 0.18);
pointer-events: auto;
cursor: pointer;
}
.wjx-toast .wjx-toast-link {
color: #ffd36b;
font-weight: 700;
}
.wjx-toast.is-visible {
opacity: 1;
transform: translateY(0);
}
.wjx-progress-modal {
position: fixed;
top: 16px;
left: 50%;
z-index: 2147483647;
min-width: 240px;
padding: 14px 18px;
border-radius: 10px;
border: 1px solid #b9d2ff;
background: rgba(255, 255, 255, 0.98);
color: var(--wjx-text);
box-shadow: 0 12px 32px rgba(0, 82, 217, 0.18);
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
text-align: center;
opacity: 0;
transform: translate(-50%, -8px);
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
}
.wjx-progress-modal.is-visible {
opacity: 1;
transform: translate(-50%, 0);
pointer-events: auto;
}
.wjx-progress-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
color: var(--wjx-primary-deep);
}
.wjx-progress-count {
margin: 0 0 10px;
font-size: 16px;
font-weight: 800;
color: var(--wjx-text);
}
.wjx-progress-bar-wrap {
height: 8px;
margin-bottom: 8px;
border-radius: 999px;
background: #e8f1ff;
overflow: hidden;
}
.wjx-progress-bar {
height: 100%;
width: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--wjx-primary), #3d8bff);
transition: width 0.25s ease;
}
.wjx-progress-percent {
margin: 0;
font-size: 12px;
font-weight: 700;
color: var(--wjx-subtle);
}
.wjx-progress-stop {
margin-top: 12px;
min-height: 34px;
padding: 0 16px;
border-radius: 6px;
border: 1px solid #ffb4b4;
background: #fff5f5;
color: #d4380d;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.wjx-progress-stop:hover {
background: #ffece8;
}
.wjx-hidden {
display: none;
}
.wjx-file-input {
display: none;
}
@media (max-width: 640px) {
#${PANEL_ID} {
top: auto;
right: 10px;
left: 10px;
bottom: 10px;
width: auto;
min-width: 0;
max-width: none;
max-height: 82vh;
resize: none;
}
.wjx-metrics,
.wjx-actions,
.wjx-grid,
.wjx-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
`;
function init() {
// 破解右键、选择和复制限制
try {
document.oncontextmenu = () => true;
document.onselectstart = () => true;
document.oncopy = () => true;
document.onpaste = () => true;
if (window.$ && window.$.fn) {
$('body').css('user-select', 'text');
}
} catch (e) {
logWarning('解除复制限制失败', e);
}
State.surveyKey = createSurveyKey();
if (isCompletionPage()) {
handleCompletion();
return;
}
if (document.getElementById(PANEL_ID)) {
return;
}
injectStyles();
renderPanel();
refreshQuestions({ silent: true, preserveInputs: false });
updateAllRelationVisibility();
scheduleRescan();
checkAndContinueAutomation();
}
function isCompletionPage() {
// 完成页判定:优先看 URL 特征,再兜底检测页面上的成功标志元素
if (/complete\w*\.aspx|join\/complete|\/done|result\.aspx|wjx.*\/(done|finish|thank)/i.test(location.href)) {
return true;
}
// 关键排除:若页面上仍存在可填写的题目节点,则一定不是完成页。
// 否则问卷填首页会因 title 残留(如“已完成/结束”)或 DOM 残留(.success-tip 等)
// 被误判为完成页,导致多份续跑时 handleCompletion 反复把 remaining 减一却不真正填写,
// 表现为“填了一份就不动了”。
if (findQuestionNodes().some((node) => isNodeVisible(node))) {
return false;
}
// title 兜底去掉过于宽泛的“结束|已完成”——它们极易在填写页命中;
// 保留明确表示提交成功的措辞。
if (/感谢您的参与|提交成功|谢谢您的填写|问卷已结束/.test(document.title || '')) {
return true;
}
return !!document.querySelector('#QEnd, .QEnd, .endtext, .div_end, .success-tip');
}
function getAutomationSessionKey() {
return REMAINING_COUNT_KEY + '_' + State.surveyKey;
}
function loadAutomationSession() {
try {
const raw = localStorage.getItem(REMAINING_COUNT_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return null;
}
// 完成页通常会切换 pathname,此时仍需读取原问卷的自动化会话。
// 普通题目页继续严格校验,避免不同问卷之间串扰。
if (parsed.surveyKey && State.surveyKey && parsed.surveyKey !== State.surveyKey && !isCompletionPage()) {
return null;
}
return parsed;
} catch (error) {
return null;
}
}
function saveAutomationSession(session) {
try {
localStorage.setItem(REMAINING_COUNT_KEY, JSON.stringify(session));
} catch (error) {
logWarning('保存自动化会话失败', error);
}
}
function scheduleNextSubmissionRedirect(session) {
const url = session && (session.url || localStorage.getItem(SURVEY_URL_KEY));
if (!url) {
endAutomationSession('缺失问卷地址,无法续跑下一份');
return false;
}
clearTimeout(automationRedirectTimer);
automationRedirectTimer = setTimeout(() => {
automationRedirectTimer = null;
if (!isAutomationStillActive()) {
return;
}
location.href = url;
}, 2000);
return true;
}
function handleCompletion() {
// 用户已停止:即使到达完成页也不要再减份数/跳转续跑
if (isAutomationAborted()) {
clearAutomationStorage();
return;
}
if (isAutomationPaused()) {
showAutomationProgress();
return;
}
const session = loadAutomationSession();
if (!session) {
return;
}
let remaining = Math.max(0, Number(session.remaining) || 0);
if (session.completionHandled) {
if (remaining > 0) {
hidePanelForAutomation();
showAutomationProgress();
scheduleNextSubmissionRedirect(session);
}
return;
}
if (remaining > 0) {
remaining -= 1;
session.remaining = remaining;
session.retryCount = 0;
session.lastError = '';
session.attemptQuotaSnapshot = null;
session.completionHandled = true;
saveAutomationSession(session);
if (remaining > 0) {
ensureTotalCountForAutomation();
hidePanelForAutomation();
showAutomationProgress();
scheduleNextSubmissionRedirect(session);
} else {
endAutomationSession('所有自动化提交任务已完成!');
}
updateAutomationProgress();
}
}
function checkAndContinueAutomation() {
// 跨页面持久化的停止标志:用户上次点过“停止提交”,
// 即便完成页→首页的 location.href 跳转把内存标志清零,这里仍能拦住续跑。
if (isAutomationAborted()) {
return;
}
const session = loadAutomationSession();
if (session && session.skipping) {
session.skipping = false;
saveAutomationSession(session);
}
if (session && session.paused) {
hidePanelForAutomation();
showAutomationProgress();
return;
}
const remaining = session ? Math.max(0, Number(session.remaining) || 0) : 0;
if (remaining > 0) {
if (session.completionHandled) {
session.completionHandled = false;
saveAutomationSession(session);
}
ensureTotalCountForAutomation();
ensureQuotaState();
// 双重保险:定时器触发前再确认一次未被停止
beginAutomationSession();
setTimeout(() => {
if (isAutomationInterrupted()) {
return;
}
executeAutoFillAndSubmit();
}, 2000);
}
}
function createSurveyKey() {
return STORAGE_PREFIX + `${location.host}${location.pathname}`.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function injectStyles() {
const style = document.createElement('style');
style.textContent = STYLE_TEXT;
document.head.appendChild(style);
}
function renderPanel() {
const panel = document.createElement('aside');
panel.id = PANEL_ID;
panel.innerHTML = `
全局设置
比例模式:各选项数值为权重比例。
配置尚未修改
`;
document.body.appendChild(panel);
bindEvents(panel);
bindPanelDragging(panel);
}
function bindPanelDragging(panel) {
const head = panel.querySelector('.wjx-head');
if (!head) {
return;
}
let dragging = false;
let offsetX = 0;
let offsetY = 0;
head.addEventListener('pointerdown', (event) => {
if (event.target.closest('button, input, select')) {
return;
}
const rect = panel.getBoundingClientRect();
dragging = true;
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
head.setPointerCapture && head.setPointerCapture(event.pointerId);
});
head.addEventListener('pointermove', (event) => {
if (!dragging || window.matchMedia('(max-width: 640px)').matches) {
return;
}
const left = Math.max(8, Math.min(window.innerWidth - panel.offsetWidth - 8, event.clientX - offsetX));
const top = Math.max(8, Math.min(window.innerHeight - 80, event.clientY - offsetY));
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
});
const release = () => {
if (!dragging) {
return;
}
dragging = false;
try {
localStorage.setItem(`${State.surveyKey}_PANEL_POSITION`, JSON.stringify({
left: panel.style.left,
top: panel.style.top
}));
} catch (error) {}
};
head.addEventListener('pointerup', release);
head.addEventListener('pointercancel', release);
try {
const position = JSON.parse(localStorage.getItem(`${State.surveyKey}_PANEL_POSITION`) || 'null');
if (position && position.left && position.top && !window.matchMedia('(max-width: 640px)').matches) {
panel.style.left = position.left;
panel.style.top = position.top;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
}
} catch (error) {}
}
function bindEvents(panel) {
panel.querySelector('#wjx-toggle-panel').addEventListener('click', () => {
panel.classList.toggle('is-collapsed');
const body = panel.querySelector('#wjx-panel-body');
const button = panel.querySelector('#wjx-toggle-panel');
const collapsed = panel.classList.contains('is-collapsed');
body.classList.toggle('wjx-hidden', collapsed);
button.textContent = collapsed ? '展开' : '收起';
});
panel.querySelector('#wjx-target-count').addEventListener('input', (event) => {
State.config.targetCount = Math.max(1, Number(event.target.value) || 1);
updateCountModeHints();
scheduleConfigSave();
});
panel.querySelector('#wjx-speed-profile').addEventListener('change', (event) => {
State.config.speedProfile = ['slow', 'normal', 'fast'].includes(event.target.value)
? event.target.value
: 'normal';
scheduleConfigSave();
});
panel.addEventListener('input', (event) => {
if (event.target.matches('[data-role="weight-input"]')) {
updateWeightVisuals(event.target.closest('.wjx-card'));
if (isCountMode()) {
updateCountModeHints();
}
}
if (event.target.matches('[data-role="weight-input"], [data-role="content-editor"], [data-role="slide-min"], [data-role="slide-max"]')) {
scheduleConfigSave();
}
});
const searchInput = panel.querySelector('#wjx-question-search');
const typeFilter = panel.querySelector('#wjx-question-type-filter');
[searchInput, typeFilter].forEach((element) => {
if (element) {
element.addEventListener('input', applyQuestionFilters);
element.addEventListener('change', applyQuestionFilters);
}
});
panel.querySelector('#wjx-mode-ratio').addEventListener('click', () => {
setDistributionMode('ratio');
});
panel.querySelector('#wjx-mode-count').addEventListener('click', () => {
setDistributionMode('count');
});
panel.querySelector('#wjx-randomize-all').addEventListener('click', () => {
randomizeAllQuestions();
});
panel.querySelector('#wjx-rescan').addEventListener('click', () => {
refreshQuestions({ silent: false, preserveInputs: true });
});
panel.querySelector('#wjx-export-config').addEventListener('click', () => {
exportCurrentConfig();
});
panel.querySelector('#wjx-import-config').addEventListener('click', () => {
const fileInput = panel.querySelector('#wjx-import-file');
if (fileInput) {
fileInput.click();
}
});
panel.querySelector('#wjx-import-file').addEventListener('change', (event) => {
const file = event.target.files && event.target.files[0];
importConfigFromFile(file);
event.target.value = '';
});
panel.querySelector('#wjx-start-automation').addEventListener('click', () => {
syncStateFromDom();
if (!saveConfig()) {
showToast('配置无法保存,已停止启动以避免数据丢失。');
return;
}
startAutomation();
});
const previewBtn = panel.querySelector('#wjx-preview-fill');
if (previewBtn) {
previewBtn.addEventListener('click', async () => {
if (previewBtn.dataset.running === '1') {
return;
}
previewBtn.dataset.running = '1';
previewBtn.textContent = '正在预览填写…';
try {
await previewFillOnce();
} finally {
previewBtn.dataset.running = '';
previewBtn.textContent = '预览填写(不提交)';
}
});
}
const previewPlanBtn = panel.querySelector('#wjx-preview-plan');
if (previewPlanBtn) {
previewPlanBtn.addEventListener('click', async () => {
if (previewPlanBtn.dataset.running === '1') {
return;
}
previewPlanBtn.dataset.running = '1';
previewPlanBtn.textContent = '正在生成预览…';
try {
await previewSelectionPlan();
} finally {
previewPlanBtn.dataset.running = '';
previewPlanBtn.textContent = '预览选择(只高亮)';
}
});
}
panel.querySelector('#wjx-fill-to-submit').addEventListener('click', () => {
fillToSubmitAndPause();
});
panel.querySelector('#wjx-reset-config').addEventListener('click', () => {
resetCurrentSurveyConfig();
});
panel.querySelector('#wjx-clear-storage').addEventListener('click', () => {
clearCurrentSurveyStorage();
});
panel.addEventListener('click', (event) => {
const trigger = event.target.closest('[data-preset]');
if (trigger) {
applyPreset(trigger.closest('.wjx-card'), trigger.dataset.preset);
scheduleConfigSave();
return;
}
const action = event.target.closest('[data-action]');
if (action) {
handleQuestionAction(action.closest('.wjx-card'), action.dataset.action);
}
});
}
function refreshQuestions(options) {
const settings = Object.assign({ silent: false, preserveInputs: true }, options);
const previousQuestions = settings.preserveInputs ? collectCurrentQuestionsFromDom() : [];
const previousTargetCount = getCurrentTargetCount();
const savedConfig = loadConfig();
const scannedQuestions = scanQuestions();
State.config = mergeConfig(savedConfig, previousQuestions, previousTargetCount, scannedQuestions);
renderStats();
renderQuestionList();
updateCountModeHints();
const targetInput = document.getElementById('wjx-target-count');
if (targetInput) {
targetInput.value = String(State.config.targetCount);
}
const speedInput = document.getElementById('wjx-speed-profile');
if (speedInput) {
speedInput.value = ['slow', 'normal', 'fast'].includes(State.config.speedProfile)
? State.config.speedProfile
: 'normal';
}
updateDistributionModeUI();
if (shouldAutoRandomize(settings, previousQuestions, savedConfig, scannedQuestions)) {
randomizeAllQuestions({ silent: true });
}
updateAllRelationVisibility();
if (!settings.silent) {
const relationCount = scannedQuestions.filter((item) => Array.isArray(item.relations) && item.relations.length).length;
if (scannedQuestions.length) {
const relationTip = relationCount ? `,其中 ${relationCount} 道含题目关联` : '';
showToast(`已重新识别 ${scannedQuestions.length} 道题${relationTip}。`);
} else {
showToast('当前页面没有识别到可配置题目。');
}
}
}
function shouldAutoRandomize(settings, previousQuestions, savedConfig, scannedQuestions) {
if (!scannedQuestions.length) {
return false;
}
if (settings.preserveInputs && previousQuestions.length) {
return false;
}
if (hasConfiguredQuestions(savedConfig && savedConfig.questions, savedConfig && savedConfig.distributionMode)) {
return false;
}
return true;
}
function hasConfiguredQuestions(list, distributionMode) {
if (!Array.isArray(list) || !list.length) {
return false;
}
const countMode = isCountModeFromConfig({ distributionMode });
return list.some((question) => {
if (question.type === 'text') {
return Array.isArray(question.content) && question.content.length > 0;
}
if (question.type === 'slide') {
return clampScore(question.minScore, 1) !== 1 || clampScore(question.maxScore, 100) !== 100;
}
if (countMode) {
return Array.isArray(question.weights) && question.weights.some((weight) => Number(weight) > 0);
}
return Array.isArray(question.weights) && question.weights.some((weight) => Number(weight) > 1);
});
}
function mergeConfig(savedConfig, previousQuestions, previousTargetCount, scannedQuestions) {
const mergedQuestions = scannedQuestions.map((question) => {
const previous = findMatchingQuestion(previousQuestions, question) || findMatchingQuestion(savedConfig && savedConfig.questions, question);
if (!previous) {
return question;
}
const next = Object.assign({}, question);
next.enabled = previous.enabled !== false;
if (question.type === 'text') {
next.content = Array.isArray(previous.content) && previous.content.length ? previous.content.slice() : question.content.slice();
} else if (question.type === 'slide') {
next.minScore = clampScore(previous.minScore, question.minScore);
next.maxScore = clampScore(previous.maxScore, question.maxScore);
if (next.maxScore < next.minScore) {
next.maxScore = next.minScore;
}
} else if (Array.isArray(previous.weights) && previous.weights.length === question.weights.length) {
next.weights = previous.weights.slice();
}
return next;
});
const mode = normalizeDistributionMode(
(savedConfig && savedConfig.distributionMode) ||
State.config.distributionMode
);
return {
targetCount: Math.max(1, Number(previousTargetCount || (savedConfig && savedConfig.targetCount) || 1) || 1),
distributionMode: mode,
speedProfile: ['slow', 'normal', 'fast'].includes(savedConfig && savedConfig.speedProfile)
? savedConfig.speedProfile
: 'normal',
questions: mergedQuestions
};
}
function findMatchingQuestion(list, question) {
if (!Array.isArray(list)) {
return null;
}
return list.find((item) => String(item.id) === String(question.id) && item.type === question.type) || null;
}
function loadConfig() {
try {
return JSON.parse(localStorage.getItem(State.surveyKey) || 'null');
} catch (error) {
return null;
}
}
function saveConfig() {
try {
localStorage.setItem(State.surveyKey, JSON.stringify(buildConfigPayload()));
setSaveStatus('配置已保存', 'saved');
return true;
} catch (error) {
setSaveStatus('配置保存失败,请导出备份', 'error');
logWarning('保存配置失败', error);
return false;
}
}
let saveConfigTimer = null;
function setSaveStatus(message, state) {
const element = document.getElementById('wjx-save-status');
if (!element) {
return;
}
element.textContent = message;
element.classList.toggle('is-saved', state === 'saved');
element.classList.toggle('is-error', state === 'error');
}
function scheduleConfigSave() {
setSaveStatus('正在保存…', '');
clearTimeout(saveConfigTimer);
saveConfigTimer = setTimeout(() => {
syncStateFromDom();
saveConfig();
}, 350);
}
function buildConfigPayload() {
return {
targetCount: State.config.targetCount,
distributionMode: normalizeDistributionMode(State.config.distributionMode),
speedProfile: ['slow', 'normal', 'fast'].includes(State.config.speedProfile) ? State.config.speedProfile : 'normal',
questions: State.config.questions
};
}
function cloneQuestionForExport(question) {
const next = Object.assign({}, question);
if (Array.isArray(next.optionLabels)) {
next.optionLabels = next.optionLabels.slice();
}
if (Array.isArray(next.weights)) {
next.weights = next.weights.slice();
}
if (Array.isArray(next.content)) {
next.content = next.content.slice();
}
if (Array.isArray(next.relations)) {
next.relations = next.relations.map((relation) => Object.assign({}, relation));
}
return next;
}
function buildConfigExportSnapshot() {
syncStateFromDom();
return {
version: CONFIG_EXPORT_VERSION,
exportedAt: new Date().toISOString(),
surveyKey: State.surveyKey,
targetCount: State.config.targetCount,
distributionMode: normalizeDistributionMode(State.config.distributionMode),
speedProfile: State.config.speedProfile,
questions: State.config.questions.map((question) => cloneQuestionForExport(question))
};
}
function downloadJsonFile(filename, payload) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportCurrentConfig() {
syncStateFromDom();
if (!State.config.questions.length) {
showToast('当前没有可导出的题目配置。');
return;
}
const payload = buildConfigExportSnapshot();
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
downloadJsonFile(`wjx-config-${stamp}.json`, payload);
showToast('配置已导出为 JSON 文件。');
}
function normalizeImportedConfig(raw) {
if (!raw || typeof raw !== 'object') {
return null;
}
const questions = Array.isArray(raw.questions) ? raw.questions : null;
if (!questions || !questions.length) {
return null;
}
const allowedTypes = new Set(Object.keys(TYPE_LABELS));
const normalizedQuestions = questions.filter((question) => {
return question && question.id != null && allowedTypes.has(question.type);
}).map((question) => ({
id: String(question.id),
type: question.type,
enabled: question.enabled !== false,
weights: Array.isArray(question.weights)
? question.weights.slice(0, 100).map((value) => Math.max(0, Number.isFinite(Number(value)) ? Number(value) : 0))
: [],
content: Array.isArray(question.content)
? question.content.slice(0, 500).map((value) => String(value).trim()).filter(Boolean)
: [],
minScore: clampScore(question.minScore, 1),
maxScore: clampScore(question.maxScore, 100),
relations: Array.isArray(question.relations)
? question.relations.slice(0, 100).map((relation) => ({
topicId: String(relation && relation.topicId || ''),
optionIndex: Math.max(1, Math.floor(Number(relation && relation.optionIndex) || 1))
})).filter((relation) => relation.topicId)
: []
}));
if (!normalizedQuestions.length) {
return null;
}
return {
targetCount: Math.min(10000, Math.max(1, Number(raw.targetCount) || 1)),
distributionMode: normalizeDistributionMode(raw.distributionMode),
speedProfile: ['slow', 'normal', 'fast'].includes(raw.speedProfile) ? raw.speedProfile : 'normal',
questions: normalizedQuestions
};
}
function importConfigFromFile(file) {
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const raw = JSON.parse(String(reader.result || ''));
applyImportedConfig(raw);
} catch (error) {
showToast('配置文件解析失败,请检查 JSON 格式。');
}
};
reader.onerror = () => {
showToast('读取配置文件失败。');
};
reader.readAsText(file, 'utf-8');
}
function applyImportedConfig(raw) {
const imported = normalizeImportedConfig(raw);
if (!imported) {
showToast('配置文件格式无效,需包含 questions 数组。');
return;
}
const scannedQuestions = scanQuestions();
if (!scannedQuestions.length) {
showToast('当前页面未识别到题目,请先打开问卷题目页再导入。');
return;
}
State.config = mergeConfig(imported, [], imported.targetCount, scannedQuestions);
State.config.distributionMode = imported.distributionMode;
State.config.speedProfile = imported.speedProfile;
saveConfig();
renderStats();
renderQuestionList();
updateCountModeHints();
const targetInput = document.getElementById('wjx-target-count');
if (targetInput) {
targetInput.value = String(State.config.targetCount);
}
const speedInput = document.getElementById('wjx-speed-profile');
if (speedInput) {
speedInput.value = State.config.speedProfile;
}
updateDistributionModeUI();
updateAllRelationVisibility();
const matched = imported.questions.filter((question) => findMatchingQuestion(scannedQuestions, question)).length;
const unmatched = imported.questions.length - matched;
showToast(`已导入配置:匹配 ${matched} 道,未匹配 ${unmatched} 道。`, { duration: 4200 });
if (unmatched > 0) {
logWarning('导入配置存在未匹配题目', imported.questions.filter((question) => !findMatchingQuestion(scannedQuestions, question)));
}
}
function normalizeDistributionMode(mode) {
return mode === 'count' ? 'count' : 'ratio';
}
function isCountModeFromConfig(config) {
return normalizeDistributionMode(config && config.distributionMode) === 'count';
}
function isCountMode() {
return normalizeDistributionMode(State.config.distributionMode) === 'count';
}
function supportsCountDistribution(type) {
return ['radio', 'dropdown', 'rating', 'checkbox', 'matrix'].includes(type);
}
function setDistributionMode(mode) {
syncStateFromDom();
State.config.distributionMode = normalizeDistributionMode(mode);
saveConfig();
updateDistributionModeUI();
renderQuestionList();
updateCountModeHints();
showToast(mode === 'count' ? '已切换为数量模式' : '已切换为比例模式');
}
function updateDistributionModeUI() {
const mode = normalizeDistributionMode(State.config.distributionMode);
State.config.distributionMode = mode;
const ratioBtn = document.getElementById('wjx-mode-ratio');
const countBtn = document.getElementById('wjx-mode-count');
const hint = document.getElementById('wjx-mode-hint');
if (ratioBtn) {
ratioBtn.classList.toggle('is-active', mode === 'ratio');
}
if (countBtn) {
countBtn.classList.toggle('is-active', mode === 'count');
}
if (hint) {
hint.textContent = mode === 'count'
? '数量模式:各选项数值为该选项被选中的次数。无关联题目次数合计应等于设计份数;有关联题目次数合计应等于其父题对应选项的次数。'
: '比例模式:各选项数值为权重比例。';
}
}
function getWeightInputSuffix() {
return isCountMode() ? '次数' : '比例';
}
function getRandomPresetLabel() {
return isCountMode() ? '均衡次数' : '随机比例';
}
function distributeCountsEvenly(optionCount, total) {
const count = Math.max(1, optionCount || 1);
const target = Math.max(0, Math.round(Number(total) || 0));
const base = Math.floor(target / count);
const remainder = target % count;
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
}
function buildRandomWeightValues(optionCount, question) {
if (isCountMode()) {
const targetQuestion = question || { relations: [] };
return distributeCountsEvenly(optionCount, getExpectedCountTotalForQuestion(targetQuestion));
}
return distributeRatiosToTotal(optionCount, 100);
}
// 比例模式:生成一组总和恒为 total(默认 100)的随机正整数份额,
// 例如 4 选项 → 28/19/37/16(约等于 0.28/0.19/0.37/0.16)。
// 每个选项都给一个非零权重,再按比例缩放到 total 并把取整误差补到最大项,保证和=total。
function distributeRatiosToTotal(optionCount, total) {
const count = Math.max(1, optionCount || 1);
const target = Math.max(count, Math.round(Number(total) || 0));
const raw = Array.from({ length: count }, () => Math.random() + 0.05);
const sum = raw.reduce((acc, value) => acc + value, 0);
const scaled = raw.map((value) => Math.max(1, Math.round((value / sum) * target)));
let diff = scaled.reduce((acc, value) => acc + value, 0) - target;
while (diff !== 0) {
// 找当前最大/最小项把误差补回去,维持相对比例
let extremumIndex = 0;
for (let i = 1; i < scaled.length; i++) {
if (diff > 0 ? scaled[i] > scaled[extremumIndex] : scaled[i] < scaled[extremumIndex]) {
extremumIndex = i;
}
}
const step = diff > 0 ? -1 : 1;
if (scaled[extremumIndex] + step < 1) {
// 不允许把某项扣到 0,退化为均分兜底
break;
}
scaled[extremumIndex] += step;
diff += step;
}
return scaled;
}
function findConfigQuestionByTopicId(topicId) {
return State.config.questions.find((item) => String(item.id) === String(topicId)) || null;
}
function getRelationOptionCount(relation) {
const optionIndex = Number(relation.optionIndex) - 1;
if (!Number.isFinite(optionIndex) || optionIndex < 0) {
return null;
}
const parentCard = document.querySelector(
`#${PANEL_ID} .wjx-card[data-qid="${String(relation.topicId).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`
);
if (parentCard) {
const inputs = parentCard.querySelectorAll('[data-role="weight-input"]');
if (optionIndex < inputs.length) {
return Math.max(0, Math.round(Number(inputs[optionIndex].value) || 0));
}
}
const parent = findConfigQuestionByTopicId(relation.topicId);
if (!parent || !Array.isArray(parent.weights)) {
return null;
}
if (optionIndex >= parent.weights.length) {
return null;
}
return Math.max(0, Math.round(Number(parent.weights[optionIndex]) || 0));
}
function getExpectedCountTotalForQuestion(question) {
const total = getCurrentTargetCount();
if (!Array.isArray(question.relations) || !question.relations.length) {
return total;
}
// 关联子题的可见份数 = 各关联父题选项次数之和(父题按 OR 逻辑任一满足即显示)。
// 之前用 Math.min 会在父题某选项配额耗尽时让子题提前结束,语义错误。
const groups = new Map();
question.relations.forEach((relation) => {
const count = getRelationOptionCount(relation);
if (count !== null) {
const key = String(relation.topicId);
groups.set(key, (groups.get(key) || 0) + Math.max(0, count));
}
});
if (!groups.size) {
return total;
}
const groupCounts = Array.from(groups.values()).map((count) => Math.min(total, count));
if (groupCounts.length === 1) {
return Math.min(total, groupCounts[0]);
}
// 不同父题之间是 OR 关系,无法仅凭边际次数精确知道重叠量;
// 用独立事件的并集估计避免把多个条件简单相加到满额。
const notVisible = groupCounts.reduce((product, count) => {
return product * (1 - Math.min(1, count / Math.max(1, total)));
}, 1);
return Math.min(total, Math.round(total * (1 - notVisible)));
}
function formatCountSumOnlyHint(question) {
if (question.type === 'checkbox') {
return '';
}
const expected = getExpectedCountTotalForQuestion(question);
if (question.type === 'matrix') {
return `每行次数合计应为 ${expected}`;
}
return `次数合计应为 ${expected}`;
}
function findQuestionByCard(card) {
if (!card) {
return null;
}
return State.config.questions.find((item) => {
return String(item.id) === String(card.dataset.qid) && item.type === card.dataset.type;
}) || null;
}
function getQuestionWeightInputCount(card, question) {
const inputCount = card ? card.querySelectorAll('[data-role="weight-input"]').length : 0;
const labelCount = question && Array.isArray(question.optionLabels) ? question.optionLabels.length : 0;
const weightCount = question && Array.isArray(question.weights) ? question.weights.length : 0;
return Math.max(inputCount, labelCount, weightCount, 1);
}
function syncQuestionWeightsFromCard(card) {
const question = findQuestionByCard(card);
if (!question) {
return;
}
question.weights = Array.from(card.querySelectorAll('[data-role="weight-input"]')).map((input) => {
const value = Number(input.value);
return Number.isFinite(value) && value >= 0 ? Math.round(value) : 0;
});
}
function sortCardsParentsFirst(cards) {
const relationDepth = (card, stack) => {
const visited = stack || new Set();
if (visited.has(card)) {
return 0;
}
visited.add(card);
const question = findQuestionByCard(card);
if (!question || !Array.isArray(question.relations) || !question.relations.length) {
return 0;
}
let depth = 0;
question.relations.forEach((relation) => {
const parentCard = cards.find((item) => String(item.dataset.qid) === String(relation.topicId));
if (parentCard) {
depth = Math.max(depth, relationDepth(parentCard, visited) + 1);
}
});
return depth;
};
return cards.slice().sort((left, right) => relationDepth(left) - relationDepth(right));
}
function updateCountModeHints() {
if (!isCountMode()) {
return;
}
State.config.targetCount = getCurrentTargetCount();
document.querySelectorAll(`#${PANEL_ID} [data-role="count-expected-hint"]`).forEach((element) => {
const question = findQuestionByCard(element.closest('.wjx-card'));
if (!question) {
return;
}
const text = formatCountSumOnlyHint(question);
element.textContent = text;
element.classList.toggle('wjx-hidden', !text);
});
document.querySelectorAll(`#${PANEL_ID} .wjx-card`).forEach((card) => {
const meta = card.querySelector('.wjx-card-meta');
const question = findQuestionByCard(card);
if (!meta || !question) {
return;
}
meta.textContent = buildQuestionMeta(question);
});
}
function formatCountModeExpectedHint(question) {
const expected = getExpectedCountTotalForQuestion(question);
const total = getCurrentTargetCount();
if (question.type === 'matrix') {
return `${question.rowCount || 0} 行 ${question.optionLabels.length} 列,每行次数合计应为 ${expected}`;
}
if (question.type === 'checkbox') {
if (Array.isArray(question.relations) && question.relations.length) {
return `${question.optionLabels.length} 个选项,数量为各选项在关联可见的 ${expected} 份答卷中的选中次数`;
}
return `${question.optionLabels.length} 个选项,数量为各选项在 ${total} 份答卷中的选中次数`;
}
if (Array.isArray(question.relations) && question.relations.length && expected !== total) {
return `${question.optionLabels.length} 个选项,次数合计应为 ${expected}(关联 ${formatRelationText(question.relations)})`;
}
return `${question.optionLabels.length} 个选项,次数合计应为 ${expected}`;
}
function validateCountModeBeforeStart() {
if (!isCountMode()) {
return true;
}
const total = getCurrentTargetCount();
const issues = [];
State.config.questions.forEach((question) => {
if (question.enabled === false || !supportsCountDistribution(question.type)) {
return;
}
const weights = Array.isArray(question.weights) ? question.weights : [];
const sum = weights.reduce((acc, value) => acc + Math.max(0, Math.round(Number(value) || 0)), 0);
const hasRelations = Array.isArray(question.relations) && question.relations.length;
if (question.type === 'checkbox') {
const expected = getExpectedCountTotalForQuestion(question);
const normalizedWeights = weights.map((value) => Math.max(0, Math.round(Number(value) || 0)));
const maxSelections = Math.max(
Number(question.minSelections) || 0,
Math.min(normalizedWeights.length, Number(question.maxSelections) || normalizedWeights.length)
);
const minSelections = Math.min(normalizedWeights.length, Number(question.minSelections) || 0);
const minTotal = expected * minSelections;
const maxTotal = expected * maxSelections;
const overMax = normalizedWeights.some((value) => value > expected);
if (overMax || sum < minTotal || sum > maxTotal) {
issues.push(`Q${question.order} 多选次数应在 ${minTotal}~${maxTotal} 之间,且单项不超过 ${expected},当前为 ${sum}`);
}
return;
}
const expected = getExpectedCountTotalForQuestion(question);
if (sum !== expected) {
const relationTip = hasRelations
? `(关联父题选项次数为 ${expected})`
: `(设计份数为 ${total})`;
issues.push(`Q${question.order} 选项次数合计为 ${sum},应为 ${expected}${relationTip}`);
}
});
if (!issues.length) {
return true;
}
showToast(issues[0] + (issues.length > 1 ? ' 等' : ''));
return false;
}
function loadQuotaState() {
try {
const raw = localStorage.getItem(QUOTA_STATE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
}
function saveQuotaState(state) {
localStorage.setItem(QUOTA_STATE_KEY, JSON.stringify(state));
}
function clearQuotaState() {
localStorage.removeItem(QUOTA_STATE_KEY);
}
function buildQuotaEntry(question) {
const weights = (question.weights || []).map((value) => Math.max(0, Math.round(Number(value) || 0)));
if (question.type === 'matrix') {
const rowCount = Math.max(1, Number(question.rowCount) || 1);
return {
type: 'matrix',
rows: Array.from({ length: rowCount }, () => weights.slice())
};
}
return {
type: 'flat',
values: weights.slice()
};
}
function initQuotaState() {
const quotas = {};
State.config.questions.forEach((question) => {
if (!supportsCountDistribution(question.type)) {
return;
}
quotas[String(question.id)] = buildQuotaEntry(question);
});
saveQuotaState({
surveyKey: State.surveyKey,
quotas
});
}
function ensureQuotaState() {
if (!isCountMode()) {
clearQuotaState();
return;
}
const existing = loadQuotaState();
if (!existing || existing.surveyKey !== State.surveyKey || !existing.quotas) {
initQuotaState();
return;
}
let changed = false;
State.config.questions.forEach((question) => {
const key = String(question.id);
if (!supportsCountDistribution(question.type) || existing.quotas[key]) {
return;
}
existing.quotas[key] = buildQuotaEntry(question);
changed = true;
});
if (changed) {
saveQuotaState(existing);
}
}
function getQuotaEntry(questionId) {
const state = loadQuotaState();
if (!state || state.surveyKey !== State.surveyKey || !state.quotas) {
return null;
}
return state.quotas[String(questionId)] || null;
}
function getFlatQuotas(questionId) {
const entry = getQuotaEntry(questionId);
if (!entry || entry.type !== 'flat' || !Array.isArray(entry.values)) {
return null;
}
return entry.values;
}
function getMatrixRowQuotas(questionId, rowIndex) {
const entry = getQuotaEntry(questionId);
if (!entry || entry.type !== 'matrix' || !Array.isArray(entry.rows)) {
return null;
}
return entry.rows[rowIndex] || null;
}
function pickQuotaIndexFromValues(quotas) {
const available = quotas
.map((quota, index) => ({ index, quota: Math.max(0, Number(quota) || 0) }))
.filter((item) => item.quota > 0);
if (!available.length) {
return Math.floor(Math.random() * quotas.length);
}
const total = available.reduce((sum, item) => sum + item.quota, 0);
let random = Math.random() * total;
for (const item of available) {
random -= item.quota;
if (random <= 0) {
return item.index;
}
}
return available[available.length - 1].index;
}
function consumeFlatQuota(questionId, optionIndex) {
const state = loadQuotaState();
const entry = state && state.surveyKey === State.surveyKey && state.quotas
? state.quotas[String(questionId)]
: null;
if (!entry || entry.type !== 'flat' || !Array.isArray(entry.values)) {
return;
}
if (entry.values[optionIndex] > 0) {
entry.values[optionIndex] -= 1;
saveQuotaState(state);
}
}
function consumeMatrixQuota(questionId, rowIndex, optionIndex) {
const state = loadQuotaState();
const entry = state && state.surveyKey === State.surveyKey && state.quotas
? state.quotas[String(questionId)]
: null;
if (!entry || entry.type !== 'matrix' || !entry.rows[rowIndex]) {
return;
}
if (entry.rows[rowIndex][optionIndex] > 0) {
entry.rows[rowIndex][optionIndex] -= 1;
saveQuotaState(state);
}
}
function pickOptionIndexForQuestion(question) {
if (!isCountMode() || !supportsCountDistribution(question.type)) {
return pickWeightedIndex(Array.isArray(question.weights) ? question.weights : []);
}
const quotas = getFlatQuotas(question.id);
if (!quotas) {
return pickWeightedIndex(Array.isArray(question.weights) ? question.weights : []);
}
return pickQuotaIndexFromValues(quotas);
}
function pickCheckboxIndicesForQuestion(question) {
const weights = Array.isArray(question.weights) ? question.weights : [];
if (!isCountMode()) {
return pickMultipleIndices(weights, question.minSelections, question.maxSelections);
}
const quotas = getFlatQuotas(question.id);
if (!quotas) {
return pickMultipleIndices(weights, question.minSelections, question.maxSelections);
}
const totalOptions = quotas.length;
const available = quotas
.map((quota, index) => ({ index, quota: Math.max(0, Number(quota) || 0) }))
.filter((item) => item.quota > 0);
if (!available.length) {
return pickMultipleIndices(question.weights, question.minSelections, question.maxSelections);
}
const minCount = Math.max(0, Math.min(Number(question.minSelections) || 0, totalOptions));
const defaultMax = Math.min(totalOptions, Math.max(minCount || 1, totalOptions));
const maxCount = Math.max(minCount, Math.min(Number(question.maxSelections) || defaultMax, totalOptions));
const remainingAnswers = Math.max(1, Number((loadAutomationSession() || {}).remaining) || getCurrentTargetCount());
const remainingQuotaTotal = available.reduce((sum, item) => sum + item.quota, 0);
const lowerBound = Math.max(minCount, remainingQuotaTotal - (remainingAnswers - 1) * maxCount);
const upperBound = Math.min(maxCount, remainingQuotaTotal - (remainingAnswers - 1) * minCount);
const mandatory = available.filter((item) => item.quota >= remainingAnswers).map((item) => item.index);
const lower = Math.max(mandatory.length, Math.min(available.length, lowerBound));
const upper = Math.max(lower, Math.min(available.length, upperBound));
const count = Math.min(available.length, randomInt(lower, upper));
const pool = available.filter((item) => !mandatory.includes(item.index));
const results = [];
mandatory.slice(0, count).forEach((index) => results.push(index));
while (results.length < count && pool.length > 0) {
const total = pool.reduce((sum, item) => sum + item.quota, 0);
let random = Math.random() * total;
let pickIndex = 0;
for (let i = 0; i < pool.length; i++) {
random -= pool[i].quota;
if (random <= 0) {
pickIndex = i;
break;
}
}
const pick = pool[pickIndex];
results.push(pick.index);
pool.splice(pool.indexOf(pick), 1);
}
return results;
}
function pickMatrixRowIndex(question, rowIndex) {
if (!isCountMode()) {
return pickWeightedIndex(question.weights);
}
const quotas = getMatrixRowQuotas(question.id, rowIndex);
if (!quotas) {
return pickWeightedIndex(question.weights);
}
return pickQuotaIndexFromValues(quotas);
}
function renderStats() {
document.getElementById('wjx-stat-total').textContent = String(State.config.questions.length);
document.getElementById('wjx-stat-types').textContent = String(new Set(State.config.questions.map((item) => item.type)).size);
}
function renderQuestionList() {
const container = document.getElementById('wjx-question-list');
if (!container) {
return;
}
if (!State.config.questions.length) {
container.innerHTML = `
未识别到题目
`;
return;
}
container.innerHTML = State.config.questions.map((question) => renderQuestionCard(question)).join('');
applyQuestionFilters();
}
function renderQuestionCard(question) {
const title = escapeHtml(question.title || `题目 ${question.order}`);
const typeLabel = TYPE_LABELS[question.type] || TYPE_LABELS.unknown;
const meta = escapeHtml(buildQuestionMeta(question));
const enabled = question.enabled !== false;
const weights = Array.isArray(question.weights) ? question.weights : [];
const weightTotal = weights.reduce((sum, value) => sum + Math.max(0, Number(value) || 0), 0);
const editor = question.type === 'text'
? `
`
: question.type === 'slide'
? `
`
: question.type === 'unknown'
? ''
: `
${question.optionLabels.map((label, index) => `
`).join('')}
`;
const presetTool = question.type === 'text'
? ''
: question.type === 'slide'
? ''
: question.type === 'unknown'
? ''
: ``;
const tools = `
${presetTool}
${question.type === 'rating' ? '' : ''}
`;
const countHint = isCountMode() && supportsCountDistribution(question.type) && question.type !== 'checkbox'
? `${escapeHtml(formatCountSumOnlyHint(question))}
`
: '';
const summary = question.type !== 'text' && question.type !== 'slide' && question.type !== 'unknown'
? `合计 ${Math.round(weightTotal)}${isCountMode() ? ' 次' : ''}
`
: '';
return `
Q${question.order}. ${title}
${typeLabel}
${meta}
${editor}
${summary}
${countHint}
${tools}
`;
}
function applyQuestionFilters() {
const search = cleanText(document.getElementById('wjx-question-search') && document.getElementById('wjx-question-search').value).toLowerCase();
const type = String(document.getElementById('wjx-question-type-filter') && document.getElementById('wjx-question-type-filter').value || 'all');
document.querySelectorAll(`#${PANEL_ID} .wjx-card`).forEach((card) => {
const matchesType = type === 'all' || card.dataset.type === type;
const matchesSearch = !search || cleanText(card.textContent).toLowerCase().includes(search);
card.classList.toggle('is-filtered', !matchesType || !matchesSearch);
});
}
function updateWeightVisuals(card) {
if (!card) {
return;
}
const values = Array.from(card.querySelectorAll('[data-role="weight-input"]')).map((input) => Math.max(0, Number(input.value) || 0));
const total = values.reduce((sum, value) => sum + value, 0);
card.querySelectorAll('.wjx-weight-bar').forEach((bar, index) => {
bar.style.width = `${total > 0 ? Math.round((values[index] / total) * 100) : 0}%`;
});
const summary = card.querySelector('[data-role="weight-summary"]');
if (summary) {
summary.textContent = `合计 ${Math.round(total)}${isCountMode() ? ' 次' : ''}`;
}
}
function handleQuestionAction(card, action) {
if (!findQuestionByCard(card)) {
return;
}
syncStateFromDom();
const question = findQuestionByCard(card);
if (!question) {
return;
}
if (action === 'toggle') {
question.enabled = question.enabled === false;
renderQuestionList();
scheduleConfigSave();
return;
}
if (action === 'reset') {
const fresh = scanQuestions().find((item) => String(item.id) === String(question.id) && item.type === question.type);
if (fresh) {
fresh.enabled = question.enabled !== false;
const index = State.config.questions.findIndex((item) => String(item.id) === String(question.id) && item.type === question.type);
State.config.questions[index] = fresh;
renderQuestionList();
scheduleConfigSave();
}
return;
}
if (action === 'copy') {
State.config.questions.forEach((target) => {
if (target === question || target.type !== question.type) {
return;
}
if (Array.isArray(question.weights) && Array.isArray(target.weights) && question.weights.length === target.weights.length) {
target.weights = question.weights.slice();
}
if (question.type === 'text') {
target.content = Array.isArray(question.content) ? question.content.slice() : [];
}
if (question.type === 'slide') {
target.minScore = question.minScore;
target.maxScore = question.maxScore;
}
});
renderQuestionList();
scheduleConfigSave();
showToast('已复制到选项结构一致的同类题。');
}
}
function resetCurrentSurveyConfig() {
if (!window.confirm('恢复当前问卷的默认配置?')) {
return;
}
const targetCount = getCurrentTargetCount();
const mode = State.config.distributionMode;
const speedProfile = State.config.speedProfile;
State.config = {
targetCount,
distributionMode: mode,
speedProfile,
questions: scanQuestions()
};
renderStats();
renderQuestionList();
updateDistributionModeUI();
saveConfig();
showToast('已恢复当前问卷默认配置。');
}
function clearCurrentSurveyStorage() {
if (!window.confirm('清除当前问卷配置、进度和配额数据?')) {
return;
}
localStorage.removeItem(State.surveyKey);
clearAutomationStorage();
clearAutomationStoppedFlag();
refreshQuestions({ silent: true, preserveInputs: false });
setSaveStatus('本地数据已清除', 'saved');
showToast('当前问卷的本地数据已清除。');
}
function buildQuestionMeta(question) {
const relationText = formatRelationText(question.relations);
if (relationText) {
return `关联:${relationText}`;
}
if (question.type === 'matrix') {
if (isCountMode() && supportsCountDistribution(question.type)) {
return formatCountModeExpectedHint(question);
}
return `${question.rowCount || 0} 行 ${question.optionLabels.length} 列`;
}
if (question.type === 'text') {
return '填空题';
}
if (question.type === 'slide') {
return `分值范围 ${clampScore(question.minScore, 1)} - ${clampScore(question.maxScore, 100)}`;
}
if (question.type === 'unknown') {
return '未知题型';
}
if (question.type === 'checkbox' && question.minSelections) {
return `${question.optionLabels.length} 个选项,最少选 ${question.minSelections} 项`;
}
if (isCountMode() && supportsCountDistribution(question.type)) {
return formatCountModeExpectedHint(question);
}
return `${question.optionLabels.length} 个选项`;
}
function syncStateFromDom() {
State.config.targetCount = getCurrentTargetCount();
State.config.questions = collectCurrentQuestionsFromDom();
}
function getCurrentTargetCount() {
const input = document.getElementById('wjx-target-count');
return Math.max(1, Number(input && input.value) || 1);
}
function collectCurrentQuestionsFromDom() {
return Array.from(document.querySelectorAll(`#${PANEL_ID} .wjx-card`)).map((card, index) => {
const id = card.dataset.qid;
const type = card.dataset.type;
const original = State.config.questions.find((item) => String(item.id) === String(id) && item.type === type) || {};
const next = {
id,
order: original.order || index + 1,
title: original.title || '',
type,
optionLabels: Array.isArray(original.optionLabels) ? original.optionLabels.slice() : [],
relations: Array.isArray(original.relations) ? original.relations.map((relation) => Object.assign({}, relation)) : [],
rowCount: original.rowCount || 0,
minSelections: original.minSelections || 0,
maxSelections: original.maxSelections || 0,
enabled: original.enabled !== false,
minScore: clampScore(original.minScore, 1),
maxScore: clampScore(original.maxScore, 100)
};
if (type === 'text') {
const area = card.querySelector('[data-role="content-editor"]');
next.content = area
? area.value.split('\n').map((line) => line.trim()).filter(Boolean)
: DEFAULT_TEXT_LIBRARY.slice();
} else if (type === 'slide') {
next.minScore = clampScore(card.querySelector('[data-role="slide-min"]') && card.querySelector('[data-role="slide-min"]').value, original.minScore || 1);
next.maxScore = clampScore(card.querySelector('[data-role="slide-max"]') && card.querySelector('[data-role="slide-max"]').value, original.maxScore || 100);
if (next.maxScore < next.minScore) {
next.maxScore = next.minScore;
}
} else if (type !== 'unknown') {
next.weights = Array.from(card.querySelectorAll('[data-role="weight-input"]')).map((input) => {
const value = Number(input.value);
return Number.isFinite(value) && value >= 0 ? value : 0;
});
}
return next;
});
}
function applyPreset(card, preset) {
if (!card) {
return;
}
if (preset === 'text-default') {
const area = card.querySelector('[data-role="content-editor"]');
if (area) {
area.value = DEFAULT_TEXT_LIBRARY.join('\n');
showToast('已填入默认词库。');
}
return;
}
if (preset === 'slide-default') {
const minInput = card.querySelector('[data-role="slide-min"]');
const maxInput = card.querySelector('[data-role="slide-max"]');
if (minInput && maxInput) {
const minScore = randomInt(20, 70);
const maxScore = randomInt(minScore, Math.min(100, minScore + 30));
minInput.value = String(minScore);
maxInput.value = String(maxScore);
showToast('已生成随机分值区间。');
}
return;
}
const inputs = Array.from(card.querySelectorAll('[data-role="weight-input"]'));
if (!inputs.length) {
return;
}
syncStateFromDom();
const original = findQuestionByCard(card) || { relations: [] };
if (preset === 'rating-positive' && original.type === 'rating') {
const activeStart = Math.max(0, inputs.length - 3);
const values = isCountMode()
? distributeCountsEvenly(inputs.length - activeStart, getExpectedCountTotalForQuestion(original))
: distributeRatiosToTotal(inputs.length - activeStart, 100);
inputs.forEach((input, index) => {
input.value = String(index >= activeStart ? values[index - activeStart] || 0 : 0);
});
syncQuestionWeightsFromCard(card);
updateWeightVisuals(card);
showToast('已设置为偏向高分,仅保留高星级权重。');
return;
}
const values = buildRandomWeightValues(getQuestionWeightInputCount(card, original), original);
inputs.forEach((input, index) => {
input.value = String(values[index] || 0);
});
syncQuestionWeightsFromCard(card);
updateCountModeHints();
showToast(isCountMode() ? '已按关联次数均衡分配。' : '已生成随机比例。');
}
function randomizeAllQuestions(options) {
const settings = Object.assign({ silent: false }, options);
syncStateFromDom();
const cards = sortCardsParentsFirst(Array.from(document.querySelectorAll(`#${PANEL_ID} .wjx-card`)));
cards.forEach((card) => {
const type = card.dataset.type;
if (type === 'text') {
const area = card.querySelector('[data-role="content-editor"]');
if (area) {
area.value = DEFAULT_TEXT_LIBRARY.join('\n');
}
return;
}
if (type === 'slide') {
const minInput = card.querySelector('[data-role="slide-min"]');
const maxInput = card.querySelector('[data-role="slide-max"]');
if (minInput && maxInput) {
const minScore = randomInt(20, 70);
const maxScore = randomInt(minScore, Math.min(100, minScore + 30));
minInput.value = String(minScore);
maxInput.value = String(maxScore);
}
return;
}
if (type === 'unknown') {
return;
}
const original = findQuestionByCard(card) || { relations: [] };
const values = buildRandomWeightValues(getQuestionWeightInputCount(card, original), original);
card.querySelectorAll('[data-role="weight-input"]').forEach((input, index) => {
input.value = String(values[index] || 0);
});
syncQuestionWeightsFromCard(card);
});
syncStateFromDom();
saveConfig();
updateCountModeHints();
if (!settings.silent) {
showToast(isCountMode() ? '已按各题关联次数均衡分配。' : '已随机全部题目。');
}
}
function scanQuestions() {
return findQuestionNodes().map((node, index) => buildQuestionConfig(node, index + 1)).filter(Boolean);
}
function findQuestionNodes() {
const nodes = Array.from(document.querySelectorAll(QUESTION_NODE_SELECTORS.join(',')));
return nodes.filter((node, index, list) => {
if (!(node instanceof HTMLElement)) {
return false;
}
if (!extractTitle(node)) {
return false;
}
return !list.some((other, otherIndex) => otherIndex !== index && other.contains(node));
});
}
function parseRelations(node) {
const raw = String(node.getAttribute('relation') || '').trim();
if (!raw) {
return [];
}
return raw.split(';').map((part) => {
const pieces = part.split(',').map((item) => item.trim()).filter(Boolean);
if (pieces.length < 2) {
return null;
}
const topicId = pieces[0].replace(/\D/g, '') || pieces[0];
const optionIndex = Number(pieces[1]);
if (!topicId || !Number.isFinite(optionIndex) || optionIndex < 1) {
return null;
}
return { topicId: String(topicId), optionIndex: Math.floor(optionIndex) };
}).filter(Boolean);
}
function findQuestionNodeById(topicId) {
return findQuestionNodes().find((node) => String(extractQuestionId(node, 0)) === String(topicId)) || null;
}
function getRelationOptionLabel(topicId, optionIndex) {
const parentNode = findQuestionNodeById(topicId);
if (!parentNode) {
return `选项${optionIndex}`;
}
const parentType = detectType(parentNode);
const detail = extractDetail(parentNode, parentType);
return detail.optionLabels[optionIndex - 1] || `选项${optionIndex}`;
}
function formatRelationText(relations) {
if (!Array.isArray(relations) || !relations.length) {
return '';
}
return relations.map((relation) => {
const label = getRelationOptionLabel(relation.topicId, relation.optionIndex);
return `第${relation.topicId}题·${label}`;
}).join(';');
}
function isRelationSatisfied(relations) {
if (!Array.isArray(relations) || !relations.length) {
return true;
}
return relations.some((relation) => isOptionSelectedByIndex(relation.topicId, relation.optionIndex));
}
function isOptionSelectedByIndex(topicId, optionIndex) {
const parentNode = findQuestionNodeById(topicId);
if (!parentNode) {
return false;
}
return isOptionSelected(parentNode, optionIndex);
}
function isOptionSelected(node, optionIndex) {
const type = detectType(node);
if (type === 'dropdown') {
const select = node.querySelector('select');
if (!select || select.selectedIndex < 0) {
return false;
}
const selected = select.options[select.selectedIndex];
if (!selected) {
return false;
}
if (Number(selected.value) === optionIndex) {
return true;
}
// 若存在「请选择」占位项, selectedIndex 需要减去占位项偏移
let realIndex = select.selectedIndex;
if (select.options[0] && PLACEHOLDER_OPTION_PATTERN.test(cleanText(select.options[0].textContent))) {
realIndex = select.selectedIndex - 1;
}
return realIndex === optionIndex - 1;
}
const valueInput = node.querySelector(`input[type="radio"][value="${optionIndex}"], input[type="checkbox"][value="${optionIndex}"]`);
if (valueInput) {
return !!valueInput.checked;
}
const choiceType = type === 'checkbox' ? 'checkbox' : 'radio';
const options = getChoiceElements(node, type === 'checkbox' ? 'checkbox' : (type === 'rating' ? 'rating' : 'radio'));
const option = options[optionIndex - 1];
return !!(option && isChoiceSelected(option, choiceType));
}
function updateAllRelationVisibility() {
findQuestionNodes().forEach((node) => {
const relations = parseRelations(node);
if (!relations.length) {
return;
}
const satisfied = isRelationSatisfied(relations);
// 用 data 属性标记脚本隐藏,避免直接覆盖 style.display 与问卷星自身联动冲突
if (satisfied) {
if (node.dataset.wjxHiddenByRelation === '1') {
delete node.dataset.wjxHiddenByRelation;
node.removeAttribute('hidden');
}
} else {
if (node.dataset.wjxHiddenByRelation !== '1') {
node.dataset.wjxHiddenByRelation = '1';
}
node.setAttribute('hidden', '');
}
});
}
function buildQuestionConfig(node, order) {
const title = extractTitle(node);
if (!title) {
return null;
}
const type = detectType(node);
const base = {
id: extractQuestionId(node, order),
order,
title,
type,
rawType: String(node.getAttribute('type') || ''),
relations: parseRelations(node),
optionLabels: [],
rowCount: 0,
minSelections: 0,
maxSelections: 0,
enabled: true,
minScore: 1,
maxScore: 100
};
if (type === 'text') {
return Object.assign(base, { content: DEFAULT_TEXT_LIBRARY.slice() });
}
if (type === 'slide') {
return Object.assign(base, {
minScore: 1,
maxScore: 100
});
}
if (type === 'unknown') {
return base;
}
const detail = extractDetail(node, type);
const limits = extractSelectionLimits(node, type, detail.optionLabels.length);
return Object.assign(base, {
optionLabels: detail.optionLabels,
rowCount: detail.rowCount || 0,
minSelections: limits.minSelections,
maxSelections: limits.maxSelections,
weights: Array.from({ length: detail.optionLabels.length }, () => 1)
});
}
function extractQuestionId(node, fallbackOrder) {
const raw = node.id || node.getAttribute('topic') || '';
const match = raw.match(/(\d+)/);
return match ? match[1] : String(fallbackOrder);
}
function extractTitle(node) {
const titleNode = node.querySelector('.div_title_question, .ui-controlgroup-label, .field-label, .legend, .title');
return cleanText(titleNode ? titleNode.textContent : '').replace(/^\d+\s*[\.、)]\s*/, '');
}
function detectType(node) {
const wjxType = String(node.getAttribute('type') || '');
if (wjxType === '6') {
return 'matrix';
}
if (wjxType === '5') {
return 'rating';
}
if (wjxType === '4') {
return 'checkbox';
}
if (wjxType === '3') {
return 'radio';
}
if (wjxType === '7') {
return 'dropdown';
}
if (wjxType === '11') {
return 'sorting';
}
if (wjxType === '1' || wjxType === '2') {
return 'text';
}
if (wjxType === '8') {
return 'slide';
}
if (node.querySelector('.div_table_radio_question, .div_table_clear_top')) {
return 'matrix';
}
const table = node.querySelector('table');
if (table && table.querySelector('input[type="radio"], input[type="checkbox"], .ui-radio, .ui-checkbox, a[dval], .rate-off, .rate-on')) {
return 'matrix';
}
if (node.querySelector('.city-container, .divProvince, .divCity')) {
return 'location';
}
if (node.querySelector('.reorder-list, .ui-sortable')) {
return 'sorting';
}
if (node.querySelector('.scale-div, .rating-star, .onscore, .starlevel')) {
return 'rating';
}
if (node.querySelector('.ui-checkbox, .jqCheckbox, input[type="checkbox"]')) {
return 'checkbox';
}
if (node.querySelector('.ui-radio, .jqRadio, input[type="radio"]')) {
return 'radio';
}
if (node.querySelector('select')) {
return 'dropdown';
}
if (node.querySelector('textarea, input[type="text"]:not([style*="display:none"]), input[type="tel"], input[type="email"], input[type="number"]')) {
return 'text';
}
return 'unknown';
}
function extractSelectionLimits(node, type, optionCount) {
const rawMin = Number(node.getAttribute('minvalue') || 0);
const rawMax = Number(node.getAttribute('maxvalue') || 0);
let minSelections = Number.isFinite(rawMin) && rawMin > 0 ? rawMin : 0;
let maxSelections = Number.isFinite(rawMax) && rawMax > 0 ? rawMax : 0;
if (!minSelections && (type === 'checkbox' || type === 'radio') && node.getAttribute('req') === '1') {
minSelections = 1;
}
minSelections = Math.min(Math.max(minSelections, 0), optionCount || 0);
maxSelections = Math.min(Math.max(maxSelections, 0), optionCount || 0);
if (maxSelections && maxSelections < minSelections) {
maxSelections = minSelections;
}
return { minSelections, maxSelections };
}
function extractDetail(node, type) {
if (type === 'dropdown') {
const select = node.querySelector('select');
const optionLabels = Array.from(select ? select.options : [])
.map((item) => trimOptionPrefix(cleanText(item.textContent)))
.filter((text) => text && !PLACEHOLDER_OPTION_PATTERN.test(text));
return {
optionLabels: optionLabels.length ? optionLabels : ['选项1', '选项2'],
rowCount: 0
};
}
if (type === 'rating') {
const optionLabels = extractRatingLabels(node);
return {
optionLabels: optionLabels.length ? optionLabels : ['1星', '2星', '3星', '4星', '5星'],
rowCount: 0
};
}
if (type === 'matrix') {
return extractMatrixDetail(node);
}
const optionLabels = extractChoiceLabels(node);
return {
optionLabels: optionLabels.length ? optionLabels : ['选项1', '选项2'],
rowCount: 0
};
}
function extractRatingLabels(node) {
return getChoiceElements(node, 'rating').map((option, index) => {
const control = option.matches('[val], [dval], input[value]')
? option
: option.querySelector('a[val], [dval], input[value], a[title], [aria-label]');
const rawScore = control && (
control.getAttribute('val') ||
control.getAttribute('dval') ||
control.getAttribute('value')
);
const score = cleanText(rawScore) || String(index + 1);
const description = cleanText(
control && (control.getAttribute('title') || control.getAttribute('aria-label')) || ''
);
return description && description !== score
? `${score}星(${description})`
: `${score}星`;
});
}
function extractChoiceLabels(node) {
const selectors = [
'.ulradiocheck li',
'.ui-controlgroup .ui-radio',
'.ui-controlgroup .ui-checkbox',
'.label',
'.option-item',
'.wjx-options li'
];
const rawTexts = [];
selectors.forEach((selector) => {
node.querySelectorAll(selector).forEach((item) => {
const text = trimOptionPrefix(cleanText(item.textContent));
if (text) {
rawTexts.push(text);
}
});
});
return dedupeTexts(rawTexts).slice(0, 20);
}
function extractMatrixDetail(node) {
const table = node.querySelector('table');
if (!table) {
return { optionLabels: ['选项1', '选项2'], rowCount: 0 };
}
let headerCells = [];
const theadCells = table.querySelectorAll('thead tr:first-child th, thead tr:first-child td');
if (theadCells.length > 1) {
headerCells = Array.from(theadCells).slice(1);
} else {
const firstRow = table.querySelector('tbody tr, tr');
const bodyCells = firstRow ? firstRow.querySelectorAll('td, th') : [];
if (bodyCells.length > 1) {
headerCells = Array.from(bodyCells).slice(1);
}
}
const optionLabels = dedupeTexts(headerCells.map((cell) => trimOptionPrefix(cleanText(cell.textContent)))).filter(Boolean);
const rowCount = table.querySelectorAll('tbody tr').length || Math.max(0, table.querySelectorAll('tr').length - 1);
return {
optionLabels: optionLabels.length ? optionLabels : ['选项1', '选项2'],
rowCount
};
}
function dedupeTexts(list) {
const result = [];
const seen = new Set();
list.forEach((item) => {
const normalized = cleanText(item);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
result.push(normalized);
});
return result;
}
function trimOptionPrefix(text) {
return String(text || '').replace(/^[A-ZA-Za-za-z0-90-9]+[\.\、\)]\s*/, '').trim();
}
function cleanText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function clampScore(value, fallback) {
const score = Number(value);
if (!Number.isFinite(score)) {
return Number(fallback);
}
return Math.max(0, Math.min(100, Math.round(score)));
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
let progressStyleInjected = false;
let automationRedirectTimer = null;
// 停止标志:用户点“停止提交”后置位,用于中断正在飞行的异步提交流程,
// 避免依赖清空 session 才停(清完 session 后完成页跳转/正在 await 的提交仍会继续走)。
// 同时持久化到 localStorage,跨页面(完成页→跳首页的 location.href)后仍能阻止续跑。
let automationAborted = false;
function isAutomationAborted() {
if (automationAborted) {
return true;
}
try {
return localStorage.getItem(AUTOMATION_STOPPED_KEY) === '1';
} catch (error) {
return false;
}
}
function isAutomationPaused() {
const session = loadAutomationSession();
return !!session && (session.paused === true || session.skipping === true);
}
function isAutomationInterrupted() {
return isAutomationAborted() || isAutomationPaused();
}
function markAutomationStopped() {
automationAborted = true;
try {
localStorage.setItem(AUTOMATION_STOPPED_KEY, '1');
} catch (error) {
logWarning('写入停止标志失败', error);
}
}
function clearAutomationStoppedFlag() {
automationAborted = false;
try {
localStorage.removeItem(AUTOMATION_STOPPED_KEY);
} catch (error) {}
}
function isAutomationStillActive() {
// 真正的“还在跑”:未被用户停止 且 session 有剩余份数
return !isAutomationInterrupted() && isAutomationActive();
}
function injectProgressStyles() {
if (progressStyleInjected || document.querySelector('style[data-wjx-progress]')) {
progressStyleInjected = true;
return;
}
const style = document.createElement('style');
style.setAttribute('data-wjx-progress', '1');
// 主进度模态框样式已在 STYLE_TEXT 中定义,这里只补充主样式未覆盖的扩展样式
style.textContent = `
.wjx-progress-status {
margin: 0 0 8px;
font-size: 12px;
font-weight: 700;
color: #d4380d;
}
`;
document.head.appendChild(style);
progressStyleInjected = true;
}
function hidePanelForAutomation() {
const panel = document.getElementById(PANEL_ID);
if (!panel) {
return;
}
try {
sessionStorage.setItem(
PANEL_COLLAPSED_KEY,
panel.classList.contains('is-collapsed') ? '1' : '0'
);
} catch (error) {
logWarning('记忆面板折叠状态失败', error);
}
panel.classList.add('is-automation-hidden');
}
function showPanelAfterAutomation() {
const panel = document.getElementById(PANEL_ID);
if (!panel) {
return;
}
panel.classList.remove('is-automation-hidden');
let collapsed = false;
try {
const saved = sessionStorage.getItem(PANEL_COLLAPSED_KEY);
collapsed = saved === '1';
sessionStorage.removeItem(PANEL_COLLAPSED_KEY);
} catch (error) {
logWarning('恢复面板折叠状态失败', error);
}
const body = panel.querySelector('#wjx-panel-body');
const button = panel.querySelector('#wjx-toggle-panel');
panel.classList.toggle('is-collapsed', collapsed);
if (body) {
body.classList.toggle('wjx-hidden', collapsed);
}
if (button) {
button.textContent = collapsed ? '展开' : '收起';
}
}
function clearAutomationStorage() {
localStorage.removeItem(REMAINING_COUNT_KEY);
localStorage.removeItem(TOTAL_COUNT_KEY);
localStorage.removeItem(SURVEY_URL_KEY);
clearQuotaState();
}
function endAutomationSession(message) {
// 立即置停止标志,打断正在飞行的异步提交与完成页跳转
markAutomationStopped();
clearTimeout(automationRedirectTimer);
automationRedirectTimer = null;
clearAutomationStorage();
hideAutomationProgress();
showPanelAfterAutomation();
if (message) {
showToast(message);
}
}
function beginAutomationSession() {
// 新一轮自动化开始时复位停止标志
clearAutomationStoppedFlag();
hidePanelForAutomation();
showAutomationProgress();
}
function stopAutomation() {
endAutomationSession('已停止自动化提交');
}
function captureAttemptQuotaSnapshot() {
const session = loadAutomationSession();
if (!session || session.attemptQuotaSnapshot !== null || !isCountMode()) {
return;
}
try {
session.attemptQuotaSnapshot = localStorage.getItem(QUOTA_STATE_KEY) || '';
saveAutomationSession(session);
} catch (error) {
logWarning('保存本次尝试配额快照失败', error);
}
}
function restoreAttemptQuotaSnapshot(session) {
if (!session || typeof session.attemptQuotaSnapshot !== 'string') {
return;
}
try {
if (!session.attemptQuotaSnapshot) {
localStorage.removeItem(QUOTA_STATE_KEY);
} else {
localStorage.setItem(QUOTA_STATE_KEY, session.attemptQuotaSnapshot);
}
} catch (error) {
logWarning('恢复本次尝试配额快照失败', error);
}
}
function toggleAutomationPause() {
const session = loadAutomationSession();
if (!session) {
return;
}
if (session.paused) {
session.paused = false;
saveAutomationSession(session);
showToast('已恢复自动化,将重新打开问卷入口。');
const url = session.url || localStorage.getItem(SURVEY_URL_KEY);
if (url) {
location.href = url;
} else {
executeAutoFillAndSubmit();
}
return;
}
restoreAttemptQuotaSnapshot(session);
session.paused = true;
saveAutomationSession(session);
clearTimeout(automationRedirectTimer);
automationRedirectTimer = null;
updateAutomationProgress();
showToast('自动化已暂停,可恢复、跳过当前份或停止。', { duration: 4200 });
}
function skipAutomationAttempt() {
const session = loadAutomationSession();
if (!session) {
return;
}
restoreAttemptQuotaSnapshot(session);
session.failed = Math.max(0, Number(session.failed) || 0) + 1;
session.remaining = Math.max(0, Number(session.remaining) || 0) - 1;
session.retryCount = 0;
session.attemptQuotaSnapshot = null;
session.paused = false;
session.skipping = true;
saveAutomationSession(session);
if (session.remaining <= 0) {
endAutomationSession('自动化任务已结束,当前份已跳过。');
return;
}
updateAutomationProgress();
const url = session.url || localStorage.getItem(SURVEY_URL_KEY);
if (!url) {
endAutomationSession('缺失问卷地址,无法继续下一份。');
return;
}
setTimeout(() => {
location.href = url;
}, 500);
}
function isAutomationActive() {
const session = loadAutomationSession();
return !!session && Number(session.remaining) > 0;
}
function ensureTotalCountForAutomation() {
const session = loadAutomationSession();
if (!session) {
return;
}
const remaining = Math.max(0, Number(session.remaining) || 0);
const total = Math.max(0, Number(session.total) || 0);
if (remaining > 0 && total <= 0) {
session.total = remaining;
saveAutomationSession(session);
}
}
function getAutomationProgress() {
const session = loadAutomationSession();
if (!session) {
return null;
}
const remaining = Math.max(0, Number(session.remaining) || 0);
let total = Math.max(0, Number(session.total) || 0);
if (total <= 0) {
total = remaining;
}
if (remaining <= 0 || total <= 0) {
return null;
}
const completed = Math.max(0, total - remaining);
const current = Math.min(total, completed + 1);
const percent = Math.min(100, Math.round((completed / total) * 100));
const failed = Math.max(0, Number(session.failed) || 0);
const succeeded = Math.max(0, completed - failed);
return {
current,
total,
percent,
completed,
succeeded,
failed,
paused: session.paused === true,
error: String(session.lastError || ''),
retryCount: Math.max(0, Number(session.retryCount) || 0)
};
}
function recordAutomationFailure() {
const session = loadAutomationSession();
if (!session) {
return;
}
session.failed = Math.max(0, Number(session.failed) || 0) + 1;
saveAutomationSession(session);
}
function ensureProgressModal() {
injectProgressStyles();
let modal = document.querySelector('.wjx-progress-modal');
if (!modal) {
modal = document.createElement('div');
modal.className = 'wjx-progress-modal';
modal.innerHTML = `
自动化提交中
第1份/共1份
进度 0%
`;
document.body.appendChild(modal);
modal.querySelector('#wjx-progress-pause').addEventListener('click', (event) => {
event.stopPropagation();
toggleAutomationPause();
});
modal.querySelector('#wjx-progress-skip').addEventListener('click', (event) => {
event.stopPropagation();
skipAutomationAttempt();
});
modal.querySelector('#wjx-progress-stop').addEventListener('click', (event) => {
event.stopPropagation();
stopAutomation();
});
}
return modal;
}
function updateAutomationProgressUI(progress) {
const countEl = document.getElementById('wjx-progress-count');
const percentEl = document.getElementById('wjx-progress-percent');
const barEl = document.getElementById('wjx-progress-bar');
const statusEl = document.getElementById('wjx-progress-status');
const pauseEl = document.getElementById('wjx-progress-pause');
if (countEl) {
countEl.textContent = `第${progress.current}份/共${progress.total}份`;
}
if (percentEl) {
percentEl.textContent = `进度 ${progress.percent}%`;
}
if (barEl) {
barEl.style.width = `${progress.percent}%`;
}
if (statusEl) {
if (progress.error) {
statusEl.textContent = `${progress.error}${progress.retryCount ? `(已重试 ${progress.retryCount} 次)` : ''}`;
statusEl.style.display = '';
} else if (progress.paused) {
statusEl.textContent = '任务已暂停';
statusEl.style.display = '';
} else if (progress.failed > 0) {
statusEl.textContent = `已成功 ${progress.succeeded} 份,失败 ${progress.failed} 份`;
statusEl.style.display = '';
} else {
statusEl.style.display = 'none';
}
}
}
function showAutomationProgress() {
const progress = getAutomationProgress();
if (!progress) {
return;
}
const modal = ensureProgressModal();
updateAutomationProgressUI(progress);
modal.classList.add('is-visible');
}
function updateAutomationProgress() {
const progress = getAutomationProgress();
if (!progress) {
return;
}
const modal = document.querySelector('.wjx-progress-modal') || ensureProgressModal();
updateAutomationProgressUI(progress);
modal.classList.add('is-visible');
}
function hideAutomationProgress() {
const modal = document.querySelector('.wjx-progress-modal');
if (modal) {
modal.classList.remove('is-visible');
}
}
let toastTimer = null;
function showToast(message, options) {
const settings = Object.assign({ html: false, duration: 2200, actionable: false, href: '' }, options);
let toast = document.querySelector('.wjx-toast');
if (!toast) {
toast = document.createElement('div');
toast.className = 'wjx-toast';
document.body.appendChild(toast);
}
toast.classList.toggle('is-actionable', settings.actionable);
toast.onclick = null;
if (settings.html) {
toast.innerHTML = message;
} else {
toast.textContent = message;
}
if (settings.actionable && settings.href) {
toast.onclick = () => {
window.open(settings.href, '_blank', 'noopener,noreferrer');
};
}
toast.classList.add('is-visible');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.classList.remove('is-visible');
}, settings.duration);
}
let questionObserver = null;
let rescanTimer = null;
function scheduleRescan() {
setTimeout(() => {
if (document.getElementById(PANEL_ID)) {
refreshQuestions({ silent: true, preserveInputs: true });
}
}, 1500);
if (questionObserver) {
return;
}
const root = document.querySelector('#divQuestion, #divContent, form') || document.body;
questionObserver = new MutationObserver((mutations) => {
const hasQuestionMutation = mutations.some((mutation) => {
return Array.from(mutation.addedNodes).some((node) => {
return node instanceof HTMLElement && !node.closest(`#${PANEL_ID}`);
});
});
if (!hasQuestionMutation) {
return;
}
clearTimeout(rescanTimer);
rescanTimer = setTimeout(() => {
if (document.getElementById(PANEL_ID) && !isAutomationActive()) {
refreshQuestions({ silent: true, preserveInputs: true });
}
}, 450);
});
questionObserver.observe(root, { childList: true, subtree: true });
}
function startAutomation() {
const count = State.config.targetCount;
if (count <= 0) {
showToast('请输入有效的设计份数。');
return;
}
if (!validateCountModeBeforeStart()) {
return;
}
saveAutomationSession({
surveyKey: State.surveyKey,
remaining: count,
total: count,
failed: 0,
paused: false,
retryCount: 0,
maxRetries: 2,
attemptQuotaSnapshot: null,
completionHandled: false,
url: location.href
});
if (isCountMode()) {
initQuotaState();
} else {
clearQuotaState();
}
beginAutomationSession();
clearAutomationStoppedFlag();
executeAutoFillAndSubmit();
}
function syncQuestionConfigForCurrentPage() {
const scannedQuestions = scanQuestions();
if (!scannedQuestions.length) {
return;
}
const existingQuestions = Array.isArray(State.config.questions) ? State.config.questions : [];
const mergedQuestions = existingQuestions.slice();
const newQuestionKeys = new Set();
let changed = false;
scannedQuestions.forEach((question) => {
const index = mergedQuestions.findIndex((item) => String(item.id) === String(question.id) && item.type === question.type);
if (index >= 0) {
mergedQuestions[index] = mergeQuestionConfig(mergedQuestions[index], question);
} else {
const nextQuestion = Object.assign({}, question);
mergedQuestions.push(nextQuestion);
newQuestionKeys.add(`${String(nextQuestion.id)}:${nextQuestion.type}`);
changed = true;
}
});
if (changed) {
State.config.questions = mergedQuestions.sort((a, b) => Number(a.order || 0) - Number(b.order || 0));
if (isCountMode()) {
State.config.questions.forEach((question) => {
if (!newQuestionKeys.has(`${String(question.id)}:${question.type}`) || !supportsCountDistribution(question.type)) {
return;
}
const optionCount = Array.isArray(question.optionLabels) ? question.optionLabels.length : 0;
question.weights = distributeCountsEvenly(optionCount, getExpectedCountTotalForQuestion(question));
});
}
saveConfig();
renderStats();
renderQuestionList();
updateCountModeHints();
}
if (isCountMode()) {
ensureQuotaState();
}
}
function mergeQuestionConfig(previous, question) {
const next = Object.assign({}, question);
if (!previous) {
return next;
}
next.enabled = previous.enabled !== false;
if (question.type === 'text') {
next.content = Array.isArray(previous.content) && previous.content.length ? previous.content.slice() : question.content.slice();
} else if (question.type === 'slide') {
next.minScore = clampScore(previous.minScore, question.minScore);
next.maxScore = clampScore(previous.maxScore, question.maxScore);
if (next.maxScore < next.minScore) {
next.maxScore = next.minScore;
}
} else if (Array.isArray(previous.weights) && previous.weights.length === question.weights.length) {
next.weights = previous.weights.slice();
}
return next;
}
async function fillAllQuestions(options) {
const settings = Object.assign({ stopBeforeSubmit: false }, options);
const filledQuestionIds = new Set();
for (let pageIndex = 0; pageIndex < MAX_AUTO_PAGES; pageIndex++) {
if (isAutomationInterrupted()) {
return { submitted: false, aborted: true };
}
syncQuestionConfigForCurrentPage();
updateAllRelationVisibility();
const visibleNodes = getVisibleQuestionNodes();
if (!visibleNodes.length) {
throw new Error('当前页面未识别到可填写题目');
}
const pageSignature = getPageStateSignature();
let filledOnPage = false;
for (let pass = 0; pass < 8; pass++) {
if (isAutomationInterrupted()) {
return { submitted: false, aborted: true };
}
updateAllRelationVisibility();
let filledInPass = false;
for (const node of getVisibleQuestionNodes()) {
if (isAutomationInterrupted()) {
return { submitted: false, aborted: true };
}
const questionId = extractQuestionId(node, 0);
if (filledQuestionIds.has(questionId)) {
continue;
}
const relations = parseRelations(node);
if (relations.length && !isRelationSatisfied(relations)) {
continue;
}
const question = State.config.questions.find((item) => String(item.id) === String(questionId));
if (!question || question.enabled === false) {
continue;
}
await fillQuestion(question, node);
filledQuestionIds.add(questionId);
updateAllRelationVisibility();
await scaledDelay(200, 500);
filledInPass = true;
filledOnPage = true;
}
if (!filledInPass) {
break;
}
}
if (!filledOnPage) {
const pendingVisible = getVisibleQuestionNodes().filter((node) => {
const questionId = extractQuestionId(node, 0);
if (filledQuestionIds.has(questionId)) {
return false;
}
const relations = parseRelations(node);
const question = State.config.questions.find((item) => String(item.id) === String(questionId));
if (!question || question.enabled === false) {
return false;
}
return !relations.length || isRelationSatisfied(relations);
});
if (pendingVisible.length) {
throw new Error('存在未填写的关联题目,请检查父题选项是否已选中');
}
}
const action = await advanceSurvey(pageSignature);
if (action === 'next') {
continue;
}
if (action === 'submit') {
if (settings.stopBeforeSubmit) {
return { submitted: false, readyToSubmit: true };
}
await scaledDelay(800, 1500);
const result = await submitSurvey();
if (!result || !result.ok) {
recordAutomationFailure();
updateAutomationProgress();
if (result && result.reason === 'no-submit-button') {
throw new Error('进入提交分支后仍未找到提交按钮');
}
throw new Error('进入提交分支后提交未成功(可能验证码未通过)');
}
return { submitted: true };
}
if (action === 'stalled') {
throw new Error('检测到下一页按钮,但页面未成功翻页');
}
return { submitted: false };
}
throw new Error(`已达到自动翻页上限 ${MAX_AUTO_PAGES} 页,请检查是否卡在分页或验证环节`);
}
async function fillQuestion(question, node) {
switch (question.type) {
case 'radio':
case 'dropdown':
case 'rating':
const index = pickOptionIndexForQuestion(question);
const selected = clickOption(node, index, question.type);
if (isCountMode() && selected) {
consumeFlatQuota(question.id, index);
}
updateAllRelationVisibility();
break;
case 'checkbox':
const indices = pickCheckboxIndicesForQuestion(question);
const selectedIndices = indices.filter((idx) => clickOption(node, idx, question.type));
if (isCountMode()) {
selectedIndices.forEach((idx) => consumeFlatQuota(question.id, idx));
}
updateAllRelationVisibility();
break;
case 'text':
const text = pickRandomText(question.content, question);
fillText(node, text);
break;
case 'slide':
fillSlide(node, question.minScore, question.maxScore);
break;
case 'matrix':
fillMatrix(node, question, question.weights);
break;
case 'location':
await fillLocation(node);
break;
case 'sorting':
await fillSorting(node);
break;
}
}
async function fillLocation(node) {
const trigger = node.querySelector('.city-container, .divProvince, .ui-select, textarea, input[type="text"]');
if (trigger) {
trigger.click();
await scaledDelay(700, 1200);
const clickAndPick = async (selector) => {
const btn = document.querySelector(selector);
if (btn) {
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
await scaledDelay(350, 700);
const options = document.querySelectorAll('[id$=-results] > li:not(:first-child)');
if (options.length > 0) {
const randomOpt = options[Math.floor(Math.random() * options.length)];
randomOpt.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
await scaledDelay(350, 700);
return true;
}
}
return false;
};
const hasProvince = await clickAndPick('.divProvince, [id^=select2-province]');
if (hasProvince) {
await clickAndPick('.divCity, [id^=select2-city]');
await clickAndPick('.divArea, [id^=select2-area]');
const saveBtn = document.querySelector('.layer_save_btn a, .ui-dialog .save_btn');
if (saveBtn) saveBtn.click();
}
}
}
function fisherYatesShuffle(list) {
const result = list.slice();
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = result[i];
result[i] = result[j];
result[j] = tmp;
}
return result;
}
async function fillSorting(node) {
const items = Array.from(node.querySelectorAll('.reorder-list > li, .ui-sortable > li, .ui-sortable-handle, .reorder-item, ul > li'))
.filter((item) => cleanText(item.textContent));
if (items.length > 0) {
// Fisher–Yates shuffle 到新数组,避免就地修改污染 DOM 顺序
const shuffled = fisherYatesShuffle(items);
for (const item of shuffled) {
item.click();
await scaledDelay(220, 420);
}
}
}
function pickWeightedIndex(weights) {
const list = Array.isArray(weights) ? weights : [];
if (!list.length) {
return 0;
}
const total = list.reduce((a, b) => a + Math.max(0, Number(b) || 0), 0);
if (total <= 0) return Math.floor(Math.random() * list.length);
let random = Math.random() * total;
for (let i = 0; i < list.length; i++) {
if (random < list[i]) return i;
random -= list[i];
}
return list.length - 1;
}
function pickMultipleIndices(weights, minSelections, maxSelections) {
const list = Array.isArray(weights) ? weights : [];
const totalOptions = list.length;
if (!totalOptions) {
return [];
}
const minCount = Math.max(0, Math.min(Number(minSelections) || 0, totalOptions));
const defaultMax = Math.min(totalOptions, Math.max(minCount || 1, Math.min(3, totalOptions)));
const maxCount = Math.max(minCount, Math.min(Number(maxSelections) || defaultMax, totalOptions));
const count = minCount >= maxCount ? Math.max(1, minCount || 1) : randomInt(minCount || 1, maxCount);
const pool = list.map((weight, index) => ({
index,
weight: Math.max(1, Number(weight) || 1)
}));
const results = [];
while (results.length < count && pool.length > 0) {
const total = pool.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * total;
let selectedIndex = 0;
for (let i = 0; i < pool.length; i++) {
random -= pool[i].weight;
if (random <= 0) {
selectedIndex = i;
break;
}
}
results.push(pool[selectedIndex].index);
pool.splice(selectedIndex, 1);
}
return results;
}
const usedTextAnswers = new Map();
function pickRandomText(texts, question) {
const source = Array.isArray(texts) && texts.length ? texts : DEFAULT_TEXT_LIBRARY;
const key = String(question && question.id || 'default');
const used = usedTextAnswers.get(key) || new Set();
let candidates = source.filter((value) => !used.has(value));
if (!candidates.length) {
used.clear();
candidates = source.slice();
}
const selected = candidates[Math.floor(Math.random() * candidates.length)] || '';
used.add(selected);
usedTextAnswers.set(key, used);
return String(selected)
.replace(/\{序号\}/g, String((getAutomationProgress() || {}).current || 1))
.replace(/\{随机数\}/g, String(randomInt(100, 999)));
}
function randomInt(min, max) {
return min + Math.floor(Math.random() * (max - min + 1));
}
function isNodeVisible(node) {
if (!(node instanceof HTMLElement)) {
return false;
}
if (node.hasAttribute('hidden')) {
return false;
}
const style = window.getComputedStyle(node);
return style.display !== 'none' && style.visibility !== 'hidden' && node.getClientRects().length > 0;
}
function getVisibleQuestionNodes() {
return findQuestionNodes().filter(isNodeVisible);
}
function getButtonText(button) {
if (!(button instanceof HTMLElement)) {
return '';
}
return cleanText(button.textContent || button.value || button.getAttribute('aria-label') || '');
}
function getVisibleActionButtons() {
return Array.from(document.querySelectorAll(
'#divNext, #ctlNext, #divStart, #submit_button, .submitbutton, .btn-submit, .nextbutton, .prevbutton, a.button, .button, button, input[type="button"], input[type="submit"], [onclick*="next"], [onclick*="Next"], [onclick*="page"], [onclick*="Page"], [id*="Next"], [id*="next"]'
)).filter((button) => {
return isNodeVisible(button) && !button.closest(`#${PANEL_ID}`);
});
}
function isNextButtonText(text) {
return /\u4e0b\u4e00\u9875|\u4e0b\u4e00\u6b65|\u4e0b\u4e00\u9898|\u7ee7\u7eed|next|continue|>/i.test(text);
}
function isStartButtonText(text) {
return /\u5f00\u59cb|\u8fdb\u5165|\u7ee7\u7eed|\u53c2\u4e0e|\u9a6c\u4e0a|\u7acb\u5373/.test(text);
}
function isSubmitButtonText(text) {
return /\u63d0\u4ea4|\u4ea4\u5377|\u5b8c\u6210|\u53d1\u9001/.test(text);
}
function getElementOnclickText(element) {
return String(element && element.getAttribute && element.getAttribute('onclick') || '');
}
function getCoverStartButton() {
return getVisibleActionButtons().find((button) => {
const text = getButtonText(button);
if (button.matches('#divStart')) {
return true;
}
if (button.matches('#ctlNext, #divNext') && (isStartButtonText(text) || isNextButtonText(text))) {
return true;
}
return isStartButtonText(text);
}) || null;
}
function getNextPageButton() {
const exact = document.querySelector('#divNext');
if (exact && isNodeVisible(exact)) {
return resolveActionTarget(exact);
}
const candidates = getVisibleActionButtons();
return candidates.find((button) => {
const text = getButtonText(button);
const onclick = getElementOnclickText(button);
if (button.id === 'divNext' || button.id === 'ctlNext') {
return !isSubmitButtonText(text);
}
return isNextButtonText(text) || /next|page/i.test(onclick) || /next/i.test(button.id || '');
}) || null;
}
function getSubmitButton() {
const exactCandidates = Array.from(document.querySelectorAll('#ctlNext, #submit_button, .submitbutton, .btn-submit'));
const exact = exactCandidates.find((button) => isNodeVisible(button));
if (exact) {
return resolveActionTarget(exact);
}
return getVisibleActionButtons().find((button) => {
const text = getButtonText(button);
if (button.matches('#submit_button, .submitbutton, .btn-submit')) {
return true;
}
if (button.matches('#ctlNext')) {
return isSubmitButtonText(text) && !isNextButtonText(text);
}
return isSubmitButtonText(text);
}) || null;
}
function getPageStateSignature() {
const fieldsets = Array.from(document.querySelectorAll('fieldset.fieldset, [pg]'))
.filter(isNodeVisible)
.map((node) => `${node.id || ''}:${node.getAttribute('pg') || ''}`)
.join(',');
const questionIds = getVisibleQuestionNodes().map((node) => extractQuestionId(node, 0)).join(',');
const nativePage = typeof window.cur_page === 'number' ? String(window.cur_page) : '';
return `${nativePage}|${fieldsets}|${questionIds}|${getNextPageButton() ? 'N' : ''}${getSubmitButton() ? 'S' : ''}`;
}
async function waitForNextPage(previousSignature) {
const start = Date.now();
const previousNodeCount = getVisibleQuestionNodes().length;
while (Date.now() - start < 8000) {
if (isAutomationInterrupted()) {
return false;
}
await sleep(250);
updateAllRelationVisibility();
const currentNodes = getVisibleQuestionNodes();
const currentSignature = getPageStateSignature();
if (currentSignature && currentSignature !== previousSignature) {
return true;
}
if (currentNodes.length && currentNodes.length !== previousNodeCount) {
return true;
}
if (!getNextPageButton() && getSubmitButton()) {
return true;
}
}
return false;
}
async function waitForQuestionPage() {
const start = Date.now();
while (Date.now() - start < 8000) {
await sleep(250);
if (getVisibleQuestionNodes().length > 0) {
return true;
}
}
return false;
}
async function ensureSurveyStarted() {
if (getVisibleQuestionNodes().length > 0) {
return true;
}
const startButton = getCoverStartButton();
if (!startButton) {
return false;
}
clickActionButton(startButton);
return waitForQuestionPage();
}
async function advanceSurvey(previousSignature) {
const nextPageButton = getNextPageButton();
if (nextPageButton) {
clickActionButton(nextPageButton);
let pageChanged = await waitForNextPage(previousSignature);
if (!pageChanged) {
triggerNativeNextPage(nextPageButton);
pageChanged = await waitForNextPage(previousSignature);
}
return pageChanged ? 'next' : 'stalled';
}
return getSubmitButton() ? 'submit' : null;
}
function resolveActionTarget(element) {
if (!(element instanceof HTMLElement)) {
return null;
}
if (element.matches('a, button, input, [onclick], [role="button"]')) {
return element;
}
return Array.from(element.querySelectorAll('a, button, input, [onclick], [role="button"]'))
.find((candidate) => isNodeVisible(candidate)) || element;
}
function clickActionButton(button) {
const actionTarget = resolveActionTarget(button);
if (!actionTarget) {
return false;
}
try {
actionTarget.scrollIntoView({ block: 'center', inline: 'center' });
} catch (error) {
logWarning('滚动到按钮失败', error);
}
const rect = actionTarget.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = rect.top + rect.height / 2;
const target = getEventTargetAt(clientX, clientY, actionTarget);
dispatchMouseLikeEvent(target, 'pointerdown', clientX, clientY, { buttons: 1, pressure: 0.5 });
dispatchMouseLikeEvent(target, 'mousedown', clientX, clientY, { buttons: 1 });
dispatchTouchLikeEvent(target, 'touchstart', clientX, clientY);
dispatchMouseLikeEvent(target, 'pointerup', clientX, clientY, { buttons: 0, pressure: 0 });
dispatchMouseLikeEvent(target, 'mouseup', clientX, clientY, { buttons: 0 });
dispatchTouchLikeEvent(target, 'touchend', clientX, clientY);
if (typeof actionTarget.click === 'function') {
actionTarget.click();
}
return true;
}
function dispatchTouchLikeEvent(target, type, clientX, clientY) {
if (!(target instanceof HTMLElement) || typeof TouchEvent === 'undefined') {
return;
}
try {
const touch = new Touch({ identifier: Date.now(), target, clientX, clientY, screenX: window.screenX + clientX, screenY: window.screenY + clientY });
target.dispatchEvent(new TouchEvent(type, { bubbles: true, cancelable: true, touches: type === 'touchend' ? [] : [touch], targetTouches: type === 'touchend' ? [] : [touch], changedTouches: [touch] }));
} catch (error) {
logWarning('派发触摸事件失败', error);
}
}
function triggerNativeNextPage(button) {
const actionTarget = resolveActionTarget(button) || button;
const onclick = getElementOnclickText(actionTarget);
const names = ['show_next_page', 'to_next_page', 'PDF_nextPage', 'nextPage', 'NextPage', 'showNextPage', 'goNextPage', 'jumpNextPage'];
for (const name of names) {
if (typeof window[name] === 'function') {
try {
window[name](actionTarget);
return;
} catch (error) {
try {
window[name]();
return;
} catch (innerError) {
logWarning(`调用原生翻页函数 ${name} 失败`, innerError);
}
}
}
}
if (pauseEl) {
pauseEl.textContent = progress.paused ? '继续' : '暂停';
}
if (onclick) {
try {
new Function('event', onclick).call(actionTarget, new MouseEvent('click', { bubbles: true, cancelable: true }));
} catch (error) {}
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getSpeedFactor() {
if (State.config.speedProfile === 'slow') {
return 1.45;
}
if (State.config.speedProfile === 'fast') {
return 0.68;
}
return 1;
}
function scaledDelay(min, max) {
return sleep((min + Math.random() * (max - min)) * getSpeedFactor());
}
function getChoiceElements(node, type) {
const controlGroup = node.querySelector('.ui-controlgroup');
if (type === 'checkbox' && controlGroup) {
const directOptions = Array.from(controlGroup.children).filter((item) => item.matches('.ui-checkbox, li, .option-item'));
if (directOptions.length) {
return directOptions;
}
}
if ((type === 'radio' || type === 'rating') && controlGroup) {
const directOptions = Array.from(controlGroup.children).filter((item) => item.matches('.ui-radio, li, .option-item'));
if (directOptions.length) {
return directOptions;
}
}
if (type === 'checkbox') {
const options = Array.from(node.querySelectorAll('.ui-controlgroup .ui-checkbox, .ulradiocheck > li, .option-item'));
return options.length ? options : Array.from(node.querySelectorAll('input[type="checkbox"]'));
}
if (type === 'radio' || type === 'rating') {
const options = Array.from(node.querySelectorAll('.ui-controlgroup .ui-radio, .ulradiocheck > li, .scale-div li, .rating-star, .onscore, .starlevel, .option-item'));
return options.length ? options : Array.from(node.querySelectorAll('input[type="radio"]'));
}
return [];
}
function isChoiceSelected(option, type) {
if (!option) {
return false;
}
const inputSelector = type === 'checkbox' ? 'input[type="checkbox"]' : 'input[type="radio"]';
const input = option.matches(inputSelector) ? option : option.querySelector(inputSelector);
if (input) {
return !!input.checked;
}
return option.classList.contains('checked') ||
option.classList.contains('active') ||
option.classList.contains('on') ||
option.classList.contains('rate-on') ||
option.querySelector('.jqchecked, .checked, .active, .rate-on, a.checked, a.jqchecked') !== null;
}
function clickChoiceElement(option, type) {
if (!option) {
return false;
}
const ratingTarget = type === 'rating'
? (option.matches('a[val], [dval], .rate-off, .rate-on, .rating-star, .onscore, .starlevel')
? option
: option.querySelector('a[val], [dval], .rate-off, .rate-on, .rating-star, .onscore, .starlevel'))
: null;
const targets = [
ratingTarget,
option,
option.querySelector('.label'),
option.querySelector('.jqcheck, .jqradio, a.jqcheck, a.jqradio'),
option.querySelector('label'),
option.querySelector('input')
].filter(Boolean);
for (const target of targets) {
['mousedown', 'mouseup'].forEach((eventName) => {
target.dispatchEvent(new MouseEvent(eventName, { bubbles: true, cancelable: true }));
});
if (typeof target.click === 'function') {
target.click();
}
if (isChoiceSelected(option, type)) {
break;
}
}
const otherInput = option.querySelector('input.OtherText, input.OtherRadioText, .OtherText, .OtherRadioText, .ui-text input[type="text"]');
if (otherInput && !otherInput.value) {
otherInput.value = '其他';
otherInput.dispatchEvent(new Event('input', { bubbles: true }));
otherInput.dispatchEvent(new Event('change', { bubbles: true }));
}
return isChoiceSelected(option, type);
}
function clickOption(node, index, type) {
if (type === 'dropdown') {
const select = node.querySelector('select');
if (select && select.options.length > index) {
let realIndex = index;
// If the first option is a placeholder, we skip it
if (select.options[0] && PLACEHOLDER_OPTION_PATTERN.test(cleanText(select.options[0].textContent))) {
realIndex += 1;
}
if (select.options[realIndex]) {
select.selectedIndex = realIndex;
select.dispatchEvent(new Event('change', { bubbles: true }));
return select.selectedIndex === realIndex;
}
}
return false;
}
const options = getChoiceElements(node, type);
if (options[index]) {
const option = options[index];
let selected = clickChoiceElement(option, type);
if (!isChoiceSelected(option, type)) {
const fallback = option.querySelector(type === 'checkbox' ? 'input[type="checkbox"]' : 'input[type="radio"]');
if (fallback && typeof fallback.click === 'function') {
fallback.click();
selected = isChoiceSelected(option, type);
}
}
return selected || isChoiceSelected(option, type);
}
return false;
}
function fillText(node, text) {
const inputs = Array.from(node.querySelectorAll('textarea, input[type="text"], input[type="tel"], input[type="email"], input[type="number"]'))
.filter((input) => isElementVisible(input) && !input.classList.contains('OtherText') && !input.classList.contains('OtherRadioText'));
inputs.forEach((input) => {
input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
});
}
function fillSlide(node, minScore, maxScore) {
const lower = clampScore(minScore, 1);
const upper = Math.max(lower, clampScore(maxScore, 100));
const value = randomInt(lower, upper);
const input = node.querySelector('input[type="range"], input[type="hidden"], input[type="number"], input[type="text"][id^="q"], input[name^="q"]');
if (!input) {
return;
}
input.value = String(value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
const visibleDisplay = node.querySelector('.slider-value, .scale-value, .slider_num, .slider-value-text');
if (visibleDisplay) {
visibleDisplay.textContent = String(value);
}
}
function getMatrixRowOptions(row) {
const directControls = Array.from(row.querySelectorAll('.ui-radio, .ui-checkbox, .jqRadio, .jqCheckbox'));
if (directControls.length) {
return directControls;
}
const rateAnchors = Array.from(row.querySelectorAll('a[dval], .rate-off, .rate-on, .rate-offlarge, .rate-onlarge'));
if (rateAnchors.length) {
return rateAnchors;
}
const cells = Array.from(row.querySelectorAll('td, th')).slice(1);
return cells.filter((cell) =>
cell.querySelector('input[type="radio"], input[type="checkbox"], .ui-radio, .ui-checkbox, a[dval], .rate-off, .rate-on') ||
cleanText(cell.textContent)
);
}
function fillMatrix(node, question, weights) {
const rows = node.querySelectorAll('tr[rowindex], tbody tr');
let rowIndex = 0;
rows.forEach((row) => {
if (!cleanText(row.textContent)) {
return;
}
const index = pickMatrixRowIndex(question, rowIndex);
const options = getMatrixRowOptions(row);
if (options[index]) {
const option = options[index];
if (option.matches('a[dval], .rate-off, .rate-on, .rate-offlarge, .rate-onlarge')) {
option.click();
} else if (option.matches('td, th')) {
const clickable = option.querySelector('a[dval], .rate-off, .rate-on, .rate-offlarge, .rate-onlarge, .ui-radio, .ui-checkbox, input[type="radio"], input[type="checkbox"]');
if (clickable) {
clickable.click();
} else {
option.click();
}
} else {
clickChoiceElement(option, option.querySelector('input[type="checkbox"]') ? 'checkbox' : 'radio');
}
if (isCountMode()) {
consumeMatrixQuota(question.id, rowIndex, index);
}
}
rowIndex += 1;
});
}
function isElementVisible(element) {
if (!(element instanceof HTMLElement)) {
return false;
}
if (element.hasAttribute('hidden')) {
return false;
}
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
return false;
}
return element.getClientRects().length > 0;
}
async function waitForCondition(checker, timeoutMs, intervalMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
// 自动化被用户停止后立即跳出轮询,避免“停不下来”
if (isAutomationInterrupted()) {
return checker();
}
if (checker()) {
return true;
}
await sleep(intervalMs);
}
return false;
}
const SECURITY_DIALOG_POLL_MS = 40;
const SECURITY_DIALOG_CONFIRM_DELAY_MIN_MS = 800;
const SECURITY_DIALOG_CONFIRM_DELAY_MAX_MS = 2000;
const SECURITY_DIALOG_POST_CLICK_MS = 350;
function getSecurityCheckDialog() {
const layers = document.querySelectorAll('.layui-layer-dialog');
for (let i = 0; i < layers.length; i++) {
const layer = layers[i];
if (!isElementVisible(layer)) {
continue;
}
const content = layer.querySelector('.layui-layer-content');
if (content && /需要安全校验/.test(cleanText(content.textContent))) {
return layer;
}
}
return null;
}
function getSecurityCheckConfirmButton(layer) {
const root = layer || getSecurityCheckDialog();
if (!root) {
return null;
}
const directBtn = root.querySelector('.layui-layer-btn0');
if (directBtn && isElementVisible(directBtn)) {
return directBtn;
}
return Array.from(root.querySelectorAll('.layui-layer-btn a, .layui-layer-btn button')).find((btn) => {
return isElementVisible(btn) && /确认|确定|知道了/.test(getButtonText(btn));
}) || null;
}
function clickConfirmFast(element) {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = rect.top + rect.height / 2;
dispatchMouseLikeEvent(element, 'pointerdown', clientX, clientY, { buttons: 1, pressure: 0.5 });
dispatchMouseLikeEvent(element, 'mousedown', clientX, clientY, { buttons: 1 });
dispatchMouseLikeEvent(element, 'pointerup', clientX, clientY, { buttons: 0, pressure: 0 });
dispatchMouseLikeEvent(element, 'mouseup', clientX, clientY, { buttons: 0 });
if (typeof element.click === 'function') {
element.click();
}
return true;
}
function getAliyunCaptchaPopup() {
const popup = document.querySelector('#aliyunCaptcha-window-popup');
return popup && isElementVisible(popup) ? popup : null;
}
function getAliyunCaptchaCheckboxIcon() {
const icon = document.querySelector('#aliyunCaptcha-checkbox-icon.aliyunCaptcha-checkbox-icon') ||
document.getElementById('aliyunCaptcha-checkbox-icon');
return icon && isElementVisible(icon) ? icon : null;
}
function randomFloat(min, max) {
return min + Math.random() * (max - min);
}
function getRandomPointInElement(element, paddingRatio) {
const ratio = Number.isFinite(paddingRatio) ? paddingRatio : 0.22;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return { clientX: rect.left, clientY: rect.top };
}
const padX = Math.min(rect.width * ratio, rect.width * 0.42);
const padY = Math.min(rect.height * ratio, rect.height * 0.42);
const minX = rect.left + padX;
const maxX = Math.max(minX, rect.right - padX);
const minY = rect.top + padY;
const maxY = Math.max(minY, rect.bottom - padY);
return {
clientX: randomFloat(minX, maxX),
clientY: randomFloat(minY, maxY)
};
}
function buildPointerInit(clientX, clientY, extra) {
return Object.assign({
bubbles: true,
cancelable: true,
view: window,
clientX,
clientY,
screenX: window.screenX + clientX,
screenY: window.screenY + clientY,
button: 0,
buttons: 0,
detail: 1,
pointerId: 1,
pointerType: 'mouse',
isPrimary: true,
width: 1,
height: 1,
pressure: 0.5
}, extra || {});
}
function dispatchMouseLikeEvent(target, type, clientX, clientY, extra) {
if (!(target instanceof HTMLElement)) {
return;
}
const mouseInit = buildPointerInit(clientX, clientY, extra);
if (type.startsWith('pointer') && typeof PointerEvent !== 'undefined') {
const pointerInit = Object.assign({}, mouseInit, {
pressure: type === 'pointerdown' ? 0.5 : 0
});
target.dispatchEvent(new PointerEvent(type, pointerInit));
}
if (/^mouse|click|dblclick$/.test(type)) {
target.dispatchEvent(new MouseEvent(type, mouseInit));
}
}
function getEventTargetAt(clientX, clientY, fallback) {
const hit = document.elementFromPoint(clientX, clientY);
return hit instanceof HTMLElement ? hit : fallback;
}
function collectEventTargets(hitTarget, rootElement) {
const chain = [];
let node = hitTarget;
while (node instanceof HTMLElement) {
chain.push(node);
if (node === rootElement) {
break;
}
node = node.parentElement;
}
if (rootElement instanceof HTMLElement && !chain.includes(rootElement)) {
chain.push(rootElement);
}
return chain;
}
async function simulateMouseMovePath(endX, endY, startX, startY) {
const steps = randomInt(10, 22);
for (let i = 1; i <= steps; i++) {
const progress = i / steps;
const eased = progress * progress * (3 - 2 * progress);
const clientX = startX + (endX - startX) * eased + randomFloat(-1.8, 1.8);
const clientY = startY + (endY - startY) * eased + randomFloat(-1.8, 1.8);
const target = getEventTargetAt(clientX, clientY, document.body);
dispatchMouseLikeEvent(target, 'mousemove', clientX, clientY, { buttons: 0 });
if (typeof PointerEvent !== 'undefined') {
dispatchMouseLikeEvent(target, 'pointermove', clientX, clientY, { buttons: 0 });
}
await sleep(randomInt(14, 42));
}
}
async function simulateHumanClick(element, options) {
if (!(element instanceof HTMLElement)) {
return false;
}
const settings = Object.assign({
paddingRatio: 0.22,
preDelay: [120, 380],
hoverDelay: [60, 180],
pressDelay: [55, 140],
postDelay: [80, 200],
approachDistance: [45, 110]
}, options || {});
await sleep(randomInt(settings.preDelay[0], settings.preDelay[1]));
const point = getRandomPointInElement(element, settings.paddingRatio);
const clientX = point.clientX;
const clientY = point.clientY;
const approach = settings.approachDistance;
const startX = clientX + randomFloat(-approach[1], approach[1]);
const startY = clientY + randomFloat(-approach[0], approach[0]);
await simulateMouseMovePath(clientX, clientY, startX, startY);
await sleep(randomInt(settings.hoverDelay[0], settings.hoverDelay[1]));
const hitTarget = getEventTargetAt(clientX, clientY, element);
const eventTargets = collectEventTargets(hitTarget, element);
eventTargets.forEach((target) => {
dispatchMouseLikeEvent(target, 'mouseover', clientX, clientY, { buttons: 0 });
dispatchMouseLikeEvent(target, 'mouseenter', clientX, clientY, { buttons: 0 });
if (typeof PointerEvent !== 'undefined') {
dispatchMouseLikeEvent(target, 'pointerover', clientX, clientY, { buttons: 0 });
dispatchMouseLikeEvent(target, 'pointerenter', clientX, clientY, { buttons: 0 });
}
});
await sleep(randomInt(settings.pressDelay[0], settings.pressDelay[1]));
const clickTarget = hitTarget.closest('#aliyunCaptcha-checkbox-icon, #aliyunCaptcha-checkbox-body, #aliyunCaptcha-checkbox-left, #aliyunCaptcha-checkbox-wrapper') || element;
dispatchMouseLikeEvent(clickTarget, 'pointerdown', clientX, clientY, { buttons: 1, pressure: 0.5 });
dispatchMouseLikeEvent(clickTarget, 'mousedown', clientX, clientY, { buttons: 1 });
await sleep(randomInt(45, 125));
dispatchMouseLikeEvent(clickTarget, 'pointerup', clientX, clientY, { buttons: 0, pressure: 0 });
dispatchMouseLikeEvent(clickTarget, 'mouseup', clientX, clientY, { buttons: 0 });
dispatchMouseLikeEvent(clickTarget, 'click', clientX, clientY, { buttons: 0 });
await sleep(randomInt(settings.postDelay[0], settings.postDelay[1]));
return true;
}
async function handleAliyunCaptchaCheckbox(timeoutMs) {
const appeared = await waitForCondition(() => getAliyunCaptchaPopup(), timeoutMs, SECURITY_DIALOG_POLL_MS);
if (!appeared) {
return false;
}
return performAliyunCaptchaClick(timeoutMs);
}
async function performAliyunCaptchaClick(timeoutMs) {
const icon = getAliyunCaptchaCheckboxIcon() ||
document.querySelector('#aliyunCaptcha-checkbox-body') ||
document.querySelector('#aliyunCaptcha-checkbox-left');
if (!icon) {
return false;
}
await sleep(randomInt(450, 1100));
await simulateHumanClick(icon, {
paddingRatio: 0.18,
preDelay: [180, 520],
hoverDelay: [90, 240],
pressDelay: [70, 160],
postDelay: [120, 280],
approachDistance: [55, 130]
});
await sleep(randomInt(600, 1200));
const loading = document.querySelector('#aliyunCaptcha-loading');
if (loading && isElementVisible(loading)) {
await waitForCondition(() => {
return !isElementVisible(loading) || !!document.querySelector('.aliyunCaptcha-checkbox-icon-checked');
}, Math.min(timeoutMs, 12000), 400);
}
await waitForCondition(() => !getAliyunCaptchaPopup(), Math.min(timeoutMs, 15000), 400);
return true;
}
async function handleVerification(timeoutMs) {
const deadline = Date.now() + (timeoutMs || 12000);
let handled = false;
let securityConfirmed = false;
let aliyunClicked = false;
let sliderHandled = false;
let idleSince = Date.now();
while (Date.now() < deadline) {
let activeThisTick = false;
if (!securityConfirmed) {
const confirmBtn = getSecurityCheckConfirmButton(getSecurityCheckDialog());
if (confirmBtn) {
await sleep(randomInt(SECURITY_DIALOG_CONFIRM_DELAY_MIN_MS, SECURITY_DIALOG_CONFIRM_DELAY_MAX_MS));
clickConfirmFast(confirmBtn);
await sleep(SECURITY_DIALOG_POST_CLICK_MS);
securityConfirmed = true;
handled = true;
activeThisTick = true;
idleSince = Date.now();
}
}
if (!aliyunClicked && getAliyunCaptchaPopup()) {
if (await performAliyunCaptchaClick(deadline - Date.now())) {
aliyunClicked = true;
handled = true;
activeThisTick = true;
idleSince = Date.now();
}
}
if (!sliderHandled) {
const rectMask = document.querySelector('#rectMask');
if (rectMask && isElementVisible(rectMask)) {
rectMask.click();
await sleep(2000);
await simulateSlider();
sliderHandled = true;
handled = true;
activeThisTick = true;
idleSince = Date.now();
}
}
if (activeThisTick) {
await sleep(SECURITY_DIALOG_POLL_MS);
continue;
}
if (Date.now() - idleSince >= 1200) {
break;
}
await sleep(SECURITY_DIALOG_POLL_MS);
}
return handled;
}
async function submitSurvey() {
const submitBtn = getSubmitButton();
if (!submitBtn) {
return { ok: false, reason: 'no-submit-button' };
}
clickActionButton(submitBtn);
const verificationHandled = await handleVerification();
if (verificationHandled) {
const completedAfterVerification = await waitForCondition(() => isCompletionPage(), 1800, 250);
if (!completedAfterVerification) {
const retrySubmit = getSubmitButton();
if (retrySubmit && !getAliyunCaptchaPopup() && !getSecurityCheckDialog()) {
clickActionButton(retrySubmit);
await handleVerification();
}
}
} else {
await sleep(800);
}
// 只接受明确的完成页信号。按钮可能因加载、校验或 DOM 重绘暂时消失,不能据此判定成功。
const navigated = await waitForCondition(() => {
return isCompletionPage();
}, 8000, 250);
if (navigated) {
return { ok: true };
}
// 提交按钮仍在且没跳转 => 大概率验证码未通过 / 提交被拒
logWarning('提交未确认完成', { verificationHandled, stillHasSubmit: !!getSubmitButton() });
return { ok: false, reason: 'verification-failed', verificationFailed: !verificationHandled };
}
async function runOneAutomationSubmission() {
try {
showAutomationProgress();
captureAttemptQuotaSnapshot();
const started = await ensureSurveyStarted();
if (!isAutomationStillActive()) {
return;
}
if (!started) {
throw new Error('未识别到题目页或封面开始按钮');
}
const result = await fillAllQuestions();
if (!isAutomationStillActive()) {
return;
}
if (result && result.submitted) {
await advanceToNextSubmission();
return;
}
// Wait a bit to look more natural
await scaledDelay(1100, 1800);
if (!isAutomationStillActive()) {
return;
}
const submitResult = await submitSurvey();
if (!isAutomationStillActive()) {
return;
}
if (!submitResult.ok) {
if (submitResult.reason === 'no-submit-button') {
throw new Error('未找到提交按钮');
}
// 验证失败:记录失败数,停止本次会话避免盲目续跑
recordAutomationFailure();
updateAutomationProgress();
throw new Error('提交未成功(可能验证码未通过),已计入失败数');
}
// 提交成功:主动推进到下一份,不被动依赖感谢页 handleCompletion。
// 感谢页 URL 多变且 isCompletionPage 可能漏判,这里作为主续跑路径。
await advanceToNextSubmission();
} catch (error) {
logWarning('自动化单次提交出错', error);
const message = error.message || '未知错误';
if (/验证码|验证失败|提交未成功/.test(message)) {
endAutomationSession(`提交出错: ${message}`);
return;
}
const session = loadAutomationSession();
if (!session || isAutomationAborted()) {
endAutomationSession(`提交出错: ${message}`);
return;
}
restoreAttemptQuotaSnapshot(session);
session.retryCount = Math.max(0, Number(session.retryCount) || 0) + 1;
session.lastError = `本次执行失败:${message}`;
session.attemptQuotaSnapshot = null;
const maxRetries = Math.max(0, Number(session.maxRetries) || 2);
if (session.retryCount <= maxRetries) {
session.paused = false;
saveAutomationSession(session);
updateAutomationProgress();
showToast(`执行失败,正在进行第 ${session.retryCount} 次重试。`, { duration: 3500 });
scheduleNextSubmissionRedirect(session);
return;
}
session.paused = true;
saveAutomationSession(session);
updateAutomationProgress();
showToast('重试次数已用完,任务已暂停。', { duration: 5000 });
}
}
// 提交成功后减少剩余份数并跳回问卷首页续跑下一份,或正常结束。
async function advanceToNextSubmission() {
if (isCompletionPage()) {
handleCompletion();
return;
}
const session = loadAutomationSession();
if (!session) {
// 没有会话(可能已被 handleCompletion 处理过),交给后续 init 检查
return;
}
let remaining = Math.max(0, Number(session.remaining) || 0);
remaining = Math.max(0, remaining - 1);
session.remaining = remaining;
session.retryCount = 0;
session.lastError = '';
session.attemptQuotaSnapshot = null;
session.completionHandled = false;
saveAutomationSession(session);
if (remaining <= 0) {
endAutomationSession('所有自动化提交任务已完成!');
return;
}
ensureTotalCountForAutomation();
scheduleNextSubmissionRedirect(session);
}
async function executeAutoFillAndSubmit() {
if (!isAutomationStillActive()) {
return;
}
await runOneAutomationSubmission();
}
async function withQuotaRollback(callback) {
let snapshot = null;
try {
snapshot = localStorage.getItem(QUOTA_STATE_KEY);
} catch (error) {}
try {
return await callback();
} finally {
try {
if (snapshot === null) {
localStorage.removeItem(QUOTA_STATE_KEY);
} else {
localStorage.setItem(QUOTA_STATE_KEY, snapshot);
}
} catch (error) {}
}
}
async function previewSelectionPlan() {
syncStateFromDom();
saveConfig();
clearAutomationStoppedFlag();
const started = await ensureSurveyStarted();
if (!started) {
showToast('未识别到题目页或开始按钮,无法生成预览。');
return;
}
updateAllRelationVisibility();
const highlighted = [];
getVisibleQuestionNodes().forEach((node) => {
const questionId = extractQuestionId(node, 0);
const question = State.config.questions.find((item) => String(item.id) === String(questionId));
if (!question || question.enabled === false) {
return;
}
const relations = parseRelations(node);
if (relations.length && !isRelationSatisfied(relations)) {
return;
}
node.classList.add('wjx-preview-highlight');
highlighted.push(node);
if (['radio', 'rating', 'dropdown', 'checkbox'].includes(question.type)) {
const indexes = question.type === 'checkbox'
? pickCheckboxIndicesForQuestion(question)
: [pickOptionIndexForQuestion(question)];
const options = getChoiceElements(node, question.type);
indexes.forEach((index) => {
if (options[index]) {
options[index].classList.add('wjx-preview-highlight');
highlighted.push(options[index]);
}
});
}
});
showToast(`已高亮 ${highlighted.filter((node) => node.matches('.field, .div_question, .ui-field-contain')).length} 道题的预选结果。`);
setTimeout(() => highlighted.forEach((node) => node.classList.remove('wjx-preview-highlight')), 6000);
}
async function fillToSubmitAndPause() {
syncStateFromDom();
saveConfig();
clearAutomationStoppedFlag();
try {
const result = await withQuotaRollback(async () => {
const started = await ensureSurveyStarted();
if (!started) {
throw new Error('未识别到题目页或开始按钮');
}
return fillAllQuestions({ stopBeforeSubmit: true });
});
if (result && result.readyToSubmit) {
showToast('已填写到提交前,请检查答案后手动提交。', { duration: 5000 });
}
} catch (error) {
logWarning('填写到提交前失败', error);
showToast(`填写失败:${error.message || '未知错误'}`);
}
}
async function previewFillOnce() {
syncStateFromDom();
saveConfig();
if (!State.config.questions.length) {
showToast('当前没有可填写的题目配置。');
return;
}
let quotaSnapshot = null;
try {
quotaSnapshot = localStorage.getItem(QUOTA_STATE_KEY);
} catch (error) {}
try {
const started = await ensureSurveyStarted();
if (!started) {
showToast('未识别到题目页或开始按钮,无法预览。');
return;
}
// 仅填写当前可见页,不翻页、不提交
syncQuestionConfigForCurrentPage();
updateAllRelationVisibility();
const visibleNodes = getVisibleQuestionNodes();
if (!visibleNodes.length) {
showToast('当前页面未识别到可填写题目。');
return;
}
const filledIds = new Set();
for (let pass = 0; pass < 8; pass++) {
updateAllRelationVisibility();
let filledInPass = false;
for (const node of getVisibleQuestionNodes()) {
const questionId = extractQuestionId(node, 0);
if (filledIds.has(questionId)) {
continue;
}
const relations = parseRelations(node);
if (relations.length && !isRelationSatisfied(relations)) {
continue;
}
const question = State.config.questions.find((item) => String(item.id) === String(questionId));
if (!question || question.enabled === false) {
continue;
}
await fillQuestion(question, node);
filledIds.add(questionId);
updateAllRelationVisibility();
await scaledDelay(120, 320);
filledInPass = true;
}
if (!filledInPass) {
break;
}
}
showToast(`预览完成:已填写 ${filledIds.size} 道题(未提交,可检查后手动操作)。`);
} catch (error) {
logWarning('预览填写出错', error);
showToast(`预览填写出错: ${error.message || '未知错误'}`);
} finally {
try {
if (quotaSnapshot === null) {
localStorage.removeItem(QUOTA_STATE_KEY);
} else {
localStorage.setItem(QUOTA_STATE_KEY, quotaSnapshot);
}
} catch (error) {}
}
}
async function simulateSlider() {
const slider = document.querySelector('#nc_1__scale_text > span') || document.querySelector('.nc_iconfont.btn_slide');
if (!slider) {
return;
}
// 读取轨道实际宽度,避免硬编码 300px 导致拖不到底或越界
const track = document.querySelector('#nc_1_n1z, .nc_iconfont.btn_slide, .nc-lang-cnt, .scale_text') || slider.parentElement;
const trackRect = (track && track.getBoundingClientRect) ? track.getBoundingClientRect() : slider.getBoundingClientRect();
const sliderRect = slider.getBoundingClientRect();
const startX = sliderRect.left + sliderRect.width / 2;
const startY = sliderRect.top + sliderRect.height / 2;
// 目标位移 = 轨道宽度 - 滑块宽度,留一点余量
const totalWidth = Math.max(40, (trackRect.width || 300) - sliderRect.width - 4);
const dispatchDrag = (type, clientX, clientY) => {
const target = type === 'mousemove' || type === 'mouseup' ? document : slider;
target.dispatchEvent(new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX,
clientY
}));
};
dispatchDrag('mousedown', startX, startY);
const steps = randomInt(15, 25);
for (let i = 0; i <= steps; i++) {
await new Promise(r => setTimeout(r, 50 + Math.random() * 100));
const progress = i / steps;
const clientX = startX + totalWidth * progress + (Math.random() * 6 - 3);
const clientY = startY + (Math.random() * 6 - 3);
dispatchDrag('mousemove', clientX, clientY);
}
dispatchDrag('mouseup', startX + totalWidth, startY);
}
init();
})();