-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathWeLearn-Go.user.js
More file actions
9594 lines (8577 loc) · 365 KB
/
WeLearn-Go.user.js
File metadata and controls
9594 lines (8577 loc) · 365 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// ==UserScript==
// @name WeLearn-Go
// @namespace https://github.com/noxsk/WeLearn-Go
// @supportURL https://github.com/noxsk/WeLearn-Go/issues
// @version 0.10.1
// @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
// @require https://unpkg.com/lucide@latest
// ==/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 = 360; // 面板最小宽度
const PANEL_MIN_HEIGHT = 180; // 面板最小高度
const PANEL_MAX_WIDTH = 540; // 面板最大宽度
const PANEL_MAX_HEIGHT = 460; // 面板最大高度
const PANEL_DEFAULT_WIDTH = 360; // 面板默认宽度
const PANEL_DEFAULT_HEIGHT = 450; // 面板默认高度
const MINIMIZED_PANEL_WIDTH = 220; // 最小化时的面板宽度
const MINIMIZED_PANEL_HEIGHT = 50; // 最小化时的面板高度
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 DEBUG_MODE_KEY = 'welearn_debug_mode'; // 调试模式存储键
const LOGO_TAP_TRIGGER_COUNT = 5; // 顶部 logo 连击触发次数
const LOGO_TAP_WINDOW_MS = 2200; // 顶部 logo 连击时间窗口
const UPDATE_CHECK_URLS = [
'https://fastly.jsdelivr.net/gh/noxsk/WeLearn-Go@New-UI/WeLearn-Go.user.js',
'https://cdn.jsdelivr.net/gh/noxsk/WeLearn-Go@New-UI/WeLearn-Go.user.js',
'https://raw.githubusercontent.com/noxsk/WeLearn-Go/refs/heads/New-UI/WeLearn-Go.user.js',
]; // 版本检查地址(含中国大陆可用加速)
const UPDATE_INSTALL_URL = UPDATE_CHECK_URLS[0];
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; // 最新版本号
let batchStopResetTimer = null; // 批量停止按钮二次确认计时器
let debugModeState = null; // 调试模式状态
let logoTapCount = 0; // 顶部 logo 连击计数
let logoTapTimer = null; // 顶部 logo 连击计时器
/** 判断是否为 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+([^\s]+)/i);
return match ? match[1] : null;
};
/** 带超时的版本请求(避免个别线路卡住) */
const fetchScriptVersion = async (url, timeout = 8000) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(`${url}?_=${Date.now()}`, {
cache: 'no-cache',
headers: { Accept: 'text/plain' },
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const content = await response.text();
const version = extractVersionFromScript(content);
if (!version) throw new Error('parse-version-failed');
return { version, source: url };
} finally {
clearTimeout(timer);
}
};
/** 检查是否有新版本 */
const checkForUpdates = async () => {
const cached = (() => {
try {
const raw = localStorage.getItem(UPDATE_CHECK_CACHE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
})();
if (cached?.version && Date.now() - (cached.timestamp || 0) < UPDATE_CHECK_INTERVAL) {
latestVersion = cached.version;
showUpdateHint(cached.version, { fromCache: true });
return;
}
let remoteResult = null;
for (const url of UPDATE_CHECK_URLS) {
try {
remoteResult = await fetchScriptVersion(url);
break;
} catch (error) {
console.warn('[WeLearn-Go] 版本检查线路失败:', url, error);
}
}
if (!remoteResult?.version) {
showUpdateHint(VERSION, { checkFailed: true });
return;
}
latestVersion = remoteResult.version;
localStorage.setItem(UPDATE_CHECK_CACHE_KEY, JSON.stringify({
version: remoteResult.version,
timestamp: Date.now(),
source: remoteResult.source,
}));
console.log('[WeLearn-Go] 版本检查:', {
current: VERSION,
remote: remoteResult.version,
source: remoteResult.source,
});
showUpdateHint(remoteResult.version);
};
/** 显示更新提示 */
const showUpdateHint = (remoteVersion, options = {}) => {
const hint = document.querySelector('.welearn-update-hint');
const versionBadge = document.querySelector('.welearn-footer-version');
const miniBadge = document.querySelector('.welearn-minimized-update');
if (!hint && !versionBadge && !miniBadge) return;
const hasUpdate = compareVersions(remoteVersion, VERSION) > 0;
const { checkFailed = false } = options;
const text = hasUpdate ? `🆕 Update v${remoteVersion}` : `当前 v${remoteVersion}`;
const title = checkFailed
? `当前版本 v${VERSION}(版本检查失败,点击可手动更新)`
: hasUpdate
? `发现新版本 v${remoteVersion},点击更新`
: `已是最新版本 v${remoteVersion}`;
if (hint) {
hint.textContent = text;
hint.title = title;
hint.classList.toggle('is-update', hasUpdate);
hint.classList.toggle('is-current', !hasUpdate);
hint.style.display = 'inline-flex';
}
if (versionBadge) {
const versionText = versionBadge.querySelector('.welearn-version-text');
if (versionText) {
versionText.textContent = `v${remoteVersion}`;
} else {
versionBadge.textContent = `v${remoteVersion}`;
}
versionBadge.title = title;
versionBadge.classList.toggle('is-update', hasUpdate);
versionBadge.classList.toggle('is-current', !hasUpdate);
}
if (miniBadge) {
miniBadge.textContent = hasUpdate ? `Update v${remoteVersion}` : `v${remoteVersion}`;
miniBadge.title = title;
}
};
/**
* 高亮显示两个字符串的差异
* 返回带 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);