chatgpt-chat-exporter
CLAUDE.md402 starsChanged 43 days agoMITView on GitHub
ChatGPT Chat Exporter is a browser-based tool for exporting ChatGPT and Google Gemini conversations to Markdown, HTML, or print-ready PDF. Pure JavaScript that runs in the browser console or as userscripts — no runtime dependencies. Single source of truth: src/extraction-engine.js. Every shipped exporter is generated from it by scripts/build-exporters.js: Never edit the generated files directly. Change the engine or the build script, then run npm run build. npm test fails if the generated files are stale. Userscript @version headers come…
We split this CLAUDE.md into the rules it gives an agent
9 rules. About 0 are common, 2 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 65
Fidelity rules: never backslash-escape inside code spans or code fences.
- Distinctive: line 144
Verify the downloaded export against the rendered conversation
- Very specific: line 146
Before blaming the exporter — or before trusting a fixture — paste selector-doctor.js into the same console.
- Very specific: line 164
Every user turn carries button[data-testid="share-prompt-link-turn-action-button"] ("Share prompt"), which shares that message, not the …
- Very specific: line 215
Every li opens with h4[data-message-attribution] ("You said:"), visually hidden by atomic CSS, not by an sr-only class
- Distinctive: line 274
When ChatGPT/Gemini UI changes, update the provider's selector list in this priority order:
- Very specific: line 284
Update README (What's New, Version History) and add temporal/release-notes-vX.Y.Z.md
- Very specific: line 285
Commit, tag vX.Y.Z, push, create the GitHub release
- Very specific: line 291
No private live captures may be committed (private-research/ is gitignored)
≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
ChatGPT Chat Exporter is a browser-based tool for exporting ChatGPT and Google Gemini conversations to Markdown, HTML, or print-ready PDF. Pure JavaScript that runs in the browser console or as userscripts — no runtime dependencies.
Architecture
Single source of truth: src/extraction-engine.js. Every shipped exporter is generated from it by scripts/build-exporters.js:
exporter-markdown.js,exporter-html.js,exporter-pdf.js— ChatGPT console scriptsgemini-exporter-markdown.js— Gemini console scriptselector-doctor.js— console health check for either provider's selector cascadesrc/progress-overlay.js— the in-page progress card, bundled into every console runner and userscriptsrc/userscript-ui.js— native ChatGPT conversation-menu and Share-menu integrationchatgpt-markdown-exporter.user.js,chatgpt-pdf-exporter.user.js— generated userscripts with Markdown and PDF menu actions
Never edit the generated files directly. Change the engine or the build script, then run npm run build. npm test fails if the generated files are stale.
Userscript @version headers come from package.json's version field.
Reference docs live in docs/. The former core/ and archived/ prototype directories were removed in v0.12.0; they are in git history if ever needed.
Source of truth per format
Markdown from ChatGPT reads the payload; everything else reads the DOM.
The payload is the markdown the model produced — the DOM is a rendering of it.
Scraping the rendering and then repairing the result from the source we could
have read directly is what produced seven of the eight defects fixed in
v0.9.3–v0.11.0. canUsePayloadSource gates it; any failure falls through to the
sweep unchanged, and the fetched payload is handed along so it is never
requested twice.
The inversion is Markdown-only on purpose: HTML and PDF need rendered HTML, which the DOM provides natively and the payload would need a markdown parser to produce. Payload-first also needs no sweep at all — no scrolling, no virtualizer, no hidden-tab stall.
- Citation markers are private-use code points:
U+E200 cite U+E202 turn1search0 U+E201. Write the range as an escaped[\uE200-\uE20F]; with literal characters the class can collapse into one that also matches-, silently turninggrep-basedintogrepbasedacross an entire export.metadata.content_references[].items[]carries the real title and URL — better than the DOM, where the same citation is a pill labelled "W3C+1" - Some answers are
toolrecords (isToolMediaMessage,mainPayloadMessages(entries, true)): ChatGPT stores a generated image, and a chart from code execution (execution_outputwithmetadata.aggregate_result.messages[].image_url), as a tool reply — usually beside a hidden copy (is_visually_hidden_from_conversation) and followed by an empty assistant reply. Payload-first rendering exports the visible image-bearing tool record as the assistant's answer and drops the empty reply; a real 142-message export had lost 24 answers this way. A tool record that carries text is not an answer (isToolMediaMessagerequires none):api_toolstores a file it read back as page images plus "Make sure to include in your response to cite this file…", and v1.2.1 exported four of those as replies. The page path adds visible image records to its id matching (pagePayloadMessages) — the signed-in page renders each generated image in a unit listing the tool record's id (verified live 2026-09-27) — but keeps its reply selection, so a caption the page shows is never folded into the image - Only records that render something are expected (
isRenderablePayloadEntry): the empty reply after a generated image and a reply interrupted before any text show nothing in any export, and counting them made a live HTML export "incomplete" - A message addressed to a tool is never content (
recipientother thanall): tool calls — code, searches, image prompts, and redaction notices such as "This code was redacted." — reached exports as "Reasoning / progress" - Read a file id after the pointer's scheme (
payloadAttachmentDescriptors):sediment://file_…(newer uploads, every generated image) andfile-service://file-…. Searching the whole pointer took "file-service" for an id — a bogus[Image: file-service]beside every such upload — and found nothing in the underscore form - Image time grows with the image count (
attachmentDeadline,attachmentRequestTimeout): one fixed 15-second budget embedded 7 of 48 images in a real export. Defaults are 15 s plus 3 s per image, capped at 180 s, with 15 s per download;attachmentMaxDuration/attachmentFetchTimeoutoverride, and explicitmetadataMaxDuration/metadataFetchTimeoutstill bound them. Key an image by its file id, never its name — names repeat - Only the final turn can be unfinished (
hasUnfinishedPayloadMessages): ChatGPT stores a stopped, failed or abandoned reply for good asin_progressorfinished_partial_completion; onlyfinished_successfullymeans a reply ran to its end. An unfinished record earlier in the conversation is part of the record — flagging it made a real 93-message export "incomplete". The final turn is judged by its answer, so a stale tool call beside a finished answer is not streaming - Say where an unfinished reply ends (
INTERRUPTED_NOTE,UNSAVED_REPLY_NOTE,interruptedWithoutReply): a reply that saved no text becomes a stand-in turn rather than vanishing between two prompts, and a partial one ends with a note.closeOpenFencecloses a code fence the reply left open — each reply is its own Markdown document in ChatGPT, but the export concatenates them - Dates are the reader's calendar day (
formatDate):toISOString()dated evening exports in the Americas tomorrow payloadContentTextreturns''for a multimodal part: an image-only turn has its content in attachments, so callers must allow an empty body rather than dropping the message
Engine design
- Provider adapters (
PROVIDERS): per-platform selector cascades for messages, content roots, and titles, tried in priority order (data attributes → ARIA/custom elements → semantic HTML → class heuristics) - ChatGPT serves three transcript generations at once: role-tagged
div[data-message-author-role]turns; anol[data-conversation-transcript]ofli[data-message-role]items whose class names are atomic (meaningless, logged out, verified live 2026-09-25); and, signed in (verified live 2026-09-27), units keyeddata-chatgpt-search-unit-key="…:user"/"…:assistant"that list the message ids they render indata-chatgpt-search-message-ids. All are one cascade entry, so none reads as drift in the doctor. On the second, the message id is theli's ownid(providerMessageId), and chrome is only recognizable by data attributes —[data-message-attribution]("You said:"),[data-message-actions],[data-message-content-controls],[popover]. Never strip it by atomic class. On the third,div[data-turn-key]wraps a prompt and its answer, so it is never a message or a scope. A generated image has no key: a bare[data-chatgpt-search-message-ids]lists its tool record(s), after anh4[data-conversation-role]"ChatGPT said:" label that gives its role (transcriptUnitRole). An answer lists its own id twice; a gallery lists one id per image, and every record a unit lists counts as on the page (represented), or recovery added the gallery's other images again - A button can hold the message (
removeUiElements): the second generation wraps every prompt in<button data-user-message-bubble>, and uploaded images are clickable previews. Stripping every button deleted "Hi" outright (issue #43) and images (issue #40). Buttons that wrap content (paragraphs, lists, tables, the bubble) are unwrapped; media-only buttons keep their media; everything else is removed - Sources can be buttons, not links (
sourceReferenceItems,processSourceReferences): the second generation carries each source's targets as JSON indata-assistant-sources-payload. They feed the References list in document order and become inline links; the trailingsources_footnotecontrol is dropped. Only HTTP(S) targets survive - Pre-wrap is evidence of typed text only at the top of a region with no rendered structure (
isVerbatimTextElement): Gemini styles its whole answerwhite-space: pre-wrap, and reading that verbatim flattened every heading, list and bold run (verified live 2026-09-25). Descendants inherit pre-wrap, so a span beside a<b>must serialize normally too - A turn is a provider concept, not a ChatGPT one (
turnSelector,messageScope): the wrapper that owns a message and whatever renders beside it differs per platform, so each provider declares its own andmessageScopetakes the provider. It must wrap exactly one message. Gemini's owndiv.conversation-containerwraps a pair — oneuser-queryplus onemodel-response— and adopting it would hand both toselectContentRoot, which ranks candidates by text length: the pair wins, every answer gets prefixed with its own question, and both messages collapse to onemessageKeyso the second is dropped as already seen. Gemini's turn is its message element - The title cascade is a liability on Gemini, not an asset (verified live 2026-08-17):
document.titleis the real source on both providers, and Gemini stamps" - Google Gemini"on it — hencedocumentTitleSuffix. Gemini also mounts its model picker under a class matching[class*="conversation-title"], lazily: a first probe saw nothing and v0.9.4 shipped believing the cascade was inert, then the selector won and exported a conversation titled "Flash-Lite". That selector is now gone from Gemini andpreferDocumentTitleputs the tab ahead of any guess. Two rules follow: never loosen a title selector ([class*="title"]also matches Gemini's sidebar — "Notebooks"), and never conclude a selector is inert from one point-in-time probe — page chrome mounts late.filenameFortakes the already-cleanedconversation.title, neverdoc.title diagnose()/selector-doctor.js: a paste-into-the-console health check generated from this engine, so it reports on the selectors that actually ship. It flags the dangerous case — a cascade still working, but only on a late fallback entry, which is what silent drift looks like one release before it breaks- Content pipeline (
serializeMessageContent): clone the message, annotatewhite-space: pre-wrapregions from computed style, strip UI chrome, then process cards → code blocks → math → media → links → tables before serializing to Markdown or HTML - Verbatim protection: code fences, display math, and pre-wrap prompt text bypass Markdown whitespace cleanup; pre-wrap text travels through collision-proof randomized placeholders (
MARKER_PREFIX) - Fidelity rules: never backslash-escape inside code spans or code fences; inline backtick collisions use longer CommonMark delimiters; table cells escape only
| - Sender detection: role attributes first, then class/aria hints, then content heuristics, alternating fallback last (
identifySender) - Detect streaming by behaviour, not by selector (
awaitStreamingSettled): watch whether the newest message keeps growing. A "stop generating" test id changes with every redesign; "the last message is still getting longer" is true of any streaming provider - Deduplicate by identity, never by content (
messageKey): preserve separately identified or separately positioned repeated turns. Use message ids, numbered turn ids, measurable scroller position plus role, then DOM-node identity. Anonymous virtualizers that shift turn positions remain heuristic; never reintroduce content-only or prefix deduplication. - Waiting on the reader is not the export spending its budget (
awaitVisible): the hidden-tab wait used to runwhile (doc.hidden && !outOfTime()), so a backgrounded tab burned the wholemaxDurationdoing nothing and then saved whatever happened to be mounted. Worse, raisingmaxDurationto help a long conversation just bought a longer stall. The wait now draws on its ownmaxHiddenWaitbudget and adds the time back to the deadline. A sweep that runs for minutes must also say so (PROGRESS_INTERVAL) — silence is indistinguishable from a hang, which is how a working export gets abandoned - Wait for the DOM to settle, not for a fixed guess (
awaitRenderSettled): the sweep sleptscrollDelayper step whether or not the virtualizer had already rendered, which on a long conversation is most of the export's wall clock. A MutationObserver on the scroll container resolves as soon as it has been quiet forRENDER_QUIET_INTERVAL, capped at the old delay — so it can only be faster, never less thorough. Measured 3.9× on a 40-message virtualized fixture with identical output. The top-pinning loop deliberately keeps the fixed sleep: it waits on a network fetch of older history, which produces no mutation until it lands, so settling on quiet would declare "no more history" after 60ms and start the sweep below the real top - Gemini renders KaTeX with
.katex-htmlalone — no.katex-mathml, no<annotation>, no MathML — and keeps the TeX indata-mathon the wrapper around the render (div.math-blockfor display,spanfor inline). Two consequences:data-mathmust be in the attribute list and inMATH_ROOT_SELECTOR(the source-carrying node is the parent, not the.katex), andisDisplayMathmust look down for.katex-displayas well as up. Verified live: 42 formulas, 42 recovered - Never remove a visual duplicate that is the only copy: de-duplication is only safe when an accessible representation survives it. v0.11.0 removed
.katex-htmlwhenever no TeX was found, which on Gemini would have deleted every formula in the export instead of de-duplicating anything - Every math renderer hides its TeX somewhere else, and every one emits a visual duplicate (
processMath,texFromNode): look forannotation/annotation-xmlwith a tex encoding, thendata-latex/data-tex/data-formula, then a MathJax v2script[type^="math/tex"]. When no source exists, delete the visual copy (.katex-html,aria-hiddeninsidemjx-container) — serializing both is how a formula becomesf(x)f(x).removeUiElementsmust not stripscript[type^="math/tex"]: it runs first, and stripping it left the MathJax v2 branch as dead code for as long as it existed - Regenerating or editing a turn leaves the old one in the mapping as a sibling branch (
payloadVariantMessages):activePayloadMessageswalks only the branch on screen, so those versions are invisible to the export.includeVariants: trueappends them, labelled, next to the turn that replaced them. Off by default — silently adding alternates would change the shape of every existing user's file. When variants exist the export says how many and how to get them - Code keeps every newline its text holds (
collectCodeText): collapsing runs of blank lines turned the two between Python definitions into one. Line elements (adivper line) end a line exactly once. A code header is never the code itself — Gemini marks its<code>data-test-id="code-content", and taking that as the header madeprint("hello")the fence language; its real header is.code-block-decoration - Display math can be marked on either side of the TeX (
isDisplayMath): ChatGPT's second generation nests native MathML inspan.katex, so the TeX-carrying root is the span whiledisplay="block"sits on themathbelow it and[data-assistant-math-rendered="display"]above - Media is serialized before links, so link text already contains our own markdown (
markdownLink): escaping it produced[!\[Image\](data:image/png;base64,…)Label](url)— not a link, a wall of base64 shown as text. ChatGPT's inline citations are exactly this shape (favicon + label in one<a>). Take the label and drop the media; keep the image, unescaped and properly nested, only when it is all the link has. HTML is unaffected — its media are opaque placeholders, so<a><img></a>already nests correctly - Screen-reader-only text is chrome, not content (
removeUiElements): ChatGPT labels every turn with<h4 class="sr-only select-none">ChatGPT said:</h4>— 1x1px, positioned off-screen, invisible to a reader — and it reached every exported message as a redundant#### ChatGPT said:heading. Strip by class (sr-only,visually-hidden,cdk-visually-hidden), never by text: a message whose own heading ends in "said:" is legitimate content - Finish the sweep at the bottom, however it ended: the loop can
breakon stalls at any scroll position, and the finalcapture()used to run wherever that was. A real conversation stalled at 85% and shipped without its last two messages — the ones a reader notices first. The sweep now scrolls to the bottom before its last capture - The payload decides order and fills holes (
alignWithPayload): the sweep orders byrect.top + scrollTopmeasured at capture time, so a virtualizer that changes heights between captures can make two neighbours compare wrongly — a real export put an answer ahead of its own question. Where the payload chain covers every captured message it decides the order, and messages missing from the export are rendered from the payload rather than reported as a hole. Two rules: recovery is gated on payload entries not represented in the export — a turn the sweep saw but could never read is as absent as one it never reached, and duplicate DOM representations match one entry so they recover nothing — and reordering only applies when the payload accounts for every message, never half-rewriting an order it cannot explain - The stored conversation is read after a sweep with the primary budget (
enrichChatGptConversation): it is what proves an HTML/PDF export complete and fills its holes, and a long conversation is megabytes (issue #41). It runs even when the sweep ran out ofmaxDuration, and a payload that accounts for every turn makes the export complete regardless; only the attachments/reports that follow keep the short metadata budget - Ground truth beats a heuristic for completeness (
expectedMessages/unreachedMessages): a sweep that stops early captures every turn it saw cleanly, somissedMessagesreads zero and the export claims to be complete. The ChatGPT payload knows the real message count. Compare the ids the sweep encountered against the payload's, never the counts — duplicate DOM representations and unsupported empty turns make count-only comparisons unreliable - Warnings and notes are different lists (
diagnose): anything that fires on a healthy page belongs innotes. Two warnings had to be demoted after they fired on correct behaviour — a title cascade missing on a provider that prefers the tab, and a virtualized conversation having more messages than are currently mounted. A warning list that cries wolf stops being read, which defeats the point of having one - An incomplete sweep must announce itself (
conversation.complete/missedMessages): a short file that looks whole is the worst outcome. The sweep also runs against a wall-clock deadline (maxDuration) and restores scroll position in afinally - A turn is not readable the moment it mounts: the virtualizer renders the container before the text. Never retire a message key until
captureMessagereports success, track the selector's candidates (findMessageCandidates) sinceisValidMessagehides empty shells, and re-capture in place after another delay — never with a return trip, because the older, top-down ChatGPT layout snaps back to the newest message when scrolled up (the signed-in, column-reverse one holds its position and is swept upward from the start) - A hidden tab cannot be swept: Chrome throttles timers and suspends off-screen rendering, so exports come out short. The engine warns; testing must be done with the tab visible or the results are meaningless
- Capture order is not conversation order (
sortByConversationOrder): the sweep sees messages in whatever order the virtualizer mounts them — a capture taken at the top still sees the bottom turns ChatGPT hasn't unmounted yet. Every message recordsrect.top + container.scrollTopand the list is sorted by it before rendering. Never append-and-ship from a sweep - Virtualized conversations (
extractConversationFull): ChatGPT drops off-screen messages from the DOM, so the async path scrolls the conversation container, serializing each message while it exists (keys: message/turn ids, then scroller position plus role, then DOM-node identity). All runners and userscripts export through it; passscroll: falseor no scrollable container to get the single-pass behavior - A column-reverse scroller is swept upward (
scrollRange().reversed,sweepUpward,awaitOlderHistory): signed-in ChatGPT's scroller opens on the newest message, withscrollTop0 at the bottom and older history at negative offsets, and it loads that history only while the view is held at the top, in batches 1–6 s apart (verified live 2026-09-27). Pinning to the top first saw one batch settle, took it for the start, and read 15 of 166 messages from the page. The sweep climbs from the newest message and holds the top forhistoryWait(8 s) before calling it the start; positions measured from the bottom never move when history is prepended.maxScrollStepsdefaults to 2000 — the 400 it replaced cut a small-window sweep off halfway;maxDurationis the real bound - A second look only for turns on screen (
capture()returns how many are waiting): re-checking whenever any key was pending made every step after an interrupted reply's never-filling unit wait twice — 370 ms a step live - A page's blob images are its copies of the stored files (
mediaSource,resolveDeferredMedia): canvas re-encoding as PNG made a 1.7 MB generated image 2.3 MB, and 20 of them spent the 50 MB budget. The async path defersblob:images to a token and reads the blob's own bytes after the sweep — blobs outlive the image unmounting — falling back to canvas, then a labelled placeholder; enrichment then embeds any upload the page did not. A gallery shows its selected image twice from the same blob (DUPLICATE_MEDIA), and an upload the page embedded is not added again (embeddedPageImageCount, which counts onlydata:images, since ChatGPT's links expire) - Citations (
collectCitations): links withutm_source=chatgpt.comor inside citation/sources containers are collected before UI stripping and appended per-message as a numbered References list (issue #27) - A refusal is not always a 4xx you recognize (
isChatGptAuthFailure,fetchChatGptJson): chatgpt.com's private API rejects cookie-only requests and reports it as 404conversation_inaccessible— the same status a deleted conversation returns — so a retry keyed on 401/403 never fires. Read the token from/api/auth/sessionup front and send it on every/backend-api/*call rather than retrying after a failure: the request that would 404 is then never made, which is the only way to keep the error out of the reader's console. Escalate only on refusal — refresh the token, then try eachChatGPT-Account-Idfromaccounts/check(a workspace owns its members' conversations). Never escalate on a 404 without an auth code; that conversation really is gone - Credentials stop at the origin:
/backend-api/files/download/*answers with a signed link to a third-party CDN. Attaching the bearer token to that hop would hand the reader's ChatGPT session to another host — authenticate same-origin only (isSameOrigin). That endpoint also reports failure as HTTP 200 with{"status":"error",…}, soresponse.okproves nothing - Turn-level attachments and metadata: ChatGPT can render an image/file beside an empty
data-message-author-rolenode, so validation and content selection inspect the enclosingconversation-turn-*wrapper. The async path then makes a bounded best-effort read of the current conversation payload to add message timestamps, attachment names/bytes, generated sandbox links, and user-visiblereasoning_recapcontent; the whole enrichment pass is capped at 15s and failure must never block the DOM export (issues #32, #33) - Raster media only: safe PNG/JPEG/GIF/WebP/AVIF/BMP data may be embedded; SVG data is never embedded. Per-image and total byte caps protect the browser, with HTTPS URLs/placeholders as fallbacks
- Markdown downloads bundle image files (
bundleMarkdownImages,createZip,finishExport): move raster data URLs into a ZIP'simages/folder and replace actual image destinations with local links. Scan labels and payloads linearly; preserve fenced/inline code and HTML reasoning. Reuse repeated image bytes, preserve the original extracted conversation, and keep HTML/PDF and the text serializers unchanged.bundleImages: falseopts into legacy inline Markdown.
Progress overlay design (src/progress-overlay.js)
- The engine emits events; it never draws.
options.onProgressis a callback, so the engine stays usable headless in jsdom. A listener that throws is swallowed — a broken progress card must never cost the reader their export - Same Trusted-Types rule as the userscript UI: nodes via
createElement/textContent, styles set per-property. NoinnerHTML, and no injected stylesheet (style-srcis a CSP surface too). A test builds the card with every HTML sink throwing - The card must not be exportable by the sweep watching it. It is a direct
child of
body(outside the conversation container), carries no message markers, and isposition: fixedso it cannot change the container'sscrollHeightmid-sweep.isValidMessagealso refuses anything inside[data-chat-exporter-ui]— belt and braces for the day someone moves it pointer-events: none: the reader must never have to fight it for a click- An incomplete export must not end on a green bar. The
doneevent carriescomplete/unreachedMessages, and the card turns amber and names the shortfall
Userscript UI design (src/userscript-ui.js)
- Never depend on a ChatGPT affordance for the only entry point. Native menu integration is an enhancement; the floating launcher (
syncLauncher) is the guarantee — enterprise policies can remove Share entirely (issue #31). The launcher mounts only when no share control is visible and the page has messages, and hides itself when one appears - No HTML injection sinks. ChatGPT deployments can enforce Trusted Types, which makes
innerHTML/outerHTML/insertAdjacentHTML/document.writethrow. Build nodes with DOM APIs; build fixed SVG geometry withcreateElementNS(renderIcon);DOMParser.parseFromStringis also a Trusted Types sink. A test runs the built userscript with every sink throwing - Only extend menus that already offer a whole-conversation action (
findMenuTemplaterequires a Share item). Sidebar per-conversation menus reach the same code and would export the open conversation, not theirs - Per-turn Share controls stay native (
TURN_CONTAINER): both transcript generations put a Share action on every message (share-prompt-link-turn-action-button, and an icon button in eachli[data-message-role]); it shares that message, not the conversation - Watch for observer feedback loops: writing an unchanged attribute still queues a mutation record, so style updates are diffed first (
setLauncherVisible) - Cloned native items are stripped of
id/data-testid/data-test-id(including descendants) so ChatGPT's own queries never match our copies window.ChatExporter(markdown(),pdf(),showLauncher()) is the documented console fallback for bug reports
Development
npm ci # development dependencies only
npm run build # regenerate exporters and self-contained bookmarklets
npm test # exporter/bookmarklet freshness + node --test
npx playwright install --with-deps # install browser test engines
npm run test:browser # Chromium, Firefox, WebKitTests live in test/ — exporters.test.js (engine, UI and runners), hardening.test.js (network, media and payload limits), provider-dom.test.js (live-observed page shapes) — with synthetic DOM fixtures (test/fixtures/) mirroring live-observed ChatGPT/Gemini shapes, plus the browser matrix in browser-tests/. Every bug fix gets a regression test. Note: jsdom lacks innerText; the engine intentionally avoids relying on it (it degrades to textContent on detached clones in real browsers too).
Manual testing
- Open a ChatGPT or Gemini conversation
- DevTools Console (F12) → paste the built exporter file → Enter
- Verify the downloaded export against the rendered conversation
Before blaming the exporter — or before trusting a fixture — paste
selector-doctor.js into the same console. It reports what each shipped
selector matches on that page, which one is carrying it, whether the title came
from a selector or the tab, and (on ChatGPT) whether the private API
authenticates. The full report is left on window.ChatExporterReport.
Live ChatGPT DOM notes (observed 2026-08-05, desktop Chrome)
Verified by running the built userscript against a real conversation. Re-check before trusting any of it — but do not assume a fixture reflects these:
- Header:
button[data-testid="share-chat-button"]andbutton[data-testid="conversation-options-button"]insidediv[data-testid="thread-header-right-actions"] - ••• menu:
div[role="menu"][data-radix-menu-content], items arediv[role="menuitem"].__menu-itemwith ansvg.icon(noviewBox). Its Share row (data-testid="share-chat-menu-item") carriessm:hidden—display:noneon desktop, so never require it to be visible - Every user turn carries
button[data-testid="share-prompt-link-turn-action-button"]("Share prompt"), which shares that message, not the conversation - Turns are
section[data-testid="conversation-turn-N"]; N is the ground truth for conversation order and is not contiguous in the DOM (virtualized) - Messages have
data-message-id; the scroll container is the first scrollable ancestor and keeps a constantscrollHeightwhile turns are swapped - CSP:
script-srchas no'unsafe-eval',connect-srcblocks localhost, and framing localhost is blocked — a userscript manager is the only sane way to load the exporter; for ad-hoc testing, CDP-evaluated synchronousevalworks but anything after anawaitis subject to page CSP
Live ChatGPT DOM notes, signed in (observed 2026-09-27, desktop Chrome)
Verified on a real 180-message conversation with 24 generated images and 48
uploads. test/fixtures/chatgpt-signed-in-2026.html mirrors it:
- Scroller:
div[data-app-action-timeline-scroll][role="presentation"],flex-direction: column-reverse,overflow-y: auto.scrollTopis 0 at the newest message and negative above it. It opens with about 20,000 px of the newest turns; older history loads while the view sits at the top, about 10,000–20,000 px per batch, batches 1–6 s apart. No DOM element marks "more history" ([role=status]and the block above the first turn were checked) - Turns:
div[data-turn-key]>div[data-content-search-turn-key]> atomic wrappers > units. Units are keyed<turn uuid>:<n>:user|assistant, orfallback-turn-<n>:<n>:…for recent turns - Prompt:
div[data-user-message-bubble="true"]> … >div[dir=auto]withwhite-space: pre-wrap(classwhitespace-pre-wrap); actions are buttons labelled Copy message, Share prompt, Edit message - Uploads:
div[role="button"][aria-label="User attachment"] > imginside the user unit, a same-originblob:URL of the stored file. The preview mounts after the prompt text - Answer: unit >
h4[data-conversation-role="assistant"]"ChatGPT said:" +div[data-chatgpt-selection-message-id]>div[data-markdown-text-style] - Generated image:
h4[data-conversation-role]and a barediv[data-chatgpt-search-message-ids]are siblings; the div lists the visible tool record(s) and holds[data-testid="generated-image-gallery"]>button[data-testid="generated-image-preview"] > img[alt="Generated image 1"](ablob:URL, 1536×1024), plus a thumbnail strip — the selected image's thumbnail shares its blob. The empty assistant reply stored after it renders nothing - Tab title: the conversation title
Live ChatGPT DOM notes, second generation (observed 2026-09-25, logged out, desktop Chrome)
Seen on a logged-out chat (no stored copy, like a temporary chat); which
signed-in accounts get it is unverified. test/fixtures/chatgpt-transcript-2026.html
mirrors it:
ol[aria-label="Conversation"][data-conversation-transcript]>li[data-message-role="user"|"assistant"][id="<message uuid>"]; assistant items also carrydata-message-completeonce finished- Every
liopens withh4[data-message-attribution]("You said:"), visually hidden by atomic CSS, not by ansr-onlyclass - Prompts:
button[data-user-message-bubble] > p[data-user-message-copy], thepcomputingwhite-space: pre-wrap; the answer body (div[data-assistant-markdown]) iswhite-space: normal - Code:
pre > code.language-<lang>[data-assistant-syntax-highlighted]with a span per line and\ntext nodes between them, plusspan[data-message-content-controls]holding the copy button - Math:
span[data-assistant-math-rendered="inline"|"display"] > span.katex > mathwith anapplication/x-texannotation; display math hasdisplay="block"on themath - Sources:
button[data-assistant-sources-payload]with a JSON array of{attribution, title, url};data-content-reference-typeisgrouped_webpageson the enclosing span,urlfor inline links, andsources_footnotefor the trailing "Sources" control - The only
data-testids are app shells; the scroll container isdiv[data-swipable-detail-body](overflow-y: auto) - The logged-out tab title is the site tagline "ChatGPT: Chat, Work, Create & Code with AI"
document.hiddenis true whenever the window is not visible (for example a background automation window), so the sweep waits for it by design
Live Gemini DOM notes (observed 2026-08-17, desktop Chrome)
Verified against a real signed-in conversation. Re-check before trusting it:
- Messages are
user-query/model-responsecustom elements — the first entry of the cascade, still healthy. Every other entry ([data-test-id="conversation-turn"],[data-message-author-role],[role="listitem"], …) matches nothing; they are dead weight kept as insurance - Content roots
message-content,.query-text,.response-containerall match, one per turn div.conversation-containerwraps a question and its answer together — never use it as the turn scope (see the engine-design note above)- The scroll container is
infinite-scroller.chat-history(overflow-y: scroll), found at depth 2 from a message - No per-message timestamps exist in the DOM (
time[datetime]and friends: zero matches) — and Gemini has no private-API path, so Gemini exports carry no timestamps at all. ChatGPT's also has none; its timestamps come entirely from the conversation payload, which is why the metadata 404 meant every ChatGPT export shipped without them - All
imgelements sit outside the message elements (avatars, product chrome), so media serialization does not pick them up - The tab title is
"<conversation name> - Google Gemini"and is the only reliable source for it - The model picker (e.g. "Flash-Lite") carries a class matching
[class*="conversation-title"]and mounts after first paint — it is page chrome, not the conversation title - The only
h1ish1.cdk-visually-hiddenreading "Conversation with Gemini" - Re-checked 2026-09-25 in a temporary chat (tab title just "Google Gemini"):
the answer's
div.markdown.markdown-main-panelcomputeswhite-space: pre-wrap, inherited by every descendant; paragraphs arep > spanwith<b>as a sibling of the spans; code iscode-block>.code-block-decoration("Python") +pre > code[data-test-id="code-content"]with nolanguageattribute; prompts are.query-text > p.query-text-line
Selector updates
When ChatGPT/Gemini UI changes, update the provider's selector list in this priority order:
- Data attributes (
[data-testid*="..."],data-message-author-role) - ARIA roles / custom elements (
user-query,model-response) - Semantic HTML elements
- Class-based selectors (least preferred)
Release checklist
- Bump
versioninpackage.jsonandENGINE_VERSIONin the source; update the lockfile (the build rejects mismatches) npm run build && npm test- Update README (What's New, Version History) and add
temporal/release-notes-vX.Y.Z.md - Commit, tag
vX.Y.Z, push, create the GitHub release
Important Notes
- Exports omit the exact conversation URL unless
includeSourceUrl: trueis passed - HTML/PDF output escapes all conversation content; unsafe link schemes are dropped
- No private live captures may be committed (
private-research/is gitignored)
v1.2.0 release hardening
- Primary ChatGPT payload reads — Markdown's payload-first read and the read after an HTML/PDF sweep — have a 60-second request timeout and 120-second retrieval budget; optional metadata/report/image enrichment retains separate 5-second/15-second defaults. Legacy explicit metadata timeout overrides still work.
fetchWithTimeoutconsumes the response body inside the deadline and races a timeout even if fetch ignores abort. - Authenticated requests refuse redirects. Signed HTTPS image downloads omit credentials and referrers. Streamed image bytes are capped while reading.
- Unwrap buttons that hold message content before stripping UI. Non-empty short turns are valid content.
- Completed Deep Research reports survive a following assistant summary; missing reports mark the export incomplete. Incomplete notices persist inside saved files.
scripts/build-bookmarklets.jsgeneratespublic/bookmarklets/from the console bundles with pinned Terser. Do not edit the generated page or URL text files directly.npm run release:prepareprepares checksummed artifacts in ignoreddist/; it does not publish. Readdocs/RELEASE_AUDIT.mdfor outstanding live release checks.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

