
Web Fetch
- 2 installs
- 4 repo stars
- Updated June 12, 2026
- code-yeongyu/web-fetch
Fetch and parse content from web URLs for processing and analysis
About
Fetches and parses content from URLs for agents to download pages and extract text. Enables web content processing and analysis.
- URL content fetching
- Content parsing
Web Fetch by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,839 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/code-yeongyu/web-fetch --skill web-fetchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 4 |
| Last updated | June 12, 2026 |
| Repository | code-yeongyu/web-fetch ↗ |
What it does
Fetch and parse content from web URLs for processing and analysis
Files
web-fetch
Fetch a URL, write the body to $TMPDIR/web-fetch-<runid>/, print the path. Pipe the path through rg / jq / awk to surgically extract what you need.
Why this skill exists
The built-in webfetch tool dumps the whole rendered page into the conversation. For anything bigger than a few KB that drowns the context window. The fix is dead simple: write the page to disk, then grep it.
That is all this skill does. One curl call, one Cloudflare-aware retry, HTML → Markdown via stdlib, save to a temp dir, print the path. No providers, no fallback chain, no fancy machinery. The same shape as pi-webfetch and opencode/webfetch, but as a standalone skill that works from any agent (Claude Code, OpenCode, pi, hermes, openclaw, whatever speaks Bash).
When to use it
Trigger this skill any time the user hands you a URL and the goal is to read its content. Examples:
- "Fetch <https://docs.python.org/3/library/asyncio.html> and tell me about TaskGroup."
- "Read the README at <https://github.com/anthropics/anthropic-sdk-python>."
- "What does this article say about <topic>? <URL>"
- "Pull <PR url>, what's the key change?"
- "Fetch the page and pull out only the deprecated section."
If the URL is https://example.com and you only need 3 lines, the built-in tool is fine. Use this skill when the page is non-trivially large or you want to filter before reading.
How to invoke it
The script is at scripts/web_fetch.py. It needs python3 (3.9+) and ideally curl (auto-detected; urllib fallback works too).
Basic fetch (markdown is default)
python3 <skill_dir>/scripts/web_fetch.py https://docs.python.org/3/library/asyncio.htmlStdout prints two lines:
/tmp/web-fetch-20260501-171120-abc123/result.md
/tmp/web-fetch-20260501-171120-abc123/result.jsonresult.md is the converted markdown. result.json is the same content plus a _meta envelope.
Pick format
python3 web_fetch.py https://example.com --format markdown # default; HTML auto-converted
python3 web_fetch.py https://example.com --format text # tags stripped, entities decoded
python3 web_fetch.py https://example.com --format html # original HTML untouched
python3 web_fetch.py https://example.com --format raw # bytes-as-they-came (no conversion)Other flags
| Flag | Default | Purpose |
|---|---|---|
--timeout SEC | 30 (cap 120) | Per-request timeout |
--output-dir PATH | $TMPDIR/web-fetch-<runid> | Custom output dir for stable paths |
--print-path {all,content,json} | all | What to print on stdout |
--print-content | off | Also dump rendered content to stdout (for direct piping) |
--use-curl / --use-urllib | auto | Force the HTTP transport |
--quiet / --verbose | off | Trace verbosity |
The pipe-and-grep pattern
This is the whole point. Resist the temptation to read the entire result.md. Use the path:
# Capture the content path
CONTENT=$(python3 web_fetch.py https://docs.python.org/3/library/asyncio.html --print-path content --quiet)
# Find the relevant section
rg -n "TaskGroup" "$CONTENT" | head -20
# Get N lines of context around a match
rg -B2 -A8 "asyncio.run" "$CONTENT" | head -40
# Multiple keywords
rg -in "deprecated|removed|since 3\.1[0-9]" "$CONTENT"
# Grab specific markdown sections (## or ### headings)
awk '/^## TaskGroup/,/^## /' "$CONTENT"For headers / metadata extraction:
JSON=$(python3 web_fetch.py https://example.com --print-path json --quiet)
jq -r '._meta.final_url, ._meta.content_type, ._meta.bytes' "$JSON"
jq -r '._meta.attempts[] | "\(.user_agent): \(.status) (\(.duration_ms)ms)"' "$JSON"For Windows PowerShell, see references/pipelines-windows.md.
What gets written
$TMPDIR/web-fetch-<runid>/
├── result.md rendered markdown (or .txt / .html depending on --format)
├── result.json { content, _meta }
├── trace.json _meta only (faster to inspect)
└── raw.html original response body (or raw.bin for non-text)Useful when:
- You want to compare rendered markdown against raw HTML (
diff result.md raw.html). - You want to keep the raw bytes around for forensic / replay use.
- You want the structured
_metaseparate from the content for cheap inspection.
The _meta envelope
Every successful fetch produces this metadata:
{
"version": "0.1.0",
"url": "https://docs.python.org/3/library/asyncio.html",
"format": "markdown",
"ok": true,
"http_status": 200,
"content_type": "text/html",
"final_url": "https://docs.python.org/3/library/asyncio.html",
"converted": true,
"bytes": 41023,
"raw_bytes": 198541,
"attempts": [
{"user_agent": "browser", "status": 200, "duration_ms": 187, "bytes": 198541,
"content_type": "text/html", "final_url": "...", "cf_challenge": false}
],
"duration_ms": 199,
"started_at": "2026-05-01T08:11:20.045449+00:00",
"raw_path": "/tmp/.../raw.html",
"content_path": "/tmp/.../result.md",
"output_dir": "/tmp/.../",
"transport": "curl"
}attempts[] shows the actual HTTP calls. If a Cloudflare challenge fired, you will see two entries (browser then honest). If you need to debug a fetch that "looks weird," dump this trace.
Cloudflare retry (built-in)
Some sites return HTTP 403 with a Cloudflare challenge page when called with a browser-like UA but a non-browser TLS fingerprint. The skill detects this case (status 403 or 503 plus body markers like <title>Just a moment...</title> or cf-mitigated) and retries once with a plain web-fetch/<version> UA. Some operators allow honest UAs through. This is the same trick pi-webfetch and opencode/webfetch use.
If both attempts fail, the script exits with code 2 and the trace records both attempts so you can see exactly why.
Limits
- 5 MB response cap. Anything larger is rejected (curl uses
--max-filesize; urllib reads N+1 then aborts). - 120 second timeout cap.
--timeoutlarger than this is silently clamped. - Read-only. This skill never modifies remote state; all calls are GET.
- Public HTTP/HTTPS only. No file://, no localhost shortcuts, no credentials in the URL.
- No JS rendering. If the page needs a real browser to populate content, you will get the unhydrated shell. For SPAs / aggressively dynamic content, use a headless-browser tool (
playwright,firecrawl, etc.) outside this skill.
Key invariants (do not break)
- Content lives on disk, not in the conversation. Quote what is relevant from the file. Never paste a full fetched body into your response unless the user explicitly asked for the full content.
- Always mention the path. When you tell the user "I fetched X", also mention where it is on disk so they can grep it themselves.
- Pipe before reading. When the user asks a specific question about the page, run
rg/jq/awkagainst the saved path and quote only matching lines. Do not pre-load the entire file. - Trace is machine-readable. If a fetch fails, surface the per-attempt error from
trace.json, do not blindly retry the same URL with the same args.
More
references/pipelines-posix.md- rg/jq/awk worked examples for macOS / Linux / WSL / Git Bash.references/pipelines-windows.md- PowerShell + cmd equivalents.references/compat.md- per-OS support matrix, Python/curl version floor, Windows / WSL / Alpine / RHEL 7 setup.references/troubleshooting.md- 403 / 429 / Cloudflare / TLS / proxy gotchas and how to read the trace.README.md- install, packaging, registration into pi/senpi/.agents/skills/, project structure.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
smoke:
name: smoke (${{ matrix.os }} / py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
timeout-minutes: 5
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-22.04, macos-latest, windows-latest]
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Show toolchain versions
shell: bash
run: |
python --version
curl --version | head -1 || true
echo "OS: ${{ matrix.os }}"
- name: Run smoke tests (POSIX)
if: runner.os != 'Windows'
run: bash tests/smoke.sh
- name: Run smoke tests (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: ./tests/smoke.ps1
- name: Run smoke tests with urllib transport (POSIX)
if: runner.os != 'Windows'
run: |
python scripts/web_fetch.py https://example.com --use-urllib --output-dir ./_urllib --quiet
test -f ./_urllib/result.md
grep -q "Example Domain" ./_urllib/result.md
python -c "import json; m=json.load(open('./_urllib/result.json'))['_meta']; assert m['transport']=='urllib', m['transport']"
- name: Run smoke tests with urllib transport (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
python scripts/web_fetch.py https://example.com --use-urllib --output-dir ./_urllib --quiet
if (-not (Test-Path './_urllib/result.md')) { throw 'result.md missing' }
$md = Get-Content './_urllib/result.md' -Raw
if ($md -notmatch 'Example Domain') { throw 'content missing' }
$meta = (Get-Content './_urllib/result.json' -Raw | ConvertFrom-Json)._meta
if ($meta.transport -ne 'urllib') { throw "expected urllib, got $($meta.transport)" }
syntax-check-old-python:
name: syntax check (Python 3.9 / 3.10 floor)
runs-on: ubuntu-latest
timeout-minutes: 2
strategy:
matrix:
floor: ['3.9', '3.10']
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.floor }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.floor }}
- name: Compile-check the script targets ${{ matrix.floor }}
run: |
python -c "
import ast, sys
src = open('scripts/web_fetch.py').read()
tree = ast.parse(src, feature_version=tuple(map(int, '${{ matrix.floor }}'.split('.'))))
print('parses on ${{ matrix.floor }}: OK')
"
python -m py_compile scripts/web_fetch.py
echo 'py_compile passed on ${{ matrix.floor }}'
lint:
name: lint and structure
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Verify SKILL.md frontmatter shape
run: |
python - <<'PY'
import re, sys
src = open('SKILL.md').read()
assert src.startswith('---\n'), 'SKILL.md must start with YAML frontmatter'
end = src.find('\n---\n', 4)
assert end > 0, 'SKILL.md missing closing ---'
fm = src[4:end]
assert re.search(r'^name:\s*web-fetch\s*$', fm, re.M), 'frontmatter missing name: web-fetch'
assert re.search(r'^description:', fm, re.M), 'frontmatter missing description'
print('frontmatter OK')
PY
- name: Verify no Korean in skill content
run: |
if grep -rn '[가-힣]' SKILL.md README.md references/ scripts/ tests/ 2>/dev/null; then
echo 'FAIL: Korean characters found in skill content' >&2
exit 1
fi
echo 'no korean'
- name: Verify required files exist
run: |
for f in SKILL.md README.md LICENSE scripts/web_fetch.py tests/smoke.sh tests/smoke.ps1 \
references/pipelines-posix.md references/pipelines-windows.md \
references/troubleshooting.md references/compat.md; do
test -f "$f" || { echo "FAIL: missing $f"; exit 1; }
done
echo 'all required files present'
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
# Editor / OS
.DS_Store
.vscode/
.idea/
*.swp
# Test / scratch artifacts
scratch/
.web-fetch-rr.json
/tmp/web-fetch-*
# Local config (may contain credentials for future provider extensions)
.web-fetch.json
.web-fetch/
MIT License
Copyright (c) 2026 Yeongyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
web-fetch
LLM-neutral skill for fetching a URL and writing the body to a temp file so an agent can pipe the path through rg / jq / awk instead of dumping the whole page into the conversation.
Same shape as `pi-webfetch` and `opencode/webfetch`, packaged as a standalone skill that any Bash-capable agent (Claude Code, OpenCode, pi, hermes, openclaw) can load.
Install
git clone https://github.com/code-yeongyu/web-fetch ~/.agents/skills/web-fetchThat is it. The script is single-file Python 3 stdlib; no pip install needed. curl is preferred but optional (urllib fallback).
Symlink for active development
ln -s /path/to/your/clone ~/.agents/skills/web-fetchOther agents
- Claude Code / OpenCode: drop the directory under
~/.agents/skills/and the skill auto-registers via thename+descriptionin the frontmatter. - pi (`~/.senpi/agent`): not a
piextension - this is a skill, not a Tool extension. Pi consumes skills via~/.agents/skills/symlinks; see~/.senpi/.pi/agent/skills/for the convention. - Direct CLI use:
python3 ~/.agents/skills/web-fetch/scripts/web_fetch.py <URL>.
Usage
# Default (markdown, auto-converted from HTML)
python3 scripts/web_fetch.py https://example.com
# Format options
python3 scripts/web_fetch.py https://example.com --format markdown
python3 scripts/web_fetch.py https://example.com --format text
python3 scripts/web_fetch.py https://example.com --format html
python3 scripts/web_fetch.py https://example.com --format raw
# Custom output dir, custom timeout
python3 scripts/web_fetch.py https://example.com --output-dir ./scratch --timeout 60
# Print only the content path (for piping)
python3 scripts/web_fetch.py https://example.com --print-path content
# Also write content to stdout
python3 scripts/web_fetch.py https://example.com --print-contentStdout prints two paths by default: result.<ext> then result.json. The trace lives on stderr (suppress with --quiet).
See SKILL.md for the full agent-facing usage and `references/pipelines.md` for rg / jq / awk worked examples.
Project layout
web-fetch/
├── SKILL.md agent-facing skill (loaded by Claude Code, OpenCode, pi, etc.)
├── README.md this file
├── LICENSE MIT
├── scripts/
│ └── web_fetch.py single-file Python 3 stdlib script
├── references/
│ ├── pipelines-posix.md rg / jq / awk patterns for macOS / Linux / WSL / Git Bash
│ ├── pipelines-windows.md PowerShell + cmd equivalents
│ ├── compat.md per-OS support matrix, Python/curl version floor
│ └── troubleshooting.md 403 / 429 / Cloudflare / TLS gotchas
├── tests/
│ ├── smoke.sh POSIX self-test (fetches example.com)
│ └── smoke.ps1 PowerShell self-test (Windows CI)
└── .github/workflows/ci.yml matrix CI: macos/ubuntu/windows x py 3.9-3.13What it does
1. One curl GET (or urllib if curl missing). 2. If response is 403/503 with Cloudflare challenge markers → retry once with an honest UA. 3. If Content-Type: text/html and --format is markdown/text → convert via stdlib html.parser. 4. Write result.<ext> (rendered), raw.<ext> (original body), result.json (envelope), trace.json (metadata only). 5. Print paths on stdout.
What it does NOT do
- No multi-provider fallback chains. (Use a different tool if curl cannot reach the site.)
- No JavaScript rendering. (SPAs need a real browser; use
playwright/firecrawloutside this skill.) - No authentication. (No cookies, no API keys; this is a public-content fetcher.)
- No retries beyond the one Cloudflare retry. (If the site is down, it is down.)
- No content caching across runs. (Each call writes a fresh
<runid>directory; pass--output-dirfor stable paths.)
The simplicity is the point. If you need providers / fallback / load-balancing, that belongs in a search skill, not a fetch skill.
Limits
- 5 MB response size cap.
- 120 second timeout cap.
- Public HTTP/HTTPS only.
Requirements
- Python ≥ 3.9 (stdlib only).
curl(optional but recommended; auto-detected). Windows 10 1803+ shipscurl.exe.
For older systems (RHEL 7, Ubuntu 18.04, etc.) and Windows-specific setup, see `references/compat.md`.
Testing
bash tests/smoke.sh # POSIX (macOS / Linux / WSL / Git Bash)
pwsh tests/smoke.ps1 # Windows (PowerShell 5.1+ or 7+)CI runs the matrix on every push: {macos-latest, ubuntu-latest, ubuntu-22.04, windows-latest} x {Python 3.9, 3.10, 3.11, 3.12, 3.13} plus a syntax-floor check on Python 3.9 and 3.10.
License
MIT.
Acknowledgments
- `pi-webfetch` - direct ancestor for the URL/format/timeout shape.
- `opencode/webfetch` - reference for the Cloudflare retry pattern.
- Anthropic skills - the
SKILL.md+references/packaging convention.
Compatibility - Python and OS support matrix
This skill is designed to run on macOS, Linux (modern + older LTS), and Windows. The script is single-file Python 3 stdlib, so the surface area is small. This file documents the exact requirements per platform.
Supported platforms
| OS | Tested | Notes |
|---|---|---|
| macOS 12+ | Yes | System Python 3.9 + Homebrew curl works |
| Ubuntu 22.04+ / Debian 12+ | Yes | Default Python 3.10/3.11 + curl 7.81+ |
| Ubuntu 20.04 / Debian 11 | Yes | Default Python 3.8 needs upgrade; install python3.9 from PPA |
| RHEL 8 / Rocky 8 / AlmaLinux 8 | Yes | dnf install python3.9 |
| RHEL 7 / CentOS 7 | Limited | EOL; install python3.9 from EPEL/SCL or build from source |
| Windows 11 | Yes | Native via python.exe + bundled curl.exe |
| Windows 10 (1803+) | Yes | Same as 11 |
| Windows 10 (older) | Limited | curl.exe not bundled before 1803; install via winget/scoop, or use --use-urllib |
| WSL2 | Yes | Treated as Linux |
| Alpine Linux | Yes | apk add python3 curl |
| FreeBSD / OpenBSD | Likely | Untested in CI but uses only POSIX features |
Python version floor
Minimum supported: Python 3.9 (released October 2020).
Why 3.9 and not older:
- The script uses
from __future__ import annotations, so PEP 585 generic syntax (dict[str, str]etc.) works at parse time on 3.7+. subprocess.run(capture_output=True)requires 3.7+.- We never call
eval()on annotations, so type-hint shape is irrelevant at runtime. - 3.9 reached upstream end-of-life in 2025-10. It remains the floor only because it is still what ships on older LTS distros; prefer 3.10+ wherever you can choose.
For older Pythons, you have two options:
1. Install a newer Python alongside system Python. This is the recommended path on RHEL 7 / Ubuntu 18.04 / etc.
- Ubuntu/Debian:
sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt install python3.9 - RHEL/CentOS 7:
sudo yum install -y python39(from EPEL or IUS) - Alpine:
apk add python3(already 3.9+ on alpine 3.13+) - Then invoke explicitly:
python3.9 scripts/web_fetch.py ...
2. Force urllib transport. The Python urllib fallback works on any Python 3.6+ in practice, even if our test matrix only certifies 3.9. Pass --use-urllib:
python3 scripts/web_fetch.py <URL> --use-urllibThis avoids any curl-related issues on older systems.
curl version floor
Minimum supported: curl 7.40+ (released January 2015).
The script's curl invocation uses these flags:
| Flag | Required curl version |
|---|---|
-sS (silent + show errors) | ancient |
-L (follow redirects) | ancient |
-o - (write to stdout) | ancient |
-X GET | ancient |
-H "header: value" | ancient |
--max-time SEC | ancient |
--max-filesize BYTES | curl 7.10 (2002) |
--data-binary @- | ancient |
-w "%{http_code}\t%{url_effective}\t%{content_type}" | curl 7.40 (Jan 2015) - %{content_type} was added then |
If your distro ships an older curl, the -w template will print empty values for %{content_type} and HTML→markdown conversion will not auto-trigger. Workaround: use --use-urllib.
Verify with:
curl --version | head -1OS-specific gotchas
macOS
- System Python 3 may be 3.9 (old macOS) or absent on newer macOS where
python3opens the App Store. Use Homebrew Python:brew install python@3.12. - System curl uses Secure Transport (Apple's TLS) which can lag behind OpenSSL. If you hit "SSL CA bundle problem" (curl exit 60), install Homebrew curl:
brew install curland prepend/opt/homebrew/opt/curl/binto PATH.
Modern Linux (Ubuntu 22.04+, Debian 12+, Fedora 38+)
Ships with everything needed out of the box. No setup required beyond:
sudo apt install python3 curl # or dnf, or pacmanOld Linux (RHEL 7, Ubuntu 18.04, CentOS 7)
Default Python is 3.6, which fails because of from __future__ import annotations + parsing of generic syntax in .pyi style. Install Python 3.9 from EPEL / deadsnakes / SCL:
# RHEL 7 / CentOS 7
sudo yum install -y centos-release-scl
sudo yum install -y rh-python39
scl enable rh-python39 bash
# Then 'python3 --version' shows 3.9.x
# Ubuntu 18.04 / 20.04
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update && sudo apt install python3.9Run explicitly:
python3.9 scripts/web_fetch.py https://example.comIf you cannot install a newer Python, you can patch the script: remove from __future__ import annotations and replace generic syntax with typing.Dict[str, str] etc. We do not maintain a 3.6-compatible branch.
Windows 10 / 11
Out of the box on 1803+:
python --version # if missing: winget install Python.Python.3.12
curl --version # bundled
.\scripts\web_fetch.py https://example.com # works as-isThe shebang #!/usr/bin/env python3 is ignored on Windows; invoke with python or py explicitly. Path separators are handled by pathlib so output paths use \ correctly.
WSL / WSL2
Treated as Linux. No special config. Output paths inside WSL use /tmp/web-fetch-... (the Linux temp dir, not the Windows one). If you want Windows tools to see them, use --output-dir /mnt/c/Users/you/Downloads/web-fetch.
Alpine / Docker
FROM alpine:3.18
RUN apk add --no-cache python3 curl ripgrep jq
COPY scripts/web_fetch.py /usr/local/bin/web-fetch.py
ENTRYPOINT ["python3", "/usr/local/bin/web-fetch.py"]This image is ~50 MB and works as a drop-in fetch sidecar.
Verifying your environment
Run the smoke test:
bash tests/smoke.shIt exits non-zero on any failure and reports which step broke. The test fetches https://example.com so it requires outbound HTTPS to that host.
For Windows PowerShell, equivalents:
.\tests\smoke.ps1Both test scripts cover: version flag, markdown fetch, envelope shape, format=text, format=html, scheme rejection, --print-path modes, urllib fallback.
Reporting compatibility issues
If something fails on a platform listed as supported, the trace will tell you what broke:
python3 scripts/web_fetch.py <URL> --verbose 2>trace.log
cat trace.log
cat /tmp/web-fetch-*/trace.json | python3 -m json.toolOpen an issue with:
- Your OS + version (
uname -aorwinver) python3 --versioncurl --version | head -1- The trace log
- The
_meta.attempts[]fromtrace.json
Pipeline patterns - POSIX (macOS / Linux / WSL / Git Bash)
This file covers Bash / zsh on macOS, modern Linux, WSL, and Git Bash on Windows. For native Windows PowerShell, see pipelines-windows.md.
Capture paths into shell variables
# Two paths on stdout (default --print-path=all): content, then json
read -r CONTENT JSON < <(python3 web_fetch.py https://example.com --quiet | tr '\n' ' ')
# Or just the content path
CONTENT=$(python3 web_fetch.py https://example.com --print-path content --quiet)
# Or just the json envelope path
JSON=$(python3 web_fetch.py https://example.com --print-path json --quiet)Search inside the page
rg -in "TaskGroup" "$CONTENT" # match (case-insensitive, with line numbers)
rg -B2 -A6 "asyncio.run" "$CONTENT" # context lines
rg -inw "await" "$CONTENT" | head -20 # word boundary
rg -in "deprecated|removed since" "$CONTENT" # alternates
rg -c "TaskGroup" "$CONTENT" # count matches
rg -o 'https?://[A-Za-z0-9./_-]+' "$CONTENT" | sort -u # extract URLsIf rg is not installed, fall back to grep -RiIn. The flags differ slightly:
grep -in "TaskGroup" "$CONTENT"
grep -B2 -A6 "asyncio.run" "$CONTENT"
grep -inE "deprecated|removed since" "$CONTENT"Section extraction (markdown)
# Everything between "## TaskGroup" and the next "## " heading
awk '/^## TaskGroup/{flag=1} /^## /{if(flag&&!/^## TaskGroup/)exit} flag' "$CONTENT"
# Same but tolerant of any heading level
awk '/^#+ TaskGroup/{flag=1; next} /^#+ /{if(flag)exit} flag' "$CONTENT"
# Just code blocks (fenced)
awk '/^```/{f=!f; next} f' "$CONTENT"
# Headings only (table of contents)
rg -n '^#{1,6} ' "$CONTENT"Inspect metadata via jq
jq -r '._meta.final_url' "$JSON"
jq -r '._meta.content_type' "$JSON"
jq -r '._meta.converted' "$JSON"
jq -r '._meta | "rendered=\(.bytes) raw=\(.raw_bytes)"' "$JSON"
jq -r '._meta.attempts[] | "\(.user_agent)\t\(.status)\t\(.duration_ms)ms\t\(.bytes)B"' "$JSON"
jq -r '.content' "$JSON"If jq is not installed:
python3 -c "import json,sys; print(json.load(open('$JSON'))['_meta']['final_url'])"Compose - fetch + filter + format
# All GitHub issue links from a project README
CONTENT=$(python3 web_fetch.py https://github.com/anthropics/anthropic-sdk-python --print-path content --quiet)
rg -o 'https://github.com/[^)]+/issues/[0-9]+' "$CONTENT" | sort -u
# External links from a docs page (skip same-host)
CONTENT=$(python3 web_fetch.py https://docs.python.org/3/library/asyncio.html --print-path content --quiet)
rg -o 'https?://[^) ]+' "$CONTENT" | rg -v 'docs\.python\.org' | sort -uBatch fetch many URLs in parallel
# urls.txt has one URL per line
xargs -n1 -P8 -I{} python3 web_fetch.py {} --print-path content --quiet < urls.txt > paths.txt
# Grep across all of them at once
xargs cat < paths.txt | rg -in "deprecated"
# Or process each one independently
while read -r p; do
echo "=== $p ==="
rg -in "deprecated" "$p" | head -5
done < paths.txtxargs -P is GNU/BSD-portable. On macOS, default xargs works; on very old Linux without -P, fall back to a &/wait loop:
while read -r url; do
python3 web_fetch.py "$url" --print-path content --quiet &
done < urls.txt
waitStable output dir for repeated runs
python3 web_fetch.py https://example.com --output-dir ./scratch/example-com --quiet
ls ./scratch/example-com/Useful when you want to track changes to a page over time (commit the directory and diff between versions).
Stream the content directly (no temp file path)
# All output to stdout: paths first, then content
python3 web_fetch.py https://example.com --print-content --quiet
# Just the content, no path lines (use jq to read result.json)
JSON=$(python3 web_fetch.py https://example.com --print-path json --quiet)
jq -r '.content' "$JSON"Anti-patterns
- Reading the whole file when you only need one section - use
rgfirst; quote the matching lines. - Re-fetching the same URL across follow-up turns - save
$CONTENTbetween turns; the temp dir persists for the session. - Hardcoding paths from a previous run - the
<runid>changes per call. Pass--output-dirif you need a stable path. - Pasting the whole `result.md` into a chat reply - quote the relevant lines from
rgoutput. Mention the path so the user can grep it themselves.
Pipeline patterns - Windows (PowerShell + cmd)
This file covers native Windows shells. If you have Git Bash, WSL, or Cygwin, follow pipelines-posix.md instead - the bash patterns there work unchanged.
Prerequisites on Windows
The Python script itself only needs:
- Python 3.9+ (https://www.python.org/downloads/, or
winget install Python.Python.3.12) - `curl.exe` (bundled with Windows 10 1803+ / Server 2019+; verify with
curl --version)
For piping/grepping, you will want:
- ripgrep (
winget install BurntSushi.ripgrep.MSVCorscoop install ripgrep) - jq (
winget install jqlang.jqorscoop install jq)
PowerShell has built-in equivalents (Select-String, ConvertFrom-Json) so external tools are optional.
PowerShell
Capture paths into variables
# Default --print-path=all returns two lines: content, then json
$paths = python web_fetch.py https://example.com --quiet
$content = $paths[0]
$json = $paths[1]
# Or grab one path directly
$content = python web_fetch.py https://example.com --print-path content --quiet
$json = python web_fetch.py https://example.com --print-path json --quietSearch inside the page
# Built-in (no rg required)
Select-String -Path $content -Pattern 'TaskGroup'
Select-String -Path $content -Pattern 'asyncio.run' -Context 2,6
Select-String -Path $content -Pattern 'deprecated|removed since' -CaseSensitive:$false
# Count matches
(Select-String -Path $content -Pattern 'TaskGroup').Count
# Extract URLs (regex)
Get-Content $content | Select-String -Pattern 'https?://[A-Za-z0-9./_-]+' -AllMatches |
ForEach-Object { $_.Matches.Value } | Sort-Object -UniqueIf you have rg.exe installed, the POSIX rg examples in pipelines-posix.md work in PowerShell too:
rg -in "TaskGroup" $content
rg -B2 -A6 "asyncio.run" $contentSection extraction (markdown)
PowerShell does not have awk natively. Either install gawk (scoop/chocolatey) or use this pattern:
# Everything between "## TaskGroup" and the next "## " heading
$lines = Get-Content $content
$inSection = $false
foreach ($line in $lines) {
if ($line -match '^## TaskGroup') { $inSection = $true; $line; continue }
if ($inSection -and $line -match '^## ' -and $line -notmatch '^## TaskGroup') { break }
if ($inSection) { $line }
}Inspect metadata
# Built-in JSON parsing
$meta = (Get-Content $json -Raw | ConvertFrom-Json)._meta
$meta.final_url
$meta.content_type
$meta.bytes
$meta.attempts | ForEach-Object {
"$($_.user_agent)`t$($_.status)`t$($_.duration_ms)ms`t$($_.bytes)B"
}If you have jq.exe:
jq -r '._meta.final_url' $json
jq -r '._meta.attempts[] | "\(.user_agent)\t\(.status)\t\(.duration_ms)ms"' $jsonCompose - fetch + filter + format
# All GitHub issue links from a project README
$content = python web_fetch.py https://github.com/anthropics/anthropic-sdk-python --print-path content --quiet
Get-Content $content | Select-String -Pattern 'https://github\.com/[^\)]+/issues/\d+' -AllMatches |
ForEach-Object { $_.Matches.Value } | Sort-Object -UniqueBatch fetch many URLs in parallel
PowerShell 7+ has ForEach-Object -Parallel:
# urls.txt with one URL per line
$urls = Get-Content urls.txt
$paths = $urls | ForEach-Object -Parallel {
python web_fetch.py $_ --print-path content --quiet
} -ThrottleLimit 8
$paths | Set-Content paths.txt
# Grep across all
Get-Content paths.txt | ForEach-Object {
Select-String -Path $_ -Pattern 'deprecated'
}Windows PowerShell 5.1 (the default on Windows 10/11) does NOT have -Parallel. Use background jobs:
$jobs = @()
foreach ($url in (Get-Content urls.txt)) {
$jobs += Start-Job -ScriptBlock {
param($u) python web_fetch.py $u --print-path content --quiet
} -ArgumentList $url
}
$paths = $jobs | Receive-Job -Wait | Where-Object { $_ }
$jobs | Remove-JobStable output dir
python web_fetch.py https://example.com --output-dir .\scratch\example-com --quiet
Get-ChildItem .\scratch\example-com\cmd.exe (legacy)
Modern Windows ships PowerShell as the default. cmd.exe still works:
REM Capture content path
for /f "delims=" %p in ('python web_fetch.py https://example.com --print-path content --quiet') do set CONTENT=%p
REM Display the file
type %CONTENT%
REM Search with findstr (very limited compared to rg / Select-String)
findstr /i "TaskGroup" %CONTENT%If you can use cmd.exe at all, you can use PowerShell. Prefer PowerShell.
Anti-patterns
- `type %FILE% | findstr ...` for big files - findstr is slow and limited. Install ripgrep.
- Manual JSON parsing in cmd.exe - install jq, or use PowerShell's
ConvertFrom-Json. - Calling `python3` instead of `python` - on Windows, the launcher is
python(and optionallypy). The Python script itself is named the same; only the invocation differs. - Relying on `\` vs `/` path separators - the Python script uses
pathlib, so paths it produces work either way. Just quote them.
Path quoting gotcha
Output paths from web_fetch.py on Windows look like:
C:\Users\you\AppData\Local\Temp\web-fetch-20260501-171120-abc123\result.mdThe backslashes are fine in PowerShell strings, but in cmd.exe variable expansion they sometimes need escaping. When in doubt, wrap the path in double quotes:
Select-String -Path "$content" -Pattern 'TaskGroup'findstr /i "TaskGroup" "%CONTENT%"Troubleshooting
When a fetch behaves badly, the answer almost always lives in trace.json. This file walks through the most common failure modes and how to recognize them in the trace.
Read the trace first
JSON=$(python3 web_fetch.py <URL> --print-path json --quiet)
jq . "$JSON" | lessThe fields that matter:
| Field | What it tells you |
|---|---|
_meta.ok | Top-line success flag. False means content is empty. |
_meta.http_status | The HTTP status of the final request (after redirects + CF retry). |
_meta.attempts[] | Every individual HTTP call. Length 1 = clean run. Length 2 = CF retry fired. |
_meta.attempts[].cf_challenge | True if that attempt hit a Cloudflare challenge page. |
_meta.final_url | Where you actually ended up. If different from the requested URL, you got redirected. |
_meta.content_type | The MIME the server returned. If empty/wrong, conversion may have skipped. |
_meta.converted | True if HTML was converted to markdown/text; false means content is the raw body. |
_meta.error | Snippet of the response body when status >= 400. |
Symptom tree
result.md exists but is empty
Look at _meta.bytes (rendered) vs _meta.raw_bytes (response body).
raw_bytes > 0,bytes == 0→ HTML→markdown conversion produced nothing. Usually means the response was wrapped in dropped tags (<head>,<script>, etc.) or the page is mostly JS-injected and the markup is empty. Try--format htmlor--format rawto see what actually came back.raw_bytes == 0→ the server returned 200 with an empty body. Common with API endpoints that return 204 / empty 200.
HTTP 403 with one attempt
"attempts": [{"user_agent": "browser", "status": 403, "cf_challenge": false, ...}]The site detected automated traffic but it was not Cloudflare's branded challenge. Possible causes:
- Custom WAF rule (Akamai, AWS WAF, Imperva).
- Bot scoring on the User-Agent + TLS fingerprint combo.
- Rate limiting (often returns 403 instead of 429).
Workarounds:
- Add a referrer: this script does not send
Refererby default. The site may require it. Use--use-curland run a manualcurl -H 'Referer: ...'to confirm. - Use a real browser (
playwright,firecrawl) outside this skill.
HTTP 403 with two attempts (CF retry fired but failed)
"attempts": [
{"user_agent": "browser", "status": 403, "cf_challenge": true, ...},
{"user_agent": "honest", "status": 403, "cf_challenge": true, ...}
]The site is gating with the JS challenge that requires a real browser to solve. There is no curl workaround. Move to a headless-browser tool.
HTTP 429 (rate limited)
The trace will show attempts[0].status == 429. Check the response body for retry hints:
jq -r '._meta.error' "$JSON"
# or look at the raw response
cat $(jq -r '._meta.raw_path' "$JSON")Many APIs include Retry-After. We do not auto-honor it because this skill is "fetch once and report"; back off in the calling agent and retry later.
"curl exit 28" or "curl exit 56"
Network-level errors before any HTTP response.
| curl exit | Meaning |
|---|---|
| 6 | DNS resolution failed. Check the hostname. |
| 7 | Connection refused. Service is down or wrong port. |
22 (with -f) | HTTP error. Not used here, but if you see it manually, check status. |
| 28 | Operation timed out. Bump --timeout. |
| 35 | SSL connect error. TLS handshake failed. Often means the server has an outdated TLS stack. |
| 51 | Server SSL certificate is invalid (CN mismatch, expired, etc.). |
| 52 | Server replied with empty data. |
| 56 | Failure receiving network data mid-stream. Bad network or server killed connection. |
| 60 | SSL CA bundle problem. macOS sometimes hits this. |
For 60 specifically: check that curl --version shows a recent TLS backend. On macOS, system curl uses Secure Transport which sometimes lags. Try Homebrew curl instead.
"response exceeds 5242880 bytes" (urllib) or curl --max-filesize rejection
The page is bigger than 5 MB. Three options:
1. The URL is wrong (you are downloading a tarball when you wanted a docs page). Double-check. 2. Use --format raw and pipe to a file directly:
curl -sSL <URL> > /tmp/big-file.html
# then process with rg / jq / etc. directly3. Edit MAX_RESPONSE_SIZE_BYTES in the script. The cap exists to keep accidental binaries out of $TMPDIR; if you legitimately need bigger files, raise it.
Markdown conversion is ugly / lossy
The stdlib HTML→markdown converter is intentionally simple. It covers headings, paragraphs, links, lists, code/pre, emphasis, blockquotes. It does NOT handle:
- Nested tables (degraded to
|separators with no row headers). <details>/<summary>(treated as plain text).- MathML, KaTeX, complex
<svg>(stripped). - CSS-driven content (
::before/::aftertext, JS-injected DOM).
If conversion quality matters more than zero dependencies:
- Use
--format htmland feed the raw HTML topandoc -f html -t markdownif pandoc is installed. - Or use
--format textfor a flatter strip that may preserve more semantic content.
Content type was JSON or XML, not HTML
When the server returns application/json or application/xml, the script does NOT convert. You get the raw bytes in result.md (the file extension is misleading; it is whatever was in the body). Use:
jq . "$CONTENT" # JSON
xmllint --format "$CONTENT" # XML, if libxml2 is installedOr change to --format raw to skip the markdown filename suffix:
python3 web_fetch.py https://api.example.com/data.json --format raw
# writes to result.txt with raw bytes, no conversion attemptedRedirect went somewhere unexpected
jq -r '._meta.final_url' "$JSON"If final_url is different from the URL you passed, the server redirected. Check:
- Was it HTTPS upgrade? (
http://→https://is normal.) - Is the final host the same domain? (Cross-domain redirects can indicate hijacking or an intermediary auth gate.)
- Is the path different? (Some sites redirect
/footo/foo/or to a localized version like/en/foo.)
If the redirect is wrong, fetch the canonical URL directly.
Two attempts, second one is 200 (CF retry succeeded)
"attempts": [
{"user_agent": "browser", "status": 403, "cf_challenge": true, ...},
{"user_agent": "honest", "status": 200, "cf_challenge": false, ...}
]Working as designed. The site allows the honest web-fetch/<version> UA through. No action needed; the rendered content uses the second attempt's body.
transport: "urllib" instead of curl
The script auto-detected that curl is not on PATH. To verify:
which curl # or: command -v curlInstall curl (usually already there on macOS / Linux; Windows 10 1803+ has curl.exe). Or force urllib explicitly:
python3 web_fetch.py <URL> --use-urllibThe behavior is the same; curl is just preferred for consistent TLS / proxy handling.
When the skill is the wrong tool
Move to a different tool when:
- JS-rendered SPA: the markup is mostly empty until a bundle runs. Use
playwrightor a service likefirecrawl/scrapingbeethat renders. - Authenticated content: this skill never sends cookies or auth. Wire your auth into a different tool (or accept that it cannot be fetched).
- Bulk crawling: 50+ URLs in tight sequence will rate-limit you. Use a real crawler (
scrapy,crawlee). - Binary downloads: tarballs, zips, PDFs, images. Use plain
curl -Oand process the file directly.
#!/usr/bin/env python3
"""web-fetch: fetch a URL, save the body to a temp dir, print the path.
Single-file Python 3 stdlib. Shells out to `curl` (preferred, OS-neutral) or
falls back to `urllib.request` when curl is unavailable.
USAGE
web-fetch URL [--format markdown|text|html|raw]
[--timeout SEC]
[--output-dir DIR]
[--print-content]
[--quiet|--verbose]
EXIT CODES
0 Fetched successfully.
1 Argument error.
2 Fetch failed (network, HTTP >= 400, oversized, timeout).
OUTPUT
Stdout : path to the rendered file, then path to result.json. Pipe-friendly.
Stderr : trace lines prefixed [web-fetch].
Files : <output-dir>/result.<ext> rendered content in requested format
<output-dir>/raw.<ext> original response body
<output-dir>/result.json content + _meta envelope
<output-dir>/trace.json _meta only
The skill writes content to disk and returns paths so callers can pipe through
rg / jq / awk / head instead of pulling 50KB of HTML into the conversation.
"""
from __future__ import annotations
import argparse
import datetime as dt
import html as _html
import html.parser
import json
import os
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
import uuid
from typing import Any
VERSION = "0.1.0"
DEFAULT_TIMEOUT_SECONDS = 30
MAX_TIMEOUT_SECONDS = 120
MAX_RESPONSE_SIZE_BYTES = 5 * 1024 * 1024
BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
)
HONEST_USER_AGENT = "web-fetch/" + VERSION
VERBOSE = False
QUIET = False
def trace(msg: str) -> None:
if QUIET:
return
sys.stderr.write(f"[web-fetch] {msg}\n")
sys.stderr.flush()
def vtrace(msg: str) -> None:
if VERBOSE and not QUIET:
sys.stderr.write(f"[web-fetch] {msg}\n")
sys.stderr.flush()
def have_curl() -> bool:
return shutil.which("curl") is not None
class HttpResp:
__slots__ = ("status", "body", "content_type", "duration_ms", "final_url",
"cf_challenge")
def __init__(self, status: int, body: bytes, content_type: str,
duration_ms: int, final_url: str, cf_challenge: bool):
self.status = status
self.body = body
self.content_type = content_type
self.duration_ms = duration_ms
self.final_url = final_url
self.cf_challenge = cf_challenge
_CF_MARKERS = (b"<title>Just a moment...</title>", b"cf-mitigated", b"cf-chl-bypass",
b"Attention Required! | Cloudflare", b"challenge-platform")
def _detect_cf_challenge(status: int, body: bytes) -> bool:
if status != 403 and status != 503:
return False
head = body[:4096].lower()
return any(m.lower() in head for m in _CF_MARKERS)
def http_get(
url: str,
*,
headers: dict[str, str],
timeout: int,
use_curl: bool,
) -> HttpResp:
"""GET with redirects + bounded timeout. Captures Content-Type and Cloudflare hints."""
started = time.monotonic()
if use_curl:
marker = b"\n__WEBFETCH_TRAILER__\t"
cmd = [
"curl", "-sS", "-L",
"-w", "\n__WEBFETCH_TRAILER__\t%{http_code}\t%{url_effective}\t%{content_type}",
"-o", "-",
url,
"--max-time", str(timeout),
"--max-filesize", str(MAX_RESPONSE_SIZE_BYTES),
]
for k, v in headers.items():
cmd += ["-H", f"{k}: {v}"]
proc = subprocess.run(cmd, capture_output=True, check=False)
if proc.returncode != 0 and not proc.stdout:
raise RuntimeError(
f"curl exit {proc.returncode}: "
f"{proc.stderr.decode('utf-8', errors='replace').strip()}"
)
out = proc.stdout
split_at = out.rfind(marker)
if split_at < 0:
body_bytes, status, final_url, ct = out, 0, url, ""
else:
body_bytes = out[:split_at]
tail = out[split_at + len(marker) :].decode("utf-8", errors="replace").strip()
parts = tail.split("\t", 2)
status = int(parts[0]) if parts and parts[0].isdigit() else 0
final_url = parts[1] if len(parts) > 1 else url
ct = parts[2] if len(parts) > 2 else ""
cf = _detect_cf_challenge(status, body_bytes)
duration_ms = int((time.monotonic() - started) * 1000)
return HttpResp(status, body_bytes, ct, duration_ms, final_url, cf)
req = urllib.request.Request(url, method="GET", headers=headers)
final_url = url
ct = ""
body_bytes = b""
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
ct = resp.headers.get("Content-Type") or ""
final_url = resp.url
body_bytes = resp.read(MAX_RESPONSE_SIZE_BYTES + 1)
if len(body_bytes) > MAX_RESPONSE_SIZE_BYTES:
raise RuntimeError(f"response exceeds {MAX_RESPONSE_SIZE_BYTES} bytes")
status = resp.status
except urllib.error.HTTPError as e:
body_bytes = e.read() if hasattr(e, "read") else b""
status = e.code
ct = e.headers.get("Content-Type", "") if e.headers else ""
cf = _detect_cf_challenge(status, body_bytes)
duration_ms = int((time.monotonic() - started) * 1000)
return HttpResp(status, body_bytes, ct, duration_ms, final_url, cf)
def accept_for(fmt: str) -> str:
if fmt == "html":
return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, */*;q=0.1"
if fmt == "markdown":
return "text/markdown;q=1.0, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1"
return "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
_HTML_VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
"meta", "param", "source", "track", "wbr"}
_HTML_DROP_TAGS = {"script", "style", "noscript", "iframe", "object", "head", "title",
"svg", "canvas", "form", "button", "select", "textarea", "label"}
class _MarkdownExtractor(html.parser.HTMLParser):
"""Stdlib-only HTML to Markdown converter. Covers headings, paragraphs, links,
lists, code/pre, emphasis, line breaks, blockquotes. Tables degrade to pipes."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.skip_depth = 0
self.list_stack: list[str] = []
self.in_pre = 0
self.in_code = 0
self.in_a: list[str | None] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if tag in _HTML_VOID_TAGS:
if self.skip_depth:
return
if tag == "br":
self.parts.append("\n")
elif tag == "hr":
self.parts.append("\n\n---\n\n")
elif tag == "img":
d = dict(attrs)
alt = d.get("alt") or ""
src = d.get("src") or ""
if src:
self.parts.append(f"")
return
if tag in _HTML_DROP_TAGS:
self.skip_depth += 1
return
if self.skip_depth:
return
if tag.startswith("h") and len(tag) == 2 and tag[1].isdigit():
self.parts.append("\n\n" + "#" * int(tag[1]) + " ")
elif tag == "p":
self.parts.append("\n\n")
elif tag in ("ul", "ol"):
self.list_stack.append(tag)
self.parts.append("\n")
elif tag == "li":
indent = " " * (len(self.list_stack) - 1) if self.list_stack else ""
marker = "1." if self.list_stack and self.list_stack[-1] == "ol" else "-"
self.parts.append(f"\n{indent}{marker} ")
elif tag == "blockquote":
self.parts.append("\n\n> ")
elif tag == "pre":
self.in_pre += 1
self.parts.append("\n\n```\n")
elif tag == "code":
if not self.in_pre:
self.in_code += 1
self.parts.append("`")
elif tag in ("strong", "b"):
self.parts.append("**")
elif tag in ("em", "i"):
self.parts.append("*")
elif tag == "a":
self.in_a.append(dict(attrs).get("href"))
self.parts.append("[")
elif tag == "tr":
self.parts.append("\n")
elif tag in ("td", "th"):
self.parts.append(" | ")
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if tag in _HTML_DROP_TAGS:
if self.skip_depth:
self.skip_depth -= 1
return
if self.skip_depth:
return
if tag.startswith("h") and len(tag) == 2 and tag[1].isdigit():
self.parts.append("\n\n")
elif tag in ("ul", "ol"):
if self.list_stack:
self.list_stack.pop()
self.parts.append("\n")
elif tag == "pre":
if self.in_pre:
self.in_pre -= 1
self.parts.append("\n```\n\n")
elif tag == "code":
if not self.in_pre and self.in_code:
self.in_code -= 1
self.parts.append("`")
elif tag in ("strong", "b"):
self.parts.append("**")
elif tag in ("em", "i"):
self.parts.append("*")
elif tag == "a":
href = self.in_a.pop() if self.in_a else None
self.parts.append(f"]({href})" if href else "]")
def handle_data(self, data: str) -> None:
if self.skip_depth:
return
self.parts.append(data)
def output(self) -> str:
text = "".join(self.parts)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r" *\n", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def html_to_markdown(html_str: str) -> str:
p = _MarkdownExtractor()
try:
p.feed(html_str)
p.close()
except Exception:
return _html.unescape(re.sub(r"<[^>]+>", " ", html_str)).strip()
return p.output()
def html_to_text(html_str: str) -> str:
s = re.sub(r"<(script|style|noscript|iframe|object|embed)\b[^>]*>[\s\S]*?</\1>", "", html_str, flags=re.I)
s = re.sub(r"</?(p|div|br|li|tr|h[1-6]|section|article|header|footer|nav|aside|main|blockquote|pre)\b[^>]*>", "\n", s, flags=re.I)
s = re.sub(r"<[^>]+>", "", s)
s = _html.unescape(s)
s = re.sub(r"[ \t]+", " ", s)
s = re.sub(r"\n[ \t]+", "\n", s)
s = re.sub(r"\n{3,}", "\n\n", s)
return s.strip()
def render(body: bytes, content_type: str, fmt: str) -> str:
raw = body.decode("utf-8", errors="replace")
is_html = "text/html" in content_type.lower() or "application/xhtml" in content_type.lower()
if fmt == "raw" or fmt == "html" or not is_html:
return raw
if fmt == "markdown":
return html_to_markdown(raw)
return html_to_text(raw)
def make_output_dir(explicit: str | None) -> pathlib.Path:
if explicit:
p = pathlib.Path(explicit).expanduser()
p.mkdir(parents=True, exist_ok=True)
return p
base = pathlib.Path(tempfile.gettempdir())
run_id = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]
p = base / f"web-fetch-{run_id}"
p.mkdir(parents=True, exist_ok=True)
return p
def fetch_with_cf_retry(
url: str, *, fmt: str, timeout: int, use_curl: bool
) -> tuple[HttpResp, list[dict[str, Any]]]:
"""One real request with browser UA. If CF challenge, retry once with honest UA."""
attempts: list[dict[str, Any]] = []
headers = {
"Accept": accept_for(fmt),
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": BROWSER_USER_AGENT,
}
trace(f"GET {url} (browser UA, timeout={timeout}s)")
resp = http_get(url, headers=headers, timeout=timeout, use_curl=use_curl)
attempts.append({
"user_agent": "browser",
"status": resp.status,
"duration_ms": resp.duration_ms,
"bytes": len(resp.body),
"content_type": resp.content_type,
"final_url": resp.final_url,
"cf_challenge": resp.cf_challenge,
})
if resp.status == 403 and resp.cf_challenge:
trace("Cloudflare challenge detected; retrying with honest UA")
headers["User-Agent"] = HONEST_USER_AGENT
resp = http_get(url, headers=headers, timeout=timeout, use_curl=use_curl)
attempts.append({
"user_agent": "honest",
"status": resp.status,
"duration_ms": resp.duration_ms,
"bytes": len(resp.body),
"content_type": resp.content_type,
"final_url": resp.final_url,
"cf_challenge": resp.cf_challenge,
})
return resp, attempts
def cmd_fetch(args: argparse.Namespace) -> int:
if not (args.url.startswith("http://") or args.url.startswith("https://")):
sys.exit("[web-fetch] URL must start with http:// or https://")
timeout = min(args.timeout or DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS)
use_curl = True if args.use_curl else (False if args.use_urllib else have_curl())
output_dir = make_output_dir(args.output_dir)
started = time.monotonic()
try:
resp, attempts = fetch_with_cf_retry(
args.url, fmt=args.format, timeout=timeout, use_curl=use_curl
)
except Exception as e:
trace(f"FAIL: {e}")
envelope = {
"content": "",
"_meta": {
"version": VERSION,
"url": args.url,
"format": args.format,
"ok": False,
"error": str(e),
"attempts": [],
"duration_ms": int((time.monotonic() - started) * 1000),
"output_dir": str(output_dir),
},
}
(output_dir / "result.json").write_text(json.dumps(envelope, indent=2, ensure_ascii=False))
(output_dir / "trace.json").write_text(json.dumps(envelope["_meta"], indent=2, ensure_ascii=False))
sys.stdout.write(str(output_dir / "result.json") + "\n")
return 2
raw_ext = {"markdown": "html", "text": "html", "html": "html", "raw": "bin"}
raw_path = output_dir / f"raw.{raw_ext[args.format]}"
try:
raw_path.write_bytes(resp.body)
except Exception:
pass
ok = 200 <= resp.status < 400 and len(resp.body) > 0
if not ok:
snippet = resp.body[:500].decode("utf-8", errors="replace").replace("\n", " ")
trace(f"HTTP {resp.status}: {snippet[:200]}")
envelope = {
"content": "",
"_meta": {
"version": VERSION,
"url": args.url,
"format": args.format,
"ok": False,
"http_status": resp.status,
"content_type": resp.content_type,
"final_url": resp.final_url,
"error": snippet,
"attempts": attempts,
"duration_ms": int((time.monotonic() - started) * 1000),
"raw_path": str(raw_path),
"output_dir": str(output_dir),
},
}
(output_dir / "result.json").write_text(json.dumps(envelope, indent=2, ensure_ascii=False))
(output_dir / "trace.json").write_text(json.dumps(envelope["_meta"], indent=2, ensure_ascii=False))
sys.stdout.write(str(output_dir / "result.json") + "\n")
return 2
content = render(resp.body, resp.content_type, args.format)
converted = "text/html" in resp.content_type.lower() and args.format in ("markdown", "text")
ext = {"markdown": "md", "text": "txt", "html": "html", "raw": "txt"}[args.format]
content_path = output_dir / f"result.{ext}"
content_path.write_text(content)
envelope = {
"content": content,
"_meta": {
"version": VERSION,
"url": args.url,
"format": args.format,
"ok": True,
"http_status": resp.status,
"content_type": resp.content_type,
"final_url": resp.final_url,
"converted": converted,
"bytes": len(content.encode("utf-8")),
"raw_bytes": len(resp.body),
"attempts": attempts,
"duration_ms": int((time.monotonic() - started) * 1000),
"started_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"raw_path": str(raw_path),
"content_path": str(content_path),
"output_dir": str(output_dir),
"transport": "curl" if use_curl else "urllib",
},
}
(output_dir / "result.json").write_text(json.dumps(envelope, indent=2, ensure_ascii=False))
(output_dir / "trace.json").write_text(json.dumps(envelope["_meta"], indent=2, ensure_ascii=False))
trace(
f"OK {resp.status} {resp.content_type} "
f"{len(content.encode('utf-8'))} bytes in {envelope['_meta']['duration_ms']}ms"
)
if args.print_path == "json":
sys.stdout.write(str(output_dir / "result.json") + "\n")
elif args.print_path == "content":
sys.stdout.write(str(content_path) + "\n")
else:
sys.stdout.write(str(content_path) + "\n")
sys.stdout.write(str(output_dir / "result.json") + "\n")
if args.print_content:
sys.stdout.write(content)
if not content.endswith("\n"):
sys.stdout.write("\n")
return 0
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="web-fetch",
description="Fetch a URL, save the body to a temp dir, print the path.",
)
p.add_argument("url", help="URL to fetch (must start with http:// or https://).")
p.add_argument("--format", choices=["markdown", "text", "html", "raw"], default="markdown",
help="Output format. HTML responses are converted when 'markdown' or 'text'. Default: markdown.")
p.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS,
help=f"HTTP timeout in seconds. Capped at {MAX_TIMEOUT_SECONDS}. Default: {DEFAULT_TIMEOUT_SECONDS}.")
p.add_argument("--output-dir", help="Output directory. Default: $TMPDIR/web-fetch-<runid>.")
p.add_argument("--use-curl", action="store_true", help="Force curl as HTTP transport.")
p.add_argument("--use-urllib", action="store_true", help="Force urllib as HTTP transport.")
p.add_argument("--print-path", choices=["all", "content", "json"], default="all",
help="Stdout: 'all' (default) prints content path then json path, "
"'content' prints only content file, 'json' prints only result.json.")
p.add_argument("--print-content", action="store_true",
help="Also write the fetched content to stdout after the path lines.")
p.add_argument("--quiet", action="store_true", help="Suppress trace logs on stderr.")
p.add_argument("--verbose", action="store_true", help="Verbose trace.")
p.add_argument("--version", action="version", version=f"web-fetch {VERSION}")
return p.parse_args()
def main() -> int:
global VERBOSE, QUIET
args = parse_args()
VERBOSE = bool(args.verbose)
QUIET = bool(args.quiet)
return cmd_fetch(args)
if __name__ == "__main__":
sys.exit(main())
#Requires -Version 5.1
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$SkillDir = Split-Path -Parent $ScriptDir
$Script = Join-Path $SkillDir 'scripts/web_fetch.py'
$Python = if (Get-Command py -ErrorAction SilentlyContinue) { 'py' } else { 'python' }
$Output = Join-Path $env:TEMP ("web-fetch-smoke-" + [guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $Output -Force | Out-Null
function Pass([string]$msg) { Write-Host "PASS: $msg" }
function Fail([string]$msg) { Write-Host "FAIL: $msg" -ForegroundColor Red; Remove-Item -Recurse -Force $Output; exit 1 }
try {
$version = & $Python $Script --version 2>&1
if ($version -notmatch 'web-fetch') { Fail '--version output missing' }
Pass '--version'
$run1 = Join-Path $Output 'run1'
& $Python $Script https://example.com --output-dir $run1 --quiet | Out-Null
foreach ($f in 'result.md','result.json','trace.json','raw.html') {
if (-not (Test-Path (Join-Path $run1 $f))) { Fail "$f missing" }
}
$md = Get-Content (Join-Path $run1 'result.md') -Raw
if ($md -notmatch '# Example Domain') { Fail 'markdown missing heading' }
if ($md -notmatch 'Learn more') { Fail 'markdown missing link text' }
Pass 'fetch markdown writes all expected files'
$meta = (Get-Content (Join-Path $run1 'result.json') -Raw | ConvertFrom-Json)._meta
if (-not $meta.ok) { Fail 'ok should be True' }
if ($meta.http_status -ne 200) { Fail "http_status was $($meta.http_status)" }
if (-not $meta.converted) { Fail 'should be marked converted' }
if ($meta.bytes -le 0) { Fail 'rendered bytes should be > 0' }
if ($meta.raw_bytes -le 0) { Fail 'raw bytes should be > 0' }
if ($meta.transport -notin 'curl','urllib') { Fail "unexpected transport: $($meta.transport)" }
if ($meta.attempts.Count -lt 1) { Fail 'attempts missing' }
Pass 'envelope shape'
$run2 = Join-Path $Output 'run2'
& $Python $Script https://example.com --format text --output-dir $run2 --quiet | Out-Null
if (-not (Test-Path (Join-Path $run2 'result.txt'))) { Fail 'result.txt missing' }
$txt = Get-Content (Join-Path $run2 'result.txt') -Raw
if ($txt -notmatch 'Example Domain') { Fail 'text format missing content' }
if ($txt -match '<html') { Fail 'text format leaked HTML' }
Pass 'format=text strips tags'
$run3 = Join-Path $Output 'run3'
& $Python $Script https://example.com --format html --output-dir $run3 --quiet | Out-Null
if (-not (Test-Path (Join-Path $run3 'result.html'))) { Fail 'result.html missing' }
$html = Get-Content (Join-Path $run3 'result.html') -Raw
if ($html -notmatch '<html') { Fail 'html format stripped tags' }
Pass 'format=html preserves tags'
$errOutput = & $Python $Script ftp://example.com --quiet 2>&1
if ($LASTEXITCODE -eq 0) { Fail 'ftp:// URL should have errored' }
Pass 'non-http(s) scheme rejected'
$run4 = Join-Path $Output 'run4'
$out = & $Python $Script https://example.com --print-path content --output-dir $run4 --quiet
if (($out -split "`n" | Where-Object { $_ }).Count -ne 1) { Fail '--print-path content should print exactly 1 line' }
Pass '--print-path content prints one line'
$run5 = Join-Path $Output 'run5'
$out = & $Python $Script https://example.com --print-path json --output-dir $run5 --quiet
if (($out -split "`n" | Where-Object { $_ }).Count -ne 1) { Fail '--print-path json should print exactly 1 line' }
Pass '--print-path json prints one line'
$run6 = Join-Path $Output 'run6'
& $Python $Script https://example.com --use-urllib --output-dir $run6 --quiet | Out-Null
$md = Get-Content (Join-Path $run6 'result.md') -Raw
if ($md -notmatch 'Example Domain') { Fail 'urllib fallback failed' }
$meta = (Get-Content (Join-Path $run6 'result.json') -Raw | ConvertFrom-Json)._meta
if ($meta.transport -ne 'urllib') { Fail "expected urllib transport, got $($meta.transport)" }
Pass 'urllib transport'
Write-Host ''
Write-Host 'all smoke tests passed'
}
finally {
Remove-Item -Recurse -Force $Output -ErrorAction SilentlyContinue
}
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
WEB_FETCH="python3 $SKILL_DIR/scripts/web_fetch.py"
OUTPUT_DIR="$(mktemp -d -t web-fetch-smoke-XXXXXX)"
trap 'rm -rf "$OUTPUT_DIR"' EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
pass() { echo "PASS: $*"; }
$WEB_FETCH --version | grep -q "web-fetch" || fail "--version output missing"
pass "--version"
$WEB_FETCH https://example.com --output-dir "$OUTPUT_DIR/run1" --quiet >/dev/null
[ -f "$OUTPUT_DIR/run1/result.md" ] || fail "result.md missing"
[ -f "$OUTPUT_DIR/run1/result.json" ] || fail "result.json missing"
[ -f "$OUTPUT_DIR/run1/trace.json" ] || fail "trace.json missing"
[ -f "$OUTPUT_DIR/run1/raw.html" ] || fail "raw.html missing"
grep -q "# Example Domain" "$OUTPUT_DIR/run1/result.md" || fail "markdown conversion missing heading"
grep -q "Learn more" "$OUTPUT_DIR/run1/result.md" || fail "markdown conversion missing link text"
pass "fetch markdown writes all expected files"
python3 - <<PY
import json
m = json.load(open("$OUTPUT_DIR/run1/result.json"))["_meta"]
assert m["ok"] is True, "ok should be True"
assert m["http_status"] == 200, f"http_status was {m['http_status']}"
assert m["converted"] is True, "should be marked converted"
assert m["bytes"] > 0, "rendered bytes should be > 0"
assert m["raw_bytes"] > 0, "raw bytes should be > 0"
assert m["transport"] in ("curl", "urllib"), "transport must be set"
assert isinstance(m["attempts"], list) and len(m["attempts"]) >= 1, "attempts missing"
PY
pass "envelope shape"
$WEB_FETCH https://example.com --format text --output-dir "$OUTPUT_DIR/run2" --quiet >/dev/null
[ -f "$OUTPUT_DIR/run2/result.txt" ] || fail "result.txt missing for text format"
grep -q "Example Domain" "$OUTPUT_DIR/run2/result.txt" || fail "text format missing content"
! grep -q "<html" "$OUTPUT_DIR/run2/result.txt" || fail "text format leaked HTML"
pass "format=text strips tags"
$WEB_FETCH https://example.com --format html --output-dir "$OUTPUT_DIR/run3" --quiet >/dev/null
[ -f "$OUTPUT_DIR/run3/result.html" ] || fail "result.html missing for html format"
grep -q "<html" "$OUTPUT_DIR/run3/result.html" || fail "html format stripped tags"
pass "format=html preserves tags"
if $WEB_FETCH ftp://example.com --quiet 2>/dev/null; then
fail "ftp:// URL should have errored"
fi
pass "non-http(s) scheme rejected"
LINES=$($WEB_FETCH https://example.com --print-path content --output-dir "$OUTPUT_DIR/run4" --quiet | wc -l)
[ "$LINES" -eq 1 ] || fail "--print-path content should print exactly 1 line, got $LINES"
pass "--print-path content prints one line"
LINES=$($WEB_FETCH https://example.com --print-path json --output-dir "$OUTPUT_DIR/run5" --quiet | wc -l)
[ "$LINES" -eq 1 ] || fail "--print-path json should print exactly 1 line, got $LINES"
pass "--print-path json prints one line"
$WEB_FETCH https://example.com --use-urllib --output-dir "$OUTPUT_DIR/run6" --quiet >/dev/null
grep -q "Example Domain" "$OUTPUT_DIR/run6/result.md" || fail "urllib fallback failed"
python3 -c "
import json
m = json.load(open('$OUTPUT_DIR/run6/result.json'))['_meta']
assert m['transport'] == 'urllib', f'expected urllib transport, got {m[\"transport\"]}'
"
pass "urllib transport"
echo ""
echo "all smoke tests passed"