This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
openclaw-skills/skills/kb-workflow/kb_workflow.py
T
OpenClaw Agent 679cca3b2c feat(kb-workflow): 优化OCR处理逻辑
- 无论content步骤有没有字幕,都执行ocr步骤
- ocr超时设置为30分钟(1800秒)
- 如果没有字幕文件但有视频文件,使用视频作为ocr源
2026-02-10 20:34:11 +08:00

713 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KB Workflow - Knowledge Base Workflow Orchestrator
知识库工作流编排技能。
核心职责:
1. 接收用户消息,识别触发词和链接
2. 查重(根据URL/文件名)
3. 调用子agent处理各阶段
4. 文件流转(mv)
5. Git版本管理
6. 归档和清理
设计原则:
- workflow不读文件内容
- 从文件名提取平台(第一个下划线之前)
- 子技能自己定义输入输出格式
- workflow只传递提示词 + 需要的路径信息
"""
import os
import sys
import json
import argparse
import subprocess
import shutil
import re
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
# ==================== 子技能映射 ====================
LINK_PARSER_AGENT = "kb-link-parser"
METADATA_AGENTS = {
"bilibili": "kb-bilibili-metadata",
"zhihu": "kb-zhihu-metadata",
}
CONTENT_AGENTS = {
"bilibili": "kb-bilibili-content",
}
OCR_AGENT = "kb-ocr-processor"
ANALYZER_AGENT = "kb-deep-analyzer"
def extract_platform(filename: str) -> str:
"""从文件名提取平台(第一个下划线之前)"""
match = re.match(r'^([a-zA-Z0-9]+)_(.+)$', filename)
if match:
return match.group(1)
return "unknown"
def extract_base_name(filename: str) -> str:
"""从文件名提取基础名称(去掉扩展名)"""
return Path(filename).stem
def ensure_directories(base_path: Path) -> Dict[str, Path]:
"""确保目录存在"""
dirs = {
"pending": base_path / "pending",
"metadata": base_path / "metadata",
"content": base_path / "content",
"content_downloads": base_path / "content" / "downloads",
"content_subtitles": base_path / "content" / "subtitles",
"analyzed": base_path / "analyzed",
"archive": base_path / "archive",
"archive_subscriptions": base_path / "archive" / "subscriptions",
}
for name, dir_path in dirs.items():
dir_path.mkdir(parents=True, exist_ok=True)
return dirs
def check_duplicate(base_path: Path, url: str) -> Dict[str, Any]:
"""查重检查"""
metadata_dir = base_path / "metadata"
archive_dir = base_path / "archive"
# 从URL提取ID
bv_match = re.search(r'BV[a-zA-Z0-9]+', url)
content_id = bv_match.group() if bv_match else None
result = {"duplicate": False, "status": None, "files": []}
if metadata_dir.exists():
for f in metadata_dir.iterdir():
if f.is_file() and content_id and content_id in f.name:
result["duplicate"] = True
result["status"] = "metadata"
result["files"].append(str(f))
if archive_dir.exists():
for date_dir in archive_dir.iterdir():
if date_dir.is_dir():
for f in date_dir.iterdir():
if f.is_file() and content_id and content_id in f.name:
result["duplicate"] = True
result["status"] = "archived"
result["files"].append(str(f))
return result
def call_subagent(agent_id: str, task: str, timeout: int = 120) -> Dict[str, Any]:
"""调用子agent(通过 OpenClaw sessions_spawn 或命令行)"""
print(f"\n{'='*50}")
print(f"调用子技能: {agent_id}")
print(f"{'='*50}")
# 尝试使用 sessions_spawn(OpenClaw 环境)
try:
from openclaw_tool import sessions_spawn
result = sessions_spawn(
agentId=agent_id,
task=task,
timeoutSeconds=timeout
)
print(f"\n子技能返回结果:")
print(json.dumps(result, ensure_ascii=False, indent=2))
return result
except ImportError:
# 回退到命令行调用
pass
# 命令行回退:调用子技能的脚本
print(f"使用命令行方式调用: {agent_id}")
# 技能目录路径
skills_path = Path(__file__).resolve().parent.parent
# 从任务中提取关键信息
import re
# 提取URL
url_match = re.search(r'URL: (\S+)', task)
url = url_match.group(1) if url_match else ""
# 提取输出目录
output_match = re.search(r'输出目录: (\S+)', task)
output_dir = output_match.group(1) if output_match else ""
# 提取输出文件名格式
filename_match = re.search(r'输出文件名格式: (\S+)', task)
filename_format = filename_match.group(1) if filename_match else "{platform}_{id}.json"
# 提取链接文件路径
link_file_match = re.search(r'链接文件: (\S+)', task)
link_file = link_file_match.group(1) if link_file_match else ""
# 提取元数据文件路径
meta_file_match = re.search(r'元数据文件: (\S+)', task)
meta_file = meta_file_match.group(1) if meta_file_match else ""
# kb-link-parser
if agent_id == "kb-link-parser":
cmd = ['python3', str(skills_path / "kb-link-parser" / "link-parser.py"), '--url', url]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
try:
data = json.loads(proc.stdout)
if data.get('success'):
platform = data.get('platform', 'unknown')
content_id = data.get('id', 'unknown')
ext = '.link.json'
filename = f"{platform}_{content_id}{ext}"
output_file = Path(output_dir) / filename
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"保存文件: {output_file}")
return {"success": True, "output_file": str(output_file)}
else:
return {"success": False, "error": data.get('error', 'unknown')}
except json.JSONDecodeError:
return {"success": False, "error": f"解析失败: {proc.stdout[:200]}"}
else:
return {"success": False, "error": proc.stderr}
except Exception as e:
return {"success": False, "error": str(e)}
# kb-bilibili-metadata
elif agent_id == "kb-bilibili-metadata":
# 从链接文件或URL中提取BV号
bv = None
if link_file and Path(link_file).exists():
with open(link_file, 'r', encoding='utf-8') as f:
link_data = json.load(f)
bv = link_data.get('id')
if not bv:
bv_match = re.search(r'(BV[a-zA-Z0-9]+)', url or link_file)
bv = bv_match.group(1) if bv_match else None
if not bv:
return {"success": False, "error": "无法提取BV号"}
# 生成输出文件名
filename = f"bilibili_{bv}.meta.json"
output_file = Path(output_dir) / filename
output_file.parent.mkdir(parents=True, exist_ok=True)
cmd = [
'python3', str(skills_path / "kb-bilibili-metadata" / "bilibili_metadata.py"),
'--id', bv,
'--type', 'video',
'--output', str(output_file)
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
print(f"保存文件: {output_file}")
return {"success": True, "output_file": str(output_file)}
else:
return {"success": False, "error": proc.stderr}
except Exception as e:
return {"success": False, "error": str(e)}
# kb-bilibili-content
elif agent_id == "kb-bilibili-content":
bv_match = re.search(r'(BV[a-zA-Z0-9]+)', url or meta_file)
bv = bv_match.group(1) if bv_match else None
if not bv:
return {"success": False, "error": "无法提取BV号"}
downloads_dir = Path(output_dir) / "downloads"
subtitles_dir = Path(output_dir) / "subtitles"
downloads_dir.mkdir(parents=True, exist_ok=True)
subtitles_dir.mkdir(parents=True, exist_ok=True)
video_file = None
subtitle_file = None
# 下载视频
cmd_video = [
'python3', str(skills_path / "kb-bilibili-content" / "bilibili_content.py"),
'--id', bv,
'--type', 'video',
'--output', output_dir
]
try:
proc = subprocess.run(cmd_video, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
# 查找生成的视频文件(可能包含BV号)
for f in Path(output_dir).iterdir():
if f.is_file() and bv in f.name and f.suffix in ['.flv', '.mp4']:
# 重命名为标准格式
new_name = f"bilibili_{bv}{f.suffix}"
new_path = downloads_dir / new_name
f.rename(new_path)
video_file = str(new_path)
break
except Exception as e:
print(f"视频下载警告: {e}")
# 下载字幕
cmd_subtitle = [
'python3', str(skills_path / "kb-bilibili-content" / "bilibili_content.py"),
'--id', bv,
'--type', 'subtitle',
'--output', output_dir
]
try:
proc = subprocess.run(cmd_subtitle, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
# 查找生成的字幕文件
for f in Path(output_dir).iterdir():
if f.is_file() and bv in f.name and f.suffix in ['.srt', '.lrc', '.ass']:
# 重命名为标准格式
new_name = f"bilibili_{bv}{f.suffix}"
new_path = subtitles_dir / new_name
f.rename(new_path)
subtitle_file = str(new_path)
break
except Exception as e:
print(f"字幕下载警告: {e}")
if video_file or subtitle_file:
return {"success": True, "output_file": video_file, "subtitles": subtitle_file}
else:
return {"success": False, "error": "下载未生成任何文件"}
# kb-ocr-processor
elif agent_id == "kb-ocr-processor":
# 查找字幕文件
subtitles_dir = Path(output_dir) / "subtitles"
subtitle_file = None
if subtitles_dir.exists():
for f in subtitles_dir.iterdir():
if f.suffix in ['.srt', '.lrc']:
subtitle_file = str(f)
break
if not subtitle_file:
return {"success": False, "error": "未找到字幕文件"}
# 生成输出文件名
text_file = Path(output_dir) / f"{Path(subtitle_file).stem}.txt"
cmd = [
'python3', str(skills_path / "kb-ocr-processor" / "ocr_processor.py"),
'--input', subtitle_file,
'--output', str(text_file)
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
print(f"保存文件: {text_file}")
return {"success": True, "output_file": str(text_file)}
else:
return {"success": False, "error": proc.stderr}
except Exception as e:
return {"success": False, "error": str(e)}
# kb-deep-analyzer
elif agent_id == "kb-deep-analyzer":
# 生成输出文件名
meta_name = Path(meta_file).stem if meta_file else "unknown"
filename = f"{meta_name}.md"
output_file = Path(output_dir) / filename
output_file.parent.mkdir(parents=True, exist_ok=True)
# 从任务中提取文字内容文件路径
text_file_match = re.search(r'文字内容文件: (\S+)', task)
text_content_file = text_file_match.group(1) if text_file_match else ""
# 如果没有文字内容文件,使用 metadata 作为 content
content_file = meta_file
if text_content_file and Path(text_content_file).exists():
# 从文字文件创建 content JSON
content_json = {
"type": "transcript",
"source": text_content_file,
"text": Path(text_content_file).read_text(encoding='utf-8')[:10000] if Path(text_content_file).exists() else ""
}
content_file = Path(output_dir) / f"{meta_name}.content.json"
with open(content_file, 'w', encoding='utf-8') as f:
json.dump(content_json, f, ensure_ascii=False, indent=2)
cmd = [
'python3', str(skills_path / "kb-deep-analyzer" / "deep_analyzer.py"),
'--metadata', meta_file or output_dir,
'--content', str(content_file),
'--output', str(output_file)
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
print(f"保存文件: {output_file}")
return {"success": True, "output_file": str(output_file)}
else:
return {"success": False, "error": proc.stderr}
except Exception as e:
return {"success": False, "error": str(e)}
return {"success": False, "error": f"暂未实现命令行回退: {agent_id}"}
def call_link_parser(url: str, pending_dir: Path) -> Dict[str, Any]:
"""调用 kb-link-parser"""
task = f"""解析以下URL,将结果保存到指定目录:
URL: {url}
输出目录: {pending_dir}
输出文件名格式: {{platform}}_{{id}}.link.json
请解析URL中的平台和内容ID,将解析结果保存为JSON文件。
返回结果时告诉我输出文件的完整路径。
只需要解析URL并保存结果,不需要下载内容。"""
return call_subagent(LINK_PARSER_AGENT, task, timeout=60)
def call_metadata_agent(platform: str, link_file: Path, metadata_dir: Path) -> Dict[str, Any]:
"""调用对应的元数据获取技能"""
agent_id = METADATA_AGENTS.get(platform)
if not agent_id:
return {"success": False, "error": f"不支持的平台: {platform}"}
task = f"""获取链接的元数据:
链接文件: {link_file}
输出目录: {metadata_dir}
请读取链接文件,提取URL,然后获取该链接的元数据(标题、作者、播放量等)。
将元数据保存为JSON文件,文件名格式: {{platform}}_{{id}}.meta.json
返回结果时告诉我输出文件的完整路径。"""
return call_subagent(agent_id, task, timeout=120)
def call_content_agent(platform: str, meta_file: Path, content_dir: Path) -> Dict[str, Any]:
"""调用对应的内容获取技能"""
agent_id = CONTENT_AGENTS.get(platform)
if not agent_id:
return {"success": False, "error": f"不支持的平台: {platform}"}
task = f"""下载视频内容:
元数据文件: {meta_file}
下载目录: {content_dir}
请读取元数据文件,获取视频信息,然后下载视频和字幕。
视频保存到: content/downloads/
字幕保存到: content/subtitles/
文件名格式: {{platform}}_{{id}}.mp4 和 {{platform}}_{{id}}.srt
返回结果时告诉我下载的视频文件和字幕文件的完整路径。"""
return call_subagent(agent_id, task, timeout=600)
def call_ocr_processor(source_file: Path, content_dir: Path, timeout: int = 1800) -> Dict[str, Any]:
"""调用 OCR 处理"""
task = f"""处理文件,提取文字内容:
源文件: {source_file}
输出目录: {content_dir}
请读取文件,提取文字内容。
输出文件名格式: {{platform}}_{{id}}.txt
返回结果时告诉我输出文件的完整路径。"""
return call_subagent(OCR_AGENT, task, timeout=timeout)
def call_deep_analyzer(meta_file: Path, text_file: Path, analyzed_dir: Path) -> Dict[str, Any]:
"""调用深度分析"""
task = f"""深度分析内容:
元数据文件: {meta_file}
文字内容文件: {text_file}
输出目录: {analyzed_dir}
请读取元数据和文字内容,进行深度分析。
生成分析报告,保存为Markdown文件。
文件名格式: {{platform}}_{{id}}.md
分析报告应包含:
- 一句话总结
- 核心要点
- 时间轴(如适用)
- 推荐标签
返回结果时告诉我输出文件的完整路径。"""
return call_subagent(ANALYZER_AGENT, task, timeout=180)
def archive_and_git(base_path: Path, analyzed_file: Path, git_root: Path, git_user: str, git_email: str, git_remote: str) -> Dict[str, Any]:
"""归档并提交Git"""
result = {
"success": False,
"archive_path": "",
"git_commit": False,
"git_push": None,
"error": None
}
# 1. 创建归档目录
date_str = datetime.now().strftime('%Y-%m-%d')
date_dir = base_path / "archive" / date_str
date_dir.mkdir(parents=True, exist_ok=True)
# 2. 复制文件到归档目录
archive_file = date_dir / analyzed_file.name
shutil.copy(analyzed_file, archive_file)
result["archive_path"] = str(archive_file)
# 3. 复制元数据
meta_file = base_path / "metadata" / f"{extract_base_name(analyzed_file.name)}.meta.json"
if meta_file.exists():
shutil.copy(meta_file, date_dir / meta_file.name)
# 4. Git操作
git_dir = git_root / ".git"
if not git_dir.exists():
subprocess.run(['git', 'init'], cwd=str(git_root), capture_output=True)
if git_user:
subprocess.run(['git', 'config', 'user.name', git_user], cwd=str(git_root))
if git_email:
subprocess.run(['git', 'config', 'user.email', git_email], cwd=str(git_root))
if (git_root / ".git").exists():
subprocess.run(['git', 'add', '.'], cwd=str(git_root), capture_output=True)
commit_msg = f"Archive: {analyzed_file.name} - {datetime.now().isoformat()}"
commit_result = subprocess.run(
['git', 'commit', '-m', commit_msg],
cwd=str(git_root), capture_output=True, text=True
)
result["git_commit"] = commit_result.returncode == 0
if git_remote and commit_result.returncode == 0:
push_result = subprocess.run(
['git', 'push', 'origin', 'master'],
cwd=str(git_root), capture_output=True, text=True, timeout=60
)
result["git_push"] = {"success": push_result.returncode == 0}
result["success"] = True
return result
def process_url(url: str, base_path: Path = None) -> Dict[str, Any]:
"""处理单个URL的完整流程"""
if not base_path:
base_path = Path.home() / "work" / "knowledge-base"
git_root = os.environ.get('KB_GIT_ROOT', str(base_path / 'archive'))
git_user = os.environ.get('KB_GIT_USER', '')
git_email = os.environ.get('KB_GIT_EMAIL', '')
git_remote = os.environ.get('KB_GIT_REMOTE', '')
dirs = ensure_directories(base_path)
result = {
"url": url,
"success": False,
"stages": [],
"final_file": None,
"archive": None,
"error": None
}
try:
# 查重
print(f"\n{'='*50}")
print(f"步骤1: 查重")
print(f"{'='*50}")
dup_result = check_duplicate(base_path, url)
if dup_result["duplicate"]:
result["error"] = f"已存在: {dup_result['status']}"
result["duplicate"] = True
result["existing_files"] = dup_result["files"]
return result
# kb-link-parser
print(f"\n{'='*50}")
print(f"步骤2: 链接解析 (kb-link-parser)")
print(f"{'='*50}")
link_result = call_link_parser(url, dirs["pending"])
if not link_result.get("success"):
result["error"] = f"链接解析失败: {link_result.get('error')}"
return result
link_file = link_result.get("output_file")
if not link_file:
result["error"] = "链接解析未返回输出文件"
return result
link_file = Path(link_file)
result["stages"].append({"stage": "link_parsing", "file": str(link_file)})
# 提取平台
filename = link_file.name
platform = extract_platform(filename)
content_id = extract_base_name(filename)
print(f"\n平台: {platform}")
print(f"内容ID: {content_id}")
# 元数据
print(f"\n{'='*50}")
print(f"步骤3: 元数据提取 ({METADATA_AGENTS.get(platform, 'unknown')})")
print(f"{'='*50}")
meta_result = call_metadata_agent(platform, link_file, dirs["metadata"])
if not meta_result.get("success"):
result["error"] = f"元数据获取失败: {meta_result.get('error')}"
return result
meta_file = meta_result.get("output_file")
if not meta_file:
result["error"] = "元数据获取未返回输出文件"
return result
meta_file = Path(meta_file)
result["stages"].append({"stage": "metadata", "file": str(meta_file)})
# 内容下载
print(f"\n{'='*50}")
print(f"步骤4: 内容下载 ({CONTENT_AGENTS.get(platform, 'unknown')})")
print(f"{'='*50}")
content_result = call_content_agent(platform, meta_file, dirs["content"])
if not content_result.get("success"):
result["error"] = f"内容下载失败: {content_result.get('error')}"
return result
video_file = content_result.get("output_file")
subtitle_file = content_result.get("subtitle") or content_result.get("subtitles")
result["stages"].append({"stage": "content", "video": video_file, "subtitles": subtitle_file})
# OCR
text_file = None
print(f"\n{'='*50}")
print(f"步骤5: OCR处理 (kb-ocr-processor)")
print(f"{'='*50}")
# 无论有没有字幕,都尝试OCR处理
ocr_source = subtitle_file
if not ocr_source and video_file:
ocr_source = video_file
if ocr_source:
# OCR超时设置为30分钟
ocr_result = call_ocr_processor(Path(ocr_source), dirs["content"], timeout=1800)
text_file = ocr_result.get("output_file") if ocr_result.get("success") else None
if text_file:
result["stages"].append({"stage": "ocr", "file": text_file})
else:
print(f"OCR处理未生成文件")
else:
print(f"无字幕文件,跳过OCR处理")
# 深度分析
print(f"\n{'='*50}")
print(f"步骤6: 深度分析 (kb-deep-analyzer)")
print(f"{'='*50}")
analyze_result = call_deep_analyzer(
meta_file,
Path(text_file) if text_file else meta_file,
dirs["analyzed"]
)
if not analyze_result.get("success"):
result["error"] = f"深度分析失败: {analyze_result.get('error')}"
return result
analyzed_file = analyze_result.get("output_file")
if not analyzed_file:
result["error"] = "深度分析未返回输出文件"
return result
analyzed_file = Path(analyzed_file)
result["stages"].append({"stage": "analyzer", "file": str(analyzed_file)})
result["final_file"] = str(analyzed_file)
# 归档和Git
print(f"\n{'='*50}")
print(f"步骤7: 归档和Git")
print(f"{'='*50}")
archive_result = archive_and_git(
base_path, analyzed_file,
Path(git_root), git_user, git_email, git_remote
)
result["archive"] = archive_result
result["success"] = True
print(f"\n{'='*50}")
print(f"处理完成!")
print(f"{'='*50}")
except Exception as e:
result["error"] = str(e)
import traceback
traceback.print_exc()
return result
def main():
"""CLI入口"""
parser = argparse.ArgumentParser(
description='KB Workflow - Knowledge Base Workflow Orchestrator',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('--url', type=str, help='要处理的URL')
parser.add_argument('--base-path', type=str, help='知识库根目录')
args = parser.parse_args()
if not args.url:
print("请提供URL: --url https://...")
sys.exit(1)
base_path = Path(args.base_path) if args.base_path else None
result = process_url(args.url, base_path)
print(f"\n{'='*50}")
print("KB Workflow Result")
print(f"{'='*50}")
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
sys.exit(0 if result.get('success') else 1)
if __name__ == '__main__':
main()