Built with
Turn your open tabs into clean JSON.
Tabs2JSON is a browser extension that reads the text and metadata of the tabs you select and exports it as structured JSON, built for feeding page content to a large language model. It works across page types by relying on how HTML organizes content rather than assuming any particular site structure.
Copying a job description, an article, or a set of research pages into an LLM one tab at a time is slow, and pasted browser text arrives cluttered with navigation and footer boilerplate. Tabs2JSON grabs the readable content from every tab you pick in one click, strips most of the chrome, preserves any Schema.org structured data the page already ships, and hands you a single JSON document an LLM can consume end to end.
Install from the Chrome Web Store, which works in any Chromium browser that supports Manifest V3: Chrome, Brave, Edge, Opera, and others.
To run the source directly instead:
- Open
chrome://extensions(orbrave://extensionsin Brave). - Turn on Developer mode.
- Click Load unpacked and select this folder.
- Pin the extension and click its icon to open the popup.
Open the popup, tick the tabs you want, and choose Download JSON or Copy to Clipboard. Readable tabs are selected by default. Browser internal pages and any domains you block are shown disabled and cannot be read. The gear opens Settings; the refresh button re-reads your open tabs.
With more than one window open, tabs are listed under a header per window, and any tab groups appear nested inside their window with the group's own name and color. A header's checkbox selects every readable tab beneath it, so you can take a whole window or a whole group in one click. The window you opened the popup from is listed first as This window and starts expanded; the others start collapsed, labelled by number and by their active tab. With a single window open the headers are omitted.
Download JSON saves to your downloads folder under a timestamped name and closes the popup so it is not covering the browser's download UI. To be asked for a name and folder each time, turn on "ask where to save each file before downloading" in your browser's download settings. Copy to Clipboard leaves the popup open.
The export is a single JSON object. Consumers should gate on ok first, then read the optional fields, treating any absent optional key as "not provided".
Top level:
exported_at: ISO 8601 timestamp of the export.tab_count: number of tab records.tabs: array of tab records.
Every tab record always has:
id: the browser tab id. Stable identifier for mapping results back to a tab, even when URL parameters are stripped.title: the tab title.url: the page URL. Query parameters are removed when Strip Query Parameters is on.content_source: where the text came from, one ofmain,article, an element tag,heuristic, orbody.captured_at: ISO 8601 timestamp of the capture.ok:trueon success,falseon capture failure.
On success, present when the page provides them and the matching setting is on:
canonical_url,site_name,description,language,author,published_at: page metadata, each omitted when absent. Drawn from standard meta tags, Open Graph properties, and the canonical link.content_frame_url: present only when the body text came from a cross-origin sub-frame rather than the tab's own page, such as an embedded applicant tracking system or document viewer. It is the URL of that frame, so a consumer can see the text is not fromurl. Absent on ordinary single-frame pages.headings: array of{ level, text }, when Include Headings Outline is on.structured_data: array of JSON-LD objects the page embedded, when Include Structured Data is on. Any string value that contains HTML markup is reduced to its text, so embedded markup and inline CSS do not leak into the output; values without markup are unchanged.text: the cleaned visible text, when Include Page Text is on.word_count: word count oftext. Counted by whitespace, so languages written without spaces (Chinese, Japanese, Thai) read low; thetextitself is unaffected.content_type: set tovideowhen the page is video-only, otherwise absent.
Boolean flags are present only when true. Their absence means false:
text_truncated: the text was shortened by the video trim or the character cap.low_signal: the extractedtextis unreliable, set in two cases. Either the text came back nearly empty (under 200 characters), which usually means a client-rendered page never populated; or the page is video-only, whose body carries little useful text and whose real content sits instructured_data. It is a high-precision flag, not a noise detector: a page with substantial but low-quality text (for example a search results page) will not carry it, so absence does not guarantee the text is clean.
On failure, the record has id, title, url, captured_at, ok set to false, and:
error: a short message describing the failure. No text or metadata fields are present.
- Gate on
okbefore reading anything else. - Treat
low_signaland acontent_typeofvideoas signals to distrusttextand preferstructured_data. idis the reliable join key.urlis not unique once parameters are stripped.- When
content_frame_urlis present, the text came from an embedded frame, not fromurl.
A two-tab export, trimmed:
{
"exported_at": "2026-07-16T05:06:39.530Z",
"tab_count": 2,
"tabs": [
{
"id": 1490142341,
"title": "JSON - Wikipedia",
"url": "https://en.wikipedia.org/wiki/JSON",
"canonical_url": "https://en.wikipedia.org/wiki/JSON",
"language": "en",
"content_source": "main",
"captured_at": "2026-07-16T05:06:39.493Z",
"ok": true,
"headings": [
{ "level": 1, "text": "JSON" },
{ "level": 2, "text": "Syntax" }
],
"structured_data": [
{ "@context": "https://schema.org", "@type": "Article", "headline": "JSON" }
],
"text": "JSON (JavaScript Object Notation) is an open standard file format ...",
"word_count": 1863
},
{
"id": 1490142248,
"title": "What is JSON? (Explained in 5 minutes) - YouTube",
"url": "https://www.youtube.com/watch",
"content_source": "ytd-watch-flexy",
"content_type": "video",
"captured_at": "2026-07-16T05:06:39.501Z",
"ok": true,
"text": "What is JSON? (Explained in 5 minutes) ...",
"word_count": 112,
"low_signal": true
}
]
}The first record is a normal article capture. The second is a video-only page, flagged content_type: video and low_signal: true so a consumer knows to lean on structured_data over text.
The extension reads each page in a way that does not depend on the site being modern or well-built.
- Content root. It looks for a semantic
<main>, then<article>, then an element withrole="main". If none exist, it scores content blocks by text length and link density to find the real content, and falls back to the<body>as a last resort. This keeps it working on older table-layout pages as well as current ones. - Text. It reads
innerTextfrom the chosen root, so visible block structure survives. It then peels a leading navigation or aside block when that block's text is an exact prefix of the body, which removes in-page menu bars without touching prose, and normalizes whitespace. - Structured data. It parses every JSON-LD block on the page, whatever the Schema.org type, and reduces any string value that carries HTML markup to its text. Some sites embed large HTML fragments inside JSON-LD strings; this keeps that markup out of the output while leaving ordinary values untouched.
- Metadata. It reads standard meta tags, the canonical URL, and the document language.
Some sites render the real content inside a cross-origin iframe, such as embedded applicant tracking systems or document viewers, leaving the top frame as a shell. To handle that, the extension injects into every frame it has access to, skips known junk frames (captcha, ad, analytics, consent, and chat widgets) by URL, and keeps the remaining frame with the most words. Ranking by words rather than characters keeps a machine-generated blob, like a reCAPTCHA widget's payload, from beating the real page. When the winning frame is not the tab's own page, its URL is reported as content_frame_url.
The read runs through the chrome.scripting API only on the tabs you select, and only when you trigger an export.
The options page is built with the options_ui pattern and stores preferences locally via the chrome.storage API.
- Content: include or exclude page text, structured data, and the headings outline.
- Video Pages: trim text on video-only pages to a short snippet.
- Limits: cap the characters of text kept per tab.
- Privacy: strip query parameters from URLs, and block domains the extension will never read.
- Output: pretty-print or compact JSON.
Settings save automatically and apply on the next export.
There is no build step and the extension has no runtime dependencies; it runs the source directly. The popup and options pages share their pure logic through src/lib/extract.js, and the injected page extractor lives in src/lib/extractor.js. Downloads are run by the service worker in src/background.js, which parks the generated file in the offscreen document at src/offscreen.html and src/offscreen.js so the save outlives the popup that started it.
Tests come in six suites. The unit and sections suites cover the shared pure logic and run on Node with no dependencies:
node --test test/unit.mjs test/sections.test.mjs
The other four run the real code against fixture HTML under jsdom, the one dev dependency, so they need an install first:
npm install
npm test
The sections suite covers the pure arrangement of tabs into window and group sections that the popup list renders. The extractor suite runs the injected page extractor and checks which content root it picks and whether leading nav is peeled. The pipeline suite feeds real extractor output from several frames into the frame picker, covering the cross-origin iframe and junk-frame cases. The holistic suite runs the whole flow, from rendered frames through the record assembly, and asserts the full output record. The capture suite covers the orchestration the popup wires to the browser: the per-tab timeout, the slow-sub-frame fallback to the top frame, the progress count, the failure summary, and the guard that runs one export at a time so a slow save dialog cannot queue duplicate downloads. All six use Node's built-in node:test runner, so npm test runs them in one pass, aggregates the results, and prints a full diff on any failure.
jsdom is used only for tests; it is never shipped with the extension. Two coverage notes. jsdom has no innerText, so the suites approximate it by keeping only rendered, visible text: they drop the non-rendered elements (script, style, noscript, template) and any node hidden with display:none, visibility:hidden, or the hidden attribute. They do not drop aria-hidden, which changes the accessibility tree but not rendering, so a real browser's innerText still returns that text. jsdom has no layout engine, so it resolves visibility from inline styles and simple stylesheet rules but not from anything geometric (off-screen positioning, zero-size clipping), and it does not reproduce innerText's block-boundary whitespace; the suites assert structural behaviour rather than exact spacing, and a real-browser export stays the backstop for layout-dependent visibility. And the checks that reduce HTML with DOMParser (stripHtml and the markup path of sanitizeStructured) run whenever a DOMParser is available: under npm test the unit suite loads one from jsdom, and the holistic suite exercises the same path end to end. They skip only when unit.mjs is run on its own without jsdom installed, which keeps that command dependency-free.
- Type is set in Atkinson Hyperlegible Next, a typeface from the Braille Institute designed for legibility.
- Light and dark themes both meet WCAG AA contrast, and most pairings meet AAA.
- The palette relies on brightness rather than hue, and error states carry a symbol and weight in addition to color, so meaning survives for color vision deficiency.
- The theme follows your system setting by default via
prefers-color-scheme, with explicit light and dark overrides.
All work happens locally in the browser. The extension sends none of your data anywhere and includes no analytics or tracking. The only network activity it causes is your browser fetching tab favicons for the popup list, exactly as the tab strip does. See PRIVACY.md for detail.
Declared in manifest.json under Manifest V3:
| Permission | Why |
|---|---|
tabs |
List open tabs and read their titles and URLs |
tabGroups |
Read tab group names and colors for the popup list |
scripting |
Read page content of the tabs you select |
downloads |
Save the JSON file to your downloads folder |
offscreen |
Hold the generated file in memory while it downloads |
storage |
Remember your settings locally |
| host access | Required so scripting can read the pages you choose |
This project uses Calendar Versioning (CalVer) in the format YYYY.M.PATCH. The first two parts are the year and month of the release; the last is the patch number for additional releases within that month.
This project is licensed under CC BY-NC-SA 4.0.
You are free to:
- Use, share, and adapt this work
- Use it at your job
Under these terms:
- Attribution. Credit the original author.
- NonCommercial. No selling or commercial products.
- ShareAlike. Derivatives must use the same license.