ChatGPT File-Batch Sender (v0.6)

Batch-send JSON messages with collapsible, draggable, resizable panel & adjustable rest interval

当前为 2025-05-20 提交的版本,查看 最新版本

// ==UserScript==
// @name         ChatGPT File-Batch Sender (v0.6)
// @namespace    http://tampermonkey.net/
// @version      0.6
// @description  Batch-send JSON messages with collapsible, draggable, resizable panel & adjustable rest interval
// @author       liuweiqing
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @grant        none
// @license      MIT
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com

// ==/UserScript==

(() => {
  'use strict';

  /* ---------- 工具 ---------- */
  const $ = (sel, ctx = document) => ctx.querySelector(sel);
  const delay = ms => new Promise(r => setTimeout(r, ms));
  async function waitFor(sel, t = 10000) {
    const start = performance.now();
    while (performance.now() - start < t) {
      const n = $(sel); if (n) return n;
      await delay(100);
    }
    throw `timeout: ${sel}`;
  }
  const untilEnabled = btn =>
    new Promise(res => {
      if (!btn.disabled) return res();
      const mo = new MutationObserver(() => {
        if (!btn.disabled) { mo.disconnect(); res(); }
      });
      mo.observe(btn, { attributes: true, attributeFilter: ['disabled'] });
    });
  async function setComposer(text) {
    const p = await waitFor('div.ProseMirror[data-virtualkeyboard]');
    p.focus();
    document.execCommand('selectAll', false);
    document.execCommand('insertText', false, text);
    p.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }));
  }

  /* ---------- 主题变化监听 ---------- */
  const onTheme = cb => {
    cb();
    new MutationObserver(cb)
      .observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
  };

  /* ---------- 生成操作面板 ---------- */
  const panel = document.createElement('div');
  panel.id = 'batchPanel';
  panel.innerHTML = `
    <style>
      #batchPanel{
        position:fixed;top:12px;right:12px;z-index:2147483647;
        width:220px;padding:0;font-family:Arial,sans-serif;
        background:var(--bg,#fff);color:var(--fg,#000);
        border:1px solid var(--bd,#0003);border-radius:8px;
        box-shadow:0 4px 14px #0004;resize:both;overflow:auto;
        transition:background .2s,color .2s;
      }
      #batchPanel.collapsed{width:46px;height:46px;padding:0;overflow:hidden}
      #batchHeader{
        cursor:move;user-select:none;height:36px;line-height:36px;
        padding:0 10px;font-weight:bold;display:flex;justify-content:space-between;align-items:center;
        border-bottom:1px solid var(--bd,#0003);background:var(--hdr,#f1f3f5);
      }
      #batchBody{padding:10px;display:flex;flex-direction:column;gap:6px}
      #batchPanel input[type="text"],
      #batchPanel input[type="number"]{width:100%;padding:4px;border:1px solid var(--bd,#0003);border-radius:4px}
      #batchBody div.flexRow{display:flex;gap:4px}
      #run{padding:6px;border:none;border-radius:4px;background:#0b5cff;color:#fff;cursor:pointer}
      #run:disabled{opacity:.5;cursor:not-allowed}
      .dark #batchPanel{--bg:#1f1f1f;--fg:#f8f8f8;--bd:#555;--hdr:#2a2a2a}
    </style>
    <div id="batchHeader">
      <span>Batch&nbsp;Sender</span>
      <span id="toggle">▾</span>
    </div>
    <div id="batchBody">
      <input type="file" id="file">
      <span id="fname" style="font-size:12px;color:#888"></span>

      <label>Prompt 前缀</label>
      <input type="text" id="common">

  <label style="display:flex;align-items:center;gap:4px">
  <input type="checkbox" id="restSwitch"> 自动休息
</label>

<div class="flexRow" style="align-items:center">
  <input type="number" id="restCount" value="25"
         placeholder="条数"  title="连续发送多少条后休息"  style="width:60px">
  <span style="font-size:12px;">条</span>
  <input type="number" id="restHours" value="3"
         placeholder="小时" title="每次休息时长(小时,可填小数)"
         step="0.1" min="0" style="width:60px">
  <span style="font-size:12px;">小时</span>
</div>

      <input type="number" id="gap" placeholder="间隔(s)">
      <button id="run">开始</button>
      <progress id="bar" value="0" max="1" style="width:100%"></progress>
    </div>`;
  document.body.appendChild(panel);

  const $header = $('#batchHeader');
  const $toggle = $('#toggle');

  /* ---------- 主题同步 ---------- */
  onTheme(() => {
    if (document.documentElement.classList.contains('dark'))
      panel.classList.add('dark');
    else panel.classList.remove('dark');
  });

  /* ---------- 折叠 / 展开 ---------- */
  let collapsed = localStorage.getItem('batchCollapsed') === '1';
  const applyCollapse = () => {
    panel.classList.toggle('collapsed', collapsed);
    $toggle.textContent = collapsed ? '▸' : '▾';
    localStorage.setItem('batchCollapsed', collapsed ? '1' : '0');
  };
  $toggle.onclick = e => { collapsed = !collapsed; applyCollapse(); e.stopPropagation(); };
  applyCollapse();

  /* ---------- 拖拽移动 ---------- */
  let drag = null;
  $header.addEventListener('mousedown', e => {
    if (e.button !== 0) return;
    drag = { x: e.clientX, y: e.clientY, left: panel.offsetLeft, top: panel.offsetTop };
    e.preventDefault();
  });
  window.addEventListener('mousemove', e => {
    if (!drag) return;
    panel.style.left = drag.left + (e.clientX - drag.x) + 'px';
    panel.style.top  = drag.top  + (e.clientY - drag.y) + 'px';
  });
  window.addEventListener('mouseup', () => { drag = null; });

  /* ---------- DOM 引用 ---------- */
  const $file   = $('#file');   const $fname   = $('#fname');
  const $common = $('#common'); const $gap     = $('#gap');
  const $restSw = $('#restSwitch');
  const $restCt = $('#restCount');
  const $restHr = $('#restHours');
  const $run    = $('#run');    const $bar     = $('#bar');

  /* ---------- 恢复设置 ---------- */
  [
    ['savedFileName', v => $fname.textContent = v],
    ['prompt',        v => $common.value     = v],
    ['delay',         v => $gap.value        = v],
    ['restFlag',      v => $restSw.checked   = v === '1'],
    ['restCount',     v => $restCt.value     = v],
    ['restHours',     v => $restHr.value     = v],
    ['panelLeft',     v => panel.style.left  = v],
    ['panelTop',      v => panel.style.top   = v],
    ['panelBottom',   v => panel.style.bottom = v]
  ].forEach(([k, fn]) => { const v = localStorage.getItem(k); if (v) fn(v); });

  /* ---------- 保存位置变化 ---------- */
  new MutationObserver(() => {
    localStorage.setItem('panelLeft',   panel.style.left   || '');
    localStorage.setItem('panelTop',    panel.style.top    || '');
    localStorage.setItem('panelBottom', panel.style.bottom || '');
  }).observe(panel, { attributes: true, attributeFilter: ['style'] });

  /* ---------- 读取文件 ---------- */
  $file.onchange = e => {
    const f = e.target.files?.[0]; if (!f) return;
    const rd = new FileReader();
    rd.onload = ev => {
      localStorage.setItem('savedFile', ev.target.result);
      localStorage.setItem('savedFileName', f.name);
      $fname.textContent = f.name;
    };
    rd.readAsText(f);
  };

  /* ---------- 主要发送逻辑 ---------- */
  $run.onclick = async () => {
    const raw = localStorage.getItem('savedFile');
    if (!raw) return alert('请先选择文件');
    let data;
    try { data = JSON.parse(raw); } catch { return alert('JSON 解析失败'); }
    if (!Array.isArray(data)) return alert('JSON 必须是数组');

    /* 保存参数 */
    localStorage.setItem('prompt',    $common.value);
    localStorage.setItem('delay',     $gap.value);
    localStorage.setItem('restFlag',  $restSw.checked ? '1' : '0');
    localStorage.setItem('restCount', $restCt.value);
    localStorage.setItem('restHours', $restHr.value);

    /* 参数解析 */
    const prefix     = $common.value || '';
    const gapMs      = (+$gap.value || 100) * 1000;
    const restOn     = $restSw.checked;
    const restAfter  = Math.max(1, +$restCt.value || 25);
    const restMs     = Math.max(0, +$restHr.value || 3) * 3600 * 1000;

    $bar.max = data.length;
    $run.disabled = true;

    try {
      for (let i = 0; i < data.length; i++) {
        await setComposer(`${prefix}${data[i].title ?? data[i]}`);
        await delay(1000);                                         // 额外 1 秒
        const btn = await waitFor('button[data-testid="send-button"]');
        await untilEnabled(btn); btn.click();
        $bar.value = i + 1;

        if (restOn && (i + 1) % restAfter === 0) await delay(restMs);
        else await delay(gapMs);
      }
      alert('全部发送完毕!');
    } catch (e) {
      console.error(e);
      alert(e.message);
    } finally {
      $run.disabled = false;
    }
  };
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址