Sno Station
Sno Station은 AI 에이전트를 하나의 팀으로 묶는 오픈소스 워크스테이션으로, 로컬 머신에서 에이전트 협업을 가능하게 함
웹 페이지를 깔끔한 Markdown으로 변환해 AI에 활용. 브라우저 확장·MCP 서버·라이브러리 제공, 로컬 실행, 외부 API 호출 없음
Convert any web page to clean Markdown for AI. Browser extension, MCP server, and library. Runs locally, no external API calls. By Igor Saevets.
플러그인TypeScriptGitHub @igorsaevets온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
Extract clean Markdown from any web page HTML. Node.js (linkedom) + browser adapters. Powers Page2AI extension and @page2ai/mcp server.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Webpage-to-Markdown extraction engine for LLM context (ChatGPT, Claude, Gemini). The engine of the
@page2ai/mcpserver. The Page2AI Chrome & Firefox extensions run their own pipeline in the browser (see below). Preserves code blocks with language hints, tables, and reading structure. Zero telemetry, MIT-licensed.
Works in Node.js (via linkedom) and in browsers.
htmlToMarkdown(html, opts) or fetchAndConvert(url, opts) — zero external API calls, uses linkedom for parsingBrowserAdapter and the PageAdapter interface; the full extract pipeline lives in the extension repo (a migration is planned)npm install @page2ai/core
import { htmlToMarkdown, fetchAndConvert } from '@page2ai/core';
// From an HTML string
const html = `<article><h1>Hello</h1><p>World</p></article>`;
const { markdown, title, charCount } = htmlToMarkdown(html, {
baseUrl: 'https://example.com/hello',
includeFrontmatter: true,
});
console.log(markdown);
// From a URL
const result = await fetchAndConvert('https://docs.anthropic.com/en/api/messages', {
timeoutMs: 15000,
});
console.log(result.markdown);
htmlToMarkdown(html, opts?)Converts an HTML string to Markdown. Synchronous.
html (string) — the HTML to parse: a whole document or a fragment (an HTMX partial, a template snippet, plain text)opts.baseUrl (string) — URL used to resolve relative links (default: 'about:blank')opts.profile (string) — accepted, but ignored by the Node build in this version: 'dashboard', 'marketing' and the rest produce the same output here. The profiles switch the browser extension's live-page pipeline (clicking tabs, waiting for lazy content), which the Node renderer does not runopts.includeFrontmatter (boolean) — emit YAML frontmatter block (default: true)opts.includeImages (boolean) — emit image links (default: true). Inside a table cell, a layout table's included, an image is written as its alt text either way, and an image with an empty alt or aria-hidden="true" adds nothingopts.includeHidden (boolean) — also convert content under the HTML hidden attribute, which a browser does not show (default: false). Tab panels, hidden="until-found" sections and React streaming segments are converted either way, and a hidden block that holds code contributes its code blocks and nothing else. Code, link names and tab labels never take text from a hidden element. A <main> under hidden is not taken as the page's main content while a visible one exists, and one inside a hidden tab panel or until-found section never is. The default is not a security boundary: those kept kinds are set by the page's own attributes, so a page can still put text a reader does not see into the output that way, and the converter reads no CSS (display: none is converted)Returns { markdown, title, baseUrl, charCount, extractedAt }. When nothing can be extracted the Markdown says so with <!-- AI: page has no extractable body content --> instead of coming back empty.
fetchAndConvert(url, opts?)Fetches a URL and converts it to Markdown. Async.
url (string) — the URL to fetchopts.timeoutMs (number) — abort after N ms (default: 15000)opts.userAgent (string) — custom user agent (default: page2ai-core/0.1)htmlToMarkdown optionsUses Node 18+ built-in fetch(). Follows redirects; passes the final URL to htmlToMarkdown as baseUrl unless overridden. It requests only the URL you pass: no <url>.md twin, no llms.txt, and the Accept header is the one Firefox sends when it opens a page (text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8), so a docs platform that negotiates (Fumadocs, Mintlify, Vercel, Supabase, Stripe, the Claude docs) answers with the page a person sees, not its own Markdown copy, which can be older than the page. A URL whose path names a Markdown or text file (.md, .markdown, .mdown, .mkd, .mdx, .txt) is the page and is returned as is, whether the server sends it as text/markdown, text/x-markdown, text/plain (GitHub raw does) or application/octet-stream. Markdown sent for any other address is refused with a FetchProtectionError: the request asked for HTML, so that Markdown is the site's copy, not the page. source in the result says which way it came (html-parse or markdown-response), and includeFrontmatter applies on both. The body is decoded as UTF-8 whatever charset the server declares, so a page in another encoding comes out garbled.
The fetch refuses anything that is not public, on the URL and on every redirect hop, after DNS resolution: localhost, loopback 127.0.0.0/8 and ::1, 0.0.0.0/8, private 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, link-local 169.254.0.0/16 (cloud metadata) and fe80::/10, CGNAT 100.64.0.0/10 (this includes Tailscale addresses), IPv6 unique-local fc00::/7, IPv4-mapped IPv6, the unspecified address ::, and the hostnames metadata and metadata.google.internal. So a page on your own intranet or tailnet cannot be fetched this way: fetch the HTML yourself and pass it to htmlToMarkdown. assertSafeUrlResolved(url) runs the same check without fetching, and findIpBlockReason(address) applies its address rule; both are exported.
$RC, $RR, $RS) move it into place in a browser. The converter runs no scripts, so it makes those moves itself, in document order, and drops the fallback, only where a browser runs the call: in a classic script (not one with src, nomodule or a non-JavaScript type, not inside <noscript> or <template>), as a statement of its own, on nodes parsed before that script and on the markup React writes for them. What a script would do when run is not checked: call text in a script that redefines $RC still moves a segment. A segment whose boundary is gone is dropped, as React drops it. A hidden segment that no call places stays where it stands: inside the content root it is converted there, outside it is left out. React's external runtime (<template data-rci>, an opt-in that no page we sampled uses) is not replayed<main> (else [role="main"], else <body>), replaced by its largest <article> when that article holds at least half of the root's text or is three times larger than every other <article>. Text outside the root (beside <main>, or beside such an article) is not converted# ), paragraphs, lists (ordered/unordered, nested), links [text](url), images , code blocks (with language tag when detectable), tables (| a | b |; a cell spanning columns or rows leaves empty cells, so every value stays under its header; a span ends with its row group, as browsers lay it out, and a table whose spans would cover more than 100,000 cells is written in the plain layout, cells in source order), blockquotes, definition lists (**term** then : definition), inline formatting (bold, italic, code). Text sitting directly in a <div>, <section> or <span> is kept as its own paragraph, so dashboards and admin pages built from bare <div>s convert toorole="presentation" or role="none" (the role takes the table semantics away; HTML e-mails are built from nested ones), as the blocks in its cells, in source order, not as a Markdown table. A data table inside it stays a table. Only the first token of a list is read<details> clicking, and none is needed: the body of a closed <details> is in the HTML and is converted with the rest of the page::before / ::after contentAll the above ship with the Chrome/Firefox extension, which runs the full lib/core/* pipeline against a live browser DOM. See github.com/igorsaevets/page2ai-extension.
fetchAndConvert reads only the page (0.2.0, BL-49)lib/core/html-to-md.ts via PageAdapter, browser entry migrated from extension repo<div id="root"> in its static HTML produces a graceful fallback error until then; the docs platforms above render on the server and convert today.Two implementations behind a single PageAdapter interface:
src/
├── shared/ # No DOM assumptions — works with any adapter
│ ├── page-adapter.ts # PageAdapter interface + defaults + nodeCssEscape
│ ├── utils.ts # pure helpers (absUrl, escapeMd, etc.)
│ ├── types.ts # all TypeScript interfaces
│ ├── constants.ts # DEFAULTS, BLOCK_TAGS, PII patterns
│ └── profiles.ts # profile presets
├── node/ # LinkedomAdapter + htmlToMarkdown + basic-renderer
│ ├── index.ts
│ ├── linkedom-adapter.ts
│ └── basic-renderer.ts
└── browser/ # BrowserAdapter; extract() comes with the port
├── index.ts
└── browser-adapter.ts
git clone https://github.com/igorsaevets/page2ai-core
cd page2ai-core
npm install
npm run build # tsc → dist/
npm test # vitest
npm run typecheck # tsc --noEmit
@page2ai/mcp — MCP server for Claude Desktop, Cursor, Windsurf, Zedswh:1:snp:05123c51ef9e7c0aeb06f42b1263c07a8d26999aReleases from v0.1.1 onward are published from GitHub Actions with npm provenance. Each published version carries a Sigstore attestation recording which commit and which workflow run produced the tarball, logged in the public Rekor transparency ledger. npmjs.com shows a provenance badge next to the version, linking to both.
Verify it yourself, without trusting this README:
npm audit signatures
npm view @page2ai/core --json | jq '.dist.attestations'
The point is that the link between the source you can read and the artifact you install is checkable by a third party rather than asserted here.
MIT — Copyright (c) 2026 Igor Saevets. See LICENSE.
role<button> is a control, so its label («Copy page», «Search», a feedback «Yes», a code-sample switcher) is not converted, and neither is an icon-font glyph inside it. A button that holds the page's text is converted: the whole text of a heading or a table cell (an accordion's question, a sortable column's name), a button holding block content or an image that is not aria-hidden, a button inside a link or holding one, the term of a definition list or a role="heading" element it fills, an accordion's question that stands alone right before its panel, a label that the button's accessible name acts on (aria-label="Copy command: npx …" on a button showing the command), a disclosure's label (aria-expanded without aria-haspopup), a word of a sentence (a tooltip), and the selected tab of a tab list whose panels the converter cannot pair with it (the other tabs name content that is not in the page). The extension labels buttons instead ([Button: …])title, source, captured_at, language, description, canonical, og_title, og_description, author, published, extractor, extractor_version