// ==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 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;
}
};
})();