// ==UserScript== // @name Deepseek网页输入框草稿留存 // @namespace https://scriptcat.org/zh-CN/users/214553 // @version 1.1.0 // @description 独立保存 DeepSeek 网页版每个会话的输入草稿。告别输入内容未发送因误删页面、刷新页面、退出浏览器、意外关机等导致的文本丢失焦虑。也可用以暂时留存预发送文本(14天)。 // @author English3(Σπg|!εh๑๑๑) // @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; const STABLE_FRAMES = 3; const MAX_WRITES = 5; const NAV_POLL_MS = 150; // URL 轮询间隔(v1.0.0 的核心兼容利器) //存储降级// 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// 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; //定位输入框// 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// 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 k = key(); const target = load(k); if (target) { writeGuarded(target); } else { const ta = findTA(); if (ta && ta.value) write(ta, ''); } }); }; //路由检测:history hook(快)+ URL 轮询(兜底)// 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); setInterval(() => { const k = key(); if (k !== lastKey) onNav(); }, NAV_POLL_MS); //首次加载// 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 天 TTL 清理// (() => { 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) || !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); }); })(); })();