// ==UserScript== // @name WeLearn-Go // @namespace https://github.com/noxsk/WeLearn-Go // @supportURL https://github.com/noxsk/WeLearn-Go/issues // @version 0.9.9 // @description 自动填写 WeLearn 练习答案,支持小错误生成、自动提交和批量任务执行! // @author Noxsk // @match https://welearn.sflep.com/* // @match http://welearn.sflep.com/* // @match https://centercourseware.sflep.com/* // @match http://centercourseware.sflep.com/* // @match https://*.sflep.com/* // @run-at document-end // @grant GM_addStyle // @grant GM_info // ==/UserScript== (function () { 'use strict'; // ==================== 配置常量 ==================== // 从 UserScript 元数据获取版本号(避免重复定义) const VERSION = (typeof GM_info !== 'undefined' && GM_info.script?.version) || '0.0.0'; const SUBMIT_DELAY_MS = 300; // 提交前的延迟时间(毫秒) const PANEL_MIN_WIDTH = 340; // 面板最小宽度 const PANEL_MIN_HEIGHT = 180; // 面板最小高度 const PANEL_MAX_WIDTH = 540; // 面板最大宽度 const PANEL_MAX_HEIGHT = 460; // 面板最大高度 const PANEL_DEFAULT_WIDTH = 340; // 面板默认宽度 const PANEL_DEFAULT_HEIGHT = 280; // 面板默认高度 const MINIMIZED_PANEL_SIZE = 42; // 最小化时的面板尺寸 const PANEL_STATE_KEY = 'welearn_panel_state'; // 面板状态存储键 const ONBOARDING_STATE_KEY = 'welearn_onboarding_state'; // 引导状态存储键 const ERROR_STATS_KEY = 'welearn_error_stats'; // 错误统计存储键 const ERROR_WEIGHTS_KEY = 'welearn_error_weights'; // 错误权重配置存储键 const MAX_ERRORS_PER_PAGE = 2; // 每页最多添加的小错误数量 // 默认错误数量百分比配置:0个(50%) vs 1个(35%) vs 2个(15%) const DEFAULT_ERROR_WEIGHTS = { w0: 50, w1: 35, w2: 15 }; const GROUP_WORK_PATTERN = /group\s*work/i; // Group Work 匹配模式 const DONATE_IMAGE_URL = 'https://ossimg.yzitc.com/2025/12/03/eb461afdde7b3.png'; // 微信赞赏码图片地址 const DONATE_IMAGE_CACHE_KEY = 'welearn_donate_image_cache'; // 赞赏码图片缓存键 const BATCH_COMPLETED_KEY = 'welearn_batch_completed'; // 批量任务已完成记录存储键 const BATCH_MODE_KEY = 'welearn_batch_mode'; // 批量模式状态存储键 const COURSE_DIRECTORY_CACHE_KEY = 'welearn_course_directory_cache'; // 课程目录缓存键 const BATCH_TASKS_CACHE_KEY = 'welearn_batch_tasks_cache'; // 批量任务选择缓存键 const DURATION_MODE_KEY = 'welearn_duration_mode'; // 刷时长模式存储键 const UPDATE_CHECK_URL = 'https://raw.githubusercontent.com/noxsk/WeLearn-Go/refs/heads/main/WeLearn-Go.user.js'; // 版本检查地址 const UPDATE_CHECK_CACHE_KEY = 'welearn_update_check'; // 版本检查缓存键 const UPDATE_CHECK_INTERVAL = 1 * 60 * 60 * 1000; // 版本检查间隔1小时 // 刷时长模式配置 const DURATION_MODES = { off: { name: '关闭', baseTime: 0, perQuestionTime: 0, maxTime: 0, intervalTime: 0 }, fast: { name: '快速', baseTime: 30 * 1000, // 基础 30 秒 perQuestionTime: 5 * 1000, // 每题 5 秒 maxTime: 60 * 1000, // 最大 60 秒 intervalTime: 15 * 1000 // 心跳间隔 15 秒 }, standard: { name: '标准', baseTime: 60 * 1000, // 基础 60 秒 perQuestionTime: 10 * 1000, // 每题 10 秒 maxTime: 120 * 1000, // 最大 120 秒 intervalTime: 30 * 1000 // 心跳间隔 30 秒 } }; // ==================== 全局状态变量 ==================== let lastKnownUrl = location.href; // 记录上次的 URL,用于检测页面切换 let groupWorkDetected = false; // 是否检测到 Group Work let groupWorkNoticeShown = false; // 是否已显示 Group Work 提示 let openEndedExerciseShown = false; // 是否已显示开放式练习提示 let donateImageDataUrl = null; // 缓存的赞赏码图片 Data URL let batchModeActive = false; // 批量模式是否激活 let batchTaskQueue = []; // 批量任务队列 let currentBatchTask = null; // 当前正在处理的批量任务 let selectedBatchTasks = []; // 用户选择的待执行任务 let selectedCourseName = ''; // 选择任务时的课程名称 let latestVersion = null; // 最新版本号 /** 判断是否为 WeLearn 相关域名 */ const isWeLearnHost = () => { const host = location.hostname; return host.includes('welearn.sflep.com') || host.includes('centercourseware.sflep.com') || host.endsWith('.sflep.com'); }; /** 判断当前是否在 iframe 中运行 */ const isInIframe = () => { try { return window.self !== window.top; } catch (e) { return true; // 跨域时无法访问 top,说明在 iframe 中 } }; const getAccessibleDocuments = () => { const docs = [document]; document.querySelectorAll('iframe').forEach((frame) => { try { if (frame.contentDocument) docs.push(frame.contentDocument); } catch (error) { /* Ignore cross-origin frames */ } }); return docs; }; /** 检查页面是否包含练习元素 */ const hasExerciseElements = () => getAccessibleDocuments().some((doc) => doc.querySelector( '[data-controltype="pagecontrol"], [data-controltype="filling"], [data-controltype="fillinglong"], [data-controltype="choice"], [data-controltype="submit"], et-item, et-song, et-toggle, et-blank, .lrc, .dialog, .question-content, .exercise-content, .subjective, iframe', ), ); /** 判断当前是否为 WeLearn 练习页面 */ const isWeLearnPage = () => isWeLearnHost() && hasExerciseElements(); /** 分割答案字符串(支持多种分隔符:/、|、;、,、、) */ const splitSolutions = (value) => value .split(/[\/|;,、]/) .map((item) => item.trim()) .filter(Boolean); /** 标准化文本(去空格、转大写,用于答案比对) */ const normalizeText = (text) => (text ?? '').trim().toUpperCase(); /** * 格式化答案文本 * @param {string} text - 原始文本 * @param {Object} options - 配置选项 * @param {boolean} options.collapseLines - 是否合并多行为单行(用于 Group Work) */ const formatSolutionText = (text = '', { collapseLines = false } = {}) => { if (collapseLines) { return text .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .join(' ') .trim(); } const lines = text.split(/\r?\n/); const nonEmptyLines = lines.filter((line) => line.trim().length > 0); if (!nonEmptyLines.length) return text.trim(); const baseIndent = nonEmptyLines.reduce((indent, line) => { const match = line.match(/^(\s*)/); const length = match ? match[1].length : 0; return indent === null ? length : Math.min(indent, length); }, null); return lines .map((line) => { if (!line.trim()) return ''; const trimmedIndentLine = baseIndent ? line.slice(baseIndent) : line; return ` ${trimmedIndentLine.trimEnd()}`; }) .join('\n') .trim(); }; /** 生成指定范围内的随机整数 */ const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; /** * 带权重的随机选择 * @param {Array<{value: any, weight: number}>} options - 选项数组,每个选项包含值和权重 * @returns {any} 根据权重随机选中的值 */ const weightedRandom = (options) => { const totalWeight = options.reduce((sum, opt) => sum + opt.weight, 0); let random = Math.random() * totalWeight; for (const { value, weight } of options) { random -= weight; if (random <= 0) return value; } return options[options.length - 1].value; }; // ==================== 错误统计管理 ==================== /** 加载错误权重配置 */ const loadErrorWeights = () => { try { const raw = localStorage.getItem(ERROR_WEIGHTS_KEY); return raw ? JSON.parse(raw) : { ...DEFAULT_ERROR_WEIGHTS }; } catch (error) { console.warn('WeLearn autofill: failed to load error weights', error); return { ...DEFAULT_ERROR_WEIGHTS }; } }; /** 保存错误权重配置 */ const saveErrorWeights = (weights) => { try { localStorage.setItem(ERROR_WEIGHTS_KEY, JSON.stringify(weights)); } catch (error) { console.warn('WeLearn autofill: failed to save error weights', error); } }; /** 获取当前错误权重数组(用于 weightedRandom) */ const getErrorCountWeights = () => { const w = loadErrorWeights(); return [ { value: 0, weight: w.w0 }, { value: 1, weight: w.w1 }, { value: 2, weight: w.w2 }, ]; }; /** 加载错误统计数据 */ const loadErrorStats = () => { try { const raw = localStorage.getItem(ERROR_STATS_KEY); return raw ? JSON.parse(raw) : { count0: 0, count1: 0, count2: 0 }; } catch (error) { console.warn('WeLearn autofill: failed to load error stats', error); return { count0: 0, count1: 0, count2: 0 }; } }; /** 保存错误统计数据 */ const saveErrorStats = (stats) => { try { localStorage.setItem(ERROR_STATS_KEY, JSON.stringify(stats)); } catch (error) { console.warn('WeLearn autofill: failed to save error stats', error); } }; /** 更新错误统计并刷新显示 */ const updateErrorStats = (errorCount) => { const stats = loadErrorStats(); if (errorCount === 0) stats.count0++; else if (errorCount === 1) stats.count1++; else if (errorCount === 2) stats.count2++; saveErrorStats(stats); refreshErrorStatsDisplay(); return stats; }; /** 清空错误统计 */ const clearErrorStats = () => { saveErrorStats({ count0: 0, count1: 0, count2: 0 }); refreshErrorStatsDisplay(); }; /** 刷新面板上的统计显示 */ const refreshErrorStatsDisplay = () => { const statsEl = document.querySelector('.welearn-error-stats'); if (!statsEl) return; const stats = loadErrorStats(); const total = stats.count0 + stats.count1 + stats.count2; if (total === 0) { statsEl.innerHTML = '统计:暂无数据'; } else { const pct0 = ((stats.count0 / total) * 100).toFixed(0); const pct1 = ((stats.count1 / total) * 100).toFixed(0); const pct2 = ((stats.count2 / total) * 100).toFixed(0); statsEl.innerHTML = `统计:${stats.count0} ${stats.count1} ${stats.count2} (${pct0}%/${pct1}%/${pct2}%)`; } }; // ==================== 刷时长模式管理 ==================== /** 加载刷时长模式配置 */ const loadDurationMode = () => { try { const mode = localStorage.getItem(DURATION_MODE_KEY); return (mode && DURATION_MODES[mode]) ? mode : 'standard'; } catch (error) { console.warn('WeLearn: 加载刷时长模式失败', error); return 'standard'; } }; /** 保存刷时长模式配置 */ const saveDurationMode = (mode) => { try { if (DURATION_MODES[mode]) { localStorage.setItem(DURATION_MODE_KEY, mode); } } catch (error) { console.warn('WeLearn: 保存刷时长模式失败', error); } }; /** 获取当前刷时长模式配置 */ const getDurationConfig = () => { const mode = loadDurationMode(); return DURATION_MODES[mode] || DURATION_MODES.standard; }; /** 计算刷时长等待时间 */ const calculateDurationTime = (questionCount) => { const config = getDurationConfig(); const calculatedTime = Math.min( Math.max(questionCount * config.perQuestionTime, config.baseTime), config.maxTime ); return calculatedTime; }; // ==================== 版本检查功能 ==================== /** 比较版本号,返回 1(a>b), -1(a { const partsA = a.replace(/^v/, '').split('.').map(Number); const partsB = b.replace(/^v/, '').split('.').map(Number); const len = Math.max(partsA.length, partsB.length); for (let i = 0; i < len; i++) { const numA = partsA[i] || 0; const numB = partsB[i] || 0; if (numA > numB) return 1; if (numA < numB) return -1; } return 0; }; /** 从脚本内容提取版本号 */ const extractVersionFromScript = (content) => { const match = content.match(/@version\s+(\d+\.\d+\.\d+)/); return match ? match[1] : null; }; /** 检查是否有新版本 */ const checkForUpdates = async () => { try { const handleUpdateFound = (ver) => { latestVersion = ver; showUpdateHint(ver); }; // 检查缓存,避免频繁请求 const cached = localStorage.getItem(UPDATE_CHECK_CACHE_KEY); if (cached) { const { version, timestamp } = JSON.parse(cached); if (Date.now() - timestamp < UPDATE_CHECK_INTERVAL) { // 使用缓存的版本信息 if (version && compareVersions(version, VERSION) > 0) { handleUpdateFound(version); } return; } } // 请求最新脚本获取版本号 const response = await fetch(UPDATE_CHECK_URL, { cache: 'no-cache', headers: { 'Accept': 'text/plain' } }); if (!response.ok) { console.warn('[WeLearn-Go] 版本检查请求失败:', response.status); return; } const content = await response.text(); const remoteVersion = extractVersionFromScript(content); if (!remoteVersion) { console.warn('[WeLearn-Go] 无法解析远程版本号'); return; } // 缓存检查结果 localStorage.setItem(UPDATE_CHECK_CACHE_KEY, JSON.stringify({ version: remoteVersion, timestamp: Date.now() })); console.log('[WeLearn-Go] 版本检查:', { current: VERSION, remote: remoteVersion }); // 如果有新版本,显示提示 if (compareVersions(remoteVersion, VERSION) > 0) { handleUpdateFound(remoteVersion); } } catch (error) { console.warn('[WeLearn-Go] 版本检查失败:', error); } }; /** 显示更新提示 */ const showUpdateHint = (newVersion) => { const hint = document.querySelector('.welearn-update-hint'); if (hint) { hint.textContent = `🆕 v${newVersion}`; hint.title = `发现新版本 v${newVersion},点击更新`; hint.style.display = 'inline'; } }; /** * 高亮显示两个字符串的差异 * 返回带 HTML 标记的字符串,红色表示修改的部分 */ const highlightDiff = (original, modified) => { let result = ''; const maxLen = Math.max(original.length, modified.length); for (let i = 0; i < maxLen; i++) { const origChar = original[i] || ''; const modChar = modified[i] || ''; if (origChar !== modChar) { result += `${modChar}`; } else { result += modChar; } } return result; }; // ==================== 小错误生成策略 ==================== /** * 键盘相邻字母映射表(基于 QWERTY 键盘布局) * 每个字母映射到其键盘上相邻的、容易误触的字母 */ const ADJACENT_KEYS = { a: ['s', 'q', 'z'], b: ['v', 'n', 'g', 'h'], c: ['x', 'v', 'd', 'f'], d: ['s', 'f', 'e', 'r', 'c', 'x'], e: ['w', 'r', 'd', 's'], f: ['d', 'g', 'r', 't', 'v', 'c'], g: ['f', 'h', 't', 'y', 'b', 'v'], h: ['g', 'j', 'y', 'u', 'n', 'b'], i: ['u', 'o', 'k', 'j'], j: ['h', 'k', 'u', 'i', 'm', 'n'], k: ['j', 'l', 'i', 'o', 'm'], l: ['k', 'o', 'p'], m: ['n', 'j', 'k'], n: ['b', 'm', 'h', 'j'], o: ['i', 'p', 'k', 'l'], p: ['o', 'l'], q: ['w', 'a'], r: ['e', 't', 'd', 'f'], s: ['a', 'd', 'w', 'e', 'x', 'z'], t: ['r', 'y', 'f', 'g'], u: ['y', 'i', 'h', 'j'], v: ['c', 'b', 'f', 'g'], w: ['q', 'e', 'a', 's'], x: ['z', 'c', 's', 'd'], y: ['t', 'u', 'g', 'h'], z: ['a', 's', 'x'], }; /** * 常见的可交换字母对(非首字母位置) * 这些是打字时容易顺序颠倒的字母组合 */ const SWAPPABLE_PAIRS = ['ea', 'ae', 'ei', 'ie', 'ou', 'uo', 'er', 're', 'ru', 'ur', 'ti', 'it', 'th', 'ht', 'io', 'oi', 'an', 'na', 'en', 'ne', 'al', 'la']; /** * 错误类型1:键盘相邻字母拼写错误 * 在单词中间(非首尾)将一个字母替换为键盘上相邻的字母 */ const makeAdjacentKeyMistake = (text) => { const words = text.split(/\s+/); // 筛选长度大于3的英文单词(确保有中间字母可替换) const candidates = words .map((word, index) => ({ word, index })) .filter(({ word }) => /^[a-z]+$/i.test(word) && word.length > 3); if (!candidates.length) return ''; const { word, index: wordIndex } = candidates[randomInt(0, candidates.length - 1)]; // 只在中间位置(非首尾)进行替换 const charIndex = randomInt(1, word.length - 2); const originalChar = word[charIndex].toLowerCase(); const adjacentChars = ADJACENT_KEYS[originalChar]; if (!adjacentChars || !adjacentChars.length) return ''; const replacement = adjacentChars[randomInt(0, adjacentChars.length - 1)]; // 保持原始大小写 const finalReplacement = word[charIndex] === word[charIndex].toUpperCase() ? replacement.toUpperCase() : replacement; const newWord = word.slice(0, charIndex) + finalReplacement + word.slice(charIndex + 1); words[wordIndex] = newWord; return words.join(' '); }; /** * 错误类型2:字母顺序颠倒 * 将单词中常见的字母对顺序颠倒(如 ea -> ae, ru -> ur) * 注意:不在首字母位置进行交换,且只处理纯字母单词(排除括号、斜杠等特殊字符) */ const makeLetterSwapMistake = (text) => { const words = text.split(/\s+/); const candidates = []; // 查找包含可交换字母对的单词 words.forEach((word, wordIndex) => { // 跳过长度不足的单词 if (word.length < 3) return; // 只处理纯字母单词,排除包含 ()、/、数字等特殊字符的单词 if (!/^[a-z]+$/i.test(word)) return; const lowerWord = word.toLowerCase(); SWAPPABLE_PAIRS.forEach((pair) => { // 从位置1开始搜索,确保字母对不在首字母位置 const pairIndex = lowerWord.indexOf(pair, 1); if (pairIndex > 0) { // 确保不在首字母位置(交换后首字母不会变) candidates.push({ word, wordIndex, pairIndex, pair }); } }); }); if (!candidates.length) return ''; const { word, wordIndex, pairIndex, pair } = candidates[randomInt(0, candidates.length - 1)]; // 交换字母对 const swapped = pair[1] + pair[0]; // 保持原始大小写 let finalSwapped = ''; for (let i = 0; i < 2; i++) { const origChar = word[pairIndex + i]; const newChar = swapped[i]; finalSwapped += origChar === origChar.toUpperCase() ? newChar.toUpperCase() : newChar; } const newWord = word.slice(0, pairIndex) + finalSwapped + word.slice(pairIndex + 2); words[wordIndex] = newWord; return words.join(' '); }; /** * 错误类型3:句子首字母大小写错误 * 将句子首字母的大小写切换 */ const makeCapitalizationMistake = (text) => { const trimmed = text.trim(); if (!trimmed.length) return ''; // 检查是否像句子(以字母开头) const firstChar = trimmed[0]; if (!/[a-z]/i.test(firstChar)) return ''; // 切换首字母大小写 const toggledFirst = firstChar === firstChar.toUpperCase() ? firstChar.toLowerCase() : firstChar.toUpperCase(); return toggledFirst + trimmed.slice(1); }; /** * 错误类型4:句子末尾标点符号错误 * 删除或添加句子末尾的标点符号 */ const makePunctuationMistake = (text) => { const trimmed = text.trimEnd(); if (!trimmed.length) return ''; const trailingSpaces = text.slice(trimmed.length); const endsWithPunctuation = /[.!?]$/.test(trimmed); if (endsWithPunctuation) { // 删除末尾标点 return trimmed.slice(0, -1) + trailingSpaces; } else { // 检查是否像句子(以大写字母开头,且有一定长度) if (trimmed.length > 10 && /^[A-Z]/.test(trimmed)) { // 添加句号 return trimmed + '.' + trailingSpaces; } } return ''; }; /** * 错误类型名称映射 */ const MISTAKE_TYPE_NAMES = { adjacentKey: '键盘误触', letterSwap: '字母顺序', capitalization: '大小写', punctuation: '标点', }; /** * 提取变化的单词(用于错误显示) */ const findChangedWord = (original, modified) => { const origWords = original.split(/\s+/); const modWords = modified.split(/\s+/); // 找到变化的单词 for (let i = 0; i < origWords.length; i++) { if (origWords[i] !== modWords[i]) { return { original: origWords[i], modified: modWords[i] }; } } // 如果是整体变化(如首字母大小写、标点),截取前15个字符 const len = Math.min(15, original.length); return { original: original.slice(0, len) + (original.length > len ? '...' : ''), modified: modified.slice(0, len) + (modified.length > len ? '...' : ''), }; }; /** * 创建错误生成器 * @param {boolean} enabled - 是否启用错误生成 * @returns {Object} 包含 mutate 函数和 getErrors 方法的对象 */ const createMistakeMutator = (enabled) => { if (!enabled) { return { mutate: (value) => value, getErrors: () => [], getTargetCount: () => 0, }; } const errors = []; // 按用户配置的权重随机选择错误数量 const targetCount = weightedRandom(getErrorCountWeights()); let remaining = targetCount; // 策略列表 const strategies = [ { fn: makeAdjacentKeyMistake, type: 'adjacentKey' }, { fn: makeLetterSwapMistake, type: 'letterSwap' }, { fn: makeCapitalizationMistake, type: 'capitalization' }, { fn: makePunctuationMistake, type: 'punctuation' }, ]; const mutate = (value) => { if (remaining <= 0) return value; // Fisher-Yates 洗牌 for (let i = strategies.length - 1; i > 0; i--) { const j = randomInt(0, i); [strategies[i], strategies[j]] = [strategies[j], strategies[i]]; } for (const { fn, type } of strategies) { const next = fn(value); if (next && next !== value) { remaining--; const changed = findChangedWord(value, next); errors.push({ type: MISTAKE_TYPE_NAMES[type], ...changed, }); return next; } } return value; }; return { mutate, getErrors: () => errors, getTargetCount: () => targetCount }; }; // ==================== 答案填充逻辑 ==================== /** * 规范化答案文本,清理多余的换行和空格 * 将多个连续空白字符(包括换行)合并为单个空格 */ const normalizeAnswer = (text) => { if (!text) return ''; return text .replace(/\s+/g, ' ') // 将所有连续空白字符(包括换行、制表符)替换为单个空格 .trim(); }; /** * 清理 Group Work 类型答案的前缀 * 移除 "(Answers may vary.)" 等提示语 */ const cleanGroupWorkAnswer = (text) => { if (!text) return ''; return text // 移除 "(Answers may vary.)" 及其变体 .replace(/\(?\s*Answers?\s+may\s+vary\.?\s*\)?/gi, '') // 移除 "(Sample answer)" 等 .replace(/\(?\s*Sample\s+answers?\.?\s*\)?/gi, '') // 移除 "(Reference answer)" 等 .replace(/\(?\s*Reference\s+answers?\.?\s*\)?/gi, '') // 移除 "(Suggested answer)" 等 .replace(/\(?\s*Suggested\s+answers?\.?\s*\)?/gi, '') // 移除开头的空白 .trim(); }; /** 从容器中读取正确答案 */ const readSolution = (input, container) => { const resultNode = container.querySelector('[data-itemtype="result"]'); let resultText = resultNode?.textContent; if (resultText) { // 对于 fillinglong(主观题),清理前缀 const isLongFilling = container.getAttribute('data-controltype') === 'fillinglong'; if (isLongFilling) { resultText = cleanGroupWorkAnswer(resultText); } return normalizeAnswer(resultText); } const solutionFromInput = input?.dataset?.solution; if (!solutionFromInput) return ''; const normalized = normalizeAnswer(solutionFromInput); const candidates = splitSolutions(normalized); return candidates[0] ?? ''; }; /** 填充填空题 */ const fillFillingItem = (container, mutateAnswer) => { // 支持多种输入元素格式:data-itemtype 属性或直接使用 textarea 标签 const input = container.querySelector('[data-itemtype="input"], [data-itemtype="textarea"], textarea'); if (!input) { console.debug('[WeLearn-Go] fillFillingItem: 找不到 input 元素', container.outerHTML?.slice(0, 100)); return false; } // 获取控件类型 const controlType = container.getAttribute('data-controltype'); console.debug('[WeLearn-Go] fillFillingItem:', { controlType, tagName: input.tagName, id: container.getAttribute('data-id') }); // 对于主观题(fillinglong),检查是否有实质性答案 if (controlType === 'fillinglong') { // 获取原始答案文本 const resultEl = container.querySelector('[data-itemtype="result"]'); const rawAnswer = resultEl?.textContent?.trim() || ''; // 检查是否只有 "Answers may vary" 类的占位文本 const cleanedAnswer = cleanGroupWorkAnswer(rawAnswer); if (!cleanedAnswer) { // 没有实质性答案,跳过填充(留空) console.info('[WeLearn-Go] fillinglong 无实质答案,跳过:', rawAnswer.slice(0, 50)); return false; } console.debug('[WeLearn-Go] fillinglong 有实质答案,继续填充'); } const solution = readSolution(input, container); if (!solution) { console.debug('[WeLearn-Go] fillFillingItem: 无法读取答案'); return false; } console.debug('[WeLearn-Go] fillFillingItem: 读取到答案:', solution.slice(0, 50)); const finalValue = mutateAnswer(solution); const formattedValue = input.tagName === 'TEXTAREA' ? formatSolutionText(finalValue, { collapseLines: groupWorkDetected }) : finalValue.trim(); if (input.value.trim() === formattedValue) { console.debug('[WeLearn-Go] fillFillingItem: 值已相同,跳过'); return false; } input.value = formattedValue; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); console.debug('[WeLearn-Go] fillFillingItem: 填充成功'); return true; }; /** 选择选项(单选/多选) */ const selectChoiceOption = (option) => { const input = option.querySelector('input[type="radio"], input[type="checkbox"]'); if (input) { if (input.checked) return false; input.click(); return true; } // 检测多种已选状态:CSS 类、aria-checked 属性、data-choiced 属性(T/F/N 判断题使用) const wasSelected = option.classList.contains('selected') || option.getAttribute('aria-checked') === 'true' || option.hasAttribute('data-choiced'); option.click(); return !wasSelected; }; /** 查找正确答案选项 */ const findChoiceSolutions = (options, container) => { const optionsWithSolution = options.filter((item) => item.hasAttribute('data-solution')); if (optionsWithSolution.length) return optionsWithSolution; const extractCandidates = (raw) => splitSolutions(raw || '').map(normalizeText); const candidates = [ ...extractCandidates(container.querySelector('[data-itemtype="result"]')?.textContent), ...extractCandidates(container.dataset?.solution), ]; if (!candidates.length) return []; return options.filter((item) => { const optionText = normalizeText(item.textContent); const optionSolution = normalizeText(item.dataset?.solution); return candidates.includes(optionText) || candidates.includes(optionSolution); }); }; /** 填充选择题(单选/多选/T-F-N 判断题) */ const fillChoiceItem = (container) => { const containerId = container.getAttribute('data-id') || container.id || 'unknown'; const options = Array.from(container.querySelectorAll('ul[data-itemtype="options"] > li')); console.debug('[WeLearn-Go] fillChoiceItem:', { containerId, optionsCount: options.length, optionTexts: options.map(o => o.textContent?.trim()) }); if (!options.length) { console.debug('[WeLearn-Go] fillChoiceItem: 未找到选项, 容器:', containerId); return false; } const matchedOptions = findChoiceSolutions(options, container); console.debug('[WeLearn-Go] fillChoiceItem: 匹配到的正确答案:', { containerId, matchedCount: matchedOptions.length, matchedTexts: matchedOptions.map(o => o.textContent?.trim()), matchedHasSolution: matchedOptions.map(o => o.hasAttribute('data-solution')) }); if (!matchedOptions.length) { console.debug('[WeLearn-Go] fillChoiceItem: 未找到正确答案, 容器:', containerId); return false; } const isCheckboxGroup = options.some((item) => item.querySelector('input[type="checkbox"]')); if (isCheckboxGroup) { return matchedOptions.reduce((changed, option) => selectChoiceOption(option) || changed, false); } return selectChoiceOption(matchedOptions[0]); }; // ==================== AngularJS 组件适配(et-* 系列) ==================== // 点击选择类型的填充队列(串行执行避免选项面板冲突) const clickFillQueue = []; let isProcessingClickQueue = false; let clickQueueSchedulerId = null; const scheduleClickQueueProcessing = () => { if (clickQueueSchedulerId !== null) return; const run = () => { clickQueueSchedulerId = null; processClickFillQueue(); }; if (typeof requestAnimationFrame === 'function') { clickQueueSchedulerId = requestAnimationFrame(run); } else { clickQueueSchedulerId = setTimeout(run, 0); } }; /** * 处理点击填充队列 */ const processClickFillQueue = async () => { console.info('[WeLearn-Go] processClickFillQueue: 被调用', { isProcessingClickQueue, queueLength: clickFillQueue.length }); if (isProcessingClickQueue || clickFillQueue.length === 0) { return; } console.info('[WeLearn-Go] processClickFillQueue: 开始处理队列'); isProcessingClickQueue = true; while (clickFillQueue.length > 0) { const { container, solution } = clickFillQueue.shift(); console.info('[WeLearn-Go] processClickFillQueue: 处理队列项', { solution, id: container.id, remaining: clickFillQueue.length }); await doFillEtBlankByClick(container, solution); // 给 AngularJS 一点时间完成 digest await new Promise(resolve => setTimeout(resolve, 50)); } isProcessingClickQueue = false; console.info('[WeLearn-Go] processClickFillQueue: 队列处理完成'); }; /** * 从答案中提取纯文本(去除选项字母前缀如 "A. "、"B. " 等) * @param {string} solution - 完整答案(如 "D. open") * @returns {string} 纯文本答案(如 "open") */ const extractPureAnswer = (solution) => { // 匹配格式:字母 + 点/括号 + 可选空格 + 答案内容 // 如 "A. open", "B) answer", "C answer" 等 const match = solution.match(/^[A-Za-z][.\)]\s*(.+)$/); return match ? match[1].trim() : solution; }; /** * 检查选项文本是否与答案匹配 * 支持完整匹配(如 "D. open" === "D. open") * 以及去除前缀后的匹配(如 "open" 匹配 "D. open") * @param {string} optionText - 选项文本 * @param {string} solution - 答案 * @returns {boolean} 是否匹配 */ const isOptionMatch = (optionText, solution) => { const normalizedOpt = normalizeAnswer(optionText); const normalizedSol = normalizeAnswer(solution); // 完全匹配 if (normalizedOpt === normalizedSol) return true; // 去除前缀后匹配 const pureAnswer = extractPureAnswer(normalizedSol); if (normalizedOpt === normalizeAnswer(pureAnswer)) return true; // 选项可能也带前缀,去除后比较 const pureOption = extractPureAnswer(normalizedOpt); if (pureOption === normalizeAnswer(pureAnswer)) return true; return false; }; /** * 实际执行点击选项填充 et-blank * 直接修改 DOM 内容,不依赖 AngularJS * @param {Element} container - et-blank 容器元素 * @param {string} solution - 答案 * @returns {Promise} 是否成功填充 */ const doFillEtBlankByClick = (container, solution) => { console.info('[WeLearn-Go] doFillEtBlankByClick: 开始处理', { solution, id: container.id }); return new Promise((resolve) => { const blankEl = container.querySelector('span.blank'); if (!blankEl) { console.warn('[WeLearn-Go] doFillEtBlankByClick: 未找到 blank 元素'); resolve(false); return; } const doc = container.ownerDocument || document; // 步骤1: 点击 blank 元素激活 optionsPicker console.info('[WeLearn-Go] doFillEtBlankByClick: 点击 blank 激活选项', { id: container.id }); blankEl.click(); // 步骤2: 等待 optionsPicker 出现,然后点击对应选项 setTimeout(() => { // 查找可见的 optionsPicker const picker = doc.querySelector('.optionsPicker.visible') || doc.querySelector('.optionsPicker'); if (!picker) { console.warn('[WeLearn-Go] doFillEtBlankByClick: 未找到 optionsPicker'); // 回退方案:直接设置文本 blankEl.textContent = solution; resolve(true); return; } // 查找匹配的选项 const pickerItems = picker.querySelectorAll('li[option]'); let targetOption = null; for (const li of pickerItems) { const optionText = li.textContent?.trim(); // 精确匹配或者去掉字母前缀后匹配 if (optionText === solution || isOptionMatch(optionText, solution)) { // 跳过已使用的选项 if (!li.classList.contains('used')) { targetOption = li; break; } } } if (targetOption) { console.info('[WeLearn-Go] doFillEtBlankByClick: 点击选项', { option: targetOption.textContent?.trim(), solution }); targetOption.click(); resolve(true); } else { console.warn('[WeLearn-Go] doFillEtBlankByClick: 未找到匹配的选项', { solution, available: Array.from(pickerItems).map(li => li.textContent?.trim()) }); // 回退方案:直接设置文本 blankEl.textContent = solution; resolve(true); } }, 100); // 等待 optionsPicker 出现 }); }; /** * 通过点击选项填充 et-blank(用于带有 noinput 属性的选择题) * @param {Element} container - et-blank 容器元素 * @param {string} solution - 答案 * @returns {boolean} 是否成功填充(加入队列) */ const fillEtBlankByClick = (container, solution) => { // 获取当前值 const blankEl = container.querySelector('span.blank'); if (blankEl) { const currentValue = blankEl.textContent?.trim() || ''; const isAlreadyFilled = isOptionMatch(currentValue, solution); console.info('[WeLearn-Go] fillEtBlankByClick: 检查', { currentValue, solution, match: isAlreadyFilled, id: container.id }); if (isAlreadyFilled) { return false; // 已填充 } } console.info('[WeLearn-Go] fillEtBlankByClick: 加入队列', { solution, id: container.id }); // 加入队列 clickFillQueue.push({ container, solution }); console.info('[WeLearn-Go] fillEtBlankByClick: 队列长度', clickFillQueue.length); // 调度处理队列 scheduleClickQueueProcessing(); return true; }; /** * 填充 et-blank 填空题 * 答案可能在以下位置: * 1. et-blank 内部的 span.key 元素 * 2. et-blank 父级容器的兄弟元素 .visible-box 中(句型练习题) * 支持两种输入方式: * - 普通输入:textarea, input, contenteditable * - 点击选择:带有 noinput 属性,需要点击 et-options 中的选项 * * 答案来源(按优先级): * 1. et-blank 内部的 span.key 元素(WELearnHelper 方式,用 | 分隔多选项) * 2. 父级的 .visible-box 元素 * 3. g 属性 * 4. 全局上下文 * * @param {Element} container - et-blank 容器元素 * @param {Function} mutateAnswer - 答案变异函数(用于生成小错误) * @returns {boolean} 是否成功填充 */ const fillEtBlank = (container, mutateAnswer) => { let solution = ''; // 方法1: 查找 et-blank 内部的 span.key 或 .key 元素(WELearnHelper 的核心方式) const keyEl = container.querySelector('span.key, .key'); if (keyEl) { // WELearnHelper: 答案可能用 | 分隔多个选项,取第一个 const rawText = keyEl.textContent || ''; solution = normalizeAnswer(rawText.split('|')[0]); } // 方法2: 查找父级容器的兄弟元素 .visible-box(句型练习题) if (!solution) { // 向上查找包含 et-blank 的容器(通常是 div[et-stem-index] 或直接父级) const stemContainer = container.closest('[et-stem-index]') || container.parentElement?.parentElement; if (stemContainer) { const visibleBox = stemContainer.querySelector('.visible-box'); if (visibleBox) { solution = normalizeAnswer(visibleBox.textContent); console.debug('[WeLearn-Go] fillEtBlank: 从 .visible-box 获取答案'); } } } // 方法3: 查找同级的 .visible-box if (!solution && container.parentElement) { const sibling = container.parentElement.querySelector('.visible-box'); if (sibling) { solution = normalizeAnswer(sibling.textContent); console.debug('[WeLearn-Go] fillEtBlank: 从同级 .visible-box 获取答案'); } } // 方法4: 从 g 属性获取答案(某些题型的答案存储在此) if (!solution) { const gAttr = container.getAttribute('g'); if (gAttr && gAttr.trim()) { try { // g 属性可能是 JSON 或纯文本 const parsed = JSON.parse(gAttr); if (typeof parsed === 'string') { solution = normalizeAnswer(parsed); } else if (parsed.answer || parsed.key) { solution = normalizeAnswer(parsed.answer || parsed.key); } } catch { // 不是 JSON,直接使用 solution = normalizeAnswer(gAttr); } if (solution) { console.debug('[WeLearn-Go] fillEtBlank: 从 g 属性获取答案'); } } } // 方法5: 从全局上下文获取答案 if (!solution) { const globalAnswer = findAnswerFromGlobalContext(container); if (globalAnswer) { solution = normalizeAnswer(globalAnswer); console.debug('[WeLearn-Go] fillEtBlank: 从全局上下文获取答案'); } } if (!solution) { // 检测是否为开放式无答案练习(g="" 且 noprogress) const gAttr = container.getAttribute('g'); const isOpenEnded = gAttr === '' || gAttr === null; const hasNoprogress = container.hasAttribute('noprogress'); if (isOpenEnded && hasNoprogress) { console.info('[WeLearn-Go] fillEtBlank: 跳过开放式练习(无标准答案)', container.id); } else { console.debug('[WeLearn-Go] fillEtBlank: 未找到答案', container.outerHTML?.substring(0, 200)); } return false; } // 检查是否为点击选择类型(带有 noinput 属性) const isNoInput = container.hasAttribute('noinput'); console.info('[WeLearn-Go] fillEtBlank: 处理', { isNoInput, solution: solution.substring(0, 30), id: container.id }); if (isNoInput) { // 点击选择类型:需要先点击 blank 激活,然后点击对应的选项 return fillEtBlankByClick(container, solution); } // 普通输入类型:查找输入区域(优先真实输入元素) const textInputSelector = 'textarea, [contenteditable], input.blank, input[type="text"]'; let inputEl = container.querySelector(textInputSelector); if (!inputEl) { const etItem = container.closest('et-item'); if (etItem) { const candidateInputs = Array.from(etItem.querySelectorAll(textInputSelector)) .filter((el) => !el.closest('et-blank')); if (candidateInputs.length) { const blanksNeedingExternal = Array.from(etItem.querySelectorAll('et-blank')) .filter((blank) => !blank.querySelector(textInputSelector)); const externalIndex = blanksNeedingExternal.indexOf(container); if (externalIndex > -1) { inputEl = candidateInputs[externalIndex] || null; } } } } if (!inputEl) { const scopeDoc = container.ownerDocument || document; inputEl = scopeDoc.querySelector(`[data-blank-id="${container.id}"]`) || scopeDoc.querySelector(`[blank-id="${container.id}"]`) || scopeDoc.querySelector(`[data-target="${container.id}"]`); } console.info('[WeLearn-Go] fillEtBlank: 查找输入元素', { found: !!inputEl, tagName: inputEl?.tagName, hasContentEditable: inputEl ? inputEl.hasAttribute?.('contenteditable') : false, containerHTML: container.innerHTML?.substring(0, 300) }); if (!inputEl) { console.info('[WeLearn-Go] fillEtBlank: 未找到输入元素'); return false; } const finalValue = mutateAnswer(solution); // 获取当前值(根据元素类型) const isContentEditable = inputEl.hasAttribute('contenteditable'); const currentValue = (inputEl.tagName === 'TEXTAREA' || inputEl.tagName === 'INPUT') && !isContentEditable ? normalizeAnswer(inputEl.value) : normalizeAnswer(inputEl.textContent); // 如果已填充相同答案,跳过 if (currentValue === finalValue) { console.info('[WeLearn-Go] fillEtBlank: 已填充相同答案,跳过', { currentValue, finalValue }); return false; } // WELearnHelper 的事件触发策略: // 输入前事件序列 const triggerReadyEvents = (el) => { try { el.click?.(); el.focus?.(); el.dispatchEvent(new Event('click', { bubbles: true })); el.dispatchEvent(new Event('focus', { bubbles: true })); el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })); el.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) { /* 忽略 */ } }; // 输入后事件序列 const triggerCompleteEvents = (el) => { try { el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('blur', { bubbles: true })); // AngularJS 事件触发 const win = el.ownerDocument?.defaultView || window; const angular = win.angular; if (angular) { angular.element(el).triggerHandler?.('hover'); angular.element(el).triggerHandler?.('keyup'); angular.element(el).triggerHandler?.('blur'); } } catch (e) { /* 忽略 */ } }; // 填充答案 - 根据元素类型选择正确的方式 console.debug('[WeLearn-Go] fillEtBlank: 填充', { solution: solution.substring(0, 50), inputEl: inputEl.tagName, isContentEditable }); // 触发准备事件 triggerReadyEvents(inputEl); if ((inputEl.tagName === 'INPUT' || inputEl.tagName === 'TEXTAREA') && !isContentEditable) { inputEl.value = finalValue; } else { // contenteditable 或 span 元素 (WELearnHelper 使用 span.blank) inputEl.textContent = finalValue; } // 触发完成事件 triggerCompleteEvents(inputEl); // 尝试触发 AngularJS 的数据绑定更新 try { const win = inputEl.ownerDocument?.defaultView || window; const ngModelController = win.angular?.element(inputEl)?.controller('ngModel'); if (ngModelController) { ngModelController.$setViewValue(finalValue); ngModelController.$render(); } // 触发 AngularJS 的 $apply const scope = win.angular?.element(inputEl)?.scope(); if (scope && scope.$apply) { scope.$apply(); } } catch (e) { /* 忽略 AngularJS 相关错误 */ } return true; }; /** * 填充 et-multi-noinput 多选题 * 有两种模式: * 1. 选择填空模式:点击 span.multi-noinput 激活 multiOptionsPicker 浮窗,然后选择选项 * 2. 直接选择模式:直接点击 et-multi-options 中的选项 * 答案存储在 span.key 中(格式如 "B,I,D,K,E") * @param {Element} container - et-multi-noinput 容器元素或其父容器 * @returns {boolean} 是否成功填充 */ const fillEtMultiNoinput = (container) => { // 查找答案:在 span.key 元素中 const keyEl = container.querySelector('span.key'); if (!keyEl) return false; const solutionText = keyEl.textContent?.trim(); if (!solutionText) return false; // 解析正确答案(格式如 "B,I,D,K,E") const correctOptions = solutionText.split(',').map(s => s.trim().toUpperCase()).filter(Boolean); if (!correctOptions.length) return false; // 检查是否为选择填空模式(有 span.multi-noinput) const multiNoinputSpan = container.querySelector('span.multi-noinput'); if (multiNoinputSpan) { // 选择填空模式:需要点击激活浮窗,然后依次选择选项 console.info('[WeLearn-Go] fillEtMultiNoinput: 选择填空模式', { id: container.id, correctOptions }); // 加入异步队列处理 fillMultiNoinputByClick(container, correctOptions); return true; } // 直接选择模式:查找 et-multi-options 中的选项列表 const optionsContainer = container.closest('et-item')?.querySelector('et-multi-options ul') || container.parentElement?.querySelector('et-multi-options ul'); if (!optionsContainer) return false; const optionItems = Array.from(optionsContainer.querySelectorAll('li')); let changed = false; optionItems.forEach((li) => { // 提取选项字母(通常在 li 开头,如 "A. ...") const optionMatch = li.textContent?.trim().match(/^([A-Z])\./i); if (!optionMatch) return; const optionLetter = optionMatch[1].toUpperCase(); const shouldBeSelected = correctOptions.includes(optionLetter); const isCurrentlySelected = li.classList.contains('selected') || li.classList.contains('used') || li.getAttribute('aria-checked') === 'true'; // 如果选中状态需要改变 if (shouldBeSelected !== isCurrentlySelected) { li.click(); changed = true; } }); return changed; }; /** * 通过点击选项填充 et-multi-noinput(选择填空模式) * 需要先点击 span.multi-noinput 激活浮窗,然后依次点击所有正确选项 * @param {Element} container - et-multi-noinput 容器元素 * @param {string[]} correctOptions - 正确选项字母数组,如 ['B', 'I', 'D', 'K', 'E'] */ const fillMultiNoinputByClick = async (container, correctOptions) => { const multiNoinputSpan = container.querySelector('span.multi-noinput'); if (!multiNoinputSpan) return; const doc = container.ownerDocument || document; console.info('[WeLearn-Go] fillMultiNoinputByClick: 开始处理', { id: container.id, correctOptions }); // 步骤1: 点击 multi-noinput 激活浮窗 multiNoinputSpan.click(); // 等待浮窗出现 await new Promise(resolve => setTimeout(resolve, 200)); // 步骤2: 查找浮窗 - 浮窗在 et-item 之后的同级位置 // 先尝试查找可见的浮窗 let picker = doc.querySelector('.multiOptionsPicker.visible'); // 如果没找到可见的,尝试查找任意浮窗 if (!picker) { picker = doc.querySelector('.multiOptionsPicker'); } // 如果还是没找到,尝试在 body 级别查找(有时浮窗会被移到 body 下) if (!picker) { picker = doc.body?.querySelector('.multiOptionsPicker'); } if (!picker) { console.warn('[WeLearn-Go] fillMultiNoinputByClick: 未找到 multiOptionsPicker'); return; } console.info('[WeLearn-Go] fillMultiNoinputByClick: 找到浮窗', { visible: picker.classList.contains('visible'), optionsCount: picker.querySelectorAll('li[preoption]').length }); const pickerItems = picker.querySelectorAll('li[preoption]'); // 依次点击每个正确选项 for (const optionLetter of correctOptions) { // 每次点击前,重新点击 multi-noinput 确保浮窗激活 multiNoinputSpan.click(); await new Promise(resolve => setTimeout(resolve, 100)); // 重新获取浮窗(因为可能会重新渲染) const currentPicker = doc.querySelector('.multiOptionsPicker.visible') || doc.querySelector('.multiOptionsPicker'); if (!currentPicker) { console.warn('[WeLearn-Go] fillMultiNoinputByClick: 浮窗消失了'); continue; } const currentItems = currentPicker.querySelectorAll('li[preoption]'); for (const li of currentItems) { const optionMatch = li.textContent?.trim().match(/^([A-Z])\./i); if (optionMatch && optionMatch[1].toUpperCase() === optionLetter) { // 检查是否已被选中(有 used class) if (!li.classList.contains('used')) { console.info('[WeLearn-Go] fillMultiNoinputByClick: 点击选项', optionLetter); li.click(); // 等待系统处理 await new Promise(resolve => setTimeout(resolve, 100)); } else { console.info('[WeLearn-Go] fillMultiNoinputByClick: 选项已使用,跳过', optionLetter); } break; } } } console.info('[WeLearn-Go] fillMultiNoinputByClick: 完成', { id: container.id }); }; /** * 检测并处理 et-song 类型(朗读/引用音频练习) * 这种类型通常是无需交互的阅读材料,标记为 notscored * @param {Element} container - et-item 容器元素 * @returns {boolean} 是否为 et-song 类型 */ const isEtSongItem = (container) => { return container.querySelector('et-song') !== null; }; /** * 检测是否为开放式练习(无标准答案,需要用户自行填写) * 特征:et-blank 的 g 属性为空,且没有任何答案来源(.key 元素或 .visible-box) * @param {Element} container - et-item 容器元素 * @returns {boolean} 是否为开放式练习 */ const isOpenEndedItem = (container) => { const blanks = container.querySelectorAll('et-blank'); if (blanks.length === 0) return false; // 检查是否所有 et-blank 都没有答案来源 const allBlanksEmpty = Array.from(blanks).every(blank => { // 检查 g 属性 const gAttr = blank.getAttribute('g'); if (gAttr && gAttr.trim()) return false; // 检查内部 .key 元素 const keyEl = blank.querySelector('.key, span.key'); if (keyEl?.textContent?.trim()) return false; // 检查同级或父级的 .visible-box const stemContainer = blank.closest('[et-stem-index]') || blank.parentElement?.parentElement; if (stemContainer) { const visibleBox = stemContainer.querySelector('.visible-box'); if (visibleBox?.textContent?.trim()) return false; } // 检查直接父级的 .visible-box if (blank.parentElement) { const sibling = blank.parentElement.querySelector('.visible-box'); if (sibling?.textContent?.trim()) return false; } return true; }); if (!allBlanksEmpty) return false; // 如果所有空格都没有答案,且包含 et-recorder(录音)或 notscored,则认为是开放式练习 const hasRecorder = container.querySelector('et-recorder') !== null; return hasRecorder || container.hasAttribute('notscored'); }; /** * 检测是否为无交互类型的 et-item * 注意:notscored 属性只表示不计分,不意味着不需要填写 * @param {Element} container - et-item 容器元素 * @returns {boolean} 是否为无交互类型 */ const isNoInteractionItem = (container) => { // 检查是否包含 et-song(朗读/引用类型)- 这种确实不需要交互 if (isEtSongItem(container)) return true; // 检查是否有可填写的元素(包括 textarea) const hasInputElements = container.querySelector('et-blank, et-multi-noinput, et-multi-options, et-recorder, et-choice, et-tof, et-matching, [contenteditable="true"], textarea, input[type="text"]'); // 如果没有任何可填写的元素,才认为是无交互类型 if (!hasInputElements) return true; return false; }; /** * 填充 et-item 容器中的所有题目 * @param {Element} container - et-item 容器元素 * @param {Function} mutateAnswer - 答案变异函数 * @returns {boolean} 是否有任何填充操作 */ const fillEtItem = (container, mutateAnswer) => { // 跳过无交互类型 if (isNoInteractionItem(container)) { return false; } // 检测开放式练习(无标准答案) if (isOpenEndedItem(container)) { console.info('[WeLearn-Go] fillEtItem: 检测到开放式练习(无标准答案)', container.id || container.getAttribute('uuid')); handleOpenEndedExercise(container); return false; } let filled = false; // 填充 et-blank 填空题 const blanks = Array.from(container.querySelectorAll('et-blank')); console.info('[WeLearn-Go] fillEtItem: 找到 et-blank 数量:', blanks.length); blanks.forEach((blank) => { const changed = fillEtBlank(blank, mutateAnswer); console.info('[WeLearn-Go] fillEtBlank 返回:', changed, blank.id); filled = filled || changed; }); // 填充 et-multi-noinput 多选题 const multiNoinputs = Array.from(container.querySelectorAll('et-multi-noinput')); multiNoinputs.forEach((multi) => { const changed = fillEtMultiNoinput(multi); filled = filled || changed; }); // 填充 et-toggle 对话填空题 const toggles = Array.from(container.querySelectorAll('et-toggle')); toggles.forEach((toggle) => { const changed = fillEtToggle(toggle, mutateAnswer); filled = filled || changed; }); // 填充 et-choice 二选一选择题 const etChoices = Array.from(container.querySelectorAll('et-choice')); etChoices.forEach((choice) => { const changed = fillEtChoice(choice); filled = filled || changed; }); // 填充 et-tof 判断题(True/False 或自定义标签如 B/S) const etTofs = Array.from(container.querySelectorAll('et-tof')); etTofs.forEach((tof) => { const changed = fillEtTof(tof); filled = filled || changed; }); // 填充 et-matching 连线题 const etMatchings = Array.from(container.querySelectorAll('et-matching')); etMatchings.forEach((matching) => { const changed = fillEtMatching(matching); filled = filled || changed; }); return filled; }; /** * 填充 et-matching 连线题 * 参考 WELearnHelper 项目的实现:直接注入 SVG line 元素并更新 AngularJS 数据 * @param {Element} container - et-matching 容器元素 * @returns {boolean} 是否成功填充 */ const fillEtMatching = (container) => { // 防止重复执行 if (container.dataset.welearnGoProcessed === 'true') return false; const key = container.getAttribute('key'); if (!key) { console.warn('[WeLearn-Go] fillEtMatching: 没有 key 属性'); return false; } // 标记为已处理 container.dataset.welearnGoProcessed = 'true'; // 解析答案 key="1-2,2-5,3-4,4-3,5-1" 或 key="1-6,2-5,3-4,4-2,5-1,6-9,7-7,8-3,9-8,10-10" // 格式:左边索引-右边索引 (1-based) const pairs = key.split(',').map(p => p.trim()).filter(p => p); if (pairs.length === 0) { console.warn('[WeLearn-Go] fillEtMatching: 空的 key'); return false; } console.info('[WeLearn-Go] fillEtMatching: 解析答案', { key, pairs }); // 获取 AngularJS scope 和 matching 控制器 const ownerWindow = container.ownerDocument?.defaultView || window; const angular = ownerWindow.angular; let scope = null; let matchingCtrl = null; if (angular) { try { scope = angular.element(container)?.scope(); matchingCtrl = scope?.matching; } catch (e) { console.debug('[WeLearn-Go] fillEtMatching: 获取 scope 失败', e); } } if (!matchingCtrl) { console.warn('[WeLearn-Go] fillEtMatching: 未找到 matching 控制器'); // 继续尝试 DOM 方式 } // 获取圆点信息 const leftCircles = Array.from(container.querySelectorAll('circle[data-circle="A"]')); const rightCircles = Array.from(container.querySelectorAll('circle[data-circle="B"]')); console.info('[WeLearn-Go] fillEtMatching: 圆点数量', { left: leftCircles.length, right: rightCircles.length }); if (leftCircles.length === 0 || rightCircles.length === 0) { console.warn('[WeLearn-Go] fillEtMatching: 未找到圆点'); return false; } // ═══════════════════════════════════════════════════════════════ // 方法1: 通过 AngularJS 控制器设置答案 (最可靠) // ═══════════════════════════════════════════════════════════════ if (matchingCtrl) { try { // 初始化 answers 数组 if (!matchingCtrl.answers || !Array.isArray(matchingCtrl.answers)) { matchingCtrl.answers = []; } // 确保数组足够长 for (let i = 0; i < leftCircles.length; i++) { if (!matchingCtrl.answers[i]) { matchingCtrl.answers[i] = []; } } // 设置每条连线 pairs.forEach(pair => { const parts = pair.split('-'); if (parts.length !== 2) return; const leftIdx = parseInt(parts[0], 10) - 1; // 1-based to 0-based const rightIdx = parseInt(parts[1], 10) - 1; if (leftIdx >= 0 && leftIdx < leftCircles.length && rightIdx >= 0 && rightIdx < rightCircles.length) { // 确保不重复添加 if (!matchingCtrl.answers[leftIdx].includes(rightIdx)) { matchingCtrl.answers[leftIdx].push(rightIdx); } } }); console.info('[WeLearn-Go] fillEtMatching: 设置 answers', matchingCtrl.answers); // 触发 AngularJS 更新 if (scope && scope.$apply) { try { scope.$apply(); } catch (e) { // 可能已经在 digest 中 scope.$evalAsync(() => {}); } } // 短暂延迟后再次触发更新,确保 SVG 渲染 setTimeout(() => { if (scope && scope.$digest) { try { scope.$digest(); } catch (e) {} } }, 100); return true; } catch (e) { console.warn('[WeLearn-Go] fillEtMatching: AngularJS 方式失败', e); } } // ═══════════════════════════════════════════════════════════════ // 方法2: 直接操作 SVG (备用方案) // ═══════════════════════════════════════════════════════════════ const svg = container.querySelector('svg'); let answersGroup = container.querySelector('g.answers'); if (!svg) { console.warn('[WeLearn-Go] fillEtMatching: 未找到 SVG'); return false; } // 如果没有 answers 组,创建一个 if (!answersGroup) { answersGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); answersGroup.setAttribute('class', 'answers'); svg.appendChild(answersGroup); } // 清除现有的线条(避免重复) const existingLines = answersGroup.querySelectorAll('line'); existingLines.forEach(line => line.remove()); // 画每条连线 pairs.forEach(pair => { const parts = pair.split('-'); if (parts.length !== 2) return; const leftIdx = parseInt(parts[0], 10) - 1; const rightIdx = parseInt(parts[1], 10) - 1; const leftCircle = leftCircles[leftIdx]; const rightCircle = rightCircles[rightIdx]; if (!leftCircle || !rightCircle) { console.warn('[WeLearn-Go] fillEtMatching: 未找到圆点', { leftIdx, rightIdx }); return; } const x1 = leftCircle.getAttribute('cx'); const y1 = leftCircle.getAttribute('cy'); const x2 = rightCircle.getAttribute('cx'); const y2 = rightCircle.getAttribute('cy'); // 创建线条 const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', x1); line.setAttribute('y1', y1); line.setAttribute('x2', x2); line.setAttribute('y2', y2); line.setAttribute('stroke', '#4a9'); // 绿色线条 line.setAttribute('stroke-width', '2'); answersGroup.appendChild(line); console.info('[WeLearn-Go] fillEtMatching: 画线', { leftIdx, rightIdx, x1, y1, x2, y2 }); }); // ═══════════════════════════════════════════════════════════════ // 方法3: 模拟点击 (最后备用) // ═══════════════════════════════════════════════════════════════ if (!matchingCtrl) { console.info('[WeLearn-Go] fillEtMatching: 尝试点击模拟'); let clickIndex = 0; const doClick = () => { if (clickIndex >= pairs.length) return; const pair = pairs[clickIndex]; const parts = pair.split('-'); if (parts.length !== 2) { clickIndex++; setTimeout(doClick, 50); return; } const leftIdx = parseInt(parts[0], 10) - 1; const rightIdx = parseInt(parts[1], 10) - 1; const leftCircle = leftCircles[leftIdx]; const rightCircle = rightCircles[rightIdx]; if (leftCircle && rightCircle) { // 点击左边圆点 leftCircle.dispatchEvent(new MouseEvent('click', { view: ownerWindow, bubbles: true, cancelable: true })); // 稍后点击右边圆点 setTimeout(() => { rightCircle.dispatchEvent(new MouseEvent('click', { view: ownerWindow, bubbles: true, cancelable: true })); clickIndex++; setTimeout(doClick, 100); }, 150); } else { clickIndex++; setTimeout(doClick, 50); } }; doClick(); } return true; }; /** * 填充 et-toggle 对话填空题(带视频/音频的对话练习) * 对话内容通常在 .lrc 或 .dialog 区域,填空位置有 et-blank 或 input 元素 * @param {Element} container - et-toggle 容器元素 * @param {Function} mutateAnswer - 答案变异函数 * @returns {boolean} 是否有任何填充操作 */ const fillEtToggle = (container, mutateAnswer) => { let filled = false; // 查找对话区域中的 et-blank 填空 const blanks = Array.from(container.querySelectorAll('et-blank')); blanks.forEach((blank) => { const changed = fillEtBlank(blank, mutateAnswer); filled = filled || changed; }); // 查找 .lrc 区域中的填空(可能是 span 或 input) const lrcBlanks = Array.from(container.querySelectorAll('.lrc [contenteditable="true"], .lrc input[type="text"]')); lrcBlanks.forEach((input) => { const changed = fillGenericInput(input, mutateAnswer); filled = filled || changed; }); // 查找对话区域中的填空 const dialogBlanks = Array.from(container.querySelectorAll('.dialog [contenteditable="true"], .dialog input[type="text"]')); dialogBlanks.forEach((input) => { const changed = fillGenericInput(input, mutateAnswer); filled = filled || changed; }); return filled; }; /** * 从全局上下文中查找答案 * @param {Element} element - 题目元素 * @returns {string|null} 找到的答案或 null */ const findAnswerFromGlobalContext = (element) => { const win = element.ownerDocument?.defaultView || window; const ids = [ element.id, element.getAttribute('data-id'), element.getAttribute('data-question-id'), element.getAttribute('data-item-id'), element.closest('et-item')?.id ].filter(Boolean); if (ids.length === 0) return null; // 常见的全局数据源 const dataSources = [ win.courseData, win.pageData, win.activity, win.questionList, win.__INITIAL_STATE__, win.g_data ]; for (const source of dataSources) { if (!source) continue; // 递归搜索答案 const search = (obj, depth = 0) => { if (depth > 3 || !obj || typeof obj !== 'object') return null; // 检查当前对象是否包含 ID 和答案 if (ids.some(id => obj.id == id || obj.questionId == id || obj.itemId == id)) { const possibleKeys = ['answer', 'key', 'correctAnswer', 'solution', 'rightAnswer']; for (const key of possibleKeys) { if (obj[key] !== undefined && obj[key] !== null && obj[key] !== '') { return String(obj[key]); } } } // 遍历数组或对象属性 if (Array.isArray(obj)) { for (const item of obj) { const res = search(item, depth + 1); if (res) return res; } } else { for (const key in obj) { if (key === 'parent' || key === 'prev' || key === 'next') continue; // 避免循环引用 const res = search(obj[key], depth + 1); if (res) return res; } } return null; }; const result = search(source); if (result) { console.debug('[WeLearn-Go] findAnswerFromGlobalContext: 找到答案', result); return result; } } return null; }; /** * 从解释文本中查找答案(针对 et-choice) * @param {Element} container - et-choice 容器 * @param {Array} options - 选项元素数组 * @returns {Element|null} 匹配的选项或 null */ const findAnswerFromExplanation = (container, options) => { // 查找紧邻的 explanation 元素 let explanationEl = container.nextElementSibling; if (!explanationEl || !explanationEl.classList.contains('explanation')) { // 尝试在父级查找 const parent = container.parentElement; if (parent) { const explanationInParent = parent.querySelector(`.explanation[visible-on-key]`); // 确保它属于当前题目(简单的位置判断) if (explanationInParent && explanationInParent.compareDocumentPosition(container) & Node.DOCUMENT_POSITION_PRECEDING) { // explanation 在 container 之后 explanationEl = explanationInParent; } } } if (!explanationEl) return null; const explanationText = normalizeText(explanationEl.textContent); if (!explanationText) return null; // 1. 寻找最长完整子串匹配 let longestSubstringMatch = null; let longestLen = 0; options.forEach(opt => { const optText = normalizeText(opt.textContent); if (optText && explanationText.includes(optText)) { if (optText.length > longestLen) { longestLen = optText.length; longestSubstringMatch = opt; } } }); if (longestSubstringMatch) { console.debug('[WeLearn-Go] findAnswerFromExplanation: 找到完整子串匹配', longestSubstringMatch.textContent); return longestSubstringMatch; } // 2. 如果没有完整匹配,尝试单词覆盖率 let bestFuzzyMatch = null; let bestFuzzyScore = 0; options.forEach(opt => { const optText = normalizeText(opt.textContent); const stopWords = ['THE', 'A', 'AN', 'IN', 'ON', 'AT', 'TO', 'OF', 'FOR', 'AND', 'BUT', 'OR', 'IS', 'ARE', 'WAS', 'WERE', 'IT', 'THIS', 'THAT', 'HE', 'SHE', 'THEY']; const words = optText.split(/[^A-Z0-9]+/).filter(w => w.length > 2 && !stopWords.includes(w)); if (words.length < 2) return; // 单词太少不准确 let matchCount = 0; words.forEach(w => { // 简单的单词包含检查 if (explanationText.includes(w)) matchCount++; }); const score = matchCount / words.length; if (score > 0.75 && score > bestFuzzyScore) { bestFuzzyScore = score; bestFuzzyMatch = opt; } }); if (bestFuzzyMatch) { console.debug('[WeLearn-Go] findAnswerFromExplanation: 找到模糊匹配', bestFuzzyMatch.textContent, bestFuzzyScore); } return bestFuzzyMatch; }; /** * 填充 et-choice 选择题(综合实现) * * 核心原理:et-choice 元素可能有 key 属性存储正确答案 * 也可能需要从 AngularJS scope 或 .key 类获取答案 * 选项可以是 li 或 span 形式 * * 答案来源(按优先级): * 1. et-choice 的 key 属性 - 如 "A", "B", "1", "2" 或多选 "A,B" * 2. AngularJS scope 的 isKey() 方法 * 3. 已显示的 .key 类 * 4. span.key 答案提示文本 * * @param {Element} container - et-choice 容器元素 * @returns {boolean} 是否成功填充 */ const fillEtChoice = (container) => { console.log('[WeLearn-Go] fillEtChoice: 开始处理'); // 跳过重复元素(WELearnHelper 的 isRepeat 逻辑简化版) if (container.closest('et-web-only')) { console.log('[WeLearn-Go] fillEtChoice: 在 et-web-only 中,跳过'); return false; } // 检查是否已经有选中的选项(如果已选中正确答案则跳过) const alreadyChosen = container.querySelector('li.chosen, li.active, li.selected'); if (alreadyChosen) { console.log('[WeLearn-Go] fillEtChoice: 已有选中选项,跳过'); return false; } // ====== 1. 查找选项元素 ====== let options = Array.from(container.querySelectorAll('li')); let useSpan = false; if (options.length === 0) { options = Array.from(container.querySelectorAll('span[ng-click*="select"]')); useSpan = true; } if (options.length === 0) { // 尝试从 .wrapper 内查找 const wrapper = container.querySelector('.wrapper'); if (wrapper) { options = Array.from(wrapper.querySelectorAll('li')); if (options.length === 0) { options = Array.from(wrapper.querySelectorAll('span[ng-click*="select"]')); useSpan = true; } } } if (options.length === 0) { console.warn('[WeLearn-Go] fillEtChoice: 没有找到选项元素'); return false; } console.log('[WeLearn-Go] fillEtChoice: 选项类型:', useSpan ? 'span' : 'li', '数量:', options.length); // ====== 2. 获取答案 ====== let targetOption = null; let targetIdx = -1; let answerSource = ''; // 记录答案来源:'key', 'scope', 'explanation', 'fuzzy' 等 let isReliable = true; // 答案是否可靠(标准答案 vs 解析推断) // 方法1: 从 key 属性获取答案 const keyAttr = container.getAttribute('key'); if (keyAttr) { console.log('[WeLearn-Go] fillEtChoice: 发现 key 属性:', keyAttr); const answerKeys = keyAttr.split(',').map(k => k.trim()); for (const answerKey of answerKeys) { let idx = -1; if (/^[A-Za-z]$/.test(answerKey)) { idx = answerKey.toUpperCase().charCodeAt(0) - 65; } else if (/^\d+$/.test(answerKey)) { idx = parseInt(answerKey, 10) - 1; } if (idx >= 0 && idx < options.length) { targetOption = options[idx]; targetIdx = idx; console.log('[WeLearn-Go] fillEtChoice: 通过 key 属性找到答案,索引:', idx); answerSource = 'key属性'; isReliable = true; break; } } } // 方法2: 通过 AngularJS scope 获取答案 (详细调试版) if (!targetOption) { try { const scopeDoc = container.ownerDocument?.defaultView || window; const angular = scopeDoc.angular; if (angular) { console.log('[WeLearn-Go] fillEtChoice: 尝试 AngularJS 方法'); // 获取 scope - 尝试多种元素 const elementsToTry = [container]; const wrapper = container.querySelector('.wrapper'); if (wrapper) elementsToTry.push(wrapper); if (options[0]) elementsToTry.push(options[0]); let scope = null; let controller = null; for (const el of elementsToTry) { scope = angular.element(el)?.scope(); if (scope) { controller = scope.choice || scope.$ctrl || scope.vm; if (controller) break; } } if (scope) { console.log('[WeLearn-Go] fillEtChoice: 获取到 scope'); // ★★★ 详细调试:打印 scope 中所有非 $ 开头的属性 ★★★ const scopeKeys = Object.keys(scope).filter(k => !k.startsWith('$') && !k.startsWith('_')); console.log('[WeLearn-Go] fillEtChoice: scope 属性:', scopeKeys); // 特别查看 choice 对象 if (scope.choice) { const choiceKeys = Object.keys(scope.choice).filter(k => !k.startsWith('$')); console.log('[WeLearn-Go] fillEtChoice: choice 属性:', choiceKeys); // 打印所有 choice 的值(调试用) for (const k of choiceKeys) { const v = scope.choice[k]; if (typeof v !== 'function') { console.log(`[WeLearn-Go] choice.${k} =`, v); } else { console.log(`[WeLearn-Go] choice.${k} = [Function]`); } } controller = scope.choice; } } if (controller) { console.log('[WeLearn-Go] fillEtChoice: 找到 controller,keys:', Object.keys(controller).filter(k => !k.startsWith('$'))); // ★★★ 核心:尝试调用 isKey 方法 ★★★ if (typeof controller.isKey === 'function') { console.log('[WeLearn-Go] fillEtChoice: 找到 isKey 方法,遍历选项'); for (let i = 0; i < options.length; i++) { try { const isKey = controller.isKey(i); console.log(`[WeLearn-Go] fillEtChoice: isKey(${i}) = ${isKey}`); if (isKey) { targetOption = options[i]; targetIdx = i; console.log('[WeLearn-Go] fillEtChoice: 通过 isKey 找到答案,索引:', i); answerSource = 'AngularJS isKey'; isReliable = true; break; } } catch (e) { console.debug('[WeLearn-Go] fillEtChoice: isKey 调用失败', e); } } } // ★★★ 核心:检查 data.key 属性 ★★★ if (!targetOption && controller.data) { console.log('[WeLearn-Go] fillEtChoice: controller.data 存在'); if (controller.data.key !== undefined) { let idx = controller.data.key; console.log('[WeLearn-Go] fillEtChoice: controller.data.key =', idx, typeof idx); if (typeof idx === 'number') { // key 可能是 0-based 或 1-based const try0 = idx; const try1 = idx - 1; if (try0 >= 0 && try0 < options.length) { targetOption = options[try0]; targetIdx = try0; console.log('[WeLearn-Go] fillEtChoice: 通过 data.key (0-based) 找到答案,索引:', try0); answerSource = 'AngularJS data.key'; isReliable = true; } else if (try1 >= 0 && try1 < options.length) { targetOption = options[try1]; targetIdx = try1; console.log('[WeLearn-Go] fillEtChoice: 通过 data.key (1-based) 找到答案,索引:', try1); answerSource = 'AngularJS data.key'; isReliable = true; } } } // 打印 data 的其他属性 const dataKeys = Object.keys(controller.data); console.log('[WeLearn-Go] fillEtChoice: controller.data 属性:', dataKeys); } // ★★★ 核心:检查 key 属性(字符串或数字) ★★★ if (!targetOption && controller.key !== undefined) { let idx = controller.key; console.log('[WeLearn-Go] fillEtChoice: controller.key =', idx, typeof idx); if (typeof idx === 'number') { if (idx >= 0 && idx < options.length) { targetOption = options[idx]; targetIdx = idx; } else if (idx - 1 >= 0 && idx - 1 < options.length) { targetOption = options[idx - 1]; targetIdx = idx - 1; } } else if (typeof idx === 'string') { // 可能是字母 A/B/C/D if (/^[A-Da-d]$/.test(idx)) { const letterIdx = idx.toUpperCase().charCodeAt(0) - 65; if (letterIdx >= 0 && letterIdx < options.length) { targetOption = options[letterIdx]; targetIdx = letterIdx; } } else if (/^\d+$/.test(idx)) { const numIdx = parseInt(idx, 10) - 1; if (numIdx >= 0 && numIdx < options.length) { targetOption = options[numIdx]; targetIdx = numIdx; } } } if (targetOption) { console.log('[WeLearn-Go] fillEtChoice: 通过 controller.key 找到答案,索引:', targetIdx); } } // ★★★ 核心:检查 std_answer 或 answer 属性 ★★★ if (!targetOption) { const answerProps = ['std_answer', 'answer', 'correctAnswer', 'correct', 'correctIndex']; for (const prop of answerProps) { if (controller[prop] !== undefined) { const val = controller[prop]; console.log(`[WeLearn-Go] fillEtChoice: controller.${prop} =`, val, typeof val); let idx = -1; if (typeof val === 'number') { idx = val >= 1 && val <= options.length ? val - 1 : val; } else if (typeof val === 'string' && /^[A-Da-d]$/.test(val)) { idx = val.toUpperCase().charCodeAt(0) - 65; } else if (typeof val === 'string' && /^\d+$/.test(val)) { idx = parseInt(val, 10) - 1; } if (idx >= 0 && idx < options.length) { targetOption = options[idx]; targetIdx = idx; console.log(`[WeLearn-Go] fillEtChoice: 通过 ${prop} 找到答案,索引:`, idx); break; } } } } } else { console.log('[WeLearn-Go] fillEtChoice: 未找到 controller/choice'); // 打印 scope 内容帮助调试 if (scope) { console.log('[WeLearn-Go] fillEtChoice: scope 内容:', Object.keys(scope).filter(k => !k.startsWith('$') && !k.startsWith('_')).slice(0, 20)); } } } else { console.log('[WeLearn-Go] fillEtChoice: 未找到 angular'); } } catch (e) { console.debug('[WeLearn-Go] fillEtChoice: AngularJS 访问失败', e); } } // 方法3: 查找已有 .key 类的选项 if (!targetOption) { targetOption = options.find((opt, i) => { if (opt.classList.contains('key')) { targetIdx = i; return true; } return false; }); if (targetOption) { console.log('[WeLearn-Go] fillEtChoice: 通过 .key 类找到答案,索引:', targetIdx); answerSource = 'CSS .key类'; isReliable = true; } } // 方法4: 从父级 et-item 中查找 span.key 答案提示 if (!targetOption) { const etItem = container.closest('et-item'); if (etItem) { const keySpan = etItem.querySelector('span.key:not([ng-click])'); if (keySpan) { const keyText = keySpan.textContent?.trim().toLowerCase(); console.log('[WeLearn-Go] fillEtChoice: 找到 span.key 答案:', keyText); targetOption = options.find((opt, i) => { const optText = opt.textContent?.trim().toLowerCase(); if (optText === keyText || optText.includes(keyText)) { targetIdx = i; return true; } return false; }); if (targetOption) { console.log('[WeLearn-Go] fillEtChoice: 通过 span.key 文本匹配找到答案'); } } } } // 方法5: 从选项的 ng-class 解析 isKey if (!targetOption) { for (let i = 0; i < options.length; i++) { const opt = options[i]; const ngClass = opt.getAttribute('ng-class') || ''; const keyMatch = ngClass.match(/key:\s*choice\.isKey\((\d+)\)/); if (keyMatch) { const expectedIdx = parseInt(keyMatch[1], 10); try { const scopeDoc = container.ownerDocument?.defaultView || window; const angular = scopeDoc.angular; const scope = angular?.element(opt)?.scope(); if (scope?.choice?.isKey) { const isKey = scope.choice.isKey(expectedIdx); if (isKey) { targetOption = opt; targetIdx = i; console.log('[WeLearn-Go] fillEtChoice: 通过 ng-class isKey 找到答案,索引:', i); break; } } } catch (e) { /* 忽略 */ } } } } // 方法6: ★★★ 从解释文本 (p.explanation) 中提取答案 ★★★ // 解释文本通常紧跟在 et-choice 后面,格式如:"正确答案是B" 或 "故C是正确答案" if (!targetOption) { // 查找紧邻的 p.explanation 元素 // 注意:explanation 必须是当前 et-choice 的直接后继,不能跨越其他 et-choice let explanationEl = null; let sibling = container.nextElementSibling; while (sibling) { // 如果遇到另一个 et-choice,停止搜索 if (sibling.tagName?.toLowerCase() === 'et-choice') { break; } // 找到 explanation if (sibling.classList?.contains('explanation')) { explanationEl = sibling; break; } sibling = sibling.nextElementSibling; } if (explanationEl) { const explanationText = explanationEl.textContent || ''; // 打印更多文本内容用于调试 console.log('[WeLearn-Go] fillEtChoice: 找到解释文本 (长度' + explanationText.length + '):', explanationText.length > 200 ? explanationText.substring(0, 100) + '...' + explanationText.substring(explanationText.length - 100) : explanationText); // 匹配多种答案格式: // "正确答案是B" "正确答案是 B" "正确答案为B" // "故C是正确答案" "故 C 是正确答案" "故C项为正确答案" // "答案是A" "答案为A" "选A" "选择A" // "所以D项并非..." (反向选择题,选错误项) // "The answer is B" "Answer: C" // "C项表述符合" "只有C项与新闻相符" const patterns = [ /正确答案[是为]?\s*([A-Da-d])/, /故\s*([A-Da-d])\s*项?[是为]?正确答案/, // "故C项为正确答案" /([A-Da-d])\s*项?[是为]正确答案/, // "C项为正确答案" /([A-Da-d])\s*项?表述符合/, // ★新增:"C项表述符合" /([A-Da-d])\s*项?与新闻相符/, // ★新增:"C项与新闻相符" /([A-Da-d])\s*项?符合/, // ★新增:"C项符合" /只有\s*([A-Da-d])\s*项?/, // ★新增:"只有C项" /答案[是为]?\s*([A-Da-d])/, /选[择]?\s*([A-Da-d])/, /[Aa]nswer[:\s]+([A-Da-d])/i, /([A-Da-d])\s*[是为]正确/, /([A-Da-d])\s*项?正确/, // 反向选择题格式 "所以D项并非" "D项不是" 等 /所以\s*([A-Da-d])\s*项?/, /([A-Da-d])\s*项?并非/, /([A-Da-d])\s*项?不是/, /([A-Da-d])\s*项?错误/, /排除\s*([A-Da-d])/, ]; for (const pattern of patterns) { const match = explanationText.match(pattern); if (match) { const answerLetter = match[1].toUpperCase(); const idx = answerLetter.charCodeAt(0) - 65; // A=0, B=1, C=2, D=3 if (idx >= 0 && idx < options.length) { targetOption = options[idx]; targetIdx = idx; console.log('[WeLearn-Go] fillEtChoice: 从解释文本提取到答案:', answerLetter, '-> 索引:', idx); answerSource = '解释文本正则匹配'; isReliable = false; // 解析推断,可能有误 break; } } } // ★★★ 方法6a-2: 中文解析末尾字母提取 ★★★ // 规则:如果解释文本主要是中文,且末尾有单独的字母 A/B/C/D,则该字母就是答案 // 例如:"...C项表述符合新闻的主旨大意。C" -> 答案是 C // 或者:"...综上所述,答案选 B。" -> 答案是 B if (!targetOption) { // 检查是否主要是中文(包含中文字符) const hasChinese = /[\u4e00-\u9fa5]/.test(explanationText); if (hasChinese) { // 提取文本末尾的字母(去除标点和空格后) // 匹配模式:文本结尾的 A/B/C/D,可能前面有标点或空格 const endPatterns = [ /[。.,,;;!!??\s]+([A-Da-d])\s*[。.]*\s*$/, // "...主旨大意。C" 或 "...答案选 B。" /([A-Da-d])\s*[。.]*\s*$/, // 直接以字母结尾 /选\s*([A-Da-d])\s*[。.]*\s*$/, // "选C" 结尾 /是\s*([A-Da-d])\s*[。.]*\s*$/, // "是C" 结尾 /为\s*([A-Da-d])\s*[。.]*\s*$/, // "为C" 结尾 ]; for (const pattern of endPatterns) { const match = explanationText.match(pattern); if (match) { const answerLetter = match[1].toUpperCase(); const idx = answerLetter.charCodeAt(0) - 65; if (idx >= 0 && idx < options.length) { targetOption = options[idx]; targetIdx = idx; console.log('[WeLearn-Go] fillEtChoice: 从解释文本末尾提取到答案:', answerLetter, '-> 索引:', idx); answerSource = '解释文本末尾字母'; isReliable = false; // 解析推断,可能有误 break; } } } // 如果上面的模式没匹配到,尝试找最后一个出现的 A/B/C/D if (!targetOption) { // 找文本中所有的 A/B/C/D(独立出现,不是单词的一部分) const letterMatches = explanationText.match(/(?:^|[^a-zA-Z])([A-Da-d])(?:[^a-zA-Z]|$)/g); if (letterMatches && letterMatches.length > 0) { // 取最后一个匹配 const lastMatch = letterMatches[letterMatches.length - 1]; const letterMatch = lastMatch.match(/[A-Da-d]/); if (letterMatch) { const answerLetter = letterMatch[0].toUpperCase(); const idx = answerLetter.charCodeAt(0) - 65; if (idx >= 0 && idx < options.length) { targetOption = options[idx]; targetIdx = idx; console.log('[WeLearn-Go] fillEtChoice: 从解释文本最后一个字母提取答案:', answerLetter, '-> 索引:', idx); answerSource = '解释文本最后字母'; isReliable = false; // 解析推断,可能有误 } } } } } } // ★★★ 方法6b: 从解释文本中的数值与选项进行匹配 ★★★ // 例如: 解释 "1.1 degrees Celsius" 匹配选项 "1.1°C" // 或者: 解释 "over 620,000" 匹配选项 "More than 620,000" // 注意: 只在选项本身包含数值时才启用数值匹配 if (!targetOption) { // 先检查选项是否主要是数值型选项 const optionTexts = options.map(opt => opt.textContent?.trim() || ''); const numericOptionCount = optionTexts.filter(t => /^\d|^[\$€£¥]?\d|^[<>≤≥]?\s*\d/.test(t) || /\d+[%°]/.test(t) || /\d+\/\d+/.test(t)).length; // 只有当至少一半选项是数值型时,才使用数值匹配 const shouldUseNumericMatch = numericOptionCount >= options.length / 2; console.log('[WeLearn-Go] fillEtChoice: 数值型选项数量:', numericOptionCount, '/', options.length, shouldUseNumericMatch ? '-> 启用数值匹配' : '-> 跳过数值匹配'); if (shouldUseNumericMatch) { console.log('[WeLearn-Go] fillEtChoice: 尝试数值匹配'); // ═══════════════════════════════════════════════════════════════ // ★★★ 完整的文本标准化规则系统 ★★★ // ═══════════════════════════════════════════════════════════════ const normalizeText = (text) => { let n = text.toLowerCase(); // ───────────────────────────────────────────────────────────── // 规则1: 英文小数点表达 "point" -> "." // ───────────────────────────────────────────────────────────── n = n.replace(/\bpoint\s+/g, '.'); // ───────────────────────────────────────────────────────────── // 规则2: 英文复合数字 (21-99) // ───────────────────────────────────────────────────────────── const compoundNumbers = { 'twenty': 20, 'thirty': 30, 'forty': 40, 'fifty': 50, 'sixty': 60, 'seventy': 70, 'eighty': 80, 'ninety': 90 }; const unitNumbers = { 'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5, 'six': 6, 'seven': 7, 'eight': 8, 'nine': 9 }; // 处理 "eighty-eight", "twenty one" 等 for (const [tens, tensVal] of Object.entries(compoundNumbers)) { for (const [unit, unitVal] of Object.entries(unitNumbers)) { const combined = tensVal + unitVal; n = n.replace(new RegExp(`\\b${tens}[\\s-]${unit}\\b`, 'g'), String(combined)); } } // ───────────────────────────────────────────────────────────── // 规则3: 英文基础数字 (0-19, 整十, 大数) // ───────────────────────────────────────────────────────────── const basicNumbers = { 'zero': '0', 'one': '1', 'two': '2', 'three': '3', 'four': '4', 'five': '5', 'six': '6', 'seven': '7', 'eight': '8', 'nine': '9', 'ten': '10', 'eleven': '11', 'twelve': '12', 'thirteen': '13', 'fourteen': '14', 'fifteen': '15', 'sixteen': '16', 'seventeen': '17', 'eighteen': '18', 'nineteen': '19', 'twenty': '20', 'thirty': '30', 'forty': '40', 'fifty': '50', 'sixty': '60', 'seventy': '70', 'eighty': '80', 'ninety': '90', 'hundred': '00', 'thousand': '000', 'million': '000000', 'billion': '000000000' }; for (const [word, num] of Object.entries(basicNumbers)) { n = n.replace(new RegExp(`\\b${word}\\b`, 'g'), num); } // ───────────────────────────────────────────────────────────── // 规则4: 中文数字 // ───────────────────────────────────────────────────────────── const chineseNumbers = { '零': '0', '一': '1', '二': '2', '两': '2', '三': '3', '四': '4', '五': '5', '六': '6', '七': '7', '八': '8', '九': '9', '十': '10', '百': '00', '千': '000', '万': '0000', '亿': '00000000' }; for (const [cn, num] of Object.entries(chineseNumbers)) { n = n.replace(new RegExp(cn, 'g'), num); } // ───────────────────────────────────────────────────────────── // 规则5: 序数词 // ───────────────────────────────────────────────────────────── const ordinals = { 'first': '1', 'second': '2', 'third': '3', 'fourth': '4', 'fifth': '5', 'sixth': '6', 'seventh': '7', 'eighth': '8', 'ninth': '9', 'tenth': '10', '第一': '1', '第二': '2', '第三': '3', '第四': '4', '第五': '5' }; for (const [ord, num] of Object.entries(ordinals)) { n = n.replace(new RegExp(`\\b${ord}\\b`, 'gi'), num); } // ───────────────────────────────────────────────────────────── // 规则6: 分数表达 // ───────────────────────────────────────────────────────────── const fractions = { 'quarter': '1/4', 'half': '1/2', 'third': '1/3', 'one quarter': '1/4', 'one half': '1/2', 'one third': '1/3', 'two thirds': '2/3', 'three quarters': '3/4', '四分之一': '1/4', '二分之一': '1/2', '三分之一': '1/3', '三分之二': '2/3', '四分之三': '3/4' }; for (const [frac, num] of Object.entries(fractions)) { n = n.replace(new RegExp(frac, 'gi'), num); } // ───────────────────────────────────────────────────────────── // 规则7: 百分比表达 // ───────────────────────────────────────────────────────────── n = n.replace(/\s*percent\b/gi, '%'); n = n.replace(/\s*per\s*cent\b/gi, '%'); n = n.replace(/%/g, '%'); n = n.replace(/百分之(\d+)/g, '$1%'); // ───────────────────────────────────────────────────────────── // 规则8: 温度表达 // ───────────────────────────────────────────────────────────── n = n.replace(/(\d+\.?\d*)\s*degrees?\s*celsius/gi, '$1°C'); n = n.replace(/(\d+\.?\d*)\s*degrees?\s*fahrenheit/gi, '$1°F'); n = n.replace(/(\d+\.?\d*)\s*degrees?\s*centigrade/gi, '$1°C'); n = n.replace(/degrees?\s*celsius/gi, '°C'); n = n.replace(/degrees?\s*fahrenheit/gi, '°F'); n = n.replace(/摄氏(\d+)/g, '$1°C'); n = n.replace(/华氏(\d+)/g, '$1°F'); n = n.replace(/(\d+)\s*摄氏度/g, '$1°C'); n = n.replace(/(\d+)\s*华氏度/g, '$1°F'); // ───────────────────────────────────────────────────────────── // 规则9: 货币表达 // ───────────────────────────────────────────────────────────── n = n.replace(/\$\s*(\d)/g, '$$$1'); // 移除 $ 后的空格 n = n.replace(/(\d+\.?\d*)\s*dollars?/gi, '$$$1'); n = n.replace(/(\d+\.?\d*)\s*euros?/gi, '€$1'); n = n.replace(/(\d+\.?\d*)\s*pounds?/gi, '£$1'); n = n.replace(/(\d+\.?\d*)\s*元/g, '¥$1'); n = n.replace(/(\d+\.?\d*)\s*美元/g, '$$$1'); // ───────────────────────────────────────────────────────────── // 规则10: 数量级表达 // ───────────────────────────────────────────────────────────── n = n.replace(/(\d+\.?\d*)\s*million/gi, (m, p1) => String(parseFloat(p1) * 1000000)); n = n.replace(/(\d+\.?\d*)\s*billion/gi, (m, p1) => String(parseFloat(p1) * 1000000000)); n = n.replace(/(\d+\.?\d*)\s*thousand/gi, (m, p1) => String(parseFloat(p1) * 1000)); // ───────────────────────────────────────────────────────────── // 规则11: 比较词标准化 // ───────────────────────────────────────────────────────────── n = n.replace(/\bmore\s+than\b/gi, '>'); n = n.replace(/\bover\b/gi, '>'); n = n.replace(/\babove\b/gi, '>'); n = n.replace(/\bexceeds?\b/gi, '>'); n = n.replace(/\bless\s+than\b/gi, '<'); n = n.replace(/\bunder\b/gi, '<'); n = n.replace(/\bbelow\b/gi, '<'); n = n.replace(/\bfewer\s+than\b/gi, '<'); n = n.replace(/\babout\b/gi, '≈'); n = n.replace(/\baround\b/gi, '≈'); n = n.replace(/\bapproximately\b/gi, '≈'); n = n.replace(/\bnearly\b/gi, '≈'); n = n.replace(/\balmost\b/gi, '≈'); n = n.replace(/\bat\s+least\b/gi, '≥'); n = n.replace(/\bat\s+most\b/gi, '≤'); n = n.replace(/\bup\s+to\b/gi, '≤'); n = n.replace(/超过/g, '>'); n = n.replace(/多于/g, '>'); n = n.replace(/大于/g, '>'); n = n.replace(/少于/g, '<'); n = n.replace(/小于/g, '<'); n = n.replace(/低于/g, '<'); n = n.replace(/大约/g, '≈'); n = n.replace(/约/g, '≈'); n = n.replace(/近/g, '≈'); n = n.replace(/至少/g, '≥'); n = n.replace(/最多/g, '≤'); // ───────────────────────────────────────────────────────────── // 规则12: 时间表达 // ───────────────────────────────────────────────────────────── n = n.replace(/(\d+)\s*years?\s*old/gi, '$1岁'); n = n.replace(/(\d+)\s*年/g, '$1年'); n = n.replace(/(\d+)\s*months?/gi, '$1月'); n = n.replace(/(\d+)\s*weeks?/gi, '$1周'); n = n.replace(/(\d+)\s*days?/gi, '$1天'); n = n.replace(/(\d+)\s*hours?/gi, '$1小时'); n = n.replace(/(\d+)\s*minutes?/gi, '$1分钟'); n = n.replace(/(\d+)\s*seconds?/gi, '$1秒'); n = n.replace(/century/gi, '世纪'); n = n.replace(/centuries/gi, '世纪'); n = n.replace(/decade/gi, '十年'); n = n.replace(/decades/gi, '十年'); // ───────────────────────────────────────────────────────────── // 规则13: 清理格式 // ───────────────────────────────────────────────────────────── n = n.replace(/,/g, ''); // 移除千位分隔符 n = n.replace(/(\d+)\s*\.\s*(\d+)/g, '$1.$2'); // 修复小数点空格 n = n.replace(/\s+/g, ' '); // 合并多余空格 return n.trim(); }; const normalizedExplanation = normalizeText(explanationText); console.log('[WeLearn-Go] fillEtChoice: 标准化解释文本:', normalizedExplanation.substring(0, 150)); // ═══════════════════════════════════════════════════════════════ // ★★★ 数值提取规则 ★★★ // ═══════════════════════════════════════════════════════════════ const extractPatterns = [ { name: '温度', pattern: /(\d+\.?\d*)°[CF]/gi }, { name: '百分比', pattern: /(\d+\.?\d*)\s*%/g }, { name: '分数', pattern: /(\d+)\s*\/\s*(\d+)/g }, { name: '货币', pattern: /[$€£¥]\s*(\d+\.?\d*)/g }, { name: '比较数值', pattern: /[><=≈≥≤]\s*(\d+\.?\d*)/g }, { name: '普通数字', pattern: /\b(\d+\.?\d*)\b/g }, ]; const extractedValues = []; for (const { name, pattern } of extractPatterns) { let match; const p = new RegExp(pattern.source, pattern.flags); while ((match = p.exec(normalizedExplanation)) !== null) { extractedValues.push({ type: name, value: match[0].toLowerCase(), raw: match[0] }); } } console.log('[WeLearn-Go] fillEtChoice: 提取的数值:', extractedValues.map(v => v.value)); // ═══════════════════════════════════════════════════════════════ // ★★★ 分数与百分比等价映射 ★★★ // ═══════════════════════════════════════════════════════════════ const fractionToPercent = { '1/4': 25, '1/2': 50, '1/3': 33.33, '2/3': 66.67, '3/4': 75, '1/5': 20, '2/5': 40, '3/5': 60, '4/5': 80, '1/10': 10, '1/8': 12.5, '1/6': 16.67, '3/10': 30, '7/10': 70 }; // ═══════════════════════════════════════════════════════════════ // ★★★ 单位标准化映射表 ★★★ // ═══════════════════════════════════════════════════════════════ const unitMappings = { // 温度 '°C': ['°c', 'celsius', '摄氏', '摄氏度'], '°F': ['°f', 'fahrenheit', '华氏', '华氏度'], // 长度 'km': ['kilometer', 'kilometers', 'kilometre', 'kilometres', '公里', '千米'], 'm': ['meter', 'meters', 'metre', 'metres', '米'], 'cm': ['centimeter', 'centimeters', 'centimetre', 'centimetres', '厘米'], 'mm': ['millimeter', 'millimeters', 'millimetre', 'millimetres', '毫米'], 'mi': ['mile', 'miles', '英里'], 'ft': ['foot', 'feet', '英尺'], 'in': ['inch', 'inches', '英寸'], // 重量 'kg': ['kilogram', 'kilograms', '公斤', '千克'], 'g': ['gram', 'grams', '克'], 'mg': ['milligram', 'milligrams', '毫克'], 'lb': ['pound', 'pounds', '磅'], 'oz': ['ounce', 'ounces', '盎司'], 't': ['ton', 'tons', 'tonne', 'tonnes', '吨'], // 体积/容量 'L': ['liter', 'liters', 'litre', 'litres', '升'], 'mL': ['milliliter', 'milliliters', 'millilitre', 'millilitres', '毫升'], 'gal': ['gallon', 'gallons', '加仑'], // 面积 'km²': ['square kilometer', 'square kilometers', 'sq km', '平方公里'], 'm²': ['square meter', 'square meters', 'sq m', '平方米'], // 速度 'km/h': ['kilometers per hour', 'kph', '公里/小时', '千米每小时'], 'mph': ['miles per hour', '英里/小时'], 'm/s': ['meters per second', '米/秒'], // 时间 'h': ['hour', 'hours', '小时', '时'], 'min': ['minute', 'minutes', '分钟', '分'], 's': ['second', 'seconds', '秒'], 'yr': ['year', 'years', '年'], 'mo': ['month', 'months', '月'], 'wk': ['week', 'weeks', '周'], 'd': ['day', 'days', '天', '日'], // 人口/数量 'people': ['人', '人口', 'persons'], 'billion': ['十亿', 'bn', 'b'], 'million': ['百万', 'm', 'mn'], 'thousand': ['千', 'k'], }; // 提取数值+单位的函数 const extractValueWithUnit = (text) => { const results = []; // 匹配数值+单位的模式 const patterns = [ /(\d+\.?\d*)\s*°([CF])/gi, // 温度 /(\d+\.?\d*)\s*%/g, // 百分比 /(\d+\.?\d*)\s*(km²|m²|km\/h|mph|m\/s)/gi, // 复合单位 /(\d+\.?\d*)\s*(km|cm|mm|mi|ft|in|kg|mg|lb|oz|mL|gal|yr|mo|wk)\b/gi, // 常用单位 /(\d+\.?\d*)\s*(meters?|miles?|pounds?|gallons?|liters?|years?|months?|weeks?|days?|hours?|minutes?|seconds?)\b/gi, /(\d+\.?\d*)\s*(billion|million|thousand)\b/gi, // 数量级 /(\d+\.?\d*)\s*(人|公里|千米|米|公斤|升|年|月|周|天|小时)\b/g, // 中文单位 ]; for (const pattern of patterns) { let match; const p = new RegExp(pattern.source, pattern.flags); while ((match = p.exec(text)) !== null) { results.push({ full: match[0], value: match[1], unit: match[2] || '' }); } } return results; }; // 标准化单位 const normalizeUnit = (unit) => { const lowerUnit = unit.toLowerCase(); for (const [standard, variants] of Object.entries(unitMappings)) { if (lowerUnit === standard.toLowerCase()) return standard; for (const variant of variants) { if (lowerUnit === variant.toLowerCase() || lowerUnit.includes(variant.toLowerCase())) { return standard; } } } return unit; }; // 提取解释文本中的数值+单位 const expValueUnits = extractValueWithUnit(normalizedExplanation); console.log('[WeLearn-Go] fillEtChoice: 解释文本中的数值+单位:', expValueUnits.map(v => v.full)); // ═══════════════════════════════════════════════════════════════ // ★★★ 选项匹配评分系统 ★★★ // ═══════════════════════════════════════════════════════════════ let bestMatch = null; let bestScore = 0; options.forEach((opt, i) => { const optRaw = opt.textContent?.trim() || ''; const optText = normalizeText(optRaw); let score = 0; let matchDetails = []; // 提取选项中的数值+单位 const optValueUnits = extractValueWithUnit(optText); // ───────────────────────────────────────────────────────────── // 评分规则0: 数值+单位精确匹配 (最高优先级) // ───────────────────────────────────────────────────────────── for (const optVU of optValueUnits) { const optUnit = normalizeUnit(optVU.unit); const optValue = optVU.value; for (const expVU of expValueUnits) { const expUnit = normalizeUnit(expVU.unit); const expValue = expVU.value; // 数值相同 if (optValue === expValue) { // 单位也相同 -> 完全匹配 if (optUnit === expUnit) { score += 20; matchDetails.push(`完全匹配: ${optValue}${optUnit}`); } // 数值相同但单位不同 -> 可能是错误选项,减分 else if (optUnit && expUnit && optUnit !== expUnit) { score -= 10; matchDetails.push(`单位不匹配: ${optValue}${optUnit} vs ${expValue}${expUnit}`); } } } } // ───────────────────────────────────────────────────────────── // 评分规则1: 温度精确匹配 (数值+单位完全一致) // ───────────────────────────────────────────────────────────── const tempMatch = optText.match(/([\d.]+)°([CF])/i); if (tempMatch) { const optNum = tempMatch[1]; const optUnit = tempMatch[2].toUpperCase(); const expTempPattern = new RegExp(optNum.replace('.', '\\.') + '°' + optUnit, 'i'); if (expTempPattern.test(normalizedExplanation)) { score += 15; matchDetails.push(`温度完全匹配: ${optNum}°${optUnit}`); } else { // 检查是否数值匹配但单位错误 const wrongUnitPattern = new RegExp(optNum.replace('.', '\\.') + '°[CF]', 'i'); if (wrongUnitPattern.test(normalizedExplanation) && !expTempPattern.test(normalizedExplanation)) { score -= 15; // 数值对但单位错,强烈惩罚 matchDetails.push(`温度单位错误: 期望°${optUnit === 'C' ? 'F' : 'C'}`); } } } // ───────────────────────────────────────────────────────────── // 评分规则2: 百分比精确匹配 // ───────────────────────────────────────────────────────────── const percentMatch = optText.match(/([\d.]+)\s*%/); if (percentMatch) { const optPercent = percentMatch[1]; if (normalizedExplanation.includes(optPercent + '%')) { score += 15; matchDetails.push(`百分比完全匹配: ${optPercent}%`); } else if (normalizedExplanation.includes(optPercent)) { // 数值存在但不是百分比形式 score += 3; matchDetails.push(`百分比数值存在: ${optPercent}`); } } // ───────────────────────────────────────────────────────────── // 评分规则3: 分数与百分比等价匹配 // ───────────────────────────────────────────────────────────── for (const [fraction, percent] of Object.entries(fractionToPercent)) { if (optText.includes(fraction)) { const percentStr = String(percent); if (normalizedExplanation.includes(percentStr + '%') || normalizedExplanation.includes(percentStr)) { score += 12; matchDetails.push(`分数等价: ${fraction} = ${percent}%`); } } const percentStr = String(percent); if (optText.includes(percentStr + '%')) { if (normalizedExplanation.includes(fraction)) { score += 12; matchDetails.push(`百分比等价: ${percent}% = ${fraction}`); } } } // ───────────────────────────────────────────────────────────── // 评分规则4: 比较词匹配 (>, <, ≈ 等) // ───────────────────────────────────────────────────────────── const comparators = ['>', '<', '≈', '≥', '≤']; for (const comp of comparators) { if (optText.includes(comp) && normalizedExplanation.includes(comp)) { // 检查比较符后的数值是否匹配 const optCompMatch = optText.match(new RegExp(comp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*(\\d+\\.?\\d*)')); const expCompMatch = normalizedExplanation.match(new RegExp(comp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*(\\d+\\.?\\d*)')); if (optCompMatch && expCompMatch && optCompMatch[1] === expCompMatch[1]) { score += 10; matchDetails.push(`比较词匹配: ${comp}${optCompMatch[1]}`); } } } // ───────────────────────────────────────────────────────────── // 评分规则5: 货币精确匹配 // ───────────────────────────────────────────────────────────── const currencyMatch = optText.match(/([$€£¥])\s*([\d.]+)/); if (currencyMatch) { const symbol = currencyMatch[1]; const amount = currencyMatch[2]; if (normalizedExplanation.includes(symbol + amount) || normalizedExplanation.includes(symbol + ' ' + amount)) { score += 12; matchDetails.push(`货币匹配: ${symbol}${amount}`); } } // ───────────────────────────────────────────────────────────── // 评分规则6: 普通数值匹配 (需要更严格的上下文) // ───────────────────────────────────────────────────────────── const optNumbers = optText.match(/\b(\d+\.?\d*)\b/g) || []; for (const optNum of optNumbers) { // 检查是否是选项中的主要数值(排除序号等) if (optNum.length >= 2 || parseFloat(optNum) >= 10) { // 精确匹配:数值两边是边界或非数字 const numPattern = new RegExp(`(^|[^\\d.])${optNum.replace('.', '\\.')}([^\\d.]|$)`); if (numPattern.test(normalizedExplanation)) { score += 8; matchDetails.push(`数值匹配: ${optNum}`); } } } // ───────────────────────────────────────────────────────────── // 评分规则7: 时间/年龄匹配 // ───────────────────────────────────────────────────────────── const timeMatch = optText.match(/(\d+)\s*(世纪|年|月|周|天|小时|岁)/); if (timeMatch) { const timeNum = timeMatch[1]; const timeUnit = timeMatch[2]; if (normalizedExplanation.includes(timeNum + timeUnit) || normalizedExplanation.includes(timeNum + ' ' + timeUnit)) { score += 10; matchDetails.push(`时间匹配: ${timeNum}${timeUnit}`); } } console.log('[WeLearn-Go] fillEtChoice: 选项', i, '得分:', score, matchDetails.length > 0 ? matchDetails.join('; ') : '无匹配', '| 原文:', optRaw.substring(0, 30)); if (score > bestScore) { bestScore = score; bestMatch = opt; targetIdx = i; } }); if (bestMatch && bestScore >= 5) { targetOption = bestMatch; console.log('[WeLearn-Go] fillEtChoice: 通过数值匹配找到答案,索引:', targetIdx, '得分:', bestScore); answerSource = '数值匹配'; isReliable = false; // 推断,可能有误 } } } // ★★★ 方法6c: 关键词语义匹配(中英文通用)★★★ // 用于非数值型选项,匹配解释文本中的关键词 if (!targetOption) { console.log('[WeLearn-Go] fillEtChoice: 尝试关键词语义匹配'); const expText = explanationText.toLowerCase(); let bestMatch = null; let bestScore = 0; // 英文停用词 const enStopWords = new Set(['it', 'is', 'a', 'an', 'the', 'to', 'by', 'in', 'of', 'for', 'has', 'been', 'was', 'will', 'be', 'about', 'that', 'this', 'with', 'are', 'have', 'do', 'does', 'and', 'or', 'but']); // 中文停用词 const cnStopWords = new Set(['的', '了', '是', '在', '和', '与', '或', '及', '也', '都', '而', '但', '这', '那', '个', '些', '所', '以', '为', '于', '从', '到', '等', '被', '把', '让', '使']); options.forEach((opt, i) => { const optText = (opt.textContent || '').trim(); const optLower = optText.toLowerCase(); let score = 0; let matchedWords = []; // ───────────────────────────────────────────────────────────── // 英文关键词提取和匹配 // ───────────────────────────────────────────────────────────── const enWords = optLower.match(/[a-z]+/g) || []; for (const word of enWords) { if (word.length <= 2 || enStopWords.has(word)) continue; // 精确匹配 if (expText.includes(word)) { score += 3; matchedWords.push(word); } // 词根匹配 const stem = word.replace(/(ing|ed|s|ly|er|est|tion|ment|ness|able|ible)$/, ''); if (stem.length > 3 && stem !== word && expText.includes(stem)) { score += 2; matchedWords.push(stem + '*'); } } // ───────────────────────────────────────────────────────────── // 中文关键词提取和匹配 // ───────────────────────────────────────────────────────────── const cnChars = optText.match(/[\u4e00-\u9fa5]+/g) || []; for (const phrase of cnChars) { // 跳过单字停用词 if (phrase.length === 1 && cnStopWords.has(phrase)) continue; // 完整词组匹配 if (expText.includes(phrase)) { score += phrase.length * 2; // 中文匹配按字数加分 matchedWords.push(phrase); } // 拆分成2字词组匹配 if (phrase.length >= 2) { for (let j = 0; j < phrase.length - 1; j++) { const biGram = phrase.substring(j, j + 2); if (!cnStopWords.has(biGram[0]) && !cnStopWords.has(biGram[1])) { if (expText.includes(biGram)) { score += 1; } } } } } // ───────────────────────────────────────────────────────────── // 特殊语义匹配规则 // ───────────────────────────────────────────────────────────── // 年份/世纪匹配 const yearMatch = expText.match(/\b(17|18|19|20)\d{2}\b/); if (yearMatch && (optLower.includes('century') || optLower.includes('year') || optText.includes('世纪') || optText.includes('年'))) { const year = parseInt(yearMatch[0]); const age = new Date().getFullYear() - year; if ((optLower.includes('three') && optLower.includes('century')) || optText.includes('三') && optText.includes('世纪')) { if (age >= 250 && age <= 350) score += 8; } } // 增长/下降相关词 const growthWords = ['increase', 'grow', 'rise', 'boom', 'surge', 'expand', '增长', '上升', '增加', '扩大']; const declineWords = ['decrease', 'fall', 'drop', 'decline', 'reduce', 'shrink', '下降', '减少', '降低', '缩小']; if (growthWords.some(w => optLower.includes(w) || optText.includes(w))) { if (growthWords.some(w => expText.includes(w))) score += 4; if (declineWords.some(w => expText.includes(w))) score -= 3; // 相反语义减分 } if (declineWords.some(w => optLower.includes(w) || optText.includes(w))) { if (declineWords.some(w => expText.includes(w))) score += 4; if (growthWords.some(w => expText.includes(w))) score -= 3; } // 最大/最小相关词 const superlativeWords = ['biggest', 'largest', 'most', 'highest', 'best', 'greatest', '最大', '最多', '最高', '最好']; if (superlativeWords.some(w => optLower.includes(w) || optText.includes(w))) { if (superlativeWords.some(w => expText.includes(w))) score += 5; } console.log('[WeLearn-Go] fillEtChoice: 选项', i, '关键词得分:', score, '匹配词:', matchedWords.slice(0, 5).join(','), '| 选项:', optText.substring(0, 40)); if (score > bestScore) { bestScore = score; bestMatch = opt; targetIdx = i; } }); // 只有当得分足够高时才选择 if (bestMatch && bestScore >= 5) { targetOption = bestMatch; console.log('[WeLearn-Go] fillEtChoice: 通过关键词语义匹配找到答案,索引:', targetIdx, '得分:', bestScore); answerSource = '关键词语义匹配'; isReliable = false; // 推断,可能有误 } } } } // 如果没有找到答案,尝试最后的方法 if (!targetOption) { // 方法7: 深度搜索 AngularJS scope 中的答案 try { const scopeDoc = container.ownerDocument?.defaultView || window; const angular = scopeDoc.angular; if (angular) { const wrapper = container.querySelector('.wrapper'); const scopeEl = wrapper || container; const scope = angular.element(scopeEl)?.scope(); if (scope) { // 打印 scope 中所有包含 key/answer/correct 的属性 const findAnswer = (obj, path = '', depth = 0) => { if (depth > 3 || !obj || typeof obj !== 'object') return null; for (const key of Object.keys(obj)) { if (key.startsWith('$') || key.startsWith('_')) continue; const val = obj[key]; const fullPath = path ? `${path}.${key}` : key; // 直接检查 key/answer 属性 if ((key === 'key' || key === 'answer' || key === 'correctIndex' || key === 'std_answer') && (typeof val === 'number' || typeof val === 'string')) { console.log(`[WeLearn-Go] fillEtChoice: 发现 ${fullPath} = ${val}`); let idx = -1; if (typeof val === 'number') { idx = val >= 1 && val <= options.length ? val - 1 : val; } else if (typeof val === 'string' && /^[A-Da-d]$/.test(val)) { idx = val.toUpperCase().charCodeAt(0) - 65; } else if (typeof val === 'string' && /^\d+$/.test(val)) { idx = parseInt(val, 10) - 1; } if (idx >= 0 && idx < options.length) { targetOption = options[idx]; targetIdx = idx; console.log('[WeLearn-Go] fillEtChoice: 通过深度搜索找到答案,索引:', idx); return true; } } // 递归搜索 if (typeof val === 'object' && val !== null && !Array.isArray(val)) { if (findAnswer(val, fullPath, depth + 1)) return true; } } return false; }; findAnswer(scope); } } } catch (e) { console.debug('[WeLearn-Go] fillEtChoice: 深度搜索失败', e); } } // 方法8: ★★★ 使用 findAnswerFromExplanation 进行模糊匹配 ★★★ if (!targetOption) { console.log('[WeLearn-Go] fillEtChoice: 尝试解释文本模糊匹配'); const fuzzyMatch = findAnswerFromExplanation(container, options); if (fuzzyMatch) { targetOption = fuzzyMatch; targetIdx = options.indexOf(fuzzyMatch); console.log('[WeLearn-Go] fillEtChoice: 通过模糊匹配找到答案,索引:', targetIdx); } } // 如果还是没有找到答案,打印详细调试信息 if (!targetOption) { console.warn('[WeLearn-Go] fillEtChoice: 无法确定正确答案,跳过填写'); // 打印调试信息 console.debug('[WeLearn-Go] fillEtChoice: container attrs:', Array.from(container.attributes).map(a => `${a.name}="${a.value}"`).join(' ')); // ★★★ 详细调试:输出完整的 scope 内容供分析 ★★★ try { const scopeDoc = container.ownerDocument?.defaultView || window; const angular = scopeDoc.angular; if (angular) { const wrapper = container.querySelector('.wrapper'); const scope = angular.element(wrapper || container)?.scope(); if (scope?.choice) { console.log('[WeLearn-Go] fillEtChoice: ★★★ 请检查以下 choice 对象的内容 ★★★'); console.log('[WeLearn-Go] choice =', scope.choice); console.log('[WeLearn-Go] choice.data =', scope.choice?.data); // 尝试遍历 choice 的所有属性 const props = {}; for (const k in scope.choice) { if (!k.startsWith('$') && !k.startsWith('_') && typeof scope.choice[k] !== 'function') { props[k] = scope.choice[k]; } } console.log('[WeLearn-Go] choice 属性 (非函数):', props); // 打印 data 的详细内容 if (scope.choice.data) { const dataProps = {}; for (const k in scope.choice.data) { if (!k.startsWith('$') && typeof scope.choice.data[k] !== 'function') { dataProps[k] = scope.choice.data[k]; } } console.log('[WeLearn-Go] choice.data 属性:', dataProps); } } else { console.log('[WeLearn-Go] fillEtChoice: 未找到 scope.choice,scope 内容:', Object.keys(scope || {}).filter(k => !k.startsWith('$'))); } } } catch (e) { console.debug('[WeLearn-Go] fillEtChoice: 调试输出失败', e); } return false; } // ====== 3. 点击选项 ====== // 检查是否已选中 const isAlreadyChosen = targetOption.classList.contains('chosen') || targetOption.classList.contains('active') || targetOption.classList.contains('selected'); if (isAlreadyChosen) { console.log('[WeLearn-Go] fillEtChoice: 选项已被选中,跳过'); return false; } console.info('[WeLearn-Go] fillEtChoice: 点击选项', targetIdx, ':', targetOption.textContent?.trim()?.substring(0, 50)); // ★★★ 如果答案来自解析推断,在控制台和页面上提示用户 ★★★ if (!isReliable) { console.warn(`[WeLearn-Go] ⚠️ 答案来源: ${answerSource},存在一定错误率,请注意核对!`); // 在选项旁边添加警告标记 try { const warningSpan = document.createElement('span'); warningSpan.className = 'welearn-go-warning'; warningSpan.style.cssText = 'color: #e67e22; font-size: 12px; margin-left: 5px; font-weight: bold;'; warningSpan.textContent = '⚠️ 推断'; warningSpan.title = `答案来源: ${answerSource}\n该答案通过解析文本推断,可能存在错误,请核对!`; // 检查是否已添加过警告 if (!targetOption.querySelector('.welearn-go-warning')) { targetOption.appendChild(warningSpan); } } catch (e) { // 忽略添加标记失败 } } else { console.info(`[WeLearn-Go] ✓ 答案来源: ${answerSource},标准答案`); } targetOption.click(); // 触发 AngularJS 更新 try { const scopeDoc = container.ownerDocument?.defaultView || window; const scope = scopeDoc.angular?.element(targetOption)?.scope(); if (scope?.$apply) { scope.$apply(); } } catch (e) { /* 忽略 */ } return true; }; /** * 填充 et-tof 判断题(True/False 或自定义标签如 B/S) * 结构: * B - 第一个选项(true) * S - 第二个选项(false) * * * 答案来源(按优先级): * 1. 元素的 key 属性(WELearnHelper 方式)- "t" 或 "f" * 2. 已显示的 .key 类 * 3. AngularJS scope 的 isKey 方法 * * @param {Element} container - et-tof 容器元素 * @returns {boolean} 是否成功填充 */ const fillEtTof = (container) => { console.info('[WeLearn-Go] fillEtTof: 开始处理', container.id, container.outerHTML?.substring(0, 200)); // 获取正确的 window 对象(支持 iframe) const ownerWindow = container.ownerDocument?.defaultView || window; const angular = ownerWindow.angular; // 尝试多种方式查找选项容器 let wrapper = container.querySelector('.wrapper'); let controls = wrapper?.querySelector('.controls'); // 如果标准结构找不到,直接在 container 中查找 if (!controls) { controls = container.querySelector('.controls'); } if (!controls) { controls = container.querySelector('span.controls'); } console.info('[WeLearn-Go] fillEtTof: wrapper=', !!wrapper, 'controls=', !!controls); // 查找选项(多种选择器)- WELearnHelper 使用 'et-tof span.controls span' let options = []; if (controls) { options = Array.from(controls.querySelectorAll('span[ng-click*="chose"]')); // 备用:直接获取 controls 下的 span if (options.length < 2) { options = Array.from(controls.querySelectorAll('span')); } } // 备用:直接在 container 或 wrapper 中查找 if (options.length < 2) { const searchIn = wrapper || container; options = Array.from(searchIn.querySelectorAll('span[ng-click*="chose"]')); } // 再备用:查找任何带有 ng-click 包含 tof 的 span if (options.length < 2) { options = Array.from(container.querySelectorAll('span[ng-click*="tof"]')); } console.info('[WeLearn-Go] fillEtTof: 找到选项数量:', options.length, options.map(o => o.textContent?.trim())); if (options.length < 2) { console.warn('[WeLearn-Go] fillEtTof: 选项不足', container.id); return false; } let keyOption = null; // ★★★ 方法0: 从 key 属性获取答案(WELearnHelper 的核心方式)★★★ const keyAttr = container.getAttribute('key'); if (keyAttr) { const keyVal = keyAttr.trim().toLowerCase(); console.debug('[WeLearn-Go] fillEtTof: 发现 key 属性:', keyVal); // WELearnHelper 的逻辑:t/T = 第一个选项(索引0),f/F = 第二个选项(索引1) if (keyVal === 't') { keyOption = options[0]; console.info('[WeLearn-Go] fillEtTof: 通过 key="t" 选择第一个选项'); } else if (keyVal === 'f') { keyOption = options[1]; console.info('[WeLearn-Go] fillEtTof: 通过 key="f" 选择第二个选项'); } } // 方法1: 查找已有 .key 类的选项(答案已显示时) if (!keyOption) { keyOption = options.find(opt => opt.classList.contains('key')); if (keyOption) { console.info('[WeLearn-Go] fillEtTof: 通过 .key 类找到答案'); } } // 方法2: 通过 AngularJS scope 获取正确答案 if (!keyOption && angular) { try { const scope = angular.element(container)?.scope() || angular.element(wrapper || container)?.scope(); if (scope?.tof) { console.info('[WeLearn-Go] fillEtTof: 找到 tof scope', Object.keys(scope.tof)); // 尝试调用 isKey 方法 if (typeof scope.tof.isKey === 'function') { if (scope.tof.isKey('t')) { keyOption = options.find(opt => { const ngClick = opt.getAttribute('ng-click') || ''; return ngClick.includes("'t'") || ngClick.includes('"t"'); }); console.info('[WeLearn-Go] fillEtTof: isKey(t) = true'); } else if (scope.tof.isKey('f')) { keyOption = options.find(opt => { const ngClick = opt.getAttribute('ng-click') || ''; return ngClick.includes("'f'") || ngClick.includes('"f"'); }); console.info('[WeLearn-Go] fillEtTof: isKey(f) = true'); } } // 尝试读取 key 属性 if (!keyOption && scope.tof.key !== undefined) { const key = scope.tof.key; console.info('[WeLearn-Go] fillEtTof: tof.key =', key); keyOption = options.find(opt => { const ngClick = opt.getAttribute('ng-click') || ''; return ngClick.includes(`'${key}'`) || ngClick.includes(`"${key}"`); }); } // 尝试读取 data.key 属性 if (!keyOption && scope.tof.data?.key !== undefined) { const key = scope.tof.data.key; console.info('[WeLearn-Go] fillEtTof: tof.data.key =', key); keyOption = options.find(opt => { const ngClick = opt.getAttribute('ng-click') || ''; return ngClick.includes(`'${key}'`) || ngClick.includes(`"${key}"`); }); } } } catch (e) { console.warn('[WeLearn-Go] fillEtTof: AngularJS scope 访问失败', e); } } // 方法3: 从 ng-class 中解析 key 状态 if (!keyOption && angular) { for (const opt of options) { const ngClass = opt.getAttribute('ng-class') || ''; // 格式类似: {chosen:tof.value[0] === 't', key: tof.isKey('t')} const keyMatch = ngClass.match(/key:\s*tof\.isKey\(['"](t|f)['"]\)/); if (keyMatch) { const keyValue = keyMatch[1]; try { const scope = angular.element(opt)?.scope(); if (scope?.tof?.isKey && scope.tof.isKey(keyValue)) { keyOption = opt; console.info(`[WeLearn-Go] fillEtTof: 从 ng-class 确认 isKey('${keyValue}') = true`); break; } } catch (e) { /* 忽略 */ } } } } if (!keyOption) { console.warn('[WeLearn-Go] fillEtTof: 无法确定正确答案', container.id); return false; } // 检查是否已选中 if (keyOption.classList.contains('chosen')) { console.info('[WeLearn-Go] fillEtTof: 已经选中正确答案,跳过'); return false; // 已经选中正确答案 } // 点击正确选项 console.info('[WeLearn-Go] fillEtTof: 选择答案', keyOption.textContent?.trim()); keyOption.click(); // 尝试触发 AngularJS 更新 try { const scope = angular?.element(keyOption)?.scope(); if (scope && scope.$apply) { scope.$apply(); } } catch (e) { /* 忽略 */ } return true; }; /** * 填充通用输入元素(input 或 contenteditable) * 尝试从父元素或相邻元素中查找答案 * @param {Element} input - 输入元素 * @param {Function} mutateAnswer - 答案变异函数 * @returns {boolean} 是否成功填充 */ const fillGenericInput = (input, mutateAnswer) => { // 尝试查找答案:从父元素的 .key 或 data-solution 属性 let solution = ''; // 方法1: 查找同级或父级的 .key 元素 const parent = input.closest('et-blank, .blank, .filling, [data-controltype]'); if (parent) { const keyEl = parent.querySelector('.key, [data-itemtype="result"]'); if (keyEl) { solution = normalizeAnswer(keyEl.textContent); } } // 方法2: 从 input 的 data-solution 属性获取 if (!solution && input.dataset?.solution) { solution = normalizeAnswer(input.dataset.solution); } // 方法3: 查找 placeholder 中可能的提示 if (!solution && input.placeholder) { // 有些题目会在 placeholder 中给出答案格式提示 } if (!solution) return false; const finalValue = mutateAnswer(solution); // 判断是 contenteditable 还是 input if (input.hasAttribute('contenteditable')) { const currentValue = normalizeAnswer(input.textContent); if (currentValue === finalValue) return false; input.textContent = finalValue; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('blur', { bubbles: true })); } else { // input 元素 const currentValue = normalizeAnswer(input.value); if (currentValue === finalValue) return false; input.value = finalValue; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } return true; }; /** 检测是否为 Group Work 类型(有标准答案) */ const detectGroupWork = (contexts) => contexts.some((doc) => { const candidates = doc.querySelectorAll( '.subtitle2, .direction, .part_title, [data-controltype="group"], [data-controltype="page"], et-direction', ); return Array.from(candidates).some((node) => GROUP_WORK_PATTERN.test(node.textContent || '')); }); /** 检测是否为开放性题目(没有标准答案,如 "Answers may vary") */ const detectOpenEndedGroupWork = (contexts) => contexts.some((doc) => { // 检测 "Answers may vary" 开放性题目 - 需要更精确的匹配 const allText = doc.body?.textContent || ''; // 必须是完整的短语 "Answers may vary" 或 "Answer may vary" if (/\banswers?\s+(may|will|could|can)\s+vary\b/i.test(allText)) return true; // 检测带有 vary 类名的元素(用于标记开放性答案的特定类名) const varyElements = doc.querySelectorAll('.vary-answers, .answers-vary, [data-vary="true"]'); return varyElements.length > 0; }); /** 禁用自动提交功能 */ const disableAutoSubmit = () => { const submitToggle = document.querySelector('.welearn-submit-toggle'); if (submitToggle && submitToggle.checked) { submitToggle.checked = false; submitToggle.classList.remove('active'); } }; /** 处理有标准答案的 Group Work 模式(正常填充但禁用自动提交) */ const handleGroupWorkMode = () => { groupWorkDetected = true; disableAutoSubmit(); if (groupWorkNoticeShown) return; groupWorkNoticeShown = true; showToast('检测到 Group Work 讨论作业,已填充参考答案,请修改后再提交', { duration: 5000, }); }; /** 检查 fillinglong 是否有实质性答案(排除 "Answers may vary" 等占位文本) */ const hasSubstantiveAnswer = (container) => { // 获取答案文本 const resultEl = container.querySelector('[data-itemtype="result"]'); const solutionAttr = container.querySelector('[data-solution]')?.getAttribute('data-solution'); let answerText = resultEl?.textContent?.trim() || solutionAttr || ''; // 清理答案文本 answerText = cleanGroupWorkAnswer(answerText); // 如果清理后还有内容,则有实质性答案 return answerText.length > 0; }; /** 处理没有标准答案的开放性 Group Work(复制提示词到剪贴板) */ const handleOpenEndedGroupWork = (contexts) => { groupWorkDetected = true; disableAutoSubmit(); if (groupWorkNoticeShown) return; // 固定的提示词 let promptText = '请根据要求完成题目,使用英语回答\n\n'; // 记录是否有需要复制的主观题 let hasSubjectiveQuestions = false; // 获取原始题目内容 contexts.forEach((doc) => { // 优先获取 et-item 题目区域 const etItems = doc.querySelectorAll('et-item'); if (etItems.length > 0) { etItems.forEach((item) => { // 克隆节点以便移除不需要的元素 const clone = item.cloneNode(true); // 移除底部的提示和按钮区域,以及 style 标签 clone.querySelectorAll('style, script, .vary-answers, .key, .submit-btn, .btn, button, [class*="submit"], [class*="key"]').forEach(el => el.remove()); let text = clone.innerText?.trim(); if (text) { // 移除末尾的 "Answers may vary"、"Key"、提交时间、"Submit" 等 text = text.replace(/\n*Answers?\s*(may|will)?\s*vary\.?\s*$/i, ''); text = text.replace(/\n*Key\s*$/i, ''); text = text.replace(/\n*上次在.*提交\s*$/i, ''); text = text.replace(/\n*Submit\s*$/i, ''); text = text.trim(); if (text) { promptText += text + '\n\n'; hasSubjectiveQuestions = true; } } }); return; } // 其次尝试获取主观题(fillinglong)的题目区域,排除客观题(filling) const subjectiveAreas = doc.querySelectorAll('[data-controltype="fillinglong"]'); if (subjectiveAreas.length > 0) { subjectiveAreas.forEach((area) => { // 检查是否有实质性答案,如果有则跳过(会被自动填充) if (hasSubstantiveAnswer(area)) return; // 克隆并清理 const clone = area.cloneNode(true); clone.querySelectorAll('style, script, .key, [data-itemtype="result"], textarea').forEach(el => el.remove()); let text = clone.innerText?.trim(); if (text) { // 清理 "Answers may vary" 等文本 text = text.replace(/\(?Answers?\s*(may|will|could|can)?\s*vary\.?\)?/gi, ''); text = text.replace(/\n{3,}/g, '\n\n').trim(); if (text) { promptText += text + '\n\n'; hasSubjectiveQuestions = true; } } }); // 已处理 fillinglong,继续下一个 doc return; } // 如果没有找到特定题目区域,尝试获取通用内容(但排除 filling 类型) const contentAreas = doc.querySelectorAll('.question-content, .exercise-content'); if (contentAreas.length > 0) { contentAreas.forEach((area) => { const text = area.innerText?.trim(); if (text) { promptText += text + '\n\n'; hasSubjectiveQuestions = true; } }); return; } // 最后尝试获取 body 内容(排除脚本等)- 只在特殊情况下使用 // 对于混合题目页面,不使用此方法,避免复制客观题内容 }); // 只有在有需要 AI 生成的主观题时才复制到剪贴板 if (!hasSubjectiveQuestions) { console.info('[WeLearn-Go] 没有需要 AI 生成的主观题,跳过剪贴板复制'); return; } // 复制到剪贴板 navigator.clipboard.writeText(promptText.trim()).then(() => { showToast('检测到无标准答案的主观题,提示词已复制,请使用 AI 生成后填写', { duration: 0, }); }).catch((err) => { console.error('[WeLearn-Go] 复制到剪贴板失败:', err); showToast('检测到无标准答案的主观题,请手动复制题目使用 AI 生成', { duration: 0, }); }); }; /** 处理开放式练习(如口语大纲、录音等,复制题目到剪贴板) */ const handleOpenEndedExercise = (container) => { if (openEndedExerciseShown) return; openEndedExerciseShown = true; disableAutoSubmit(); // 构建提示词 let promptText = '请根据要求完成以下口语练习,使用英语回答\n\n'; // 获取题目内容 const clone = container.cloneNode(true); // 移除不需要的元素 clone.querySelectorAll('style, script, .key, button, et-recorder, textarea').forEach(el => el.remove()); let text = clone.innerText?.trim(); if (text) { // 清理多余的空白行 text = text.replace(/\n{3,}/g, '\n\n').trim(); promptText += text + '\n\n'; } // 添加提示 promptText += '---\n请为上述大纲的每个部分提供简短的要点内容。'; // 复制到剪贴板 navigator.clipboard.writeText(promptText.trim()).then(() => { showToast('该练习没有标准答案(口语/开放式),题目已复制,请使用 AI 生成后填写', { duration: 0, }); }).catch((err) => { console.error('[WeLearn-Go] 复制到剪贴板失败:', err); showToast('该练习没有标准答案,请手动复制题目使用 AI 生成', { duration: 0, }); }); }; /** * 尝试填充 Vue 组件管理的题目 * @param {Element} doc - 文档对象 * @param {Function} mutate - 答案变异函数 */ const fillVueItems = (doc, mutate) => { let filled = false; // 查找所有可能的输入元素 const inputs = Array.from(doc.querySelectorAll('input, textarea, .option, .choice, .item-option')); inputs.forEach(el => { // 尝试获取 Vue 实例 let vue = el.__vue__; if (!vue && el.parentElement) vue = el.parentElement.__vue__; if (!vue && el.parentElement?.parentElement) vue = el.parentElement.parentElement.__vue__; if (!vue) return; // 尝试从 Vue 数据中查找答案 const possibleKeys = ['answer', 'correctAnswer', 'solution', 'key', 'rightAnswer', 'correct']; let answer = null; for (const key of possibleKeys) { if (vue[key] !== undefined) answer = vue[key]; else if (vue.$data?.[key] !== undefined) answer = vue.$data[key]; else if (vue.props?.[key] !== undefined) answer = vue.props[key]; if (answer) break; } if (!answer) { // 尝试从全局上下文获取 answer = findAnswerFromGlobalContext(el); } if (!answer) return; // 规范化答案 if (Array.isArray(answer)) answer = answer.join(','); if (typeof answer !== 'string') answer = String(answer); answer = normalizeAnswer(answer); if (!answer) return; const finalValue = mutate(answer); // 填充逻辑 if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { if (el.value !== finalValue) { el.value = finalValue; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); filled = true; } } else { // 可能是选择题选项 const text = normalizeAnswer(el.textContent); // 检查是否匹配答案 (例如 "A" 匹配 "A. Option Text") if (text === finalValue || (finalValue.length === 1 && text.startsWith(finalValue))) { const isActive = el.classList.contains('active') || el.classList.contains('selected') || el.classList.contains('checked'); if (!isActive) { el.click(); filled = true; } } } }); return filled; }; /** * 填充所有题目(主入口函数) * @param {Object} options - 配置选项 * @param {boolean} options.enableSoftErrors - 是否启用小错误 * @returns {Object} 包含 filled 和 errors 的结果对象 */ const fillAll = ({ enableSoftErrors = false } = {}) => { if (!isWeLearnPage()) { console.debug('[WeLearn-Go] fillAll: 不是 WeLearn 页面'); return { filled: false, errors: [] }; } const mutator = createMistakeMutator(enableSoftErrors); const contexts = getAccessibleDocuments(); let filledAny = false; console.info('[WeLearn-Go] fillAll 开始执行,文档数量:', contexts.length); // 检测是否为 Group Work(有或没有标准答案) const isOpenEnded = detectOpenEndedGroupWork(contexts); groupWorkDetected = detectGroupWork(contexts) || isOpenEnded; if (groupWorkDetected) { // 禁用自动提交 disableAutoSubmit(); } contexts.forEach((doc) => { // 原有的填空题和选择题处理 const fillings = Array.from( doc.querySelectorAll('[data-controltype="filling"], [data-controltype="fillinglong"]'), ); const choices = Array.from( doc.querySelectorAll('[data-controltype="choice"], .checkbox_choice, .radio_choice, .normal_choice'), ); // AngularJS 组件 const etItems = Array.from(doc.querySelectorAll('et-item')); const standaloneToggles = Array.from(doc.querySelectorAll('et-toggle:not(et-item et-toggle)')); const standaloneBlanks = Array.from(doc.querySelectorAll('et-blank:not(et-item et-blank)')); const standaloneChoices = Array.from(doc.querySelectorAll('et-choice:not(et-item et-choice)')); const standaloneTofs = Array.from(doc.querySelectorAll('et-tof:not(et-item et-tof)')); console.info('[WeLearn-Go] 找到元素:', { fillings: fillings.length, choices: choices.length, etItems: etItems.length, standaloneToggles: standaloneToggles.length, standaloneBlanks: standaloneBlanks.length, etChoices: standaloneChoices.length, standaloneTofs: standaloneTofs.length, docLocation: doc === document ? 'main' : 'iframe' }); fillings.forEach((container, idx) => { console.debug('[WeLearn-Go] 处理 filling #' + idx, container.getAttribute('data-id'), container.getAttribute('data-controltype')); const changed = fillFillingItem(container, mutator.mutate); filledAny = filledAny || changed; }); choices.forEach((container) => { const changed = fillChoiceItem(container); filledAny = filledAny || changed; }); // AngularJS 组件适配(et-item 系列) etItems.forEach((etItem) => { console.info('[WeLearn-Go] 处理 et-item:', etItem.id, 'isNoInteraction:', isNoInteractionItem(etItem)); const changed = fillEtItem(etItem, mutator.mutate); console.info('[WeLearn-Go] fillEtItem 返回:', changed); filledAny = filledAny || changed; }); // 页面级别的 et-toggle 处理(不在 et-item 内的) standaloneToggles.forEach((toggle) => { const changed = fillEtToggle(toggle, mutator.mutate); filledAny = filledAny || changed; }); // 页面级别的 et-blank 处理(不在 et-item 内的) standaloneBlanks.forEach((blank) => { const changed = fillEtBlank(blank, mutator.mutate); filledAny = filledAny || changed; }); // 页面级别的 et-choice 二选一选择题处理 standaloneChoices.forEach((choice) => { const changed = fillEtChoice(choice); filledAny = filledAny || changed; }); // 页面级别的 et-tof 判断题处理(不在 et-item 内的) standaloneTofs.forEach((tof) => { const changed = fillEtTof(tof); filledAny = filledAny || changed; }); // Vue 组件处理 const vueChanged = fillVueItems(doc, mutator.mutate); filledAny = filledAny || vueChanged; }); // 如果检测到开放性题目(Answers may vary),复制没有标准答案的主观题到剪贴板 if (isOpenEnded) { handleOpenEndedGroupWork(contexts); } // 显示 Group Work 提示 if (groupWorkDetected && !groupWorkNoticeShown) { groupWorkNoticeShown = true; // 检查是否有 fillinglong(主观题)被填充 const hasFilledSubjective = contexts.some(doc => { const fillinglongs = doc.querySelectorAll('[data-controltype="fillinglong"]'); return Array.from(fillinglongs).some(el => { const textarea = el.querySelector('textarea'); return textarea && textarea.value.trim().length > 0; }); }); if (hasFilledSubjective) { // 有主观题被填充了参考答案 showToast('检测到 Group Work / Pair Work,已填充参考答案,建议修改或使用 AI 重写后再提交', { duration: 6000, }); } else if (filledAny) { // 只填充了客观题 showToast('检测到 Group Work / Pair Work,已填充客观题,请检查后提交', { duration: 5000, }); } } return { filled: filledAny, errors: mutator.getErrors(), targetCount: mutator.getTargetCount() }; }; /** 自动提交答案(如果启用且不是 Group Work) */ const submitIfNeeded = (shouldSubmit) => { if (!shouldSubmit || !isWeLearnPage() || groupWorkDetected) return; const contexts = getAccessibleDocuments(); // 查找并点击提交按钮 for (const doc of contexts) { // 方法1:原有的 data-controltype="submit" 选择器 let submitButton = doc.querySelector('[data-controltype="submit"]'); if (submitButton) { if (!submitButton.disabled && !submitButton.hasAttribute('disabled')) { submitButton.click(); console.log('[WeLearn] 已点击提交按钮 (data-controltype)'); return; } } // 方法2:查找 et-button[action*="submit"] 的 AngularJS 按钮 // 这种按钮结构是: `; overlay.querySelector('.welearn-modal-cancel')?.addEventListener('click', () => overlay.remove()); overlay.querySelector('.welearn-btn-refresh')?.addEventListener('click', () => { showLoading(); refreshDirectory(); }); return; } // 检查是否有之前保存的任务选择 const savedTaskIds = (tasksCache && tasksCache.courseName === currentCourseName) ? tasksCache.tasks.map(t => t.id) : []; const tasksHtml = generateTasksHtml(availableTasks, savedTaskIds); const cacheTime = isFromCache && cache?.timestamp ? new Date(cache.timestamp).toLocaleString('zh-CN') : ''; overlay.innerHTML = `

📖 课程目录 - ${currentCourseName}

${showMismatchWarning ? `

⚠️ 缓存的课程与当前课程不匹配,建议重新读取

` : ''}

勾选要执行的任务,然后点击「⚡ 批量执行」按钮开始。 ${isFromCache ? `(缓存于 ${cacheTime})` : ''}

${tasksHtml}
已选择: 0 个任务
`; bindTaskListEvents(overlay, currentCourseName, availableTasks); }; // 从页面刷新读取目录(以页面为准,清理错误的本地记录) const refreshDirectory = async () => { showLoading(); // 等待展开所有目录 await expandAllCategories(); // 使用页面真实状态,忽略本地记录 const tasks = getCourseTaskList({ ignoreLocalCompleted: true }); const availableTasks = tasks.filter(t => !t.isDisabled); // 清理本地记录中与页面状态不一致的任务 const completed = loadBatchCompleted(); const ourCompletedTasks = completed[currentCourseName] || []; if (ourCompletedTasks.length > 0) { const pageCompletedIds = tasks.filter(t => t.isCompleted).map(t => t.id); const tasksToRemove = ourCompletedTasks.filter(id => !pageCompletedIds.includes(id)); if (tasksToRemove.length > 0) { completed[currentCourseName] = ourCompletedTasks.filter(id => !tasksToRemove.includes(id)); if (completed[currentCourseName].length === 0) { delete completed[currentCourseName]; } saveBatchCompleted(completed); console.log('[WeLearn-Go] 清理了本地完成记录中的错误任务:', tasksToRemove); } } // 保存到缓存 saveCourseDirectoryCache(currentCourseId, currentCourseName, availableTasks); renderTaskList(availableTasks, false, false); showToast(`已读取 ${availableTasks.length} 个任务`, { duration: 2000 }); }; // 刷新完成状态(从页面重新扫描任务状态,以页面为准) const refreshCompletionStatus = async (cachedTasks) => { showLoading(); // 重新扫描页面获取最新任务状态(忽略本地记录,只看页面真实状态) const freshTasks = getCourseTaskList({ ignoreLocalCompleted: true }); const freshTaskMap = new Map(freshTasks.map(t => [t.id, t])); // 加载本地完成记录 const completed = loadBatchCompleted(); const ourCompletedTasks = completed[currentCourseName] || []; // 找出本地记录中标记完成但页面显示未完成的任务(需要清理) const tasksToRemove = []; // 更新任务的完成状态(只使用页面真实状态) const updatedTasks = cachedTasks.map(task => { const freshTask = freshTaskMap.get(task.id); const pageCompleted = freshTask?.isCompleted || false; // 如果本地记录说已完成,但页面显示未完成,需要清理 if (ourCompletedTasks.includes(task.id) && !pageCompleted) { tasksToRemove.push(task.id); } return { ...task, isCompleted: pageCompleted }; }); // 清理本地记录中错误标记的任务 if (tasksToRemove.length > 0 && completed[currentCourseName]) { completed[currentCourseName] = completed[currentCourseName].filter(id => !tasksToRemove.includes(id)); if (completed[currentCourseName].length === 0) { delete completed[currentCourseName]; } saveBatchCompleted(completed); console.log('[WeLearn-Go] 清理了本地完成记录中的错误任务:', tasksToRemove); } // 更新缓存 saveCourseDirectoryCache(currentCourseId, currentCourseName, updatedTasks); renderTaskList(updatedTasks, false, true); // 统计完成数量(只计算页面真实状态) const completedCount = updatedTasks.filter(t => t.isCompleted).length; const cleanedMsg = tasksToRemove.length > 0 ? `,已清理 ${tasksToRemove.length} 条错误记录` : ''; showToast(`已刷新完成状态 (${completedCount}/${updatedTasks.length} 已完成)${cleanedMsg}`, { duration: 3000 }); }; document.body.appendChild(overlay); // 点击遮罩关闭 overlay.addEventListener('click', (e) => { if (e.target === overlay) { overlay.remove(); } }); // 如果强制刷新或没有缓存,直接读取 if (forceRefresh || !hasCacheForCurrentCourse) { if (courseIdMismatch) { // 课程不匹配,显示警告并读取 showLoading(); await refreshDirectory(); } else { // 无缓存,直接读取 showLoading(); await refreshDirectory(); } } else { // 有缓存,先刷新完成状态 await refreshCompletionStatus(cache.tasks); } }; /** 绑定任务列表事件 */ const bindTaskListEvents = (overlay, courseName, availableTasks) => { const taskCheckboxes = overlay.querySelectorAll('.welearn-task-checkbox:not([disabled])'); const unitCheckboxes = overlay.querySelectorAll('.welearn-unit-checkbox'); const selectedCountEl = overlay.querySelector('.welearn-selected-count'); const confirmButton = overlay.querySelector('.welearn-modal-confirm'); const cancelButton = overlay.querySelector('.welearn-modal-cancel'); const selectAllBtn = overlay.querySelector('.welearn-btn-select-all'); const deselectAllBtn = overlay.querySelector('.welearn-btn-deselect-all'); const refreshBtn = overlay.querySelector('.welearn-btn-refresh'); const refreshStatusBtn = overlay.querySelector('.welearn-btn-refresh-status'); /** 更新选中数量和按钮状态 */ const updateSelectionState = () => { const checkedCount = overlay.querySelectorAll('.welearn-task-checkbox:checked').length; selectedCountEl.textContent = checkedCount; confirmButton.disabled = checkedCount === 0; // 更新单元复选框状态 unitCheckboxes.forEach(unitCb => { const unitContainer = unitCb.closest('.welearn-task-unit'); const unitTasks = unitContainer?.querySelectorAll('.welearn-task-checkbox:not([disabled])') || []; const checkedInUnit = unitContainer?.querySelectorAll('.welearn-task-checkbox:checked').length || 0; unitCb.checked = unitTasks.length > 0 && checkedInUnit === unitTasks.length; unitCb.indeterminate = checkedInUnit > 0 && checkedInUnit < unitTasks.length; }); }; // 初始化选中状态 updateSelectionState(); // 任务复选框事件 taskCheckboxes.forEach(cb => { cb.addEventListener('change', updateSelectionState); }); // 单元复选框事件 unitCheckboxes.forEach(unitCb => { unitCb.addEventListener('change', () => { const unitContainer = unitCb.closest('.welearn-task-unit'); const unitTasks = unitContainer?.querySelectorAll('.welearn-task-checkbox:not([disabled])') || []; unitTasks.forEach(cb => { cb.checked = unitCb.checked; }); updateSelectionState(); }); }); // 全选按钮 selectAllBtn?.addEventListener('click', () => { taskCheckboxes.forEach(cb => { cb.checked = true; }); updateSelectionState(); }); // 取消全选按钮 deselectAllBtn?.addEventListener('click', () => { taskCheckboxes.forEach(cb => { cb.checked = false; }); updateSelectionState(); }); // 重新读取按钮 refreshBtn?.addEventListener('click', () => { showTaskSelectorModal(true); overlay.remove(); }); // 刷新完成状态按钮 - 重新扫描页面获取最新完成状态(以页面为准) refreshStatusBtn?.addEventListener('click', async () => { refreshStatusBtn.disabled = true; refreshStatusBtn.textContent = '刷新中...'; // 保存当前选中的任务ID const checkedTaskIds = []; overlay.querySelectorAll('.welearn-task-checkbox:checked').forEach(cb => { checkedTaskIds.push(cb.dataset.taskId); }); // 重新扫描页面获取最新任务状态(忽略本地记录,只看页面真实状态) const freshTasks = getCourseTaskList({ ignoreLocalCompleted: true }); const freshTaskMap = new Map(freshTasks.map(t => [t.id, t])); // 加载本地完成记录 const completed = loadBatchCompleted(); const ourCompletedTasks = completed[courseName] || []; // 找出本地记录中标记完成但页面显示未完成的任务(需要清理) const tasksToRemove = []; // 更新任务列表中的完成状态 overlay.querySelectorAll('.welearn-task-item').forEach(item => { const checkbox = item.querySelector('.welearn-task-checkbox'); if (!checkbox) return; const taskId = checkbox.dataset.taskId; const freshTask = freshTaskMap.get(taskId); // 页面真实的完成状态(不考虑本地记录) const pageCompleted = freshTask?.isCompleted || false; const wasCompleted = item.classList.contains('completed'); const wasInLocalRecord = ourCompletedTasks.includes(taskId); // 如果本地记录说已完成,但页面显示未完成,需要清理本地记录 if (wasInLocalRecord && !pageCompleted) { tasksToRemove.push(taskId); } if (pageCompleted && !wasCompleted) { // 页面显示已完成 item.classList.add('completed'); checkbox.checked = false; checkbox.disabled = true; // 更新徽章为已完成(绿色) let badge = item.querySelector('.welearn-task-badge'); if (!badge) { badge = document.createElement('span'); item.appendChild(badge); } badge.className = 'welearn-task-badge'; badge.textContent = '✓ 已完成'; } else if (!pageCompleted && wasCompleted) { // 页面显示未完成(之前可能是本地记录标记的) item.classList.remove('completed'); checkbox.disabled = false; // 更新徽章为待完成(黄色) let badge = item.querySelector('.welearn-task-badge'); if (!badge) { badge = document.createElement('span'); item.appendChild(badge); } badge.className = 'welearn-task-badge pending'; badge.textContent = '○ 待完成'; // 恢复之前的选中状态 if (checkedTaskIds.includes(taskId)) { checkbox.checked = true; } } }); // 清理本地记录中错误标记的任务 if (tasksToRemove.length > 0 && completed[courseName]) { completed[courseName] = completed[courseName].filter(id => !tasksToRemove.includes(id)); if (completed[courseName].length === 0) { delete completed[courseName]; } saveBatchCompleted(completed); console.log('[WeLearn-Go] 清理了本地完成记录中的错误任务:', tasksToRemove); } // 更新缓存中的任务状态(使用页面真实状态) const currentCourseId = getCourseId(); const validTasks = freshTasks.filter(t => !t.isDisabled); saveCourseDirectoryCache(currentCourseId, courseName, validTasks); // 计算完成数量(只计算页面真实状态) const completedCount = validTasks.filter(t => t.isCompleted).length; updateSelectionState(); refreshStatusBtn.disabled = false; refreshStatusBtn.textContent = '🔃 刷新完成状态'; const cleanedMsg = tasksToRemove.length > 0 ? `,已清理 ${tasksToRemove.length} 条错误记录` : ''; showToast(`已刷新完成状态 (${completedCount}/${validTasks.length} 已完成)${cleanedMsg}`, { duration: 3000 }); }); // 取消按钮 cancelButton?.addEventListener('click', () => { overlay.remove(); }); // 确认选择按钮 confirmButton?.addEventListener('click', () => { const tasks = []; overlay.querySelectorAll('.welearn-task-checkbox:checked').forEach(cb => { tasks.push({ id: cb.dataset.taskId, title: cb.dataset.title }); }); if (tasks.length === 0) { showToast('请至少选择一个任务'); return; } // 保存选择的任务到全局变量和缓存 selectedBatchTasks = tasks; selectedCourseName = courseName; saveBatchTasksCache(courseName, tasks); overlay.remove(); showToast(`已选择 ${tasks.length} 个任务,点击「⚡ 批量执行」开始`, { duration: 3000 }); updateBatchButtonState(); }); }; /** 显示恢复批量任务提示 */ const showBatchTasksRecoveryPrompt = () => { const tasksCache = loadBatchTasksCache(); if (!tasksCache || !tasksCache.tasks || tasksCache.tasks.length === 0) return; const currentCourseName = getCourseName(); const isSameCourse = tasksCache.courseName === currentCourseName; const cacheTime = new Date(tasksCache.timestamp).toLocaleString('zh-CN'); const overlay = document.createElement('div'); overlay.className = 'welearn-modal-overlay welearn-recovery-prompt'; overlay.innerHTML = `

📋 发现未完成的批量任务

上次选择了 ${tasksCache.tasks.length} 个任务 ${!isSameCourse ? `
⚠️ 来自其他课程: ${tasksCache.courseName}` : ''}

保存于: ${cacheTime}

`; document.body.appendChild(overlay); overlay.querySelector('.welearn-modal-cancel')?.addEventListener('click', () => { clearBatchTasksCache(); overlay.remove(); showToast('已忽略,任务列表已清除'); }); overlay.querySelector('.welearn-modal-confirm')?.addEventListener('click', () => { // 恢复任务列表 selectedBatchTasks = tasksCache.tasks; selectedCourseName = tasksCache.courseName; updateBatchButtonState(); overlay.remove(); showToast(`已恢复 ${tasksCache.tasks.length} 个任务,点击「⚡ 批量执行」开始`, { duration: 3000 }); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { // 点击背景只关闭对话框,不清除缓存(下次还会提示) overlay.remove(); } }); }; /** 更新批量执行按钮状态 */ const updateBatchButtonState = () => { const batchBtn = document.querySelector('.welearn-batch-btn'); if (batchBtn) { if (selectedBatchTasks.length > 0) { batchBtn.textContent = `⚡ 执行 (${selectedBatchTasks.length})`; batchBtn.style.boxShadow = '0 0 0 2px rgba(56, 189, 248, 0.5), 0 6px 14px rgba(245, 158, 11, 0.3)'; } else { batchBtn.textContent = '⚡ 批量执行'; batchBtn.style.boxShadow = ''; } } }; /** 执行已选择的批量任务 */ const executeBatchTasks = () => { if (selectedBatchTasks.length === 0) { showToast('请先点击「📖 查看目录」选择要执行的任务', { duration: 3000 }); return; } const taskCount = selectedBatchTasks.length; const courseName = selectedCourseName; // 清空已选任务(防止重复执行) const tasksToExecute = [...selectedBatchTasks]; selectedBatchTasks = []; selectedCourseName = ''; updateBatchButtonState(); // 开始执行 startBatchExecution(tasksToExecute, courseName); }; /** 开始批量执行任务 */ const startBatchExecution = (tasks, courseName) => { if (tasks.length === 0) { showToast('所有任务已完成!'); clearBatchModeState(); return; } batchModeActive = true; batchTaskQueue = [...tasks]; // 清除任务选择缓存(任务已开始执行,不需要恢复提示了) clearBatchTasksCache(); // 保存状态到 localStorage(用于页面跳转后恢复) saveBatchModeState({ active: true, queue: batchTaskQueue, courseName: courseName, currentIndex: 0, totalTasks: tasks.length, phase: 'navigating' // 'navigating' | 'filling' | 'submitting' | 'waiting_next' }); showBatchProgressIndicator(tasks.length, 0); showToast(`开始执行 ${tasks.length} 个任务,请勿操作页面...`, { duration: 3000 }); // 执行第一个任务 setTimeout(() => { executeNextTask(); }, 1000); }; /** 显示批量进度指示器 */ const showBatchProgressIndicator = (total, current) => { // 移除已有的指示器 document.querySelector('.welearn-batch-progress')?.remove(); const indicator = document.createElement('div'); indicator.className = 'welearn-batch-progress'; indicator.innerHTML = ` 批量执行中: ${current + 1}/${total} `; indicator.querySelector('.welearn-batch-stop')?.addEventListener('click', () => { if (confirm('确定要停止批量执行吗?已完成的任务不会撤销。')) { stopBatchExecution(); } }); document.body.appendChild(indicator); }; /** 更新批量进度 */ const updateBatchProgress = () => { const state = loadBatchModeState(); if (!state) return; const indicator = document.querySelector('.welearn-batch-progress .progress-text'); if (indicator) { const completed = state.totalTasks - state.queue.length; indicator.textContent = `${completed + 1}/${state.totalTasks}`; } else { // 如果指示器不存在,重新创建 showBatchProgressIndicator(state.totalTasks, state.totalTasks - state.queue.length); } }; /** 停止批量执行 */ const stopBatchExecution = () => { batchModeActive = false; batchTaskQueue = []; currentBatchTask = null; clearBatchModeState(); document.querySelector('.welearn-batch-progress')?.remove(); showToast('批量执行已停止'); }; /** 执行下一个任务 */ const executeNextTask = () => { const state = loadBatchModeState(); if (!state || !state.active || state.queue.length === 0) { finishBatchExecution(); return; } const task = state.queue[0]; currentBatchTask = task; console.log('[WeLearn-Go] 批量执行: 开始任务', task.title); showToast(`正在执行: ${task.title}`, { duration: 2000 }); // 尝试多种方式启动任务 // 方式1: 直接调用 StartSCO 函数 (新版页面) if (typeof window.StartSCO === 'function') { console.log('[WeLearn-Go] 批量执行: 使用 StartSCO 启动', task.id); state.phase = 'navigating'; saveBatchModeState(state); window.StartSCO(task.id); return; } // 方式2: 通过点击元素 (旧版页面或备用方案) // 查找对应的任务项: li[onclick*="StartSCO('ITEM-xxx')"] let taskElement = document.querySelector(`li[onclick*="StartSCO('${task.id}')"]`); // 如果找不到,尝试其他选择器 if (!taskElement) { taskElement = document.querySelector(`li[id="${task.id}"], [data-sco="${task.id}"]`); } if (!taskElement) { console.warn('[WeLearn-Go] 批量执行: 未找到任务元素', task.id); // 跳过这个任务,继续下一个 skipCurrentTask('未找到任务元素'); return; } // 点击任务进入学习页面 // 优先使用 onclick 属性 const onclickAttr = taskElement.getAttribute('onclick'); if (onclickAttr && onclickAttr.includes('StartSCO')) { state.phase = 'navigating'; saveBatchModeState(state); // 直接执行 onclick taskElement.click(); // 页面会跳转,在新页面中通过 checkAndResumeBatchMode 继续执行 } else { // 尝试点击内部链接 const link = taskElement.querySelector('a'); if (link) { state.phase = 'navigating'; saveBatchModeState(state); link.click(); } else { skipCurrentTask('未找到任务链接'); } } }; /** 跳过当前任务 */ const skipCurrentTask = (reason) => { const state = loadBatchModeState(); if (!state) return; console.warn('[WeLearn-Go] 批量执行: 跳过任务', state.queue[0]?.title, reason); // 移除当前任务 state.queue.shift(); state.currentIndex++; state.phase = 'navigating'; saveBatchModeState(state); // 短暂延迟后执行下一个 setTimeout(() => { executeNextTask(); }, 1000); }; /** 完成当前任务 */ const completeCurrentTask = () => { const state = loadBatchModeState(); if (!state) return; const task = state.queue[0]; if (task) { markTaskCompleted(task.id, state.courseName); console.log('[WeLearn-Go] 批量执行: 完成任务', task.title); showToast(`✓ 已完成: ${task.title}`, { duration: 2000 }); } // 移除当前任务 state.queue.shift(); state.currentIndex++; saveBatchModeState(state); // 更新进度显示 updateBatchProgress(); // 检查是否还有更多任务 if (state.queue.length === 0) { setTimeout(() => { finishBatchExecution(); }, 1500); return; } // 任务间隔等待 30 秒 const TASK_INTERVAL = 30 * 1000; showCountdownToast('任务间隔等待中', TASK_INTERVAL, '即将执行下一个任务...'); setTimeout(() => { returnToCoursePage(); }, TASK_INTERVAL); }; /** 返回课程主页 */ const returnToCoursePage = () => { const state = loadBatchModeState(); if (state) { state.phase = 'returning'; saveBatchModeState(state); } console.log('[WeLearn-Go] 批量执行: 返回课程主页'); // 方法1:查找页面上的返回按钮 (.main-goback) const mainGoback = document.querySelector('.main-goback'); if (mainGoback && mainGoback.offsetParent !== null) { console.log('[WeLearn-Go] 批量执行: 点击 .main-goback 返回'); mainGoback.click(); return; } // 方法2:查找面包屑或返回链接 const backLinks = document.querySelectorAll( 'a[href*="StudyCourse"], a[href*="course_info"], a[href*="CourseIndex"], .breadcrumb a, .back-link, .back-btn, .goback' ); for (const link of backLinks) { if (link.offsetParent !== null) { // 可见 console.log('[WeLearn-Go] 批量执行: 点击返回链接'); link.click(); return; } } // 方法3:通过 body 的 data-classid 获取 classid const bodyClassid = document.body.getAttribute('data-classid'); const urlParams = new URLSearchParams(window.location.search); const cid = urlParams.get('cid'); const classid = urlParams.get('classid') || bodyClassid; if (cid && classid) { console.log('[WeLearn-Go] 批量执行: 通过 URL 返回课程页面'); window.location.href = `https://welearn.sflep.com/student/course_info.aspx?cid=${cid}&classid=${classid}`; return; } // 方法4:使用浏览器后退 console.log('[WeLearn-Go] 批量执行: 使用浏览器后退'); window.history.back(); }; /** 完成批量执行 */ const finishBatchExecution = () => { batchModeActive = false; batchTaskQueue = []; currentBatchTask = null; clearBatchModeState(); clearBatchTasksCache(); // 清除任务选择缓存 document.querySelector('.welearn-batch-progress')?.remove(); showToast('🎉 所有任务已完成!', { duration: 5000 }); }; /** 检查并恢复批量模式(页面加载时调用) */ const checkAndResumeBatchMode = () => { const state = loadBatchModeState(); if (!state || !state.active) { return false; } // 检查是否是真正的异常中断(超过3分钟没有更新) const ABNORMAL_TIMEOUT = 3 * 60 * 1000; // 3分钟 const timeSinceLastUpdate = Date.now() - (state.lastUpdate || 0); if (timeSinceLastUpdate < ABNORMAL_TIMEOUT) { // 批量任务仍在正常进行中,不处理 console.log('[WeLearn-Go] 批量模式: 任务仍在进行中', { timeSinceLastUpdate: Math.round(timeSinceLastUpdate / 1000) + '秒' }); return false; } // 超过3分钟没有更新,认为是异常中断 const remainingCount = state.queue?.length || 0; console.log('[WeLearn-Go] 批量模式: 检测到异常中断的批量执行', { remainingTasks: remainingCount, phase: state.phase, courseName: state.courseName, timeSinceLastUpdate: Math.round(timeSinceLastUpdate / 1000) + '秒' }); // 将剩余任务保存到任务选择缓存,方便用户手动恢复 if (remainingCount > 0 && state.queue && state.courseName) { saveBatchTasksCache(state.courseName, state.queue); } // 清除批量执行状态 clearBatchModeState(); // 不显示toast提示,让任务恢复对话框来处理 return false; }; /** 执行填写和提交 */ const executeFillAndSubmit = async () => { const state = loadBatchModeState(); if (!state || !state.active) return; try { console.log('[WeLearn-Go] 批量执行: 开始填写'); state.phase = 'filling'; saveBatchModeState(state); // 等待 iframe 加载(最多等待 10 秒) await waitForIframeReady(); // 等待练习内容加载完成(最多等待 15 秒) await waitForExerciseContent(); // 统计题目数量,用于计算等待时间 const questionCount = countQuestions(); console.log('[WeLearn-Go] 批量执行: 检测到题目数量:', questionCount); // 执行填写 const result = fillAll({ enableSoftErrors: false }); triggerIframeFill(false); // 等待填写完成(给异步操作足够时间) await new Promise(resolve => setTimeout(resolve, 2000)); // 二次填充:有些元素可能是异步加载的 const result2 = fillAll({ enableSoftErrors: false }); triggerIframeFill(false); // 再等待一下 await new Promise(resolve => setTimeout(resolve, 1000)); // 检查是否需要处理多页(Next 按钮)- 最多处理 20 页 await handleMultiplePages(); // 计算刷时长等待时间(根据当前模式配置) const durationMode = loadDurationMode(); const durationConfig = getDurationConfig(); const calculatedTime = calculateDurationTime(questionCount); // 只有非关闭模式才等待刷时长 if (durationMode !== 'off' && calculatedTime > 0) { console.log('[WeLearn-Go] 批量执行: 等待刷时长', { mode: durationConfig.name, questionCount, waitTime: Math.round(calculatedTime / 1000) + '秒' }); // 显示刷时长倒计时(包含模式信息) const modeIcon = durationMode === 'fast' ? '🚀' : '🐢'; showCountdownToast(`${modeIcon} 正在刷时长`, calculatedTime, `${durationConfig.name}模式 | ${questionCount} 道题目`); // 等待刷时长,使用配置的心跳间隔 await waitWithHeartbeat(calculatedTime); } else { console.log('[WeLearn-Go] 批量执行: 刷时长已关闭,直接提交'); showToast('⏭️ 刷时长已关闭,直接提交', { duration: 1500 }); await new Promise(resolve => setTimeout(resolve, 500)); } // 提交 const latestState = loadBatchModeState(); if (latestState) { latestState.phase = 'submitting'; saveBatchModeState(latestState); } await performSubmit(); } catch (error) { console.error('[WeLearn-Go] 批量执行: 填写过程出错', error); showToast('填写过程出错,跳过当前任务', { duration: 3000 }); // 出错时也要继续下一个任务,避免卡住 completeCurrentTask(); } }; /** 带心跳的等待(定期更新状态时间戳,防止被误判为异常中断) */ const waitWithHeartbeat = (totalMs) => { return new Promise((resolve) => { const durationConfig = getDurationConfig(); const heartbeatInterval = durationConfig.intervalTime; // 使用配置的心跳间隔 let elapsed = 0; const heartbeat = setInterval(() => { elapsed += heartbeatInterval; // 更新状态时间戳 const state = loadBatchModeState(); if (state) { saveBatchModeState(state); } if (elapsed >= totalMs) { clearInterval(heartbeat); resolve(); } }, heartbeatInterval); // 如果总时间小于心跳间隔,直接等待 if (totalMs <= heartbeatInterval) { clearInterval(heartbeat); setTimeout(resolve, totalMs); } else { // 等待剩余时间 setTimeout(() => { clearInterval(heartbeat); resolve(); }, totalMs); } }); }; /** 统计当前页面的题目数量 */ const countQuestions = () => { let count = 0; // 统计各种题型 const blanks = document.querySelectorAll('et-blank, .blank, input[type="text"], [contenteditable="true"]'); const toggles = document.querySelectorAll('et-toggle'); const choices = document.querySelectorAll('et-item, .choice-item, input[type="radio"], input[type="checkbox"]'); const textareas = document.querySelectorAll('textarea'); // 也检查 iframe 内的内容 const iframes = document.querySelectorAll('iframe'); iframes.forEach(iframe => { try { const doc = iframe.contentDocument; if (doc) { count += doc.querySelectorAll('et-blank, .blank, input[type="text"]').length; count += doc.querySelectorAll('et-toggle').length; count += doc.querySelectorAll('et-item, .choice-item').length; count += doc.querySelectorAll('textarea').length; } } catch (e) { /* 跨域忽略 */ } }); count += blanks.length + toggles.length + Math.ceil(choices.length / 4) + textareas.length; // 至少返回 10(保证有基础等待时间) return Math.max(count, 10); }; /** 显示倒计时 Toast */ const showCountdownToast = (title, totalMs, subtitle = '') => { // 移除已有的倒计时 toast document.querySelector('.welearn-countdown-toast')?.remove(); const toast = document.createElement('div'); toast.className = 'welearn-countdown-toast'; toast.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.85); color: white; padding: 24px 32px; border-radius: 12px; z-index: 100001; text-align: center; min-width: 200px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); `; const remainingSeconds = Math.ceil(totalMs / 1000); toast.innerHTML = `
⏱️ ${title}
${remainingSeconds}
${subtitle}
`; document.body.appendChild(toast); // 更新倒计时 let remaining = remainingSeconds; const interval = setInterval(() => { remaining--; const numberEl = toast.querySelector('.countdown-number'); if (numberEl) { numberEl.textContent = remaining; } if (remaining <= 0) { clearInterval(interval); toast.remove(); } }, 1000); // 确保在总时间后移除 setTimeout(() => { clearInterval(interval); toast.remove(); }, totalMs); }; /** 等待 iframe 准备就绪 */ const waitForIframeReady = () => { return new Promise((resolve) => { const maxWait = 10000; const startTime = Date.now(); const check = () => { const iframes = document.querySelectorAll('iframe'); let ready = iframes.length === 0; // 没有 iframe 则直接就绪 iframes.forEach(iframe => { try { if (iframe.contentDocument?.body) { ready = true; } } catch (e) { // 跨域 iframe,假设已就绪 ready = true; } }); if (ready || Date.now() - startTime > maxWait) { resolve(); } else { setTimeout(check, 500); } }; check(); }); }; /** 等待练习内容加载完成 */ const waitForExerciseContent = () => { return new Promise((resolve) => { const maxWait = 15000; // 最多等待 15 秒 const startTime = Date.now(); const check = () => { const contexts = getAccessibleDocuments(); let hasContent = false; let elementCount = 0; for (const doc of contexts) { // 检查各种练习元素 const fillings = doc.querySelectorAll('[data-controltype="filling"], [data-controltype="fillinglong"]'); const choices = doc.querySelectorAll('[data-controltype="choice"]'); const etItems = doc.querySelectorAll('et-item'); const etBlanks = doc.querySelectorAll('et-blank'); const etToggles = doc.querySelectorAll('et-toggle'); const etTofs = doc.querySelectorAll('et-tof'); const options = doc.querySelectorAll('ul[data-itemtype="options"]'); elementCount += fillings.length + choices.length + etItems.length + etBlanks.length + etToggles.length + etTofs.length + options.length; if (elementCount > 0) { hasContent = true; } } console.log('[WeLearn-Go] waitForExerciseContent: 检测到练习元素数量:', elementCount); // 如果找到练习内容,或者超时,则返回 if (hasContent || Date.now() - startTime > maxWait) { if (!hasContent) { console.warn('[WeLearn-Go] waitForExerciseContent: 等待超时,未检测到练习内容'); } resolve(); } else { setTimeout(check, 500); } }; // 首次延迟 1 秒再检查(给 AngularJS 等框架初始化时间) setTimeout(check, 1000); }); }; /** 处理多页情况 */ const handleMultiplePages = async () => { const maxPages = 20; // 最多处理 20 页 let pageCount = 0; while (pageCount < maxPages) { // 查找 Next 按钮 const nextButton = findNextButton(); if (!nextButton) { console.log('[WeLearn-Go] 批量执行: 没有找到 Next 按钮,当前是最后一页'); break; } console.log('[WeLearn-Go] 批量执行: 点击 Next 进入下一页'); nextButton.click(); pageCount++; // 等待页面切换 await new Promise(resolve => setTimeout(resolve, 2000)); // 不需要重新填写(按需求,第一页已填写所有问题) // 继续查找 Submit 或下一个 Next } }; /** 查找 Next 按钮 */ const findNextButton = () => { const contexts = getAccessibleDocuments(); for (const doc of contexts) { // 查找各种可能的 Next 按钮 const selectors = [ 'button:contains("Next")', 'a:contains("Next")', '.next-btn', '.btn-next', '[class*="next"]', 'button[ng-click*="next"]', 'et-button[action*="next"]' ]; // 直接文本匹配 const allButtons = doc.querySelectorAll('button, a.btn, et-button button, .controls button'); for (const btn of allButtons) { const text = btn.textContent?.trim().toLowerCase(); if (text === 'next' || text === '下一页' || text === '下一步') { // 检查是否可见和可点击 if (!btn.disabled && btn.offsetParent !== null) { return btn; } } } // AngularJS et-button const etButtons = doc.querySelectorAll('et-button'); for (const etBtn of etButtons) { const action = etBtn.getAttribute('action') || ''; if (action.includes('next') || action.includes('Next')) { const innerBtn = etBtn.querySelector('button'); if (innerBtn && !innerBtn.disabled && etBtn.offsetParent !== null) { return innerBtn; } } } } return null; }; /** 查找 Submit 按钮 */ const findSubmitButton = () => { const contexts = getAccessibleDocuments(); for (const doc of contexts) { // 直接文本匹配 const allButtons = doc.querySelectorAll('button, a.btn, et-button button, .controls button'); for (const btn of allButtons) { const text = btn.textContent?.trim().toLowerCase(); if (text === 'submit' || text === '提交' || text === '提交答案') { if (!btn.disabled && btn.offsetParent !== null) { return btn; } } } // data-controltype="submit" const submitByAttr = doc.querySelector('[data-controltype="submit"]:not([disabled])'); if (submitByAttr && submitByAttr.offsetParent !== null) { return submitByAttr; } // AngularJS et-button const etButtons = doc.querySelectorAll('et-button[action*="submit"]'); for (const etBtn of etButtons) { if (!etBtn.classList.contains('ng-hide') && etBtn.offsetParent !== null) { const innerBtn = etBtn.querySelector('button'); if (innerBtn && !innerBtn.disabled) { return innerBtn; } } } } return null; }; /** 执行提交 */ const performSubmit = async () => { const submitBtn = findSubmitButton(); if (submitBtn) { console.log('[WeLearn-Go] 批量执行: 点击 Submit 按钮'); submitBtn.click(); // 等待提交完成 await new Promise(resolve => setTimeout(resolve, 2000)); // 检查是否提交成功(查找成功提示或确认界面) const isSuccess = await checkSubmitSuccess(); if (isSuccess) { console.log('[WeLearn-Go] 批量执行: 提交成功'); completeCurrentTask(); } else { console.warn('[WeLearn-Go] 批量执行: 提交可能失败,继续下一个任务'); completeCurrentTask(); // 暂时还是标记完成,避免卡住 } } else { console.warn('[WeLearn-Go] 批量执行: 未找到 Submit 按钮'); // 可能是已经提交过了,或者不需要提交 completeCurrentTask(); } }; /** 检查提交是否成功 */ const checkSubmitSuccess = async () => { // 等待结果显示 await new Promise(resolve => setTimeout(resolve, 1000)); const contexts = getAccessibleDocuments(); for (const doc of contexts) { // 查找成功提示 const successIndicators = doc.querySelectorAll('.success, .submitted, .complete, [class*="success"], [class*="submitted"]'); if (successIndicators.length > 0) { return true; } // 查找错误提示 const errorIndicators = doc.querySelectorAll('.error, .failed, [class*="error"], [class*="fail"]'); if (errorIndicators.length > 0) { return false; } } // 默认认为成功 return true; }; // ==================== UI 组件 ==================== /** * 显示 Toast 提示 * @param {string} message - 提示消息(支持 HTML) * @param {Object} options - 配置选项 * @param {number} options.duration - 显示时长(毫秒),0 表示不自动关闭 * @param {boolean} options.html - 是否作为 HTML 渲染 */ const showToast = (message, { duration = 2500, html = false } = {}) => { const toast = document.createElement('div'); toast.className = 'welearn-toast'; if (html) { toast.innerHTML = message; } else { toast.textContent = message; } // 先添加到 DOM 以便计算高度 document.body.appendChild(toast); // 计算当前已有 Toast 的总高度,让新 Toast 堆叠在下面 // 包括所有 Toast(包括刚添加但还没有 visible 类的) const existingToasts = document.querySelectorAll('.welearn-toast'); let topOffset = 18; existingToasts.forEach((t) => { if (t !== toast) { topOffset += t.offsetHeight + 10; } }); toast.style.top = topOffset + 'px'; requestAnimationFrame(() => { toast.classList.add('visible'); }); // Toast 移除时重新计算其他 Toast 的位置 const removeToast = () => { toast.classList.remove('visible'); setTimeout(() => { toast.remove(); // 重新计算剩余 Toast 的位置 const remainingToasts = document.querySelectorAll('.welearn-toast.visible'); let newTop = 18; remainingToasts.forEach((t) => { t.style.top = newTop + 'px'; newTop += t.offsetHeight + 10; }); }, 300); }; if (duration > 0 && Number.isFinite(duration)) { setTimeout(removeToast, duration); } }; /** 清理页面上的所有 UI 元素 */ const cleanupPageArtifacts = () => { document.querySelector('.welearn-panel')?.remove(); document.querySelectorAll('.welearn-modal-overlay').forEach((node) => node.remove()); document.querySelectorAll('.welearn-toast').forEach((node) => node.remove()); }; // ==================== 状态持久化 ==================== /** 加载面板状态 */ const loadPanelState = () => { try { const raw = localStorage.getItem(PANEL_STATE_KEY); return raw ? JSON.parse(raw) : {}; } catch (error) { console.warn('WeLearn autofill: failed to load panel state', error); return {}; } }; /** 保存面板状态 */ const savePanelState = (state) => { try { localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state)); } catch (error) { console.warn('WeLearn autofill: failed to save panel state', error); } }; // ==================== 样式定义 ==================== /** 创建并注入样式 */ const createStyles = () => { const css = ` /* 主面板样式 */ .welearn-panel { position: fixed; top: 120px; left: 24px; width: 340px; min-width: 340px; max-width: 540px; padding: 12px; background: rgba(27, 38, 56, 0.95); color: #f8fafc; border-radius: 16px; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); z-index: 2147483647; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; backdrop-filter: blur(6px); transition: width 0.25s ease, height 0.25s ease, min-width 0.25s ease, max-width 0.25s ease, padding 0.25s ease; overflow: hidden; } .welearn-body { display: flex; flex-direction: column; gap: 8px; opacity: 1; transition: opacity 0.15s ease 0.1s; min-width: 316px; margin: 0; padding: 0; } .welearn-panel.minimized .welearn-body { opacity: 0; pointer-events: none; transition: opacity 0.1s ease; } body.welearn-dragging, body.welearn-dragging * { user-select: none !important; } .welearn-drag-zone { position: absolute; top: 0; left: 0; right: 44px; height: 44px; cursor: move; user-select: none; z-index: 5; } .welearn-panel h3 { margin: 0 0 8px; font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 6px; cursor: move; user-select: none; white-space: nowrap; position: relative; z-index: 6; pointer-events: none; } .welearn-panel h3 span { font-size: 13px; font-weight: 500; color: #cbd5e1; pointer-events: none; } .welearn-update-hint { font-size: 10px; font-weight: 600; color: #fbbf24; background: rgba(251, 191, 36, 0.15); padding: 2px 6px; border-radius: 8px; margin-left: 6px; text-decoration: none; pointer-events: auto; cursor: pointer; animation: welearn-pulse 2s ease-in-out infinite; transition: background 0.2s ease, transform 0.2s ease; } .welearn-update-hint:hover { background: rgba(251, 191, 36, 0.25); transform: scale(1.05); } @keyframes welearn-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } .welearn-actions { display: grid; grid-template-columns: 1fr 1fr; align-items: stretch; gap: 8px; margin: 8px 0 10px; min-width: 280px; } .welearn-actions .welearn-start { grid-column: 1 / -1; width: 100%; background: linear-gradient(135deg, #38bdf8, #6366f1); color: #0b1221; border: none; border-radius: 16px; padding: 10px 12px; font-weight: 700; font-size: 14px; cursor: pointer; box-shadow: 0 8px 18px rgba(99, 102, 241, 0.35); transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; } .welearn-actions .welearn-start:hover { transform: translateY(-1px); box-shadow: 0 10px 22px rgba(56, 189, 248, 0.32); filter: brightness(1.03); } .welearn-actions .welearn-start:disabled { cursor: not-allowed; opacity: 0.65; box-shadow: none; } .welearn-toggle-btn { background: rgba(148, 163, 184, 0.15); color: #94a3b8; border: 1px solid rgba(148, 163, 184, 0.25); border-radius: 16px; padding: 10px 12px; font-weight: 600; font-size: 13px; cursor: pointer; box-shadow: none; transition: transform 0.12s ease, background 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, filter 0.12s ease; white-space: nowrap; } .welearn-toggle-btn:hover { background: rgba(148, 163, 184, 0.25); transform: translateY(-1px); } .welearn-toggle-btn.active { background: linear-gradient(135deg, #38bdf8, #6366f1); background-origin: border-box; background-clip: padding-box; color: #0b1221; border-color: transparent; box-shadow: 0 6px 14px rgba(99, 102, 241, 0.3); } .welearn-toggle-btn.active:hover { transform: translateY(-1px); box-shadow: 0 8px 18px rgba(56, 189, 248, 0.32); filter: brightness(1.03); } .welearn-footer { font-size: 12px; color: #94a3b8; display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 8px; margin: 8px 0 0 0; padding: 0; } .welearn-footer > span { width: 100%; text-align: center; margin: 0; padding: 0; } .welearn-footer a { color: #38bdf8; text-decoration: none; white-space: nowrap; } .welearn-footer a:hover { opacity: 0.8; } .welearn-support { background: rgba(56, 189, 248, 0.14); color: #38bdf8; border: 1px solid rgba(56, 189, 248, 0.35); border-radius: 16px; padding: 8px 12px; margin: 0; cursor: pointer; font-weight: 700; font-size: 12px; transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; } .welearn-support:hover { background: rgba(56, 189, 248, 0.22); box-shadow: 0 6px 16px rgba(56, 189, 248, 0.28); transform: translateY(-1px); } .welearn-stats-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; padding: 6px 10px; background: rgba(148, 163, 184, 0.08); border-radius: 10px; font-size: 11px; color: #94a3b8; } .welearn-error-stats { flex: 1; line-height: 1.4; } .welearn-error-stats b { color: #38bdf8; margin: 0 2px; } .welearn-clear-stats { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 8px; padding: 4px 8px; font-size: 10px; font-weight: 600; cursor: pointer; transition: background 0.15s ease, transform 0.1s ease; white-space: nowrap; } .welearn-clear-stats:hover { background: rgba(239, 68, 68, 0.25); transform: translateY(-1px); } .welearn-weights-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; padding: 6px 10px; background: rgba(148, 163, 184, 0.08); border-radius: 10px; font-size: 11px; color: #94a3b8; } .welearn-weights-row label { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; margin: 0 !important; margin-bottom: 0 !important; font-weight: normal !important; max-width: none !important; } .welearn-weight-text { display: inline-flex; align-items: center; height: 22px; line-height: 1; margin: 0 !important; } .welearn-weights-row input { width: 32px; height: 22px; padding: 0 4px; margin: 0 !important; background: rgba(30, 41, 59, 0.8); border: 1px solid rgba(148, 163, 184, 0.3); border-radius: 4px; color: #e2e8f0; font-size: 11px; font-family: inherit; text-align: center; line-height: 1; box-sizing: border-box; vertical-align: middle; -moz-appearance: textfield; -webkit-appearance: none; appearance: none; } .welearn-weights-row input::-webkit-outer-spin-button, .welearn-weights-row input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .welearn-weights-row input:focus { outline: none; border-color: #38bdf8; } .welearn-weights-row input.error { border-color: #ef4444; } .welearn-weights-row span.welearn-weights-label { color: #cbd5e1; white-space: nowrap; } .welearn-weights-error { width: 100%; color: #f87171; font-size: 10px; margin-top: 2px; display: none; } .welearn-weights-error.visible { display: block; } .welearn-duration-row { display: flex; align-items: center; gap: 8px; padding: 6px 0 0 0; border-top: 1px solid rgba(148, 163, 184, 0.15); margin: 4px 0 0 0; } .welearn-duration-label { color: #cbd5e1; font-size: 12px; white-space: nowrap; flex-shrink: 0; margin: 0; padding: 0; } .welearn-duration-options { display: flex; gap: 6px; flex: 1; min-width: 0; margin: 0; padding: 0; } .welearn-duration-btn { flex: 1; background: rgba(148, 163, 184, 0.15); color: #94a3b8; border: 1px solid rgba(148, 163, 184, 0.25); border-radius: 12px; padding: 6px 8px; margin: 0; font-weight: 600; font-size: 11px; cursor: pointer; box-shadow: none; transition: transform 0.12s ease, background 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; white-space: nowrap; } .welearn-duration-btn:hover { background: rgba(148, 163, 184, 0.25); transform: translateY(-1px); } .welearn-duration-btn.active { background: linear-gradient(135deg, #38bdf8, #6366f1); background-origin: border-box; color: #0b1221; border: none; box-shadow: 0 4px 10px rgba(99, 102, 241, 0.3); } .welearn-duration-btn.active:hover { transform: translateY(-1px); box-shadow: 0 6px 14px rgba(56, 189, 248, 0.32); } .welearn-handle { position: absolute; display: none; } .welearn-minify { position: absolute; top: 8px; right: 10px; width: 26px; height: 26px; border: none; border-radius: 50%; cursor: pointer; background: rgba(56, 189, 248, 0.2); color: #38bdf8; display: grid; place-items: center; transition: background 0.15s ease; z-index: 10; } .welearn-minify:hover { background: rgba(56, 189, 248, 0.35); } .welearn-panel.minimized { width: ${MINIMIZED_PANEL_SIZE}px !important; height: ${MINIMIZED_PANEL_SIZE}px !important; min-width: ${MINIMIZED_PANEL_SIZE}px !important; max-width: ${MINIMIZED_PANEL_SIZE}px !important; padding: 0 !important; border-radius: 999px; } .welearn-panel.minimized h3, .welearn-panel.minimized .welearn-footer, .welearn-panel.minimized .welearn-handle { opacity: 0; pointer-events: none; } .welearn-panel.minimized .welearn-minify { top: 50%; left: 50%; right: auto; transform: translate(-50%, -50%); width: 26px; height: 26px; } .welearn-panel.minimized .welearn-minify:hover { background: rgba(56, 189, 248, 0.4); } .welearn-toast { position: fixed; left: 50%; transform: translateX(-50%) translateY(-20px); padding: 12px 18px; background: rgba(16, 185, 129, 0.95); color: #0b1221; border-radius: 16px; box-shadow: 0 12px 28px rgba(16, 185, 129, 0.35); font-weight: 600; opacity: 0; transition: opacity 0.15s ease-out, transform 0.15s ease-out, top 0.2s ease-out; z-index: 2147483647; font-size: 13px; line-height: 1.5; will-change: opacity, transform, top; } .welearn-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); } .welearn-toast .welearn-error-item { display: inline-block; margin-left: 8px; padding: 2px 8px; background: rgba(0, 0, 0, 0.15); border-radius: 6px; } .welearn-toast .welearn-error-item b { margin-right: 4px; font-weight: 600; } .welearn-toast .welearn-error-item em { color: #dc2626; font-style: normal; font-weight: 700; } .welearn-modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; z-index: 2147483647; backdrop-filter: blur(4px); } .welearn-modal { width: min(520px, 92vw); padding: 20px; background: #0f172a; color: #e2e8f0; border-radius: 20px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); border: 1px solid rgba(148, 163, 184, 0.2); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .welearn-modal h3 { margin: 0 0 10px; font-size: 18px; } .welearn-modal p { margin: 6px 0; line-height: 1.6; } .welearn-guide { margin: 10px 0 14px; padding: 10px 12px; background: rgba(59, 130, 246, 0.08); border-radius: 16px; border: 1px solid rgba(59, 130, 246, 0.15); } .welearn-guide ol { margin: 8px 0 0; padding-left: 18px; } .welearn-guide li + li { margin-top: 4px; } .welearn-donate-grid { margin: 12px 0 16px; display: flex; justify-content: center; } .welearn-donate-grid a { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px 20px; border-radius: 16px; background: rgba(148, 163, 184, 0.08); border: 1px solid rgba(148, 163, 184, 0.2); color: #e2e8f0; text-decoration: none; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease; } .welearn-donate-grid a:hover { transform: translateY(-1px); background: rgba(56, 189, 248, 0.08); border-color: rgba(56, 189, 248, 0.35); } .welearn-donate-grid img { width: 200px; max-width: 100%; border-radius: 12px; background: #0f172a; } .welearn-donate-grid span { font-weight: 700; color: #cbd5e1; } .welearn-modal-footer { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; } .welearn-modal-close { background: linear-gradient(135deg, #38bdf8, #22d3ee); color: #0b1221; border: none; border-radius: 16px; padding: 10px 16px; font-weight: 700; cursor: pointer; box-shadow: 0 10px 24px rgba(56, 189, 248, 0.35); transition: transform 0.12s ease, box-shadow 0.12s ease; } .welearn-modal-close:hover { transform: translateY(-1px); box-shadow: 0 12px 26px rgba(56, 189, 248, 0.4); } .welearn-badge { padding: 6px 10px; background: rgba(16, 185, 129, 0.16); color: #34d399; border-radius: 16px; border: 1px solid rgba(16, 185, 129, 0.4); font-weight: 600; } /* 批量任务选择器样式 */ .welearn-task-modal { width: min(680px, 92vw); max-height: 85vh; display: flex; flex-direction: column; } .welearn-task-desc { color: #94a3b8; margin-bottom: 12px; } .welearn-task-actions-top { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; } .welearn-task-actions-top button { background: rgba(148, 163, 184, 0.15); color: #94a3b8; border: 1px solid rgba(148, 163, 184, 0.25); border-radius: 8px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.15s ease; } .welearn-task-actions-top button:hover { background: rgba(148, 163, 184, 0.25); color: #e2e8f0; } .welearn-btn-refresh-status { background: rgba(16, 185, 129, 0.15) !important; color: #34d399 !important; border-color: rgba(16, 185, 129, 0.3) !important; } .welearn-btn-refresh-status:hover { background: rgba(16, 185, 129, 0.25) !important; } .welearn-btn-refresh-status:disabled { opacity: 0.6; cursor: not-allowed; } .welearn-task-container { flex: 1; overflow-y: auto; max-height: 50vh; margin-bottom: 12px; padding-right: 8px; } .welearn-task-container::-webkit-scrollbar { width: 6px; } .welearn-task-container::-webkit-scrollbar-track { background: rgba(148, 163, 184, 0.1); border-radius: 3px; } .welearn-task-container::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.3); border-radius: 3px; } .welearn-task-unit { margin-bottom: 16px; } .welearn-task-unit-header { background: rgba(56, 189, 248, 0.1); border: 1px solid rgba(56, 189, 248, 0.2); border-radius: 8px; padding: 8px 12px; margin-bottom: 8px; } .welearn-task-unit-header label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: #38bdf8; } .welearn-task-list { display: flex; flex-direction: column; gap: 4px; padding-left: 12px; } .welearn-task-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(148, 163, 184, 0.08); border-radius: 6px; cursor: pointer; transition: background 0.15s ease; } .welearn-task-item:hover { background: rgba(148, 163, 184, 0.15); } .welearn-task-item.completed { opacity: 0.6; background: rgba(16, 185, 129, 0.1); } .welearn-task-item.intro { opacity: 0.6; background: rgba(59, 130, 246, 0.1); } .welearn-task-item input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; } .welearn-task-title { flex: 1; font-size: 13px; color: #e2e8f0; } .welearn-task-badge { font-size: 11px; padding: 2px 8px; background: rgba(16, 185, 129, 0.2); color: #34d399; border-radius: 4px; font-weight: 600; } .welearn-task-badge.pending { background: rgba(234, 179, 8, 0.2); color: #fbbf24; } .welearn-task-badge.intro { background: rgba(59, 130, 246, 0.2); color: #60a5fa; } .welearn-task-summary { padding: 10px 12px; background: rgba(56, 189, 248, 0.1); border-radius: 8px; font-size: 13px; color: #38bdf8; margin-bottom: 12px; } .welearn-selected-count { font-weight: 700; font-size: 16px; } .welearn-checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; } /* 加载动画样式 */ .welearn-loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #94a3b8; } .welearn-loading-spinner { width: 40px; height: 40px; border: 3px solid rgba(56, 189, 248, 0.2); border-top-color: #38bdf8; border-radius: 50%; animation: welearn-spin 0.8s linear infinite; margin-bottom: 16px; } @keyframes welearn-spin { to { transform: rotate(360deg); } } .welearn-loading-container p { font-size: 14px; margin: 0; } /* 警告文本样式 */ .welearn-warning-text { color: #f87171 !important; font-size: 12px; } /* 缓存时间显示 */ .welearn-cache-time { color: #64748b; font-size: 11px; } /* 恢复任务提示模态框 */ .welearn-recovery-modal { width: min(400px, 90vw); text-align: center; } .welearn-recovery-modal p { margin: 12px 0; color: #cbd5e1; } .welearn-recovery-modal strong { color: #38bdf8; font-size: 18px; } /* 重新读取按钮特殊样式 */ .welearn-btn-refresh { background: rgba(56, 189, 248, 0.15) !important; color: #38bdf8 !important; border-color: rgba(56, 189, 248, 0.3) !important; } .welearn-btn-refresh:hover { background: rgba(56, 189, 248, 0.25) !important; } .welearn-modal-cancel { background: rgba(148, 163, 184, 0.15); color: #94a3b8; border: 1px solid rgba(148, 163, 184, 0.25); border-radius: 16px; padding: 10px 20px; font-weight: 600; cursor: pointer; transition: all 0.15s ease; } .welearn-modal-cancel:hover { background: rgba(148, 163, 184, 0.25); color: #e2e8f0; } .welearn-modal-confirm { background: linear-gradient(135deg, #38bdf8, #6366f1); color: #fff; border: none; border-radius: 16px; padding: 10px 24px; font-weight: 700; cursor: pointer; box-shadow: 0 8px 18px rgba(99, 102, 241, 0.35); transition: all 0.15s ease; } .welearn-modal-confirm:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 10px 22px rgba(56, 189, 248, 0.32); } .welearn-modal-confirm:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } .welearn-modal-start { background: linear-gradient(135deg, #38bdf8, #6366f1); color: #0b1221; border: none; border-radius: 16px; padding: 10px 24px; font-weight: 700; cursor: pointer; box-shadow: 0 8px 18px rgba(99, 102, 241, 0.35); transition: all 0.15s ease; } .welearn-modal-start:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 10px 22px rgba(56, 189, 248, 0.32); } .welearn-modal-start:disabled { opacity: 0.5; cursor: not-allowed; } /* 读取目录按钮样式 */ .welearn-scan-btn { background: linear-gradient(135deg, #10b981, #059669); color: #0b1221; border: none; border-radius: 16px; padding: 10px 12px; font-weight: 700; font-size: 13px; cursor: pointer; box-shadow: 0 6px 14px rgba(16, 185, 129, 0.3); transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; } .welearn-scan-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 18px rgba(16, 185, 129, 0.4); filter: brightness(1.03); } .welearn-scan-btn:disabled { opacity: 0.5; cursor: not-allowed; } /* 批量执行按钮样式 */ .welearn-batch-btn { background: linear-gradient(135deg, #f59e0b, #ef4444); color: #0b1221; border: none; border-radius: 16px; padding: 10px 12px; font-weight: 700; font-size: 13px; cursor: pointer; box-shadow: 0 6px 14px rgba(245, 158, 11, 0.3); transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; } .welearn-batch-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 18px rgba(245, 158, 11, 0.4); filter: brightness(1.03); } .welearn-batch-btn:disabled { opacity: 0.5; cursor: not-allowed; } /* 批量模式进度提示 */ .welearn-batch-progress { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); padding: 12px 24px; background: rgba(27, 38, 56, 0.95); color: #f8fafc; border-radius: 16px; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); z-index: 2147483647; font-size: 14px; font-weight: 600; backdrop-filter: blur(6px); border: 1px solid rgba(56, 189, 248, 0.3); } .welearn-batch-progress .progress-text { color: #38bdf8; } `; if (typeof GM_addStyle === 'function') { GM_addStyle(css); } else { const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } }; // ==================== 面板拖动与尺寸控制 ==================== /** 限制数值在指定范围内 */ const clampSize = (value, min, max) => Math.min(Math.max(value, min), max); /** 获取最大可用宽度 */ const getMaxWidth = () => Math.min(window.innerWidth - 24, PANEL_MAX_WIDTH); /** 获取最大可用高度 */ const getMaxHeight = () => Math.min(window.innerHeight - 24, PANEL_MAX_HEIGHT); /** 获取可见视口尺寸 */ const getVisibleViewport = () => { const vw = window.innerWidth || document.documentElement.clientWidth; const vh = window.innerHeight || document.documentElement.clientHeight; return { width: vw, height: vh }; }; /** 初始化面板拖动和尺寸调整功能 */ const initDragAndResize = (panel, header) => { let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; let isDragging = false; const beginInteraction = () => document.body.classList.add('welearn-dragging'); const endInteraction = () => document.body.classList.remove('welearn-dragging'); /** 自动调整面板尺寸 */ const applyAutoSize = () => { if (panel.classList.contains('minimized')) { panel.style.width = `${MINIMIZED_PANEL_SIZE}px`; panel.style.height = `${MINIMIZED_PANEL_SIZE}px`; return; } const { width: vw } = getVisibleViewport(); const maxW = Math.min(vw - 24, PANEL_MAX_WIDTH); const width = clampSize(PANEL_DEFAULT_WIDTH, PANEL_MIN_WIDTH, maxW); panel.style.width = `${width}px`; panel.style.height = 'auto'; // 高度自适应内容 }; /** 确保面板在视口范围内 */ const enforceBounds = () => { const rect = panel.getBoundingClientRect(); const { width: vw, height: vh } = getVisibleViewport(); const isMinimized = panel.classList.contains('minimized'); const maxW = Math.min(vw - 24, PANEL_MAX_WIDTH); const targetWidth = isMinimized ? MINIMIZED_PANEL_SIZE : clampSize(rect.width, PANEL_MIN_WIDTH, maxW); // 确保面板完全在视口内 const minLeft = 8; const minTop = 8; const maxLeft = Math.max(minLeft, vw - targetWidth - 8); const maxTop = Math.max(minTop, vh - rect.height - 8); panel.style.width = `${targetWidth}px`; if (isMinimized) { panel.style.height = `${MINIMIZED_PANEL_SIZE}px`; } panel.style.left = `${clampSize(rect.left, minLeft, maxLeft)}px`; panel.style.top = `${clampSize(rect.top, minTop, maxTop)}px`; }; const state = loadPanelState(); const { width: vw, height: vh } = getVisibleViewport(); // 加载保存的位置,但确保在可见范围内 if (state.left !== undefined) { const maxLeft = Math.max(8, vw - PANEL_DEFAULT_WIDTH - 8); panel.style.left = `${clampSize(state.left, 8, maxLeft)}px`; } if (state.top !== undefined) { const maxTop = Math.max(8, vh - PANEL_DEFAULT_HEIGHT - 8); panel.style.top = `${clampSize(state.top, 8, maxTop)}px`; } applyAutoSize(); if (state.minimized) { panel.classList.add('minimized'); applyAutoSize(); } enforceBounds(); /** 鼠标移动事件处理(拖动面板) */ const onMouseMove = (event) => { if (isDragging) { const { width: vw, height: vh } = getVisibleViewport(); const rect = panel.getBoundingClientRect(); const deltaX = event.clientX - startX; const deltaY = event.clientY - startY; const newLeft = startLeft + deltaX; const newTop = startTop + deltaY; // 限制在视口范围内 const maxLeft = vw - rect.width - 8; const maxTop = vh - rect.height - 8; panel.style.left = `${clampSize(newLeft, 8, maxLeft)}px`; panel.style.top = `${clampSize(newTop, 8, maxTop)}px`; } }; /** 鼠标释放事件处理(结束拖动并保存状态) */ const onMouseUp = () => { if (isDragging) { const rect = panel.getBoundingClientRect(); const limitedWidth = clampSize(rect.width, PANEL_MIN_WIDTH, getMaxWidth()); panel.style.width = `${limitedWidth}px`; savePanelState({ left: rect.left, top: rect.top, width: limitedWidth, minimized: panel.classList.contains('minimized'), }); } isDragging = false; endInteraction(); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; // 展开状态下:只允许通过标题栏拖动 header.addEventListener('mousedown', (event) => { if ((event.target instanceof HTMLElement && event.target.closest('button, input, label')) || panel.classList.contains('minimized')) return; event.preventDefault(); isDragging = true; startX = event.clientX; startY = event.clientY; const rect = panel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; beginInteraction(); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); // ==================== 最小化状态拖动处理 ==================== // 最小化状态使用独立的拖动逻辑,支持拖动移动位置和点击展开 const DRAG_THRESHOLD = 5; // 拖动阈值(像素),超过此距离才算拖动,否则视为点击 // 使用对象存储拖动状态,避免闭包问题 const minDragState = { active: false, // 是否正在拖动 moved: false, // 是否已超过阈值 startX: 0, // 鼠标起始 X startY: 0, // 鼠标起始 Y panelStartX: 0, // 面板起始 X panelStartY: 0, // 面板起始 Y pointerId: null, // 指针 ID,用于 pointer capture }; /** 结束最小化状态拖动 */ const endMinimizedDrag = (savePosition = true) => { // 释放指针捕获 if (minDragState.pointerId !== null) { try { panel.releasePointerCapture(minDragState.pointerId); } catch (e) { /* 忽略错误 */ } minDragState.pointerId = null; } minDragState.active = false; panel.style.cursor = ''; if (minDragState.moved && savePosition) { enforceBounds(); // 延迟重置 moved 状态,确保 click 事件能被正确拦截 setTimeout(() => { minDragState.moved = false; }, 50); } else { minDragState.moved = false; } }; /** 最小化状态指针移动处理 */ const handleMinimizedMove = (event) => { if (!minDragState.active) return; const dx = event.clientX - minDragState.startX; const dy = event.clientY - minDragState.startY; // 检查是否超过拖动阈值 if (!minDragState.moved && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) { minDragState.moved = true; panel.style.cursor = 'grabbing'; } if (minDragState.moved) { // 计算新位置并限制在视口范围内 const { width: vw, height: vh } = getVisibleViewport(); const newLeft = minDragState.panelStartX + dx; const newTop = minDragState.panelStartY + dy; const maxLeft = vw - MINIMIZED_PANEL_SIZE - 8; const maxTop = vh - MINIMIZED_PANEL_SIZE - 8; panel.style.left = clampSize(newLeft, 8, maxLeft) + 'px'; panel.style.top = clampSize(newTop, 8, maxTop) + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; } }; /** 指针释放处理 */ const handleMinimizedUp = (event) => { if (!minDragState.active) return; const wasMoved = minDragState.moved; endMinimizedDrag(); // 如果没有发生拖动,视为点击,触发展开 if (!wasMoved && panel.classList.contains('minimized')) { // 模拟点击 minify 按钮来展开 const minifyBtn = panel.querySelector('.welearn-minify'); if (minifyBtn) { minifyBtn.click(); } } }; /** 阻止拖动后的 click 事件 */ const blockMinimizedClick = (event) => { if (minDragState.moved) { event.stopPropagation(); event.preventDefault(); } }; // 使用 Pointer Events API,支持指针捕获 panel.addEventListener('pointerdown', (event) => { if (!panel.classList.contains('minimized')) return; if (event.button !== 0) return; // 只响应左键 const rect = panel.getBoundingClientRect(); minDragState.active = true; minDragState.moved = false; minDragState.startX = event.clientX; minDragState.startY = event.clientY; minDragState.panelStartX = rect.left; minDragState.panelStartY = rect.top; minDragState.pointerId = event.pointerId; // 捕获指针,确保即使鼠标快速移动离开元素,事件仍然发送到 panel panel.setPointerCapture(event.pointerId); }); panel.addEventListener('pointermove', handleMinimizedMove); panel.addEventListener('pointerup', handleMinimizedUp); panel.addEventListener('pointercancel', () => endMinimizedDrag(false)); // 当指针捕获丢失时结束拖动 panel.addEventListener('lostpointercapture', () => { if (minDragState.active) { endMinimizedDrag(); } }); // 捕获阶段拦截 click,如果发生了拖动则阻止 panel.addEventListener('click', blockMinimizedClick, true); window.addEventListener('resize', () => { applyAutoSize(); enforceBounds(); }); }; // ==================== 面板初始化 ==================== /** 初始化控制面板 */ const initPanel = () => { createStyles(); const panel = document.createElement('div'); panel.className = 'welearn-panel'; panel.innerHTML = `

WeLearn-Gov${VERSION}

错误统计:暂无数据
错误比例: 总和必须为 100%
刷时长:
`; document.body.appendChild(panel); // 获取 UI 元素引用 const header = panel.querySelector('.welearn-drag-zone'); const startButton = panel.querySelector('.welearn-start'); const submitToggle = panel.querySelector('.welearn-submit-toggle'); const mistakeToggle = panel.querySelector('.welearn-mistake-toggle'); const scanButton = panel.querySelector('.welearn-scan-btn'); const batchButton = panel.querySelector('.welearn-batch-btn'); const minifyButton = panel.querySelector('.welearn-minify'); const supportButton = panel.querySelector('.welearn-support'); const updateHint = panel.querySelector('.welearn-update-hint'); // 点击更新提示时的行为 updateHint?.addEventListener('click', (e) => { e.preventDefault(); showToast(`正在前往 v${latestVersion || '新版本'} 更新页面...(跳转后请稍作等待)`, { duration: 5000 }); setTimeout(() => { window.location.href = UPDATE_CHECK_URL; }, 5000); }); // 为按钮添加 checked 属性模拟 checkbox 行为 submitToggle.checked = false; mistakeToggle.checked = false; const state = loadPanelState(); if (state.autoSubmit) { submitToggle.checked = true; submitToggle.classList.add('active'); } if (state.enableSoftErrors) { mistakeToggle.checked = true; mistakeToggle.classList.add('active'); } initDragAndResize(panel, header); /** 保存当前状态到 localStorage */ const persistState = () => { const rect = panel.getBoundingClientRect(); if (panel.classList.contains('minimized')) { panel.style.width = `${MINIMIZED_PANEL_SIZE}px`; panel.style.height = `${MINIMIZED_PANEL_SIZE}px`; } else { const width = clampSize(PANEL_DEFAULT_WIDTH, PANEL_MIN_WIDTH, getMaxWidth()); panel.style.width = `${width}px`; panel.style.height = 'auto'; // 高度自适应 } savePanelState({ left: rect.left, top: rect.top, width: panel.offsetWidth, minimized: panel.classList.contains('minimized'), autoSubmit: submitToggle.checked, enableSoftErrors: mistakeToggle.checked, }); }; // 绑定事件监听器 submitToggle.addEventListener('click', () => { submitToggle.checked = !submitToggle.checked; submitToggle.classList.toggle('active', submitToggle.checked); persistState(); }); mistakeToggle.addEventListener('click', () => { mistakeToggle.checked = !mistakeToggle.checked; mistakeToggle.classList.toggle('active', mistakeToggle.checked); persistState(); }); minifyButton.addEventListener('click', () => { const wasMinimized = panel.classList.contains('minimized'); panel.classList.toggle('minimized'); // 展开时检查是否会超出屏幕,如果是则平滑移动到可见区域 if (wasMinimized) { // 等待 CSS 尺寸动画开始后计算实际需要的空间 requestAnimationFrame(() => { const { width: vw, height: vh } = getVisibleViewport(); const rect = panel.getBoundingClientRect(); // 预估展开后的尺寸 const expandedWidth = PANEL_DEFAULT_WIDTH; const expandedHeight = PANEL_DEFAULT_HEIGHT; // 计算需要调整的位置 let targetLeft = rect.left; let targetTop = rect.top; let needsMove = false; // 检查右边界 if (rect.left + expandedWidth > vw - 8) { targetLeft = Math.max(8, vw - expandedWidth - 8); needsMove = true; } // 检查下边界 if (rect.top + expandedHeight > vh - 8) { targetTop = Math.max(8, vh - expandedHeight - 8); needsMove = true; } if (needsMove) { // 添加位置过渡动画 panel.style.transition = 'width 0.25s ease, height 0.25s ease, min-width 0.25s ease, max-width 0.25s ease, padding 0.25s ease, left 0.25s ease, top 0.25s ease'; panel.style.left = targetLeft + 'px'; panel.style.top = targetTop + 'px'; // 动画结束后移除位置过渡,保留原有过渡 setTimeout(() => { panel.style.transition = 'width 0.25s ease, height 0.25s ease, min-width 0.25s ease, max-width 0.25s ease, padding 0.25s ease'; }, 260); } }); } persistState(); }); supportButton?.addEventListener('click', showSupportModal); // 读取目录按钮 - 显示任务选择弹窗 scanButton?.addEventListener('click', () => { showTaskSelectorModal(); }); // 批量执行按钮 - 执行已选择的任务 batchButton?.addEventListener('click', () => { executeBatchTasks(); }); // 清空统计按钮 const clearStatsButton = panel.querySelector('.welearn-clear-stats'); clearStatsButton?.addEventListener('click', () => { if (confirm('确定要清空错误统计数据吗?')) { clearErrorStats(); showToast('统计数据已清空'); } }); // 权重设置输入框 const weight0Input = panel.querySelector('.welearn-weight-0'); const weight1Input = panel.querySelector('.welearn-weight-1'); const weight2Input = panel.querySelector('.welearn-weight-2'); const weightsErrorEl = panel.querySelector('.welearn-weights-error'); /** 获取输入框的数值,空值返回0 */ const getInputValue = (input) => { const val = input.value.trim(); if (val === '') return 0; const num = parseInt(val, 10); return isNaN(num) ? 0 : Math.max(0, Math.min(100, num)); }; /** 验证并保存权重配置 */ const validateAndSaveWeights = () => { const w0 = getInputValue(weight0Input); const w1 = getInputValue(weight1Input); const w2 = getInputValue(weight2Input); const total = w0 + w1 + w2; const isValid = total === 100; // 显示/隐藏错误提示 weightsErrorEl.classList.toggle('visible', !isValid); [weight0Input, weight1Input, weight2Input].forEach((input) => { input.classList.toggle('error', !isValid); }); if (isValid) { saveErrorWeights({ w0, w1, w2 }); } return isValid; }; /** 过滤非数字字符 */ const filterNonNumeric = (input) => { input.value = input.value.replace(/[^0-9]/g, ''); }; // 加载已保存的权重配置 const savedWeights = loadErrorWeights(); weight0Input.value = savedWeights.w0; weight1Input.value = savedWeights.w1; weight2Input.value = savedWeights.w2; validateAndSaveWeights(); // 绑定权重输入事件 [weight0Input, weight1Input, weight2Input].forEach((input) => { input.addEventListener('input', () => { filterNonNumeric(input); validateAndSaveWeights(); }); input.addEventListener('change', validateAndSaveWeights); }); // 刷时长模式选择器 const durationBtns = panel.querySelectorAll('.welearn-duration-btn'); // 加载已保存的刷时长模式 const savedDurationMode = loadDurationMode(); durationBtns.forEach((btn) => { if (btn.dataset.mode === savedDurationMode) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); // 绑定刷时长模式选择事件 durationBtns.forEach((btn) => { btn.addEventListener('click', () => { // 移除所有active durationBtns.forEach(b => b.classList.remove('active')); // 添加当前active btn.classList.add('active'); const mode = btn.dataset.mode; saveDurationMode(mode); const config = DURATION_MODES[mode]; if (mode === 'off') { showToast('⏭️ 刷时长已关闭,将直接提交', { duration: 2000 }); } else { showToast(`已切换到${config.name}模式:${Math.round(config.baseTime/1000)}-${Math.round(config.maxTime/1000)}秒`, { duration: 2000 }); } }); }); // 初始化统计显示 refreshErrorStatsDisplay(); // 检查版本更新 checkForUpdates(); // 注意:最小化状态下的点击展开逻辑已移至 initDragAndResize 函数中 // 通过拖动阈值判断:移动小于 5px 视为点击,展开面板 startButton.addEventListener('click', () => { startButton.disabled = true; const result = fillAll({ enableSoftErrors: mistakeToggle.checked }); // 同时触发 iframe 内的填充 triggerIframeFill(mistakeToggle.checked); if (result.filled) { // 更新错误统计(如果启用了添加小错误功能) if (mistakeToggle.checked) { updateErrorStats(result.errors.length); } // 立即显示填写完成提示 if (!groupWorkDetected) { const errorCount = result.errors.length; if (mistakeToggle.checked && errorCount > 0) { // 生成带红色高亮的错误详情 const details = result.errors.map((e) => { // 找出不同的字符并标红 const highlighted = highlightDiff(e.original, e.modified); return `${e.type} ${highlighted}`; }).join(''); showToast(`填写完成!已添加 ${errorCount} 处小错误:${details}`, { html: true, duration: 3500 }); } else if (mistakeToggle.checked) { showToast('填写完成!本次无小错误'); } else { showToast('填写完成!'); } } // 延迟提交(如果启用) if (submitToggle.checked) { setTimeout(() => { submitIfNeeded(true); startButton.disabled = false; }, SUBMIT_DELAY_MS); } else { startButton.disabled = false; } } else { // 检查是否有 iframe 可能包含内容 const hasIframes = document.querySelectorAll('iframe').length > 0; if (hasIframes) { showToast('已发送填充请求到页面框架'); } else { showToast('未发现可填写的内容'); } startButton.disabled = false; } }); }; /** 确保面板已挂载到页面 */ const ensurePanelMounted = () => { if (!document.body) return; if (document.querySelector('.welearn-panel')) return; initPanel(); }; // ==================== 引导模态框 ==================== /** 加载引导状态 */ const loadOnboardingState = () => { try { const raw = localStorage.getItem(ONBOARDING_STATE_KEY); return raw ? JSON.parse(raw) : {}; } catch (error) { console.warn('WeLearn autofill: failed to load onboarding state', error); return {}; } }; /** 保存引导状态 */ const saveOnboardingState = (state) => { try { localStorage.setItem(ONBOARDING_STATE_KEY, JSON.stringify(state)); } catch (error) { console.warn('WeLearn autofill: failed to save onboarding state', error); } }; // ==================== 赞赏码图片缓存 ==================== /** 从 localStorage 加载缓存的图片 */ const loadCachedDonateImage = () => { try { const cached = localStorage.getItem(DONATE_IMAGE_CACHE_KEY); if (cached) { donateImageDataUrl = cached; return true; } } catch (error) { console.warn('WeLearn: 加载缓存图片失败', error); } return false; }; /** 预加载赞赏码图片(仅预热浏览器缓存,不转换为 Data URL) */ const preloadDonateImage = () => { // 如果已有缓存,直接使用 if (loadCachedDonateImage()) { console.info('[WeLearn-Go] 已从缓存加载赞赏码图片'); return; } // 使用 Image 对象预加载图片(不设置 crossOrigin,避免 CORS 问题) // 这样图片会被浏览器缓存,后续显示时可以直接从缓存加载 const img = new Image(); img.onload = () => { console.info('[WeLearn-Go] 赞赏码图片已预加载到浏览器缓存'); }; // 静默处理错误,不影响脚本运行 img.onerror = () => {}; img.src = DONATE_IMAGE_URL; }; /** 显示赞赏模态框 */ const showSupportModal = () => { // 使用缓存的图片或原始 URL const imageUrl = donateImageDataUrl || DONATE_IMAGE_URL; const overlay = document.createElement('div'); overlay.className = 'welearn-modal-overlay'; overlay.innerHTML = `

赞助支持

如果你能请我喝一杯咖啡,我将不胜感激!

`; const close = () => overlay.remove(); overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); overlay.querySelector('.welearn-modal-close')?.addEventListener('click', close); document.body.appendChild(overlay); }; /** 显示首次使用引导模态框 */ const showOnboardingModal = () => { const state = loadOnboardingState(); if (state.seen) return; const overlay = document.createElement('div'); overlay.className = 'welearn-modal-overlay'; overlay.innerHTML = `

使用须知

本脚本仅供学习使用,请在 24H 内删除。对使用该脚本产生的后果均由使用者承担。

本脚本始终保持免费,如购买所得说明被骗了。

简易使用教程:

  1. 进入对应课程练习页面(当前已适配:领航大学英语综合教程1)。
  2. 点击页面左侧的「一键填写」按钮自动填写答案。
  3. 如需自动提交,可在面板中勾选「自动提交」。
`; const close = () => { saveOnboardingState({ seen: true }); overlay.remove(); }; overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); const closeButton = overlay.querySelector('.welearn-modal-close'); closeButton?.addEventListener('click', close); document.body.appendChild(overlay); }; // ==================== 页面生命周期管理 ==================== /** 初始化页面元素 */ const initPageArtifacts = (showSwitchToast = false) => { groupWorkDetected = false; groupWorkNoticeShown = false; openEndedExerciseShown = false; cleanupPageArtifacts(); showOnboardingModal(); ensurePanelMounted(); if (!ensurePanelMounted.observer && document.body) { ensurePanelMounted.observer = new MutationObserver(() => ensurePanelMounted()); ensurePanelMounted.observer.observe(document.body, { childList: true, subtree: true }); } if (showSwitchToast && !isInIframe()) { showToast('检测到页面切换,已为新作业自动初始化'); } // 检查批量任务状态 setTimeout(() => { // 检查是否有正在进行的批量执行 const batchState = loadBatchModeState(); const isExecuting = batchState && batchState.active; if (isOnCourseDirectoryPage()) { // 在目录页面 if (isExecuting && batchState.phase === 'returning') { // 批量执行中,从任务页面返回,继续执行下一个任务 console.log('[WeLearn-Go] 批量执行: 已返回目录页面,继续执行下一个任务'); batchModeActive = true; showBatchProgressIndicator(batchState.totalTasks, batchState.currentIndex); setTimeout(() => { executeNextTask(); }, 1500); } else if (!batchModeActive && !isExecuting && selectedBatchTasks.length === 0) { // 没有正在执行的批量任务,检查异常中断的任务 checkAndResumeBatchMode(); // 检查是否有缓存的任务可以恢复 const tasksCache = loadBatchTasksCache(); if (tasksCache && tasksCache.tasks && tasksCache.tasks.length > 0) { showBatchTasksRecoveryPrompt(); } } } else if (isExecuting) { // 在任务页面,且批量执行正在进行中 // 支持多种 phase:navigating(正在导航到任务)、filling(填写中被刷新)、submitting(提交中被刷新) const shouldFill = ['navigating', 'filling', 'submitting'].includes(batchState.phase); if (shouldFill) { console.log('[WeLearn-Go] 批量执行: 任务页面已加载,开始填写 (phase:', batchState.phase + ')'); batchModeActive = true; showBatchProgressIndicator(batchState.totalTasks, batchState.currentIndex); // 等待页面完全加载后执行填写(增加延迟到 3 秒) setTimeout(() => { executeFillAndSubmit(); }, 3000); } else { console.log('[WeLearn-Go] 批量执行: 未知 phase,跳过当前任务', batchState.phase); // 跳过当前任务,继续下一个 skipCurrentTask('页面状态异常'); } } }, 1500); }; /** 在 iframe 中初始化(不显示面板,监听父窗口消息) */ const initInIframe = () => { console.info('[WeLearn-Go]', 'iframe 模式已加载', location.href); // 使用 MutationObserver 监听 DOM 变化,适应 SPA const observer = new MutationObserver((mutations) => { // 简单的防抖,避免频繁检测 if (observer.timer) clearTimeout(observer.timer); observer.timer = setTimeout(() => { checkContent(); }, 1000); }); observer.observe(document.body, { childList: true, subtree: true }); // 检测页面是否有练习元素 const checkContent = () => { const blanks = document.querySelectorAll('et-blank'); const toggles = document.querySelectorAll('et-toggle'); const items = document.querySelectorAll('et-item'); const allContentEditable = document.querySelectorAll('[contenteditable="true"]'); console.info('[WeLearn-Go] iframe 内容检测:', { 'et-blank': blanks.length, 'et-toggle': toggles.length, 'et-item': items.length, 'contenteditable': allContentEditable.length }); }; // 监听来自父窗口的填充请求 window.addEventListener('message', (event) => { // 验证消息来源 if (!event.origin.includes('sflep.com')) return; if (event.data?.type === 'welearn-fill') { const result = fillAll({ enableSoftErrors: event.data.enableSoftErrors || false }); // 向父窗口报告结果 try { window.parent.postMessage({ type: 'welearn-fill-result', ...result }, '*'); } catch (e) { /* 忽略跨域错误 */ } } }); // 暴露全局 API 供父窗口或控制台调用 window.WeLearnGo = { fill: (options = {}) => fillAll(options), isReady: true }; // 通知父窗口 iframe 已准备就绪 try { window.parent.postMessage({ type: 'welearn-iframe-ready' }, '*'); } catch (e) { /* 忽略跨域错误 */ } }; /** 脚本入口函数 */ const start = () => { if (!isWeLearnHost()) return; console.info('[WeLearn-Go]', '辅助脚本已加载,祝你学习顺利!','相关内容仅供学习研究,请在 24H 内删除。','使用该脚本产生的后果均由使用者承担。'); const run = () => { if (!document.body) { setTimeout(run, 50); return; } // 根据是否在 iframe 中采用不同策略 if (isInIframe()) { initInIframe(); // iframe 中也需要自动确认 startAutoConfirmDialog(); } else { // 预加载赞赏码图片(只在主页面) preloadDonateImage(); initPageArtifacts(); monitorPageSwitches(); // 监听 iframe 就绪消息 listenForIframeReady(); // 启动自动确认提交对话框监听 startAutoConfirmDialog(); } }; run(); }; /** 监听 iframe 就绪消息,并触发填充 */ const listenForIframeReady = () => { window.addEventListener('message', (event) => { if (!event.origin.includes('sflep.com')) return; if (event.data?.type === 'welearn-iframe-ready') { console.info('[WeLearn-Go]', 'iframe 已就绪'); } if (event.data?.type === 'welearn-fill-result') { // 收到 iframe 填充结果 if (event.data.filled) { console.info('[WeLearn-Go]', 'iframe 填充完成'); } } }); }; /** 触发 iframe 中的填充操作 */ const triggerIframeFill = (enableSoftErrors = false) => { const iframes = document.querySelectorAll('iframe'); iframes.forEach((iframe) => { try { iframe.contentWindow?.postMessage({ type: 'welearn-fill', enableSoftErrors }, '*'); } catch (e) { /* 忽略跨域错误 */ } }); }; /** 处理页面切换(重新初始化) */ const handlePageChange = () => { if (!isWeLearnHost()) return; initPageArtifacts(true); }; /** 监控页面切换(SPA 路由变化) */ const monitorPageSwitches = () => { const WATCH_INTERVAL_MS = 1000; if (monitorPageSwitches.started) return; monitorPageSwitches.started = true; setInterval(() => { if (location.href === lastKnownUrl) return; lastKnownUrl = location.href; handlePageChange(); }, WATCH_INTERVAL_MS); }; // ==================== 脚本启动 ==================== if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start, { once: true }); } else { start(); } })();