您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Work-around for LeetCode’s removed “Session Management” feature (see issue #22883). It emulates the legacy behaviour by creating a self-updating favorite list that contains every problem and automatically syncs it.
// ==UserScript== // @name LeetSession – Code obsession // @namespace https://github.com/wallandteen // @version 1.0.0 // @description Work-around for LeetCode’s removed “Session Management” feature (see issue #22883). It emulates the legacy behaviour by creating a self-updating favorite list that contains every problem and automatically syncs it. // @author Valentin Chizhov // @license MIT // @homepageURL https://github.com/wallandteen/leetsession#readme // @supportURL https://github.com/wallandteen/leetsession/issues // @icon https://raw.githubusercontent.com/wallandteen/leetsession/main/assets/icon48.ico // @match https://leetcode.com/* // @run-at document-end // @noframes // @grant none // ==/UserScript== (() => { "use strict"; const MARK = "[LS]"; const SESSION_FLAGS = { CREATING: "[CREATING]", }; const SESSION_DESCRIPTION = `Customise freely but keep [LS] in the name for auto-sync. Give me a ⭐: https://github.com/wallandteen/leetsession`; const CONFIG = Object.freeze({ BTN_ID: "leet-session-btn", CHUNK: 1000, MAX_PAR: 6, LAST_SYNC_KEY: "leetSession_lastSync_v1", }); const MESSAGES = Object.freeze({ TOAST: { ALREADY_CREATING: "⚠️ You are already creating a session. Please wait for it to complete.", CREATING_SESSION: "⏳ Creating new session... Please wait.", SESSION_CREATED: "✅ Session created successfully!", SESSION_FAILED: (error) => `❌ Failed to create session: ${error}`, SYNCED_PROBLEMS: "✅ All problems synchronized.", INCOMPLETE_SESSIONS: "⚠️ Found incomplete sessions. Syncing to complete...", }, UI: { BUTTON_TEXT: "New Session", BEFORE_UNLOAD: "Session creation is in progress. Are you sure you want to leave?" } }); const log = (...args) => console.log("[LeetSession]", ...args); class Toast { static _ensureStyle() { if (document.getElementById("leet-toast-css")) return; const style = document.createElement("style"); style.id = "leet-toast-css"; style.textContent = ` .leet{position:fixed;top:20px;right:20px;z-index:90000;background:#fff;border-radius:8px; padding:12px 16px;margin-top:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);max-width:420px; font-family:system-ui,'Segoe UI',sans-serif;display:flex;gap:12px;animation:in .3s;color:#000;} .leet.i{border-left:4px solid #2196f3}.leet.s{border-left:4px solid #4caf50} .leet.w{border-left:4px solid #ff9800}.leet.e{border-left:4px solid #f44336} .leet .x{margin-left:auto;background:none;border:none;font:16px monospace;cursor:pointer;color:#777} @keyframes in{from{opacity:0;transform:translateX(100%)}to{opacity:1;transform:none}} @keyframes out{from{opacity:1}to{opacity:0;transform:translateX(100%)}}`; document.head.appendChild(style); } static _show(message, type, duration) { this._ensureStyle(); const toast = document.createElement("div"); toast.className = `leet ${type}`; toast.innerHTML = `<span>${message}</span><button class="x">×</button>`; toast.querySelector(".x").onclick = () => toast.remove(); document.body.appendChild(toast); if (duration > 0) { setTimeout(() => { toast.style.animation = "out .3s forwards"; setTimeout(() => toast.remove(), 300); }, duration); } } static info(msg, d = 4e3) { this._show(msg, "i", d); } static success(msg, d = 4e3) { this._show(msg, "s", d); } static warn(msg, d = 4e3) { this._show(msg, "w", d); } static error(msg, d = 6e3) { this._show(msg, "e", d); } } class GQL { static _csrf() { return ( document.cookie.split("; ").find((c) => c.startsWith("csrftoken="))?.split("=")[1] || "" ); } static async request(query, variables = {}, operationName = "") { log("🌐", operationName || query.split(/[({]/)[0].trim(), variables); const response = await fetch("https://leetcode.com/graphql/", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "x-csrftoken": this._csrf(), }, body: JSON.stringify({ query, variables, operationName }), }); if (!response.ok) throw Error(`HTTP ${response.status}`); const json = await response.json(); if (json.errors) throw Error(json.errors.map((e) => e.message).join("; ")); return json.data; } } const Lists = { create: ({ name, description = "", pub = false }) => GQL.request( `mutation createEmptyFavorite($name: String!, $description: String, $favoriteType: FavoriteTypeEnum!, $isPublicFavorite: Boolean) { createEmptyFavorite( name: $name, description: $description, favoriteType: $favoriteType, isPublicFavorite: $isPublicFavorite ) { ok error favoriteSlug } }`, { name, description: `${description}`, favoriteType: "NORMAL", isPublicFavorite: pub, }, "createEmptyFavorite" ), add: (favoriteSlug, questionSlugs) => GQL.request( `mutation batchAddQuestionsToFavorite($favoriteSlug: String!, $questionSlugs: [String]!) { batchAddQuestionsToFavorite( favoriteSlug: $favoriteSlug, questionSlugs: $questionSlugs ) { ok error } }`, { favoriteSlug, questionSlugs }, "batchAddQuestionsToFavorite" ), reset: (favoriteSlug) => GQL.request( `mutation resetFavoriteSessionV2($favoriteSlug: String!, $deleteSyncedCode: Boolean) { resetFavoriteSessionV2( favoriteSlug: $favoriteSlug, deleteSyncedCode: $deleteSyncedCode ) { ok error } }`, { favoriteSlug, deleteSyncedCode: true }, "resetFavoriteSessionV2" ), mine: () => GQL.request( `query myFavoriteList { myCreatedFavoriteList { favorites { name slug } } }`, {}, "myFavoriteList" ), questions: async (favoriteSlug) => { const data = await GQL.request( `query favoriteQuestionList($favoriteSlug: String!) { favoriteQuestionList(favoriteSlug: $favoriteSlug, limit: 10000) { questions { titleSlug } } }`, { favoriteSlug }, "favoriteQuestionList" ); return data.favoriteQuestionList.questions.map((q) => q.titleSlug); }, updateSessionName: (favoriteSlug, newName) => { log(`🔄 Updating session name: ${favoriteSlug} → "${newName}"`); return GQL.request( `mutation updateFavoriteNameDescriptionV2($favoriteSlug: String!, $name: String!, $description: String) { updateFavoriteNameDescriptionV2( favoriteSlug: $favoriteSlug, name: $name, description: $description ) { ok error } }`, { favoriteSlug, name: newName, description: SESSION_DESCRIPTION, }, "updateFavoriteNameDescriptionV2" ).then(result => { if (result.updateFavoriteNameDescriptionV2.ok) { log(`✅ Successfully updated session name to: "${newName}"`); } else { log(`❌ Failed to update session name: ${result.updateFavoriteNameDescriptionV2.error}`); } return result; }); }, }; class SessionManager { static async getMine() { return (await Lists.mine()).myCreatedFavoriteList.favorites .filter((f) => f.name?.includes(MARK)); } static async getIncompleteSessions() { return (await SessionManager.getMine()).filter(f => f.name?.includes(SESSION_FLAGS.CREATING)); } static async hasIncompleteSessions() { return (await SessionManager.getIncompleteSessions()).length > 0; } static async fetchAllSlugs() { log("📡 Loading problems..."); const json = await (await fetch("https://leetcode.com/api/problems/all/")).json(); const slugs = json.stat_status_pairs.map((p) => p.stat.question__title_slug); log(`✅ Loaded ${slugs.length} problems`); return slugs; } static async generateUniqueSessionName(baseName, state = SESSION_FLAGS.CREATING) { const existingSessions = (await Lists.mine()).myCreatedFavoriteList.favorites .filter(f => f.name?.includes(MARK)) .map(f => f.name); // Find all sessions that start with baseName (with or without state flags) const matchingSessions = existingSessions.filter(name => name.startsWith(baseName)); let sessionNumber = 1; let sessionName = `${baseName} ${state}`; if (matchingSessions.length > 0) { const numbers = matchingSessions.map(name => { const match = name.match(new RegExp(`^${baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} #(\\d+).*`)); return match ? parseInt(match[1]) : 0; }); const maxNumber = Math.max(...numbers); sessionNumber = maxNumber + 1; sessionName = `${baseName} #${sessionNumber} ${state}`; } log(`📝 Generated session name: "${sessionName}" (existing: ${existingSessions.length}, max number: ${sessionNumber - 1})`); return sessionName; } static async addProblemsToSession(favoriteSlug, problemsToAdd, sessionName = "Session") { if (!problemsToAdd.length) { log(`✅ No problems to add to ${sessionName}`); return 0; } log(`📦 Adding ${problemsToAdd.length} problems to ${sessionName}...`); let concurrency = CONFIG.MAX_PAR; let addedCount = 0; for (let i = 0; i < problemsToAdd.length; i += CONFIG.CHUNK * concurrency) { const group = []; for (let j = 0; j < concurrency && i + j * CONFIG.CHUNK < problemsToAdd.length; j++) { group.push(problemsToAdd.slice(i + j * CONFIG.CHUNK, i + (j + 1) * CONFIG.CHUNK)); } try { await Promise.all(group.map((a) => Lists.add(favoriteSlug, a))); addedCount += Math.min(i + CONFIG.CHUNK * concurrency, problemsToAdd.length) - i; const currentProgress = Math.min(i + CONFIG.CHUNK * concurrency, problemsToAdd.length); log(`➕ Added ${currentProgress}/${problemsToAdd.length} problems to ${sessionName}`); } catch (e) { if (e.message.includes("429") && concurrency > 1) { concurrency--; i -= CONFIG.CHUNK * concurrency; // retry current window with reduced concurrency continue; } throw e; } } log(`✅ Successfully added ${addedCount} problems to ${sessionName}`); return addedCount; } static async create() { if (await SessionManager.hasIncompleteSessions()) { Toast.warn(MESSAGES.TOAST.ALREADY_CREATING); return; } // Show toast immediately when button is clicked Toast.info(MESSAGES.TOAST.CREATING_SESSION, 6000); try { const dateLabel = new Date().toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }).replace(",", ""); const listName = await SessionManager.generateUniqueSessionName(`${dateLabel} ${MARK}`, SESSION_FLAGS.CREATING); const { createEmptyFavorite: { ok, favoriteSlug }, } = await Lists.create({ name: listName, description: SESSION_DESCRIPTION }); if (!ok) throw Error("createEmptyFavorite failed"); log(`📝 Created session: ${listName} (${favoriteSlug})`); // Add all problems to the new session const slugs = await SessionManager.fetchAllSlugs(); await SessionManager.addProblemsToSession(favoriteSlug, slugs, listName); log(`✅ Successfully synchronized session "${listName}"`); await Lists.reset(favoriteSlug); log("✅ Session progress reset successfully"); // Mark session as ready by removing the CREATING flag const finalName = listName.replace(SESSION_FLAGS.CREATING, ""); await Lists.updateSessionName(favoriteSlug, finalName); log(`✅ Session marked as ready: "${finalName}"`); // Remove the "creating" toast and show success const creatingToast = document.querySelector(".leet.i"); if (creatingToast) creatingToast.remove(); Toast.success(MESSAGES.TOAST.SESSION_CREATED, 6000); // Navigate to the created session window.location.href = `https://leetcode.com/problem-list/${favoriteSlug}`; } catch (e) { console.error(e); Toast.error(MESSAGES.TOAST.SESSION_FAILED(e.message)); // Force sync if session creation was interrupted if (await SessionManager.hasIncompleteSessions()) { log("⚠️ Session creation was interrupted. Forcing sync to complete..."); await SessionManager.sync(); } } finally { } } static async sync() { const todayUTC = new Date().toISOString().slice(0, 10); // YYYY-MM-DD const lastSyncDay = localStorage.getItem(CONFIG.LAST_SYNC_KEY); const incompleteSessions = await SessionManager.getIncompleteSessions(); const forceSync = incompleteSessions.length > 0; if (lastSyncDay === todayUTC && !forceSync) { log("sync skipped: already done today", todayUTC); return; } if (forceSync) { log("🔄 Force sync due to incomplete sessions or interrupted creation"); if (incompleteSessions.length > 0) { log(`⚠️ Found ${incompleteSessions.length} incomplete session(s):`); incompleteSessions.forEach(session => { log(` - "${session.name}" (${session.slug})`); }); } } const mine = await SessionManager.getMine(); if (!mine.length) return; const slugs = await SessionManager.fetchAllSlugs(); let addedTotal = 0; for (const fav of mine) { log(`🔄 Processing session: "${fav.name}" (${fav.slug})`); const haveArr = await Lists.questions(fav.slug); const have = new Set(haveArr); const diff = slugs.filter((x) => !have.has(x)); log(` - Has ${haveArr.length} problems, ${diff.length} new problems to add`); // Add missing problems to the session if any if (diff.length > 0) { const addedCount = await SessionManager.addProblemsToSession(fav.slug, diff, fav.name); addedTotal += addedCount; log(`🔄 ${fav.name}: +${addedCount} problems`); } else { log(` - No new problems to add`); } // If this was an incomplete session, reset progress and mark it as ready if (fav.name?.includes(SESSION_FLAGS.CREATING)) { log(`🔄 Resetting progress for incomplete session: "${fav.name}"`); await Lists.reset(fav.slug); const finalName = fav.name.replace(SESSION_FLAGS.CREATING, ""); log(`🔄 Marking session as ready: "${fav.name}" → "${finalName}"`); await Lists.updateSessionName(fav.slug, finalName); log(`✅ Successfully marked session as ready: ${finalName}`); } else { log(` - Session "${fav.name}" is already ready (no CREATING flag)`); } } if (addedTotal) { Toast.success(MESSAGES.TOAST.SYNCED_PROBLEMS, 5000); log(`✅ Total synced: +${addedTotal} problems`); } if (forceSync) { log("🧹 Cleared interrupted session tracking"); } localStorage.setItem(CONFIG.LAST_SYNC_KEY, todayUTC); } } class UI { static _insertButton() { const currentPath = window.location.pathname; const allowedPaths = ['/problemset', '/problem-list', '/studyplan']; const isAllowedPage = allowedPaths.some(path => currentPath.startsWith(path)); if (!isAllowedPage) { log(`Skipping button insertion on path: ${currentPath}`); return; } if (document.getElementById(CONFIG.BTN_ID)) { log("Button already exists, skipping..."); return; // Don't disconnect observer - keep watching for changes } const allDivs = document.querySelectorAll("div.flex.flex-col.gap-1 > div"); const studyPlanButton = [...allDivs].find( (d) => d.textContent.trim() === "Study Plan" ); if (studyPlanButton) { const btn = document.createElement("div"); btn.id = CONFIG.BTN_ID; btn.className = "rounded-sd-sm hover:bg-sd-accent flex h-10 cursor-pointer items-center gap-2 py-2 pl-2 transition-all"; btn.onclick = SessionManager.create; const iconContainer = document.createElement("div"); iconContainer.className = "relative text-[16px] leading-[normal] p-1 before:block before:h-4 before:w-4"; const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false"); svg.setAttribute("class", "svg-inline--fa fa-refresh absolute left-1/2 top-1/2 h-[1em] -translate-x-1/2 -translate-y-1/2 align-[-0.125em]"); svg.setAttribute("role", "img"); svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); svg.setAttribute("viewBox", "0 0 24 24"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("fill", "currentColor"); path.setAttribute("d", "M23,12A11,11,0,1,1,12,1a10.9,10.9,0,0,1,5.882,1.7l1.411-1.411A1,1,0,0,1,21,2V6a1,1,0,0,1-1,1H16a1,1,0,0,1-.707-1.707L16.42,4.166A8.9,8.9,0,0,0,12,3a9,9,0,1,0,9,9,1,1,0,0,1,2,0Z"); svg.appendChild(path); iconContainer.appendChild(svg); const textContainer = document.createElement("div"); textContainer.className = "select-none text-base font-semibold"; textContainer.textContent = MESSAGES.UI.BUTTON_TEXT; btn.appendChild(iconContainer); btn.appendChild(textContainer); studyPlanButton.parentNode.insertBefore(btn, studyPlanButton.nextSibling); } } } const observer = new MutationObserver(() => { // Only try to insert if button doesn't exist if (!document.getElementById(CONFIG.BTN_ID)) { UI._insertButton(); } }); observer.observe(document.body, { childList: true, subtree: true }); UI._insertButton(); // initial check setTimeout(SessionManager.sync, 1000); // Check for interrupted session creation on page load (async () => { if (await SessionManager.hasIncompleteSessions()) { log("⚠️ Found incomplete sessions. Syncing to complete..."); Toast.warn(MESSAGES.TOAST.INCOMPLETE_SESSIONS, 4000); } })(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址