diff --git a/lib/build-url.js b/lib/build-url.js new file mode 100644 index 0000000..e27e356 --- /dev/null +++ b/lib/build-url.js @@ -0,0 +1,100 @@ +// Pure URL construction. No browser APIs, so node:test imports this directly. +// +// Every ChatGPT parameter below is undocumented by OpenAI and has broken before. +// This module's contract is only that it emits a well-formed URL; whether +// ChatGPT honours model / temporary-chat / hints is out of our control, which is +// why each is independently omittable and every label is hint-strength. +// +// The root path is mandatory: chatgpt.com/search began returning 404 in +// mid-2025, and the root form is what OpenAI's own extension uses. + +const BASE = "https://chatgpt.com/"; + +export const MAX_URL_CHARS = 8000; + +// Fraction of the kept text within which a whitespace cut is preferred. +const WHITESPACE_WINDOW = 0.9; + +function applyTemplate(template, query) { + if (query === "") { + return ""; + } + const tpl = typeof template === "string" ? template : ""; + if (tpl.includes("{query}")) { + return tpl.replaceAll("{query}", query); + } + const trimmed = tpl.trim(); + return trimmed === "" ? query : `${trimmed} ${query}`; +} + +function addOptionalParams(url, settings) { + const model = typeof settings.model === "string" ? settings.model.trim() : ""; + if (model !== "") { + url.searchParams.set("model", model); + } + if (settings.temporaryChat) { + url.searchParams.set("temporary-chat", "true"); + } + if (settings.webSearch) { + url.searchParams.set("hints", "search"); + } + return url; +} + +function lastWhitespaceIndex(text) { + for (let i = text.length - 1; i >= 0; i -= 1) { + if (/\s/.test(text[i])) { + return i; + } + } + return -1; +} + +// Returns the longest prefix of `prompt` whose finished URL fits the budget. +// Slicing happens by CODE POINT on the decoded string, so a surrogate pair can +// never be split and a percent-escape can never be severed (encoding happens +// afterwards, via URLSearchParams). +function fitToBudget(prompt, settings) { + const codePoints = Array.from(prompt); + + const urlLengthFor = (count) => { + const url = new URL(BASE); + url.searchParams.set("q", codePoints.slice(0, count).join("")); + addOptionalParams(url, settings); + return url.href.length; + }; + + if (urlLengthFor(codePoints.length) <= MAX_URL_CHARS) { + return prompt; + } + + let low = 0; + let high = codePoints.length; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + if (urlLengthFor(mid) <= MAX_URL_CHARS) { + low = mid; + } else { + high = mid - 1; + } + } + + const kept = codePoints.slice(0, low).join(""); + const boundary = Math.floor(kept.length * WHITESPACE_WINDOW); + const cut = lastWhitespaceIndex(kept); + return cut >= boundary && cut > 0 ? kept.slice(0, cut) : kept; +} + +export function buildChatGptUrl(settings, rawQuery) { + const query = String(rawQuery ?? "").trim(); + const prompt = fitToBudget(applyTemplate(settings.promptTemplate, query), settings); + + // q first, matching the most-cited working examples. Total serialized length + // is order-independent, so measuring without q above is sound. + const url = new URL(BASE); + if (prompt !== "") { + url.searchParams.set("q", prompt); + } + addOptionalParams(url, settings); + return url.href; +} diff --git a/test/build-url.test.js b/test/build-url.test.js new file mode 100644 index 0000000..aa3202f --- /dev/null +++ b/test/build-url.test.js @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js"; + +const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides }); +const queryOf = (url) => new URL(url).searchParams.get("q"); +const paramsOf = (url) => [...new URL(url).searchParams.keys()]; + +test("defaults produce only the q parameter on the root path", () => { + assert.equal( + buildChatGptUrl(DEFAULT_SETTINGS, "hello"), + "https://chatgpt.com/?q=hello", + ); +}); + +test("an empty or whitespace-only query omits q entirely", () => { + for (const raw of ["", " ", "\n\t", null, undefined]) { + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`); + assert.equal(url, "https://chatgpt.com/"); + } +}); + +test("model is sent only when non-empty after trimming", () => { + assert.equal(queryOf(buildChatGptUrl(withSettings({ model: "auto" }), "hi")), "hi"); + assert.equal(new URL(buildChatGptUrl(withSettings({ model: "auto" }), "hi")).searchParams.get("model"), "auto"); + assert.equal(new URL(buildChatGptUrl(withSettings({ model: " auto " }), "hi")).searchParams.get("model"), "auto"); + for (const model of ["", " "]) { + assert.equal(paramsOf(buildChatGptUrl(withSettings({ model }), "hi")).includes("model"), false); + } +}); + +test("temporaryChat and webSearch each appear only when enabled", () => { + assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false); + assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false); + + const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi")); + assert.equal(temp.searchParams.get("temporary-chat"), "true"); + + const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi")); + assert.equal(search.searchParams.get("hints"), "search"); +}); + +test("all four URL-affecting settings combine, q first", () => { + const url = buildChatGptUrl( + withSettings({ + model: "auto", + temporaryChat: true, + webSearch: true, + promptTemplate: "Explain: {query}", + }), + "entropy", + ); + assert.deepEqual(paramsOf(url), ["q", "model", "temporary-chat", "hints"]); + assert.equal(queryOf(url), "Explain: entropy"); +}); + +test("openIn never influences the URL", () => { + const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi"); + for (const openIn of ["new-tab", "background-tab", "new-window"]) { + assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base); + } +}); + +test("special characters survive encoding round-trip", () => { + const raw = 'a & b # c ? d = e + f "g" 100% 😀 日本語\nnewline'; + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(queryOf(url), raw); + assert.equal(url.includes(" "), false); + assert.equal(url.includes("#"), false); +}); + +test("the template replaces every {query} occurrence", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x"); + assert.equal(queryOf(url), "x / x"); +}); + +test("a template without {query} is treated as a prefix, never dropping the query", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why"); + assert.equal(queryOf(url), "Be concise: why"); +}); + +test("an empty or whitespace-only template yields the bare query", () => { + for (const promptTemplate of ["", " "]) { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why"); + } +}); + +test("a non-string template degrades to the bare query", () => { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why"); +}); + +test("an over-long prompt is truncated to fit the URL budget", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "x".repeat(20000)); + assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); + assert.ok(queryOf(url).length > 0); + assert.ok(queryOf(url).length < 20000); +}); + +test("truncation accounts for the other parameters", () => { + const settings = withSettings({ model: "some-long-model-slug", temporaryChat: true, webSearch: true }); + const url = buildChatGptUrl(settings, "x".repeat(20000)); + assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); + assert.equal(new URL(url).searchParams.get("model"), "some-long-model-slug"); + assert.equal(new URL(url).searchParams.get("hints"), "search"); +}); + +test("truncation never splits a surrogate pair", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀".repeat(9000)); + assert.ok(url.length <= MAX_URL_CHARS); + const lonelySurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀 ".repeat(9000)); + assert.equal(/%[0-9A-Fa-f]?$/.test(url), false); + assert.doesNotThrow(() => decodeURIComponent(new URL(url).search.slice(3))); +}); + +test("truncation prefers a whitespace boundary, so no word is cut in half", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "lorem ipsum ".repeat(2000)); + const tokens = queryOf(url).trim().split(/\s+/); + assert.ok(tokens.length > 1); + for (const token of tokens) { + assert.ok(token === "lorem" || token === "ipsum", `truncated mid-word: ${token}`); + } +}); + +test("a query needing no truncation is left byte-identical", () => { + const raw = "a modest question about thermodynamics"; + assert.equal(queryOf(buildChatGptUrl(DEFAULT_SETTINGS, raw)), raw); +});