One click. Clean Markdown. Perfect LLM context.
Install · Library · Supported Sites · Why? · Build · Contributing
You're chatting with ChatGPT, Claude, or Gemini. You want to share a web page for context — a Wikipedia article, a Reddit thread, a YouTube video, a news story. What do you do?
- Copy-paste raw text → Loses all structure. Headers become blobs. Tables vanish. Links disappear.
- Share a URL → The LLM can't browse it (or hallucinates what it says).
- Screenshot → Eats your token budget on image processing. Can't search or quote.
- Manually reformat → Life's too short.
Copy as Markdown allows you to extract content with a single click. Depending on your installation method, you interact with it in two ways:
- Browser Extension: Click the "Copy as Markdown" icon in your browser toolbar. Datadog dashboards, Datadog notebooks, and W&B runs also get a page button through narrowly scoped site access; extraction and clipboard writes still run only after a click.
- Userscript: A context-aware button is added to supported websites (e.g., a draggable floating button, or inline buttons on Wikipedia, Google Docs, Atlassian, and Datadog pages). Floating positions persist per site.
One click, and the page's content lands in your clipboard as clean, structured Markdown — headers, tables, links, code blocks, metadata — all preserved. Unicode compatibility forms, non-ASCII spaces, smart quotes, and dashes are normalized; invisible watermark and direction-control characters are removed. Paste it into your LLM conversation. Done.
💡 Structured Markdown is the most token-efficient, context-rich format for sharing web content with LLMs. It preserves semantic meaning (headers = hierarchy, tables = data, links = sources) while stripping visual noise.
The fastest way to get started. Works in any browser with a userscript manager.
- Install Tampermonkey or Violentmonkey
- Install Copy as Markdown
- That's it — you'll see the button on supported sites
Install Copy as Markdown from the Chrome Web Store.
For local development:
- Download or clone this repository
- Run
pnpm install && pnpm build - Open
chrome://extensions/→ enable Developer mode - Click Load unpacked → select the
dist/chrome/folder
Chrome may show Wants access to this site on first use. Click Allow, then click Copy as Markdown again. The toolbar badge shows … while copying, ✓ on success, or ! when the page or clipboard is unavailable.
Install Copy as Markdown from Firefox Add-ons.
For local development:
- Download or clone this repository
- Run
pnpm install && pnpm build - Open
about:debugging#/runtime/this-firefox - Click Load Temporary Add-on → select
dist/firefox/manifest.json
The toolbar badge shows … while copying, ✓ on success, or ! when the page or clipboard is unavailable.
Pages that already provide a copy control can suppress injected page UI with reserved ID:
<button id="copy_as_markdown_btn">Copy as Markdown</button>An empty marker works too:
<div id="copy_as_markdown_btn"></div>Adding or removing marker updates injected UI dynamically. Extension toolbar action remains available.
Install browser-safe ESM package with pnpm:
pnpm add @bvolpato/copy-as-markdownLibrary loads only requested extractor chunks without starting userscript or extension UI. It does not inject buttons, write clipboard data, use extension APIs, or persist state. Fixed subpath imports let extension bundlers include only selected extractors.
This matcher enables only Jira, Confluence, GitHub, and Google Docs. Domain list narrows broad built-in site patterns, and when adds DOM-specific policy owned by calling extension.
import { createExtractorMatcher } from '@bvolpato/copy-as-markdown/core';
import { confluenceExtractor } from '@bvolpato/copy-as-markdown/extractors/confluence';
import { githubExtractor } from '@bvolpato/copy-as-markdown/extractors/github';
import { googleDocsExtractor } from '@bvolpato/copy-as-markdown/extractors/google-docs';
import { jiraExtractor } from '@bvolpato/copy-as-markdown/extractors/jira';
const matcher = createExtractorMatcher({
extractors: [jiraExtractor, confluenceExtractor, githubExtractor, googleDocsExtractor],
domains: [
'jira.example.com',
'confluence.example.com',
'github.com',
'docs.google.com',
'acme.atlassian.net',
],
when: ({ document, extractor }) => {
if (extractor.name !== 'GitHub') return true;
return Boolean(document?.querySelector('[data-my-extension-context]'));
},
});
const match = matcher.match({
url: window.location.href,
document,
});
if (match) {
const markdown = await match.extract();
await navigator.clipboard.writeText(markdown);
}domains uses exact hostnames. Use *.atlassian.net only when every Atlassian subdomain should be eligible. origins can restrict scheme and port. urlPatterns accepts userscript-style patterns or regular expressions. when receives parsed URL, current document, and matched extractor.
Runtime selection is also available when extractor set is not known at build time:
import {
createExtractorMatcher,
loadExtractors,
} from '@bvolpato/copy-as-markdown';
const extractors = await loadExtractors(['jira', 'confluence', 'github', 'google-docs']);
const matcher = createExtractorMatcher({ extractors });Site extractors run inside active page because authenticated Jira and Confluence extraction can use same-origin APIs and current browser state. Use generic DOM conversion for detached or caller-created DOM:
import { domToMarkdown, htmlToMarkdown } from '@bvolpato/copy-as-markdown';
const fromElement = domToMarkdown(document.querySelector('main')!);
const fromDocument = domToMarkdown(new DOMParser().parseFromString(html, 'text/html'));
const fromHtml = htmlToMarkdown('<article><h1>Hello</h1></article>');Custom extractors can be DOM-only. Pass empty URL patterns and inspect provided document:
import { defineExtractor, domToMarkdown } from '@bvolpato/copy-as-markdown/core';
const internalApp = defineExtractor({
name: 'Internal app',
matches: [],
detect: (document) => Boolean(document?.querySelector('[data-internal-app]')),
extract: async () => domToMarkdown(document.querySelector('main')!),
});Useful APIs:
getAvailableExtractorIds()lists 50+ loadable extractor IDs.loadExtractor('github')loads one extractor chunk.loadExtractors(['github', 'jira'])loads selected chunks in parallel.loadAllExtractors()loads full catalog.getExtractors()lists extractors loaded in current module instance.createExtractorMatcher()applies extractor URL rules plus caller restrictions.defineExtractor()creates custom extractor objects for same matcher.domToMarkdown(),elementToMarkdown(), andhtmlToMarkdown()convert caller-owned DOM without site UI.
Interaction Model:
- Browser Extension: Click the toolbar icon on any supported site, or the page button on Datadog dashboards, Datadog notebooks, and W&B runs.
- Userscript: Clicks are handled via injected buttons (inline where a site integration provides a reviewed anchor, floating otherwise). Drag floating buttons out of the way without disabling them; their positions persist per site.
| Site | What's Extracted |
|---|---|
| Wikipedia | Article body, tables, infoboxes — edit buttons and references stripped |
| Google Docs | Full document export via Google Docs HTML export — headings, lists, tables, links, images, and off-screen content |
| Google Sheets | Active sheet or selected range as a bounded Markdown table |
| Google Slides | Choose current slide or full deck; preserves order, titles, text, links, and speaker notes when available |
| Gmail | Full authenticated thread from Print all view — subject, participants, message headers, bodies, links, images, and attachments |
| Notion | Pages and databases with properties, rich blocks, tables, code, and rendered rows |
| Sphinx / Read the Docs | Content-detected documentation on hosted or custom domains, with navigation stripped and code languages preserved |
| Microsoft 365 | Word, Excel, and PowerPoint web content through structured live-page views |
| Slack | Loaded channel or thread messages with authors, timestamps, reactions, replies, and attachments |
| Discord | Loaded channel messages and threads with authors, timestamps, replies, reactions, and attachments |
| Jira | Authenticated REST issue fields, ADF descriptions/comments, and links; rendered issue DOM fallback |
| Confluence | Authenticated REST page body, labels, tables, and code; visible comments and rendered DOM fallback |
| Grokipedia | Full article content with metadata |
| Google Search | Query, featured snippets, knowledge panel, ranked results, "People Also Ask" |
| Bing Search | Query, search results, knowledge sidebar, related searches |
| DuckDuckGo Search | Query, ranked results, snippets, destination links, and related searches |
| Yahoo Search | Query, ranked results, snippets, and destination links |
| Yandex Search | Query, answer cards, ranked results, and related searches |
| Baidu Search | Query, ranked results, abstracts, and destination links |
| Brave Search | Query, answer cards, ranked results, discussions, and related searches |
| Post title, body, subreddit, author, score, threaded comments with depth | |
| YouTube | Video title, channel, views, likes, description, chapters, comments, transcript |
| WhatsApp Web | Chat name, all messages with sender, timestamp, media indicators |
| X (Twitter) | Single posts with replies, or full timelines with engagement stats |
| Polymarket | Market title, description, outcome probabilities, volume, resolution rules |
| OpenRouter | Full model definitions, architecture, modalities, pricing, limits, supported parameters, benchmarks, provider endpoint fields, and FAQ |
| Artificial Analysis | Homepage featured items, analysis sections, complete published leaderboards, model overview, exact benchmark values, technical specifications, provenance, and FAQ |
| DeepSWE | Benchmark overview, all published leaderboard configurations and efficiency metrics, methodology, task examples, and blog sources |
| Datadog dashboards | Dashboard title, timeframe, template variables, grouped widget values, top lists, and visible chart annotations |
| Datadog notebooks | Notebook metadata, narrative headings and rich text, ordered visualization cells, types, no-data states, and visible chart annotations |
| Datadog Documentation | Authored .md source when available; cleaned rendered documentation DOM otherwise |
| Weights & Biases | Run metadata, configuration, numeric metric summaries, sparklines, and sampled history tables through W&B GraphQL |
| MLflow | Self-hosted run metadata plus chart-mode comparisons for visible runs and loaded metrics, with paginated metric-history tables through same-origin APIs |
| GitHub | Issues and PRs, repository/directory listings with READMEs, full code-file contents, and canonical patches with commit/file metadata |
| GitLab | Repositories, trees, code files, issues, merge requests, comments, and visible diffs |
| Bitbucket | Repositories, source files, pull requests, issues, comments, and visible diffs |
| Perplexity | Ordered user and assistant turns with citations and source links |
| Grok | Ordered user and assistant turns with citations, code, and images |
| ChatGPT | Ordered user and assistant turns with Markdown, canvas writing blocks, code, model metadata, and images |
| Claude | Ordered user and assistant turns with Markdown, code, citations, and images |
| Gemini | Ordered user and model turns with Markdown, code, citations, and images |
| Meta AI | Ordered user and assistant turns with citations, code, and images |
| LeetLLM | Lessons, glossary pages, practice content, code, links, and learning context |
| Stack Overflow | Question with votes & tags, all answers (✅ accepted marked), comment threads |
| Hacker News | Post title, link, score, author, nested comment threads with depth |
| Profiles (experience, education, about), posts (with reactions and comments), articles | |
| Posts, reels, captions, author metadata, engagement, and visible comments | |
| Posts and reels with captions, media descriptions, engagement, and visible comments | |
| TikTok | Videos with creator, caption, engagement, transcript or captions, and visible comments |
| Pins with creator, description, destination, media, engagement, and visible comments | |
| VK | Posts with author, timestamp, text, media, engagement, and visible comments |
| Amazon | Product title, ASIN, price, rating, feature bullets, tech specs, reviews (top 10) |
| Temu | Product title, price, availability, ratings, variants, specifications, and description |
| Booking.com | Hotels and search results with prices, scores, facilities, policies, and availability |
| Netflix | Title metadata, synopsis, cast, genres, ratings, seasons, and visible episodes |
| Twitch | Channels, live streams, videos, and clips with game, viewers, tags, and description |
| Weather.com | Current conditions, alerts, hourly outlook, and daily forecast |
| arXiv | Paper title, authors, abstract, subjects, DOI, links; full body from HTML pages |
| Globo | Articles and videos with headline, author, date, structured metadata, and clean body |
| FOX | Shows, episodes, movies, and videos with synopsis and structured details |
| News sites | Fox News, CNN, BBC, NYT, Reuters, and 20+ others — article body, author, date; paywall detection |
Every extractor is purpose-built to separate signal from noise: no ads, no navigation menus, no cookie banners, no related-articles sidebars. Just the content that matters.
W&B returns up to 500 sampled history rows per run through its browser GraphQL API. MLflow run history fetches are paginated up to 10,000 points per metric. MLflow chart comparisons include up to 10 visible runs and 50 loaded run-metric series, fetching up to 2,500 points per series. Both integrations include full-series statistics, then evenly sample Markdown history rows when needed to keep clipboard output bounded. W&B Server and arbitrary self-hosted MLflow deployments work through userscript content detection or extension toolbar; their active browser session must permit same-origin API access.
If an extractor is not explicitly opted into inline placement (for userscript builds), the button stays in the bottom-right corner. If an inline anchor is enabled but the selector is missing (for example after a site redesign), the button also falls back to the bottom-right floating button.
# Vigenère Cipher ← LLM knows: this is the topic
## History ← LLM knows: this is a section about history
| Inventor | Blaise de Vigenère | ← LLM knows: structured key-value data
The LLM doesn't have to guess what's a heading vs. body text vs. metadata. Markdown makes the hierarchy explicit.
Raw HTML from a typical Wikipedia article: ~200K characters. Copy as Markdown output: ~15K characters.
That's >90% noise reduction — more room for your actual conversation.
- Headers →
#,##,###(hierarchy preserved) - Tables → Pipe-delimited Markdown tables (data preserved)
- Code blocks → Fenced with language tags (syntax preserved)
- Links →
[text](url)(sources preserved) - Lists → Nested bullets/numbers (structure preserved)
Every major LLM — GPT-5.4, Claude, Gemini, Llama, Mistral — understands Markdown natively. It's the lingua franca of AI conversations.
Clicking the extension icon or userscript button on a Wikipedia article produces:
---
source: Wikipedia
title: Vigenère cipher
url: https://en.wikipedia.org/wiki/Vigen%C3%A8re_cipher
last_modified: 15 March 2025
---
# Vigenère cipher
The **Vigenère cipher** is a method of encrypting alphabetic text
where each letter of the plaintext is encoded with a different
Caesar cipher, whose increment is determined by the corresponding
letter of another text, the **key**.
## History
The Vigenère cipher is simple enough to be a field cipher if it
is used in conjunction with cipher disks...
## Description
| Component | Details |
| --- | --- |
| Type | Polyalphabetic substitution |
| Key | A repeating keyword |
| Inventor | Blaise de Vigenère |# Clone the repository
git clone https://github.com/bvolpato/copy-as-markdown.git
cd copy-as-markdown
# Install dependencies
pnpm install
# Type-check
pnpm typecheck
# Build all targets
pnpm build
# Package extensions as .zip
pnpm package:alldist/
├── userscript/
│ └── copy-as-markdown.user.js ← Install directly in Tampermonkey
├── chrome/
│ ├── manifest.json ← Chrome Manifest V3
│ ├── content.js
│ └── icons/
├── firefox/
│ ├── manifest.json ← Firefox Manifest V2
│ ├── content.js
│ └── icons/
└── library/
├── index.js ← Browser-safe ESM package entry
├── browser.js ← CopyAsMarkdown browser global
├── chunks/ ← Lazy site extractor chunks
├── extractors/ ← Fixed Jira, Confluence, GitHub, Google Docs entries
└── types/ ← TypeScript declarations
Unscoped copy-as-markdown name is already used on npm. This repository publishes as public scoped package @bvolpato/copy-as-markdown.
Validate exact package contents without publishing:
pnpm pack:libraryReleases are driven by signed vMAJOR.MINOR.PATCH tags on main. The release workflow validates the tag, tests and packages every target, publishes the npm package when that version does not already exist, then creates the GitHub release and browser artifacts.
npm trusted publishing requires one-time package configuration by an npm owner:
pnpm dlx npm@12.0.2 trust github @bvolpato/copy-as-markdown \
--file release.yml \
--repo bvolpato/copy-as-markdown \
--allow-publish \
--yesAfter this one-time setup, the workflow uses npm OIDC trusted publishing and provenance without a long-lived token. Existing package versions are detected and skipped, allowing the GitHub artifacts for an already-published npm version to be completed safely. prepack rebuilds standalone library without touching extension or userscript artifacts. files allowlist publishes only standalone library, declarations, README, license, and package manifest.
- TypeScript — all source code, compiled with esbuild
- esbuild — fast userscript, extension, ESM, and browser-global bundling
- pnpm — package management
- Zero runtime dependencies — extension targets are self-contained; library uses local ESM chunks only
src/
├── core/
│ ├── types.ts ← AnchorConfig, ExtractorConfig, PageMetadata interfaces
│ ├── markdown.ts ← HTML→Markdown converter (tables, lists, code, etc.)
│ ├── ui.ts ← Button injection: anchored (inline) or floating (FAB)
│ ├── utils.ts ← DOM helpers, meta extraction, paywall detection
│ └── registry.ts ← URL pattern → extractor mapping
├── extractors/
│ ├── wikipedia.ts ← extractor with active inline placement
│ ├── google-docs.ts ← extractor with active inline placement
│ ├── datadog-dashboard.ts ← semantic dashboard extractor + toolbar placement
│ ├── datadog-notebook.ts ← structured notebook extractor + toolbar placement
│ ├── youtube.ts ← extractor
│ ├── reddit.ts ← extractor
│ ├── x-twitter.ts ← extractor
│ └── news.ts ← extractor
├── catalog.ts ← Load extractors without UI startup
├── library/index.ts ← Standalone matcher and DOM API
└── main.ts ← Entry point: detect site, show button
build/
└── build.ts ← esbuild bundler → userscript + extensions + library
Note: Button positioning only applies to the Userscript build. The browser extensions rely exclusively on the toolbar icon.
The default behavior is simple: unless a site is explicitly opted into inline placement, the userscript button is rendered as a floating action button in the bottom-right corner.
To enable a custom inline position for a specific site, you need two things:
- An
anchorconfig that describes where and how to inject the button buttonPlacement: 'anchor'on the extractor
That second step is the gate. It lets us keep site-specific selectors in the codebase without turning them on until we're ready.
register({
name: 'Wikipedia',
matches: ['*://*.wikipedia.org/wiki/*'],
buttonPlacement: 'anchor',
anchor: {
selector: '#p-views ul',
position: 'append',
style: 'tab',
css: {
marginLeft: '8px',
paddingLeft: '8px',
borderLeft: '1px solid #a2a9b1',
},
label: 'Copy as Markdown',
},
async extract() {
// ...
},
});The anchor object controls the inline position:
anchor: {
selector: '#p-views ul', // CSS selector for the target container
position: 'append', // 'append' | 'prepend' | 'before' | 'after'
style: 'tab', // 'tab' | 'pill' | 'icon' | 'link'
wrapperTag: 'li', // Optional wrapper when the host expects a specific child tag
wrapperClass: 'mw-list-item', // Optional wrapper classes
wrapperCss: { marginLeft: '8px' }, // Optional wrapper overrides
css: { color: '#0645ad' }, // Optional button overrides
label: 'Copy as Markdown', // Custom label (omit for icon-only)
}Use wrapperTag / wrapperClass / wrapperCss when the host container expects a particular DOM shape. Wikipedia is the main example: the tab bar is a ul, so the injected control needs to live inside an li to align correctly with the native tabs.
Positioning rules:
- Omit
buttonPlacement, or set it to'floating', to keep the default bottom-right button - Add
buttonPlacement: 'anchor'to activate the extractor'sanchorconfig - If the anchor selector is missing at runtime, the UI falls back to the bottom-right floating button
Extractors enable anchored placement only after their site selector and SPA lifecycle are covered by browser fixtures. Others retain floating placement.
- Create
src/extractors/my-site.ts - Import
registerfrom../core/registryand call it withname,matches, andextract - Leave the button floating by default unless you are intentionally enabling a reviewed inline placement
- If you want to prepare an inline placement for later, add an
anchorconfig but do not setbuttonPlacement: 'anchor'yet - Import the new file in
src/main.ts - Run
pnpm build— the new patterns propagate to all targets
Public extractors can be checked against browser-rendered pages without committing raw page data:
# Capture current public page in a clean headless browser.
# If live capture fails, use an exact Wayback CDX capture.
pnpm fixtures:capture -- --site mdn
# Force one source while debugging.
pnpm fixtures:capture -- --site mdn --source live
pnpm fixtures:capture -- --site mdn --source wayback
# Replay committed fixtures entirely offline.
pnpm fixtures:verifyCatalog lives at test/sites/catalog.yaml. Capture writes raw reference screenshots only under gitignored .fixture-work/. A failed live attempt writes live-failure.png before Wayback fallback. Committed fixtures contain synthetic text, normalized links, no scripts or media, a screenshot of sanitized DOM, expected Markdown, and source provenance. Wayback provenance includes exact capture timestamp and digest. Set wayback: true to discover a capture through CDX, then pin accepted timestamp and digest in catalog for stable replay.
Capture refuses credentials, localhost, private IP addresses, and non-HTTP protocols. Use only curated public URLs. Authenticated pages require synthetic fixtures and must never use this capture path.
Each captured fixture verifies extractor identity, exact anchor relationship, singleton UI, Markdown bounds, required output, synthetic markers for excluded page chrome, exact expected Markdown, and privacy rules. pnpm test:regression runs this offline lane in CI.
PRs welcome! See CONTRIBUTING.md for details.
MIT © Bruno Volpato