// ==UserScript== // @name Deepseek网页输入框草稿留存 // @namespace https://scriptcat.org/zh-CN/users/214553 // @version 1.0.0 // @description 独立保存 DeepSeek 网页版每个会话的输入草稿。告别输入内容未发送因误删页面、刷新页面、退出浏览器等文本丢失焦虑。也可用以暂时留存预发送文本(14天)。 // @author English3 // @license MIT // @icon https://deepseek.com/favicon.ico // @match https://chat.deepseek.com/* // @run-at document-idle // @grant none // ==/UserScript== (() => { 'use strict'; const PREFIX = 'ds_draft:'; const META = 'ds_draft_ts:'; const TTL_MS = 14 * 24 * 3600 * 1000; const SAVE_DEBOUNCE = 250; const MAX_FRAMES = 180; // ≈3s,覆盖 React 慢挂载 const STABLE_FRAMES = 3; // 连续 3 帧值正确视为稳定 const MAX_WRITES = 5; // 单次恢复最多补写 5 次 const MISS_THRESHOLD = 3; // 侧边栏连续缺失 N 次判定为删除 //存储:异常降级到内存 Map(并警告一次)// const makeStore = () => { try { const s = localStorage; s.setItem('__t','1'); s.removeItem('__t'); return s; } catch { return null; } }; const memoryStore = () => { const m = new Map(); return { getItem: k => m.has(k) ? m.get(k) : null, setItem: (k, v) => m.set(k, v), removeItem: k => m.delete(k), key: i => [...m.keys()][i], get length() { return m.size; }, }; }; const storage = makeStore() || ( console.warn('[文本框留存] localStorage 不可用,草稿仅保存在内存,刷新将丢失。'), memoryStore() ); const session = (() => { try { const s = sessionStorage; s.setItem('__t','1'); s.removeItem('__t'); return s; } catch { return storage; } })(); //会话 key(带缓存,pathname/hash 不变时零正则)// let cachedPath = null, cachedKey = '__new__'; const key = () => { const p = location.pathname + location.hash; if (p === cachedPath) return cachedKey; cachedPath = p; const m = p.match(/\/chat\/(?:s\/)?([^/?#]+)/); return (cachedKey = m ? m[1] : '__new__'); }; const isNew = k => k === '__new__'; const src = k => isNew(k) ? session : storage; //定位输入框:优先 name="search",回退到主区域内可编辑 textarea// const TA_SET = new WeakSet(); const findTA = () => { let ta = document.querySelector('textarea[name="search"]'); if (!ta) ta = document.querySelector('main textarea:not([readonly]):not([disabled])'); if (ta) TA_SET.add(ta); return ta; }; const isTargetTA = el => { if (!el || el.tagName !== 'TEXTAREA') return false; if (el.name === 'search') return true; if (TA_SET.has(el)) return true; return el === findTA(); }; //原生 setter 写值(React 受控框)// let internal = false; const write = (ta, val) => { if (!ta || ta.value === val) return false; const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; internal = true; try { set.call(ta, val); ta.dispatchEvent(new Event('input', { bubbles: true })); } finally { internal = false; } return true; }; //读写// const load = k => { try { return src(k).getItem(PREFIX + k) || ''; } catch { return ''; } }; const save = (k, v) => { try { if (v) { src(k).setItem(PREFIX + k, v); if (!isNew(k)) storage.setItem(META + k, String(Date.now())); } else { storage.removeItem(PREFIX + k); session.removeItem(PREFIX + k); storage.removeItem(META + k); } } catch {} }; //防抖保存// let timer = null, pend = null, token = 0; const flush = () => { clearTimeout(timer); timer = null; if (pend) { save(pend.k, pend.v); pend = null; } }; const queue = (k, v) => { pend = { k, v }; clearTimeout(timer); timer = setTimeout(flush, SAVE_DEBOUNCE); }; const discard = k => { clearTimeout(timer); timer = null; pend = null; save(k, ''); token++; }; //IME 合成跟踪// let composing = false; document.addEventListener('compositionstart', e => { if (isTargetTA(e.target)) composing = true; }, true); document.addEventListener('compositionend', e => { if (!isTargetTA(e.target)) return; composing = false; if (internal) return; const k = key(), v = e.target.value; v ? queue(k, v) : discard(k); }, true); //input:非空→保存;空→清除// document.addEventListener('input', e => { if (internal || composing) return; if (!isTargetTA(e.target)) return; const k = key(), v = e.target.value; v ? queue(k, v) : discard(k); }, true); addEventListener('beforeunload', flush); addEventListener('pagehide', flush); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flush(); }); //帧同步恢复// const writeGuarded = val => { const my = ++token; let frames = 0, stable = 0, writes = 0, cancelled = false; let ta = null; const onUser = e => { if (internal) return; if (!isTargetTA(e.target)) return; cancelled = true; }; document.addEventListener('input', onUser, true); const done = () => document.removeEventListener('input', onUser, true); const step = () => { if (my !== token || cancelled) return done(); if (!ta || !ta.isConnected) ta = findTA(); if (ta) { if (ta.value === val) { if (++stable >= STABLE_FRAMES) return done(); } else if (writes < MAX_WRITES) { write(ta, val); writes++; stable = 0; } else { return done(); } } ++frames < MAX_FRAMES ? requestAnimationFrame(step) : done(); }; requestAnimationFrame(step); }; //恢复保护窗口// let guardUntil = 0; const restore = () => { const my = ++token; guardUntil = performance.now() + 4000; requestAnimationFrame(() => { if (my !== token) return; flush(); const target = load(key()); if (target) writeGuarded(target); }); }; //SPA 切会话(幂等 hook)// let lastKey = key(); const onNav = () => { const k = key(); if (k === lastKey) return; lastKey = k; restore(); }; const NAV_HOOK = Symbol.for('ds_draft_nav_hook'); if (!history[NAV_HOOK]) { history[NAV_HOOK] = true; for (const m of ['pushState', 'replaceState']) { const orig = history[m]; history[m] = function (...a) { const r = orig.apply(this, a); onNav(); return r; }; } } addEventListener('popstate', onNav); addEventListener('hashchange', onNav); //首次加载// restore(); //发送/程序化清空兜底轮询// setInterval(() => { if (performance.now() < guardUntil) return; if (document.visibilityState !== 'visible') return; const ta = findTA(); if (!ta) return; const k = key(); const saved = load(k); if (saved && ta.value === '') discard(k); }, 1000); //启动清理:14 天未访问// (() => { const now = Date.now(), stale = []; for (let i = 0; i < storage.length; i++) { const k = storage.key(i); if (!k || !k.startsWith(PREFIX)) continue; const cid = k.slice(PREFIX.length); if (isNew(cid)) continue; const ts = parseInt(storage.getItem(META + cid) || '0', 10); if (!ts || now - ts > TTL_MS) stale.push(cid); } stale.forEach(cid => { storage.removeItem(PREFIX + cid); storage.removeItem(META + cid); }); })(); //会话删除检测(已优化:500ms 防抖)// const watchSessionDeletion = () => { const findSidebar = () => { const cands = document.querySelectorAll('aside, [class*="sidebar" i]'); for (const el of cands) { if (el.querySelector('a[href*="/chat/"]')) return el; } return null; }; const snapshot = sidebar => { const s = new Set(); sidebar.querySelectorAll('a[href*="/chat/"]').forEach(a => { const m = (a.getAttribute('href') || '').match(/\/chat\/(?:s\/)?([^/?#]+)/); if (m) s.add(m[1]); }); return s; }; const start = sidebar => { const known = snapshot(sidebar); let initialized = known.size > 0; const missCount = new Map(); const handle = () => { const now = snapshot(sidebar); if (!initialized) { if (now.size) { now.forEach(id => known.add(id)); initialized = true; } return; } known.forEach(id => { if (now.has(id)) missCount.set(id, 0); else missCount.set(id, (missCount.get(id) || 0) + 1); }); missCount.forEach((cnt, id) => { if (cnt >= MISS_THRESHOLD) { storage.removeItem(PREFIX + id); storage.removeItem(META + id); missCount.delete(id); known.delete(id); } }); now.forEach(id => { if (!known.has(id)) { known.add(id); missCount.set(id, 0); } }); }; // 500ms 防抖:避免 React 重渲染时高频触发 snapshot 导致卡顿 let scheduled = false; let debounceTimer = null; const mo = new MutationObserver(() => { if (scheduled) return; scheduled = true; clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { scheduled = false; handle(); }, 500); }); mo.observe(sidebar, { childList: true, subtree: true }); }; let frames = 0; const wait = () => { const sb = findSidebar(); if (sb) return start(sb); if (++frames < 300) requestAnimationFrame(wait); }; wait(); }; addEventListener('load', () => setTimeout(watchSessionDeletion, 500)); })();