larksnap-fetch
In Claude Code of any project, "drop a Feishu link → save to local directory". This skill does not directly request Feishu, but delegates the task to the logged-in larksnap browser extension (which holds cookies and the export engine).
CC ──fetch.mjs(一次性)──HTTP /command──▶ daemon ──WebSocket──▶ Chrome 扩展
▲ 127.0.0.1:19925 └ 后台开标签页跑导出
└ 流式回传进度/产物 ──▶ 解包到 <输出目录>/<文档名>/
This skill is self-contained: The
and protocol code are packaged with the skill in
. The daemon launched by
is the one included in the skill,
it does not depend on the directory structure of the larksnap repository, so it can be called from any project. The only external dependency is the logged-in larksnap browser extension installed in Chrome (the extension cannot be packed into the skill and can only be loaded once in the browser, see "First-time Installation").
Each document is saved to its own subfolder (named after the document title), without mixing with other files:
<输出目录>/
└── 无监督数据修复/ ← Folder named after document title
├── 无监督数据修复.md
└── images/ ← Images are referenced inline with relative paths
└── xxx.png
- The extension is a WS client that actively connects out to the local daemon — no native messaging, no system-level installation.
- The daemon is automatically launched on demand by this skill and runs persistently (exits automatically after 30 minutes of idle time), bound only to .
- fetch.mjs is one-time: Exits immediately after completion, does not run in the background.
Usage
bash
node ~/.claude/skills/larksnap-fetch/scripts/fetch.mjs <Feishu link> <output directory> [--format md|pdf|html] [--profile <code>]
- is the parent directory; the output will be saved to the subfolder
<output directory>/<document name>/
(each document is stored independently to avoid confusion).
- defaults to . For /, images will be downloaded and referenced inline with relative paths in the subfolder of that directory; availability depends on tenant capabilities.
- Progress is output to stderr, and the result path is output to stdout (the absolute path of the document's subfolder follows ).
- : When multiple browser profiles are connected to the daemon simultaneously, specify which one to use (the code can be found in the Profile section of the extension popup and copied via the Copy button). Not required when only one profile exists.
Exit codes:
Success |
Failure |
Usage error |
Login required |
Domain authorization required |
Bridge not ready.
Edit Feishu Documents (Write Markdown Content)
is the sibling action of fetch, sharing the same daemon and extension: the extension
pastes content into the Feishu editor like a user in a background tab (CDP trusted input: real clipboard + trusted mouse clicks/keystrokes), and collaborative saving is handled by the Feishu frontend itself. Prerequisite: The user has
edit permissions for the document in the browser. Two side effects should be noted to the user:
the system clipboard will be occupied during editing (the written content will overwrite the user's current copied content); a "Debugging this browser" banner will appear at the top of the browser for a few seconds during task execution and disappear after completion.
bash
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs <link> new-doc [<md file>] --name "<title>"
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs new-doc [<md file>] --name "<title>" [--host <authorized domain>] # No link required
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs hosts [--profile <code>] # Read-only list of authorized domains
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs <link> append <md file>
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs <link> insert-after "<title text>" <md file>
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs <link> list-blocks
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs <link> find-blocks "<keyword>" [--regex] [--type <type prefix>] [--limit N]
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs <link> replace-block <block ID> <md file> --expect "<content summary>"
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs <link> delete-block <block ID> --expect "<content summary>"
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs <link> insert-after-block <block ID> <md file>
node ~/.claude/skills/larksnap-fetch/scripts/edit.mjs <link> replace-all <md file> --expect-first "<first block content summary>"
- Content to be written must first be saved to a local md file, then pass the file path to the command (do not use command line parameters to avoid escaping and length issues); maximum size is 2MB.
- Create new document: Creates a docx in the root directory of My Space. The here is only used to locate "which tenant/domain to create under" — you can pass the tenant's drive homepage () or any document link. is optional: if provided, the content will be written automatically after creation (reusing append); if not provided, an empty document will be created. sets the title (defaults to "Untitled" if not provided). On success, outputs , where is the address of the new document, which can be used for subsequent or editing operations. Only docx is supported (works for both public and private clouds).
- can be used without a link: When is omitted, it automatically uses the extension's "authorized domain list" to locate where to create the document — if there is only one authorized domain, it will be used directly; if there are multiple, you must specify it with (accepts the full base domain or a unique abbreviation; if ambiguous, it reports ; if not in the list, it reports , and the error message includes the full list of optional domains). only works with link-free new-doc; other operations (append/insert-after/…) still require a document link.
Private domains are more stable than public clouds: When the user authorizes a private domain in the sidebar, the extension records the real tenant entry; public clouds (feishu.cn, etc.) have no authorization action to capture, and can only be located as a fallback when a tab is open. If is reported, follow the prompt: use the old method to provide a link under that domain this time, or ask the user to open a page under that domain in the browser (or re-authorize) before re-running. The personal version has different interfaces, and is not guaranteed to succeed on the first try; if it fails, follow the prompt to use the old method.
- View authorized domain list (read-only): Outputs
{ok:true, domains:[{host,kind,sampleUrl}]}
— is builtin (public cloud built-in)/trusted (user-authorized private domain), and is non-empty indicating that the domain supports link-free document creation. Add when multiple browser profiles exist. If is reported, the extension in the browser is too old (bridge protocol <v4); ask the user to rebuild/update and reload the extension in chrome://extensions.
- Only supports docx / wiki documents; spreadsheets and multidimensional tables are not supported for editing.
- Block-level operations require locating first: If you know what content to find (especially in long documents), use first, which only returns matching blocks; use to output the full structure
{blocks:[{id,parentId,type,depth,childCount,summary}]}
only when you need to see the overall structure, where is the first 80 characters of the block content. / must include (copy the of the target block from the list-blocks / find-blocks output exactly) — the extension compares the current content of the block before execution, and reports if it does not match, preventing incorrect modification/deletion of blocks after concurrent changes to the document. Deleting a container block (list/quote, etc.) will delete its child blocks together; use // to confirm that you won't accidentally delete an entire section before proceeding.
- Search blocks by content (read-only): Matches against the full plain text of the block (not limited by the 80-character summary), defaults to "whitespace-removed + case-insensitive" substring match; matches using JS regular expressions ( flag); filters by block type (e.g., only searches for headings); controls the maximum number of returned results (default 20). Outputs
{total,shown,blocks:[{...,summary,snippet}]}
: if , it means hits are truncated; use more specific keywords; is exactly the same as in list-blocks and can be directly used as , while is the context of 40 characters before and after the hit (the hit word is marked with 【】) only for confirming that the correct block is found, do not use it to fill in . If no hits are found, returns with exit code 0, which is not considered an error. To search for content starting with , use with escaping. If "Unknown edit operation" is reported, the extension in the browser is too old; ask the user to reload/update the extension.
- matches exactly by title text: reports if not found, and if multiple matches are found (in this case, use to locate by block ID).
- is the most dangerous operation (replaces the entire body of the document, keeping the document title), and the process must be: ① First use to export the original document to local for backup; ② Get the of the first block from the output; ③ Pass it as exactly. The extension compares the first block of the current document before execution, and reports if it does not match (indicating the document has been modified; repeat steps ①②). Do not execute replace-all without a backup.
- Image writing: in md supports three sources — local path (relative path is based on the directory of the md file), external http(s) link (automatically downloaded and embedded), and data URI. Only png/jpg/jpeg/gif/webp/bmp formats are accepted (svg/ico will be silently discarded by Feishu, and the CLI will directly report ). If the overall size exceeds 2MB after image embedding, is reported (compress the image or split the writing); if external link download fails, is reported; if the local file is missing, is reported. Writing with images automatically switches to HTML paste mode (Feishu only recognizes data URI images in HTML paste), with the side effect: md tables in the same write operation will be pasted as embedded spreadsheets instead of native tables — try to write images and tables separately. For pure image writing (no text), the read-back verification is determined by the net increase in the number of image blocks.
- Success of writing is determined by read-back verification: After editing, the full text is re-exported to confirm that the new content has been saved before returning success. If is reported, the content may have been written to the document; do not blindly re-run (it may write twice), ask the user to open the document and confirm manually.
- If you want to perform block-level operations after successful editing, re-run first. Feishu blocks are addressed by ID; the IDs of blocks that have not been modified remain valid after editing, but two types of references will definitely become invalid: ① will change the block ID (Feishu handles it as "delete old block + insert new block"), and the old ID will become invalid directly; ② You do not have the block ID of the newly written content. Using the old list will result in / ; re-running is the easiest solution.
- Edit-specific exit codes: = Logged in but read-only for the document (), ask the user to confirm/apply for edit permissions in the browser before re-running; = Extension lacks debugging permissions (, usually an old version of the extension without editing functionality is installed), ask the user to update/reload the extension. Other exit codes are the same as fetch.mjs.
- Edit-specific error subtypes (exit code 1): / / / (retry after re-running list-blocks) / (manual confirmation) / .
- (exit code 5): The extension version is too old to support editing; ask the user to update/rebuild the extension.
Convert Regular Web Pages to Markdown
When the link is not a Feishu document, it automatically uses the "regular web page" pipeline: open a background tab → extract full-page body (Readability) → convert to Markdown.
- Only is supported: Requesting will directly report an error without opening a tab.
- The output is a single file, saved to
<output directory>/<page title>/<page title>.md
;
Images are kept as absolute external URLs () and not downloaded locally (unlike Feishu documents, there is no directory; exception: Xiaohongshu images will be localized, see the next section).
- If the domain is not authorized, exit code is returned, prompting the user to click "Authorize access to this domain" in the extension sidebar on that web page before re-running.
- Frontend-rendered pages (SPA) may capture incomplete body content due to page loading strategies, which is a known limitation; static article pages work best.
Xiaohongshu Notes (xiaohongshu.com)
Xiaohongshu uses a dedicated adapter (the general pipeline can only capture the navigation shell on Xiaohongshu pages), with additional requirements:
- The user must first log in to Xiaohongshu in Chrome (the note detail page must have a login state); if not logged in, exit code is returned, with the prompt including ;
- Authorization for the domain is required (exit code is returned for the first time, follow the prompt to authorize);
- Must use the full sharing link with (links copied from the "Share" function in the app/web version come with it; bare links are no longer accessible); short links can be used directly and will automatically redirect to the full link;
- The output Markdown includes title, author, publication time, interaction count, body, images, and tags; images will be downloaded to the local directory and rewritten with relative paths (Xiaohongshu CDN links have time-sensitive signatures and will expire if left as-is; if a single image fails to download, the external link will be retained without affecting the whole); videos are kept as external links and not downloaded, and the direct link signature may expire, so save them as soon as possible.
arXiv Paper Download (PDF + HTML + Markdown Saved Together)
When the link or ID points to an arXiv paper, it does not use the daemon/extension (arXiv is completely public and does not require a login state), and instead uses an independent script to download directly:
bash
node ~/.claude/skills/larksnap-fetch/scripts/arxiv.mjs <arXiv link or ID> <output directory> [--pdf-only|--html-only]
- Fully compatible with links and IDs: bare ID (), prefix, three types of links (abs/pdf/html, including suffix and version number), and old-style IDs (, slashes are replaced with underscores when used as directory names).
- The output is also saved to an independent folder named after the ID:
<output directory>/2601.18226/2601.18226.pdf
+ + .
- is injected into the HTML, so images/styles are resolved to absolute addresses on arxiv.org, and local opening will not break images (images themselves are not downloaded locally).
- Markdown is converted from HTML on-site with zero external dependencies (turndown is packaged in , no need for pandoc): formulas are restored to / using the alttext from LaTeXML, images/reference links are absolute addresses on arxiv.org; complex tables are retained as embedded HTML. Conversion failure only affects , while PDF/HTML are saved normally.
- It is normal for some papers to not have an HTML version (arXiv only provides HTML for papers with LaTeX sources that have been successfully converted, and old papers may also have been retroactively converted): in this case, only the PDF is saved, exit code is still 0, and there is an prompt in stderr — do not retry as a failure.
- Exit codes: Success | Failure | Usage error; the error contract is the same as fetch.mjs (when exiting with non-0, the last line of stderr is a JSON object, follow the branch).
Web Page Video Download (Carried by Daemon, No CLI Entry)
Starting from daemon v1.4.0 / protocol v3, a reverse task
initiated by the extension is also supported: when the user clicks "Download Video" in the extension sidebar on video sites (Bilibili/YouTube/Douyin/TikTok), the extension sends the page URL to the daemon via WS, and the daemon runs
locally to download and merge the video, saving it to
~/Downloads/larksnap-video/
, with progress pushed back to the sidebar for display.
- This function has no CLI entry, and can only be triggered from the extension sidebar; the responsibility of this skill is to distribute the new version of the daemon.
- Additional dependencies on and (only used for video download, not required for capturing/editing Feishu documents):
macOS:
brew install yt-dlp ffmpeg
; Windows: winget install yt-dlp.yt-dlp Gyan.FFmpeg
.
If missing, the sidebar will receive a clear installation prompt, which does not affect other functions.
- When the daemon is an old version, the download button in the sidebar will prompt to update this skill; after updating the skill files, kill the old daemon (
pkill -f bridge/daemon.mjs
or wait for it to exit automatically when idle).
Error Contract (AI Branches According to This, Do Not Parse Prose)
When exiting with any non-0 code, the last line of stderr is a JSON object, and the previous lines are prose for human reading:
json
{"ok":false,"error":{"type":"authentication","subtype":"need_login","message":"需要登录:浏览器里没有该域名的飞书登录态。","hint":"让用户在 Chrome 中打开该文档域名并登录飞书,登录完成后重跑本命令。","retryable":false}}
- = What went wrong (for human reading); = What to do next (imperative, follow it);
- = No changes needed, re-running the command directly may succeed; = Take action according to first (usually requires user operation), then re-run.
- / are closed enums, and exit codes are derived from subtypes:
| Exit Code | Type | Subtype | Handling |
|---|
| 2 | usage | / / / | Correct command line parameters (e.g., add/modify , ) and re-run |
| 2 | edit | | No available tenant entry for this domain: provide a link under this domain this time, or ask the user to open a page under this domain before re-running |
| 3 | authentication | | Ask the user to log in to Feishu for this domain in Chrome, confirm, then re-run |
| 4 | authentication | | Ask the user to click "Authorize this domain" in the extension sidebar, confirm, then re-run |
| 4 | usage | | is not in the authorized list: use a domain from the list, or ask the user to authorize the domain first |
| 5 | bridge | / / / / / / / | Fix the bridge according to the hint (mostly wake up/update the extension) and re-run |
| 1 | export | / / / | Handle according to the hint |
Example Output
Success (exit code 0, stdout):
✓ 已导出到 /Users/me/notes/无监督数据修复
- 无监督数据修复/无监督数据修复.md
- 无监督数据修复/images/boxcnAbc123.png
Login required (exit code 3, stderr):
✗ 需要登录:浏览器里没有该域名的飞书登录态。
→ 让用户在 Chrome 中打开该文档域名并登录飞书,登录完成后重跑本命令。
{"ok":false,"error":{"type":"authentication","subtype":"need_login","message":"需要登录:浏览器里没有该域名的飞书登录态。","hint":"让用户在 Chrome 中打开该文档域名并登录飞书,登录完成后重跑本命令。","retryable":false}}
Extension not connected (exit code 5, stderr):
✗ 扩展未连接:请确认 Chrome 已打开并加载 larksnap 扩展,点一下图标唤醒后台后重试。
→ 确认 Chrome 已打开并加载 larksnap 扩展,点一下扩展图标唤醒后台,然后重跑本命令。
{"ok":false,"error":{"type":"bridge","subtype":"extension_not_connected","message":"...","hint":"...","retryable":true}}
Multiple profiles need to be specified (exit code 2, stderr):
✗ 检测到多个浏览器 profile(a1b2c3, d4e5f6),请用 --profile <code> 指定其一。
→ 加 --profile <code> 指定用哪个浏览器 profile(code 见扩展弹窗,可点 Copy 复制),然后重跑本命令。
{"ok":false,"error":{"type":"usage","subtype":"profile_ambiguous","message":"...","hint":"...","retryable":false}}
Execution Flow (CC Operates According to This)
- Run the command directly (user-provided link + user-specified local directory; if the user does not specify a directory, it defaults to the current working directory). The daemon will be automatically launched.
- Non-0 exit → Parse the last line of JSON in stderr, follow the to execute, do not guess:
- → After completing the hint (e.g., click the extension icon to wake it up), re-run the same command directly; if the same error occurs twice in a row, stop and ask the user.
- → If the hint requires user operation (login/authorize domain), relay the hint to the user, wait for the user to confirm completion, then re-run.
- Exit code 0 → Tell the user the written files listed in stdout (path format like
<document name>/<document name>.md
).
The output is in the subfolder <output directory>/<document name>/
(md includes the subdirectory referenced with relative paths), and each document is stored independently.
Complete Workflow Example (Not Logged In → Self-Healing → Success)
Step 1 node .../fetch.mjs https://xxx.feishu.cn/docx/AbCd... ./notes
→ Exit code 3, JSON: {"subtype":"need_login","hint":"让用户在 Chrome 中打开该文档域名并登录飞书…"}
Step 2 Tell the user: "You need to log in to Feishu for xxx.feishu.cn in Chrome first. Let me know when you're done."
Step 3 After the user confirms, re-run the command from Step 1 exactly
→ Exit code 0, stdout lists written files → Tell the user the output path and end.
First-time Installation (Only Once, Just Load the Extension, No System-level Installation)
The extension comes from the larksnap repository (this skill does not include the extension itself, only the bridge daemon):
- Run in the root directory of the larksnap repository.
- Go to , enable "Developer mode" → "Load unpacked" and select the folder.
- Click the extension icon to wake up the background Service Worker — it will automatically connect to the local daemon (the daemon is launched by the first run).
No need to bind the extension ID or write any system manifest. To switch machines, just repeat these three steps (and copy this skill directory to the new machine's
).
Troubleshooting
- Check to see if the daemon is listening, if the extension is connected, and if tasks are dispatched; is the PID of the listening instance.
- Connection issues: Confirm Chrome is open and the extension is loaded; when the extension background is dormant, the first command may need to wait for it to reconnect via (alarms ~24s), clicking the extension icon can wake it up immediately.
- Port conflict: The daemon defaults to , which can be changed with the environment variable (must be consistent with the in the extension's ).
- Missing images/failed export for a domain: Mostly due to not authorizing the domain with "base domain wildcard" (including the image drive-stream subdomain), re-authorize in the sidebar.
- Version drift: When fetch.mjs detects that the running daemon version is inconsistent with this skill, it will automatically restart to the version included with this skill (old skill copies installed in multiple projects will not break silently, but it is recommended to update old skill copies as well); the extension and daemon report protocol versions to each other during WS handshake, and the popup will prompt for an update if they are inconsistent.
- Security: The daemon only binds to the loopback interface, verifies the Origin (rejects non- requests) and requires the header to block web CSRF; requests from CLI→daemon have an additional HMAC-SHA256 signature (key is in , 0600 permissions, automatically generated on first run; the signature covers timestamp/method/path/body digest, 60s anti-replay, no fallback) to block impersonation from other identities on the machine. The extension's WS cannot read the key file, so Origin verification is maintained (known limitation).