Created
July 30, 2026 12:35
-
-
Save nakasyou/2b5a8aabc5a974ae89120a343cb3951f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name ChatGPT Continue Mode | |
| // @namespace https://chatgpt.com/ | |
| // @version 1.1.0 | |
| // @description /continue on と /continue off で自動継続する | |
| // @match https://chatgpt.com/* | |
| // @match https://chat.openai.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| ;(function () { | |
| "use strict" | |
| const CONTROLLER_KEY = "__chatgptContinueModeController" | |
| // スクリプト更新・再実行時に古いインスタンスを破棄する。 | |
| window[CONTROLLER_KEY]?.destroy?.() | |
| let enabled = false | |
| let destroyed = false | |
| let processingCommand = false | |
| let sending = false | |
| /* | |
| * continue. を送信したあと、応答開始を待っている状態。 | |
| */ | |
| let waitingForResponseStart = false | |
| /* | |
| * stop-button を一度確認済みか。 | |
| */ | |
| let responseObserved = false | |
| /* | |
| * 応答終了後の送信予定時刻。 | |
| */ | |
| let sendAfter = null | |
| let lastContinuationAt = 0 | |
| const sleep = milliseconds => | |
| new Promise(resolve => setTimeout(resolve, milliseconds)) | |
| function getEditor() { | |
| const editor = document.querySelector("#prompt-textarea") | |
| return editor instanceof HTMLElement | |
| ? editor | |
| : null | |
| } | |
| function getEditorText() { | |
| const editor = getEditor() | |
| if (!editor) { | |
| return "" | |
| } | |
| return editor.innerText | |
| .replace(/\u00a0/g, " ") | |
| .replace(/\u200b/g, "") | |
| .trim() | |
| } | |
| function getStopButton() { | |
| return document.querySelector( | |
| [ | |
| '[data-testid="stop-button"]', | |
| 'button[aria-label*="Stop"]', | |
| 'button[aria-label*="停止"]', | |
| ].join(","), | |
| ) | |
| } | |
| function getSendButton() { | |
| return document.querySelector( | |
| [ | |
| '[data-testid="send-button"]', | |
| 'button[aria-label*="Send"]', | |
| 'button[aria-label*="送信"]', | |
| ].join(","), | |
| ) | |
| } | |
| function selectEditorContents(editor) { | |
| editor.focus() | |
| const selection = window.getSelection() | |
| if (!selection) { | |
| throw new Error("Selection API is unavailable") | |
| } | |
| const range = document.createRange() | |
| range.selectNodeContents(editor) | |
| selection.removeAllRanges() | |
| selection.addRange(range) | |
| } | |
| function clearEditor(editor = getEditor()) { | |
| if (!(editor instanceof HTMLElement)) { | |
| return false | |
| } | |
| selectEditorContents(editor) | |
| /* | |
| * execCommand が利用できる場合は、ProseMirror の入力経路を通す。 | |
| */ | |
| const deleted = document.execCommand( | |
| "delete", | |
| false, | |
| ) | |
| /* | |
| * 削除できなかった場合のフォールバック。 | |
| */ | |
| if (getEditorText() !== "") { | |
| editor.replaceChildren(document.createElement("p")) | |
| editor.dispatchEvent( | |
| new InputEvent("input", { | |
| bubbles: true, | |
| inputType: "deleteContentBackward", | |
| }), | |
| ) | |
| } | |
| return deleted || getEditorText() === "" | |
| } | |
| function insertText(editor, text) { | |
| if (!(editor instanceof HTMLElement)) { | |
| throw new TypeError("editor must be an HTMLElement") | |
| } | |
| editor.focus() | |
| const selection = window.getSelection() | |
| if (!selection) { | |
| throw new Error("Selection API is unavailable") | |
| } | |
| const range = document.createRange() | |
| range.selectNodeContents(editor) | |
| range.collapse(false) | |
| selection.removeAllRanges() | |
| selection.addRange(range) | |
| const inserted = document.execCommand( | |
| "insertText", | |
| false, | |
| text, | |
| ) | |
| /* | |
| * execCommand が失敗した場合のフォールバック。 | |
| */ | |
| if (!inserted || getEditorText() !== text) { | |
| editor.replaceChildren() | |
| const paragraph = document.createElement("p") | |
| paragraph.textContent = text | |
| editor.append(paragraph) | |
| editor.dispatchEvent( | |
| new InputEvent("input", { | |
| bubbles: true, | |
| inputType: "insertText", | |
| data: text, | |
| }), | |
| ) | |
| } | |
| return getEditorText() === text | |
| } | |
| function setEnabled(nextEnabled) { | |
| enabled = nextEnabled | |
| waitingForResponseStart = false | |
| responseObserved = false | |
| sendAfter = null | |
| if (enabled) { | |
| if (getStopButton()) { | |
| /* | |
| * 現在応答中なら、その終了を待つ。 | |
| */ | |
| responseObserved = true | |
| console.info( | |
| "[continue mode] on; waiting for current response", | |
| ) | |
| } else { | |
| /* | |
| * 応答終了後に ON にした場合でも、最初の continue を送る。 | |
| */ | |
| sendAfter = Date.now() + 3000 | |
| console.info( | |
| "[continue mode] on; first continuation scheduled", | |
| ) | |
| } | |
| } else { | |
| console.info("[continue mode] off") | |
| } | |
| } | |
| function normalizeCommand(text) { | |
| return text | |
| .toLowerCase() | |
| .replace(/\s+/g, " ") | |
| .trim() | |
| } | |
| function handleCommand() { | |
| if (processingCommand) { | |
| return false | |
| } | |
| const command = normalizeCommand( | |
| getEditorText(), | |
| ) | |
| if ( | |
| command !== "/continue on" && | |
| command !== "/continue off" | |
| ) { | |
| return false | |
| } | |
| processingCommand = true | |
| try { | |
| clearEditor() | |
| setEnabled(command === "/continue on") | |
| return true | |
| } finally { | |
| queueMicrotask(() => { | |
| processingCommand = false | |
| }) | |
| } | |
| } | |
| function onKeyDown(event) { | |
| if ( | |
| event.key !== "Enter" || | |
| event.shiftKey || | |
| event.ctrlKey || | |
| event.altKey || | |
| event.metaKey || | |
| event.isComposing | |
| ) { | |
| return | |
| } | |
| if (!handleCommand()) { | |
| return | |
| } | |
| event.preventDefault() | |
| event.stopPropagation() | |
| event.stopImmediatePropagation() | |
| } | |
| function onClick(event) { | |
| const target = event.target | |
| if (!(target instanceof Element)) { | |
| return | |
| } | |
| if ( | |
| !target.closest( | |
| [ | |
| '[data-testid="send-button"]', | |
| 'button[aria-label*="Send"]', | |
| 'button[aria-label*="送信"]', | |
| ].join(","), | |
| ) | |
| ) { | |
| return | |
| } | |
| if (!handleCommand()) { | |
| return | |
| } | |
| event.preventDefault() | |
| event.stopPropagation() | |
| event.stopImmediatePropagation() | |
| } | |
| async function sendContinue() { | |
| if ( | |
| !enabled || | |
| destroyed || | |
| sending || | |
| getStopButton() | |
| ) { | |
| return false | |
| } | |
| const editor = getEditor() | |
| if (!editor) { | |
| console.warn("[continue mode] editor not found") | |
| return false | |
| } | |
| if (getEditorText() !== "") { | |
| console.info( | |
| "[continue mode] paused: editor is not empty", | |
| ) | |
| return false | |
| } | |
| sending = true | |
| try { | |
| editor.focus() | |
| /* | |
| * ProseMirror にテキストを入力する。 | |
| */ | |
| const inserted = document.execCommand( | |
| "insertText", | |
| false, | |
| "continue.", | |
| ) | |
| if (!inserted || getEditorText() !== "continue.") { | |
| editor.replaceChildren() | |
| const paragraph = document.createElement("p") | |
| paragraph.textContent = "continue." | |
| editor.append(paragraph) | |
| } | |
| /* | |
| * React / ProseMirror に内容変更を認識させる。 | |
| */ | |
| editor.dispatchEvent( | |
| new InputEvent("beforeinput", { | |
| bubbles: true, | |
| cancelable: true, | |
| inputType: "insertText", | |
| data: "continue.", | |
| }), | |
| ) | |
| editor.dispatchEvent( | |
| new InputEvent("input", { | |
| bubbles: true, | |
| composed: true, | |
| inputType: "insertText", | |
| data: "continue.", | |
| }), | |
| ) | |
| editor.dispatchEvent( | |
| new Event("change", { | |
| bubbles: true, | |
| }), | |
| ) | |
| await sleep(700) | |
| if (!enabled || destroyed) { | |
| clearEditor(editor) | |
| return false | |
| } | |
| let submitted = false | |
| /* | |
| * 1. 現在有効な送信ボタンをクリックする。 | |
| */ | |
| const sendButton = [ | |
| ...document.querySelectorAll( | |
| [ | |
| '[data-testid="send-button"]', | |
| 'button[aria-label*="Send"]', | |
| 'button[aria-label*="送信"]', | |
| ].join(","), | |
| ), | |
| ].find(button => { | |
| return ( | |
| button instanceof HTMLButtonElement && | |
| !button.disabled && | |
| button.getAttribute("aria-disabled") !== "true" && | |
| button.offsetParent !== null | |
| ) | |
| }) | |
| if (sendButton instanceof HTMLButtonElement) { | |
| sendButton.dispatchEvent( | |
| new PointerEvent("pointerdown", { | |
| bubbles: true, | |
| cancelable: true, | |
| pointerType: "mouse", | |
| }), | |
| ) | |
| sendButton.dispatchEvent( | |
| new MouseEvent("mousedown", { | |
| bubbles: true, | |
| cancelable: true, | |
| view: window, | |
| }), | |
| ) | |
| sendButton.dispatchEvent( | |
| new MouseEvent("mouseup", { | |
| bubbles: true, | |
| cancelable: true, | |
| view: window, | |
| }), | |
| ) | |
| sendButton.click() | |
| submitted = true | |
| console.info( | |
| "[continue mode] send button clicked", | |
| ) | |
| } | |
| await sleep(500) | |
| /* | |
| * 2. クリック後も入力欄が残っているならフォームを送信する。 | |
| */ | |
| if (getEditorText() === "continue.") { | |
| const form = editor.closest("form") | |
| if (form instanceof HTMLFormElement) { | |
| form.requestSubmit() | |
| submitted = true | |
| console.info( | |
| "[continue mode] form.requestSubmit called", | |
| ) | |
| } | |
| } | |
| await sleep(500) | |
| /* | |
| * 3. まだ残っている場合は Enter を発火する。 | |
| */ | |
| if (getEditorText() === "continue.") { | |
| editor.focus() | |
| for (const type of [ | |
| "keydown", | |
| "keypress", | |
| "keyup", | |
| ]) { | |
| editor.dispatchEvent( | |
| new KeyboardEvent(type, { | |
| key: "Enter", | |
| code: "Enter", | |
| keyCode: 13, | |
| which: 13, | |
| bubbles: true, | |
| cancelable: true, | |
| composed: true, | |
| }), | |
| ) | |
| } | |
| submitted = true | |
| console.info( | |
| "[continue mode] Enter events dispatched", | |
| ) | |
| } | |
| await sleep(1000) | |
| /* | |
| * 入力欄が空になったか、生成が始まった場合だけ成功扱い。 | |
| */ | |
| const actuallySubmitted = | |
| getEditorText() === "" || | |
| Boolean(getStopButton()) | |
| if (!actuallySubmitted) { | |
| console.warn( | |
| "[continue mode] submission failed; text is still in editor", | |
| ) | |
| return false | |
| } | |
| lastContinuationAt = Date.now() | |
| waitingForResponseStart = true | |
| responseObserved = Boolean(getStopButton()) | |
| sendAfter = null | |
| console.info( | |
| "[continue mode] continue submitted", | |
| ) | |
| return submitted | |
| } catch (error) { | |
| console.error( | |
| "[continue mode] send failed", | |
| error, | |
| ) | |
| return false | |
| } finally { | |
| sending = false | |
| } | |
| } | |
| async function run() { | |
| while (!destroyed) { | |
| await sleep(250) | |
| if (!enabled) { | |
| continue | |
| } | |
| const stopButton = getStopButton() | |
| if (stopButton) { | |
| /* | |
| * 応答開始または生成中を確認。 | |
| */ | |
| responseObserved = true | |
| waitingForResponseStart = false | |
| sendAfter = null | |
| continue | |
| } | |
| if (waitingForResponseStart) { | |
| /* | |
| * 送信直後、stop-button が出るまで待つ。 | |
| */ | |
| if ( | |
| Date.now() - lastContinuationAt > | |
| 30_000 | |
| ) { | |
| waitingForResponseStart = false | |
| /* | |
| * 応答開始を観測できなかった場合、即座の無限再送は避ける。 | |
| */ | |
| sendAfter = Date.now() + 5000 | |
| console.warn( | |
| "[continue mode] response start not detected; retry scheduled", | |
| ) | |
| } | |
| continue | |
| } | |
| if (responseObserved) { | |
| /* | |
| * stop-button が消えたので応答終了。 | |
| */ | |
| responseObserved = false | |
| sendAfter = Date.now() + 3000 | |
| console.info( | |
| "[continue mode] response finished; continuation scheduled", | |
| ) | |
| continue | |
| } | |
| if ( | |
| sendAfter !== null && | |
| Date.now() >= sendAfter | |
| ) { | |
| /* | |
| * 送信に失敗したときも、高頻度で繰り返さない。 | |
| */ | |
| sendAfter = Date.now() + 3000 | |
| await sendContinue() | |
| } | |
| } | |
| } | |
| document.addEventListener( | |
| "keydown", | |
| onKeyDown, | |
| true, | |
| ) | |
| document.addEventListener( | |
| "click", | |
| onClick, | |
| true, | |
| ) | |
| window[CONTROLLER_KEY] = { | |
| enable() { | |
| setEnabled(true) | |
| }, | |
| disable() { | |
| setEnabled(false) | |
| }, | |
| get enabled() { | |
| return enabled | |
| }, | |
| async sendNow() { | |
| return sendContinue() | |
| }, | |
| destroy() { | |
| if (destroyed) { | |
| return | |
| } | |
| destroyed = true | |
| enabled = false | |
| document.removeEventListener( | |
| "keydown", | |
| onKeyDown, | |
| true, | |
| ) | |
| document.removeEventListener( | |
| "click", | |
| onClick, | |
| true, | |
| ) | |
| console.info( | |
| "[continue mode] destroyed", | |
| ) | |
| }, | |
| } | |
| void run() | |
| console.info( | |
| "[continue mode] loaded", | |
| ) | |
| })() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment