- 完整的知识库框架(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>
132 lines
4.2 KiB
Python
Executable File
132 lines
4.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""feishu-pull.py —— 飞书半自动单向导出(飞书 → docs/)
|
||
|
||
MVP 形态:半自动单向。飞书只作「文档来源」,不直接写 Git。
|
||
凭据走环境变量(绝不入库):
|
||
FEISHU_APP_ID, FEISHU_APP_SECRET
|
||
|
||
用法:
|
||
# 干跑(默认):只打印将要导出什么,不落盘
|
||
python3 tools/feishu-pull.py <代号> --doc <飞书文档token或url>
|
||
|
||
# 实际写入 projects/<代号>/docs/
|
||
python3 tools/feishu-pull.py <代号> --doc <token> --write
|
||
|
||
AI 自动补齐 frontmatter:title(H1回填) / ingested(今天) / source(飞书文档) / source_link。
|
||
去重:写前查 meta/ingest-manifest.json 的内容 hash。
|
||
|
||
注意:本文件是接口骨架。飞书 SDK 调用留 TODO,接入时填 _fetch_doc()。
|
||
凭据缺失时优雅报错,不泄露。
|
||
"""
|
||
import sys
|
||
import os
|
||
import json
|
||
import hashlib
|
||
import argparse
|
||
import datetime
|
||
|
||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||
MANIFEST = os.path.join(ROOT, "meta", "ingest-manifest.json")
|
||
|
||
|
||
def load_manifest():
|
||
if os.path.isfile(MANIFEST):
|
||
with open(MANIFEST, encoding="utf-8") as f:
|
||
return json.load(f)
|
||
return {"_comment": "", "entries": {}}
|
||
|
||
|
||
def save_manifest(m):
|
||
with open(MANIFEST, "w", encoding="utf-8") as f:
|
||
json.dump(m, f, ensure_ascii=False, indent=2)
|
||
|
||
|
||
def content_hash(text):
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _fetch_doc(token):
|
||
"""接入飞书时实现:用 tenant_access_token 拉文档,返回 (标题, markdown 正文, 原始链接)。
|
||
现为占位,避免误导:明确报未接入。"""
|
||
app_id = os.environ.get("FEISHU_APP_ID")
|
||
app_secret = os.environ.get("FEISHU_APP_SECRET")
|
||
if not app_id or not app_secret:
|
||
raise RuntimeError(
|
||
"缺凭据:请设置环境变量 FEISHU_APP_ID / FEISHU_APP_SECRET(不要写进代码或 .env 入库)"
|
||
)
|
||
# TODO: 接入 lark-oapi SDK:
|
||
# 1) POST /open-apis/auth/v3/tenant_access_token/internal
|
||
# 2) GET /open-apis/docx/v1/documents/{token}/raw_content
|
||
raise NotImplementedError(
|
||
"飞书 SDK 调用尚未接入(P2 阶段实现)。当前仅提供 frontmatter 自动补齐与去重骨架。"
|
||
)
|
||
|
||
|
||
def build_page(title, body, source_link):
|
||
today = datetime.date.today().isoformat()
|
||
fm = (
|
||
"---\n"
|
||
"type: doc\n"
|
||
f'title: "{title}"\n'
|
||
"status: seed\n"
|
||
f"created: {today}\n"
|
||
f"ingested: {today}\n"
|
||
"source: 飞书文档\n"
|
||
f"source_link: {source_link}\n"
|
||
"tags: []\n"
|
||
"related: []\n"
|
||
"---\n\n"
|
||
f"# {title}\n\n"
|
||
f"{body}\n"
|
||
)
|
||
return fm
|
||
|
||
|
||
def slugify(title):
|
||
keep = "".join(c if c.isalnum() or c in " -_" else "" for c in title)
|
||
return "-".join(keep.split())[:60] or "untitled"
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="飞书半自动单向导出")
|
||
ap.add_argument("code", help="项目代号(英文/拼音)")
|
||
ap.add_argument("--doc", required=True, help="飞书文档 token 或 URL")
|
||
ap.add_argument("--write", action="store_true", help="实际写盘(默认干跑)")
|
||
args = ap.parse_args()
|
||
|
||
outdir = os.path.join(ROOT, "projects", args.code, "docs")
|
||
|
||
try:
|
||
title, body, link = _fetch_doc(args.doc)
|
||
except (RuntimeError, NotImplementedError) as e:
|
||
print(f"[飞书导出未就绪] {e}", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
page = build_page(title, body, link)
|
||
h = content_hash(body)
|
||
manifest = load_manifest()
|
||
|
||
if h in manifest.get("entries", {}):
|
||
print(f"[去重] 内容已存在于 {manifest['entries'][h]},跳过。")
|
||
return
|
||
|
||
fname = slugify(title) + ".md"
|
||
dest = os.path.join(outdir, fname)
|
||
rel = os.path.relpath(dest, ROOT)
|
||
|
||
if not args.write:
|
||
print(f"[干跑] 将写入: {rel}\n--- frontmatter 预览 ---\n{page[:400]}...")
|
||
print("加 --write 实际落盘。")
|
||
return
|
||
|
||
os.makedirs(outdir, exist_ok=True)
|
||
with open(dest, "w", encoding="utf-8") as f:
|
||
f.write(page)
|
||
manifest.setdefault("entries", {})[h] = rel
|
||
save_manifest(manifest)
|
||
print(f"[已导出] {rel}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|