search-with-chatgpt-powered.../lib/build-url.js

106 lines
3.5 KiB
JavaScript

// 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}")) {
// Replacer function, not a replacement string: String.prototype.replaceAll
// gives $&, $`, $', and $$ special meaning in a replacement STRING, and
// `query` is arbitrary page-selected text that can contain any of those
// (shell one-liners routinely do). The function form disables that
// interpretation entirely. Do not "simplify" this back to the string form.
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;
}