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 |
<slug>.json (raw twitter-cli tweet output: tweet + replies) |
*.feishu.cn, *.larksuite.com |
lark-cli |
<slug>.md (docs +fetch --format pretty) |
| anything else | kimi-webbridge → 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 (), 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.
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-cliis 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 forlark-clion 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.