| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- auto_classify.py —— 知识库公众号文章自动归类
- ============================================
- 把知识库根目录下「未归类的微信公众号文章」按标题关键词匹配,
- 自动移动到对应的主题目录(文件夹)。
- 用法:
- python3 auto_classify.py # 运行一次(实际移动)
- python3 auto_classify.py --dry-run # 仅预览匹配结果,不移动
- python3 auto_classify.py --loop 10 # 每 10 分钟循环扫描一次
- python3 auto_classify.py --once # 等价默认(运行一次)
- 依赖环境变量:
- IMA_OPENAPI_CLIENTID
- IMA_OPENAPI_APIKEY
- 知识库 ID 见下方 KB_ID(源的个人知识库,公众号文章自动同步到此)。
- """
- import os
- import sys
- import json
- import time
- import argparse
- import urllib.request
- from collections import defaultdict
- BASE_URL = "https://ima.qq.com"
- KB_ID = "fsUjWTJ9mncR91ppaaPlxW2ibb0scLyM80jHHvyN0jI="
- LOG_PATH = "/sandbox/workspace/auto_classify.log"
- BATCH = 10 # 单次 move_knowledge 最多 10 个
- # 关键词 → 目标文件夹(按列表顺序优先级匹配,先命中先得)。
- # folder 名称需与知识库内真实文件夹名一致;不存在的会回落到 DEFAULT_FOLDER。
- RULES = [
- ("HermesAgent", ["Hermes", "爱马仕", "hermes"]),
- ("ClaudeCode", ["Claude", "Anthropic", "Codex", "claude"]),
- ("OpenClaw", ["OpenClaw", "龙虾", "Claw", "openclaw"]),
- ("DeepSeekV4", ["DeepSeek", "Reasonix", "deepseek"]),
- ("AIAgent智能体", ["Agent", "智能体", "OpenHuman", "agent"]),
- ("MCP模型上下文协议", ["MCP", "AI网关", "mcp"]),
- ("阿里AI生态", ["阿里", "Qwen", "通义", "qwen", "千问"]),
- ("LLM大模型", ["LLM", "大模型", "年入", "程序员", "创始人", "llm"]),
- ("CoWork", ["Workbuddy", "CoWork", "workbuddy"]),
- ("Skill本地技能", ["Skill", "技能", "skill", "仓颉"]),
- ("AI自然语言编程", ["编程", "前端", "后端", "TypeScript", "React"]),
- ("github开源工具", ["github", "GitHub", "开源", "标星", "star"]),
- ("智普GLM", ["智普", "GLM", "glm", "ChatGLM"]),
- ("智能化运维AIOPS", ["Token工厂", "Token成本", "算力", "运维", "AIOps", "aiops", "可观测"]),
- ("Office", ["Office", "出片", "剪辑", "视频生成", "Excel", "PPT", "Word", "Obsidian"]),
- ("RAG知识库应用", ["RAG", "rag", "知识库", "Karpathy", "检索"]),
- ("AI大会和论坛", ["大会", "论坛", "峰会", "全球脑力"]),
- ("语音ASR小智TTS", ["语音", "ASR", "TTS", "有声书", "电子书"]),
- ("多模态", ["多模态", "VL2", "SAIL", "视觉"]),
- ("机器人", ["机器人", "ASAP", "仿真"]),
- ("音乐AI", ["音乐", "SongBloom", "歌曲生成"]),
- ("翻译工具", ["翻译", "TinyAI"]),
- ]
- # 命中以下标记的文章不参与归类(微信平台类、致谢类等)
- SKIP_MARKERS = ["微信公众平台", "对联", "谢谢", "__SKIP__"]
- DEFAULT_FOLDER = "其他"
- def log(msg):
- ts = time.strftime("%Y-%m-%d %H:%M:%S")
- line = f"[{ts}] {msg}"
- print(line)
- try:
- with open(LOG_PATH, "a", encoding="utf-8") as f:
- f.write(line + "\n")
- except Exception:
- pass
- def call_api(endpoint, body):
- url = BASE_URL + endpoint
- data = json.dumps(body).encode("utf-8")
- req = urllib.request.Request(url, data=data, method="POST")
- req.add_header("ima-openapi-clientid", os.environ.get("IMA_OPENAPI_CLIENTID", ""))
- req.add_header("ima-openapi-apikey", os.environ.get("IMA_OPENAPI_APIKEY", ""))
- req.add_header("Content-Type", "application/json")
- with urllib.request.urlopen(req, timeout=30) as resp:
- r = json.loads(resp.read().decode("utf-8"))
- return r.get("data", r)
- def list_all(kb_id, folder_id=None):
- items = []
- cursor = ""
- while True:
- body = {"cursor": cursor, "limit": 50, "knowledge_base_id": kb_id}
- if folder_id:
- body["folder_id"] = folder_id
- r = call_api("/openapi/wiki/v1/get_knowledge_list", body)
- items.extend(r.get("knowledge_list", []))
- if r.get("is_end") or not r.get("next_cursor"):
- break
- cursor = r["next_cursor"]
- return items
- def get_folder_map(kb_id):
- items = list_all(kb_id)
- return {it["title"]: it["media_id"] for it in items if it.get("media_type") == 99}
- def classify(title, folder_map):
- low = title.lower()
- for folder, kws in RULES:
- for kw in kws:
- if kw.lower() in low:
- if folder in folder_map:
- return folder, folder_map[folder]
- if DEFAULT_FOLDER in folder_map:
- return DEFAULT_FOLDER, folder_map[DEFAULT_FOLDER]
- return None, None
- def run_once(dry_run):
- folder_map = get_folder_map(KB_ID)
- items = list_all(KB_ID)
- articles = [it for it in items if it.get("media_type") == 6] # 公众号文章
- log(f"根目录共 {len(items)} 项,公众号文章 {len(articles)} 篇;文件夹 {len(folder_map)} 个")
- moves = [] # (article, target_name, target_id)
- skipped = 0
- for a in articles:
- title = a.get("title", "")
- if any(m in title for m in SKIP_MARKERS):
- skipped += 1
- continue
- name, fid = classify(title, folder_map)
- if name is None:
- continue # 无兜底文件夹,留在根目录
- moves.append((a, name, fid))
- if dry_run:
- for a, name, _ in moves:
- log(f"[DRY-RUN] «{a['title']}» -> 「{name}」")
- log(f"[DRY-RUN] 预览:将移动 {len(moves)} 篇,跳过 {skipped} 篇")
- return len(moves)
- groups = defaultdict(list)
- for a, name, fid in moves:
- groups[fid].append((a["media_id"], name, a["title"]))
- total = 0
- for fid, batch in groups.items():
- for i in range(0, len(batch), BATCH):
- chunk = batch[i:i + BATCH]
- infos = [{"media_id": m} for m, _, _ in chunk]
- try:
- call_api("/openapi/wiki/v1/move_knowledge", {
- "src_knowledge_base_id": KB_ID,
- "dst_knowledge_base_id": KB_ID,
- "dst_folder_id": fid,
- "infos": infos,
- })
- total += len(chunk)
- for _, name, title in chunk:
- log(f"✓ «{title}» -> 「{name}」")
- except Exception as e:
- log(f"✗ 移动失败: {e}")
- log(f"本次移动 {total} 篇,跳过 {skipped} 篇")
- return total
- def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("--dry-run", action="store_true", help="仅预览匹配结果,不移动")
- ap.add_argument("--loop", type=int, default=0, help="循环间隔分钟,0=不循环")
- ap.add_argument("--once", action="store_true", help="只运行一次(默认)")
- args = ap.parse_args()
- if not os.environ.get("IMA_OPENAPI_CLIENTID") or not os.environ.get("IMA_OPENAPI_APIKEY"):
- log("✗ 缺少 IMA_OPENAPI_CLIENTID / IMA_OPENAPI_APIKEY 环境变量")
- sys.exit(1)
- if args.loop:
- interval = args.loop * 60
- log(f"循环模式启动,每 {args.loop} 分钟扫描一次")
- while True:
- try:
- run_once(args.dry_run)
- except Exception as e:
- log(f"✗ 运行异常: {e}")
- time.sleep(interval)
- else:
- run_once(args.dry_run)
- if __name__ == "__main__":
- main()
|