// ==UserScript==// @name ChatGPT Batch Move x10 (Dark Mode, REST only, Debug)// @version 1.2.0// @description 仅统计数量,按每10条一批把“根目录对话”移动到所选目标(gizmo);内置错误码、统一请求封装;支持个人/团队空间与暗色模式。// @author juan// @match https://chat.openai.com/*// @match https://chatgpt.com/*// @grant none// @license MIT// @namespace https://greasyfork.org/users/chatgpt-batch-move// ==/UserScript==(function () { 'use strict'; /** ============== 主题(明/暗) ============== */ function isDarkMode() { const html = document.documentElement; const body = document.body; const classDark = html?.classList?.contains('dark') || body?.classList?.contains('dark'); const dataDark = (html?.dataset?.theme || body?.dataset?.theme || '').toLowerCase().includes('dark'); const mediaDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; return classDark || dataDark || mediaDark; } function getTheme() { if (isDarkMode()) { return { overlay: 'rgba(0,0,0,.6)', surface: '#111827', surfaceAlt: '#0f172a', panel: '#1f2937', text: '#e5e7eb', subtext: '#9ca3af', border: '#374151', accent: '#38bdf8', accentText: '#0b1117', ok: '#22c55e', warn: '#f59e0b', error: '#f87171', code: '#a5b4fc', shadow: '0 5px 15px rgba(0,0,0,.5)' }; } return { overlay: 'rgba(0,0,0,.5)', surface: '#ffffff', surfaceAlt: '#f9fafb', panel: '#f8fafc', text: '#111827', subtext: '#6b7280', border: '#e5e7eb', accent: '#0ea5e9', accentText: '#ffffff', ok: '#16a34a', warn: '#b45309', error: '#dc2626', code: '#4338ca', shadow: '0 5px 15px rgba(0,0,0,.3)' }; } /** ============== 常量/全局 ============== */ const BASE_DELAY = 500; const JITTER = 300; const PAGE_LIMIT = 100; let accessToken = null; let capturedWorkspaceIds = new Set(); /** ============== 错误码 ============== */ const ERR = { MissingToken: 'E001', MissingDeviceId: 'E002', EndpointMoved: 'E004', Unauthorized: 'E401', Forbidden: 'E403', NotFound: 'E404', MethodNotAllowed: 'E405', RateLimited: 'E429', ServerError: 'E5xx', Network: 'E_NET', Unknown: 'E000' }; const mapStatusToErr = (s)=> s===401?ERR.Unauthorized:s===403?ERR.Forbidden:s===404?ERR.NotFound:s===405?ERR.MethodNotAllowed:s===429?ERR.RateLimited:s>=500?ERR.ServerError:ERR.Unknown; const makeErr = (label, code, status, raw)=>{ const e=new Error(`[${code}] ${label} failed (HTTP ${status}) ${(raw||'').slice(0,300)}`); e.code=code; e.status=status; e.raw=raw; return e; }; /** ============== 小工具 ============== */ const sleep = ms => new Promise(r=>setTimeout(r, ms)); const jitter = () => BASE_DELAY + Math.random()*JITTER; const safeJSON = (t)=>{ try{return t?JSON.parse(t):{};}catch{return {__parse_error:(t||'').slice(0,300)};} }; function escapeHtml(s){ return String(s||'').replace(/[&<>"']/g, m=>({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[m])); } function requireDeviceId() { const m = document.cookie.match(/oai-did=([^;]+)/); const did = m ? m[1] : null; if (!did) throw makeErr('oai-device-id', ERR.MissingDeviceId, 0, 'cookie oai-did missing'); return did; } async function ensureAccessTokenStrict() { if (accessToken) return accessToken; try { const r = await fetch('/api/auth/session?unstable_client=true', { credentials:'include' }); const j = safeJSON(await r.text().catch(()=> '')); if (j?.accessToken) { accessToken = j.accessToken; return accessToken; } } catch {} throw makeErr('accessToken', ERR.MissingToken, 0, 'no bearer token captured'); } function baseHeaders(workspaceId) { const did = requireDeviceId(); const h = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': did }; if (workspaceId) h['ChatGPT-Account-Id'] = workspaceId; return h; } async function callAPI(label, url, init, workspaceId, expectJson=true) { try { const resp = await fetch(url, { credentials: 'include', ...init, headers: { Accept:'application/json', ...baseHeaders(workspaceId), ...(init?.headers||{}) } }); const text = await resp.text().catch(()=> ''); const payload = expectJson ? safeJSON(text) : text; if (!resp.ok) throw makeErr(label, mapStatusToErr(resp.status), resp.status, text); return payload; } catch (e) { if (!e.code) e.code = ERR.Network; throw e; } } /** ============== 网络拦截:仅抓 Token/Workspace(无 GraphQL) ============== */ (function interceptNetwork(){ const rawFetch = window.fetch; window.fetch = async function(resource, options){ tryCaptureToken(options?.headers); const wsid = options?.headers?.['ChatGPT-Account-Id'] || options?.headers?.['chatgpt-account-id']; if (wsid) capturedWorkspaceIds.add(wsid); return rawFetch.apply(this, arguments); }; const rawOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(){ this.addEventListener('readystatechange', () => { if (this.readyState===4) { try { tryCaptureToken(this.getRequestHeader('Authorization')); const id = this.getRequestHeader('ChatGPT-Account-Id'); if (id) capturedWorkspaceIds.add(id); } catch {} } }); return rawOpen.apply(this, arguments); }; })(); function tryCaptureToken(header){ if (!header) return; const h = typeof header==='string' ? header : header instanceof Headers ? header.get('Authorization') : header.Authorization || header.authorization; if (h?.startsWith('Bearer ')) { const t = h.slice(7); if (t && t.toLowerCase()!=='dummy') accessToken = t; } } /** ============== 基础数据:Workspace / Gizmo 列表 ============== */ function detectAllWorkspaceIds(){ const found = new Set(capturedWorkspaceIds); try { const data = JSON.parse(document.getElementById('__NEXT_DATA__')?.textContent || '{}'); const accounts = data?.props?.pageProps?.user?.accounts; if (accounts) Object.values(accounts).forEach(acc => acc?.account?.id && found.add(acc.account.id)); } catch {} try { for (let i=0;i<localStorage.length;i++){ const key = localStorage.key(i); if (!key) continue; if (key.includes('account')||key.includes('workspace')){ let v=(localStorage.getItem(key)||'').replace(/"/g,''); const ws=v.match(/ws-[a-f0-9-]{36}/i); if (ws) found.add(ws[0]); else if (/^[a-f0-9-]{36}$/i.test(v)) found.add(v); } } } catch {} return Array.from(found); } async function getGizmoList(workspaceId){ try { const data = await callAPI('ListGizmos(snorlax/sidebar)','/backend-api/gizmos/snorlax/sidebar',{method:'GET'}, workspaceId, true); const arr=[]; data.items?.forEach(it=>{ if (it?.gizmo?.id && it?.gizmo?.display?.name) arr.push({ id: it.gizmo.id, title: it.gizmo.display.name }); }); return arr; } catch (e) { if (e.code===ERR.NotFound||e.code===ERR.MethodNotAllowed) e.code = ERR.EndpointMoved; return []; } } /** ============== 转移动作(REST 三连兜底) ============== */ async function assignViaLegacyREST(workspaceId, conversationId, gizmoId){ const headers = { 'Content-Type':'application/json' }; const tryCall = async (label, url, init)=>{ const r = await fetch(url, { credentials:'include', ...init, headers:{ Accept:'application/json', ...baseHeaders(workspaceId), ...headers } }); const text = await r.text().catch(()=> ''); if (!r.ok) throw makeErr(label, mapStatusToErr(r.status), r.status, text); return label; }; try { const l = await tryCall('POST:gizmos/:id/conversations', `/backend-api/gizmos/${gizmoId}/conversations`, { method:'POST', body: JSON.stringify({ conversation_id: conversationId }) }); return { ok:true, method:l }; } catch {} try { const l = await tryCall('PATCH:conversation/:id (gizmo_id)', `/backend-api/conversation/${conversationId}`, { method:'PATCH', body: JSON.stringify({ gizmo_id: gizmoId }) }); return { ok:true, method:l }; } catch {} try { const l = await tryCall('PUT:gizmos/:id/conversations/:cid', `/backend-api/gizmos/${gizmoId}/conversations/${conversationId}`, { method:'PUT' }); return { ok:true, method:l }; } catch (e3) { return { ok:false, error: makeErr('MoveConversation', ERR.EndpointMoved, 0, 'legacy REST endpoints failed').message }; } } /** ============== 分页“边计数边搬运”:每10条一批 ============== */ async function streamRootAndMoveBatches(workspaceId, target, log){ let total = 0, movedOk = 0, movedFail = 0, batches = 0; const buffer = []; const theme = getTheme(); const processBatch = async () => { if (buffer.length===0) return; const chunk = buffer.splice(0, 10); batches++; log(`%c▶ 批次 #${batches}:准备转移 ${chunk.length} 条 → ${target.title}`, `color:${theme.accent}`); for (let i=0;i<chunk.length;i++){ const cid = chunk[i]; const r = await assignViaLegacyREST(workspaceId, cid, target.id); if (r.ok) { movedOk++; log(`%c - ${cid.slice(0,8)}… ✅ ${r.method}`, `color:${theme.ok}`); } else { movedFail++; log(`%c - ${cid.slice(0,8)}… ❌ ${r.error}`, `color:${theme.error}`); } await sleep(jitter()); } log(` 批次 #${batches} 完成。累计成功 ${movedOk},失败 ${movedFail}。`); }; for (const is_archived of [false, true]) { let offset = 0, page = 0, has_more = true; while (has_more) { page++; const url = `/backend-api/conversations?offset=${offset}&limit=${PAGE_LIMIT}&order=updated${is_archived ? '&is_archived=true' : ''}`; const j = await callAPI(`ListRootConversations(${is_archived?'archived':'active'} p${page})`, url, { method:'GET' }, workspaceId, true); const ids = (j.items||[]).map(x=>x.id).filter(Boolean); total += ids.length; log(`页 ${page}(${is_archived?'Archived':'Active'}):获取 ${ids.length} 条,累计 ${total} 条。`); buffer.push(...ids); // 凑够 10 就处理一批 while (buffer.length >= 10) { await processBatch(); } has_more = ids.length === PAGE_LIMIT; offset += ids.length; await sleep(jitter()); } } // 处理最后不足 10 条 if (buffer.length) { await processBatch(); } log(`\n完成:共发现 ${total} 条;成功 ${movedOk};失败 ${movedFail};批次数 ${batches}。`); } /** ============== UI ============== */ function css(el, styles){ Object.assign(el.style, styles); } function showMainButton(){ if (document.getElementById('batch-move-btn')) return; const theme = getTheme(); const b = document.createElement('button'); b.id = 'batch-move-btn'; b.textContent = 'Batch Move x10'; css(b, { position:'fixed', bottom:'24px', right:'24px', zIndex:99997, padding:'10px 14px', borderRadius:'8px', border:'none', cursor:'pointer', fontWeight:'bold', background: theme.accent, color: theme.accentText, fontSize:'14px', boxShadow: theme.shadow }); b.onmouseenter = ()=> b.style.opacity = '0.9'; b.onmouseleave = ()=> b.style.opacity = '1'; b.onclick = showWizard; document.body.appendChild(b); } function showWizard(){ if (document.getElementById('bm-overlay')) return; const theme = getTheme(); const overlay = document.createElement('div'); overlay.id='bm-overlay'; css(overlay, { position:'fixed', inset:0, background:theme.overlay, zIndex:99998, display:'flex', alignItems:'center', justifyContent:'center' }); const dlg = document.createElement('div'); dlg.id='bm-dialog'; css(dlg, { width:'760px', maxWidth:'92vw', background:theme.surface, color:theme.text, borderRadius:'12px', boxShadow:theme.shadow, padding:'18px', fontFamily:'sans-serif', border:`1px solid ${theme.border}` }); const state = { workspaceId:null, targets:[], selected:null }; const close = ()=> document.body.removeChild(overlay); function sectionInfo(html){ const box = document.createElement('div'); css(box, { marginTop:'12px', padding:'10px', border:`1px dashed ${theme.border}`, borderRadius:'8px', background:theme.panel, color:theme.subtext }); box.innerHTML = html; return box; } function actionBtn(label, kind){ const btn = document.createElement('button'); css(btn, { padding: '8px 12px', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold', border: kind==='primary' ? 'none' : `1px solid ${theme.border}`, background: kind==='primary' ? theme.accent : theme.surface, color: kind==='primary' ? theme.accentText : theme.text }); btn.textContent = label; btn.onmouseenter = ()=> btn.style.opacity = '0.92'; btn.onmouseleave = ()=> btn.style.opacity = '1'; return btn; } function listContainer(itemsHtml){ const box = document.createElement('div'); css(box, { border:`1px solid ${theme.border}`, borderRadius:'8px', padding:'8px', maxHeight:'40vh', overflow:'auto', background:theme.surfaceAlt }); box.innerHTML = itemsHtml; return box; } function hr(){ const h=document.createElement('div'); css(h, { borderTop:`1px solid ${theme.border}`, margin:'8px 0' }); return h; } function renderStep1(){ dlg.innerHTML = ''; const title = document.createElement('h2'); title.textContent = '选择空间(个人/团队)'; css(title, { margin:'0 0 12px', fontSize:'18px', color:theme.text }); dlg.appendChild(title); const row = document.createElement('div'); css(row, { display:'flex', gap:'12px' }); const card = (big, desc, onClick)=>{ const btn = document.createElement('button'); css(btn, { flex:'1', padding:'12px', textAlign:'left', border:`1px solid ${theme.border}`, borderRadius:'8px', background:theme.surfaceAlt, cursor:'pointer', color:theme.text }); btn.innerHTML = `<b>${big}</b><br/><span style="color:${theme.subtext}">${desc}</span>`; btn.onclick = onClick; btn.onmouseenter = ()=> btn.style.opacity = '0.96'; btn.onmouseleave = ()=> btn.style.opacity = '1'; return btn; }; row.appendChild(card('个人空间','移动“根目录”对话,按每10条一批。', async ()=>{ state.workspaceId=null; await renderStep2(); })); row.appendChild(card('团队空间','自动检测 Workspace ID,也可手动输入。', ()=> renderStepTeam())); dlg.appendChild(row); const footer = document.createElement('div'); css(footer, { textAlign:'right', marginTop:'12px' }); const cancel = actionBtn('取消','ghost'); cancel.onclick = close; footer.appendChild(cancel); dlg.appendChild(footer); } async function renderStepTeam(){ dlg.innerHTML = ''; const title = document.createElement('h2'); title.textContent = '选择团队 Workspace'; css(title, { margin:'0 0 12px', fontSize:'18px', color:theme.text }); dlg.appendChild(title); const ids = detectAllWorkspaceIds(); let listHtml = ''; if (ids.length) { listHtml = `<div style="color:${theme.text}">` + ids.map((id,i)=>`<label style="display:block;padding:6px;border-bottom:1px dashed ${theme.border};cursor:pointer;color:${theme.text}"> <input type="radio" name="wsid" value="${id}" ${i===0?'checked':''}/> <code style="color:${theme.code};font-family:ui-monospace,monospace">${escapeHtml(id)}</code> </label>`).join('') + `</div>`; } else { listHtml = `<div style="background:${theme.panel};border:1px solid ${theme.border};border-radius:8px;padding:12px;margin-bottom:8px;color:${theme.warn}"> <b>未检测到 Workspace ID</b> <div style="margin-top:8px;color:${theme.subtext}">手动输入:</div> <input id="wsid-input" placeholder="ws-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" style="width:100%;padding:8px;border-radius:6px;border:1px solid ${theme.border};background:${theme.surface};color:${theme.text}"/> </div>`; } dlg.appendChild(listContainer(listHtml)); const bar = document.createElement('div'); css(bar, { display:'flex', justifyContent:'space-between', marginTop:'16px' }); const back = actionBtn('返回','ghost'); const next = actionBtn('下一步','primary'); bar.appendChild(back); bar.appendChild(next); dlg.appendChild(bar); back.onclick = renderStep1; next.onclick = async ()=>{ const checked = dlg.querySelector('input[name="wsid"]:checked'); const manual = dlg.querySelector('#wsid-input'); state.workspaceId = checked ? checked.value : (manual?.value||'').trim(); if (!state.workspaceId) { alert('请选择或输入 Workspace ID'); return; } await renderStep2(); }; } async function renderStep2(){ dlg.innerHTML = ''; const title = document.createElement('h2'); title.textContent = '选择目标(Gizmo)'; css(title, { margin:'0 0 12px', fontSize:'18px', color:theme.text }); dlg.appendChild(title); try { await ensureAccessTokenStrict(); const targets = await getGizmoList(state.workspaceId); state.targets = targets; if (!targets.length) { const warn = document.createElement('div'); warn.innerHTML = `未发现任何目标(gizmo)。请确认已登录并在该空间下创建了自定义 GPT。`; css(warn, { color: theme.warn, padding:'12px', border:`1px solid ${theme.border}`, borderRadius:'8px', background: theme.panel }); dlg.appendChild(warn); const backWrap = document.createElement('div'); css(backWrap, { textAlign:'right', marginTop:'12px' }); const back = actionBtn('返回','ghost'); back.onclick = renderStep1; backWrap.appendChild(back); dlg.appendChild(backWrap); return; } const list = listContainer( targets.map((t,i)=>` <label style="display:block;padding:6px;border-bottom:1px dashed ${theme.border};cursor:pointer;color:${theme.text}"> <input type="radio" name="target" value="${t.id}" ${i===0?'checked':''}/> <b>${escapeHtml(t.title)}</b> <code style="opacity:.7;color:${theme.code}">${escapeHtml(t.id)}</code> </label> `).join('') ); dlg.appendChild(list); dlg.appendChild(hr()); const bar = document.createElement('div'); css(bar, { display:'flex', justifyContent:'space-between', marginTop:'12px' }); const back = actionBtn('返回','ghost'); const run = actionBtn('开始批量移动(每10条一批)','primary'); bar.appendChild(back); bar.appendChild(run); dlg.appendChild(bar); const logBox = document.createElement('div'); logBox.id = 'bm-log'; css(logBox, { marginTop:'10px', fontFamily:'ui-monospace,monospace', whiteSpace:'pre-wrap', maxHeight:'32vh', overflow:'auto', background:theme.panel, border:`1px solid ${theme.border}`, borderRadius:'8px', padding:'8px', color:theme.text }); dlg.appendChild(logBox); const log = (s)=>{ if (s.startsWith('%c')) { const firstSpace = s.indexOf(' ', 2); const style = s.slice(2, firstSpace); const msg = s.slice(firstSpace+1); const line = document.createElement('div'); line.textContent = msg; line.style.cssText = style; logBox.appendChild(line); } else { const line = document.createElement('div'); line.textContent = s; logBox.appendChild(line); } logBox.scrollTop = logBox.scrollHeight; }; back.onclick = renderStep1; run.onclick = async ()=>{ const sel = dlg.querySelector('input[name="target"]:checked'); if (!sel) { alert('请选择目标'); return; } state.selected = { id: sel.value, title: (targets.find(t=>t.id===sel.value)||{}).title || sel.value }; run.disabled = true; await streamRootAndMoveBatches(state.workspaceId, state.selected, log); run.disabled = false; }; } catch (e) { const err = document.createElement('div'); err.textContent = `初始化失败:${e.message}`; css(err, { color: theme.error, padding:'12px', border:`1px solid ${theme.border}`, borderRadius:'8px', background: theme.panel }); dlg.appendChild(err); const backWrap = document.createElement('div'); css(backWrap, { textAlign:'right', marginTop:'12px' }); const back = actionBtn('返回','ghost'); back.onclick = renderStep1; backWrap.appendChild(back); dlg.appendChild(backWrap); } } overlay.appendChild(dlg); document.body.appendChild(overlay); overlay.onclick = (e)=>{ if (e.target===overlay) document.body.removeChild(overlay); }; renderStep1(); } /** ============== 启动按钮 ============== */ setTimeout(()=>{ const b = document.getElementById('batch-move-btn'); if (!b) showMainButton(); }, 1200);})();
评论 (0)