🔄 卡若AI 同步 2026-02-24 21:16 | 更新:水桥平台对接、运营中枢工作台 | 排除 >20MB: 12 个

This commit is contained in:
2026-02-24 21:16:23 +08:00
parent 93c66bfdfb
commit f132c6f42f
4 changed files with 373 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
{
"access_token": "u-5jTDb7Rkl57WWjJ3pzwPS6l5moW5k1MXV8aaJxQ00ACm",
"refresh_token": "ur-5ve40_WDh2CUZE2rESveY6l5mOU5k1MphEaaUBM00xCm",
"access_token": "u-6rHtN.Y5pcGFRZt3R.E584l5koW5k1WPq8aaIAM00ASj",
"refresh_token": "ur-6FagMxFLR1WU6.xq5hcq8Fl5moWBk1MrX8aaINM00xym",
"name": "飞书用户",
"auth_time": "2026-02-23T09:58:30.247057"
"auth_time": "2026-02-24T21:04:05.071915"
}

View File

@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""
通用发布:本地 blocks JSON含 image_paths→ 飞书 Wiki 子目录 docx含图片上传与占位替换→ 可选 webhook 发群
用法:
python3 feishu_publish_blocks_with_images.py \
--parent <wiki_parent_node_token> \
--title "文档标题" \
--json "/abs/path/to/blocks.json" \
--webhook "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
说明:
- blocks JSON 格式:{ "children": [...], "image_paths": [...] }md_to_feishu_json.py 可生成)
- 图片上传drive/v1/medias/upload_allparent_type=docx_imageparent_node=doc_token
- 图片块插入:默认用 file 块block_type=12viewType=inline可用环境变量切换
FEISHU_IMG_BLOCK=gallery → block_type=18 gallery
"""
import os
import sys
import json
import argparse
from pathlib import Path
from datetime import datetime
import requests
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import feishu_wiki_create_doc as fwd # 复用 token 逻辑
def _make_gallery_block(file_token: str) -> dict:
return {
"block_type": 18,
"gallery": {
"imageList": [{"fileToken": file_token}],
"galleryStyle": {"align": "center"},
},
}
def _make_file_block(file_token: str, filename: str) -> dict:
return {
"block_type": 12,
"file": {"fileToken": file_token, "viewType": "inline", "fileName": filename},
}
def upload_image_to_doc(token: str, doc_token: str, img_path: Path) -> str | None:
if not img_path.exists():
print(f"⚠️ 图片不存在: {img_path}")
return None
size = img_path.stat().st_size
if size > 20 * 1024 * 1024:
print(f"⚠️ 图片超过 20MB: {img_path.name}")
return None
url = "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all"
headers = {"Authorization": f"Bearer {token}"}
with open(img_path, "rb") as f:
files = {
"file_name": (None, img_path.name),
"parent_type": (None, "docx_image"),
"parent_node": (None, doc_token),
"size": (None, str(size)),
"file": (img_path.name, f, "image/png"),
}
r = requests.post(url, headers=headers, files=files, timeout=60)
data = r.json()
if data.get("code") == 0:
return data.get("data", {}).get("file_token")
print(f"⚠️ 上传失败 {img_path.name}: {data.get('msg')} debug={data.get('debug', '')}")
return None
def create_node(parent_token: str, title: str, headers: dict) -> tuple[str, str]:
"""创建 wiki 子节点,返回 (doc_token, node_token)"""
r = requests.get(
f"https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node?token={parent_token}",
headers=headers, timeout=30)
j = r.json()
if j.get("code") != 0:
raise RuntimeError(f"get_node 失败: {j.get('msg')}")
node = j["data"]["node"]
space_id = node.get("space_id") or (node.get("space") or {}).get("space_id") or node.get("origin_space_id")
if not space_id:
raise RuntimeError("无法获取 space_id")
cr = requests.post(
f"https://open.feishu.cn/open-apis/wiki/v2/spaces/{space_id}/nodes",
headers=headers,
json={
"parent_node_token": parent_token,
"obj_type": "docx",
"node_type": "origin",
"title": title,
},
timeout=30,
)
cj = cr.json()
if cr.status_code != 200 or cj.get("code") != 0:
raise RuntimeError(f"创建节点失败: {cj.get('msg', str(cj))}")
new_node = cj.get("data", {}).get("node", {})
node_token = new_node.get("node_token")
doc_token = new_node.get("obj_token") or node_token
if not doc_token:
raise RuntimeError("创建成功但无 doc_token")
return doc_token, node_token
def resolve_doc_token(node_token: str, headers: dict) -> str:
r = requests.get(
f"https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node?token={node_token}",
headers=headers, timeout=30)
j = r.json()
if j.get("code") != 0:
raise RuntimeError(f"get_node 失败: {j.get('msg')}")
node = j["data"]["node"]
return node.get("obj_token") or node_token
def clear_doc_blocks(doc_token: str, headers: dict) -> None:
"""清空文档根节点下直接子块(分页拉取 + 分批删除)"""
all_items = []
page_token = None
while True:
params = {"page_size": 100}
if page_token:
params["page_token"] = page_token
r = requests.get(
f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_token}/blocks",
headers=headers, params=params, timeout=30)
j = r.json()
if j.get("code") != 0:
raise RuntimeError(f"获取 blocks 失败: {j.get('msg')}")
data = j.get("data", {}) or {}
all_items.extend(data.get("items", []) or [])
page_token = data.get("page_token")
if not page_token:
break
child_ids = [b["block_id"] for b in all_items if b.get("parent_id") == doc_token and b.get("block_id")]
if not child_ids:
return
for i in range(0, len(child_ids), 50):
batch = child_ids[i : i + 50]
rd = requests.delete(
f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_token}/blocks/{doc_token}/children/batch_delete",
headers=headers, json={"block_id_list": batch}, timeout=30)
jd = rd.json()
if jd.get("code") != 0:
raise RuntimeError(f"清空失败: {jd.get('msg')}")
def replace_image_placeholders(blocks: list, file_tokens: list[str | None], image_paths: list[str]) -> list:
use = os.environ.get("FEISHU_IMG_BLOCK", "file") # file | gallery
out = []
for b in blocks:
if not isinstance(b, dict) or b.get("block_type") != 2:
out.append(b)
continue
elements = (b.get("text") or {}).get("elements") or []
content = ""
if elements and isinstance(elements[0], dict):
content = (elements[0].get("text_run") or {}).get("content", "") or ""
hit = None
for i in range(1, len(file_tokens) + 1):
if f"【配图 {i}" in content:
hit = i
break
if not hit:
out.append(b)
continue
ft = file_tokens[hit - 1]
if not ft:
out.append(b)
continue
filename = f"image_{hit}.png"
if hit - 1 < len(image_paths):
try:
filename = Path(image_paths[hit - 1]).name or filename
except Exception:
pass
if use == "gallery":
out.append(_make_gallery_block(ft))
else:
out.append(_make_file_block(ft, filename))
return out
def _get_text_content(block: dict) -> str:
if not isinstance(block, dict) or block.get("block_type") != 2:
return ""
elements = (block.get("text") or {}).get("elements") or []
if not elements:
return ""
tr = (elements[0].get("text_run") or {})
return (tr.get("content") or "")
def sanitize_blocks(blocks: list) -> list:
"""
飞书 docx blocks 对“空段落/异常结构”会严格校验。
这里做一次轻量清洗:去掉纯空文本块,避免 invalid param。
"""
out = []
for b in blocks:
if not isinstance(b, dict):
continue
if b.get("block_type") == 2:
c = _get_text_content(b)
if not c or not c.strip():
continue
out.append(b)
return out
def _post_children(doc_token: str, headers: dict, children: list, index: int | None = None) -> dict:
payload = {"children": children}
# 关键点index 可不传,默认追加到末尾;这对“跳过部分块”场景更稳
if index is not None:
payload["index"] = index
wr = requests.post(
f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_token}/blocks/{doc_token}/children",
headers=headers,
json=payload,
timeout=30,
)
try:
return wr.json()
except Exception:
return {"code": -1, "msg": f"non-json response: {wr.text[:200]}"}
def write_blocks(doc_token: str, headers: dict, blocks: list) -> None:
valid = sanitize_blocks([b for b in blocks if b is not None])
for i in range(0, len(valid), 50):
batch = valid[i : i + 50]
res = _post_children(doc_token, headers, batch, None)
if res.get("code") != 0:
# 含图片块时常见会失败;此处打印详情并降级为“只写文本块”
debug = res.get("debug", "")
print(f"⚠️ 写入失败: code={res.get('code')} msg={res.get('msg')} debug={debug}")
if any(b.get("block_type") in (12, 18) for b in batch):
safe = [b for b in batch if b.get("block_type") not in (12, 18)]
if safe:
res2 = _post_children(doc_token, headers, safe, None)
if res2.get("code") == 0:
print("⚠️ 图片块跳过,已写文本(图片已上传到文档素材)")
import time
time.sleep(0.35)
continue
# 仍失败:逐块写入,跳过坏块,保证整体可落地
print("⚠️ 进入逐块写入降级模式:定位并跳过非法块")
for b in batch:
if b.get("block_type") in (12, 18):
# 图片块依然不强行写,避免整批失败
continue
r1 = _post_children(doc_token, headers, [b], None)
if r1.get("code") == 0:
import time
time.sleep(0.35)
continue
# 这一个块不合法,跳过
c = _get_text_content(b)
preview = (c[:60] + "...") if c and len(c) > 60 else (c or "")
print(f"⚠️ 跳过非法块: code={r1.get('code')} msg={r1.get('msg')} preview={preview!r}")
import time
time.sleep(0.35)
continue
if len(valid) > 50:
import time
time.sleep(0.35)
def send_webhook(webhook: str, text: str) -> None:
if not webhook:
return
payload = {"msg_type": "text", "content": {"text": text}}
r = requests.post(webhook, json=payload, timeout=10)
try:
j = r.json()
except Exception:
j = {}
if j.get("code", 0) not in (0, None):
print(f"⚠️ webhook 发送失败: {j.get('msg', r.text[:200])}")
else:
print("✅ webhook 已发送")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--parent", required=True, help="Wiki 父节点 tokenURL 中 /wiki/<token>")
ap.add_argument("--target", default="", help="已有 Wiki 文档 node_token用于更新可选")
ap.add_argument("--title", required=True, help="文档标题")
ap.add_argument("--json", required=True, help="blocks JSON 路径(含 children/image_paths")
ap.add_argument("--webhook", default="", help="飞书群机器人 webhook可选")
args = ap.parse_args()
json_path = Path(args.json).expanduser().resolve()
if not json_path.exists():
raise SystemExit(f"❌ JSON 不存在: {json_path}")
base_dir = json_path.parent
data = json.loads(json_path.read_text(encoding="utf-8"))
blocks = data.get("children", [])
image_paths = data.get("image_paths", []) or []
token = fwd.get_token(args.target or args.parent)
if not token:
raise SystemExit("❌ Token 无效,请先运行 auto_log.py 完成飞书授权")
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
print("=" * 50)
print("📤 发布 blocks JSON 到飞书 Wiki含图片")
print(f"父节点: {args.parent}")
print(f"标题: {args.title}")
print(f"JSON: {json_path}")
print("=" * 50)
if args.target:
node_token = args.target
doc_token = resolve_doc_token(node_token, headers)
print(f"📋 更新已有文档: doc_token={doc_token} node_token={node_token}")
clear_doc_blocks(doc_token, headers)
print("✅ 已清空原内容")
else:
doc_token, node_token = create_node(args.parent, args.title, headers)
print(f"✅ 新建文档: doc_token={doc_token} node_token={node_token}")
# 上传图片
file_tokens = []
for p in image_paths:
pth = Path(p)
full = (base_dir / pth) if not pth.is_absolute() else pth
full = full.resolve()
ft = upload_image_to_doc(token, doc_token, full)
file_tokens.append(ft)
if ft:
print(f"✅ 图片上传: {full.name}")
# 替换占位符为图片块
blocks2 = replace_image_placeholders(blocks, file_tokens, image_paths)
write_blocks(doc_token, headers, blocks2)
url = f"https://cunkebao.feishu.cn/wiki/{node_token}"
print(f"✅ 发布完成: {url}")
# 发群
if args.webhook:
msg = "\n".join([
"【卡诺亚基因胶囊】新文章已发布 ✅",
f"标题:{args.title}",
f"链接:{url}",
"",
"要点:基因胶囊=策略+环境指纹+审计+资产IDpack/list/unpack 形成可继承闭环。",
])
send_webhook(args.webhook, msg)
if __name__ == "__main__":
main()

View File

@@ -127,3 +127,4 @@
| 2026-02-24 16:28:06 | 🔄 卡若AI 同步 2026-02-24 16:28 | 更新:水桥平台对接、卡木、卡土、运营中枢工作台 | 排除 >20MB: 10 个 |
| 2026-02-24 16:49:15 | 🔄 卡若AI 同步 2026-02-24 16:49 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 10 个 |
| 2026-02-24 19:59:17 | 🔄 卡若AI 同步 2026-02-24 19:59 | 更新:总索引与入口、水溪整理归档、卡木、运营中枢、运营中枢参考资料、运营中枢工作台 | 排除 >20MB: 12 个 |
| 2026-02-24 20:10:45 | 🔄 卡若AI 同步 2026-02-24 20:10 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 12 个 |

View File

@@ -130,3 +130,4 @@
| 2026-02-24 16:28:06 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 16:28 | 更新:水桥平台对接、卡木、卡土、运营中枢工作台 | 排除 >20MB: 10 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
| 2026-02-24 16:49:15 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 16:49 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 10 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
| 2026-02-24 19:59:17 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 19:59 | 更新:总索引与入口、水溪整理归档、卡木、运营中枢、运营中枢参考资料、运营中枢工作台 | 排除 >20MB: 12 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
| 2026-02-24 20:10:45 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 20:10 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 12 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |