
Html To Markdown
- 5 installs
- 18 repo stars
- Updated May 17, 2026
- appautomaton/webmaton
html-to-markdown is a Claude skill that converts a URL or HTML into clean Markdown with metadata by wrapping browser capture and an HTML-to-Markdown converter.
About
html-to-markdown converts a URL or raw HTML into clean Markdown along with metadata, links, images and quality signals. It wraps nodriver for browser capture of JS-heavy pages and markmaton for the HTML-to-Markdown conversion in one shell pipeline. A developer uses it when an agent or content task needs page text as structured Markdown. It defaults to headless capture but recommends a plain fetch for static articles and server-rendered docs.
- Turns a URL or raw HTML into clean Markdown plus metadata, links, images and quality signals
- Falls back to headless browser capture for JS-heavy pages, plain fetch for static docs
- Outputs a JSON envelope by default; --output-format markdown for raw body only
Html To Markdown by the numbers
- 5 all-time installs (skills.sh)
- Ranked #1,723 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
html-to-markdown capabilities & compatibility
- Capabilities
- nodriver browser · playwright cli
- Use cases
- web scraping
- Runs
- Runs locally
- Pricing
- Free
What html-to-markdown says it does
Convert a URL or HTML into clean Markdown with metadata using markmaton.
Prefer a simple fetch over browser capture for static articles, wikis, and server-rendered docs.
npx skills add https://github.com/appautomaton/webmaton --skill html-to-markdownAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 18 |
| Last updated | May 17, 2026 |
| Repository | appautomaton/webmaton ↗ |
What it does
Capture a web page and convert its rendered HTML into clean Markdown with metadata for an agent or content pipeline.
Who is it for?
capture-a-web-page tasks where browser-rendered HTML or structured Markdown is needed
Skip if: static articles, wikis, and server-rendered docs where a simple fetch is enough
When should I use this skill?
you need a URL or HTML turned into clean Markdown plus metadata, links and images
What you get
A JSON envelope with a Markdown body plus metadata, links, images and quality signals.
- clean Markdown body
- page metadata
- link and image inventory
By the numbers
- default capture timeout of 10s
Files
HTML to Markdown
Composes with
- Use for — capture-a-web-page tasks where browser-rendered HTML or structured Markdown is needed.
- Wraps — nodriver (CDP-based headless browser capture for JS-heavy pages, with Playwright Chromium discovery) and markmaton (HTML→Markdown with main-content extraction, metadata, and link/image inventory). See
references/integration-patterns.mdfor browser-vs-fetch guidance. - Outputs — JSON envelope by default (markdown body + metadata + links + images + quality signals). Use
--output-format markdownwhen only the raw Markdown body is needed.
Converts a URL or HTML into clean Markdown plus metadata, links, images, and quality signals.
From a URL
Capture the page and convert in one pipeline:
uv run --script scripts/capture_html.py <url> \
| uv run --script scripts/markmaton_convert.py --from-capture --output-format jsonThe capture script outputs a JSON envelope by default. --from-capture reads it and extracts html, url, final_url, and content_type automatically — no context lost, URL typed once.
- Add
--wait-selector <css>or--wait-text <string>to the capture step for pages that need a readiness signal. - Prefer a simple fetch over browser capture for static articles, wikis, and server-rendered docs.
From HTML
uv run --script scripts/markmaton_convert.py --html-file page.html \
--url <url> --output-format jsonOr from stdin:
echo "$html" | uv run --script scripts/markmaton_convert.py --url <url>Pass --url when available — it improves link resolution and canonical metadata.
Key defaults
- Output:
json. Use--output-format markdownfor raw Markdown only. - Main-content extraction: on. Use
--full-contentto disable. - Capture: always headless. Timeout
10s, override with--timeout. - Browser discovery: user's Chrome → user's Chromium → Playwright's Chromium.
References
Read only when needed:
references/usage.md— full CLI reference for both scriptsreferences/integration-patterns.md— browser vs fetch guidance, contracts, parser defaults
Integration Patterns
When to use a real browser
Prefer browser capture for:
- app-shell pages
- commerce pages
- form-heavy pages
- client-side card/list pages
- modern landing pages with substantial hydration
- pages where a simple HTTP fetch does not produce meaningful content
Prefer a simple fetch for:
- static article pages
- wiki pages
- docs pages that are mostly server-rendered
- direct JSON or API endpoints
Capture contract
capture_html.py produces:
html— rendered page HTMLurl— the requested URLfinal_url— browser location after redirectscontent_type— fromdocument.contentTypetitle— fromdocument.title(informational)rendered— alwaystrue(informational)
Convert contract
markmaton_convert.py accepts:
- HTML content (stdin or
--html-file) - optional
url(improves canonical fallback and absolute link normalization) - optional
final_url - optional
content_type
Good defaults
Capture:
- Use default JSON output when piping into conversion with
--from-capture. This preservesfinal_urlandcontent_type. - Add
--wait-selectoror--wait-textonly when the page needs a stronger readiness signal than<body>.
Convert:
- Start with main-content mode.
- Use
--full-contentonly when main-content extraction is clearly too aggressive. - Use
--include-selectoror--exclude-selectoronly when a page has a stable structural reason for it. - Do not turn parser usage into site-specific patching unless the task explicitly calls for that.
Usage
Two PEP 723 scripts. uv run manages isolated environments automatically.
Capture (capture_html.py)
uv run --script scripts/capture_html.py <url> [options]Capture to JSON
Default mode and preferred output for chaining:
uv run --script scripts/capture_html.py \
https://example.com/article \
--output-format jsonThe JSON envelope includes:
htmlurlfinal_urlcontent_type
It may also include narrow helper fields such as title or rendered.
Capture to raw HTML
Use this mode to pipe rendered HTML directly into conversion:
uv run --script scripts/capture_html.py \
https://example.com/article \
--output-format htmlWait controls
Use --wait-selector when the page is loaded only after a stable DOM hook appears:
uv run --script scripts/capture_html.py \
https://example.com/article \
--wait-selector articleUse --wait-text when the page has no stable selector but a clear visible string:
uv run --script scripts/capture_html.py \
https://example.com/article \
--wait-text "Read more"Use --timeout to control the maximum wait time in seconds:
uv run --script scripts/capture_html.py \
https://example.com/article \
--wait-selector main \
--timeout 15Convert (markmaton_convert.py)
uv run --script scripts/markmaton_convert.py [options]Convert to Markdown
uv run --script scripts/markmaton_convert.py \
--html-file page.html \
--url https://example.com/article \
--output-format markdownConvert to JSON
uv run --script scripts/markmaton_convert.py \
--html-file page.html \
--url https://example.com/article \
--output-format jsonConvert from capture envelope
Preferred when chaining with capture_html.py. Reads the JSON envelope and extracts all context fields automatically:
uv run --script scripts/capture_html.py https://example.com/article \
| uv run --script scripts/markmaton_convert.py --from-capture --output-format jsonCLI flags (--url, --final-url, --content-type) override envelope values if both are provided.
Convert from stdin
Use when another tool already produced raw HTML:
cat page.html | uv run --script scripts/markmaton_convert.py \
--url https://example.com/article \
--output-format jsonOutput modes
Use markdown when you only need readable Markdown.
Use json when you need:
markdownhtml_cleanmetadatalinksimagesquality
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = [
# "nodriver",
# ]
# ///
from __future__ import annotations
import argparse
import asyncio
import contextlib
import io
import json
import os
import shutil
import sys
from pathlib import Path
from typing import Any, Callable
def _playwright_candidates(version_dir: Path) -> list[Path]:
"""Yield Playwright Chromium binary paths for the current platform."""
if sys.platform == "darwin":
# Apple Silicon then Intel
return [
version_dir / "chrome-mac-arm64" / "Chromium.app" / "Contents" / "MacOS" / "Chromium",
version_dir / "chrome-mac" / "Chromium.app" / "Contents" / "MacOS" / "Chromium",
]
if os.name == "nt":
return [version_dir / "chrome-win" / "chrome.exe"]
# Linux: new layout then old
return [
version_dir / "chrome-linux64" / "chrome",
version_dir / "chrome-linux" / "chrome",
]
def find_chrome() -> str:
"""Find Chrome or Chromium. Order: env var, user's Chrome, user's Chromium, Playwright cache."""
# Explicit override via env var
for var in ("CHROME_PATH", "CHROMIUM_PATH"):
if path := os.environ.get(var):
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
# User's Chrome — PATH then platform-specific
for name in ("google-chrome", "google-chrome-stable", "chrome"):
if path := shutil.which(name):
return path
if sys.platform == "darwin":
mac_chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
if os.path.isfile(mac_chrome):
return mac_chrome
elif sys.platform == "linux":
for p in ("/opt/google/chrome/chrome",):
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
# User's Chromium — PATH then platform-specific
for name in ("chromium", "chromium-browser"):
if path := shutil.which(name):
return path
if sys.platform == "darwin":
mac_chromium = "/Applications/Chromium.app/Contents/MacOS/Chromium"
if os.path.isfile(mac_chromium):
return mac_chromium
# Playwright cache — newest version first
if os.name == "nt":
pw_cache = Path(os.environ.get("LOCALAPPDATA", "")) / "ms-playwright"
else:
pw_cache = Path.home() / ".cache" / "ms-playwright"
if pw_cache.is_dir():
for d in sorted(pw_cache.glob("chromium-*"), reverse=True):
for binary in _playwright_candidates(d):
if binary.is_file():
return str(binary)
raise FileNotFoundError(
"No Chrome or Chromium found. "
"Set CHROME_PATH or CHROMIUM_PATH, install Chrome, "
"or run: playwright install chromium"
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="html-to-markdown-capture")
parser.add_argument("url", help="URL to capture in a headless browser")
parser.add_argument(
"--wait-selector",
help="CSS selector to wait for before capture",
)
parser.add_argument(
"--wait-text",
help="Visible text to wait for before capture",
)
parser.add_argument(
"--timeout",
type=float,
default=10.0,
help="Maximum seconds to wait for body and optional readiness signals",
)
parser.add_argument(
"--output-format",
choices=("json", "html"),
default="json",
help="Emit a JSON capture envelope or raw HTML only",
)
return parser
async def js(tab: Any, expr: str) -> Any:
raw = await tab.evaluate(f"JSON.stringify({expr})")
if raw is None:
return None
if isinstance(raw, str):
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw
return raw
async def wait_for_capture_ready(
tab: Any,
*,
wait_selector: str | None,
wait_text: str | None,
timeout: float,
) -> None:
body = await tab.select("body", timeout=timeout)
if body is None:
raise RuntimeError("timed out waiting for the page body")
if wait_selector:
target = await tab.select(wait_selector, timeout=timeout)
if target is None:
raise RuntimeError(
f"timed out waiting for selector: {wait_selector}"
)
if wait_text:
try:
target = await tab.find(wait_text, timeout=timeout, best_match=True)
except TypeError:
target = await tab.find(wait_text, timeout=timeout)
if target is None:
raise RuntimeError(f"timed out waiting for text: {wait_text}")
await asyncio.sleep(0.5)
async def capture_once(
url: str,
*,
wait_selector: str | None,
wait_text: str | None,
timeout: float,
) -> dict[str, Any]:
import nodriver as uc
browser = None
try:
chrome_path = find_chrome()
browser = await uc.start(
headless=True,
browser_executable_path=chrome_path,
browser_args=["--use-mock-keychain"],
)
tab = await browser.get(url)
await wait_for_capture_ready(
tab,
wait_selector=wait_selector,
wait_text=wait_text,
timeout=timeout,
)
html = await tab.get_content()
page_state = await js(
tab,
"""(() => ({
final_url: location.href,
title: document.title || null,
content_type: document.contentType || null
}))()""",
)
final_url = url
title = None
content_type = None
if isinstance(page_state, dict):
final_url = page_state.get("final_url") or url
title = page_state.get("title")
content_type = page_state.get("content_type")
return {
"html": html or "",
"url": url,
"final_url": final_url,
"content_type": content_type,
"title": title,
"rendered": True,
}
finally:
if browser is not None:
try:
with contextlib.redirect_stdout(io.StringIO()):
with contextlib.redirect_stderr(io.StringIO()):
browser.stop()
except Exception:
pass
def capture_page(
url: str,
*,
wait_selector: str | None,
wait_text: str | None,
timeout: float,
) -> dict[str, Any]:
import nodriver as uc
return uc.loop().run_until_complete(
capture_once(
url,
wait_selector=wait_selector,
wait_text=wait_text,
timeout=timeout,
)
)
def render_output(payload: dict[str, Any], output_format: str) -> str:
if output_format == "html":
return payload["html"]
return json.dumps(payload, ensure_ascii=False)
def main(
argv: list[str] | None = None,
*,
capture_impl: Callable[..., dict[str, Any]] | None = None,
stdout: Any | None = None,
stderr: Any | None = None,
) -> int:
parser = build_parser()
args = parser.parse_args(argv)
capture = capture_impl or capture_page
stdout = stdout or sys.stdout
stderr = stderr or sys.stderr
try:
payload = capture(
args.url,
wait_selector=args.wait_selector,
wait_text=args.wait_text,
timeout=args.timeout,
)
except Exception as exc:
stderr.write(f"capture failed for {args.url}: {exc}\n")
return 1
stdout.write(render_output(payload, args.output_format))
if args.output_format == "html":
if payload["html"] and not payload["html"].endswith("\n"):
stdout.write("\n")
else:
stdout.write("\n")
return 0
if __name__ == "__main__":
_stdout = sys.stdout
_stderr = sys.stderr
sys.stdout = io.StringIO()
sys.stderr = io.StringIO()
raise SystemExit(main(stdout=_stdout, stderr=_stderr))
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = [
# "markmaton",
# ]
# ///
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from markmaton import ConvertOptions, ConvertRequest, convert_html
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="html-to-markdown")
parser.add_argument("--html-file", type=Path, help="Path to an HTML file")
parser.add_argument("--url", help="Source URL used as parsing context")
parser.add_argument("--final-url", help="Final URL after redirects")
parser.add_argument("--content-type", help="Optional content type hint")
parser.add_argument(
"--from-capture",
action="store_true",
help="Read a capture JSON envelope from stdin instead of raw HTML",
)
parser.add_argument(
"--output-format",
choices=("json", "markdown"),
default="json",
help="Choose between full JSON output or markdown only",
)
parser.add_argument(
"--full-content",
action="store_true",
help="Disable main-content-only cleaning",
)
parser.add_argument(
"--include-selector",
action="append",
default=[],
help="CSS selector to force-include before conversion",
)
parser.add_argument(
"--exclude-selector",
action="append",
default=[],
help="CSS selector to remove before conversion",
)
return parser
def _read_capture_envelope(
args: argparse.Namespace,
) -> tuple[str, str | None, str | None, str | None]:
"""Read a capture JSON envelope from stdin. CLI flags override envelope values."""
envelope = json.loads(sys.stdin.read())
html = envelope.get("html", "")
url = args.url or envelope.get("url")
final_url = args.final_url or envelope.get("final_url")
content_type = args.content_type or envelope.get("content_type")
return html, url, final_url, content_type
def _read_html(path: Path | None) -> str:
if path is None:
return sys.stdin.read()
return path.read_text(encoding="utf-8")
def main(
argv: list[str] | None = None,
*,
stdout: Any | None = None,
stderr: Any | None = None,
) -> int:
parser = build_parser()
args = parser.parse_args(argv)
stdout = stdout or sys.stdout
stderr = stderr or sys.stderr
try:
if args.from_capture:
html, url, final_url, content_type = _read_capture_envelope(args)
else:
html = _read_html(args.html_file)
url = args.url
final_url = args.final_url
content_type = args.content_type
response = convert_html(
ConvertRequest(
html=html,
url=url,
final_url=final_url,
content_type=content_type,
options=ConvertOptions(
only_main_content=not args.full_content,
include_selectors=list(args.include_selector),
exclude_selectors=list(args.exclude_selector),
),
)
)
except Exception as exc:
stderr.write(f"conversion failed: {exc}\n")
return 1
if args.output_format == "markdown":
stdout.write(response.markdown)
if response.markdown and not response.markdown.endswith("\n"):
stdout.write("\n")
return 0
stdout.write(
json.dumps(
{
"markdown": response.markdown,
"html_clean": response.html_clean,
"metadata": response.metadata.__dict__,
"links": response.links,
"images": response.images,
"quality": response.quality.__dict__,
},
ensure_ascii=False,
)
)
stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Related skills
FAQ
Does it handle JavaScript-rendered pages?
Yes, it uses nodriver for CDP-based headless browser capture of JS-heavy pages, and recommends a plain fetch for static or server-rendered docs.
What does it output?
A JSON envelope by default with the Markdown body plus metadata, links, images and quality signals; use --output-format markdown for the raw Markdown body only.