Initial commit: company-kb framework

- 完整的知识库框架(P0-P5 路线图)
- 12篇治理文档
- 工具脚本(kb-init.sh, kb-lint-fm.py, feishu-sync.py, kb-bot)
- 7个 Claude 命令
- 示例项目和测试项目(wanniu-l1)
- 契约文件(meta/kb-contract.yaml)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-07-16 18:24:39 +08:00
commit 84f7950589
60 changed files with 4410 additions and 0 deletions

576
tools/feishu-sync.py Executable file
View File

@@ -0,0 +1,576 @@
#!/usr/bin/env python3
"""feishu-sync.py —— 飞书文件夹递归镜像到本地(飞书 → 本地目录,单向)
把飞书云盘里一个文件夹(含所有子文件夹)完整拉到本地:
- docx 云文档 → markdown 正文 + 图片下载到本地 assets/ + 图链接改本地路径 + 补 frontmatter
- sheet/bitable → 导出 xlsx在线表格
- file 上传件 → 原样下载pdf/xmind/zip/视频…)
- mindnote 在线思维导图 → 跳过(拉它旁边导出的 .pdf/.xmind 即可)
- shortcut 快捷方式 → 跳过(只是指向别处的链接,无实际内容)
凭据由 lark-cli 自管lark-cli auth login本脚本不碰任何密钥。
用法:
# 干跑(默认):只列出将要拉什么,不落盘
python3 tools/feishu-sync.py --folder <飞书文件夹token> --out <本地目录>
# 实际写盘
python3 tools/feishu-sync.py --folder <token> --out <本地目录> --write
去重:每篇内容算 sha256 记进 <out>/.feishu-sync-manifest.json没变过下次跳过。
"""
import argparse
import datetime
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
# 飞书导出 markdown 里图片用的临时下载域名(会过期,需替换成本地路径)
STREAM_HOST = "internal-api-drive-stream.feishu.cn"
# 垃圾文件名:飞书/Mac 同步产生,不拉
JUNK_NAMES = {".DS_Store", "DS_Store", ".ds_store", "Thumbs.db",
"desktop.ini", ".localized"}
# 大文件扩展名(视频/音频/压缩包/大 PPT--skip-big 时跳过。
# 这类无法转 markdown、体积大飞书列表不返回体积按扩展名判断最稳
# 避免每个文件多发一次探测请求。
BIG_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".wmv", ".flv", ".m4v",
".mp3", ".wav", ".zip", ".rar", ".7z", ".iso",
".pptx", ".ppt"}
# 每篇文档 URL 前缀按类型拼
DOC_URL = {
"docx": "/docx/",
"doc": "/doc/",
"sheet": "/sheets/",
"bitable": "/base/",
"mindnote": "/mindnotes/",
"slides": "/slides/",
}
def find_lark_cli():
"""定位 lark-cliPATH 找不到就试常见位置。"""
p = shutil.which("lark-cli")
if p:
return p
for cand in [
os.path.expanduser("~/.npm-global/bin/lark-cli"),
os.path.expanduser("~/.nvm/versions/node/v22.17.1/bin/lark-cli"),
]:
if os.path.isfile(cand):
return cand
# 最后从 nvm 目录里扫一个
nvm = os.path.expanduser("~/.nvm/versions/node")
if os.path.isdir(nvm):
for v in sorted(os.listdir(nvm), reverse=True):
cand = os.path.join(nvm, v, "bin", "lark-cli")
if os.path.isfile(cand):
return cand
sys.exit("找不到 lark-cli请先安装并 `lark-cli auth login`。")
LARK = find_lark_cli()
def lark(args, binary_out=None, timeout=60, cwd=None):
"""跑 lark-cli返回解析后的 JSONbinary_out 模式返回 CompletedProcess
cwd下载/导出类命令要求 --output 是「相对当前目录」的路径,故传 cwd=目标目录,
配合 --output ./文件名 使用lark-cli 拒绝绝对路径)。
"""
cmd = [LARK] + args
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd)
if binary_out is not None:
return r
if not r.stdout.strip():
raise RuntimeError(f"lark-cli 无输出: {' '.join(args)}\n{r.stderr[:300]}")
try:
return json.loads(r.stdout)
except json.JSONDecodeError:
raise RuntimeError(f"lark-cli 输出非 JSON: {' '.join(args)}\n{r.stdout[:300]}")
def list_folder(folder_token):
"""列一个文件夹里的直接子项(自动翻页)。返回 [{name,type,token,created_time,url}...]
folder_token 为空或 "ROOT" 时列云盘根目录(不传 --folder-token飞书返回全部根目录项
"""
is_root = not folder_token or folder_token == "ROOT"
items = []
page_token = None
while True:
args = ["drive", "files", "list", "--page-size", "200", "--as", "user", "--json"]
if not is_root:
args += ["--folder-token", folder_token]
if page_token:
args += ["--page-token", page_token]
res = lark(args)
data = res.get("data", {})
items.extend(data.get("files", []))
if data.get("has_more") and data.get("next_page_token"):
page_token = data["next_page_token"]
else:
break
return items
def list_bitable_tables(app_token):
"""列一个多维表格的所有子表。返回 [{table_id, name}...]。失败返回空列表。"""
tables = []
page_token = None
while True:
params = {"page_size": 100}
if page_token:
params["page_token"] = page_token
try:
res = lark(["api", "GET",
f"/open-apis/bitable/v1/apps/{app_token}/tables",
"--params", json.dumps(params), "--as", "user", "--json"])
except RuntimeError:
return tables
data = res.get("data", {})
for it in data.get("items", []):
tables.append({"table_id": it.get("table_id"), "name": it.get("name", "")})
if data.get("has_more") and data.get("page_token"):
page_token = data["page_token"]
else:
break
return tables
def safe_name(name, token, ext=""):
"""把飞书文档名清成安全文件名;空名用 token。"""
name = (name or "").strip()
if not name:
name = token
name = re.sub(r'[/\\:*?"<>|]', "_", name)
name = name.strip(". ")[:120] or token
if ext and not name.lower().endswith(ext.lower()):
name += ext
return name
def fetch_docx(token):
"""拉一篇 docx返回 (markdown正文, [媒体 dict 按出现顺序])。
媒体 dict: {"token": file_token, "mime": mime}。同时覆盖两种飞书导出形态:
- 图片 → <img src="file_token" mime="image/..">
- 视频/文件嵌入 → <figure><source token="file_token" mime="video/..">
两者在 markdown 里分别渲染成 ![](过期链接) 和 <figure><source href=过期链接>
必须一起处理,否则视频类的过期链接会残留(违背「长久」)。
"""
md_res = lark(["docs", "+fetch", "--doc", token,
"--doc-format", "markdown", "--detail", "simple",
"--as", "user", "--json"], timeout=120)
md = md_res.get("data", {}).get("document", {}).get("content", "")
xml_res = lark(["docs", "+fetch", "--doc", token,
"--doc-format", "xml", "--detail", "full",
"--as", "user", "--json"], timeout=120)
xml = xml_res.get("data", {}).get("document", {}).get("content", "")
# 按出现顺序扫描 <img> 与 <source>figure 内),保持与 md 中链接顺序一致
media = []
for m in re.finditer(r'<(img|source)\b([^>]*)>', xml):
tag, attrs = m.group(1), m.group(2)
# img 的 file_token 在 src=source 的在 token=
if tag == "img":
tm = re.search(r'\bsrc="([^"]+)"', attrs)
else:
tm = re.search(r'\btoken="([^"]+)"', attrs)
if not tm:
continue
mm = re.search(r'\bmime="([^"]+)"', attrs)
mime = mm.group(1) if mm else ("image/png" if tag == "img" else "")
media.append({"token": tm.group(1), "mime": mime})
return md, media
def download_media(file_token, out_dir, basename):
"""下一张文档内图片,返回本地文件名(含扩展名),失败返回 None。
lark-cli 的 --output 只认「相对当前目录」的路径,故 cwd=out_dir + --output ./basename。
"""
os.makedirs(out_dir, exist_ok=True)
r = lark(["docs", "+media-download", "--token", file_token,
"--output", "./" + basename, "--overwrite", "--as", "user", "--json"],
timeout=120, cwd=out_dir)
saved = r.get("data", {}).get("saved_path")
if saved and os.path.isfile(saved):
return os.path.basename(saved)
# 兜底:目录里找 basename.*
for f in os.listdir(out_dir):
if f.startswith(os.path.basename(basename)):
return f
return None
def localize_images(md, media, assets_dir, assets_rel, doc_slug, write):
"""把 md 里会过期的飞书 stream 链接处理掉,保证不残留死链(长久性铁律)。
- 图片 (mime=image/*) → 下载到本地 assets/,链接改本地相对路径
- 视频/其它 (mime=video/* 等) → 不下载,整段换成 `> 📎 [媒体已省略:类型,见原文]`
- 任何形态的过期链接都在最后兜底清除,绝不残留。
media 与 md 中过期链接按出现顺序一一对应fetch_docx 已保证顺序)。
返回 (新md, 下载成功的图片数, 媒体总数)。
"""
img_got = 0
# 处理每个媒体:图片下载、非图片省略。按序消费 media逐个替换 md 中第一个未处理的过期链接。
def consume_next_link(new_text, replacement):
"""把 new_text 中第一个出现的过期 stream 链接(含其外层包装)替换掉。
覆盖飞书导出的三种媒体形态alt 用非贪婪匹配,兼容 alt 里含 ] [ 等字符。
"""
host = re.escape(STREAM_HOST)
pats = [
# 形态1markdown 图片 ![alt](stream_url)alt 非贪婪,兼容 alt 内含 ]
r'!\[.*?\]\(https://' + host + r'/[^)]+\)',
# 形态2<figure><source ... href="stream_url" .../></figure>
r'<figure\b[^>]*>\s*<source\b[^>]*\bhref="https://'
+ host + r'/[^"]+"[^>]*/?>\s*</figure>',
# 形态3HTML <img ... href="stream_url" .../>(表格单元格内常见)
r'<img\b[^>]*\bhref="https://' + host + r'/[^"]+"[^>]*/?>',
]
best = None
for p in pats:
m = re.search(p, new_text)
if m and (best is None or m.start() < best.start()):
best = m
if best is None:
return new_text, False
return new_text[:best.start()] + replacement + new_text[best.end():], True
new_md = md
for i, item in enumerate(media):
mime = (item.get("mime") or "").lower()
if mime.startswith("image/"):
ext = "." + mime.split("/", 1)[1].split("+")[0] if "/" in mime else ".png"
fname = f"{doc_slug}-img{i+1}"
if write:
got = download_media(item["token"], assets_dir, fname)
rep = f"![]({assets_rel}/{got})" if got else None
else:
rep = f"![]({assets_rel}/{fname}{ext})"
if rep:
new_md, ok = consume_next_link(new_md, rep)
if ok and write:
img_got += 1
# 下载失败则不消费,留给兜底清理
else:
kind = mime.split("/", 1)[0] if "/" in mime else "媒体"
note = f"> 📎 [已省略{kind}媒体,原文见 source_link]"
new_md, _ = consume_next_link(new_md, note)
# 兜底:清除任何仍残留的过期 stream 链接(各种包装形态),绝不留死链。
# 顺序从「包装最完整」到「最裸」,逐层收网;最后一条裸 URL catch-all 确保零残留。
NOTE = "> 📎 [已省略媒体,原文见 source_link]"
H = re.escape(STREAM_HOST)
# 形态A<figure><source href="stream"...></figure>
new_md = re.sub(
r'<figure\b[^>]*>\s*<source\b[^>]*\bhref="https://' + H
+ r'/[^"]+"[^>]*/?>\s*</figure>', NOTE, new_md)
# 形态BHTML <img ... src/href="stream" ...>(含表格 <td> 里的)
new_md = re.sub(
r'<img\b[^>]*\b(?:src|href)="https://' + H + r'/[^"]*"[^>]*/?>',
NOTE, new_md)
# 形态Cmarkdown ![alt](stream)。alt 用 [\s\S]*? 非贪婪,允许 alt 内含 ] 等特殊字符
new_md = re.sub(
r'!\[[\s\S]*?\]\(https://' + H + r'/[^)]+\)', NOTE, new_md)
# 形态D兜底 catch-all——任何裸露的 stream URL连同可能的 markdown 链接括号)
new_md = re.sub(r'\(https://' + H + r'/[^)]+\)', "(见原文)", new_md)
new_md = re.sub(r'https://' + H + r'/\S+', "〔媒体链接已省略〕", new_md)
return new_md, img_got, len(media)
def build_frontmatter(title, source_link, created_date):
today = datetime.date.today().isoformat()
title_esc = title.replace('"', "'")
return (
"---\n"
"type: doc\n"
f'title: "{title_esc}"\n'
"source: 飞书文档\n"
f"source_link: {source_link}\n"
f"created: {created_date}\n"
f"ingested: {today}\n"
"status: seed\n"
"tags: []\n"
"related: []\n"
"---\n\n"
)
def ts_to_date(ts):
try:
return datetime.datetime.fromtimestamp(int(ts)).date().isoformat()
except (ValueError, TypeError):
return datetime.date.today().isoformat()
def load_manifest(path):
if os.path.isfile(path):
try:
return json.load(open(path, encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
return {"_comment": "feishu-sync 去重台账key=内容sha256, value=本地相对路径", "entries": {}}
def save_manifest(path, m):
json.dump(m, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
# ── 统计 ──
STATS = {"docx": 0, "file": 0, "export": 0, "csv": 0, "skip": 0, "skipbig": 0, "dedup": 0, "img": 0, "fail": 0}
class ProgressList(list):
"""记录每条处理结果的列表LIVE=True 时 append 即实时打印并 flush
让同事在长时间同步中能看到逐条动态,而不是一片死寂。"""
LIVE = False
def append(self, line):
super().append(line)
if ProgressList.LIVE:
print(line, flush=True)
PLAN = ProgressList() # 干跑记录待办;写盘时实时打印进度
def handle_item(item, rel_path, out_root, host, manifest, write, skip_big=False):
typ = item.get("type")
name = item.get("name", "")
token = item.get("token")
created = ts_to_date(item.get("created_time"))
local_dir = os.path.join(out_root, rel_path) if rel_path else out_root
# 垃圾文件直接跳过(.DS_Store 等)
if name.strip() in JUNK_NAMES:
STATS["skip"] += 1
return
if typ == "shortcut":
STATS["skip"] += 1
PLAN.append(f" ⏭️ 跳过快捷方式: {rel_path}/{name}")
return
if typ == "mindnote":
STATS["skip"] += 1
PLAN.append(f" ⏭️ 跳过在线思维导图(拉旁边导出件): {rel_path}/{name}")
return
if typ == "docx":
slug = safe_name(name, token).rsplit(".", 1)[0]
fname = slug + ".md"
dest = os.path.join(local_dir, fname)
rel_dest = os.path.relpath(dest, out_root)
src_link = f"https://{host}{DOC_URL.get('docx','/docx/')}{token}"
if not write:
PLAN.append(f" 📄 docx → {rel_dest} (+图片到 assets/)")
STATS["docx"] += 1
return
try:
md, media = fetch_docx(token)
h = hashlib.sha256(md.encode("utf-8")).hexdigest()
if h in manifest["entries"]:
STATS["dedup"] += 1
PLAN.append(f" ♻️ 未变跳过: {rel_dest}")
return
assets_dir = os.path.join(local_dir, "assets")
new_md, got, total = localize_images(
md, media, assets_dir, "assets", slug, write=True)
STATS["img"] += got
title = name or slug
# H1 回填 title若正文首行是 # 标题,用它
m = re.match(r"^#\s+(.+)", new_md.strip())
if m:
title = m.group(1).strip()
os.makedirs(local_dir, exist_ok=True)
page = build_frontmatter(title, src_link, created) + new_md.strip() + "\n"
open(dest, "w", encoding="utf-8").write(page)
manifest["entries"][h] = rel_dest
STATS["docx"] += 1
PLAN.append(f"{rel_dest} (图 {got}/{total})")
except Exception as e:
STATS["fail"] += 1
PLAN.append(f" ❌ docx 失败 {rel_dest}: {str(e)[:120]}")
return
if typ == "sheet":
base_name = safe_name(name, token)
fname = base_name + ".xlsx"
dest = os.path.join(local_dir, fname)
rel_dest = os.path.relpath(dest, out_root)
if not write:
PLAN.append(f" 📊 sheet → {rel_dest} (导出 xlsx)")
STATS["export"] += 1
return
try:
os.makedirs(local_dir, exist_ok=True)
# lark-cli 的 --output-dir 也只认 cwd 内的相对路径,故 cwd=目标目录 + 相对 "."
lark(["drive", "+export", "--token", token, "--doc-type", "sheet",
"--file-extension", "xlsx", "--output-dir", ".",
"--file-name", base_name,
"--overwrite", "--as", "user", "--json"],
timeout=180, cwd=local_dir)
STATS["export"] += 1
PLAN.append(f"{rel_dest}")
except Exception as e:
STATS["fail"] += 1
PLAN.append(f" ❌ 导出失败 {rel_dest}: {str(e)[:120]}")
return
if typ == "bitable":
# 双轨:.base 原生存档(保真、可导回)+ 每个子表一份 csv可读、可检索
base_name = safe_name(name, token)
rel_base = os.path.relpath(os.path.join(local_dir, base_name + ".base"), out_root)
if not write:
PLAN.append(f" 🗂️ bitable → {rel_base} (.base 存档 + 各子表 csv)")
STATS["export"] += 1
return
try:
os.makedirs(local_dir, exist_ok=True)
# ① .base 原生格式存档
lark(["drive", "+export", "--token", token, "--doc-type", "bitable",
"--file-extension", "base", "--output-dir", ".",
"--file-name", base_name,
"--overwrite", "--as", "user", "--json"],
timeout=180, cwd=local_dir)
STATS["export"] += 1
PLAN.append(f"{rel_base}")
# ② 每个子表导一份 csv可读检索
tables = list_bitable_tables(token)
for t in tables:
sub_id = t.get("table_id")
sub_name = safe_name(t.get("name") or sub_id, sub_id)
csv_fname = f"{base_name}-{sub_name}"
rel_csv = os.path.relpath(
os.path.join(local_dir, csv_fname + ".csv"), out_root)
try:
lark(["drive", "+export", "--token", token,
"--doc-type", "bitable", "--file-extension", "csv",
"--sub-id", sub_id, "--output-dir", ".",
"--file-name", csv_fname,
"--overwrite", "--as", "user", "--json"],
timeout=180, cwd=local_dir)
STATS["csv"] += 1
PLAN.append(f" ↳ csv {rel_csv}")
except Exception as e:
STATS["fail"] += 1
PLAN.append(f" ↳ ❌ csv 失败 {rel_csv}: {str(e)[:80]}")
except Exception as e:
STATS["fail"] += 1
PLAN.append(f" ❌ bitable 失败 {rel_base}: {str(e)[:120]}")
return
if typ == "file":
fname = safe_name(name, token)
dest = os.path.join(local_dir, fname)
rel_dest = os.path.relpath(dest, out_root)
# 按扩展名跳过大文件(视频等);飞书列表不返回体积,靠扩展名判断,避免逐个多发请求
if skip_big and os.path.splitext(fname)[1].lower() in BIG_EXTS:
STATS["skipbig"] += 1
PLAN.append(f" ⏭️ 跳过大文件(--skip-big): {rel_dest}")
return
if not write:
PLAN.append(f" 📎 file → {rel_dest} (原样下载)")
STATS["file"] += 1
return
try:
os.makedirs(local_dir, exist_ok=True)
# lark-cli 的 --output 只认「cwd 内的相对路径」,所以 cwd=目标目录 + 相对文件名
lark(["drive", "+download", "--file-token", token,
"--output", "./" + fname, "--overwrite", "--as", "user", "--json"],
timeout=300, cwd=local_dir)
STATS["file"] += 1
PLAN.append(f"{rel_dest}")
except Exception as e:
STATS["fail"] += 1
PLAN.append(f" ❌ 下载失败 {rel_dest}: {str(e)[:120]}")
return
# 其它未知类型
STATS["skip"] += 1
PLAN.append(f" ⏭️ 未知类型 {typ}: {rel_path}/{name}")
def walk(folder_token, rel_path, out_root, host, manifest, write, skip_big=False, depth=0):
if depth > 20:
PLAN.append(f" ⚠️ 目录过深(>20层),停在 {rel_path}")
return
items = list_folder(folder_token)
for it in items:
if it.get("type") == "folder":
sub = os.path.join(rel_path, safe_name(it.get("name"), it.get("token"))) if rel_path \
else safe_name(it.get("name"), it.get("token"))
PLAN.append(f"📁 {sub}/")
walk(it["token"], sub, out_root, host, manifest, write, skip_big, depth + 1)
else:
handle_item(it, rel_path, out_root, host, manifest, write, skip_big)
def main():
ap = argparse.ArgumentParser(description="飞书文件夹递归镜像到本地")
ap.add_argument("--folder", required=True, help="飞书文件夹 token")
ap.add_argument("--out", required=True, help="本地输出目录")
ap.add_argument("--host", default="ncnc1z9jg8c4.feishu.cn",
help="飞书租户域名(拼 source_link 用)")
ap.add_argument("--write", action="store_true", help="实际写盘(默认干跑)")
ap.add_argument("--skip-big", action="store_true",
help="跳过大文件(视频/压缩包/PPT 等,见 BIG_EXTS),只拉可转 markdown 的文档")
args = ap.parse_args()
out_root = os.path.abspath(args.out)
manifest_path = os.path.join(out_root, ".feishu-sync-manifest.json")
manifest = load_manifest(manifest_path)
mode = "写盘" if args.write else "干跑(不落盘)"
print(f"=== feishu-sync [{mode}] ===")
print(f"飞书文件夹: {args.folder}")
print(f"本地输出: {out_root}\n")
if args.write:
os.makedirs(out_root, exist_ok=True)
ProgressList.LIVE = True # 写盘时逐条实时打印进度,避免长时间无输出
print("正在扫描飞书并逐个同步,下面会实时显示进度(文件多时需十几分钟,请耐心等待):\n", flush=True)
try:
walk(args.folder, "", out_root, args.host, manifest, args.write, args.skip_big)
except Exception as e:
# 顶层兜底:飞书未登录/登录过期/网络异常时,给人话而非一屏 Traceback
msg = str(e)
if any(k in msg for k in ("access_token", "unauthor", "登录", "auth",
"not configured", "identity", "401", "403")):
print("\n⚠️ 飞书未登录或登录已过期。请先运行 `lark-cli auth login "
"--domain drive,docs --recommend` 扫码登录,再重试。", file=sys.stderr)
else:
print(f"\n⚠️ 同步中断:{msg[:200]}\n"
" 若反复出现,请把本提示截图求助。", file=sys.stderr)
sys.exit(2)
# 干跑:最后一次性打印全部计划;写盘:已在处理时实时打印过,不再重复
if not args.write:
print("\n".join(PLAN))
print("\n=== 汇总 ===")
print(f"云文档 docx: {STATS['docx']} 在线表格/bitable导出: {STATS['export']} "
f"bitable子表csv: {STATS['csv']} 上传文件: {STATS['file']}")
print(f"跳过(思维导图/快捷方式): {STATS['skip']} 跳过大文件: {STATS['skipbig']} "
f"去重跳过: {STATS['dedup']} 图片下载: {STATS['img']} 失败: {STATS['fail']}")
if args.write:
save_manifest(manifest_path, manifest)
print(f"\n台账已更新: {manifest_path}")
else:
print("\n以上为干跑预览。加 --write 实际落盘。")
if __name__ == "__main__":
main()