// ==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 = `

问卷星配置面板

识别题目 0
题型种类 0

全局设置

比例模式:各选项数值为权重比例。

配置尚未修改

题目配置

`; 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) => `
${escapeHtml(label)}(${getWeightInputSuffix()})
`).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(); })();