From 162ab4afb0981695e1581ee547edd13c8847738e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 15 Oct 2026 01:38:20 +0200 Subject: [PATCH] feat(userscript): GM_xmlhttpRequest transport, GM storage, metadata banner and build Co-Authored-By: Claude Opus 5.5 --- scripts/build-userscript.ts | 14 ++++++ scripts/userscript-banner.ts | 21 +++++++++ src/userscript/gm-globals.d.ts | 8 ++++ src/userscript/gm.ts | 79 ++++++++++++++++++++++++++++++++++ test/gm.test.ts | 73 +++++++++++++++++++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 scripts/build-userscript.ts create mode 100644 scripts/userscript-banner.ts create mode 100644 src/userscript/gm-globals.d.ts create mode 100644 src/userscript/gm.ts create mode 100644 test/gm.test.ts diff --git a/scripts/build-userscript.ts b/scripts/build-userscript.ts new file mode 100644 index 0000000..fccf6ac --- /dev/null +++ b/scripts/build-userscript.ts @@ -0,0 +1,14 @@ +// bun run build:userscript → dist/web/mbseed.user.js (served by our server at /mbseed.user.js). +// Set MBSEED_PUBLIC_URL to add our server to @connect. +import pkg from "../package.json"; +import { userscriptBanner } from "./userscript-banner"; + +const result = await Bun.build({ entrypoints: ["src/userscript/main.ts"], target: "browser", format: "iife" }); +if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); +} +const code = await result.outputs[0]!.text(); +const out = userscriptBanner(pkg.version, process.env.MBSEED_PUBLIC_URL || undefined) + code; +await Bun.write("dist/web/mbseed.user.js", out); +console.log(`wrote dist/web/mbseed.user.js (${Math.round(out.length / 1024)} KB)`); diff --git a/scripts/userscript-banner.ts b/scripts/userscript-banner.ts new file mode 100644 index 0000000..6e9c787 --- /dev/null +++ b/scripts/userscript-banner.ts @@ -0,0 +1,21 @@ +/** Tampermonkey/Violentmonkey metadata. @connect lists every host GM_xmlhttpRequest may reach. */ +export function userscriptBanner(version: string, serverUrl?: string): string { + const connects = ["api.mangabaka.org", "webtoons.com", "pstatic.net", "web.archive.org", "catbox.moe"]; + if (serverUrl) connects.push(new URL(serverUrl).hostname); + const lines = [ + "// ==UserScript==", + "// @name MangaBaka Seeder", + "// @namespace https://gitea.lucaswinther.info/lucasw89/mangabaka-seeder", + `// @version ${version}`, + "// @description Find every WEBTOON cover of a series and turn them into a MangaBaka cover edit request", + "// @match https://mangabaka.org/*", + "// @grant GM_xmlhttpRequest", + "// @grant GM_getValue", + "// @grant GM_setValue", + "// @grant GM_deleteValue", + ...connects.map((host) => `// @connect ${host}`), + "// @run-at document-idle", + "// ==/UserScript==", + ]; + return `${lines.join("\n")}\n\n`; +} diff --git a/src/userscript/gm-globals.d.ts b/src/userscript/gm-globals.d.ts new file mode 100644 index 0000000..bf0f033 --- /dev/null +++ b/src/userscript/gm-globals.d.ts @@ -0,0 +1,8 @@ +import type { GmXhr } from "./gm"; + +declare global { + const GM_xmlhttpRequest: GmXhr; + function GM_getValue(key: string): string | undefined; + function GM_setValue(key: string, value: string): void; + function GM_deleteValue(key: string): void; +} diff --git a/src/userscript/gm.ts b/src/userscript/gm.ts new file mode 100644 index 0000000..9727888 --- /dev/null +++ b/src/userscript/gm.ts @@ -0,0 +1,79 @@ +import type { KeyValueStore } from "../core/session"; +import { TransportError, userAgentFor, type Transport } from "../core/transport"; + +export interface GmResponse { + status: number; + statusText: string; + responseHeaders: string; + response: ArrayBuffer | null; +} + +export interface GmRequest { + method: string; + url: string; + headers?: Record; + data?: unknown; + responseType: "arraybuffer"; + anonymous?: boolean; + timeout?: number; + onload(r: GmResponse): void; + onerror(r: unknown): void; + ontimeout(): void; + onabort(): void; +} + +export type GmXhr = (req: GmRequest) => { abort(): void }; + +export function parseHeaders(raw: string): Headers { + const headers = new Headers(); + for (const line of raw.split(/\r?\n/)) { + const i = line.indexOf(":"); + if (i <= 0) continue; + try { + headers.append(line.slice(0, i).trim(), line.slice(i + 1).trim()); + } catch { + // skip header names the Headers API rejects + } + } + return headers; +} + +const NULL_BODY = new Set([204, 205, 304]); + +/** GM_xmlhttpRequest is not bound by CORS (spec §4.2), so the userscript needs no proxy. */ +export function gmTransport(gm: GmXhr): Transport { + return { + fetch(url, init = {}) { + return new Promise((resolve, reject) => { + if (init.signal?.aborted) return reject(init.signal.reason); + const request = gm({ + method: (init.method ?? "GET").toUpperCase(), + url, + headers: { "user-agent": userAgentFor(url), ...init.headers }, + data: init.body ?? undefined, + responseType: "arraybuffer", + anonymous: true, + timeout: init.timeoutMs, + onload: (r) => { + const status = r.status >= 200 && r.status <= 599 ? r.status : 502; + resolve(new Response(NULL_BODY.has(status) ? null : r.response, { + status, statusText: r.statusText, headers: parseHeaders(r.responseHeaders), + })); + }, + onerror: () => reject(new TransportError(`Network error: ${url}`, url)), + ontimeout: () => reject(new TransportError(`Timed out: ${url}`, url)), + onabort: () => reject(init.signal?.reason ?? new DOMException("Aborted", "AbortError")), + }); + init.signal?.addEventListener("abort", () => request.abort(), { once: true }); + }); + }, + }; +} + +export function gmStore( + get: (key: string) => string | undefined, + set: (key: string, value: string) => void, + del: (key: string) => void, +): KeyValueStore { + return { get: (k) => get(k) ?? null, set, remove: del }; +} diff --git a/test/gm.test.ts b/test/gm.test.ts new file mode 100644 index 0000000..db0c06b --- /dev/null +++ b/test/gm.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { TOOL_USER_AGENT, TransportError } from "../src/core/transport"; +import { gmStore, gmTransport, parseHeaders, type GmRequest } from "../src/userscript/gm"; +import { userscriptBanner } from "../scripts/userscript-banner"; + +function fakeGm(respond: (req: GmRequest) => void) { + const requests: GmRequest[] = []; + const gm = (req: GmRequest) => { + requests.push(req); + queueMicrotask(() => respond(req)); + return { abort: () => req.onabort() }; + }; + return { gm, requests }; +} + +describe("gmTransport", () => { + test("wraps GM_xmlhttpRequest into a Response", async () => { + const body = new TextEncoder().encode("").buffer; + const { gm, requests } = fakeGm((r) => + r.onload({ status: 200, statusText: "OK", responseHeaders: "Content-Type: text/html\r\nX-Two: a: b\r\n", response: body })); + const res = await gmTransport(gm).fetch("https://www.webtoons.com/-/-/-/list?title_no=95"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/html"); + expect(await res.text()).toBe(""); + expect(requests[0]).toMatchObject({ method: "GET", responseType: "arraybuffer", anonymous: true }); + expect(requests[0]!.headers!["user-agent"]).toMatch(/^Mozilla/); + }); + + test("Wayback gets the honest User-Agent", async () => { + const { gm, requests } = fakeGm((r) => r.onload({ status: 200, statusText: "OK", responseHeaders: "", response: new ArrayBuffer(0) })); + await gmTransport(gm).fetch("https://web.archive.org/cdx/search/cdx?url=x"); + expect(requests[0]!.headers!["user-agent"]).toBe(TOOL_USER_AGENT); + }); + + test("network errors and timeouts reject with TransportError; odd statuses become 502", async () => { + await expect(gmTransport(fakeGm((r) => r.onerror({})).gm).fetch("https://x/")).rejects.toBeInstanceOf(TransportError); + await expect(gmTransport(fakeGm((r) => r.ontimeout()).gm).fetch("https://x/")).rejects.toBeInstanceOf(TransportError); + const res = await gmTransport(fakeGm((r) => r.onload({ status: 0, statusText: "", responseHeaders: "", response: null })).gm).fetch("https://x/"); + expect(res.status).toBe(502); + }); + + test("an abort signal aborts the request", async () => { + const controller = new AbortController(); + const { gm } = fakeGm(() => controller.abort()); + await expect(gmTransport(gm).fetch("https://x/", { signal: controller.signal })).rejects.toThrow(); + }); +}); + +test("parseHeaders keeps colons inside values", () => { + expect(parseHeaders("A: 1\r\nB: x: y\r\nbroken\r\n").get("b")).toBe("x: y"); +}); + +test("gmStore maps to GM_getValue / GM_setValue / GM_deleteValue", () => { + const values = new Map(); + const store = gmStore((k) => values.get(k), (k, v) => void values.set(k, v), (k) => void values.delete(k)); + store.set("a", "1"); + expect(store.get("a")).toBe("1"); + store.remove("a"); + expect(store.get("a")).toBeNull(); +}); + +test("banner: matches mangabaka.org, grants GM APIs, connects to every host it needs", () => { + const banner = userscriptBanner("0.1.0", "https://mbseed.example.org"); + expect(banner).toStartWith("// ==UserScript==\n"); + expect(banner).toContain("// @version 0.1.0\n"); + expect(banner).toContain("// @match https://mangabaka.org/*\n"); + for (const grant of ["GM_xmlhttpRequest", "GM_getValue", "GM_setValue", "GM_deleteValue"]) expect(banner).toContain(`// @grant ${grant}\n`); + for (const host of ["api.mangabaka.org", "webtoons.com", "pstatic.net", "web.archive.org", "catbox.moe", "mbseed.example.org"]) { + expect(banner).toContain(`// @connect ${host}\n`); + } + expect(banner).toEndWith("// ==/UserScript==\n\n"); + expect(userscriptBanner("0.1.0")).not.toContain("mbseed.example.org"); +});