超级洛谷任务计划:替换主页原生任务计划模块,支持主页按题号添加/删除,题目页加入/移出,JSON 导入导出,无上限 IndexedDB 存储
// ==UserScript==
// @name Super Luogu Task Plan
// @namespace https://luogu.com.cn/
// @version 2026.4
// @description 超级洛谷任务计划:替换主页原生任务计划模块,支持主页按题号添加/删除,题目页加入/移出,JSON 导入导出,无上限 IndexedDB 存储
// @author aaa_Pigeon & ChatGPT
// @match *://www.luogu.com.cn/*
// @match *://www.luogu.org/*
// @grant GM_setClipboard
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/****************************************************************
* 配置
****************************************************************/
const DB_NAME = 'super_luogu_task_plan_db';
const DB_VERSION = 4;
const STORE_NAME = 'tasks';
const STATUS = {
TODO: 'todo',
DOING: 'doing',
DONE: 'done'
};
const PRIORITY = {
HIGH: 'high',
NORMAL: 'normal',
LOW: 'low'
};
let db = null;
let currentPath = location.pathname;
/****************************************************************
* IndexedDB
****************************************************************/
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (e) => {
const database = e.target.result;
let store;
if (!database.objectStoreNames.contains(STORE_NAME)) {
store = database.createObjectStore(STORE_NAME, {
keyPath: 'id'
});
} else {
store = e.target.transaction.objectStore(STORE_NAME);
}
if (!store.indexNames.contains('pid')) {
store.createIndex('pid', 'pid', { unique: false });
}
if (!store.indexNames.contains('pidKey')) {
store.createIndex('pidKey', 'pidKey', { unique: false });
}
if (!store.indexNames.contains('status')) {
store.createIndex('status', 'status', { unique: false });
}
if (!store.indexNames.contains('updatedAt')) {
store.createIndex('updatedAt', 'updatedAt', { unique: false });
}
};
});
}
function getStore(mode = 'readonly') {
return db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
}
function putTask(task) {
task.pidKey = normalizePid(task.pid);
return new Promise((resolve, reject) => {
const req = getStore('readwrite').put(task);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
function deleteTaskById(id) {
return new Promise((resolve, reject) => {
const req = getStore('readwrite').delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
function getAllTasks() {
return new Promise((resolve, reject) => {
const req = getStore().getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
async function getTaskByPid(pid) {
const pidKey = normalizePid(pid);
const tasks = await getAllTasks();
return tasks.find(task => normalizePid(task.pid) === pidKey) || null;
}
async function deleteTasksByPid(pid) {
const pidKey = normalizePid(pid);
const tasks = await getAllTasks();
const matched = tasks.filter(task => normalizePid(task.pid) === pidKey);
for (const task of matched) {
await deleteTaskById(task.id);
}
return matched.length;
}
/****************************************************************
* 工具函数
****************************************************************/
function uuid() {
return crypto.randomUUID
? crypto.randomUUID()
: String(Date.now()) + Math.random().toString(36).slice(2);
}
function now() {
return Date.now();
}
function normalizePid(pid) {
return String(pid || '').trim().toLowerCase();
}
function formatTime(t) {
if (!t) return '';
return new Date(t).toLocaleString();
}
function escapeHTML(str) {
return String(str || '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"');
}
function decodeHTML(str) {
const textarea = document.createElement('textarea');
textarea.innerHTML = str;
return textarea.value;
}
function escapeRegExp(str) {
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function removeAll(selector) {
document.querySelectorAll(selector).forEach(el => el.remove());
}
function download(filename, content) {
const blob = new Blob([content], {
type: 'application/json;charset=utf-8'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function isHomePage() {
return location.pathname === '/' || location.pathname === '';
}
function isProblemPage() {
return /^\/problem\/[A-Za-z0-9_]+/i.test(location.pathname);
}
function getProblemUrl(pid) {
return location.origin + '/problem/' + encodeURIComponent(String(pid).trim());
}
function parseProblemLines(text) {
return String(text || '')
.split(/\n|,|,|;|;/)
.map(line => line.trim().replace(/^[—–\-•\s]+/, ''))
.filter(Boolean)
.map(line => {
const match = line.match(/^([A-Za-z0-9_]+)(?:\s+(.+))?$/);
if (!match) return null;
return {
pid: match[1].trim(),
title: (match[2] || '').trim()
};
})
.filter(Boolean);
}
function cleanProblemTitle(raw, pid) {
return String(raw || '')
.replace(/\s*-\s*洛谷.*$/i, '')
.replace(/\s*洛谷.*$/i, '')
.replace(new RegExp('^' + escapeRegExp(pid) + '\\s*[-—::]?\\s*', 'i'), '')
.trim();
}
async function tryFetchProblemTitle(pid) {
try {
const res = await fetch(getProblemUrl(pid), {
credentials: 'include'
});
if (!res.ok) return '';
const html = await res.text();
const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/i);
if (!titleMatch) return '';
const title = cleanProblemTitle(decodeHTML(titleMatch[1]), pid);
return title || '';
} catch (err) {
console.warn('[Super Luogu Task Plan] 获取题目标题失败:', pid, err);
return '';
}
}
/****************************************************************
* 当前题目识别
****************************************************************/
function getCurrentProblemInfo() {
const match = location.pathname.match(/\/problem\/([A-Za-z0-9_]+)/i);
if (!match) return null;
const pid = match[1];
let title = document.title || pid;
const h1 = document.querySelector('h1');
if (h1 && h1.innerText.trim()) {
title = h1.innerText.trim();
}
title = cleanProblemTitle(title, pid);
return {
pid,
title: title || pid,
url: getProblemUrl(pid)
};
}
/****************************************************************
* 样式
****************************************************************/
const style = document.createElement('style');
style.textContent = `
#sltp-home-panel {
width: 100%;
color: #222;
font-size: 14px;
background: #fff;
}
#sltp-home-panel * {
box-sizing: border-box;
}
#sltp-home-panel.sltp-floating-fallback {
position: fixed;
right: 24px;
bottom: 24px;
width: 330px;
max-height: 560px;
z-index: 999999;
border-radius: 8px;
box-shadow: 0 4px 22px rgba(0, 0, 0, .18);
border: 1px solid #e5e5e5;
overflow: hidden;
}
#sltp-home-header {
padding: 10px 0 8px 0;
border-bottom: 1px solid #e5e5e5;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
font-size: 15px;
}
#sltp-home-tools {
padding: 8px 0;
border-bottom: 1px solid #e5e5e5;
background: #fff;
}
#sltp-home-body {
padding: 6px 0;
overflow: visible;
max-height: none;
}
#sltp-home-panel.sltp-floating-fallback #sltp-home-body {
max-height: 300px;
overflow-y: auto;
padding: 8px 10px;
}
#sltp-home-panel.sltp-floating-fallback #sltp-home-header {
padding: 10px 12px;
background: #f6f8fa;
}
#sltp-home-panel.sltp-floating-fallback #sltp-home-tools {
padding: 8px 10px;
background: #fafafa;
}
.sltp-home-task {
padding: 5px 0;
line-height: 1.55;
}
.sltp-home-task a {
color: #3498db;
text-decoration: none;
font-weight: bold;
}
.sltp-home-task a:hover {
text-decoration: underline;
}
.sltp-home-title {
display: inline;
color: #3498db;
word-break: break-word;
}
.sltp-home-meta {
margin-left: 2px;
color: #999;
font-size: 12px;
}
.sltp-row {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.sltp-row:last-child {
margin-bottom: 0;
}
.sltp-btn {
border: none;
border-radius: 4px;
padding: 6px 9px;
cursor: pointer;
background: #3498db;
color: #fff;
font-size: 12px;
white-space: nowrap;
}
.sltp-btn:hover {
opacity: .88;
}
.sltp-btn-gray { background: #666; }
.sltp-btn-green { background: #2e7d32; }
.sltp-btn-red { background: #d32f2f; }
#sltp-id-text,
#sltp-import-text {
width: 100%;
resize: vertical;
font-size: 12px;
padding: 6px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 6px;
}
#sltp-id-text {
height: 52px;
}
#sltp-import-text {
height: 72px;
}
#sltp-problem-btn {
display: inline-block;
margin-left: 8px;
padding: 8px 16px;
border-radius: 3px;
border: none;
cursor: pointer;
color: #fff;
font-size: 14px;
line-height: 1;
vertical-align: middle;
}
#sltp-problem-btn.sltp-add {
background: #2e7d32;
}
#sltp-problem-btn.sltp-remove {
background: #d32f2f;
}
#sltp-problem-float {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 999999;
background: #ffffff;
border: 1px solid #e5e5e5;
box-shadow: 0 4px 20px rgba(0,0,0,.18);
border-radius: 8px;
padding: 12px;
width: 230px;
color: #222;
}
#sltp-problem-float-title {
font-weight: bold;
margin-bottom: 8px;
}
`;
document.head.appendChild(style);
/****************************************************************
* 寻找洛谷主页原生“任务计划”模块
****************************************************************/
function findNativeHomeTaskPlanBox() {
const marked = document.querySelector('[data-sltp-native-task-plan="1"]');
if (marked && document.body.contains(marked)) {
return marked;
}
const all = [
...document.querySelectorAll('div, section, aside, article')
];
const candidates = all.filter(el => {
if (!el || el.id === 'sltp-home-panel') return false;
if (el.closest('#sltp-home-panel')) return false;
const text = normalizeText(el.innerText || el.textContent || '');
if (!text.includes('任务计划')) return false;
const rect = el.getBoundingClientRect();
if (rect.width < 120 || rect.width > 520) return false;
if (rect.height < 40 || rect.height > 500) return false;
const looksLikeNativeTaskPlan =
text.includes('任务计划') &&
(
text.includes('编辑') ||
text.includes('随机') ||
text.length < 260
);
return looksLikeNativeTaskPlan;
});
if (!candidates.length) return null;
candidates.sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return ar.width * ar.height - br.width * br.height;
});
const target = candidates[0];
target.dataset.sltpNativeTaskPlan = '1';
return target;
}
function normalizeText(text) {
return String(text || '').replace(/\s+/g, '');
}
/****************************************************************
* 主页任务计划:替换原生模块
****************************************************************/
async function renderHomePanel() {
removeAll('#sltp-problem-btn, #sltp-problem-float');
removeAll('#sltp-home-panel');
if (!isHomePage()) return;
const tasks = await getAllTasks();
tasks.sort((a, b) => {
const pa = priorityValue(a.priority);
const pb = priorityValue(b.priority);
if (pa !== pb) return pb - pa;
return (b.updatedAt || 0) - (a.updatedAt || 0);
});
const panel = document.createElement('div');
panel.id = 'sltp-home-panel';
panel.innerHTML = `
<div id="sltp-home-header">
<span>任务计划</span>
<span>${tasks.length}</span>
</div>
<div id="sltp-home-tools">
<textarea id="sltp-id-text" placeholder="按题号添加/删除;支持:P1001 或 P1001 A+B Problem;一行一个"></textarea>
<div class="sltp-row">
<button class="sltp-btn sltp-btn-green" id="sltp-home-add-by-id">按题号加入</button>
<button class="sltp-btn sltp-btn-red" id="sltp-home-delete-by-id">按题号删除</button>
</div>
<details>
<summary style="cursor:pointer;color:#666;margin:4px 0;">JSON 导入 / 导出</summary>
<div class="sltp-row" style="margin-top:6px;">
<button class="sltp-btn sltp-btn-gray" id="sltp-export-json">导出</button>
<button class="sltp-btn sltp-btn-gray" id="sltp-copy-json">复制</button>
<button class="sltp-btn sltp-btn-green" id="sltp-import-json">导入</button>
</div>
<textarea id="sltp-import-text" placeholder="粘贴 JSON 后点导入"></textarea>
</details>
</div>
<div id="sltp-home-body">
${
tasks.length
? tasks.map(task => renderHomeTask(task)).join('')
: `<div style="color:#888;padding:8px 0;">暂无题目。可以在主页输入题号加入,也可以进入题目页加入。</div>`
}
</div>
`;
const nativeBox = findNativeHomeTaskPlanBox();
if (nativeBox) {
nativeBox.dataset.sltpNativeTaskPlan = '1';
nativeBox.innerHTML = '';
nativeBox.appendChild(panel);
} else {
panel.classList.add('sltp-floating-fallback');
document.body.appendChild(panel);
}
document.getElementById('sltp-home-add-by-id').onclick = addByPidFromHome;
document.getElementById('sltp-home-delete-by-id').onclick = deleteByPidFromHome;
document.getElementById('sltp-export-json').onclick = exportJSON;
document.getElementById('sltp-copy-json').onclick = copyJSON;
document.getElementById('sltp-import-json').onclick = importJSON;
}
function renderHomeTask(task) {
const url = task.url || getProblemUrl(task.pid);
return `
<div class="sltp-home-task">
—
<a href="${escapeHTML(url)}" target="_blank">${escapeHTML(task.pid)}</a>
<span class="sltp-home-title">${escapeHTML(task.title || task.pid)}</span>
</div>
`;
}
async function addByPidFromHome() {
const text = document.getElementById('sltp-id-text').value;
const entries = parseProblemLines(text);
if (!entries.length) {
alert('请输入题号,例如:P1001 或 CF1738F Connectivity Addicts');
return;
}
let added = 0;
let skipped = 0;
for (const entry of entries) {
const existed = await getTaskByPid(entry.pid);
if (existed) {
skipped++;
continue;
}
const fetchedTitle = entry.title ? '' : await tryFetchProblemTitle(entry.pid);
const title = entry.title || fetchedTitle || entry.pid;
await putTask({
id: uuid(),
pid: entry.pid,
title,
url: getProblemUrl(entry.pid),
status: STATUS.TODO,
priority: PRIORITY.NORMAL,
note: '',
createdAt: now(),
updatedAt: now()
});
added++;
}
document.getElementById('sltp-id-text').value = '';
alert(`加入完成:新增 ${added} 道,已存在 ${skipped} 道`);
renderHomePanel();
}
async function deleteByPidFromHome() {
const text = document.getElementById('sltp-id-text').value;
const entries = parseProblemLines(text);
if (!entries.length) {
alert('请输入要删除的题号,例如:P1001');
return;
}
const ok = confirm(`确定从任务计划中删除这 ${entries.length} 个题号吗?`);
if (!ok) return;
let removed = 0;
for (const entry of entries) {
removed += await deleteTasksByPid(entry.pid);
}
document.getElementById('sltp-id-text').value = '';
alert(`删除完成:移出 ${removed} 条任务`);
renderHomePanel();
}
/****************************************************************
* JSON 导入导出
****************************************************************/
async function exportJSON() {
const all = await getAllTasks();
const data = {
schema: 'super-luogu-task-plan',
version: '2026.4',
exportedAt: new Date().toISOString(),
total: all.length,
tasks: all
};
download(
`luogu-task-plan-${Date.now()}.json`,
JSON.stringify(data, null, 2)
);
}
async function copyJSON() {
const all = await getAllTasks();
const data = {
schema: 'super-luogu-task-plan',
version: '2026.4',
exportedAt: new Date().toISOString(),
total: all.length,
tasks: all
};
const text = JSON.stringify(data, null, 2);
if (typeof GM_setClipboard !== 'undefined') {
GM_setClipboard(text);
} else {
await navigator.clipboard.writeText(text);
}
alert('已复制 JSON');
}
async function importJSON() {
const textarea = document.getElementById('sltp-import-text');
const text = textarea.value.trim();
if (!text) {
alert('请先粘贴 JSON');
return;
}
try {
const parsed = JSON.parse(text);
const importedTasks = Array.isArray(parsed)
? parsed
: Array.isArray(parsed.tasks)
? parsed.tasks
: null;
if (!importedTasks) {
alert('JSON 格式错误');
return;
}
const oldTasks = await getAllTasks();
const oldPidSet = new Set(oldTasks.map(task => normalizePid(task.pid)));
let added = 0;
let skipped = 0;
for (const raw of importedTasks) {
if (!raw || !raw.pid) continue;
const pid = String(raw.pid).trim();
const pidKey = normalizePid(pid);
if (oldPidSet.has(pidKey)) {
skipped++;
continue;
}
await putTask({
id: raw.id || uuid(),
pid,
title: raw.title || pid,
url: raw.url || getProblemUrl(pid),
status: raw.status || STATUS.TODO,
priority: raw.priority || PRIORITY.NORMAL,
note: raw.note || '',
createdAt: raw.createdAt || now(),
updatedAt: raw.updatedAt || now()
});
oldPidSet.add(pidKey);
added++;
}
textarea.value = '';
alert(`导入成功:新增 ${added} 道,跳过重复 ${skipped} 道`);
renderHomePanel();
} catch (err) {
console.error(err);
alert('JSON 导入失败');
}
}
/****************************************************************
* 题目页:加入 / 移出计划
****************************************************************/
async function renderProblemPageButton() {
removeAll('#sltp-home-panel, #sltp-problem-btn, #sltp-problem-float');
if (!isProblemPage()) return;
const problem = getCurrentProblemInfo();
if (!problem) return;
const existed = await getTaskByPid(problem.pid);
const btn = document.createElement('button');
btn.id = 'sltp-problem-btn';
btn.className = existed ? 'sltp-remove' : 'sltp-add';
btn.textContent = existed ? '移出计划' : '加入计划';
btn.onclick = async () => {
if (existed) {
const ok = confirm(`确定把 ${problem.pid} 从任务计划中移出吗?`);
if (!ok) return;
await deleteTasksByPid(problem.pid);
alert('已移出任务计划');
} else {
await putTask({
id: uuid(),
pid: problem.pid,
title: problem.title,
url: problem.url,
status: STATUS.TODO,
priority: PRIORITY.NORMAL,
note: '',
createdAt: now(),
updatedAt: now()
});
alert('已加入任务计划');
}
renderProblemPageButton();
};
const insertTarget = findProblemButtonArea();
if (insertTarget) {
insertTarget.appendChild(btn);
} else {
renderProblemFloat(problem, existed);
}
}
function findProblemButtonArea() {
const buttons = [...document.querySelectorAll('button, a')];
const submitBtn = buttons.find(el => {
const text = (el.innerText || '').trim();
return text === '提交答案' || text === '提交';
});
if (submitBtn && submitBtn.parentElement) {
return submitBtn.parentElement;
}
const addBtn = buttons.find(el => {
const text = (el.innerText || '').trim();
return text.includes('加入题单') || text.includes('复制题目');
});
if (addBtn && addBtn.parentElement) {
return addBtn.parentElement;
}
const h1 = document.querySelector('h1');
if (h1 && h1.parentElement) {
return h1.parentElement;
}
return null;
}
function renderProblemFloat(problem, existed) {
const box = document.createElement('div');
box.id = 'sltp-problem-float';
box.innerHTML = `
<div id="sltp-problem-float-title">任务计划</div>
<div style="margin-bottom:8px;color:#555;">
${escapeHTML(problem.pid)}
</div>
<button class="sltp-btn ${existed ? 'sltp-btn-red' : 'sltp-btn-green'}" id="sltp-problem-float-btn">
${existed ? '移出计划' : '加入计划'}
</button>
`;
document.body.appendChild(box);
document.getElementById('sltp-problem-float-btn').onclick = async () => {
if (existed) {
const ok = confirm(`确定把 ${problem.pid} 从任务计划中移出吗?`);
if (!ok) return;
await deleteTasksByPid(problem.pid);
alert('已移出任务计划');
} else {
await putTask({
id: uuid(),
pid: problem.pid,
title: problem.title,
url: problem.url,
status: STATUS.TODO,
priority: PRIORITY.NORMAL,
note: '',
createdAt: now(),
updatedAt: now()
});
alert('已加入任务计划');
}
renderProblemPageButton();
};
}
/****************************************************************
* 状态文字
****************************************************************/
function getStatusText(status) {
if (status === STATUS.DOING) return '进行中';
if (status === STATUS.DONE) return '已完成';
return '待做';
}
function getPriorityText(priority) {
if (priority === PRIORITY.HIGH) return '高';
if (priority === PRIORITY.LOW) return '低';
return '普通';
}
function priorityValue(priority) {
if (priority === PRIORITY.HIGH) return 3;
if (priority === PRIORITY.NORMAL) return 2;
if (priority === PRIORITY.LOW) return 1;
return 0;
}
/****************************************************************
* 页面刷新与 SPA 路由适配
****************************************************************/
async function refreshUI() {
if (!db) return;
if (isHomePage()) {
await renderHomePanel();
} else if (isProblemPage()) {
await renderProblemPageButton();
} else {
removeAll('#sltp-home-panel, #sltp-problem-btn, #sltp-problem-float');
}
}
function hookHistory() {
const rawPushState = history.pushState;
const rawReplaceState = history.replaceState;
history.pushState = function () {
const ret = rawPushState.apply(this, arguments);
setTimeout(checkRouteChange, 300);
return ret;
};
history.replaceState = function () {
const ret = rawReplaceState.apply(this, arguments);
setTimeout(checkRouteChange, 300);
return ret;
};
window.addEventListener('popstate', () => {
setTimeout(checkRouteChange, 300);
});
setInterval(checkRouteChange, 1000);
}
function checkRouteChange() {
if (location.pathname !== currentPath) {
currentPath = location.pathname;
setTimeout(refreshUI, 400);
setTimeout(refreshUI, 1200);
setTimeout(refreshUI, 2500);
}
}
/****************************************************************
* 初始化
****************************************************************/
async function init() {
await openDB();
hookHistory();
setTimeout(refreshUI, 500);
setTimeout(refreshUI, 1500);
setTimeout(refreshUI, 3000);
setTimeout(refreshUI, 5000);
console.log('[Super Luogu Task Plan] Loaded');
}
init();
})();