// ==UserScript==
// @name 自动填写助手
// @namespace http://tampermonkey.net/
// @version 2.0.6
// @description 自动填写助手-个人使用
// @author Edison
// @match https://learnfront.cyjy.net/task/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=cyjy.net
// @require https://fastly.jsdelivr.net/npm/zh-address-parse@1.3.16/dist/zh-address-parse.min.js
// @require https://cdn.sheetjs.com/xlsx-0.20.0/package/dist/xlsx.full.min.js
// @grant none
// @license MIT
// ==/UserScript==
/**
* 科目管理器
*/
const SubjectManager = {
KEY: "auto_fill_subject",
getSubject: function () {
const saved = localStorage.getItem(this.KEY);
return saved || "物理"; // 默认物理
},
setSubject: function (subject) {
localStorage.setItem(this.KEY, subject);
this.updateDisplay();
UI.notification(`已切换到 ${subject} 模式`);
},
toggleSubject: function () {
const current = this.getSubject();
const next = current === "物理" ? "化学" : "物理";
this.setSubject(next);
},
getOffset: function () {
return this.getSubject() === "物理" ? 0 : 1;
},
updateDisplay: function () {
const btn = document.getElementById("subject-toggle-btn");
if (btn) {
const subject = this.getSubject();
btn.innerText = `当前: ${subject}`;
btn.style.backgroundColor = subject === "物理" ? "#1890ff" : "#52c41a";
btn.style.borderColor = subject === "物理" ? "#1890ff" : "#52c41a";
}
},
};
/**
* 选择器配置 - 使用getter动态获取offset
*/
const SELECTORS = {
editor: '[id^="w-e-textarea-"]',
scoreInput: 'input[role="spinbutton"]',
get addressSelectedTag() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
10 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > div > div > div > div`;
},
// 动态路径 - 使用getter函数
get abilitySelect() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
2 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > span`;
},
get yearSelect() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
5 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > div > div.ant-select-selector`;
},
get yearSelectedTag() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
5 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > div > div.ant-select-selector > div`;
},
get sceneContainer() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
6 + offset
}) > div > div.ant-col.ant-form-item-control > div > div`;
},
get categorySelect() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
7 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > div > div.ant-select-selector`;
},
get sourceBtn() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
8 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > div > div.ant-row.ant-form-item.Item > div > div > div > button`;
},
get sourceInput() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
8 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > div > div.ant-row.ant-form-item.Item > div > div > div > div > div > div > div > div.ant-popover-inner > div.ant-popover-inner-content > input`;
},
get sourceConfirm() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
8 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > div > div.ant-row.ant-form-item.Item > div > div > div > div > div > div > div > div.ant-popover-inner > div.ant-popover-inner-content > div > button.ant-btn.ant-btn-primary.ant-btn-sm.ml10`;
},
get sourceDisplay() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
9 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > span`;
},
get addressTrigger() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
10 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > div`;
},
get questionTypeContainer() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
11 + offset
}) > div > div.ant-col.ant-form-item-control > div > div`;
},
get groupSelect() {
const offset = SubjectManager.getOffset();
return `#app > div > div.resizable-layout > div.right-panel > div > form > div > div:nth-child(${
13 + offset
}) > div > div.ant-col.ant-form-item-control > div > div > div > div.ant-select-selector`;
},
};
/**
* 网络请求监控器
*/
const NetworkMonitor = {
pendingRequests: 0,
isPageLoaded: false,
callbacks: [],
init: function () {
// 监听 XMLHttpRequest
const originalXHROpen = XMLHttpRequest.prototype.open;
const originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (...args) {
this._url = args[1];
return originalXHROpen.apply(this, args);
};
XMLHttpRequest.prototype.send = function (...args) {
NetworkMonitor.pendingRequests++;
// console.log(
// `[NetworkMonitor] XHR 请求开始: ${this._url}, 待处理: ${NetworkMonitor.pendingRequests}`
// );
this.addEventListener("loadend", () => {
NetworkMonitor.pendingRequests--;
// console.log(
// `[NetworkMonitor] XHR 请求完成: ${this._url}, 待处理: ${NetworkMonitor.pendingRequests}`
// );
NetworkMonitor.checkAndNotify();
});
return originalXHRSend.apply(this, args);
};
// 监听 fetch
const originalFetch = window.fetch;
window.fetch = function (...args) {
NetworkMonitor.pendingRequests++;
// const url =
// typeof args[0] === "string" ? args[0] : args[0]?.url || "unknown";
// console.log(
// `[NetworkMonitor] Fetch 请求开始: ${url}, 待处理: ${NetworkMonitor.pendingRequests}`
// );
return originalFetch.apply(this, args).finally(() => {
NetworkMonitor.pendingRequests--;
// console.log(
// `[NetworkMonitor] Fetch 请求完成: ${url}, 待处理: ${NetworkMonitor.pendingRequests}`
// );
NetworkMonitor.checkAndNotify();
});
};
// 监听页面加载完成
if (document.readyState === "complete") {
this.isPageLoaded = true;
} else {
window.addEventListener("load", () => {
this.isPageLoaded = true;
// console.log("[NetworkMonitor] 页面加载完成");
this.checkAndNotify();
});
}
},
checkAndNotify: function () {
if (this.isPageLoaded && this.pendingRequests === 0) {
console.log("[NetworkMonitor] ✅ 所有网络请求已完成,页面已就绪");
this.callbacks.forEach((cb) => cb());
this.callbacks = [];
}
},
onReady: function (callback) {
if (this.isPageLoaded && this.pendingRequests === 0) {
callback();
} else {
this.callbacks.push(callback);
}
},
isReady: function () {
return this.isPageLoaded && this.pendingRequests === 0;
},
};
/**
* UI 工具类
*/
const UI = {
// 等待元素,超时会打印具体的 selector
wait: function (selectorOrElement, timeout = 5000) {
return new Promise((resolve, reject) => {
// 1. 定义一个通用的检查逻辑
const check = () => {
try {
// 情况A: 传入的是函数(这是解决复杂路径的关键)
if (typeof selectorOrElement === "function") {
const result = selectorOrElement();
if (result instanceof Element) return result;
}
// 情况B: 传入的是字符串选择器
else if (typeof selectorOrElement === "string") {
const el = document.querySelector(selectorOrElement);
if (el) return el;
}
// 情况C: 传入的已经是元素(通常不走这里,除非逻辑特殊)
else if (selectorOrElement instanceof Element) {
return selectorOrElement;
}
// eslint-disable-next-line no-unused-vars
} catch (e) {
// 忽略查找过程中的报错(比如父节点暂时还没出来导致的报错)
return null;
}
return null;
};
// 2. 第一次立即检查
const immediateResult = check();
if (immediateResult) return resolve(immediateResult);
// 3. 开始轮询
const startTime = Date.now();
const timer = setInterval(() => {
const el = check();
if (el) {
clearInterval(timer);
resolve(el);
}
if (Date.now() - startTime > timeout) {
clearInterval(timer);
console.error(
`❌ [UI.wait] 超时!找不到元素:\n${selectorOrElement.toString()}`
);
reject(new Error(`Timeout: 找不到元素`));
}
}, 200);
});
},
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
click: async function (selectorOrElement, timeout = 5000) {
try {
const el = await this.wait(selectorOrElement, timeout);
el.focus && el.focus();
["mousedown", "mouseup"].forEach((evt) => {
el.dispatchEvent(
new MouseEvent(evt, { bubbles: true, cancelable: true, view: window })
);
});
el.click && el.click();
return el;
} catch (e) {
// 捕获 wait 抛出的错误,避免红屏,只在控制台显示
console.warn(`⚠️ [UI.click] 跳过操作,原因: ${e.message}`);
}
},
type: async function (selectorOrElement, value) {
try {
const el = await this.wait(selectorOrElement);
el.value = "";
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
).set;
if (nativeSetter) {
nativeSetter.call(el, value);
} else {
el.value = value;
}
["input", "change"].forEach((evt) =>
el.dispatchEvent(new Event(evt, { bubbles: true }))
);
} catch (e) {
console.warn(`⚠️ [UI.type] 输入失败: ${e.message}`);
}
},
clickText: async function (selector, text, timeout = 5000) {
try {
await this.wait(selector, timeout);
let retries = 10;
while (retries > 0) {
const elements = document.querySelectorAll(selector);
for (let el of elements) {
if (el.innerText.trim() === text) {
await this.click(el);
return;
}
}
await this.sleep(200);
retries--;
}
console.warn(`⚠️ [UI.clickText] 在 ${selector} 中未找到文本 "${text}"`);
} catch (e) {
console.error(e.message);
}
},
selectTreeBySearch: async function (triggerDiv, searchText) {
const input =
triggerDiv.querySelector("input") ||
triggerDiv.querySelector(".ant-select-selection-search-input");
if (input) {
await UI.type(input, searchText);
await UI.sleep(500);
await UI.clickText(
".ant-select-tree-node-content-wrapper",
searchText,
7000
);
} else {
console.error("❌ [TreeSearch] 树形选择器中找不到输入框 input");
}
},
selectAntOption: async function (targetText) {
await UI.sleep(500);
let dropdown = null;
let maxRetries = 10;
while (maxRetries > 0) {
const allDropdowns = document.querySelectorAll(
".ant-select-dropdown:not(.ant-select-dropdown-hidden):not([style*='display: none'])"
);
if (allDropdowns.length > 0) {
dropdown = allDropdowns[allDropdowns.length - 1];
if (dropdown.innerText.trim() !== "") break;
}
await UI.sleep(200);
maxRetries--;
}
if (!dropdown) {
console.error(
"❌ [SelectOption] 未找到任何可见的下拉菜单 (ant-select-dropdown)"
);
return;
}
const listHolder = dropdown.querySelector(".rc-virtual-list-holder-inner");
const options = listHolder
? listHolder.querySelectorAll(".ant-select-item-option")
: dropdown.querySelectorAll(".ant-select-item-option");
let found = false;
for (let opt of options) {
const content = opt.querySelector(".ant-select-item-option-content");
const text = content ? content.innerText : opt.innerText;
if (text.trim() === targetText) {
await UI.click(opt);
found = true;
return;
}
}
if (!found) {
console.warn(`⚠️ [SelectOption] 下拉菜单中未找到选项: "${targetText}"`);
}
},
// 通知容器数组,用于管理多个通知
_notifications: [],
notification: function (text) {
const div = document.createElement("div");
div.innerText = text;
div.style.cssText =
"position: fixed; left: 50%; transform: translateX(-50%); background: #bfff94ff; padding: 10px 20px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 10000; font-size: 14px; transition: top 0.3s ease;";
// 添加到通知数组
this._notifications.push(div);
// 计算位置(从上往下堆叠,起始位置 20px)
this._updateNotificationPositions();
document.body.appendChild(div);
// 3秒后移除
setTimeout(() => {
div.style.opacity = "0";
div.style.transition = "opacity 0.3s ease, top 0.3s ease";
setTimeout(() => {
div.remove();
// 从数组中移除
const index = this._notifications.indexOf(div);
if (index > -1) {
this._notifications.splice(index, 1);
// 更新剩余通知的位置
this._updateNotificationPositions();
}
}, 300);
}, 3000);
},
_updateNotificationPositions: function () {
let topOffset = 55; // 起始位置
this._notifications.forEach((notif) => {
notif.style.top = topOffset + "px";
topOffset += notif.offsetHeight + 10; // 每个通知之间间隔10px
});
},
simulateBackspace: function (editor) {
editor.focus();
const keyEventConfig = {
key: "Backspace",
code: "Backspace",
keyCode: 8,
which: 8,
bubbles: true,
cancelable: true,
view: window,
};
// 1. KeyDown
const down = new KeyboardEvent("keydown", keyEventConfig);
editor.dispatchEvent(down);
const beforeInput = new InputEvent("beforeinput", {
inputType: "deleteContentBackward",
data: null,
bubbles: true,
cancelable: true,
view: window,
});
editor.dispatchEvent(beforeInput);
// if (beforeInputResult) {
// document.execCommand("delete", false, null);
// } else {
// document.execCommand("delete", false, null);
// }
// editor.dispatchEvent(
// new InputEvent("input", {
// inputType: "deleteContentBackward",
// bubbles: true,
// cancelable: false,
// view: window,
// })
// );
// 5. KeyUp
const up = new KeyboardEvent("keyup", keyEventConfig);
editor.dispatchEvent(up);
// 6. Extra Events
// editor.dispatchEvent(new Event("change", { bubbles: true }));
// editor.dispatchEvent(new Event("compositionend", { bubbles: true }));
},
};
/**
* 配置管理器
*/
const ConfigManager = {
KEY: "auto_fill_config",
data: { titleRules: [], sourceRules: [] },
init: function () {
const saved = localStorage.getItem(this.KEY);
if (saved) {
try {
this.data = JSON.parse(saved);
this.updateStatus();
// eslint-disable-next-line no-unused-vars
} catch (e) {
/* empty */
}
}
},
save: function () {
localStorage.setItem(this.KEY, JSON.stringify(this.data));
UI.notification("配置已保存");
},
importFromExcel: function (file) {
if (!window.XLSX) {
UI.notification("XLSX 库尚未加载完成,请稍后...");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result);
// eslint-disable-next-line no-undef
const workbook = XLSX.read(data, { type: "array" });
const parseSheet = (sheetName) => {
if (!workbook.Sheets[sheetName]) return [];
// eslint-disable-next-line no-undef
return XLSX.utils
.sheet_to_json(workbook.Sheets[sheetName], { header: 1 })
.slice(1)
.filter((row) => row[0] && row[1])
.map((row) => ({
key: String(row[0]).trim(),
value: String(row[1]).trim(),
}));
};
if (workbook.SheetNames.length >= 1)
this.data.titleRules = parseSheet(workbook.SheetNames[0]);
if (workbook.SheetNames.length >= 2)
this.data.sourceRules = parseSheet(workbook.SheetNames[1]);
this.save();
this.updateStatus();
} catch (err) {
console.error("❌ Excel 解析失败", err);
UI.notification("Excel 解析失败");
}
};
reader.readAsArrayBuffer(file);
},
updateStatus: function () {
const statusEl = document.getElementById("config-status-display");
if (statusEl) {
statusEl.innerText = `(题干:${this.data.titleRules.length}, 来源:${this.data.sourceRules.length})`;
statusEl.style.color = "#265c0cff";
}
},
matchRule: function (text, ruleKey) {
if (!text || !ruleKey) return false;
const conditions = ruleKey.trim().split(/\s+/);
for (let condition of conditions) {
if (!condition) continue;
if (condition.startsWith("&")) {
if (!text.includes(condition.substring(1))) return false;
} else if (condition.startsWith("!")) {
if (text.includes(condition.substring(1))) return false;
} else {
if (!text.includes(condition)) return false;
}
}
return true;
},
getMatchedScenes: function (title, source) {
const scenes = new Set();
const check = (text, rules) => {
if (text)
rules.forEach(
(rule) => this.matchRule(text, rule.key) && scenes.add(rule.value)
);
};
if (title) {
check(title, this.data.titleRules);
}
if (source) {
check(source, this.data.sourceRules);
}
return Array.from(scenes);
},
};
/**
* 业务逻辑集合
*/
const App = {
getFullContent: function (walker) {
let fullText = "";
let nodeMap = [];
let currentNode = walker.nextNode();
while (currentNode) {
const textContent = currentNode.textContent;
const length = textContent.length;
nodeMap.push({
node: currentNode,
start: fullText.length, // 该节点在全文中的起始索引
end: fullText.length + length, // 该节点在全文中的结束索引
});
fullText += textContent;
currentNode = walker.nextNode();
}
// 只取第一个逗号前的内容 (Why? User logic, keeping it but it seems risky for general purpose)
let firstText = fullText.split(",")[0];
firstText = firstText.split(",")[0];
return { fullText, firstText, nodeMap };
},
removeContent: async function (fullText, editor, nodeMap, customPattern) {
let targetStartIndex = -1;
let targetEndIndex = -1;
let textToRemove = "";
// 情况 A: 根据传入的 pattern 查找
// 这里的 fullText 包含了所有节点的内容,跨节点也能匹配到
targetStartIndex = fullText.indexOf(customPattern);
if (targetStartIndex === -1) {
return null;
}
targetEndIndex = targetStartIndex + customPattern.length;
textToRemove = customPattern;
// 3. 将全局索引转换回 DOM Range (Mapping back to Nodes)
let startContainer = null;
let startOffset = 0;
let endContainer = null;
let endOffset = 0;
for (const item of nodeMap) {
// 寻找开始节点
// 如果目标起始点落在当前节点范围内 (包含 start,不包含 end,除非是最后一个字符)
if (
!startContainer &&
targetStartIndex >= item.start &&
targetStartIndex < item.end
) {
startContainer = item.node;
startOffset = targetStartIndex - item.start;
}
// 寻找结束节点
// 结束点通常是开区间,所以可以是 item.end
if (
!endContainer &&
targetEndIndex > item.start &&
targetEndIndex <= item.end
) {
endContainer = item.node;
endOffset = targetEndIndex - item.start;
}
// 如果两头都找到了,提前结束循环
if (startContainer && endContainer) break;
}
// 兜底:如果结束点正好是全文末尾,且因为边界问题没匹配到(很少见,但为了稳健)
if (
!endContainer &&
nodeMap.length > 0 &&
targetEndIndex === fullText.length
) {
const lastItem = nodeMap[nodeMap.length - 1];
endContainer = lastItem.node;
endOffset = lastItem.node.textContent.length;
}
if (!startContainer || !endContainer) {
console.error("❌ 计算 DOM 选区失败,索引越界或节点丢失");
return null;
}
// 4. 执行选区和删除
let hasChange = false;
try {
editor.focus();
const sel = window.getSelection();
const range = document.createRange();
// 核心:设置跨节点的 Range
range.setStart(startContainer, startOffset);
range.setEnd(endContainer, endOffset);
sel.removeAllRanges();
sel.addRange(range);
await UI.sleep(500);
UI.simulateBackspace(editor);
await UI.sleep(500);
console.log(`✅ 已自动清除: "${textToRemove}" (跨节点处理完成)`);
hasChange = true;
} catch (e) {
console.error("❌ 删除操作执行出错:", e);
}
return hasChange;
},
clearContent: async function (customPattern = null) {
const editor = document.querySelector(SELECTORS.editor);
if (!editor) {
console.error(
`❌ [clearContent] 找不到题干编辑器,Selector: ${SELECTORS.editor}`
);
return null;
}
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null,
false
);
const { firstText, nodeMap } = this.getFullContent(walker);
if (customPattern) {
await this.removeContent(firstText, editor, nodeMap, customPattern);
}
},
getAndCleanTitle: async function (remove = false) {
const editor = document.querySelector(SELECTORS.editor);
if (!editor) {
console.error(
`❌ [getAndCleanTitle] 找不到题干编辑器,Selector: ${SELECTORS.editor}`
);
return null;
}
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null,
false
);
const { firstText, nodeMap } = this.getFullContent(walker);
const regex = /(?:([^)]*)|\([^)]*\))/g;
let match;
while ((match = regex.exec(firstText)) !== null) {
const matchedStr = match[0]; // 例如 "(2023北京)"
const extracted = matchedStr.trim().replace(/^[((]|[))]$/g, ""); // 例如 "2023北京"
// --- 校验逻辑 ---
const hasYear = /(?:19|20)\d{2}/.test(extracted);
let hasRegion = false;
if (typeof App !== "undefined" && App.paraseAddr) {
const inner = extracted.trim();
const addr = App.paraseAddr(inner);
if (addr && (addr.province || addr.city || addr.area)) {
hasRegion = true;
}
}
if (hasYear || hasRegion) {
// 1. 计算结束位置
const matchEndIndex = match.index + matchedStr.length;
// 2. 截取前缀:从节点文本开头(0) 到 括号结束的位置
// 如果原文是 "1. (2023北京) 题目...",这里 prefix 就是 "1. (2023北京)"
const prefixContent = firstText.substring(0, matchEndIndex);
// 准备返回的数据对象
const resultData = {
title: extracted, // "2023北京"
prefix: prefixContent, // "1. (2023北京)"
};
if (remove) {
await this.removeContent(firstText, editor, nodeMap, prefixContent);
console.log(
`✅ [匹配成功] 提取到: "${extracted}", 前缀: "${prefixContent}"`
);
}
return resultData;
}
}
if (remove) UI.notification("未找到包含年份或地区的题干标签");
return null;
},
getScore: async function () {
const el = document.querySelector(SELECTORS.scoreInput);
if (!el) {
console.error(
`❌ [getScore] 找不到星级输入框,Selector: ${SELECTORS.scoreInput}`
);
return 0;
}
const val = parseFloat(el.value);
if (!val) {
UI.notification("⚠️ 请先填写题目难度星级");
throw new Error("无难度星级"); // 中断流程
}
return val;
},
selectCategory: async function (score) {
let category = "思维拓展题";
if (score <= 0.4) category = "基础题";
else if (score <= 0.6) category = "综合运用题";
await UI.click(SELECTORS.categorySelect);
await UI.selectAntOption(category);
},
selectYear: async function (title, sourceFrom) {
// 含义:匹配 (19xx 或 20xx) 或者 (23, 24, 25, 26)
const yearRegex = /\b((19|20)\d{2}|2[3-6])\b/g;
let availableYears = [
...(title.match(yearRegex) || []),
...(sourceFrom.match(yearRegex) || []),
];
// 标准化年份 (将 2位 转换为 4位)
// 如果是 23, 24 这种,自动补全为 2023, 2024
availableYears = availableYears.map((year) => {
if (year.length === 2) {
return "20" + year;
}
return year;
});
// 去重 (此时都是4位年份了)
availableYears = [...new Set(availableYears)];
// 检查已选
const selectedTag = document.querySelector(SELECTORS.yearSelectedTag);
if (selectedTag) {
// 提取已选中的年份(假设已选标签中显示的肯定是4位年份)
const existing = selectedTag.textContent.match(/\b(19|20)\d{2}\b/);
if (existing)
// 过滤掉已经选中的年份
availableYears = availableYears.filter((y) => y !== existing[0]);
}
// 再次去重 (保险起见)
availableYears = [...new Set(availableYears)];
if (availableYears.length > 0) {
await UI.click(SELECTORS.yearSelect);
for (const year of availableYears) {
await UI.sleep(1000);
// 这里传入的 year 已经是 4 位数,例如 "2024",组合后为 "2024年"
await UI.selectAntOption(year + "年");
}
}
},
paraseAddr: function (title) {
if (typeof ZhAddressParse === "undefined") {
console.error("❌ ZhAddressParse 库未定义,无法解析地址");
return;
}
// 清洗字符串 去掉数字和特殊字符
title = title.replace(/[^\p{L}\s]/gu, "");
// eslint-disable-next-line no-undef
const addr = ZhAddressParse(title);
return addr;
},
selectAddress: async function (title, sourceFrom) {
let addrSet = new Set();
// 查询已存在的地址
const allSelectedTag = document.querySelectorAll(
SELECTORS.addressSelectedTag
);
// 提取已选中的地址
for (const tag of allSelectedTag) {
const existing = tag.textContent;
if (existing) {
addrSet.add(existing);
}
}
for (let item of [title, sourceFrom]) {
// 出现改编就跳过
if (item.indexOf("改编") != -1) {
continue;
}
const addr = App.paraseAddr(item);
if (addr.province) {
if (addr.province === "北京市") addr.province = "北京";
if (addr.province === "上海市") addr.province = "上海";
if (addr.province === "天津市") addr.province = "天津";
if (addr.province === "重庆市") addr.province = "重庆";
if (addr.province && !addrSet.has(addr.province)) {
addrSet.add(addr.province);
const trigger = await UI.click(SELECTORS.addressTrigger);
await UI.selectTreeBySearch(trigger, addr.province);
if (addr.city && !addrSet.has(addr.city)) {
addrSet.add(addr.city);
await UI.selectTreeBySearch(trigger, addr.city);
if (addr.area && !addrSet.has(addr.area)) {
addrSet.add(addr.area);
await UI.selectTreeBySearch(trigger, addr.area);
}
}
}
}
}
},
formatSmartTitle: function (text) {
if (!text) return "";
// 1. 正则捕获:前缀、年份1、年份2(可选)、后缀
// ^(.*?) -> 前缀 (Group 1)
// (\d{4}) -> 年份1 (Group 2)
// (?:-(\d{4}))? -> 年份2 (Group 3, 可选)
// \s*学年\s* -> 过滤掉"学年"
// (.*)$ -> 后缀 (Group 4, 包含学期信息)
const regex = /^(.*?)(\d{4})(?:[-—](\d{4}))?\s*学年\s*(.*)$/;
const match = text.match(regex);
// 没有匹配忽略类容
if (!match) return "";
const [, prefix, year1, year2, suffix] = match;
// 关键词检测:下学期、第二学期、下册
const isSecondSemester = /下学期|第二学期|下册/.test(text);
// 如果是下学期且存在第二个年份,则取year2,否则默认year1
let finalYear = isSecondSemester && year2 ? year2 : year1;
let otherText = prefix.trim() + suffix.trim();
const addr = App.paraseAddr(otherText);
const addrStr = addr.province + addr.city + addr.area;
// 没有地区忽略类容
if (!addrStr) return "";
let final = "";
for (let item of ["期中", "期末"]) {
if (otherText.indexOf(item) != -1) {
final = item;
break;
}
}
// 5. 返回结果
return `${finalYear}·${prefix.trim()}${final}`;
},
addSource: async function (title, sourceFrom) {
let result;
if (title && title.indexOf("改编") == -1) {
result = title;
} else if (sourceFrom && sourceFrom.indexOf("改编") == -1) {
result = sourceFrom;
} else {
return;
}
await UI.click(SELECTORS.sourceBtn);
await UI.type(SELECTORS.sourceInput, result);
await UI.sleep(100);
await UI.click(SELECTORS.sourceConfirm);
},
selectQuestionType: async function (firstText) {
const types = [
"基础题",
"压轴题",
"典型题",
"易错易混题",
"同步题",
"教材改编题",
"原创题",
"教材原题",
"跨学科",
"传统文化题",
"竞赛题",
"新考法",
"新情境",
"方法模型",
];
let forDeleteKeyWord = [];
for (let i = 0; i < types.length; i++) {
if (firstText.indexOf(types[i]) > -1) {
forDeleteKeyWord.push(types[i]);
await UI.click(
`${SELECTORS.questionTypeContainer} > span:nth-child(${i + 1})`
);
await UI.sleep(100);
}
}
for (let deleteItem of forDeleteKeyWord) {
await App.clearContent(deleteItem);
}
},
selectGroup: async function (score) {
let group = "提升";
if (score <= 0.4) group = "基础";
else if (score <= 0.6) group = "巩固";
await UI.click(SELECTORS.groupSelect);
await UI.selectAntOption(group);
},
selectScene: async function (title, sourceFrom) {
try {
let matchedScenes;
let titleCopy = "";
let sourceFromCopy = "";
if (title.indexOf("改编") == -1) {
titleCopy = title;
}
if (sourceFrom.indexOf("改编") == -1) {
sourceFromCopy = sourceFrom;
}
matchedScenes = ConfigManager.getMatchedScenes(titleCopy, sourceFromCopy);
const sceneOptions =
SubjectManager.getSubject() == "物理"
? [
"课前预习",
"同步练习",
"课后作业",
"单元测试",
"阶段练习",
"月考",
"期中",
"期末",
"专题练习",
"开学考试",
"假期作业",
"统考、联考题",
"高考真题",
"高考模拟",
]
: [
"课前预习",
"同步练习",
"课后作业",
"单元测试",
"阶段练习",
"月考",
"期中",
"期末",
"竞赛",
"专题练习",
"开学考试",
"假期作业",
"统考、联考题",
"高考真题",
"高考模拟",
"学业水平考试",
];
for (let scene of matchedScenes) {
const idx = sceneOptions.indexOf(scene);
if (idx > -1) {
console.log(`匹配到场景: ${scene}, idx: ${idx}`);
await UI.click(
`${SELECTORS.sceneContainer} > span:nth-child(${
idx + 1
}):not(.acitived)`,
200
);
await UI.sleep(200);
}
}
return sourceFrom;
// eslint-disable-next-line no-unused-vars
} catch (e) {
console.warn(
"⚠️ 无法获取当前【来源】字段,可能该元素不存在或 Selector 错误"
);
return "";
}
},
validateTitle: function (title) {
const addr = this.paraseAddr(title);
const hasLocation = addr.province || addr.city || addr.area;
const hasYear = /\d{4}/.test(title);
if (!hasLocation || !hasYear) {
const missing = [];
if (!hasLocation) missing.push("地区信息");
if (!hasYear) missing.push("年份信息");
UI.notification(`⚠️ 题目缺少${missing.join("和")},无法自动填写`);
throw new Error(`⚠️ 题目缺少${missing.join("和")},无法自动填写`); // 中断流程
}
return true;
},
findDeletedContent: function (original, modified) {
let deletedParts = [];
let i = 0; // 指向 original
let j = 0; // 指向 modified
let currentDeletion = "";
while (i < original.length) {
// 如果字符匹配,说明这部分没被删
if (j < modified.length && original[i] === modified[j]) {
// 如果之前有正在记录的删除片段,先存起来
if (currentDeletion !== "") {
deletedParts.push(currentDeletion);
currentDeletion = "";
}
i++;
j++;
} else {
// 如果不匹配,说明 original[i] 被删除了
currentDeletion += original[i];
i++;
}
}
// 处理末尾可能的删除部分
if (currentDeletion !== "") {
deletedParts.push(currentDeletion);
}
return deletedParts;
},
selectAbility: async function (score) {
let ability = "基础知识理解与运用能力";
if (score > 0.4) ability = "信息获取与加工能力";
await UI.clickText(SELECTORS.abilitySelect, ability);
},
startTask: async function () {
const btn = document.getElementById("my-smart-auto-btn");
const originalText = btn ? btn.innerText : "";
if (btn) {
btn.innerText = "执行中...";
btn.disabled = true;
btn.style.cursor = "not-allowed";
btn.style.opacity = "0.6";
}
try {
console.log("=== 开始执行自动填写 ===");
let stepCount = 0;
// 如果这里报错(例如没有星级),会自动跳到 catch
const score = await App.getScore();
UI.notification(`自动填写开始`);
// 获取逗号前的文本
const editor = document.querySelector(SELECTORS.editor);
if (!editor) {
console.error(
`❌ [getAndCleanTitle] 找不到题干编辑器,Selector: ${SELECTORS.editor}`
);
return null;
}
const walker = document.createTreeWalker(
editor,
NodeFilter.SHOW_TEXT,
null,
false
);
const { firstText } = App.getFullContent(walker);
// 保存原始内容(在任何删除操作之前)
const originalContent = editor.innerText || editor.textContent || "";
// 能力选择
if (SubjectManager.getSubject() == "化学") {
await App.selectAbility(score);
}
// 题类
await App.selectQuestionType(firstText);
// 自动删除题干并获取内容
const resultData = await App.getAndCleanTitle(true);
let title = resultData?.title || "";
await App.clearContent("(多选)");
await App.clearContent("(母题)");
await App.clearContent("(变式)");
await App.clearContent("(多选)");
await App.clearContent("(母题)");
await App.clearContent("(变式)");
await App.clearContent("多选");
await App.clearContent("母题");
await App.clearContent("变式");
// 获取删除后的内容
const modifiedContent = editor.innerText || editor.textContent || "";
// 使用 findDeletedContent 找出被删除的内容
const deletedParts = App.findDeletedContent(
originalContent.trim(),
modifiedContent.trim()
);
// 更新显示
const titleDisplay = document.getElementById("extracted-title-display");
if (titleDisplay) {
let displayHTML = "";
// 显示被删除的内容(红色)
if (deletedParts && deletedParts.length > 0) {
const deletedText = deletedParts.join(" | ").replaceAll("\n", "");
displayHTML += `已清理: ${deletedText}
`;
}
// 显示题干
if (title) {
// 替换年份后的零宽字符或其他不可见字符为 ·
title = title.replace(/(\d{4})[\uFEFF\u200B\u200C\u200D\s]+/g, "$1·");
displayHTML += `题干: ${title}`;
} else {
displayHTML += "题干: 未找到";
}
titleDisplay.innerHTML = displayHTML;
}
// 录入源
const sourceEl = await UI.wait(SELECTORS.sourceDisplay, 3000);
const rawSourceFrom = sourceEl.textContent;
const sourceFrom = App.formatSmartTitle(rawSourceFrom);
// 地区
await App.selectAddress(title, rawSourceFrom);
stepCount++;
// 场景
await App.selectScene(title, rawSourceFrom);
stepCount++;
// 题目分类
await App.selectCategory(score);
stepCount++;
await UI.sleep(2000);
// 试题年份
await App.selectYear(title, sourceFrom);
stepCount++;
await UI.sleep(1000);
// 来源
await App.addSource(title, sourceFrom);
stepCount++;
// 分组 (有实验班的才选)
if (rawSourceFrom && rawSourceFrom.indexOf("实验班") !== -1) {
await App.selectGroup(score);
stepCount++;
}
console.log(`✅ 自动填写完成,共 ${stepCount} 步`);
UI.notification(`自动填写完成,执行 ${stepCount} 步`);
} catch (e) {
if (e.message !== "无难度星级") {
console.error("❌ 任务中断:", e);
UI.notification(`自动填写失败,${e.message}`);
}
} finally {
if (btn) {
btn.innerText = originalText;
btn.disabled = false;
btn.style.cursor = "pointer";
btn.style.opacity = "1";
}
}
},
};
/**
* 主程序入口
*/
(function () {
NetworkMonitor.init();
const BTN_ID = "my-smart-auto-btn";
function createButton(parent, text, color, onClick, isPrimary = true) {
const btn = document.createElement("button");
btn.innerText = text;
btn.className = isPrimary ? "ant-btn ant-btn-primary" : "ant-btn";
btn.onclick = onClick;
Object.assign(btn.style, {
fontWeight: "bold",
...(isPrimary ? { backgroundColor: color, borderColor: color } : {}),
});
btn.style.marginLeft = "15px";
if (isPrimary) {
btn.id = BTN_ID;
// 如果网络请求未完成,初始化为禁用状态
if (!NetworkMonitor.isReady()) {
btn.innerText = "资源下载中...";
btn.disabled = true;
btn.style.cursor = "not-allowed";
btn.style.opacity = "0.6";
// 监听就绪事件
NetworkMonitor.onReady(() => {
btn.innerText = text;
btn.disabled = false;
btn.style.cursor = "pointer";
btn.style.opacity = "1";
UI.notification("✨ 资源解析完成,可以开始填写");
});
}
}
parent.appendChild(btn);
}
function createSubjectToggle(header) {
const btn = document.createElement("button");
btn.id = "subject-toggle-btn";
btn.className = "ant-btn ant-btn-primary";
btn.onclick = () => SubjectManager.toggleSubject();
Object.assign(btn.style, {
marginLeft: "15px",
fontWeight: "bold",
cursor: "pointer",
});
header.appendChild(btn);
SubjectManager.updateDisplay();
}
function createImportControl(header) {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".xlsx, .xls, .csv";
fileInput.style.display = "none";
fileInput.onchange = (e) => {
if (e.target.files.length)
ConfigManager.importFromExcel(e.target.files[0]);
fileInput.value = "";
};
header.appendChild(fileInput);
createButton(header, "导入配置", "#52c41a", () => fileInput.click(), false);
const status = document.createElement("span");
status.id = "config-status-display";
Object.assign(status.style, {
marginLeft: "10px",
fontSize: "12px",
color: "#999",
});
status.innerText = "(配置未加载)";
header.appendChild(status);
}
function checkAndInject() {
// 这里的 scoreInput 只是用来判断当前页面是否是目标页面
const target = document.querySelector(SELECTORS.scoreInput);
const header = document.querySelector("header");
const existingBtn = document.getElementById(BTN_ID);
if (target && header) {
if (!existingBtn) {
console.log("✅ 检测到目标输入框,注入控制面板...");
// 恢复直接创建按钮
createButton(header, "自动完成", "#52c41a", App.startTask, true);
// 创建题号显示区域
let titleDisplay = document.getElementById("extracted-title-display");
if (!titleDisplay) {
titleDisplay = document.createElement("span");
titleDisplay.id = "extracted-title-display";
titleDisplay.style.fontSize = "14px";
titleDisplay.style.color = "#1890ff";
titleDisplay.style.fontWeight = "bold";
titleDisplay.style.marginLeft = "auto";
titleDisplay.innerText = "题干: -";
}
const allButtons = Array.from(document.querySelectorAll("button"));
const submitResultBtn = allButtons.find((b) =>
b.innerText.includes("提交结果")
);
submitResultBtn.style.marginLeft = "15px";
if (submitResultBtn && submitResultBtn.parentNode) {
submitResultBtn.parentNode.insertBefore(
titleDisplay,
submitResultBtn
);
} else {
header.appendChild(titleDisplay);
}
createSubjectToggle(header);
createImportControl(header);
ConfigManager.init();
}
} else if (existingBtn) {
console.log("⚠️ 目标输入框消失,移除面板...");
existingBtn.remove();
// 同时也移除显示区域
const display = document.getElementById("extracted-title-display");
if (display) display.remove();
}
}
const observer = new MutationObserver(checkAndInject);
observer.observe(document.body, { childList: true, subtree: true });
checkAndInject();
})();