chatGPTのデータをTamperMoneky経由で取得する
既存の会話履歴なら取得できそう
code:js
await (async () => {
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.icon
2023-02-23
20:01:35 うまくいかない!放棄!
18:15:01 ScrapboxUserScriptにしてみる
19:16:33
選択した文が消えてしまった
sliceミス
retry pushが多すぎる
もっと低レベルなcommit関数を使ったほうが良いかも
入れた
19:09:10 動作確認
19:08:06 まだ粗があった
19:00:13 完成。動作確認する
code: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) {
return undefined;
}
const question = convertSb2Md(text);
if (!question) return undefined;
ask(question);
return undefined;
},
});
code: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.lineslineNo.id; const indent = getIndentCount(scrapbox.Page.lineslineNo.text) ?? 0; let conversationId = scrapbox.Page.lines.map((line) => line.text).join("\n")
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.parts0.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と密結合しているので、ここから通信部分だけ抜き出して使おうと思う
draft
code: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",
};
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",
};
}
export interface MessageRequest {
action: "next";
conversation_id?: string;
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",
},
},
],
model: "text-davinci-002-render",
parent_message_id: options?.parentMessageId ?? uuid(),
};
if (options) message.conversation_id = options.conversationId;
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: Bearer ${result.value},
},
body: JSON.stringify(message),
});
会話開始
code:js
{
action: 'next',
messages: [
{
id: uuid(),
role: 'user',
content: {
content_type: 'text',
},
},
],
model: 'text-davinci-002-render',
parent_message_id: uuid(),
}
続けて会話する
conversation_idにはChatGPTResponse.conversation_idを入れる
parent_message_idにはChatGPTResponse.message.idを入れる
code:js
{
action: 'next',
conversation_id: "...",
messages: [
{
id: uuid(),
role: 'user',
content: {
content_type: 'text',
},
},
],
model: 'text-davinci-002-render',
parent_message_id: "...",
}
code:mod.ts
if (!res.ok) {
switch (res.status) {
case 401:
accessToken = "";
return makeUnauthorizedError();
case 429:
return makeTooManyRequestsError();
}
}
return {
ok: true,
データの読み込み
一つのデータはdata: で始まり\n\nで終わる
code: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; }
}(),
};
};
既存の会話データを得る
code: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},
},
}
);
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を取得する
code: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 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生成
code: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++) {
en = n === 8 || n === 13 || n === 18 || n === 23 ? "-"
}
return e.join("");
};
エラー定義とエラー処理
code: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; code:mod.ts
const isBlockedByCloudflare = (responseText: string): boolean => {
try {
const html = new DOMParser().parseFromString(responseText, "text/html");
if (!html) return false;
const title = html.querySelector("title");
if (!title) return false;
return title.innerText === "Just a moment...";
}
catch (_: unknown) {
return false;
}
};
markdownへの変換
code: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 => {
// 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(deco0))) { 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 "";
};