// ==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=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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);})();