A floating outline and section-aware search for long web articles. Runs entirely on your machine — the extension has no network access at all.
Ctrl-F tells you "1 of 47". Buoy tells you where those 47 are.
Open a long article. A small bubble appears in the corner. Click it and you get:
1. A structure outline. Buoy splits the article into sections. It uses the page's own h1–h6 headings as hard boundaries — the author's headings are always better than anything an algorithm would invent. Only a heading-less run longer than ~800 words gets split automatically, using lexical cohesion (TextTiling), and those sections are labelled auto-split so you know the machine made them.
2. Keywords per section. Not fake AI-written titles — just the terms that are distinctive to each section. Scored with TF-IDF where each section is treated as a document and the page as the corpus, which is what makes "distinctive to this section" mean anything in a single article.
3. Search grouped by section. The reason this exists:
Search "cost" → 23 matches
▸ 3. Hidden costs in the supply chain 12 ████████
▸ 7. Pricing strategy 6 ████
▸ 1. Intro 3 ██
▸ [auto-split] margin · inflation 2 █
You see at a glance where a term is concentrated. Expand any group for context snippets; click to jump and highlight.
Buoy has no network code. Not "we promise not to send your data" — there is no code capable of sending anything.
manifest.jsonrequests exactly one permission:storage. Nohost_permissions.- CI fails the build if
fetch,XMLHttpRequest,WebSocket,sendBeaconor friends appear anywhere in the source. Seescripts/check-no-network.sh. - No AI API, no account, no analytics, no telemetry.
The complete list of what is stored, ever:
{
minReadingMinutes: 5, // when the bubble appears
minHeadingsOverride: 4,
panelSide: 'right',
bubblePosition: { x, y },
theme: 'auto',
uiLanguage: 'auto',
pinnedTerms: [],
disabledDomains: [],
}Page content, page titles, URLs and search queries are never written anywhere.
Not on the Chrome Web Store yet.
git clone https://github.com/YOUR_NAME/buoy.gitThen: chrome://extensions → enable Developer mode → Load unpacked → select the buoy/ folder.
There is no build step and no dependencies. What you clone is what runs.
Be clear about this before you install:
| Not supported | Why |
|---|---|
| Scanned PDFs | The pages are images. Reading them needs OCR, which needs a model — out of scope for an offline, zero-dependency tool. Buoy tells you this rather than showing an empty outline. |
| Auto-opening PDFs you click on the web | Intercepting a PDF URL means fetching its bytes, which means shipping network code and host permissions. That would end the guarantee below, so it is opt-out by design: drag the file in instead. |
| Google Docs | Renders to <canvas> since 2021; there is no readable text in the DOM. The only route is the Docs API + OAuth, which would break the offline guarantee. |
| Notion and similar | Virtualised scrolling — only the visible screen exists in the DOM. |
| Semantic search / AI summaries | Would require a model or an API. Deliberately out of scope. |
| Firefox / Safari | Chromium only for now. The code avoids Chrome-only APIs where it can, but this is untested elsewhere. |
If you want an AI reading assistant, this is not it. Buoy is a structure-and-search tool.
Click the Buoy toolbar icon on any page that has nothing to read — or open the reader view directly — and drag a PDF in.
Buoy parses the PDF itself and rebuilds it as clean, readable HTML: paragraphs merged back from positioned glyphs, headings recovered from typography, running heads and page numbers dropped. Then the ordinary outline and section-grouped search run on top of it. There is no separate PDF codepath in the search engine — it is the same one used for web pages.
It is a reader view, not a PDF renderer: you get the text, structure and search; you do not get the original layout, figures or tables.
The parser is written from scratch (src/pdf/) — no pdf.js, no dependencies.
It handles compressed object streams, subsetted CJK fonts via /ToUnicode,
CID fonts, Form XObjects, and the Kangxi-radical and fi/fl ligature
substitutions that would otherwise wreck word segmentation and copy-paste.
Crucially: the file never leaves the tab. It is read from a File object in
memory. No fetch, no upload, no temp files — which is why the offline guarantee
above is still literally true with PDF support shipped.
node tests/pdf-extract.test.mjs # 17 assertions against a known fixtureAutomatically when either:
- estimated reading time ≥ 5 minutes, or
- the article has ≥ 4 real headings
Reading time, not word count — 1200 Chinese characters and 1200 English words differ by about 1.5× in actual reading time, so a raw character threshold means different things in different languages. Rates used: 350 chars/min for Chinese, 500 for Japanese, 238 words/min for English.
Clicking the toolbar icon (or Ctrl/Cmd+Shift+F) opens the panel regardless of length.
| Stage | Approach |
|---|---|
| Main content | Text-density scoring over <p>/<li>, ancestors credited with depth decay, link-heavy blocks discarded. Navigation, sidebars, comments and footers excluded. |
| Tokenising | Intl.Segmenter — built into V8, gives real CJK word boundaries with no dictionary download. |
| Sections | Native headings are hard boundaries. Heading-less runs over ~800 units get TextTiling (sliding-window lexical cohesion, cut at the valleys). |
| Keywords | TF-IDF with sections as documents; ×1.5 for terms that appear in any heading; single CJK characters discounted. |
| Search | Substring matching on a normalised (NFKC + lowercase) flat copy of the text, with an index map back to exact DOM offsets. Necessary for CJK, and it survives a word being split across <em>/<span> boundaries. |
| Highlighting | CSS Custom Highlight API — paints Ranges without touching the DOM, so React/Vue pages don't undo it and the site's own observers never fire. |
| Long documents | Every pass is time-sliced (yield every 8 ms). Supported up to ~300k units; beyond that, auto-splitting is disabled and only native headings are used. |
| PDFs | Own parser: whole-file object scan (no xref — real files break it too often), /ObjStm expansion, content-stream interpretation into positioned glyph runs, then lines from y-proximity, paragraphs from vertical rhythm and line-ending shape, headings from font size and weight. |
Honest list — PRs welcome on all of these:
Intl.Segmentersplits Chinese compound terms and proper nouns. 供應鏈管理 becomes 供應/鏈/管理, so keyword quality for technical Chinese text is noticeably worse than it could be. Fixing it properly means bundling a dictionary segmenter (jieba-wasm), which costs several MB. Not done in v1.- Outline quality depends on the site's HTML. A page with clean headings gets a great outline; a
<div>-soup page gets whatever TextTiling can find. - Search results are capped at 500 per query. On a very long document a common word gets truncated, which skews the density bars.
- The outline caps at 80 sections. Beyond ~50 the list stops being scannable anyway; virtualised scrolling and collapsing by heading level are the real fix (v1.1).
- Only the main frame is processed — content inside iframes is ignored.
- PDF heading detection is typographic, not semantic. It reads font size and weight, so a paper with consistent styling gives a clean outline, while a PDF that fakes headings with plain body text gives none. Multi-column layouts (two-column conference papers) are read column-by-column only when the producer wrote them in that order — some will interleave.
- PDF tables and figures are dropped, along with their captions' original placement. Text inside figures may appear as stray fragments.
The lowest-effort useful contributions:
- Stopword lists —
src/stopwords.jsis plain data. Bad keywords usually mean a missing stopword. - UI translations —
src/strings.jsis one object per language. - Extraction failures — if Buoy picks the wrong content on a site, open an issue with the URL.
Run the offline guarantee locally before opening a PR:
./scripts/check-no-network.shdemo/demo.html is a self-contained sample article (with navigation and a sidebar, so you can check they're correctly ignored). Serve the folder and open it — the engine imports directly, no extension needed:
python3 -m http.server 4175To re-record docs/demo.gif after a UI change, with the server running:
./tools/record-demo.shThat needs no ffmpeg and no npm install — headless Chrome writes one PNG per
frame and tools/png2gif.mjs assembles the GIF using only
Node's built-in zlib. The encoder decodes its own LZW output and compares it
before writing, because a code-size off-by-one produces a file that some viewers
render and others silently reject.
MIT

