
Feishu Doc Scraper
- 381 installs
- 1.3k repo stars
- Updated August 4, 2026
- daymade/claude-code-skills
feishu-doc-scraper is a Claude Code skill that pulls structured text and metadata from Feishu/Lark documents into repositories, tickets, or RAG corpora for developers whose specs live behind enterprise collaboration wall
About
feishu-doc-scraper is a Claude Code skill for extracting structured text and metadata from Feishu/Lark (Lark Suite) documents into developer workflows. When product specs, API contracts, or operational runbooks live in enterprise collaboration tools instead of git, this skill retrieves readable content for repos, issue trackers, or retrieval-augmented generation corpora. Developers reach for it during onboarding to internal platforms, when syncing a Feishu PRD into docs/, or when agents need grounded context from locked collaboration spaces. The skill targets the gap between walled-garden documentation and code-adjacent markdown engineers and CI can index. Use it before implementing features documented only in Feishu or when building RAG pipelines that must include Lark-hosted knowledge bases.
- Feishu/Lark doc extraction
- Auth and pagination handling
- Normalized markdown or JSON output
- Enterprise knowledge import
- RAG and spec ingestion
Feishu Doc Scraper by the numbers
- 381 all-time installs (skills.sh)
- Ranked #456 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/daymade/claude-code-skills --skill feishu-doc-scraperAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 381 |
|---|---|
| repo stars | ★ 1.3k |
| Last updated | August 4, 2026 |
| Repository | daymade/claude-code-skills ↗ |
How do you import Feishu docs into a git repo?
Pull structured text and metadata from Feishu/Lark docs into repos, tickets, or RAG corpora when specs and runbooks live behind enterprise collaboration walls.
Who is it for?
Engineers at organizations that store PRDs and runbooks in Feishu/Lark who need those specs beside code or in agent context.
Skip if: Teams whose specifications already live in git markdown with no Feishu/Lark source of truth to synchronize.
When should I use this skill?
A Feishu or Lark doc URL contains the spec, runbook, or API details needed for the current implementation task.
What you get
Structured markdown text, document metadata, and scrape-ready chunks for repos, tickets, or RAG corpora.
- structured markdown export
- document metadata
- RAG-ready text chunks
Files
Feishu Doc Scraper
Extract a Feishu/Lark source into faithful local Markdown. Prefer the lark-cli API — it extracts the body programmatically (no model paraphrasing), follows a collection's reference graph, and reads permission boundaries from error codes instead of guessing. Treat the rendered browser page as a fallback, not the source of truth: in real collection-scraping work the API path consistently does the whole job while the browser path is never needed.
Scope (read this first)
This skill's contract is faithful per-source Markdown + a record of what was extracted. It does not decide how the resulting files are named, indexed, deduplicated against existing notes, or organized into a knowledge base — that belongs to the host PKM / the user's own conventions. Stopping at faithful extraction keeps this skill orthogonal and reusable. When the user wants the output filed into a vault, extract first, then hand the clean Markdown to their organizing workflow.
Choose the path
Is the source a Feishu/Lark URL (wiki / docx / sheets / minutes / base)?
├── YES → is lark-cli installed and authenticated to that tenant?
│ ├── YES → PATH A: lark-cli API extraction (primary — start here)
│ │ └── hit code 131006 / 99991679 (permission denied)?
│ │ └── PATH B: owner-exported .docx → faithful Markdown
│ └── NO → install/auth lark-cli first (it is worth it); only if
│ truly impossible → PATH D: browser DOM fallback
├── the URL is a Minutes / 妙记 link, or a doc references one → PATH C: Minutes transcript
└── you were handed an exported .docx (not a URL) → PATH BA collection/hub is just a docx whose body references other docs — Path A handles it by recursively following the reference graph, not by visiting pages in a browser.
Path A — lark-cli API extraction (primary)
Full command catalog, recursion engine, cross-tenant and personal-space nuances: [references/lark-cli-api-extraction.md](references/lark-cli-api-extraction.md). The essentials for the common case:
1. Disable the proxy for Feishu domestic domains. Feishu's *.feishu.cn endpoints are direct-connect in mainland China; routing them through a local proxy leaks credentials through the proxy and gets DNS-hijacked. lark-cli itself warns about this. Always:
export LARK_CLI_NO_PROXY=1This does not conflict with any "Claude/Anthropic domains must use the proxy" rule — Feishu is a different host and is direct.
2. Classify the URL, then resolve to a fetchable doc token.
…/wiki/<node_token>— a wiki node token is not a doc token. Resolve it first:
lark-cli wiki spaces get_node --params '{"token":"<node_token>"}'
# → .data.node.obj_token and .data.node.obj_type (e.g. "docx")…/docx/<doc_token>— already a doc token, fetch directly.…/sheets/<token>— spreadsheet, use the sheets commands (see reference).…/minutes/<token>— Minutes, go to Path C.
3. Fetch the body as Markdown — programmatically, never via the model.
lark-cli docs +fetch --doc <obj_token> --format json > /tmp/fetch.json 2> /tmp/fetch.err
# body is .data.markdown — extract with jq, do NOT retype or summarize it
jq -r '.data.markdown' /tmp/fetch.json > source.mdKeep stdout and stderr separate. A harmless [deprecated] docs +fetch with v1 API is deprecated goes to stderr; piping 2>/dev/null and jq together produced a false Exit code 5 in practice — redirect to files and inspect, don't blind-pipe. The body must reach disk without passing through the model (paraphrasing silently corrupts source text — this is the single most important fidelity rule).
4. If it's a collection/hub, follow the reference graph (BFS). The hub body contains <mention-doc>, <sheet>, <image> tags and cross-tenant / Minutes / Tencent-Meeting URLs. Extract every reference, dispatch by type, fetch, and repeat on each newly fetched doc until no new references remain (leaf nodes). Use the bundled extractor so nothing is silently missed (a missed reference = a missing document, the #1 hub-scraping failure):
python3 scripts/feishu_extract_refs.py source.md # → JSON list of {type, token, title}Recursion loop, dispatch table, and the cross-tenant/my.feishu.cn personal-space rules are in the reference.
5. Final residual-tag check (acceptance gate for collections). Every rich-media reference must have been resolved and rendered:
grep -rlE '<(lark-table|lark-tr|sheet token=|mention-doc|view type=)' . && echo "UNRESOLVED — keep recursing" || echo "clean"Must be empty before you stop.
Path B — permission denied → owner-exported .docx
lark-cli wiki spaces get_node returning code 131006 … node permission denied, user needs read permission (or fetch returning it) is a hard Feishu-side boundary. lark-cli, anonymous curl, and the browser all fail it — this has been verified exhaustively; do not spend cycles trying to bypass it. The only correct move: ask the permission holder to export the doc as .docx and send it back out-of-band, then convert with fidelity (font-size→heading and w:shd→highlight restoration, then visual verification). Full procedure: [references/docx-export-to-markdown.md](references/docx-export-to-markdown.md).
Path C — Feishu Minutes (妙记) transcript
lark-cli minutes only returns metadata and can download audio/video — it cannot export the text transcript. The transcript comes from a native endpoint called through lark-cli api, and needs an extra scope granted via a device-flow login. Native AI transcription is far better than downloading the media and re-running ASR — never do the latter. Endpoint, scope name, the device-flow timeout trap, and per-minute (not per-tenant) permission behavior: [references/feishu-minutes-transcript.md](references/feishu-minutes-transcript.md).
Path D — browser DOM fallback (last resort)
Only when lark-cli genuinely cannot reach the content (no install possible, and the doc is not permission-walled). This is the old virtual-scroll / TOC-driven DOM capture workflow. It is slower, depends on a connected browser surface (the in-browser extension frequently fails to connect), and an anonymous debugging Chrome can only tell you whether a page is publicly reachable — it cannot read login-walled content. Workflow: [references/browser-dom-fallback.md](references/browser-dom-fallback.md). Battle-tested DOM rules (virtual scroll, data-block-id ordering, table/bullet extraction, image streams): [references/browser-failure-rules.md](references/browser-failure-rules.md).
Hard rules
These are the rules whose violation silently ruins the output. Each has a reason — follow the reason, not just the letter.
- Never let the document body pass through the model. Extract with
jq/cat/scripts straight to disk. The model paraphrasing source text is undetectable later and destroys fidelity. This is why Path A beats the browser path structurally. - *`export LARK_CLI_NO_PROXY=1` for `.feishu.cn`.** Otherwise credentials transit a local proxy and DNS is hijacked.
- Transcripts come from the platform's native transcription, never re-ASR. Downloading media and transcribing again loses speaker labels, timestamps, and accuracy.
- *A generated docx Markdown is not done until it has been visually verified* against the source (render to image, read it). Feishu-exported docx uses font-size+bold for headings rather than Word heading styles, so a "no errors, word count matches" check passes while the entire heading hierarchy is silently flat. Text-level checks cannot catch this.
- Do not 死磕 (grind) on docx embedded-image download. lark-cli (through 1.0.32) cannot download
<image>tokens from a docx — exhaustively verified. Register the image tokens and note "needs document owner to right-click → save"; the text is the value, images are a tracked gap. - HTTP 200 from anonymous curl ≠ accessible. A Feishu login wall returns 200 with a body containing
accounts.feishu.cn/login/passport/ an empty<title>. Check the body, never infer "public" from the status code. - A file "not found" by a search agent is not authoritative. Verify against authoritative sources before concluding (this is general Inference Discipline; relevant when locating where ingested content already lives).
- U+FFFD final check on every produced file:
LC_ALL=C grep -rl $'\xef\xbf\xbd' .must be empty. A replacement character means an encoding step corrupted the text.
Acceptance contract
Stop only when all that apply are true:
- Every fetched body reached disk via
jq/script, not retyped by the model. - Collections: the residual rich-media-tag grep (Path A step 5) is empty — every
mention-doc/sheet/cross-tenant reference was followed to a leaf. LC_ALL=C grep -rl $'\xef\xbf\xbd' .is empty.- docx path: rendered to an image and visually compared to the source; heading hierarchy and highlights match (see docx reference's checklist).
- Browser fallback only: TOC coverage + scale check (see browser-failure-rules.md).
- Each output file's frontmatter records
source(the original URL/token) and, if any post-processing was applied, apost_processprovenance line. - Permission gaps (131006 docs not exported yet, undownloadable images) are explicitly listed for the user — a transparent gap beats a silent omission.
Do NOT attempt
Verified dead-ends — retrying them only wastes the session. Full table with failure modes and root causes: [references/permission-and-failure-boundaries.md](references/permission-and-failure-boundaries.md). The top ones:
- Bypassing
131006permission-denied by any means (lark-cli / curl / anonymous browser) — it is a server-side boundary. - Downloading docx embedded images via
docs +media-download,api …/drive/v1/medias/<t>/download(with or withoutextra), orschema drive.medias.download— none work; lark-cli even mis-reports the real HTTP 400 as "empty JSON". WebFetchagainstopen.feishu.cn/document/server-docs/...for API specs — backend is flaky; useopen.feishu.cn/llms-docs/zh-CN/llms-<module>.txtinstead (LLM-friendly, stable).- AppleScript/JXA
executeJavaScript, Chrome CDP on port 9222 — disabled/empty in this environment (browser path only). - Using
minimax-docxto convert docx→md — it is a docx authoring tool; use the doc-to-markdown skill instead.
Bundled resources
scripts/feishu_extract_refs.py— deterministic reference-token extractor; the recursion engine's core. Run it on every fetched body to enumerate<mention-doc>/<sheet>/<image>/cross-tenant/Minutes/Tencent-Meeting references as JSON.scripts/restore_docx_headings.py— for Path B: reads true font sizes via python-docx, maps them to heading levels, restoresw:shdhighlights to Obsidian==…==, without retyping body text.scripts/feishu_dom_capture.js— Path D: injectable end-to-end browser DOM capture.scripts/download_feishu_images.py— Path D: SSR image extraction when browser automation is unavailable.scripts/build_feishu_markdown.py— Path D: render a capture manifest into Markdown.scripts/check_heading_coverage.py— coverage verification (both paths).references/lark-cli-api-extraction.md— Path A full reference (commands, recursion, sheets, cross-tenant).references/feishu-minutes-transcript.md— Path C native transcript API + scope auth.references/permission-and-failure-boundaries.md— error codes + the full Do-NOT-attempt table.references/docx-export-to-markdown.md— Path B faithful conversion procedure.references/browser-dom-fallback.md+references/browser-failure-rules.md— Path D.references/capture-manifest.md— manifest shape forbuild_feishu_markdown.py.
Next step
After extraction completes, the clean Markdown typically feeds the user's own knowledge-base ingestion (filing, indexing, dedup) — which is deliberately out of this skill's scope. If the source went through Path B (a docx), the doc-to-markdown skill is already part of that flow. Offer the handoff; do not auto-organize:
Extraction complete: [N] sources → faithful Markdown ([M] permission/image gaps listed).
Options:
A) Hand off to your PKM/organizing workflow — file & index these (Recommended if part of a vault)
B) Run /daymade-docs:docs-cleaner — consolidate redundant content across the extracted files
C) Stop here — the faithful Markdown is the deliverableSecurity scan passed
Scanned at: 2026-05-17T16:03:13.931043
Tool: gitleaks + pattern-based validation
Content hash: 490c7a25af60db17912e78bc1932b2bc7773bcfd1c50c04c62cd501a08b6551e
Browser DOM Fallback (Path D — last resort)
Use this only when lark-cli genuinely cannot reach the content: lark-cli cannot be installed/authenticated, and the doc is not permission-walled (a permission wall → Path B, not this). On real collection work this path was never needed — the API path did the whole job. It is slower, depends on a connected browser surface, and an anonymous debugging Chrome cannot read login-walled content. Keep it as the safety net, not the plan.
Contents
- Tool surface selection
- Step 1: probe (detect virtual scroll)
- Step 2: TOC-driven capture (the injectable script)
- Step 3: images
- Step 4: normalize, order, dedup
- Step 5: acceptance signal
- The 19 battle-tested DOM rules
Tool surface selection
Prefer data-bearing surfaces over purely visual ones. Order:
1. Chrome DevTools MCP — structured DOM/accessibility snapshots, scripted evaluate, programmatic TOC clicking + per-section capture on virtual-scroll pages, and the virtual-scroll diagnostic. Best default when it can attach to the authenticated tab. 2. Browser Use — direct page-text access, lower friction for repeated section capture; may not preserve every table and is still subject to virtual-scroll partial rendering. 3. Computer Use — when DOM-native tooling cannot attach and the task depends on the real authenticated browser (extensions, corporate login). Slower, UI-drift-sensitive, verify after every interaction. 4. Screenshots + manual extraction — only when none of the above reach the content.
Rejected as a primary capture path: Web Clipper on virtual-scroll pages; clipboard copy after a copy-restriction warning; one-shot "read the whole page" without TOC coverage checking. The in-browser extension surface frequently fails to connect at all — do not assume it is available.
Step 1: probe (detect virtual scroll)
Capture ground truth before extracting: document title, source URL, authenticated+readable, visible word count (if shown), sidebar TOC, copy-restriction banners, virtual scroll.
Virtual-scroll diagnostic (the decisive check): compare TOC item count vs rendered heading count, look for loading containers, total .block count, and identify the real scroll container (Feishu scrolls a nested div — .bear-web-x-container / .page-main / [class*="docx-width"] — not window). If tocItems >> renderedHeadings, or loading blocks exist, or totalBlocks < 10 on a long doc → virtual scroll is on; one-shot extraction will silently miss sections. The full diagnostic JS is embedded in scripts/feishu_dom_capture.js.
Step 2: TOC-driven capture (the injectable script)
Do not re-implement capture logic. Inject scripts/feishu_dom_capture.js and run its pipeline:
// inject the file content via evaluate_script, then:
const result = await window.__feishuCapture.run({
title: 'Document Title',
docName: 'short-name-for-image-files',
tags: ['feishu']
});
// → { totalCaptured, afterClean, sections, images, imagesOk }
// window.__feishuCapture.manifest → feed scripts/build_feishu_markdown.py
// window.__feishuCapture.cleanedBlocks → custom renderingIt handles, in one pass: TOC-driven section capture (click TOC item → wait ~2.5s → capture all .blocks between this heading and the next), nested-bullet recursion, table extraction (skipping blocks inside tables so cells don't leak as duplicate text; merging tables split across virtual-scroll boundaries by header row), code-block UI-noise stripping, inline-markdown conversion, image download via fetch(credentials:'include'), noise/aggregation-artifact removal, deduplication, and data-block-id numeric sort.
If there is no TOC: build a manual heading list top-to-bottom, scroll the real scroll container in stable increments, snapshot after each, stop when the bottom no longer changes.
Step 3: images
Feishu image src points at authenticated internal streams (internal-api-drive-stream.larkoffice.com / internal-api-drive-stream.feishu.cn) — they 404/403 once the session ends, so they must be downloaded during capture (the injectable script does this). When browser automation cannot attach at all, use the SSR fallback:
python3 scripts/download_feishu_images.py --url "<feishu-url>" --doc-name "<doc>" --output-dir assets/It regex-extracts the authenticated image URLs straight from the SSR HTML (via browser_cookie3 + requests) and downloads them with session cookies. Name images per-document (assets/{doc-name}-{index}.ext) — never generic img-0.png shared across docs. [图片: Feishu Docs - Image] in copy-pasted Markdown is a real lost-image placeholder, not noise — recover the image, do not delete the marker.
Step 4: normalize, order, dedup
Render the manifest with scripts/build_feishu_markdown.py (shape: capture-manifest.md). Sort blocks by numeric data-block-id (document logical order; DOM order is unreliable under virtual scroll). Deduplicate after sorting, before rendering (virtual scroll re-renders blocks with new ids; table-cell and orphaned-nested-bullet leaks must be removed). Frontmatter minimal: title, source, author, created, description, tags. Trust the DOM class — only docx-heading1/2/3-block become #/##/###; bold-styled body text stays body text.
Step 5: acceptance signal
Accept only when all hold:
- final Markdown covers the expected TOC headings (run
scripts/check_heading_coverage.py) - body roughly matches the visible word-count scale (when Feishu shows one)
- >95% of sections have non-empty body (empty headings = missed virtual-scroll content)
- tables named in the TOC ("总览"/"overview"/"schedule") are present as Markdown tables
- no
docx-block-loading-containerremains unvisited LC_ALL=C grep -rl $'\xef\xbf\xbd' .is empty
The 19 battle-tested DOM rules
The detailed, verified behaviors behind the above (copy walls, virtual scroll, zoom<1 table placeholders, table-cell leakage, data-block-id ordering, nested bullets, authenticated image streams, aggregation artifacts, callout drift, code-block noise, clipboard bridge, SSR image extraction, per-doc image naming, the lost-image placeholder): [browser-failure-rules.md](browser-failure-rules.md). Read it whenever the page behaves strangely.
History-Derived Rules
These rules were distilled from repeated local Feishu scraping sessions and follow verified behavior rather than guesswork.
Rule 1: Copy Warnings Mean Clipboard Is Dead
If Feishu shows a banner saying copying is restricted, treat clipboard extraction as blocked. Do not keep retrying Cmd+C, browser copy commands, or "copy all" variants as the main plan.
Rule 2: Virtual Scroll Breaks One-Shot Extraction
Feishu wiki and doc pages often virtual-render only the visible region plus a small buffer. Any extractor that reads "the page" once can silently miss later sections.
Implication:
- never trust a single pass
- always use the real scroll container, not
window.scrollTo. Feishu scrolls a nested div (usually.bear-web-x-container,.page-main, or[class*="docx-width"]). Scrollingwindowdoes nothing. - click TOC items to trigger section rendering, not just scroll. Feishu responds to TOC clicks by fetching and rendering the target section's blocks.
- after each TOC click, wait 2.5s for rendering, then capture all
.blockelements between the target heading and the next heading - some sections span multiple virtual "pages" — scroll the content container in increments after clicking, capturing new blocks each time
- deduplicate blocks by
data-block-idto avoid double-counting overlap
Rule 3: Web Clipper Can Look Correct While Still Being Incomplete
Extension output can capture only the rendered subset and still produce plausible Markdown or HTML. Plausibility is not acceptance.
Implication:
- treat Web Clipper as non-authoritative on virtual-scroll pages
- if TOC headings or word count do not line up, discard it as the main source
Rule 4: TOC Coverage Is the Best Section-Level Contract
The left sidebar TOC is the most reliable list of meaningful document sections. Use it as the checklist for coverage validation.
Rule 5: Remove UI Noise Aggressively
Common Feishu noise to delete:
- comments
- "you may also ask"
- support footer items
- upload logs
- "contact support"
- recommendation panels
- empty interaction controls
Rule 6: Validate Against Scale, Not Exact Word Count
When Feishu shows a visible word count, use it as a scale check. A final Markdown body that is dramatically shorter than the page count is probably incomplete even if the saved file looks tidy.
Rule 7: Trust the DOM Class, Do Not Promote Text Blocks to Headings
If the sidebar TOC does not list a sub-section, it is not a heading. Feishu sometimes styles body text as bold to make it look like a heading, but the DOM class remains docx-text-block or docx-quote-block. Respect the DOM class: only docx-heading1/2/3-block become #/##/###. Bold body text stays as body text with inline ** formatting.
Rule 8: Zoom < 1 Causes Table Placeholders
Do not zoom out to force more content into the viewport. At zoom levels below 1.0, Feishu renders bear-virtual-renderUnit-placeholder inside table cells, producing empty or corrupted rows. Keep zoom at 1.0 and rely on TOC-driven section extraction instead.
Rule 9: Skip Blocks Inside Tables
When querying .block, table cell blocks (docx-table_cell-block, .table-cell-block) also match. If not excluded, they appear as duplicate standalone text blocks in the output, polluting the markdown with table cell values outside the table. Exclude any block whose closest .docx-table-block ancestor is not itself.
Rule 10: Use data-block-id Numeric Order for Document Sequence
Virtual scroll unloads and re-renders blocks, which can reorder the DOM. compareDocumentPosition and DOM order are unreliable. Feishu assigns numeric data-block-id values in document logical order (lower = earlier). Sort captured blocks by numeric data-block-id before generating markdown.
Rule 11: Nested Bullets Have Parent-Child DOM Structure
Feishu nested lists use a parent .docx-bullet-block containing .list-children with child .docx-bullet-block elements. Extract parent text from .list-content or .ace-line, then recursively extract direct child bullets. Skip child bullets in the main capture loop (they're handled by their parent).
Rule 12: Image URLs Are Authenticated Internal-API Streams
Feishu image src attributes point to internal-api-drive-stream.larkoffice.com (or internal-api-drive-stream.feishu.cn for domestic). These URLs require the user's session cookie; they are not public CDN links. After the browser session ends or cookies expire, the images 404/403.
Implication:
- during capture, download every image via
fetch(src, { credentials: 'include' })while the session is alive - convert each response blob to a data URL for transport, then decode to local files
- replace remote URLs in the markdown with local relative paths (
assets/{doc-name}-{index}.ext) blob:URLs (Feishu's in-memory object URLs) cannot be fetched at all — skip them
Rule 13: Page-Main Container Produces Aggregation Artifacts
The first few .block elements (typically data-block-id 1–4) on a Feishu page are the outer page container whose innerText concatenates the entire visible content into a single giant string. These are not real content blocks.
Implication:
- drop any
type: textblock with payload length > 350 characters — it is almost certainly an aggregation artifact - real paragraphs in Feishu rarely exceed 300 characters per block
Rule 14: Callout/Quote Blocks Have Non-Sequential data-block-id
Feishu callout boxes, quote blocks, and sticky notes receive data-block-id values that are much higher than their visual position in the document. When sorting by data-block-id, these blocks drift to the document's tail.
Implication:
- after sorting, callout content may appear after the last real section
- either mark the tail as "appendix: callout blocks" or attempt to re-parent them under the correct heading using text matching
- do not assume
data-block-idorder is perfect for all block types
Rule 15: Feishu Code Blocks Contain UI Noise Lines
Feishu renders code blocks with visible UI labels: a language label line (e.g., "Bash"), a "Copy" button text, and sometimes "Code block" / "代码块" as the first line of innerText. These are not part of the code.
Implication:
- strip lines that exactly match:
Copy,Code block,代码块, or a bare language name - extract the language from these stripped lines if no
langattribute is present
Rule 16: Clipboard Bridge Is the Most Reliable Transport
Transporting large text (>10KB) from chrome-devtools evaluate_script to the local filesystem is unreliable via base64 heredoc (truncation), HTTP localhost (Chrome security blocks), or chunked JSON (slow). The most reliable path is:
1. navigator.clipboard.writeText(content) in the browser 2. pbpaste > file.md in the local shell (macOS)
This works for text content up to ~1MB. For binary (images), use writeText(base64) + pbpaste | base64 -d > file.png.
Rule 17: SSR HTML Contains All Image URLs — No Browser Automation Required
Feishu wiki/doc pages render image URLs directly in the initial HTML response at internal-api-drive-stream.larkoffice.com / internal-api-drive-stream.feishu.cn. These can be extracted via regex without any browser automation, scrolling, or JavaScript execution.
Working fallback when browser automation fails:
Use the bundled script scripts/download_feishu_images.py:
python3 scripts/download_feishu_images.py \
--url "https://my.feishu.cn/wiki/..." \
--doc-name "my-document" \
--output-dir "assets/"Or implement manually:
import browser_cookie3, requests, re
cj = browser_cookie3.chrome()
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}
resp = requests.get(url, cookies=cj, headers=headers, timeout=30)
image_urls = re.findall(
r'https?://internal-api-drive-stream[^\s"\'<>]+',
resp.text
)
# Download each with session cookies
for i, img_url in enumerate(image_urls):
img_resp = requests.get(
img_url, cookies=cj,
headers={'Referer': 'https://my.feishu.cn/'},
timeout=30
)When to use this path:
- AppleScript / JXA execution is disabled in Chrome
- Chrome DevTools CDP returns 404/empty
- Browser automation tools cannot attach to the page
- Batch-processing many documents (faster than per-page browser automation)
Limitation: This extracts image URLs only. For full document text + structure, browser-based DOM extraction is still required.
Rule 18: Images Must Be Named Per-Document
Never use generic names like img-0.png, img-1.png across multiple documents. When multiple documents share an assets/ directory, generic names collide and overwrite each other.
Correct naming: {sanitized_doc_name}-{index}.{ext}
Example: million-dollar-creative-0.png, million-dollar-creative-1.png
Rule 19: [图片: Feishu Docs - Image] Is a Real Image Placeholder
When a Feishu document is copy-pasted into markdown and the image cannot be resolved, Feishu produces the non-standard placeholder [图片: Feishu Docs - Image]. This is not invalid markdown — it indicates a real image existed in the original document but was lost during copy-paste.
Implication:
- Do not delete these placeholders as "noise"
- They are a signal that the document contains images that need recovery
- Use Rule 17 (SSR extraction) or browser-based image download to recover the actual images
Capture Manifest
Use scripts/build_feishu_markdown.py when extraction is easier to stage as structured data before rendering.
Minimal Shape
{
"title": "Document title",
"source": "https://example.feishu.cn/wiki/...",
"author": ["Author A", "Author B"],
"published": "",
"created": "2026-05-07",
"description": "Short summary",
"tags": ["clippings", "feishu"],
"sections": [
{
"heading_level": 1,
"heading": "Main Heading",
"body": [
"Paragraph one.",
"- Bullet item",
"| Col A | Col B |",
"| --- | --- |",
"| A1 | B1 |"
]
}
]
}Field Rules
title: requiredsource: strongly recommendedauthor: string or array of stringspublished: optionalcreated: optional, defaults to today only if the caller sets itdescription: optionaltags: optional, string or arraysections: required arrayheading_level: optional, defaults to2body: string or array of Markdown blocks
Rendering Command
python3 scripts/build_feishu_markdown.py \
--input /path/to/capture.json \
--output /path/to/output.mdIf --output is omitted, the renderer prints Markdown to stdout.
Owner-Exported .docx → Faithful Markdown (Path B)
When a Feishu doc returns 131006 (permission denied) and cannot be reached by API or browser, the only correct path is: the permission holder exports it as .docx and sends it back out-of-band; you then convert it faithfully. "Faithfully" is the hard part — a naive pandoc conversion silently destroys the heading hierarchy and all highlights. Verified procedure (2026-05).
Contents
- The two silent-corruption failure modes
- Step 1: convert with the right tool
- Step 2: restore heading hierarchy (font-size → heading)
- Step 3: restore highlights (
w:shd→==…==) - Step 4: visual verification (mandatory)
- Step 5: provenance
The two silent-corruption failure modes
Feishu-exported docx does not use Word heading styles. It lays out headings with font size + bold on otherwise-normal paragraphs, and marks emphasis with cell/run shading (`w:shd`), not w:highlight. Consequences:
1. pandoc → 0 Markdown headings. Every "heading" becomes a flat **bold** paragraph. In the real case: 102 flat bold paragraphs, zero #. A text-only check ("no errors, word count matches") passes while the document's entire structure is gone. 2. All highlights vanish. pandoc reads w:highlight; Feishu uses w:shd@fill. Standard highlight APIs return nothing, so the conversion looks complete but every emphasized passage is now indistinguishable from body text.
Neither is catchable without rendering and looking. This is why Step 4 is mandatory.
Step 1: convert with the right tool
Use the doc-to-markdown skill (pandoc + 8 post-processing fixes), not minimax-docx (that is a docx authoring tool — wrong direction). Get a first-pass .md plus extracted media. Confirm the real format first — an exported .docx is sometimes mislabeled:
file -b "<exported>.docx" # expect: Microsoft Word 2007+ / Microsoft OOXMLThe text in this first pass is correct; only its structure (headings) and emphasis (highlights) are lost. Steps 2–3 add those back without retyping the body — the pandoc text stays byte-for-byte; only # prefixes and ==…== wrappers are added.
Step 2 & 3: restore headings and highlights
Use the bundled script — it does both, deterministically, by reading the docx's own XML via python-docx:
python3 scripts/restore_docx_headings.py \
--docx "<exported>.docx" \
--md "<first-pass>.md" \
--out "<final>.md"What it does (and why, so you can patch it for an odd document):
- Heading restoration: reads each paragraph's true font size (
run.font.size.pt), builds the size→count distribution, maps the largest distinct sizes toH1…Hnin descending order, and prepends the matching#s to the corresponding lines in the Markdown. It does not invent or move text. A typical observed distribution and mapping:
| pt | role |
|---|---|
| 26 | H1 |
| 18 | H2 |
| 16 | H3 |
| 15 | H4 |
| 14 | H5 |
| 11 | body |
The exact pt values differ per document — the script derives them from the distribution rather than hard-coding, but the descending-size → descending-level rule is the invariant.
- Highlight restoration: reads
rPr/w:shd@fillper run (lxml/python-docx XML access, since python-docx has no high-level API for shading). Runs whosefillis a highlight color get wrapped in Obsidian==…==at their position in the Markdown line. Observed fills:ffe928(yellow),935af6(purple).==text==combined with existing**bold**(**==text==**) is valid Obsidian and renders correctly.
The script keeps the body text identical to the pandoc output; if you must do this by hand, follow the same rule — derive sizes from run.font.size.pt, map descending, prefix #, never re-transcribe.
Step 4: visual verification (mandatory)
Text checks cannot detect a flattened hierarchy. Render and look:
# first-page thumbnail
qlmanage -t -s 1600 -o /tmp/vv "<exported>.docx"
# full document → PDF (LibreOffice), then read the PDF / screenshots
soffice --headless --convert-to pdf --outdir /tmp/vv "<exported>.docx"Read the rendered image(s) and compare against <final>.md rendered as Markdown:
- Heading levels match the visual size hierarchy in the source.
- Highlighted passages in the source are
==…==in the output, in the same places. - No body paragraph was promoted/demoted; no text added or dropped.
Only after this visual pass does the file count as done (this mirrors the general "generated docs must be visually verified, not just text-checked" rule).
Step 5: provenance
Record what was reshaped, so a future reader knows the body is not a raw API passthrough:
post_process: headings restored from docx font sizes (26/18/16/15/14pt → H1–H5) via python-docx; w:shd fills (ffe928/935af6, invisible to pandoc) restored as Obsidian ==highlight==; visually verified against the source render.Also surface to the user any embedded images the docx contains that could not be downloaded (see permission-and-failure-boundaries.md) — list the tokens; do not silently drop them.
Feishu Minutes (妙记) Transcript (Path C)
How to export the text transcript of a Feishu Minutes recording. Verified end-to-end (2026-05).
Contents
- The key fact: lark-cli cannot do it directly
- The native endpoint
- The scope and the
99991679error - Granting the scope via device-flow (and the timeout trap)
- Permission is per-minute, not per-tenant
- Never re-ASR
The key fact: lark-cli cannot do it directly
lark-cli minutes exposes minutes get (metadata), +download (audio/video), search, upload. None export the transcript text. lark-cli minutes minutes get --params '{"minute_token":"<t>"}' succeeds but returns only title/duration/url — no transcript. The transcript is a native endpoint not wrapped by lark-cli; call it through lark-cli api.
The native endpoint
GET https://open.feishu.cn/open-apis/minutes/v1/minutes/:minute_token/transcript| Param | In | Required | Notes |
|---|---|---|---|
minute_token | path | yes | the last segment of the Minutes URL |
need_speaker | query | no | true → speaker labels |
need_timestamp | query | no | true → per-line timestamps |
file_format | query | no | txt or srt; txt is best for a Markdown KB |
Auth: user_access_token (use --as user) or tenant_access_token.
export LARK_CLI_NO_PROXY=1
lark-cli api GET /open-apis/minutes/v1/minutes/<minute_token>/transcript \
--params '{"need_speaker":true,"need_timestamp":true,"file_format":"txt"}' \
--as user -o <speaker-and-timestamped-transcript>.txtA successful run yields the full transcript with speaker + millisecond timestamps; verify with the U+FFFD check (LC_ALL=C grep -rl $'\xef\xbf\xbd' . empty).
Spec lookups: usehttps://open.feishu.cn/llms-docs/zh-CN/llms-minutes.txt(stable, LLM-friendly).WebFetchagainstopen.feishu.cn/document/server-docs/...is flaky. If lark-cli has no wrapper for something, thelark-openapi-explorerskill is the systematic way to mine the native spec.
The scope and the 99991679 error
Without the export scope the call returns:
{"ok":false,"error":{"type":"permission","code":99991679,
"message":"Permission denied [99991679]",
"detail":{"permission_violations":[
{"subject":"minutes:minute:download","type":"action_privilege_required"},
{"subject":"minutes:minutes.transcript:export","type":"action_privilege_required"}]}}}The scope you need is `minutes:minutes.transcript:export`.
Granting the scope via device-flow (and the timeout trap)
lark-cli auth login --scope "minutes:minutes.transcript:export" --no-wait --json
# → returns a device flow_id + user_code + a verify URL like:
# https://accounts.feishu.cn/oauth/v1/device/verify?flow_id=...&user_code=XXXX-XXXX- Send the verify URL to the person who owns / can access the Minutes so they approve it in a browser.
- Resume polling with
lark-cli auth login --device-code <code>— do not wrap the login in a shorttimeout. lark-cli explicitly warns: each restart invalidates the previous device code, so short-timeout-retry loops never converge. The login command can legitimately block for up to ~10 minutes waiting for approval. - After approval, re-run the
api … /transcriptcall; it now succeeds.
Permission is per-minute, not per-tenant
One Minutes returning permission deny (e.g. code 2091005) does not mean other Minutes in the same tenant are denied. Check each minute_token independently. Before chasing a denied one, check whether its content is already covered by another document you can access (a meeting's AI summary doc often duplicates the transcript) — if so, skip it instead of escalating the permission request.
Never re-ASR
The platform's native AI transcription is materially better than downloading the media and running ASR yourself (speaker diarization, timestamps, domain vocabulary). Downloading the mp4/mp3 and re-transcribing is a regression — do not do it, even though lark-cli minutes +download makes it tempting.
lark-cli API Extraction (Path A — primary)
The primary, highest-fidelity way to turn a Feishu/Lark source into Markdown. Everything here was verified end-to-end on a real multi-document collection import (lark-cli 1.0.27 and 1.0.32, 2026-05).
Contents
- Why API over browser
- Step 0: proxy and auth preflight
- Step 1: classify the URL
- Step 2: resolve wiki node → doc token
- Step 3: fetch the body programmatically
- Step 4: spreadsheets
- Step 5: the reference-graph recursion (collections/hubs)
- Step 6: cross-tenant and personal-space sources
- Step 7: frontmatter and provenance
- Command troubleshooting
- What a clean run looks like
Why API over browser
On real collection work the lark-cli path did the entire job and the browser path was never needed, because the API path:
1. Recurses a hub's reference graph programmatically — a browser cannot "follow" <mention-doc> references mechanically. 2. Resolves permission boundaries from exact error codes (131006, 99991679) instead of guessing from a rendered page. 3. Streams the body to disk via jq/cat so the document text never passes through the model (paraphrasing is undetectable later — the core fidelity argument). 4. Does not depend on a browser extension being connected (the in-browser surface frequently fails to connect; an anonymous debugging Chrome cannot read login-walled content anyway).
Step 0: proxy and auth preflight
export LARK_CLI_NO_PROXY=1
lark-cli --version # confirm ≥ 1.0.32 (2026-05); older works but lacks fixes
lark-cli auth status # must be valid for the target tenantLARK_CLI_NO_PROXY=1 is mandatory for *.feishu.cn (mainland, direct-connect). Without it, lark-cli prints:
[lark-cli] [WARN] proxy detected: https_proxy=http://127.0.0.1:1082 — requests
(including credentials) will transit through this proxy. Set LARK_CLI_NO_PROXY=1 to disable proxy.That warning is the signal — credentials would transit the proxy and Feishu's domestic DNS would be hijacked. This is host-specific and does not conflict with rules that force claude.ai/anthropic.com through a proxy; Feishu is a different, direct host.
Step 1: classify the URL
| URL shape | Meaning | Action |
|---|---|---|
…/wiki/<node_token> | wiki node (a pointer, not a doc) | Step 2 then Step 3 |
…/docx/<doc_token> | doc, already a doc token | Step 3 directly |
…/sheets/<sp_token> | spreadsheet | Step 4 |
…/minutes/<minute_token> | Minutes / 妙记 | see feishu-minutes-transcript.md |
…/base/<token>, …/file/<token> | Bitable / file attachment | see reference-graph dispatch (Step 5) |
https://<anything>.feishu.cn/docx/… or https://my.feishu.cn/docx/… | cross-tenant / personal space | Step 6 (same fetch, permission is per-doc) |
Step 2: resolve wiki node → doc token
A wiki node_token is a navigation pointer; fetching it as a doc fails. Resolve it:
lark-cli wiki spaces get_node --params '{"token":"<node_token>"}'Returns {"code":0,"data":{"node":{"node_token":"…","obj_token":"<DOC_TOKEN>","obj_type":"docx","node_type":"origin","has_child":false,…}}}.
- Use
.data.node.obj_token+.data.node.obj_typefor Step 3. has_child:falseon the entry node does not mean "no content" — a collection hub is typically a single docx whose body references many other docs (Step 5), not a multi-node wiki tree.code 131006 … node permission denied→ this node is permission-walled; stop and go to Path B (docx-export-to-markdown.md). Do not try to bypass it.
Step 3: fetch the body programmatically
lark-cli docs +fetch --doc <obj_token> --format json > /tmp/fetch.json 2> /tmp/fetch.err
jq -r '.data.markdown' /tmp/fetch.json > "<sanitized-title>.md".data.markdownis clean Markdown with Feishu rich-media tags preserved (resolve them in Step 5).- Keep stdout/stderr separate.
stderrmay carry[deprecated] docs +fetch with v1 API is deprecated— harmless. Doing2>/dev/null | jqin one pipe produced a spuriousExit code 5; redirect to files and inspect instead. - Never reconstruct
.data.markdownby reading and retyping it.jq -rit to disk. This is the fidelity guarantee that makes Path A structurally safer than any browser/LLM path. --format jsonis preferred over text so you parse one field deterministically.
Step 4: spreadsheets
A <sheet token="<SP>_<SID>"/> tag (or a …/sheets/<SP> URL) carries the spreadsheet token and sheet id joined by _. Split on _:
lark-cli sheets +info --spreadsheet-token <SP> \
--jq '.data.sheets[]? | {sheet_id, title, rowCount: .gridProperties.rowCount, colCount: .gridProperties.columnCount}'
lark-cli sheets +read --spreadsheet-token <SP> --sheet-id <SID> \
--range A1:AZ200 --value-render-option ToString \
--jq '.data.valueRange.values'--value-render-option ToStringreturns plain text cells (formulas/dates rendered), which is what Markdown tables need.- The result is a 2-D array; render it to a Markdown table. Size the range from
sheets +inforow/col counts; do not blind-guess a tiny range.
Step 5: the reference-graph recursion (collections/hubs)
A hub is the root of a reference graph. Treat it as BFS/DFS over references until every branch reaches a leaf (a doc with no further references).
Enumerate references with the bundled extractor (a missed reference is a missing document — the single biggest hub-scraping failure; do not hand-roll grep and forget the my.feishu.cn personal-space pattern, which is exactly what happened before this script existed):
python3 scripts/feishu_extract_refs.py <fetched-body>.md
# → JSON array of {type, token_or_url, title}The references it recognizes (the full rich-media inventory): <mention-doc token type>, <sheet token>, <lark-table><lark-tr><lark-td> (inline tables — render in place, not a reference), <image token>, <view><file>, cross-tenant https://<tenant>.feishu.cn/(docx|wiki|sheets|base|file)/<token>, personal-space https://my.feishu.cn/docx/<token>, Minutes https://<tenant>.feishu.cn/minutes/<token>, Tencent-Meeting https://meeting.tencent.com/crm/<id>.
Dispatch table:
| Reference type | Handler |
|---|---|
mention-doc type docx / cross-tenant /docx/ / my.feishu.cn/docx/ | Step 3 docs +fetch |
mention-doc / URL /wiki/ | Step 2 then Step 3 |
sheet / /sheets/ | Step 4 |
/minutes/ URL | feishu-minutes-transcript.md (native transcript API) |
meeting.tencent.com/crm/ | Tencent Meeting tooling (outside this skill — its native transcript API; never download+re-ASR) |
<lark-table> | render inline to a Markdown table (pandas read_html handles colspan/rowspan); it is content, not a link |
<image token> | register the token; lark-cli cannot download it (see permission-and-failure-boundaries.md) |
<view><file> | attachment — record token + filename; treat like an image gap unless separately retrievable |
Recursion loop: fetch root → extract refs → for each new ref, dispatch and fetch → run the extractor on each newly fetched body → repeat until no new tokens appear. A child doc can itself embed another reference (e.g. a summary doc that embeds a third Minutes link); the loop must re-scan every newly fetched file, not only the root.
Leaf / completion gate — before declaring the collection done, no rich-media reference may remain unresolved anywhere:
grep -rlE '<(lark-table|lark-tr|sheet token=|mention-doc|view type=)' . \
&& echo "UNRESOLVED — keep recursing" || echo "clean"This grep being empty is a hard acceptance gate for collections.
Step 6: cross-tenant and personal-space sources
https://<other-tenant>.feishu.cn/docx/… and https://my.feishu.cn/docx/… (personal space) use the same docs +fetch — Feishu permission is per-document, not per-domain. A reference living in another tenant or someone's personal space is often still readable with the current token. Do not skip a reference just because its host differs; try the fetch and let the error code (131006 / 0) decide.
Step 7: frontmatter and provenance
Each produced file should carry minimal frontmatter so the extraction is auditable and the host PKM can file it (this skill stops at producing it, not filing it):
---
title: <document title>
source: <original feishu URL or token>
source_type: docx | wiki | sheet | minutes
extracted: <YYYY-MM-DD>
post_process: <one line if any non-trivial transform was applied; omit if pure jq passthrough>
---post_process matters when text was reshaped (e.g. a sheet rendered to a table, or Path B's heading restoration) — it tells a future reader the body is not a byte-for-byte API passthrough.
Command troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
docs +fetch "Exit code 5" but data looks present | 2>/dev/null swallowed stderr while jq failed on mixed stream | Redirect stdout/stderr to separate files; parse the file |
wiki spaces get_node → code 131006 | No read permission on that node | Path B (owner exports docx); do not bypass |
api …/transcript → code 99991679 | Missing scope | feishu-minutes-transcript.md (device-flow scope grant) |
lark-cli reports API returned an empty JSON response body | lark-cli mis-renders a binary/error HTTP response | Real status is hidden — see permission-and-failure-boundaries.md; do not trust "empty JSON" literally |
| Need an API lark-cli does not wrap | — | lark-cli api <METHOD> <path> --params '{…}' --as user; find the spec via open.feishu.cn/llms-docs/zh-CN/llms-<module>.txt (the /document/server-docs/ pages are flaky in WebFetch) |
What a clean run looks like
Single doc:
$ export LARK_CLI_NO_PROXY=1
$ lark-cli wiki spaces get_node --params '{"token":"<node_token>"}'
{"code":0,"data":{"node":{"obj_token":"<DOC>","obj_type":"docx","has_child":false,...}}}
$ lark-cli docs +fetch --doc <DOC> --format json > /tmp/f.json 2> /tmp/f.err
$ jq -r '.data.markdown' /tmp/f.json | wc -c
6166
$ LC_ALL=C grep -rl $'\xef\xbf\xbd' . ; echo "ffd_count=$?"
ffd_count=1 # 1 = grep found nothing = cleanCollection: the same, then N rounds of feishu_extract_refs.py → dispatch → fetch, ending with the residual-tag grep printing clean.
Permission Boundaries & Verified Dead-Ends
The single most valuable part of this skill: a record of what does not work, so the next run does not re-pay the cost of discovering it. Every entry was verified, not guessed.
Contents
- Error codes you will hit
- Dead-end table (do NOT attempt)
- Why "empty JSON" from lark-cli is a lie
- Login-wall detection
- Wrong-tool traps
Error codes you will hit
| Code | Where | Meaning | Correct response |
|---|---|---|---|
131006 | wiki spaces get_node / docs +fetch | node permission denied, user needs read permission — the current token cannot read this wiki node | Hard server-side boundary. Stop. Path B: ask the permission holder to export .docx out-of-band. Do not try lark-cli/curl/browser bypasses. |
99991679 | api …/minutes/.../transcript | missing scope minutes:minutes.transcript:export | Grant the scope via device-flow (feishu-minutes-transcript.md). |
2091005 | minutes transcript | that specific minute is permission-denied | Per-minute, not per-tenant. Check if content is covered elsewhere before escalating. |
0 | any | success | proceed |
131006 is a Feishu-side decision. It was verified that an anonymous browser redirects to accounts.feishu.cn/...login, and that even a logged-in user without a share still has to request access. There is no client-side trick. The only path is the document owner exporting it.
Dead-end table (do NOT attempt)
| Path | Failure mode (verified) | Root cause |
|---|---|---|
Bypass 131006 via lark-cli retry / different token | still 131006 | server-side per-node ACL |
Bypass 131006 via anonymous curl of the wiki URL | HTTP 200 but body is the login page (accounts.feishu.cn, login, passport, empty <title>) | unauthenticated request hits the login wall, not the doc |
Bypass 131006 via anonymous debugging Chrome | redirected to accounts.feishu.cn/.../login?redirect_uri=... | no session in that Chrome profile |
docx embedded image: lark-cli docs +media-download --token <img> --type media | HTTP 404 | command has no extra param to identify the owning docx; a bare media token out of its docx context is not resolvable |
docx image: lark-cli api GET /open-apis/drive/v1/medias/<img>/download (no extra) | {"ok":false,...,"API returned an empty JSON response body"} | lark-cli swallows the real error body |
docx image: same with --params '{"extra":"{\"drive_route_token\":\"<doc>\"}"}' | empty / fails | the extra format lark-cli passes is not what the endpoint needs; lark-cli does not wrap this correctly |
docx image: lark-cli schema drive.medias.download (and .media., .batch_get_tmp_download_url) | Unknown resource | not in lark-cli's schema registry |
docx image: lark-cli api … --dry-run then raw curl | --dry-run returns method/url/appId/as but not the Bearer token → curl authenticates as nobody → real HTTP/2 400 | lark-cli intentionally does not expose the token; the curl-around-lark-cli path is structurally closed |
| Read the downloaded image bytes to "check" them | This tool cannot read binary files | — |
WebFetch https://open.feishu.cn/document/server-docs/... for an API spec | backend flaps, often fails | use open.feishu.cn/llms-docs/zh-CN/llms-<module>.txt instead |
AppleScript executeJavaScript in Chrome | "Executing JavaScript through AppleScript is turned off" | Chrome disables JS-from-AppleEvents; defaults write + restart does not re-enable it here |
JXA executeJavaScript with async/Promise | Can't convert types. (-1700) | JXA cannot convert JS Promises to AppleScript types |
JXA with ObjC.import / shebang / includeStandardAdditions | syntax errors (-2741) | unsupported in this JXA-in-Chrome context |
Chrome DevTools CDP on :9222 | curl :9222/json/list → [] or 404 | CDP endpoints empty even with the flag (profile/policy) |
minimax-docx to convert docx→md | wrong direction | it is a docx authoring/editing tool, not an extractor |
Conclusion for docx embedded images: lark-cli (through 1.0.32) cannot download <image> tokens embedded in a docx — seven distinct approaches were exhausted. Register the tokens and dimensions, note "document owner must right-click → save and send out-of-band", and move on. The text is the deliverable; images are a tracked, transparent gap. Grinding past the established try-limit is itself the mistake.
Why "empty JSON" from lark-cli is a lie
When lark-cli prints API returned an empty JSON response body, the server did not necessarily return empty — lark-cli fails to render a binary or error response and substitutes that message. The real status (e.g. HTTP/2 400) is only visible via --dry-run + curl, but --dry-run withholds the Bearer token, so that diagnostic path cannot complete an authenticated request. Net: treat "empty JSON" as "unknown failure, lark-cli does not wrap this endpoint", not as "the resource is empty".
Login-wall detection
Never infer "publicly accessible" from an HTTP 200. A Feishu login wall returns 200 with a body containing any of: accounts.feishu.cn, passport, a login form, an empty <title></title>. Always inspect the body. This is why an anonymous debugging Chrome can only answer "is this page public?" — it can never read login-walled content.
Wrong-tool traps
- docx → Markdown: use the
doc-to-markdownskill (pandoc + post-processing), notminimax-docx(authoring tool, opposite direction). - Finding an unwrapped native API: use the
lark-openapi-explorerskill rather than guessing endpoints. - A search agent reporting "file not found": not authoritative — verify against authoritative sources (
git worktree list, repo-widefind,git log -S, the transcripts directory) before concluding. Ingested recordings/transcripts commonly live in a transcripts directory, not where you first looked.
#!/usr/bin/env python3
"""
Render a structured Feishu capture manifest into Markdown.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def to_list(value):
if value is None:
return []
if isinstance(value, list):
return [str(item) for item in value if str(item).strip()]
return [str(value)]
def yaml_lines(manifest):
lines = ["---"]
simple_fields = [
"title",
"source",
"published",
"created",
"description",
]
for field in simple_fields:
value = manifest.get(field, "")
lines.append(f'{field}: "{str(value).replace(chr(34), chr(39))}"' if value else f"{field}:")
for key in ("author", "tags"):
values = to_list(manifest.get(key))
if values:
lines.append(f"{key}:")
for value in values:
lines.append(f' - "{value.replace(chr(34), chr(39))}"')
else:
lines.append(f"{key}:")
lines.append("---")
return lines
def normalize_body(body):
if body is None:
return []
if isinstance(body, list):
return [str(block).strip() for block in body if str(block).strip()]
text = str(body).strip()
return [text] if text else []
def section_lines(section):
level = int(section.get("heading_level", 2))
level = max(1, min(level, 6))
heading = str(section.get("heading", "")).strip()
if not heading:
raise ValueError("Section heading is required")
lines = [f'{"#" * level} {heading}']
body_blocks = normalize_body(section.get("body"))
if body_blocks:
lines.append("")
lines.extend(body_blocks)
return lines
def render_markdown(manifest):
title = str(manifest.get("title", "")).strip()
if not title:
raise ValueError("Manifest title is required")
sections = manifest.get("sections")
if not isinstance(sections, list) or not sections:
raise ValueError("Manifest sections must be a non-empty list")
lines = yaml_lines(manifest)
lines.extend(["", f"# {title}"])
source = str(manifest.get("source", "")).strip()
if source:
lines.extend(["", f"Source: <{source}>"])
for section in sections:
lines.extend(["", *section_lines(section)])
return "\n".join(lines).rstrip() + "\n"
def parse_args():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True, help="Path to capture manifest JSON")
parser.add_argument("--output", help="Optional output markdown path")
return parser.parse_args()
def main():
args = parse_args()
manifest_path = Path(args.input)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
markdown = render_markdown(manifest)
if args.output:
output_path = Path(args.output)
output_path.write_text(markdown, encoding="utf-8")
print(f"Wrote {output_path}")
else:
sys.stdout.write(markdown)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Check that expected Feishu headings are present in the final Markdown output.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
NOISE_PATTERNS = (
"you may also ask",
"recommended content",
"upload logs",
"contact support",
"comments",
)
def normalize(text: str) -> str:
text = text.strip().lower()
text = re.sub(r"^#+\s*", "", text)
text = re.sub(r"\s+", "", text)
# Remove punctuation and special characters. Using a set avoids the
# regex escaping trap (the previous character class terminated early
# because \\] was interpreted as a literal backslash followed by ]).
_REMOVE_CHARS = set(
chr(c) for c in (
0x60, 0x7E, 0x7C, 0x2C, 0x2E, 0x21, 0x3F, 0x28, 0x29,
0x5B, 0x5D, 0x3C, 0x3E, 0x300A, 0x300B,
0x22, 0x27, 0x201C, 0x201D, 0x2018, 0x2019,
0x5C, 0x2D, 0x2B,
0x3A, 0xFF1A,
0x3002, 0xFF0C,
0xFF01, 0xFF1F,
0xFF08, 0xFF09,
0x2014, 0x2013,
)
)
text = "".join(c for c in text if c not in _REMOVE_CHARS)
return text
def load_expected(headings_file: Path) -> list[str]:
return [line.strip() for line in headings_file.read_text(encoding="utf-8").splitlines() if line.strip()]
def extract_headings(markdown_text: str) -> list[str]:
headings = []
for line in markdown_text.splitlines():
if re.match(r"^#{1,6}\s+\S", line):
headings.append(line.strip())
return headings
def detect_noise(markdown_text: str) -> list[str]:
lowered = markdown_text.lower()
return [pattern for pattern in NOISE_PATTERNS if pattern in lowered]
def parse_args():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--markdown-file", required=True, help="Generated markdown file")
parser.add_argument("--headings-file", required=True, help="Plain text file with one expected heading per line")
return parser.parse_args()
def main():
args = parse_args()
markdown_path = Path(args.markdown_file)
headings_path = Path(args.headings_file)
markdown_text = markdown_path.read_text(encoding="utf-8")
expected = load_expected(headings_path)
found = extract_headings(markdown_text)
found_index = {normalize(item): item for item in found}
missing = [item for item in expected if normalize(item) not in found_index]
noise_hits = detect_noise(markdown_text)
print(f"Expected headings: {len(expected)}")
print(f"Found markdown headings: {len(found)}")
if missing:
print("Missing headings:")
for item in missing:
print(f" - {item}")
if noise_hits:
print("Noise patterns detected:")
for item in noise_hits:
print(f" - {item}")
if missing or noise_hits:
sys.exit(1)
print("Heading coverage check passed.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Download images from Feishu/Lark documents via SSR HTML extraction.
When browser automation (AppleScript, JXA, Chrome DevTools) is unavailable,
this script extracts authenticated image URLs directly from the initial HTML
response and downloads them with session cookies.
Dependencies: pip install browser_cookie3 requests
Usage (single document):
python3 download_feishu_images.py \
--url "https://my.feishu.cn/wiki/..." \
--doc-name "my-document" \
--output-dir "assets/"
Usage (batch from file):
python3 download_feishu_images.py \
--batch-file urls.txt \
--output-dir "assets/"
The urls.txt format (one per line, optional doc-name prefix):
my-document|https://my.feishu.cn/wiki/...
another-doc|https://my.feishu.cn/wiki/...
Output: downloaded images + markdown image references printed to stdout.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from pathlib import Path
from urllib.parse import urlparse
try:
import browser_cookie3
import requests
except ImportError as e:
print(f"Missing dependency: {e}", file=sys.stderr)
print("Install: pip install browser_cookie3 requests", file=sys.stderr)
sys.exit(1)
IMAGE_URL_RE = re.compile(r'https?://internal-api-drive-stream[^\s"\'<>]+')
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
),
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8"
),
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
def sanitize_name(name: str) -> str:
"""Keep alphanumerics, Chinese chars, underscore, hyphen. Max 40 chars."""
cleaned = re.sub(r"[^a-zA-Z0-9一-鿿_-]", "", name)
return cleaned[:40]
def extract_image_urls(html_text: str) -> list[str]:
"""Extract authenticated Feishu image URLs from raw HTML."""
seen: set[str] = set()
unique: list[str] = []
for url in IMAGE_URL_RE.findall(html_text):
if url not in seen:
seen.add(url)
unique.append(url)
return unique
def download_image(url: str, cookies, referer: str) -> tuple[bytes, str]:
"""Download a single image with session cookies. Returns (content, content_type)."""
headers = {"Referer": referer}
resp = requests.get(url, cookies=cookies, headers=headers, timeout=30)
resp.raise_for_status()
content_type = resp.headers.get("content-type", "image/png")
return resp.content, content_type
def ext_from_content_type(content_type: str) -> str:
"""Map content-type to file extension."""
ct = content_type.lower()
if "gif" in ct:
return "gif"
if "jpeg" in ct or "jpg" in ct:
return "jpg"
if "webp" in ct:
return "webp"
if "svg" in ct:
return "svg"
return "png"
def process_document(
url: str,
doc_name: str,
output_dir: Path,
cookies,
dry_run: bool = False,
) -> dict:
"""Download all images from a single Feishu document."""
result = {
"url": url,
"doc_name": doc_name,
"found": 0,
"downloaded": 0,
"errors": 0,
"files": [],
"markdown_refs": [],
}
try:
resp = requests.get(url, cookies=cookies, headers=DEFAULT_HEADERS, timeout=30)
resp.raise_for_status()
except requests.RequestException as e:
print(f" ERROR fetching page: {e}", file=sys.stderr)
result["errors"] += 1
return result
image_urls = extract_image_urls(resp.text)
result["found"] = len(image_urls)
if not image_urls:
print(" No image URLs found in page HTML.")
return result
parsed = urlparse(url)
referer = f"{parsed.scheme}://{parsed.netloc}/"
safe_name = sanitize_name(doc_name)
output_dir.mkdir(parents=True, exist_ok=True)
for i, img_url in enumerate(image_urls):
ext = "png"
local_name = f"{safe_name}-{i}.{ext}"
local_path = output_dir / local_name
try:
if dry_run:
print(f" DRY-RUN: would download -> {local_name}")
else:
content, content_type = download_image(img_url, cookies, referer=referer)
ext = ext_from_content_type(content_type)
local_name = f"{safe_name}-{i}.{ext}"
local_path = output_dir / local_name
local_path.write_bytes(content)
print(f" OK: {local_name} ({len(content)} bytes)")
result["downloaded"] += 1
result["files"].append(str(local_path))
result["markdown_refs"].append(f"")
except requests.RequestException as e:
print(f" ERROR downloading image {i}: {e}", file=sys.stderr)
result["errors"] += 1
return result
def parse_batch_file(path: Path) -> list[tuple[str, str]]:
"""Parse batch file. Format: doc-name|url (or just url)."""
entries: list[tuple[str, str]] = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "|" in line:
doc_name, url = line.split("|", 1)
entries.append((doc_name.strip(), url.strip()))
else:
parsed = urlparse(line)
doc_name = parsed.path.strip("/").split("/")[-1] or "doc"
entries.append((doc_name, line))
return entries
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", help="Single Feishu document URL")
parser.add_argument("--doc-name", default="doc", help="Document name for image files")
parser.add_argument("--output-dir", default="assets", help="Directory to save images")
parser.add_argument("--batch-file", help="File with doc-name|url lines for batch processing")
parser.add_argument("--dry-run", action="store_true", help="Print what would be done without downloading")
return parser.parse_args()
def main() -> int:
args = parse_args()
if not args.url and not args.batch_file:
print("Error: specify --url or --batch-file", file=sys.stderr)
return 1
try:
cookies = browser_cookie3.chrome()
except Exception as e:
print(f"Error loading Chrome cookies: {e}", file=sys.stderr)
return 1
output_dir = Path(args.output_dir)
total_found = 0
total_downloaded = 0
total_errors = 0
all_markdown_refs: list[str] = []
if args.batch_file:
entries = parse_batch_file(Path(args.batch_file))
for doc_name, url in entries:
print(f"\n[{doc_name}] {url}")
result = process_document(url, doc_name, output_dir, cookies, dry_run=args.dry_run)
total_found += result["found"]
total_downloaded += result["downloaded"]
total_errors += result["errors"]
all_markdown_refs.extend(result["markdown_refs"])
else:
print(f"\n[{args.doc_name}] {args.url}")
result = process_document(args.url, args.doc_name, output_dir, cookies, dry_run=args.dry_run)
total_found = result["found"]
total_downloaded = result["downloaded"]
total_errors = result["errors"]
all_markdown_refs = result["markdown_refs"]
if all_markdown_refs:
print("\n--- Markdown references ---")
for ref in all_markdown_refs:
print(ref)
print("\n--- Summary ---")
print(f"Images found: {total_found}")
print(f"Images downloaded: {total_downloaded}")
print(f"Errors: {total_errors}")
return 0 if total_errors == 0 else 1
if __name__ == "__main__":
sys.exit(main())
// feishu_dom_capture.js — Injectable DOM capture script for Feishu/Lark documents.
// Inject via chrome-devtools evaluate_script or Browser Use javascript_tool.
// After injection, call window.__feishuCapture.run() to execute the full pipeline.
// Result: window.__feishuCapture.manifest (JSON) and window.__feishuCapture.markdown (string).
(() => {
'use strict';
// ── Noise patterns (Feishu UI chrome that leaks into innerText) ──
const NOISE_EXACT = new Set([
'Unable to print', 'Group card (Log in to view)', 'Group card',
'Copy', 'Code block', '代码块',
'Plain Text', 'Shell', 'JSON', 'Bash', 'TypeScript', 'JavaScript',
]);
const NOISE_RE = /^Unable to print|^Group card|^Modified [A-Z][a-z]+ \d+/;
function stripNoise(s) {
if (typeof s !== 'string') return s;
return s
.replace(/Unable to print[^\s]*(\d+%)?/g, '')
.replace(/Group card \(Log in to view\)/g, '')
.replace(/Group card/g, '')
.replace(/Modified [A-Z][a-z]+ \d+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function isNoise(text) {
if (!text) return true;
if (NOISE_EXACT.has(text)) return true;
if (NOISE_RE.test(text)) return true;
return false;
}
// ── Inline markdown: convert DOM inline tags to markdown syntax ──
function inlineMarkdown(node) {
let result = '';
for (const child of node.childNodes) {
if (child.nodeType === 3) {
result += child.textContent;
} else if (child.nodeType === 1) {
const tag = child.tagName.toLowerCase();
if (tag === 'br') { result += '\n'; continue; }
const inner = inlineMarkdown(child);
if (tag === 'b' || tag === 'strong') result += `**${inner}**`;
else if (tag === 'i' || tag === 'em') result += `*${inner}*`;
else if (tag === 'u') result += `<u>${inner}</u>`;
else if (tag === 'code' && !child.parentElement?.querySelector('pre')) result += `\`${inner}\``;
else if (tag === 'a' && child.getAttribute('href')) result += `[${inner}](${child.getAttribute('href')})`;
else result += inner;
}
}
return result.replace(/[]/g, '');
}
// ── Table / bullet helpers ──
function isInsideTable(el) {
return !!el.parentElement?.closest('.docx-table-block, .table-block');
}
function extractBullets(el, depth = 0) {
const results = [];
const listContent = el.querySelector(':scope > .list-wrapper, :scope > .list-content, :scope > .ace-line');
let textNode = listContent;
if (!textNode) {
const aceLines = el.querySelectorAll('.ace-line');
for (const a of aceLines) {
if (a.closest('.docx-bullet-block, .docx-list-block') === el) { textNode = a; break; }
}
}
if (textNode) {
const text = inlineMarkdown(textNode).replace(/^[•◦·]\s*/, '').trim();
if (text) results.push({ depth, text });
}
const allNested = el.querySelectorAll('.docx-bullet-block, .docx-list-block');
const directChildren = Array.from(allNested).filter(b => {
const parent = b.parentElement?.closest('.docx-bullet-block, .docx-list-block');
return b !== el && parent === el;
});
directChildren.forEach(child => results.push(...extractBullets(child, depth + 1)));
return results;
}
function extractTable(tableBlock) {
const rows = [];
tableBlock.querySelectorAll('tr, .docx-table-tr').forEach(rowEl => {
const cells = Array.from(rowEl.querySelectorAll('td, .table-cell-block, .docx-table_cell-block, [class*="table-cell"]'))
.map(c => (c.innerText || '').replace(/[\n]/g, ' ').trim());
if (cells.length > 0) rows.push(cells);
});
return rows;
}
// ── Core capture ──
const capturedBlocks = new Map();
function captureVisibleBlocks() {
const blocks = document.querySelectorAll('.block');
let newCount = 0;
for (const block of blocks) {
const bid = block.getAttribute('data-block-id');
if (!bid || capturedBlocks.has(bid)) continue;
if (isInsideTable(block) && !block.className.includes('docx-table-block')) continue;
const parentBullet = block.parentElement?.closest('.docx-bullet-block, .docx-list-block');
if ((block.className.includes('docx-bullet-block') || block.className.includes('docx-list-block')) && parentBullet) continue;
// Skip quote container child render units (they duplicate the container's content)
if (block.className.includes('quote-container-render-unit')) continue;
const cls = block.className || '';
const text = (block.innerText || '').replace(/[]/g, '').trim();
let type = 'text', payload = null;
if (cls.includes('docx-heading1-block')) { type = 'h1'; payload = text; }
else if (cls.includes('docx-heading2-block')) { type = 'h2'; payload = text; }
else if (cls.includes('docx-heading3-block')) { type = 'h3'; payload = text; }
else if (cls.includes('docx-heading4-block')) { type = 'h4'; payload = text; }
else if (cls.includes('docx-table-block')) { type = 'table'; payload = extractTable(block); }
else if (cls.includes('docx-bullet-block') || cls.includes('docx-list-block')) { type = 'bullets'; payload = extractBullets(block, 0); }
else if (cls.includes('docx-code-block')) {
type = 'code';
const langEl = block.querySelector('[class*="lang"], [class*="language"]');
payload = { lang: langEl?.innerText?.trim() || '', text };
} else if (cls.includes('docx-quote_container-block') || cls.includes('docx-quote-block') || cls.includes('docx-callout-block')) {
type = 'quote'; payload = inlineMarkdown(block).trim();
} else if (cls.includes('docx-image-block') || cls.includes('docx-image')) {
type = 'image';
const img = block.querySelector('img');
payload = { src: img?.src || '', alt: img?.alt || '' };
} else if (cls.includes('docx-divider-block')) {
type = 'divider'; payload = '---';
} else {
payload = inlineMarkdown(block).trim();
if (!payload) continue;
}
capturedBlocks.set(bid, { id: bid, idNum: parseInt(bid, 10), type, payload });
newCount++;
}
return { newCount, total: capturedBlocks.size };
}
// ── TOC-driven capture loop ──
async function tocDrivenCapture() {
const sleep = ms => new Promise(r => setTimeout(r, ms));
const tocItems = Array.from(document.querySelectorAll('.catalogue__list-item'));
const scrollContainer = document.querySelector('.bear-web-x-container, .page-main, .content-scroller, [class*="docx-width"]');
for (const item of tocItems) {
const clickTarget = item.querySelector('a, button, [role="button"]') || item;
clickTarget.click();
await sleep(800);
captureVisibleBlocks();
if (scrollContainer) {
for (let s = 0; s < 3; s++) {
scrollContainer.scrollBy(0, scrollContainer.clientHeight * 0.6);
await sleep(400);
captureVisibleBlocks();
}
}
}
// Final sweep
if (scrollContainer) {
for (let s = 0; s < 8; s++) {
scrollContainer.scrollBy(0, scrollContainer.clientHeight * 0.8);
await sleep(500);
captureVisibleBlocks();
}
}
}
// ── Image download via fetch + session cookie ──
async function downloadImages(docName = 'doc') {
const imageBlocks = Array.from(capturedBlocks.values()).filter(b => b.type === 'image' && b.payload?.src);
const downloaded = [];
for (let i = 0; i < imageBlocks.length; i++) {
const src = imageBlocks[i].payload.src;
if (src.startsWith('blob:') || src.startsWith('data:')) continue;
try {
const resp = await fetch(src, { credentials: 'include' });
if (!resp.ok) { downloaded.push({ i, src: src.substring(0, 80), ok: false }); continue; }
const contentType = resp.headers.get('content-type') || 'image/png';
const blob = await resp.blob();
const reader = new FileReader();
const dataUrl = await new Promise(resolve => {
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
const ext = contentType.includes('gif') ? 'gif' : contentType.includes('jpeg') ? 'jpg' : 'png';
// Per-document naming: never share generic img-0.png across documents
const safeName = docName.replace(/[^a-zA-Z0-9一-龥_-]/g, '').substring(0, 40);
imageBlocks[i].payload.localName = `${safeName}-${i}.${ext}`;
imageBlocks[i].payload.dataUrl = dataUrl;
imageBlocks[i].payload.size = blob.size;
downloaded.push({ i, ext, size: blob.size, ok: true });
} catch (e) {
downloaded.push({ i, error: e.message, ok: false });
}
}
return downloaded;
}
// ── Clean + deduplicate + sort ──
function cleanAndSort() {
const all = Array.from(capturedBlocks.values());
all.sort((a, b) => a.idNum - b.idNum);
// Build covered-text set for dedup
const coveredTexts = new Set();
for (const b of all) {
if (b.type === 'table' && Array.isArray(b.payload))
b.payload.forEach(row => row.forEach(cell => { if (cell.trim()) coveredTexts.add(cell.trim()); }));
if (b.type === 'bullets' && Array.isArray(b.payload))
b.payload.forEach(item => { if (item.text?.trim()) coveredTexts.add(item.text.trim()); });
if (b.type === 'quote' && typeof b.payload === 'string' && b.payload.trim())
coveredTexts.add(stripNoise(b.payload));
}
const seenText = new Set();
return all.filter(b => {
// Drop aggregation artifacts (> 350 chars text blocks are page-main innerText lumps)
if (b.type === 'text' && typeof b.payload === 'string' && b.payload.length > 350) return false;
// Drop empty bullets
if (b.type === 'bullets' && (!Array.isArray(b.payload) || b.payload.length === 0 || b.payload.every(it => !it.text?.trim()))) return false;
const text = typeof b.payload === 'string' ? stripNoise(b.payload) : '';
if (b.type === 'text' && (!text || text.length < 3)) return false;
if (b.type === 'text' && isNoise(text)) return false;
// Dedup: text covered by quote/table/bullets
if (b.type === 'text' && coveredTexts.has(text)) return false;
// Dedup by exact content
let key = null;
if (b.type === 'text' || b.type === 'quote') key = b.type + ':' + text;
else if (b.type === 'bullets' && Array.isArray(b.payload)) key = 'bullets:' + b.payload.map(it => stripNoise(it.text)).join('|');
else if (b.type === 'image' && b.payload?.src) key = 'image:' + b.payload.src;
else if (b.type === 'code' && b.payload?.text) key = 'code:' + b.payload.text.trim();
else if (b.type.startsWith('h')) key = b.type + ':' + b.payload;
if (key && seenText.has(key)) return false;
if (key) seenText.add(key);
// Clean code-block noise
if (b.type === 'code' && b.payload?.text) {
let t = b.payload.text;
t = t.split('\n').filter(l => !['Copy', 'Code block', '代码块'].includes(l.trim())).join('\n');
t = t.replace(/^(plaintext|shell|bash|json|typescript|javascript|python)\s*\n/i, m => {
if (!b.payload.lang) b.payload.lang = m.trim().toLowerCase();
return '';
});
b.payload.text = t.trim();
}
return true;
});
}
// ── Build manifest JSON ──
function buildManifest(blocks, meta = {}) {
const sections = [];
let currentSection = null;
const levelMap = { h1: 2, h2: 3, h3: 4, h4: 5 };
for (const b of blocks) {
if (levelMap[b.type]) {
currentSection = { heading_level: levelMap[b.type], heading: (b.payload || '').trim(), body: [] };
sections.push(currentSection);
continue;
}
if (!currentSection) continue;
const text = typeof b.payload === 'string' ? stripNoise(b.payload) : '';
if (b.type === 'text' && text) currentSection.body.push(text);
else if (b.type === 'quote' && text) currentSection.body.push('> ' + text);
else if (b.type === 'bullets' && Array.isArray(b.payload)) {
for (const item of b.payload) {
const bt = stripNoise(item.text || '').replace(/^[•◦·]\s*/, '');
if (bt) currentSection.body.push(' '.repeat(item.depth) + '- ' + bt);
}
} else if (b.type === 'code' && b.payload?.text) {
const lang = (b.payload.lang || '').toLowerCase().replace(/[^a-z0-9]/g, '');
currentSection.body.push('```' + lang + '\n' + b.payload.text + '\n```');
} else if (b.type === 'table' && Array.isArray(b.payload) && b.payload.length > 0) {
const rows = b.payload;
currentSection.body.push('| ' + rows[0].join(' | ') + ' |');
currentSection.body.push('|' + rows[0].map(() => '---').join('|') + '|');
for (let i = 1; i < rows.length; i++) currentSection.body.push('| ' + rows[i].join(' | ') + ' |');
} else if (b.type === 'image' && b.payload?.localName) {
currentSection.body.push(``);
} else if (b.type === 'image' && b.payload?.src && !b.payload.src.startsWith('blob:')) {
currentSection.body.push(``);
} else if (b.type === 'divider') {
currentSection.body.push('---');
}
}
return {
title: meta.title || document.title.replace(/ - Feishu Docs$/, '').trim(),
source: meta.source || location.href,
author: meta.author || [],
published: meta.published || '',
created: new Date().toISOString().substring(0, 10),
description: meta.description || '',
tags: meta.tags || [],
sections,
};
}
// ── Public API ──
window.__feishuCapture = {
capturedBlocks,
captureVisibleBlocks,
tocDrivenCapture,
downloadImages,
cleanAndSort,
buildManifest,
stripNoise,
inlineMarkdown,
async run(meta = {}) {
capturedBlocks.clear();
captureVisibleBlocks();
await tocDrivenCapture();
const docName = meta.docName || meta.title || document.title.replace(/ - Feishu Docs$/, '').trim() || 'doc';
const imgResults = await downloadImages(docName);
const cleaned = cleanAndSort();
const manifest = buildManifest(cleaned, meta);
this.manifest = manifest;
this.cleanedBlocks = cleaned;
this.imageResults = imgResults;
return {
totalCaptured: capturedBlocks.size,
afterClean: cleaned.length,
sections: manifest.sections.length,
images: imgResults.length,
imagesOk: imgResults.filter(r => r.ok).length,
};
},
};
})();
#!/usr/bin/env python3
"""Enumerate every rich-media reference in a fetched Feishu Markdown body.
This is the recursion engine's core for Path A (lark-cli API extraction). A
collection/hub is a doc whose body references other docs; missing one
reference means a missing document — the single biggest hub-scraping failure.
Hand-rolled `grep | sed` pipelines repeatedly missed the `my.feishu.cn`
personal-space pattern, so this enumeration is centralized and tested here.
Input : a Markdown file produced by `lark-cli docs +fetch ... | jq -r .data.markdown`.
Output: JSON array on stdout, one object per *distinct* reference:
{"type": ..., "ref": <token-or-url>, "title": ..., "dispatch": <hint>}
plus a human summary on stderr.
It only *enumerates*. Dispatching/fetching each reference is the caller's job
(see references/lark-cli-api-extraction.md, Step 5 dispatch table).
Usage:
python3 feishu_extract_refs.py FETCHED_BODY.md
python3 feishu_extract_refs.py FETCHED_BODY.md --type docx # filter
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
# Feishu/Lark hosts. feishu.cn = mainland tenants + my.feishu.cn personal space;
# larksuite.com = international Lark. Both serve the same /docx /wiki /sheets
# /minutes /base /file path scheme.
_HOST = r"[a-z0-9-]+\.(?:feishu\.cn|larksuite\.com)"
# Inline rich-media tags emitted by `docs +fetch` Markdown.
RE_MENTION_DOC = re.compile(
r'<mention-doc\s+token="([^"]+)"\s+type="([^"]+)"\s*>([^<]*)</mention-doc>'
)
RE_SHEET_TAG = re.compile(r'<sheet\s+token="([^"]+)"\s*/?>')
RE_IMAGE_TAG = re.compile(r'<image\s+token="([^"]+)"')
RE_FILE_TAG = re.compile(r'<file\s+token="([^"]+)"[^>]*>([^<]*)</file>')
RE_LARK_TABLE = re.compile(r"<lark-table\b")
# URLs that appear in the body (cross-tenant / personal space / minutes /
# Tencent Meeting). One regex covers mainland, international and personal
# (my.feishu.cn) because the host group accepts any sub-domain.
RE_FEISHU_URL = re.compile(
r"https://(" + _HOST + r")/(docx|wiki|sheets|base|file|minutes)/([A-Za-z0-9]+)"
)
RE_TENCENT_MEETING = re.compile(
r"https://meeting\.tencent\.com/crm/([A-Za-z0-9]+)"
)
# How the caller should handle each type (mirrors the reference's dispatch
# table, surfaced here so the caller does not have to re-derive it).
DISPATCH = {
"mention-doc-docx": "docs +fetch --doc <token>",
"mention-doc-wiki": "wiki spaces get_node then docs +fetch",
"mention-doc-sheet": "sheets +read",
"url-docx": "docs +fetch --doc <token>",
"url-wiki": "wiki spaces get_node then docs +fetch",
"url-sheets": "sheets +read (split token on '_' -> SP, SID)",
"url-base": "Bitable API (outside this skill) — record token",
"url-file": "attachment — record token + name; treat like image gap",
"url-minutes": "native transcript API (feishu-minutes-transcript.md)",
"sheet-tag": "sheets +read (split token on '_' -> SP, SID)",
"image": "register token; lark-cli cannot download docx images",
"file": "attachment — record token + name; treat like image gap",
"tencent-meeting": "Tencent Meeting native transcript (never download+re-ASR)",
"lark-table": "inline content — render in place to a Markdown table",
}
def _read_text(path: Path) -> str:
"""Read the body strictly as UTF-8.
We deliberately do NOT use errors='replace': a decode failure means an
upstream step corrupted the text, and the skill's acceptance contract
checks for U+FFFD. Masking it here would hide exactly the failure the
pipeline is trying to detect, so fail loudly instead.
"""
try:
raw = path.read_bytes()
except FileNotFoundError:
sys.exit(f"error: file not found: {path}")
except PermissionError:
sys.exit(f"error: cannot read (permission): {path}")
if not raw.strip():
sys.exit(f"error: file is empty: {path} (fetch likely failed upstream)")
try:
return raw.decode("utf-8")
except UnicodeDecodeError as exc:
sys.exit(
f"error: {path} is not valid UTF-8 ({exc}); an upstream extraction "
f"step corrupted the body — re-fetch with `lark-cli docs +fetch "
f"--format json` and `jq -r .data.markdown`, do not 'fix' encoding here."
)
def extract(text: str) -> list[dict]:
refs: list[dict] = []
for token, doc_type, title in RE_MENTION_DOC.findall(text):
t = doc_type.strip().lower()
kind = "mention-doc-sheet" if t in ("sheet", "bitable") else (
"mention-doc-wiki" if t == "wiki" else "mention-doc-docx"
)
refs.append({
"type": kind,
"ref": token,
"title": title.strip(),
"dispatch": DISPATCH[kind],
})
for token in RE_SHEET_TAG.findall(text):
refs.append({
"type": "sheet-tag",
"ref": token,
"title": "",
"dispatch": DISPATCH["sheet-tag"],
})
for token in RE_IMAGE_TAG.findall(text):
refs.append({
"type": "image",
"ref": token,
"title": "",
"dispatch": DISPATCH["image"],
})
for token, name in RE_FILE_TAG.findall(text):
refs.append({
"type": "file",
"ref": token,
"title": name.strip(),
"dispatch": DISPATCH["file"],
})
for host, seg, token in RE_FEISHU_URL.findall(text):
kind = f"url-{seg}"
refs.append({
"type": kind,
"ref": f"https://{host}/{seg}/{token}",
"title": "",
"dispatch": DISPATCH.get(kind, "record token"),
})
for mid in RE_TENCENT_MEETING.findall(text):
refs.append({
"type": "tencent-meeting",
"ref": f"https://meeting.tencent.com/crm/{mid}",
"title": "",
"dispatch": DISPATCH["tencent-meeting"],
})
n_tables = len(RE_LARK_TABLE.findall(text))
if n_tables:
# Inline content, not a link to follow — surfaced so the caller knows
# to render it in place (pandas.read_html handles colspan/rowspan).
refs.append({
"type": "lark-table",
"ref": f"(inline x{n_tables})",
"title": "",
"dispatch": DISPATCH["lark-table"],
})
# De-duplicate on (type, ref); keep first title seen.
seen: dict[tuple[str, str], dict] = {}
for r in refs:
key = (r["type"], r["ref"])
if key not in seen:
seen[key] = r
return list(seen.values())
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("markdown_file", help="fetched Feishu body (.md)")
ap.add_argument("--type", help="only emit refs of this type (e.g. docx, image)")
args = ap.parse_args()
text = _read_text(Path(args.markdown_file))
refs = extract(text)
if args.type:
refs = [r for r in refs if args.type in r["type"]]
json.dump(refs, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
# Summary to stderr so stdout stays pure JSON for piping.
by_type: dict[str, int] = {}
for r in refs:
by_type[r["type"]] = by_type.get(r["type"], 0) + 1
if by_type:
summary = ", ".join(f"{k}={v}" for k, v in sorted(by_type.items()))
print(f"[feishu_extract_refs] {len(refs)} distinct refs: {summary}",
file=sys.stderr)
else:
print("[feishu_extract_refs] no references found — this is a leaf doc "
"(nothing further to recurse).", file=sys.stderr)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Restore heading hierarchy and highlights lost when pandoc converts a
Feishu-exported .docx (Path B).
Feishu-exported docx does not use Word heading styles — it lays out headings
with font size + bold on normal paragraphs, and marks emphasis with run
shading (`w:shd@fill`), not `w:highlight`. pandoc therefore produces zero
Markdown headings (every heading becomes flat `**bold**`) and drops every
highlight. A text-level check ("no errors, word count matches") passes while
the document's entire structure is gone — only visual verification catches it.
This script repairs the pandoc Markdown WITHOUT retyping the body:
* heading levels are derived from the docx's own font-size distribution
(largest sizes -> H1..Hn, descending) and applied as `#` prefixes;
* run shading fills are restored as Obsidian `==highlight==`.
Body text is never reconstructed — only `#` prefixes and `==` wrappers are
added to the existing pandoc lines. This keeps the API/pandoc text byte-exact
(the fidelity invariant) while giving back the structure a human sees.
Usage:
python3 restore_docx_headings.py --docx SRC.docx --md PANDOC.md --out FINAL.md
python3 restore_docx_headings.py --docx SRC.docx --md PANDOC.md --dry-run
`--dry-run` prints the derived size->level mapping and match counts without
writing — verify the plan before applying it (plan / validate / execute).
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import Counter
from pathlib import Path
try:
from docx import Document
from docx.oxml.ns import qn
except ModuleNotFoundError:
sys.exit(
"error: python-docx is not installed.\n"
" run with uv: uv run --with python-docx python3 "
"scripts/restore_docx_headings.py ...\n"
" or: pip install python-docx"
)
# Run-shading fills that are page/background, not emphasis. Everything else
# applied at run level by Feishu is an intentional highlight. Deriving
# "highlight = any non-background run fill" from the document avoids
# hard-coding specific colors; the values verified in practice were
# ffe928 (yellow) and 935af6 (purple) — kept here only as the known examples,
# not as a closed allow-list.
_BACKGROUND_FILLS = {"auto", "ffffff", "000000", ""}
_ZERO_WIDTH = ""
def _norm(s: str) -> str:
"""Normalize a line for cross-format text matching.
pandoc may wrap a heading as `**text**`; the source paragraph is `text`.
Strip emphasis/heading markers, zero-width chars, and collapse whitespace
so the same logical line matches across the two representations.
"""
s = s.translate({ord(c): None for c in _ZERO_WIDTH})
s = re.sub(r"[*_#`]", "", s)
s = re.sub(r"\s+", " ", s)
return s.strip()
def _doc_default_pt(doc) -> float:
"""Resolve the document's default body point size.
Critical: body paragraphs in a Feishu/pandoc docx frequently carry NO
explicit run size — they inherit from the Normal style or docDefaults.
If such paragraphs are bucketed as "unknown" and excluded, the modal
size becomes a *heading* size and every real heading is demoted to body
(verified failure). So every paragraph must get a numeric size, falling
back to this resolved default, so the modal size is the true body size.
Resolution order: Normal style -> docDefaults rPr sz -> 11.0pt.
11.0pt is the de-facto Word default for the .docx era (Calibri 11); it
is only the last resort when the file declares no default at all.
"""
try:
sz = doc.styles["Normal"].font.size
if sz is not None:
return sz.pt
except (KeyError, AttributeError, ValueError):
pass
try:
sz_el = doc.styles.element.find(
qn("w:docDefaults") + "/" + qn("w:rPrDefault")
+ "/" + qn("w:rPr") + "/" + qn("w:sz")
)
if sz_el is not None:
val = sz_el.get(qn("w:val"))
if val:
return int(val) / 2.0 # OOXML sz is in half-points
except (AttributeError, ValueError, TypeError):
pass
return 11.0
def _para_font_pt(para, default_pt: float) -> float:
"""Effective point size of a paragraph — never None.
Headings here have all runs at one large size. Take the max run size;
fall back to the paragraph style's size; finally to the resolved
document default so unsized body paragraphs land in the body bucket
(not the 'unknown' void that corrupts the modal-size heuristic).
"""
sizes = [r.font.size.pt for r in para.runs if r.font.size is not None]
if sizes:
return max(sizes)
try:
if para.style and para.style.font and para.style.font.size:
return para.style.font.size.pt
except (AttributeError, ValueError):
pass
return default_pt
def _run_highlight_fill(run) -> str | None:
"""Return the run's shading fill if it is an emphasis highlight, else None."""
rpr = run._element.rPr
if rpr is None:
return None
shd = rpr.find(qn("w:shd"))
if shd is None:
return None
fill = (shd.get(qn("w:fill")) or "").lower()
if fill in _BACKGROUND_FILLS:
return None
return fill
def build_plan(docx_path: Path):
"""Walk the docx once, returning the heading plan and highlight plan.
heading_plan : list of (normalized_text, level, raw_text) in doc order
highlight_plan: list of (normalized_text, [run_text, ...]) in doc order
size_to_level: derived mapping for --dry-run reporting
"""
try:
doc = Document(str(docx_path))
except Exception as exc: # python-docx raises various errors for bad files
sys.exit(f"error: cannot open docx ({exc}). Confirm with `file -b`; an "
f"exported .docx is sometimes mislabeled.")
paras = list(doc.paragraphs)
default_pt = _doc_default_pt(doc)
# Every non-empty paragraph gets a numeric size (unsized -> resolved
# default), so the modal size is the true body size. Sizes strictly
# larger than body, descending, become H1..Hn.
size_counts = Counter(
round(_para_font_pt(p, default_pt), 1)
for p in paras if p.text.strip()
)
if not size_counts:
# No text paragraphs at all — nothing to restore; let the caller
# pass the markdown through unchanged rather than abort.
print("[restore] no text paragraphs in docx — passthrough.",
file=sys.stderr)
return [], [], {}, default_pt
body_size = size_counts.most_common(1)[0][0]
heading_sizes = sorted((s for s in size_counts if s > body_size), reverse=True)
size_to_level = {s: i + 1 for i, s in enumerate(heading_sizes)}
if not size_to_level:
# One distinct size only: the doc has no font-size heading hierarchy
# (it likely already uses Word heading styles, which doc-to-markdown
# converts natively). Highlights may still need restoring, so warn
# and continue rather than exit.
print(f"[restore] no font-size hierarchy above body {body_size}pt — "
f"doc likely uses Word heading styles already; restoring "
f"highlights only.", file=sys.stderr)
heading_plan, highlight_plan = [], []
for p in paras:
text = p.text.strip()
if not text:
continue
lvl = size_to_level.get(round(_para_font_pt(p, default_pt), 1))
if lvl:
heading_plan.append((_norm(text), lvl, text))
hi = [r.text for r in p.runs
if r.text.strip() and _run_highlight_fill(r) is not None]
if hi:
highlight_plan.append((_norm(text), hi))
return heading_plan, highlight_plan, size_to_level, body_size
def apply_plan(md_lines, heading_plan, highlight_plan):
"""Apply heading prefixes and highlight wrappers to the pandoc lines.
Matching is by normalized text, in document order, with a forward-only
cursor so repeated identical strings map to successive occurrences.
Returns (new_lines, n_headings_applied, n_unmatched_headings,
n_highlights_applied).
"""
norm_lines = [_norm(l) for l in md_lines]
out = list(md_lines)
cursor = 0
applied_h = unmatched_h = 0
for ntext, level, _raw in heading_plan:
if not ntext:
continue
found = -1
for i in range(cursor, len(out)):
if norm_lines[i] == ntext:
found = i
break
if found == -1:
unmatched_h += 1
continue
# Replace the whole line with a clean heading — drop pandoc's bold
# since a heading must not also be `**...**`.
out[found] = "#" * level + " " + ntext
norm_lines[found] = ntext # keep in sync for subsequent matches
cursor = found + 1
applied_h += 1
cursor = 0
applied_hl = 0
for ntext, run_texts in highlight_plan:
if not ntext:
continue
found = -1
for i in range(cursor, len(out)):
if norm_lines[i] == ntext:
found = i
break
if found == -1:
continue
line = out[found]
for rt in run_texts:
rt = rt.strip()
if not rt or rt not in line:
continue
if ("==" + rt + "==") in line: # already wrapped
continue
line = line.replace(rt, "==" + rt + "==", 1)
out[found] = line
cursor = found + 1
applied_hl += 1
return out, applied_h, unmatched_h, applied_hl
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--docx", required=True, help="the owner-exported source .docx")
ap.add_argument("--md", required=True, help="first-pass pandoc/doc-to-markdown .md")
ap.add_argument("--out", help="output path (required unless --dry-run)")
ap.add_argument("--dry-run", action="store_true",
help="print the size->level mapping and counts; do not write")
args = ap.parse_args()
docx_path, md_path = Path(args.docx), Path(args.md)
if not docx_path.exists():
sys.exit(f"error: docx not found: {docx_path}")
if not md_path.exists():
sys.exit(f"error: markdown not found: {md_path}")
if not args.dry_run and not args.out:
sys.exit("error: --out is required unless --dry-run")
heading_plan, highlight_plan, size_to_level, body_size = build_plan(docx_path)
print(f"[restore] body size = {body_size}pt (normal text)", file=sys.stderr)
for sz, lvl in sorted(size_to_level.items(), key=lambda kv: -kv[0]):
n = sum(1 for t in heading_plan if t[1] == lvl)
print(f"[restore] {sz}pt -> H{lvl} ({n} paragraphs)", file=sys.stderr)
print(f"[restore] {len(highlight_plan)} paragraphs carry run highlights",
file=sys.stderr)
md_lines = md_path.read_text(encoding="utf-8").splitlines()
new_lines, applied_h, unmatched_h, applied_hl = apply_plan(
md_lines, heading_plan, highlight_plan
)
print(f"[restore] headings applied={applied_h} unmatched={unmatched_h}; "
f"highlight lines applied={applied_hl}", file=sys.stderr)
if unmatched_h:
print(f"[restore] WARNING: {unmatched_h} heading paragraph(s) had no "
f"matching Markdown line — inspect the source vs pandoc output "
f"for those (often a table caption or an image-only paragraph).",
file=sys.stderr)
if args.dry_run:
print("[restore] dry-run: nothing written.", file=sys.stderr)
return
out_path = Path(args.out)
out_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
print(f"[restore] wrote {out_path}. Next: visually verify against the docx "
f"render (qlmanage / soffice --convert-to pdf) before accepting.",
file=sys.stderr)
if __name__ == "__main__":
main()
Related skills
FAQ
What does feishu-doc-scraper extract from Lark?
feishu-doc-scraper pulls structured text and metadata from Feishu/Lark documents. Developers use the output in repositories, issue tickets, or RAG corpora when specs live behind enterprise collaboration walls.
When should engineers use feishu-doc-scraper?
feishu-doc-scraper fits when PRDs, API contracts, or runbooks exist only in Feishu/Lark. Run it before implementation or RAG indexing so agents and git-hosted docs reference the same authoritative content.