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
// ==/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或关注我们的公众号极客氢云获取最新地址