diff --git a/src/capture.ts b/src/capture.ts index 6a2ccc8..10f07da 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -45,6 +45,15 @@ function timeoutSignal(ms: number): AbortSignal { } async function downloadAsset(assetUrl: string, slug: string): Promise { + const downloaded = await downloadImage(assetUrl); + if (!downloaded) return null; + const filename = safeAssetName(assetUrl, downloaded.contentType, downloaded.bytes); + await fs.ensureDir(artifactAssetsDir(slug)); + await fs.writeFile(path.join(artifactAssetsDir(slug), filename), downloaded.bytes); + return `assets/${filename}`; +} + +async function downloadImage(assetUrl: string): Promise<{ contentType: string; bytes: Buffer } | null> { const response = await fetch(assetUrl, { signal: timeoutSignal(20_000), headers: { @@ -56,10 +65,14 @@ async function downloadAsset(assetUrl: string, slug: string): Promise 20 * 1024 * 1024) return null; - const filename = safeAssetName(assetUrl, contentType, bytes); - await fs.ensureDir(artifactAssetsDir(slug)); - await fs.writeFile(path.join(artifactAssetsDir(slug), filename), bytes); - return `assets/${filename}`; + return { contentType, bytes }; +} + +async function downloadAssetDataUri(assetUrl: string): Promise { + const downloaded = await downloadImage(assetUrl); + if (!downloaded) return null; + if (downloaded.bytes.byteLength > 256 * 1024) return null; + return `data:${downloaded.contentType};base64,${downloaded.bytes.toString("base64")}`; } async function localizeAssets(conversation: NormalizedConversation, slug: string): Promise<{ failures: string[] }> { @@ -84,9 +97,10 @@ async function localizeAssets(conversation: NormalizedConversation, slug: string continue; } try { - const local = await downloadAsset(absolute, slug); - if (local) { - $(image).attr("src", local); + const isCitationIcon = $(image).closest("a[data-citation]").length > 0; + const src = isCitationIcon ? await downloadAssetDataUri(absolute) : await downloadAsset(absolute, slug); + if (src) { + $(image).attr("src", src); } else { $(image).removeAttr("src"); failures.push(absolute); @@ -105,6 +119,7 @@ function normalizeCopiedHtml(html: string): string { const $ = cheerio.load(html, {}, false); $("script, style, iframe, object, embed, form, button, textarea, input, nav").remove(); + $("svg").remove(); function escapeCode(value: string): string { return value.replace(/&/g, "&").replace(//g, ">"); @@ -141,6 +156,12 @@ function normalizeCopiedHtml(html: string): string { }); if (decorativeImages.length > 0) { link.attr("data-citation", "true"); + link.removeAttr("alt"); + const firstImageHtml = $.html(decorativeImages.first()); + const label = link.text().replace(/\s+/g, " ").trim(); + link.empty(); + if (firstImageHtml) link.append(firstImageHtml); + if (label) link.append(`${escapeCode(label)}`); } }); @@ -292,6 +313,18 @@ async function extractConversation(sourceUrl: string): Promise undefined); await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); await page.waitForTimeout(1000); + await page.evaluate(() => window.scrollTo(0, 0)).catch(() => undefined); + await page + .waitForFunction( + () => { + const firstTurn = document.querySelector("[data-testid='conversation-turn-1'], [data-turn]"); + if (!firstTurn) return false; + return Boolean(firstTurn.querySelector("[data-message-author-role]")) || (firstTurn.textContent || "").trim().length > 0; + }, + { timeout: 3_000 } + ) + .catch(() => undefined); + await page.waitForTimeout(750); const actualUrl = page.url(); if (!new URL(actualUrl).pathname.startsWith(expectedPath)) { @@ -349,6 +382,93 @@ async function extractConversation(sourceUrl: string): Promise message.text.length > 0); + function normalizeText(value: string): string { + return value.replace(/\s+/g, " ").trim(); + } + + function parsePayloadMessages(): Array<{ role: "user" | "assistant"; text: string }> { + const script = Array.from(document.scripts).find((candidate) => { + const text = candidate.textContent || ""; + return text.includes("serverResponse") && text.includes("linear_conversation"); + })?.textContent; + const match = script?.match(/enqueue\((.*)\);?$/s); + if (!match) return []; + try { + const flattened = JSON.parse(JSON.parse(match[1])); + const keyCache = new Map(); + function keyRef(name: string): number { + if (!keyCache.has(name)) keyCache.set(name, flattened.indexOf(name)); + return keyCache.get(name) ?? -1; + } + function propRef(objectRef: unknown, key: string): unknown { + if (typeof objectRef !== "number" || objectRef < 0) return undefined; + const object = flattened[objectRef]; + if (!object || typeof object !== "object" || Array.isArray(object)) return undefined; + return (object as Record)[`_${keyRef(key)}`]; + } + function primitive(ref: unknown): unknown { + if (typeof ref !== "number" || ref < 0) return undefined; + return flattened[ref]; + } + const linearKey = keyRef("linear_conversation"); + const dataObject = flattened.find((value: unknown) => { + return Boolean(value && typeof value === "object" && !Array.isArray(value) && `_${linearKey}` in (value as Record)); + }) as Record | undefined; + const linearRef = dataObject?.[`_${linearKey}`]; + const linearRefs = primitive(linearRef); + if (!Array.isArray(linearRefs)) return []; + function messageText(messageRef: unknown): string { + const contentRef = propRef(messageRef, "content"); + const partsRef = propRef(contentRef, "parts"); + const parts = primitive(partsRef); + if (!Array.isArray(parts)) return ""; + return normalizeText( + parts + .map((partRef) => primitive(partRef)) + .filter((part): part is string => typeof part === "string") + .join("\n") + ); + } + return linearRefs + .map((itemRef) => { + const messageRef = propRef(itemRef, "message"); + const authorRef = propRef(messageRef, "author"); + const role = primitive(propRef(authorRef, "role")); + const text = messageText(messageRef); + return { role, text }; + }) + .filter((message): message is { role: "user" | "assistant"; text: string } => { + if (message.role !== "user" && message.role !== "assistant") return false; + if (!message.text) return false; + if (message.text === "Original custom instructions no longer available") return false; + if (message.text === "The output of this plugin was redacted.") return false; + if (/^\{"only_tools":true\}$/.test(message.text)) return false; + return true; + }); + } catch { + return []; + } + } + + const payloadMessages = parsePayloadMessages(); + if (messages.length > 0 && payloadMessages.length > 0) { + const firstDomText = normalizeText(messages[0].text); + const firstDomIndex = payloadMessages.findIndex((message) => { + const payloadText = normalizeText(message.text); + return payloadText === firstDomText || payloadText.startsWith(firstDomText.slice(0, 120)) || firstDomText.startsWith(payloadText.slice(0, 120)); + }); + if (firstDomIndex > 0) { + messages.unshift( + ...payloadMessages.slice(0, firstDomIndex).map((message, index) => ({ + id: `payload-${index + 1}`, + role: message.role, + text: message.text, + html: textToHtml(message.text) + })) + ); + } + } + const bodyText = document.body.textContent || ""; if (/verify you are human|captcha|unusual activity/i.test(bodyText)) { throw new Error("ChatGPT verification or CAPTCHA page was shown."); diff --git a/src/render.ts b/src/render.ts index 894d771..1b65e34 100644 --- a/src/render.ts +++ b/src/render.ts @@ -20,7 +20,9 @@ const icons = { share: ``, chevron: ``, sun: ``, - moon: `` + moon: ``, + arrowUp: ``, + arrowDown: `` }; function css(): string { @@ -59,7 +61,7 @@ html[data-theme="dark"] { --bg: #212121; --panel: #212121; --text: #ececec; --muted: #a8a8a8; --border: #3f3f46; --soft: #2f2f2f; --code: #e5e7eb; --code-bg: #171717; --user-bubble: #303030; } * { box-sizing: border-box; } -body, .topbar, .message.user .content, .content pre, .content :not(pre) > code, .code-block, .code-block .code-header, .content th, .content td, .content blockquote, .downloads, .icon-button, .shot-menu, .shot-menu button, .toast { +body, .topbar, .message.user .content, .content pre, .content :not(pre) > code, .code-block, .code-block .code-header, .content th, .content td, .content blockquote, .content a[data-citation], .downloads, .icon-button, .shot-menu, .shot-menu button, .toast { transition: background-color .22s ease, border-color .22s ease, color .22s ease, box-shadow .22s ease; } body { margin: 0; background: var(--bg); color: var(--text); font-size: 16px; line-height: 1.65; } @@ -108,8 +110,10 @@ h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spa .content th, .content td { border: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; } .content blockquote { border-left: 3px solid var(--border); margin: 1em 0; padding-left: 14px; color: var(--muted); } .content img { max-width: 100%; height: auto; border-radius: 8px; } -.content a[data-citation] { color: var(--muted); font-size: 0.92em; white-space: nowrap; text-decoration-thickness: 1px; text-underline-offset: 2px; } -.content a[data-citation] img { width: 14px; height: 14px; max-width: 14px; max-height: 14px; border-radius: 3px; object-fit: cover; vertical-align: -2px; margin: 0 3px 0 4px; } +.content a[data-citation] { display: inline-flex; align-items: center; gap: 5px; max-width: min(100%, 260px); min-height: 24px; margin: 0 2px; padding: 2px 8px 2px 5px; border: 1px solid var(--border); border-radius: 999px; background: var(--soft); color: var(--text); font-size: 0.88em; line-height: 1.25; white-space: nowrap; text-decoration: none; vertical-align: -5px; } +.content a[data-citation]:hover { background: color-mix(in srgb, var(--soft) 82%, var(--text) 18%); } +.content a[data-citation] img { width: 16px; height: 16px; max-width: 16px; max-height: 16px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; } +.content a[data-citation] span { overflow: hidden; text-overflow: ellipsis; } .downloads { position: relative; z-index: 3; border-top: 1px solid var(--border); background: var(--soft); } .downloads-inner { max-width: 880px; margin: 0 auto; padding: 14px 20px; display: flex; flex-wrap: nowrap; gap: 8px; align-items: center; overflow: visible; } .icon-button { width: 36px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 0; display: inline-grid; place-items: center; flex: 0 0 auto; cursor: pointer; text-decoration: none; } @@ -127,6 +131,8 @@ h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spa .toast-region { position: fixed; left: 50%; bottom: 22px; z-index: 40; display: grid; justify-items: center; pointer-events: none; transform: translateX(-50%); } .toast { max-width: min(calc(100vw - 32px), 320px); border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 9px 12px; font-size: 13px; line-height: 1.35; box-shadow: 0 12px 30px rgba(0,0,0,.14); opacity: 0; transform: translateY(8px); transition: opacity .16s ease, transform .16s ease; } .toast[data-visible="true"] { opacity: 1; transform: translateY(0); } +.jump-controls { position: fixed; right: 18px; bottom: 18px; z-index: 30; display: grid; gap: 8px; } +.jump-controls .icon-button { box-shadow: 0 8px 24px rgba(0,0,0,.12); } .source { max-width: 880px; margin: 0 auto; padding: 18px 20px 34px; color: var(--muted); font-size: 12px; } .source a { color: var(--muted); } .failure { max-width: 720px; margin: 0 auto; padding: 80px 20px; } @@ -144,6 +150,7 @@ h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spa .avatar, .assistant-icon { width: 26px; height: 26px; } .content ul, .content ol { padding-left: 1.25em; } .downloads-inner { padding: 12px 16px; } + .jump-controls { right: 12px; bottom: 12px; } }`; } @@ -216,6 +223,14 @@ function pageScript(slug: string): string { if (copy) copy.addEventListener("click", async function () { await copyText(location.href.replace(/[?#].*$/, ""), "Link copied"); }); + var jumpTop = document.querySelector("[data-jump-top]"); + if (jumpTop) jumpTop.addEventListener("click", function () { + scrollTo({ top: 0, behavior: "smooth" }); + }); + var jumpBottom = document.querySelector("[data-jump-bottom]"); + if (jumpBottom) jumpBottom.addEventListener("click", function () { + scrollTo({ top: document.documentElement.scrollHeight, behavior: "smooth" }); + }); document.querySelectorAll("[data-copy-code]").forEach(function (button) { button.addEventListener("click", async function () { var block = button.closest(".code-block"); @@ -296,6 +311,10 @@ export function renderConversationPage(slug: string, conversation: NormalizedCon Captured from ChatGPT on ${escapeHtml(new Date(conversation.capturedAt).toLocaleString())}. Source: ${escapeHtml(conversation.sourceUrl)} +
+ + +