自动填写 WeLearn 练习答案,支持小错误生成、自动提交和批量任务执行!
// ==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 = `统计:<b>${stats.count0}</b> <b>${stats.count1}</b> <b>${stats.count2}</b> (${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<b), 0(a=b) */
const compareVersions = (a, b) => {
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 += `<em>${modChar}</em>`;
} 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<boolean>} 是否成功填充
*/
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<Element>} 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)
* 结构:<et-tof labels="B,S" key="t">
* <span ng-click="tof.chose('t')">B</span> - 第一个选项(true)
* <span ng-click="tof.chose('f')">S</span> - 第二个选项(false)
* </et-tof>
*
* 答案来源(按优先级):
* 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 按钮
// 这种按钮结构是: <et-button action="item.submit()"><button ng-click="btn.doAction()">
const etButtons = doc.querySelectorAll('et-button[action*="submit"]');
for (const etBtn of etButtons) {
// 检查是否可见且未禁用
const isHidden = etBtn.classList.contains('ng-hide') ||
etBtn.style.display === 'none' ||
etBtn.offsetParent === null;
const isDisabled = etBtn.hasAttribute('disabled') &&
etBtn.getAttribute('disabled') !== 'false';
if (!isHidden && !isDisabled) {
const innerBtn = etBtn.querySelector('button');
if (innerBtn && !innerBtn.disabled) {
// 尝试通过 AngularJS scope 调用
try {
const scope = angular.element(etBtn).isolateScope() ||
angular.element(etBtn).scope();
if (scope && scope.btn && typeof scope.btn.doAction === 'function') {
scope.btn.doAction();
console.log('[WeLearn] 已调用 btn.doAction() 提交');
return;
}
} catch (e) {
console.log('[WeLearn] AngularJS 调用失败,尝试直接点击');
}
// 回退:直接点击按钮
innerBtn.click();
console.log('[WeLearn] 已点击提交按钮 (et-button)');
return;
}
}
}
// 方法3:查找 controls 区域中未提交状态的按钮
// 特征:在 et-item .controls 内,ng-hide="item.isSubmitted"
const controlsArea = doc.querySelector('et-item > .controls');
if (controlsArea) {
const buttons = controlsArea.querySelectorAll('et-button');
for (const btn of buttons) {
const ngHide = btn.getAttribute('ng-hide');
// 查找带 isSubmitted 条件的按钮(未提交时显示)
if (ngHide && ngHide.includes('isSubmitted')) {
const innerBtn = btn.querySelector('button');
if (innerBtn && !innerBtn.disabled && btn.offsetParent !== null) {
try {
const scope = angular.element(btn).isolateScope() ||
angular.element(btn).scope();
if (scope && scope.btn && typeof scope.btn.doAction === 'function') {
scope.btn.doAction();
console.log('[WeLearn] 已调用控制区提交按钮');
return;
}
} catch (e) {
// 忽略
}
innerBtn.click();
console.log('[WeLearn] 已点击控制区提交按钮');
return;
}
}
}
}
// 方法4:通过 et-item 的 scope 直接调用 submit
const etItems = doc.querySelectorAll('et-item');
for (const etItem of etItems) {
try {
const scope = angular.element(etItem).scope();
if (scope && scope.item && typeof scope.item.submit === 'function') {
// 检查是否已提交
if (!scope.item.isSubmitted && !scope.item.suspendSubmit) {
scope.item.submit();
console.log('[WeLearn] 已通过 scope.item.submit() 提交');
return;
}
}
} catch (e) {
// 忽略
}
}
}
console.log('[WeLearn] 未找到可用的提交按钮');
};
/**
* 自动处理提交确认对话框
* 当点击提交按钮后,可能会弹出 layui 对话框要求二次确认
* 此函数会自动点击"是"按钮完成确认
*/
const autoConfirmSubmitDialog = () => {
// 使用 MutationObserver 监听对话框的出现
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// 检查是否是 layui 对话框
if (node.classList?.contains('layui-layer-dialog') ||
node.querySelector?.('.layui-layer-dialog')) {
const dialog = node.classList.contains('layui-layer-dialog')
? node
: node.querySelector('.layui-layer-dialog');
if (!dialog) continue;
// 检查对话框内容是否包含提交确认文字
const content = dialog.querySelector('.layui-layer-content');
if (content && content.textContent?.includes('提交')) {
// 查找"是"按钮并点击
const confirmBtn = dialog.querySelector('.layui-layer-btn0');
if (confirmBtn) {
console.log('[WeLearn-Go] 自动确认提交对话框');
setTimeout(() => {
confirmBtn.click();
}, 100);
}
}
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// 返回一个清理函数
return () => observer.disconnect();
};
// 启动自动确认监听
let confirmDialogCleanup = null;
const startAutoConfirmDialog = () => {
if (!confirmDialogCleanup) {
confirmDialogCleanup = autoConfirmSubmitDialog();
}
};
// ==================== 批量任务处理功能 ====================
/** 加载已完成的任务记录 */
const loadBatchCompleted = () => {
try {
const raw = localStorage.getItem(BATCH_COMPLETED_KEY);
return raw ? JSON.parse(raw) : {};
} catch (error) {
console.warn('WeLearn: 加载已完成记录失败', error);
return {};
}
};
/** 保存已完成的任务记录 */
const saveBatchCompleted = (completed) => {
try {
localStorage.setItem(BATCH_COMPLETED_KEY, JSON.stringify(completed));
} catch (error) {
console.warn('WeLearn: 保存已完成记录失败', error);
}
};
/** 标记任务为已完成 */
const markTaskCompleted = (taskId, courseName) => {
const completed = loadBatchCompleted();
if (!completed[courseName]) {
completed[courseName] = [];
}
if (!completed[courseName].includes(taskId)) {
completed[courseName].push(taskId);
saveBatchCompleted(completed);
}
};
/** 检查任务是否已完成 */
const isTaskCompleted = (taskId, courseName) => {
const completed = loadBatchCompleted();
return completed[courseName]?.includes(taskId) || false;
};
/** 清除课程的完成记录 */
const clearCourseCompleted = (courseName) => {
const completed = loadBatchCompleted();
if (completed[courseName]) {
delete completed[courseName];
saveBatchCompleted(completed);
}
};
/** 保存批量模式状态 */
const saveBatchModeState = (state) => {
try {
// 添加时间戳用于检测异常关闭
state.lastUpdate = Date.now();
localStorage.setItem(BATCH_MODE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('WeLearn: 保存批量模式状态失败', error);
}
};
/** 加载批量模式状态 */
const loadBatchModeState = () => {
try {
const raw = localStorage.getItem(BATCH_MODE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (error) {
console.warn('WeLearn: 加载批量模式状态失败', error);
return null;
}
};
/** 清除批量模式状态 */
const clearBatchModeState = () => {
try {
localStorage.removeItem(BATCH_MODE_KEY);
} catch (error) {
console.warn('WeLearn: 清除批量模式状态失败', error);
}
};
/** 获取当前课程 ID */
const getCourseId = () => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('cid') || '';
};
/** 获取当前课程名称 */
const getCourseName = () => {
// 尝试从页面标题或特定元素获取课程名
const courseTitle = document.querySelector('.course_title, .courseName, #courseName, .courseware_title');
if (courseTitle) {
return courseTitle.textContent?.trim() || '未知课程';
}
// 从 URL 参数获取课程 ID
const cid = getCourseId();
return cid ? `课程 ${cid}` : '未知课程';
};
/** 保存课程目录缓存 */
const saveCourseDirectoryCache = (courseId, courseName, tasks) => {
try {
const cache = {
courseId,
courseName,
tasks,
timestamp: Date.now()
};
localStorage.setItem(COURSE_DIRECTORY_CACHE_KEY, JSON.stringify(cache));
console.info('[WeLearn-Go] 课程目录已缓存:', courseName, tasks.length, '个任务');
} catch (error) {
console.warn('[WeLearn-Go] 保存目录缓存失败:', error);
}
};
/** 加载课程目录缓存 */
const loadCourseDirectoryCache = () => {
try {
const raw = localStorage.getItem(COURSE_DIRECTORY_CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (error) {
console.warn('[WeLearn-Go] 加载目录缓存失败:', error);
return null;
}
};
/** 保存批量任务选择缓存 */
const saveBatchTasksCache = (courseName, tasks) => {
try {
const cache = {
courseName,
tasks,
timestamp: Date.now()
};
localStorage.setItem(BATCH_TASKS_CACHE_KEY, JSON.stringify(cache));
console.info('[WeLearn-Go] 批量任务已缓存:', tasks.length, '个任务');
} catch (error) {
console.warn('[WeLearn-Go] 保存任务缓存失败:', error);
}
};
/** 加载批量任务选择缓存 */
const loadBatchTasksCache = () => {
try {
const raw = localStorage.getItem(BATCH_TASKS_CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (error) {
console.warn('[WeLearn-Go] 加载任务缓存失败:', error);
return null;
}
};
/** 清除批量任务选择缓存 */
const clearBatchTasksCache = () => {
try {
localStorage.removeItem(BATCH_TASKS_CACHE_KEY);
} catch (error) {
console.warn('[WeLearn-Go] 清除任务缓存失败:', error);
}
};
/** 扫描页面上所有可执行的任务元素
* @param {Object} options - 配置选项
* @param {boolean} options.ignoreLocalCompleted - 是否忽略本地完成记录,只看页面状态
*/
const scanPageForTasks = ({ ignoreLocalCompleted = false } = {}) => {
const tasks = [];
const seenIds = new Set();
const completed = loadBatchCompleted();
const courseName = getCourseName();
// 如果 ignoreLocalCompleted 为 true,则不使用本地记录
const completedTasks = ignoreLocalCompleted ? [] : (completed[courseName] || []);
console.log('[WeLearn-Go] 开始扫描页面任务...');
// 通用方法: 查找所有包含 StartSCO 的 onclick 元素
const allClickableElements = document.querySelectorAll('[onclick*="StartSCO"]');
console.log('[WeLearn-Go] 找到 StartSCO 元素:', allClickableElements.length);
allClickableElements.forEach((el) => {
const onclickAttr = el.getAttribute('onclick') || '';
const scoMatch = onclickAttr.match(/StartSCO\s*\(\s*['"]([^'"]+)['"]\s*\)/);
if (!scoMatch) return;
const taskId = scoMatch[1];
if (seenIds.has(taskId)) return;
seenIds.add(taskId);
// 获取标题 - 尝试多种方式
let title = '';
// 尝试从 span 子元素获取
const span = el.querySelector('span');
if (span) {
const spanClone = span.cloneNode(true);
// 移除图标
spanClone.querySelectorAll('i, .fa, .icon').forEach(icon => icon.remove());
title = spanClone.textContent?.trim() || '';
}
// 尝试从 a 标签获取
if (!title) {
const link = el.querySelector('a');
title = link?.getAttribute('title') || link?.textContent?.trim() || '';
}
// 尝试从元素本身获取文本
if (!title) {
const elClone = el.cloneNode(true);
elClone.querySelectorAll('i, .fa, .icon, .progress, .badge').forEach(n => n.remove());
title = elClone.textContent?.trim().substring(0, 80) || taskId;
}
// 获取父级单元名称
let unitName = '';
let isIntro = false; // 是否是课程介绍类(0/0任务组)
// 尝试从最近的 panel-heading 获取
const panelHeading = el.closest('.panel')?.querySelector('.panel-title > a');
if (panelHeading) {
const headingClone = panelHeading.cloneNode(true);
headingClone.querySelectorAll('.progress_fix, .badge').forEach(n => n.remove());
unitName = headingClone.textContent?.trim() || '';
// 检查是否是 0/0 任务组(课程介绍类)
const progressFix = el.closest('.panel')?.querySelector('.progress_fix');
if (progressFix) {
const progressText = progressFix.textContent || '';
// 匹配类似 "X /0" 的模式,表示总任务数为0
if (/\/\s*0\s*$/.test(progressText)) {
isIntro = true;
}
}
}
// 尝试从 u_listtitle 获取
if (!unitName) {
let prevEl = el.previousElementSibling || el.parentElement;
while (prevEl && !unitName) {
if (prevEl.classList?.contains('u_listtitle')) {
unitName = prevEl.textContent?.trim().substring(0, 50) || '';
break;
}
prevEl = prevEl.previousElementSibling || prevEl.parentElement;
}
}
// 判断状态
const isDisabled = el.classList.contains('disabled') ||
el.classList.contains('list-disabled') ||
el.classList.contains('course_disable');
// 检测页面上的完成状态 - 支持多种可能的完成标识
const icon = el.querySelector('i.fa');
let pageCompleted = false;
// 方式1: 检查图标类名 (支持多种完成图标)
if (icon) {
pageCompleted = icon.classList.contains('fa-check-circle-o') ||
icon.classList.contains('fa-check-circle') ||
icon.classList.contains('fa-check') ||
icon.classList.contains('fa-check-square-o') ||
icon.classList.contains('fa-check-square');
}
// 方式2: 检查元素或父元素是否有完成相关的类名
if (!pageCompleted) {
pageCompleted = el.classList.contains('completed') ||
el.classList.contains('finish') ||
el.classList.contains('done') ||
el.classList.contains('success') ||
el.closest('.completed, .finish, .done') !== null;
}
// 方式3: 检查进度条是否满 (100%)
if (!pageCompleted) {
const progressBar = el.querySelector('.progress-bar, .progress');
if (progressBar) {
const widthStyle = progressBar.style.width;
if (widthStyle === '100%') {
pageCompleted = true;
}
// 检查 aria-valuenow 属性
const ariaValue = progressBar.getAttribute('aria-valuenow');
if (ariaValue === '100') {
pageCompleted = true;
}
}
}
// 方式4: 检查文本内容是否包含完成标识
if (!pageCompleted) {
const statusBadge = el.querySelector('.badge, .status, .label');
if (statusBadge) {
const statusText = statusBadge.textContent?.trim() || '';
if (/已完成|完成|Completed|Done|Finished|100%/i.test(statusText)) {
pageCompleted = true;
}
}
}
// 方式5: 检查图标颜色 (绿色通常表示完成)
if (!pageCompleted && icon) {
const iconColor = getComputedStyle(icon).color;
// 绿色色值检测 (包括各种绿色变体)
if (iconColor && /rgb\(\s*\d{1,2}\s*,\s*(1\d{2}|2[0-4]\d|25[0-5])\s*,\s*\d{1,2}\s*\)/.test(iconColor)) {
// 这是一个大致的绿色检测,G值较高且R、B值较低
const match = iconColor.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
if (match) {
const [, r, g, b] = match.map(Number);
if (g > 100 && g > r && g > b) {
pageCompleted = true;
}
}
}
}
const isCompletedByUs = completedTasks.includes(taskId);
const isCompleted = isCompletedByUs || pageCompleted;
// 调试日志:输出每个任务的完成状态检测结果
if (pageCompleted) {
console.log('[WeLearn-Go] 任务已完成(页面):', taskId, title.substring(0, 30));
}
tasks.push({
id: taskId,
title: title,
unitName: unitName,
isDisabled: isDisabled,
isCompleted: isCompleted,
isIntro: isIntro,
element: el,
onclick: onclickAttr
});
});
// 如果没有找到 StartSCO,尝试其他结构
if (tasks.length === 0) {
console.log('[WeLearn-Go] 未找到 StartSCO 元素,尝试其他结构...');
// 旧版结构: courseware_list
const coursewareItems = document.querySelectorAll('.courseware_list_1_3, .courseware_list_1_4');
console.log('[WeLearn-Go] 找到 courseware_list 元素:', coursewareItems.length);
coursewareItems.forEach((item) => {
const taskId = item.id || item.getAttribute('data-sco') || '';
if (!taskId || seenIds.has(taskId)) return;
seenIds.add(taskId);
const link = item.querySelector('a');
const title = link?.getAttribute('title') || link?.textContent?.trim() || '';
const isDisabled = item.classList.contains('course_disable');
const isCompletedByUs = completedTasks.includes(taskId);
// 检测页面完成状态 - 多种方式
let pageCompleted = false;
// 方式1: 检查完成相关的元素
const hasProgressComplete = item.querySelector('.progress-complete, .completed, .finish, .done');
if (hasProgressComplete) {
pageCompleted = true;
}
// 方式2: 检查图标
if (!pageCompleted) {
const icon = item.querySelector('i.fa');
if (icon) {
pageCompleted = icon.classList.contains('fa-check-circle-o') ||
icon.classList.contains('fa-check-circle') ||
icon.classList.contains('fa-check') ||
icon.classList.contains('fa-check-square-o') ||
icon.classList.contains('fa-check-square');
}
}
// 方式3: 检查进度条
if (!pageCompleted) {
const progressBar = item.querySelector('.progress-bar, .progress');
if (progressBar) {
const widthStyle = progressBar.style.width;
const ariaValue = progressBar.getAttribute('aria-valuenow');
if (widthStyle === '100%' || ariaValue === '100') {
pageCompleted = true;
}
}
}
// 方式4: 检查状态文本
if (!pageCompleted) {
const statusBadge = item.querySelector('.badge, .status, .label');
if (statusBadge) {
const statusText = statusBadge.textContent?.trim() || '';
if (/已完成|完成|Completed|Done|Finished|100%/i.test(statusText)) {
pageCompleted = true;
}
}
}
const isCompleted = isCompletedByUs || pageCompleted;
let unitName = '';
const categoryContainer = item.closest('.categoryitems');
if (categoryContainer) {
const prevHeader = categoryContainer.previousElementSibling;
if (prevHeader?.classList?.contains('courseware_list_1_1')) {
const unitSpan = prevHeader.querySelector('.v_1');
unitName = unitSpan?.textContent?.trim() || '';
}
}
if (title && taskId) {
tasks.push({
id: taskId,
title: title,
unitName: unitName,
isDisabled: isDisabled,
isCompleted: isCompleted,
element: item
});
}
});
}
// 统计完成状态
const completedCount = tasks.filter(t => t.isCompleted).length;
const pendingCount = tasks.filter(t => !t.isCompleted && !t.isDisabled && !t.isIntro).length;
console.log('[WeLearn-Go] 扫描完成,共找到任务:', tasks.length,
'| 已完成:', completedCount,
'| 待完成:', pendingCount);
return tasks;
};
/** 获取课程目录中的所有任务(使用扫描方法)
* @param {Object} options - 配置选项
* @param {boolean} options.ignoreLocalCompleted - 是否忽略本地完成记录
*/
const getCourseTaskList = (options = {}) => {
return scanPageForTasks(options);
};
/** 展开所有目录项 */
const expandAllCategories = async () => {
// 检测是否在 course_info.aspx 页面
const isCourseInfoPage = window.location.href.includes('course_info.aspx');
if (isCourseInfoPage) {
// 首先确保点击了"目录"标签
const tabs = document.querySelectorAll('.nav-tabs li a, .course-tabs a, [role="tab"]');
for (const tab of tabs) {
if (tab.textContent?.includes('目录')) {
tab.click();
await new Promise(resolve => setTimeout(resolve, 500));
break;
}
}
// 展开所有单元 (点击每个单元行的展开按钮)
const unitRows = document.querySelectorAll('.u_listtitle, .unit-row, [data-toggle="collapse"]');
for (const row of unitRows) {
// 检查是否已展开
const isExpanded = row.classList.contains('expanded') ||
row.getAttribute('aria-expanded') === 'true';
if (!isExpanded) {
const expandBtn = row.querySelector('.expand-btn, .plus-icon, .fa-plus') || row;
expandBtn.click();
await new Promise(resolve => setTimeout(resolve, 300));
}
}
}
// stu_unitlist 结构: 点击 collapsed 的 panel 标题来展开
const collapsedPanels = document.querySelectorAll('.stu_unitlist .panel-title > a.collapsed');
for (const link of collapsedPanels) {
link.click();
await new Promise(resolve => setTimeout(resolve, 200));
}
// 旧版结构: 点击所有折叠的单元头部来展开
const collapsedHeaders = document.querySelectorAll('.courseware_list_1_1:not(.openheader)');
collapsedHeaders.forEach((header) => {
header.click();
});
// 等待展开动画完成
return new Promise(resolve => setTimeout(resolve, 800));
};
/** 检查当前页面是否是课程目录页面(而非任务执行页面) */
const isOnCourseDirectoryPage = () => {
const url = window.location.href;
// 任务执行页面包含 StudyCourse.aspx,不是目录页面
if (url.includes('StudyCourse.aspx')) {
return false;
}
// 目录页面的 URL 特征
return url.includes('course_info.aspx') ||
url.includes('study.aspx') ||
url.includes('directory.aspx');
};
/** 生成任务列表 HTML */
const generateTasksHtml = (availableTasks, savedTaskIds = []) => {
// 按单元分组任务
const tasksByUnit = {};
availableTasks.forEach(task => {
const unit = task.unitName || '其他';
if (!tasksByUnit[unit]) {
tasksByUnit[unit] = [];
}
tasksByUnit[unit].push(task);
});
let tasksHtml = '';
Object.entries(tasksByUnit).forEach(([unitName, unitTasks]) => {
// 检测是否是"课程说明"类型的单元(通过名称或 isIntro 属性)
const isIntroUnit = unitTasks.every(t => t.isIntro) ||
/^课程(说明|介绍|简介)/.test(unitName) ||
/^(Course\s*)?(Introduction|Info|Description)/i.test(unitName);
tasksHtml += `
<div class="welearn-task-unit">
<div class="welearn-task-unit-header">
<label class="welearn-checkbox-label">
<input type="checkbox" class="welearn-unit-checkbox" data-unit="${unitName}" ${isIntroUnit ? 'disabled' : ''}>
<span>${unitName || '任务列表'}</span>
</label>
</div>
<div class="welearn-task-list">
${unitTasks.map(task => {
const taskIsIntro = task.isIntro || isIntroUnit;
return `
<label class="welearn-task-item ${task.isCompleted ? 'completed' : ''} ${taskIsIntro ? 'intro' : ''}">
<input type="checkbox" class="welearn-task-checkbox"
data-task-id="${task.id}"
data-title="${task.title}"
${task.isCompleted || taskIsIntro ? 'disabled' : ''}
${savedTaskIds.includes(task.id) && !task.isCompleted && !taskIsIntro ? 'checked' : ''}>
<span class="welearn-task-title">${task.title}</span>
${task.isCompleted
? '<span class="welearn-task-badge">✓ 已完成</span>'
: taskIsIntro
? '<span class="welearn-task-badge intro">◇ 无需填写</span>'
: '<span class="welearn-task-badge pending">○ 待完成</span>'}
</label>
`}).join('')}
</div>
</div>
`;
});
return tasksHtml;
};
/** 显示任务选择模态框 */
const showTaskSelectorModal = async (forceRefresh = false) => {
const currentCourseId = getCourseId();
const currentCourseName = getCourseName();
const cache = loadCourseDirectoryCache();
const tasksCache = loadBatchTasksCache();
// 检查是否有可用缓存且课程匹配
const hasCacheForCurrentCourse = cache && cache.courseId === currentCourseId && cache.tasks?.length > 0;
const courseIdMismatch = cache && cache.courseId && cache.courseId !== currentCourseId;
// 创建模态框
const overlay = document.createElement('div');
overlay.className = 'welearn-modal-overlay welearn-task-selector';
// 显示加载动画
const showLoading = () => {
overlay.innerHTML = `
<div class="welearn-modal welearn-task-modal">
<h3>📖 课程目录 - ${currentCourseName}</h3>
<div class="welearn-loading-container">
<div class="welearn-loading-spinner"></div>
<p>正在读取课程目录...</p>
</div>
</div>
`;
};
// 渲染任务列表
const renderTaskList = (availableTasks, showMismatchWarning = false, isFromCache = false) => {
if (availableTasks.length === 0) {
overlay.innerHTML = `
<div class="welearn-modal welearn-task-modal">
<h3>📖 课程目录 - ${currentCourseName}</h3>
<p class="welearn-task-desc" style="color: #ef4444;">未找到可执行的任务</p>
<div class="welearn-modal-footer">
<button type="button" class="welearn-modal-cancel">关闭</button>
<button type="button" class="welearn-btn-refresh">🔄 重新读取</button>
</div>
</div>
`;
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 = `
<div class="welearn-modal welearn-task-modal">
<h3>📖 课程目录 - ${currentCourseName}</h3>
${showMismatchWarning ? `
<p class="welearn-warning-text">⚠️ 缓存的课程与当前课程不匹配,建议重新读取</p>
` : ''}
<p class="welearn-task-desc">
勾选要执行的任务,然后点击「⚡ 批量执行」按钮开始。
${isFromCache ? `<span class="welearn-cache-time">(缓存于 ${cacheTime})</span>` : ''}
</p>
<div class="welearn-task-actions-top">
<button type="button" class="welearn-btn-select-all">全选未完成</button>
<button type="button" class="welearn-btn-deselect-all">取消全选</button>
<button type="button" class="welearn-btn-refresh">🔄 重新读取目录</button>
<button type="button" class="welearn-btn-refresh-status">🔃 刷新完成状态</button>
</div>
<div class="welearn-task-container">
${tasksHtml}
</div>
<div class="welearn-task-summary">
已选择: <span class="welearn-selected-count">0</span> 个任务
</div>
<div class="welearn-modal-footer">
<button type="button" class="welearn-modal-cancel">取消</button>
<button type="button" class="welearn-modal-confirm" disabled>✓ 确认选择</button>
</div>
</div>
`;
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 = `
<div class="welearn-modal welearn-recovery-modal">
<h3>📋 发现未完成的批量任务</h3>
<p>
上次选择了 <strong>${tasksCache.tasks.length}</strong> 个任务
${!isSameCourse ? `<br><span class="welearn-warning-text">⚠️ 来自其他课程: ${tasksCache.courseName}</span>` : ''}
</p>
<p class="welearn-cache-time">保存于: ${cacheTime}</p>
<div class="welearn-modal-footer">
<button type="button" class="welearn-modal-cancel">忽略</button>
<button type="button" class="welearn-modal-confirm">恢复任务列表</button>
</div>
</div>
`;
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 = `
<span>批量执行中: <span class="progress-text">${current + 1}/${total}</span></span>
<button type="button" class="welearn-batch-stop" style="margin-left: 12px; background: rgba(239, 68, 68, 0.3); border: 1px solid rgba(239, 68, 68, 0.5); color: #f87171; padding: 4px 12px; border-radius: 8px; cursor: pointer; font-weight: 600;">停止</button>
`;
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 = `
<div style="font-size: 14px; color: #aaa; margin-bottom: 8px;">⏱️ ${title}</div>
<div style="font-size: 36px; font-weight: bold; margin-bottom: 8px;" class="countdown-number">${remainingSeconds}</div>
<div style="font-size: 12px; color: #888;">${subtitle}</div>
`;
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 = `
<div class="welearn-drag-zone"></div>
<h3>WeLearn-Go<span class="welearn-version">v${VERSION}</span><a class="welearn-update-hint" href="${UPDATE_CHECK_URL}" target="_blank" style="display:none;"></a></h3>
<button class="welearn-minify" title="折叠">●</button>
<div class="welearn-body">
<div class="welearn-actions">
<button type="button" class="welearn-start">一键填写本页问题</button>
<button type="button" class="welearn-toggle-btn welearn-submit-toggle">自动提交</button>
<button type="button" class="welearn-toggle-btn welearn-mistake-toggle">智能添加小错误</button>
<button type="button" class="welearn-scan-btn">📖 查看目录</button>
<button type="button" class="welearn-batch-btn">⚡ 批量执行</button>
</div>
<div class="welearn-stats-row">
<span class="welearn-error-stats">错误统计:暂无数据</span>
<button type="button" class="welearn-clear-stats">清空</button>
</div>
<div class="welearn-weights-row">
<span class="welearn-weights-label">错误比例:</span>
<label>
<span class="welearn-weight-text" style="margin:0px!important;">0个</span>
<input type="text" inputmode="numeric" class="welearn-weight-0" value="50">
<span class="welearn-weight-text" style="margin:0px!important;">%</span>
</label>
<label>
<span class="welearn-weight-text" style="margin:0px!important;">1个</span>
<input type="text" inputmode="numeric" class="welearn-weight-1" value="35">
<span class="welearn-weight-text" style="margin:0px!important;">%</span>
</label>
<label>
<span class="welearn-weight-text" style="margin:0px!important;">2个</span>
<input type="text" inputmode="numeric" class="welearn-weight-2" value="15">
<span class="welearn-weight-text" style="margin:0px!important;">%</span>
</label>
<span class="welearn-weights-error">总和必须为 100%</span>
</div>
<div class="welearn-duration-row">
<span class="welearn-duration-label">刷时长:</span>
<div class="welearn-duration-options">
<button type="button" class="welearn-duration-btn" data-mode="off">⏭️ 关</button>
<button type="button" class="welearn-duration-btn" data-mode="fast">🚀 快 30-60s</button>
<button type="button" class="welearn-duration-btn active" data-mode="standard">🐢 慢 60-120s</button>
</div>
</div>
<div class="welearn-footer">
<span>拖动顶部可移动,点击圆点可折叠</span>
<a href="https://github.com/noxsk/WeLearn-Go" target="_blank" rel="noopener noreferrer">项目地址</a>
<button type="button" class="welearn-support">请我喝一杯咖啡 ☕️</button>
</div>
</div>
<div class="welearn-handle"></div>
`;
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 `<span class="welearn-error-item"><b>${e.type}</b> ${highlighted}</span>`;
}).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 = `
<div class="welearn-modal">
<h3>赞助支持</h3>
<p>如果你能请我喝一杯咖啡,我将不胜感激!</p>
<div class="welearn-donate-grid">
<a href="${DONATE_IMAGE_URL}" target="_blank" rel="noopener noreferrer">
<img src="${imageUrl}" alt="微信赞赏码">
<span>微信</span>
</a>
</div>
<div class="welearn-modal-footer">
<span class="welearn-badge">感谢支持</span>
<button type="button" class="welearn-modal-close">关闭</button>
</div>
</div>
`;
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 = `
<div class="welearn-modal">
<h3>使用须知</h3>
<p>本脚本仅供学习使用,请在 24H 内删除。对使用该脚本产生的后果均由使用者承担。</p>
<p>本脚本始终保持免费,如购买所得说明被骗了。</p>
<div class="welearn-guide">
<p>简易使用教程:</p>
<ol>
<li>进入对应课程练习页面(当前已适配:领航大学英语综合教程1)。</li>
<li>点击页面左侧的「一键填写」按钮自动填写答案。</li>
<li>如需自动提交,可在面板中勾选「自动提交」。</li>
</ol>
</div>
<div class="welearn-modal-footer">
<span class="welearn-badge">适配:领航大学英语综合教程1</span>
<button type="button" class="welearn-modal-close">我已知晓</button>
</div>
</div>
`;
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();
}
})();