generated at
chatGPTのデータをTamperMoneky経由で取得する
chatGPTのデータをTamperMonkey経由でscrapboxから取得したい

既存の会話履歴なら取得できそう
GM_fetchを使う
js
await (async () => { const { makeConversation } = await import("https://scrapbox.io/api/code/takker-dist/chat-gpt/mod.js"); const result = await makeConversation("こんにちわ。あなたのことを教えてください"); if (!result.ok) { alert(`${result.value.name} ${result.value.message}`); return; } for await (const r of result.value) { console.debug(r.value); } })();

こんばんは。私はtakkerと申します。あなたは?takker


2023-02-23
20:01:35 うまくいかない!放棄!
18:15:01 ScrapboxUserScriptにしてみる
19:16:33
選択した文が消えてしまった
sliceミス
retry pushが多すぎる
もっと低レベルなcommit関数を使ったほうが良いかも
とりあえず今はpatchで凌ぐ
19:43:18 clearTimeout()を入れてなかったせいだ
入れた
19:09:10 動作確認
$ await import("https://scrapbox.io/api/code/takker-dist/scrapbox-chat-gpt/script.js")
19:08:06 まだ粗があった
19:00:13 完成。動作確認する
$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/chatGPTのデータをTamperMoneky経由で取得する/script.ts
script.ts
import { convertSb2Md } from "./convert.ts"; import { ask } from "./ask.ts"; import { Scrapbox } from "../scrapbox-jp%2Ftypes/userscript.ts"; declare const scrapbox: Scrapbox; scrapbox.PopupMenu.addButton({ title: "Ask GPT", onClick: (text) => { // @ts-ignore 型定義略 if (!globalThis.GM_fetch) { alert('Please install "GM_fetch" from https://scrapbox.io/api/code/takker/GM_fetch/scrapbox.user.js'); return undefined; } const question = convertSb2Md(text); if (!question) return undefined; ask(question); return undefined; }, });
ask.ts
import { patch, takeCursor, press, getIndentCount, makeSocket, Socket, disconnect } from "../scrapbox-userscript-std/mod.ts"; import { getConversation, makeConversation, MakeConversationOptions } from "./mod.ts"; import { Scrapbox } from "../scrapbox-jp%2Ftypes/userscript.ts"; declare const scrapbox: Scrapbox; export const ask = async (question: string): Promise<void> => { if (scrapbox.Layout !== "page") return; // line number or lineIdで書き込み先を決める const { line: lineNo } = takeCursor().getPosition(); const lineId = scrapbox.Page.lines[lineNo].id; const indent = getIndentCount(scrapbox.Page.lines[lineNo].text) ?? 0; let conversationId = scrapbox.Page.lines.map((line) => line.text).join("\n") .match(/https:\/\/chat\.openai\.com\/chat\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/)?.[1] ?? ""; let options: MakeConversationOptions | undefined; if (conversationId) { const result = await getConversation(conversationId); if (!result.ok) { alert(`${result.value.name} ${result.value.message}`); return; } options = { conversationId, parentMessageId: result.value.current_node, }; } const result = await makeConversation(question, options); if (!result.ok) { alert(`${result.value.name} ${result.value.message}`); return; } let socket: Socket | undefined; try { socket = await makeSocket(); let timer: number | undefined; let done: Promise<void> | undefined; /** 挿入した行数 */ let prevLineCount = 0; for await (const { message, conversation_id } of result.value) { conversationId = conversation_id; const inserts = message.content.parts[0].split("\n") .map((line) => `${" ".repeat(indent)}${line}`); if (!options) { inserts.unshift( `${" ".repeat(indent)}https://chat.openai.com/chat/${conversationId}` ); } if (timer) clearTimeout(timer); timer = setTimeout( () => { done ??= patch( scrapbox.Project.name, scrapbox.Page.title, (lines) => { /** この行の直後に書き込む */ let prevLineNo = lines.findIndex((line) => line.id === lineId) if (prevLineNo < 0) prevLineNo = lineNo; /** この行数だけ上書きする */ const skip = prevLineCount; prevLineCount = inserts.length; return [ ...lines.slice(0, prevLineNo + 1).map((line) => line.text), ...inserts, ...lines.slice(prevLineNo + 1 + skip).map((line) => line.text), ]; }, { socket }, ); }, 1000, ); if (done) { await done; done = undefined; } } } finally { if (socket) await disconnect(socket); } };
18:04:54 会話の継続までテストした
ふと誰かやってるかもしれないと思って調べてみたら、案の定すでに作られてあったのだった
vueと密結合しているので、ここから通信部分だけ抜き出して使おうと思う
このまま一気にchatGPTとchatするUserScriptも作れてしまいそうだtakker

draft
型定義はtransitive-bullshit/chatgpt-apiを参考に書き直す
$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/chatGPTのデータをTamperMoneky経由で取得する/mod.ts
mod.ts
/// <reference no-default-lib="true" /> /// <reference lib="esnext" /> /// <reference lib="dom" /> /// <reference lib="deno.ns" /> declare const GM_fetch: typeof fetch; export interface Author { role: "assistant" | "user"; name: string | null; metadata: Record<string, unknown>; } export interface AsisstantMessage { id: string; author: Author; /** UNIX TIME */ create_time: number | null; /** UNIX TIME */ update_time: number | null; content: { content_type: "text", parts: [string]; }; end_turn: null; weight: 1; metadata: { message_type: "next"; model_slug: "text-davinci-002-render-sha"; }; recipient: "all"; } export interface MessageResponse { conversation_id: string; message: AsisstantMessage; error: unknown; } export interface MakeConversationOptions { conversationId: string; parentMessageId: string; } export interface Message { id: string; role: "user"; content: { content_type: "text", parts: [string]; }; } export interface MessageRequest { action: "next"; conversation_id?: string; messages: [Message]; model: "text-davinci-002-render", parent_message_id: string; } export const makeConversation = async ( question: string, options?: MakeConversationOptions ): Promise<Result< AsyncGenerator<MessageResponse, void, unknown>, ErrorLike >> => { const result = await getAccessToken(); if (!result.ok) return result; const message: MessageRequest = { action: "next", messages: [ { id: uuid(), role: "user", content: { content_type: "text", parts: [question], }, }, ], model: "text-davinci-002-render", parent_message_id: options?.parentMessageId ?? uuid(), }; if (options) message.conversation_id = options.conversationId; const res = await GM_fetch("https://chat.openai.com/backend-api/conversation", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${result.value}`, Referrer: "https://chat.openai.com", }, body: JSON.stringify(message), });

会話開始
js
{ action: 'next', messages: [ { id: uuid(), role: 'user', content: { content_type: 'text', parts: [question], }, }, ], model: 'text-davinci-002-render', parent_message_id: uuid(), }
続けて会話する
conversation_id には ChatGPTResponse.conversation_id を入れる
parent_message_id には ChatGPTResponse.message.id を入れる
js
{ action: 'next', conversation_id: "...", messages: [ { id: uuid(), role: 'user', content: { content_type: 'text', parts: [question], }, }, ], model: 'text-davinci-002-render', parent_message_id: "...", }

mod.ts
if (!res.ok) { switch (res.status) { case 401: accessToken = ""; return makeUnauthorizedError(); case 429: return makeTooManyRequestsError(); } } if (!res.body) throw Error("No content in Response of https://chat.openai.com/backend-api/conversation"); return { ok: true,

データの読み込み
一つのデータは data: で始まり \n\n で終わる
mod.ts
value: async function*() { let stack = ""; // @ts-ignore まだdomの型定義にasync iteratorが実装されていない for await (const value of res.body) { stack += String.fromCharCode(...value); const items = stack.split(/\n\n/).map((item) => item.replace(/^data: /, "")); // \n\nがなければ、読み込み継続 if (items.length < 2) continue; // まだ継続するときは、それを残す。末尾が"\n\n"のときは空文字になるので、prevResはまっさらな状態となる stack = items.pop()!; for (const item of items) { if (item === "[DONE]") return; try { yield JSON.parse(item); } catch (e: unknown) { if (!(e instanceof SyntaxError)) throw e; console.error(e); } } if (stack === "[DONE]") return; } }(), }; };

既存の会話データを得る
mod.ts
export interface Conversation { title: string; create_time: number; mapping: Record<string, unknown>; moderation_results: []; /** 一番最後の会話のID */ current_node: string; } export const getConversation = async (conversationId: string): Promise<Result< Conversation, ErrorLike >> => { const result = await getAccessToken(); if (!result.ok) return result; const res = await GM_fetch( `https://chat.openai.com/backend-api/conversation/${conversationId}`, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${result.value}`, Referrer: "https://chat.openai.com", }, } ); if (!res.ok) { switch (res.status) { case 401: accessToken = ""; return makeUnauthorizedError(); case 429: return makeTooManyRequestsError(); } } const value = await res.json(); return { ok: false, value }; };

access tokenを取得する
mod.ts
let accessToken = ""; let expired = 0; export const getAccessToken = async (): Promise<Result<string, UnauthorizedError | BlockedByCloudflareError>> => { if (accessToken && expired > new Date().getTime()) return { ok: true, value: accessToken, }; const res = await GM_fetch("https://chat.openai.com/api/auth/session"); const text = await res.text(); if (isBlockedByCloudflare(text)) return makeBlockedByCloudflareError(); const { accessToken: token, expires } = JSON.parse(text); if (!token) return makeUnauthorizedError(); if (expires) expired = new Date(expires).getTime(); accessToken = token; return { ok: true, value: token }; };

ID生成
mod.ts
const uuid = (): string => { const t = [ "a", "b", "c", "d", "e", "f", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ]; const e: string[] = []; for (let n = 0; n < 36; n++) { e[n] = n === 8 || n === 13 || n === 18 || n === 23 ? "-" : t[Math.ceil(Math.random() * t.length - 1)]; } return e.join(""); };

エラー定義とエラー処理
mod.ts
export type Result<T, E = unknown> = { ok: true; value: T } | { ok: false; value: E }; export interface ErrorLike { name: string; message: string; } export interface TooManyRequestsError { name: "TooManyRequestsError", message: string; } export interface UnauthorizedError { name: "UnauthorizedError"; message: string; } export interface BlockedByCloudflareError { name: "BlockedByCloudflareError"; message: string; } const makeUnauthorizedError = () => ({ ok: false, value: { name: "UnauthorizedError", message: "Please log in https://chat.openai.com." } }) as const; const makeTooManyRequestsError = () => ({ ok: false, value: { name: "TooManyRequestsError", message: "Too many request." } }) as const; const makeBlockedByCloudflareError = () => ({ ok: false, value: { name: "BlockedByCloudflareError", message: "Please pass Cloudflare security check at https://chat.openai.com." } }) as const;
cloudflareのrecapture認証が挟まったかどうかを確かめる
mod.ts
const isBlockedByCloudflare = (responseText: string): boolean => { try { const html = new DOMParser().parseFromString(responseText, "text/html"); if (!html) return false; // cloudflare html be like: https://github.com/zhengbangbo/chat-gpt-userscript/blob/512892caabef2820a3dc3ddfbcf5464fc63c405a/parse.js const title = html.querySelector("title"); if (!title) return false; return title.innerText === "Just a moment..."; } catch (_: unknown) { return false; } };

markdownへの変換
convert.ts
import { parse, Table, Line, CodeBlock, Node as NodeType } from "../scrapbox-parser/mod.ts"; /** Scrapbox記法をMarkdown記法に変える * * @param text scrapbox記法で書かれたテキスト */ export const convertSb2Md = (text: string): string => { const blocks = parse(text, { hasTitle: false }) as ((Table | Line | CodeBlock)[]); /** このindent levelを基準にする */ const topIndentLevel = Math.min(...blocks.map((block) => block.indent)); return blocks.map((block) => { switch (block.type) { case "codeBlock": return [ block.fileName, "\n\`\`\`", block.content, "\`\`\`\n", ].join("\n"); case "table": return convertTable(block); case "line": return convertLine(block, topIndentLevel); } }).join("\n"); }; /** Table記法の変換 */ const convertTable = (table: Table): string => { const line = [table.fileName]; // columnsの最大長を計算する const maxCol = Math.max(...table.cells.map((row) => row.length)); table.cells.forEach((row, i) => { line.push( `| ${ row.map((column) => column.map((node) => convertNode(node)).join("")) .join(" | ") } |`, ); if (i === 0) line.push(`|${" -- |".repeat(maxCol)}`); }); return line.join("\n"); }; const INDENT = " "; // インデントに使う文字 /** 行の変換 */ const convertLine = (line: Line, topIndentLevel: number): string => { const content = line.nodes.map((node) => convertNode(node)).join("").trim(); if (content === "") return ""; // 空行はそのまま返す // リストを作る if (line.indent === topIndentLevel) return content; // トップレベルの行はインデントにしない let result = INDENT.repeat(line.indent - topIndentLevel - 1); if (!/^\d+\. /.test(content)) result += "- "; // 番号なしの行は`-`を入れる return result + content; }; /** Nodeを変換する */ const convertNode = (node: NodeType): string => { switch (node.type) { case "quote": return `> ${node.nodes.map((node) => convertNode(node)).join("")}`; case "helpfeel": return `\`? ${node.text}\``; case "image": case "strongImage": return `![image](${node.src})`; case "icon": case "strongIcon": // 無視 return ""; case "strong": return `**${node.nodes.map((node) => convertNode(node)).join("")}**`; case "formula": return `$${node.formula}$`; case "decoration": { let result = node.nodes.map((node) => convertNode(node)).join(""); if (node.decos.includes("/")) result = ` *${result}* `; if (node.decos.some((deco) => /\*-/.test(deco[0]))) { result = ` **${result}** `; } if (node.decos.includes("~")) result = ` ~~${result}~~ `; return result; } case "code": return ` \`${node.text}\` `; case "commandLine": return ` \`${node.symbol} ${node.text}\` `; case "link": switch (node.pathType) { case "root": return node.href; case "relative": default: return node.content === "" ? ` ${node.href} ` : `[${node.content}](${node.href})`; } case "googleMap": return `[${node.place}](${node.url})`; case "hashTag": return node.href; case "blank": case "plain": return node.text; defautl: return ""; } return ""; };

#2023-04-09 09:12:43
#2023-02-23 18:13:56
#2023-02-22 11:32:04
#2023-02-15 18:13:07