# Webread

> Save any http(s) URL as clean Markdown to disk by driving the user's real Chrome via kimi-webbridge. Images embedded in the article are downloaded to a sibling folder and the Markdown is rewritten to reference local copies — so the saved article works offline and survives URL rot. Use this skill whenever the user's message contains an http(s) URL or asks to read / look at / save / check / fetch a web page — even casually phrased ("读一下", "看看这个", "保存这个文章", "fetch this for me"). Always prefer this over WebFetch, which is stateless and fails on auth pages, paywalls, X/Twitter, and JS-heavy sites. After saving, behavior is intent-driven — if the user only gave a URL (or said "save"/"读一下"/"看看"), respond `Saved to {path}` and stop; if the user also asked a question ("总结一下"/"what does it say"/"summarize"), Read the saved file and answer in the same turn — don't make them re-prompt. Not for local files, source already in the repo, or PDFs.

- Skill: `xpzouying/webread` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add xpzouying/webread`
- Raw SKILL.md: https://api.skillmd.com/api/skills/xpzouying/webread/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: xpzouying (https://skillmd.com/u/xpzouying)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/xpzouying/webread

---


# webread

Save the content of an http(s) URL as clean Markdown to disk, with embedded images downloaded locally so the saved article is self-contained.

**Behavior after save depends on the user's ask**:

- URL alone, or with archive-style phrases ("保存", "读一下", "看看", "save", "fetch") → respond `Saved to {path}` and stop. Don't read the file.
- URL with an analysis ask ("总结一下", "讲了什么", "summarize", "what does it say", "tell me about") → after the chain returns the path, immediately Read the saved file and answer the question. Mention the saved path so the user knows where it landed.

Saving is free in both cases; the file survives URL rot and the user can delete it any time.

## Routes

The skill picks a fetcher per URL host. All routes save to the same `~/Downloads/webread/<DATE>/` folder; only the file extension differs.

| Host | Fetcher | File |
|---|---|---|
| `twitter.com`, `x.com` | [`twitter-cli`](https://github.com/xpzouying/homebrew-agent-cli) | `<slug>.json` (raw `twitter-cli tweet` output: tweet + replies) |
| `*.feishu.cn`, `*.larksuite.com` | [`lark-cli`](https://github.com/larksuite/cli) | `<slug>.md` (`docs +fetch --format pretty`) |
| anything else | [kimi-webbridge](https://www.kimi.com/features/webbridge) → [`webread`](https://www.npmjs.com/package/webread) CLI | `<slug>.md` + downloaded images |

Why the default route uses kimi-webbridge: `WebFetch` returns login pages for paywalls, blank shells for SPAs, and 402s on auth-gated sites. Driving the user's real Chrome via kimi-webbridge inherits their session, so login-gated and JS-heavy pages just work.

## Output layout

```
~/Downloads/webread/<YYYY-MM-DD>/
├── <slug>-<hash>.md                   # default route — the article
├── <slug>-<hash>.json                 # twitter route — raw tweet JSON
└── <slug>-<hash>-images/              # default route only, if the article had images
    ├── img-1.png
    ├── img-2.jpg
    └── img-3.webp
```

For the default route, the Markdown's image lines reference the sibling `-images/` folder via relative paths (`![alt](<slug>-<hash>-images/img-1.png)`), so opening the .md in Obsidian / VS Code preview / any Markdown viewer renders the images correctly even offline.

## Workflow

When the user provides a URL, run this chained command in a **single** Bash tool call. Substitute `<URL>` with the actual URL. The chain handles a pure-text article (5s) and a 5-image article (~13s) within the same code path — the image phase is skipped automatically when there's nothing to download.

```bash
URL='<URL>'
DATE=$(date +%Y-%m-%d)
SLUG=$(URL="$URL" python3 -c '
import os, re, hashlib
url = os.environ["URL"]
tail = re.sub(r"^.*/", "", url.rstrip("/").split("?", 1)[0])[:50]
tail = re.sub(r"[^\w一-鿿-]+", "-", tail).strip("-").lower() or "page"
print(f"{tail}-{hashlib.sha1(url.encode()).hexdigest()[:8]}")
')
mkdir -p ~/Downloads/webread/$DATE

# Route by URL host. Twitter and Feishu exit early — their CLIs already produce
# clean structured output, no extra processing needed.
case "$URL" in
  *twitter.com/*|*x.com/*)
    if ! command -v twitter-cli >/dev/null 2>&1; then
      echo "twitter-cli not installed. Install: brew tap xpzouying/agent-cli && brew install twitter-cli" >&2
      echo "Source: https://github.com/xpzouying/homebrew-agent-cli" >&2
      exit 1
    fi
    SAVE_PATH=~/Downloads/webread/$DATE/$SLUG.json
    twitter-cli tweet "$URL" > "$SAVE_PATH" || { rm -f "$SAVE_PATH"; exit 1; }
    [ -s "$SAVE_PATH" ] || { rm -f "$SAVE_PATH"; echo "twitter-cli returned empty" >&2; exit 1; }
    echo "$SAVE_PATH"; exit 0 ;;
  *feishu.cn/*|*larksuite.com/*)
    if ! command -v lark-cli >/dev/null 2>&1; then
      echo "lark-cli not installed. Install: npm install -g @larksuite/cli" >&2
      echo "Source: https://github.com/larksuite/cli" >&2
      exit 1
    fi
    SAVE_PATH=~/Downloads/webread/$DATE/$SLUG.md
    lark-cli docs +fetch --doc "$URL" --format pretty > "$SAVE_PATH" || { rm -f "$SAVE_PATH"; exit 1; }
    [ -s "$SAVE_PATH" ] || { rm -f "$SAVE_PATH"; echo "lark-cli returned empty" >&2; exit 1; }
    echo "$SAVE_PATH"; exit 0 ;;
esac

# Default route: kimi-webbridge → webread CLI
SAVE_PATH=~/Downloads/webread/$DATE/$SLUG.md
IMG_DIR="${SAVE_PATH%.md}-images"

# Preflight: daemon and CLI
if ! curl -sS -o /dev/null --max-time 3 http://localhost:10086/ 2>/dev/null; then
  echo "kimi-webbridge daemon not running at localhost:10086. Open Kimi Desktop App." >&2
  exit 1
fi
if ! command -v webread >/dev/null 2>&1; then
  echo "webread CLI not installed. Run: npm install -g webread" >&2
  exit 1
fi

# Step 1 — Navigate, get rendered HTML, pipe through webread, save
curl -sS -X POST http://localhost:10086/command \
  -H 'Content-Type: application/json' \
  -d "{\"action\":\"navigate\",\"args\":{\"url\":\"$URL\"},\"session\":\"webread\"}" >/dev/null
sleep 3
curl -sS -X POST http://localhost:10086/command \
  -H 'Content-Type: application/json' \
  -d '{"action":"evaluate","args":{"code":"document.documentElement.outerHTML"},"session":"webread"}' \
  | python3 -c "import json,sys; sys.stdout.write(json.load(sys.stdin)['data']['value'])" \
  | webread "$URL" > "$SAVE_PATH"

# Step 2 — Download embedded images and rewrite Markdown to local paths.
# Skipped automatically if the saved Markdown has no image references.
# Match anywhere on the line (images often appear mid-paragraph, not just at line start)
# and only count http(s) URLs (relative paths and data: URIs aren't downloadable).
if [ -s "$SAVE_PATH" ] && grep -qE '!\[[^]]*\]\(https?://' "$SAVE_PATH"; then
  mkdir -p "$IMG_DIR"
  i=0
  while IFS= read -r IMG_URL; do
    i=$((i+1))
    # Navigate to the image URL itself — same-origin fetch then bypasses CORS,
    # and the user's browser session (cookies, Referer) is inherited.
    curl -sS -X POST http://localhost:10086/command \
      -H 'Content-Type: application/json' \
      -d "{\"action\":\"navigate\",\"args\":{\"url\":\"$IMG_URL\"},\"session\":\"webread\"}" >/dev/null
    sleep 1
    BASE64=$(curl -sS -X POST http://localhost:10086/command \
      -H 'Content-Type: application/json' \
      -d '{"action":"evaluate","args":{"code":"(async()=>{const r=await fetch(location.href);const b=await r.blob();return await new Promise(res=>{const f=new FileReader();f.onloadend=()=>res(f.result);f.readAsDataURL(b);});})()","awaitPromise":true},"session":"webread"}' \
      | python3 -c "import json,sys; print(json.load(sys.stdin).get('data',{}).get('value',''))" 2>/dev/null)
    if [[ "$BASE64" != data:image/* ]]; then
      echo "image $i: skipped (could not fetch — keeping remote URL in Markdown)" >&2
      continue
    fi
    EXT=$(echo "$BASE64" | sed -E 's|^data:image/([^;]+);.*|\1|' | sed 's/jpeg/jpg/' | sed 's/svg+xml/svg/')
    LOCAL_FILE="$IMG_DIR/img-$i.$EXT"
    echo "$BASE64" | python3 -c '
import sys, base64
header, b64 = sys.stdin.read().split(",", 1)
sys.stdout.buffer.write(base64.b64decode(b64))
' > "$LOCAL_FILE"

    # SVG → PNG: vision-capable LLMs handle raster images far better than
    # SVG XML markup. If rsvg-convert is installed, rasterize to PNG and
    # drop the SVG. If not installed or conversion fails, keep the SVG —
    # it still works in Markdown viewers.
    if [ "$EXT" = "svg" ] && command -v rsvg-convert >/dev/null 2>&1; then
      PNG_FILE="$IMG_DIR/img-$i.png"
      if rsvg-convert "$LOCAL_FILE" -o "$PNG_FILE" 2>/dev/null && [ -s "$PNG_FILE" ]; then
        rm -f "$LOCAL_FILE"
        LOCAL_FILE="$PNG_FILE"
        EXT="png"
      else
        rm -f "$PNG_FILE"
      fi
    fi

    REL_PATH="$(basename "$IMG_DIR")/img-$i.$EXT"
    ESCAPED_URL=$(python3 -c 'import sys, re; print(re.escape(sys.argv[1]))' "$IMG_URL")
    sed -i '' "s|$ESCAPED_URL|$REL_PATH|g" "$SAVE_PATH"
  done < <(grep -oE '!\[[^]]*\]\(https?://[^)]+\)' "$SAVE_PATH" | grep -oE 'https?://[^)]+')
fi

# Step 3 — Always close the tab
curl -sS -X POST http://localhost:10086/command \
  -H 'Content-Type: application/json' \
  -d '{"action":"close_tab","args":{},"session":"webread"}' >/dev/null 2>&1

# Sanity check + report path
if [ ! -s "$SAVE_PATH" ]; then
  rm -rf "$SAVE_PATH" "$IMG_DIR"
  echo "extraction returned empty — URL may be a homepage / listing / login wall, not an article" >&2
  exit 1
fi
echo "$SAVE_PATH"
```

**On success** (exit 0): the chain echoes the save path on stdout.

- If the user's message was the URL alone or framed as "save / fetch / 读一下 / 看看" — respond with one line `Saved to {path}` and stop.
- If the user asked an analysis question ("总结一下", "what does it say", "tell me about", etc.) — Read the saved file and answer the question in the same response. Include the saved path in the reply so the user knows where it landed.

**On failure** (exit non-zero): the chain prints a specific reason on stderr — relay it so the user can act (open Kimi Desktop App / `npm install -g webread` / pick a real article URL).

## Hard rules

- **Never fall back to WebFetch.** The value of this skill is real-browser session inheritance — falling back to a stateless HTTP call would silently return a login page or blank shell as if it were the article. Surfacing "the daemon is down, please start it" is better than pretending the URL is unfetchable.
- **Routes don't fall back to each other.** If `twitter-cli` is missing on a Twitter URL, surface the install command — do not silently re-route through kimi-webbridge, which would return a Twitter login wall as if it were the tweet. Same for `lark-cli` on Feishu URLs.
- **Don't auto-summarize when the user only asked to save.** A bare URL or archive-style phrase ("读一下", "保存", "fetch") means *archive* — running a summary anyway wastes tokens on content the user did not ask to see. The flip side is also a rule: when the user *did* ask ("总结一下", "what does it say"), Read the file and answer in the same turn rather than making them re-prompt.
- **Don't fabricate.** If the bash chain failed, surface the actual error. Don't guess content from the URL.
- **Image download is best-effort.** A single image that fails to fetch is logged to stderr and skipped (the corresponding line in the Markdown keeps its remote URL). The save itself still succeeds.

## Multiple URLs

If the user includes multiple URLs in one message, run the chain once per URL — sequentially, not in parallel. The kimi-webbridge session uses a single tab; parallel reads would race.

