Add OpenClaw skills, platform kit, and template docs
Made-with: Cursor
This commit is contained in:
1
content-manager/content_manager/__init__.py
Normal file
1
content-manager/content_manager/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# content-manager 技能:文章 / 图片 / 视频 分层包
|
||||
1
content-manager/content_manager/cli/__init__.py
Normal file
1
content-manager/content_manager/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# CLI:解析参数并调用 services
|
||||
261
content-manager/content_manager/cli/app.py
Normal file
261
content-manager/content_manager/cli/app.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""CLI 入口:argparse 装配与分发(Controller)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
|
||||
from content_manager.services import article_service, image_service, video_service
|
||||
from content_manager.services.article_service import resolve_publish_platform
|
||||
from content_manager.util.argparse_zh import ZhArgumentParser
|
||||
|
||||
|
||||
def _print_root_usage_zh() -> None:
|
||||
print(
|
||||
"""内容管理:请指定资源类型子命令。
|
||||
|
||||
python main.py article list
|
||||
python main.py article get 1
|
||||
python main.py article add --title "标题" --body-file 文章.md
|
||||
python main.py article generate 豆包 搜狐号 RPA降本增效
|
||||
python main.py image add --file D:\\\\a.png [--title "说明"]
|
||||
python main.py video add --file D:\\\\a.mp4 [--title "说明"] [--duration-ms 120000]
|
||||
|
||||
查看完整说明:python main.py -h"""
|
||||
)
|
||||
|
||||
|
||||
def _handle_article_add(args: argparse.Namespace) -> None:
|
||||
if args.body_file:
|
||||
fp = os.path.abspath(args.body_file)
|
||||
try:
|
||||
with open(fp, encoding="utf-8") as f:
|
||||
body = f.read()
|
||||
except OSError as e:
|
||||
print(f"❌ 无法读取正文文件:{fp}\n原因:{e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
body = args.body or ""
|
||||
article_service.cmd_add(args.title, body, source="manual")
|
||||
|
||||
|
||||
def _handle_article_import(args: argparse.Namespace) -> None:
|
||||
article_service.cmd_import_json(args.path)
|
||||
|
||||
|
||||
def _handle_article_generate(args: argparse.Namespace) -> None:
|
||||
raw_parts = [str(x).strip() for x in (args.generate_args or []) if str(x).strip()]
|
||||
if not raw_parts:
|
||||
print("❌ 缺少主题或关键词。")
|
||||
print("示例:python main.py article generate 豆包 搜狐号 RPA降本增效")
|
||||
sys.exit(1)
|
||||
platform_guess = resolve_publish_platform(raw_parts[0])
|
||||
if platform_guess and len(raw_parts) == 1:
|
||||
print("❌ 缺少主题或关键词。")
|
||||
sys.exit(1)
|
||||
if platform_guess and len(raw_parts) >= 2:
|
||||
publish_platform = platform_guess
|
||||
topic = " ".join(raw_parts[1:]).strip()
|
||||
else:
|
||||
publish_platform = "common"
|
||||
topic = " ".join(raw_parts).strip()
|
||||
if not topic:
|
||||
print("❌ 主题或关键词不能为空。")
|
||||
sys.exit(1)
|
||||
article_service.cmd_generate(
|
||||
args.llm_target,
|
||||
topic,
|
||||
publish_platform=publish_platform,
|
||||
title=getattr(args, "title", None),
|
||||
)
|
||||
|
||||
|
||||
def _handle_article_feedback(args: argparse.Namespace) -> None:
|
||||
article_service.cmd_feedback(args.article_id, args.status, args.account_id, args.error_msg)
|
||||
|
||||
|
||||
def _handle_article_save_legacy(args: argparse.Namespace) -> None:
|
||||
article_service.cmd_save(args.legacy_id, args.legacy_title, args.legacy_content)
|
||||
|
||||
|
||||
def _handle_image_add(args: argparse.Namespace) -> None:
|
||||
image_service.cmd_add(args.file, title=getattr(args, "title", None))
|
||||
|
||||
|
||||
def _handle_image_feedback(args: argparse.Namespace) -> None:
|
||||
image_service.cmd_feedback(args.image_id, args.status, args.account_id, args.error_msg)
|
||||
|
||||
|
||||
def _handle_video_add(args: argparse.Namespace) -> None:
|
||||
video_service.cmd_add(
|
||||
args.file,
|
||||
title=getattr(args, "title", None),
|
||||
duration_ms=getattr(args, "duration_ms", None),
|
||||
)
|
||||
|
||||
|
||||
def _handle_video_feedback(args: argparse.Namespace) -> None:
|
||||
video_service.cmd_feedback(args.video_id, args.status, args.account_id, args.error_msg)
|
||||
|
||||
|
||||
def build_parser() -> ZhArgumentParser:
|
||||
fmt = argparse.RawDescriptionHelpFormatter
|
||||
p = ZhArgumentParser(
|
||||
prog="main.py",
|
||||
description="内容管理:文章(正文在库内)与图片/视频(文件在数据目录,库内仅存路径)。",
|
||||
epilog="示例见各子命令 -h;一级分组:article / image / video",
|
||||
formatter_class=fmt,
|
||||
)
|
||||
sub = p.add_subparsers(
|
||||
dest="resource",
|
||||
required=True,
|
||||
metavar="资源类型",
|
||||
help="article 文章 | image 图片 | video 视频",
|
||||
parser_class=ZhArgumentParser,
|
||||
)
|
||||
|
||||
# ----- article -----
|
||||
art = sub.add_parser("article", help="文章:正文与元数据在 SQLite", formatter_class=fmt)
|
||||
art_sub = art.add_subparsers(
|
||||
dest="article_cmd",
|
||||
required=True,
|
||||
metavar="子命令",
|
||||
parser_class=ZhArgumentParser,
|
||||
)
|
||||
|
||||
sp = art_sub.add_parser("list", help="列出文章")
|
||||
sp.add_argument("--limit", type=int, default=10)
|
||||
sp.add_argument("--max-chars", type=int, default=50)
|
||||
sp.set_defaults(handler=lambda a: article_service.cmd_list(limit=a.limit, max_chars=a.max_chars))
|
||||
|
||||
sp = art_sub.add_parser("get", help="按 id 输出 JSON")
|
||||
sp.add_argument("article_id", metavar="文章id")
|
||||
sp.set_defaults(handler=lambda a: article_service.cmd_get(a.article_id))
|
||||
|
||||
sp = art_sub.add_parser(
|
||||
"add",
|
||||
help="新增文章",
|
||||
formatter_class=fmt,
|
||||
epilog="示例:python main.py article add --title \"标题\" --body \"正文\"",
|
||||
)
|
||||
sp.add_argument("--title", required=True)
|
||||
g = sp.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--body-file", metavar="路径")
|
||||
g.add_argument("--body", metavar="正文")
|
||||
sp.set_defaults(handler=_handle_article_add)
|
||||
|
||||
sp = art_sub.add_parser("import-json", help="从 JSON 批量导入")
|
||||
sp.add_argument("path", metavar="JSON路径")
|
||||
sp.set_defaults(handler=_handle_article_import)
|
||||
|
||||
sp = art_sub.add_parser("generate", help="调用 llm-manager 生成并入库", formatter_class=fmt)
|
||||
sp.add_argument("llm_target", metavar="大模型目标")
|
||||
sp.add_argument("generate_args", nargs="+", metavar="生成参数")
|
||||
sp.add_argument("--title", metavar="标题", default=None)
|
||||
sp.set_defaults(handler=_handle_article_generate)
|
||||
|
||||
sp = art_sub.add_parser("prompt-list", help="查看提示词模板")
|
||||
sp.add_argument("platform", nargs="?", default=None, metavar="发布平台")
|
||||
sp.add_argument("--limit", type=int, default=30)
|
||||
sp.set_defaults(handler=lambda a: article_service.cmd_prompt_list(a.platform, a.limit))
|
||||
|
||||
sp = art_sub.add_parser("delete", help="删除文章")
|
||||
sp.add_argument("article_id", metavar="文章id")
|
||||
sp.set_defaults(handler=lambda a: article_service.cmd_delete(a.article_id))
|
||||
|
||||
sp = art_sub.add_parser("feedback", help="回写发布状态", formatter_class=fmt)
|
||||
sp.add_argument("article_id", metavar="文章id")
|
||||
sp.add_argument("status", metavar="状态")
|
||||
sp.add_argument("account_id", nargs="?", default=None, metavar="账号")
|
||||
sp.add_argument("error_msg", nargs="?", default=None, metavar="错误说明")
|
||||
sp.set_defaults(handler=_handle_article_feedback)
|
||||
|
||||
sp = art_sub.add_parser("save", help="旧版单行正文保存", formatter_class=fmt)
|
||||
sp.add_argument("legacy_id", metavar="id")
|
||||
sp.add_argument("legacy_title", metavar="标题")
|
||||
sp.add_argument("legacy_content", metavar="正文一行")
|
||||
sp.set_defaults(handler=_handle_article_save_legacy)
|
||||
|
||||
# ----- image -----
|
||||
img = sub.add_parser("image", help="图片:文件在数据目录,images 表存相对路径", formatter_class=fmt)
|
||||
img_sub = img.add_subparsers(
|
||||
dest="image_cmd",
|
||||
required=True,
|
||||
metavar="子命令",
|
||||
parser_class=ZhArgumentParser,
|
||||
)
|
||||
|
||||
sp = img_sub.add_parser("list", help="列出图片")
|
||||
sp.add_argument("--limit", type=int, default=20)
|
||||
sp.add_argument("--max-chars", type=int, default=80)
|
||||
sp.set_defaults(handler=lambda a: image_service.cmd_list(limit=a.limit, max_chars=a.max_chars))
|
||||
|
||||
sp = img_sub.add_parser("get", help="按 id 输出 JSON(含 absolute_path)")
|
||||
sp.add_argument("image_id", metavar="图片id")
|
||||
sp.set_defaults(handler=lambda a: image_service.cmd_get(a.image_id))
|
||||
|
||||
sp = img_sub.add_parser("add", help="从本地文件复制入库", formatter_class=fmt)
|
||||
sp.add_argument("--file", required=True, metavar="文件", help="源图片路径")
|
||||
sp.add_argument("--title", default=None, metavar="标题", help="可选说明")
|
||||
sp.set_defaults(handler=_handle_image_add)
|
||||
|
||||
sp = img_sub.add_parser("delete", help="删除记录与磁盘目录")
|
||||
sp.add_argument("image_id", metavar="图片id")
|
||||
sp.set_defaults(handler=lambda a: image_service.cmd_delete(a.image_id))
|
||||
|
||||
sp = img_sub.add_parser("feedback", help="回写状态", formatter_class=fmt)
|
||||
sp.add_argument("image_id", metavar="图片id")
|
||||
sp.add_argument("status", metavar="状态")
|
||||
sp.add_argument("account_id", nargs="?", default=None, metavar="账号")
|
||||
sp.add_argument("error_msg", nargs="?", default=None, metavar="错误说明")
|
||||
sp.set_defaults(handler=_handle_image_feedback)
|
||||
|
||||
# ----- video -----
|
||||
vid = sub.add_parser("video", help="视频:文件在数据目录,videos 表存相对路径", formatter_class=fmt)
|
||||
vid_sub = vid.add_subparsers(
|
||||
dest="video_cmd",
|
||||
required=True,
|
||||
metavar="子命令",
|
||||
parser_class=ZhArgumentParser,
|
||||
)
|
||||
|
||||
sp = vid_sub.add_parser("list", help="列出视频")
|
||||
sp.add_argument("--limit", type=int, default=20)
|
||||
sp.add_argument("--max-chars", type=int, default=80)
|
||||
sp.set_defaults(handler=lambda a: video_service.cmd_list(limit=a.limit, max_chars=a.max_chars))
|
||||
|
||||
sp = vid_sub.add_parser("get", help="按 id 输出 JSON")
|
||||
sp.add_argument("video_id", metavar="视频id")
|
||||
sp.set_defaults(handler=lambda a: video_service.cmd_get(a.video_id))
|
||||
|
||||
sp = vid_sub.add_parser("add", help="从本地文件复制入库", formatter_class=fmt)
|
||||
sp.add_argument("--file", required=True, metavar="文件")
|
||||
sp.add_argument("--title", default=None, metavar="标题")
|
||||
sp.add_argument("--duration-ms", type=int, default=None, metavar="毫秒", help="可选时长")
|
||||
sp.set_defaults(handler=_handle_video_add)
|
||||
|
||||
sp = vid_sub.add_parser("delete", help="删除记录与磁盘目录")
|
||||
sp.add_argument("video_id", metavar="视频id")
|
||||
sp.set_defaults(handler=lambda a: video_service.cmd_delete(a.video_id))
|
||||
|
||||
sp = vid_sub.add_parser("feedback", help="回写状态", formatter_class=fmt)
|
||||
sp.add_argument("video_id", metavar="视频id")
|
||||
sp.add_argument("status", metavar="状态")
|
||||
sp.add_argument("account_id", nargs="?", default=None, metavar="账号")
|
||||
sp.add_argument("error_msg", nargs="?", default=None, metavar="错误说明")
|
||||
sp.set_defaults(handler=_handle_video_feedback)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
argv = argv if argv is not None else sys.argv[1:]
|
||||
if not argv:
|
||||
_print_root_usage_zh()
|
||||
return 1
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
args.handler(args)
|
||||
return 0
|
||||
47
content-manager/content_manager/config.py
Normal file
47
content-manager/content_manager/config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""路径与环境:与 account-manager 一致的数据根目录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
SKILL_SLUG = "content-manager"
|
||||
|
||||
|
||||
def get_skill_root() -> str:
|
||||
# content_manager/config.py -> 技能根 content-manager/
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def get_openclaw_root() -> str:
|
||||
return os.path.dirname(get_skill_root())
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if env:
|
||||
return env
|
||||
if sys.platform == "win32":
|
||||
return r"D:\jiangchang-data"
|
||||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||||
|
||||
|
||||
def get_user_id() -> str:
|
||||
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
||||
|
||||
|
||||
def get_skill_data_dir() -> str:
|
||||
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_db_path() -> str:
|
||||
return os.path.join(get_skill_data_dir(), "content-manager.db")
|
||||
|
||||
|
||||
def resolve_stored_path(relative_file_path: str) -> str:
|
||||
"""库内相对路径 -> 绝对路径。"""
|
||||
rel = (relative_file_path or "").strip().replace("\\", "/").lstrip("/")
|
||||
return os.path.normpath(os.path.join(get_skill_data_dir(), rel))
|
||||
103
content-manager/content_manager/constants.py
Normal file
103
content-manager/content_manager/constants.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""与业务相关的常量(提示词种子、平台别名、CLI 提示文案)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Set
|
||||
|
||||
CLI_REQUIRED_ZH = {
|
||||
"cmd": "一级子命令:article 文章 | image 图片 | video 视频",
|
||||
"子命令": "二级子命令,用 -h 查看该分组下的命令",
|
||||
"llm_target": "大模型目标:写平台名(如 豆包、DeepSeek、Kimi)或 account-manager 里已登录账号的纯数字 id",
|
||||
"大模型目标": "大模型目标:写平台名(如 豆包、DeepSeek、Kimi)或 account-manager 里已登录账号的纯数字 id",
|
||||
"生成参数": "生成参数:格式是「模型 [发布平台] 主题/关键词」,例如:python main.py article generate 豆包 搜狐号 RPA降本增效",
|
||||
"主题": "主题或关键词:至少填写一项,用于自动套用提示词模板",
|
||||
"--title": "标题:写 --title \"文章标题\";add / generate / image add / video add 会用到",
|
||||
"标题": "标题:写 --title \"文章标题\"",
|
||||
"--body": "正文:写 --body \"短文\";与 --body-file 二选一",
|
||||
"正文": "正文:写 --body \"短文\";与 --body-file 二选一",
|
||||
"--body-file": "正文文件:写 --body-file 后再写 UTF-8 文件路径;与 --body 二选一",
|
||||
"路径": "正文文件:写 --body-file 后再写 UTF-8 文件路径;与 --body 二选一",
|
||||
"--file": "本地文件路径:图片或视频源文件",
|
||||
"文件": "本地文件路径:图片或视频源文件",
|
||||
"path": "JSON 文件路径:写在 import-json 后面,例如 D:\\\\data\\\\articles.json",
|
||||
"JSON路径": "JSON 文件路径:写在 import-json 后面,例如 D:\\\\data\\\\articles.json",
|
||||
"article_id": "文章编号:整数 id,可先执行 article list 看最左一列",
|
||||
"文章id": "文章编号:整数 id,可先执行 article list 看最左一列",
|
||||
"image_id": "图片编号:整数 id,可先执行 image list",
|
||||
"图片id": "图片编号:整数 id,可先执行 image list",
|
||||
"video_id": "视频编号:整数 id,可先执行 video list",
|
||||
"视频id": "视频编号:整数 id,可先执行 video list",
|
||||
"legacy_id": "id:若是已有文章的数字 id 则更新;否则新建一篇",
|
||||
"id": "id:若是已有文章的数字 id 则更新;否则新建一篇",
|
||||
"legacy_title": "标题",
|
||||
"legacy_content": "正文(整段须在一行内,不要换行)",
|
||||
"正文一行": "正文(整段须在一行内,不要换行)",
|
||||
"状态": "状态:例如 published(已发布)或 failed(失败)",
|
||||
"账号": "账号标识:可选,给发布记录用",
|
||||
"错误说明": "错误说明:可选,发布失败时写上原因",
|
||||
}
|
||||
|
||||
PUBLISH_PLATFORM_ALIASES: Dict[str, Set[str]] = {
|
||||
"common": {"common", "通用", "默认", "general", "all"},
|
||||
"sohu": {"sohu", "搜狐", "搜狐号"},
|
||||
"toutiao": {"toutiao", "头条", "头条号", "今日头条"},
|
||||
"wechat": {"wechat", "weixin", "wx", "公众号", "微信公众号", "微信"},
|
||||
}
|
||||
|
||||
PUBLISH_PLATFORM_CN = {
|
||||
"common": "通用",
|
||||
"sohu": "搜狐号",
|
||||
"toutiao": "头条号",
|
||||
"wechat": "微信公众号",
|
||||
}
|
||||
|
||||
PROMPT_TEMPLATE_SEEDS: Dict[str, List[str]] = {
|
||||
"common": [
|
||||
"请围绕主题“{topic}”写一篇结构完整、可直接发布的新媒体文章,输出纯正文,不要解释。",
|
||||
"请以“{topic}”为核心,写一篇适合中文互联网平台发布的文章,语言自然、观点清晰、可读性强。",
|
||||
"围绕“{topic}”写一篇实用向内容,要求有标题、导语、分点展开和结语,整体逻辑清楚。",
|
||||
"请写一篇关于“{topic}”的科普文章,面向普通读者,避免术语堆砌,语气专业但易懂。",
|
||||
"请从痛点、原因、方法、案例四个部分展开,写一篇主题为“{topic}”的原创内容。",
|
||||
"围绕“{topic}”写一篇信息密度高但不枯燥的文章,要求段落清晰、句子简洁。",
|
||||
"请就“{topic}”写一篇观点型文章,先给结论,再给依据和建议,最后总结。",
|
||||
"请生成一篇主题为“{topic}”的内容,适合移动端阅读,段落不宜过长,便于快速浏览。",
|
||||
"围绕“{topic}”撰写一篇可发布文章,避免空话套话,优先给出可执行建议。",
|
||||
"请以“{topic}”为题写文,要求开头抓人、中段有干货、结尾有行动建议。",
|
||||
],
|
||||
"sohu": [
|
||||
"你在为搜狐号写稿。请围绕“{topic}”写一篇原创文章,风格稳重、信息扎实,适合搜狐号读者阅读。",
|
||||
"请按搜狐号内容风格,围绕“{topic}”写一篇逻辑清晰、观点明确的文章,输出纯正文。",
|
||||
"面向搜狐号发布场景,生成主题“{topic}”文章,要求有吸引力标题和清晰分段。",
|
||||
"请写一篇搜狐号可发布稿件,主题“{topic}”,强调实用价值与可读性。",
|
||||
"围绕“{topic}”写搜狐号文章:先引出问题,再给分析,最后给建议。",
|
||||
"请生成一篇适配搜狐号用户阅读习惯的文章,主题是“{topic}”,语言自然且有深度。",
|
||||
"请写一篇“{topic}”主题稿,适合搜狐号发布,避免口水话,突出真实信息和案例。",
|
||||
"为搜狐号生成“{topic}”文章,结构为:导语-正文三段-总结,输出可直接发布内容。",
|
||||
"请围绕“{topic}”写一篇搜狐号文章,强调观点清晰、段落层次分明、结尾有启发。",
|
||||
"围绕“{topic}”产出搜狐号稿件,内容原创、连贯、可读,避免模板化表达。",
|
||||
],
|
||||
"toutiao": [
|
||||
"请按头条号风格围绕“{topic}”写文章,开头3句要抓人,正文信息密度高。",
|
||||
"请写一篇头条号可发布内容,主题“{topic}”,要求标题感强、节奏快、观点明确。",
|
||||
"围绕“{topic}”写头条稿件,语言更口语化、易传播,适当加入场景化表达。",
|
||||
"请生成“{topic}”头条文章:开头抛问题,中段拆解,结尾给结论。",
|
||||
"为头条号创作“{topic}”文章,注重读者停留与完读,段落短、信息集中。",
|
||||
"请写一篇“{topic}”主题头条文,强调实用技巧和可执行方法。",
|
||||
"围绕“{topic}”生成头条风格内容,避免空泛,突出细节与案例。",
|
||||
"请按头条读者偏好写“{topic}”文章,语气直接,观点鲜明,结尾有行动建议。",
|
||||
"请围绕“{topic}”写头条号稿件,确保逻辑清楚、表达简洁、节奏紧凑。",
|
||||
"写一篇适合头条号发布的“{topic}”文章,要求易懂、好读、可传播。",
|
||||
],
|
||||
"wechat": [
|
||||
"请按公众号长文风格围绕“{topic}”写稿,语气克制、叙述完整、可深度阅读。",
|
||||
"请写一篇适合公众号发布的“{topic}”文章,包含引言、分节标题和总结。",
|
||||
"围绕“{topic}”写公众号文章,强调逻辑深度与观点完整性,输出纯正文。",
|
||||
"请创作“{topic}”公众号稿件,要求有故事化开头、干货正文、结尾金句。",
|
||||
"请按公众号读者习惯,写一篇主题“{topic}”的内容,表达自然、层次清晰。",
|
||||
"生成一篇“{topic}”微信公众号文章,强调洞察与方法论,避免碎片化表达。",
|
||||
"请围绕“{topic}”写公众号稿,风格专业可信,段落清楚且有小标题。",
|
||||
"请写一篇“{topic}”公众号内容,结构为:问题提出-原因分析-解决建议-结语。",
|
||||
"请生成“{topic}”公众号文章,注重阅读体验,段落与节奏适合移动端。",
|
||||
"围绕“{topic}”撰写可直接发公众号的文章,要求原创、完整、可读。",
|
||||
],
|
||||
}
|
||||
3
content-manager/content_manager/db/__init__.py
Normal file
3
content-manager/content_manager/db/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from content_manager.db.connection import get_conn, init_db
|
||||
|
||||
__all__ = ["get_conn", "init_db"]
|
||||
122
content-manager/content_manager/db/articles_repository.py
Normal file
122
content-manager/content_manager/db/articles_repository.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""articles 表:仅负责 SQL 读写,不含业务规则。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
|
||||
def insert_article(
|
||||
conn: sqlite3.Connection,
|
||||
title: str,
|
||||
body: str,
|
||||
content_html: Optional[str],
|
||||
status: str,
|
||||
source: str,
|
||||
account_id: Optional[str],
|
||||
error_msg: Optional[str],
|
||||
llm_target: Optional[str],
|
||||
extra_json: Optional[str],
|
||||
created_at: int,
|
||||
updated_at: int,
|
||||
) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO articles (
|
||||
title, body, content_html, status, source, account_id, error_msg, llm_target, extra_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
title,
|
||||
body,
|
||||
content_html,
|
||||
status,
|
||||
source,
|
||||
account_id,
|
||||
error_msg,
|
||||
llm_target,
|
||||
extra_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_article_body(
|
||||
conn: sqlite3.Connection,
|
||||
article_id: int,
|
||||
title: str,
|
||||
body: str,
|
||||
updated_at: int,
|
||||
) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE articles SET title = ?, body = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(title, body, updated_at, article_id),
|
||||
)
|
||||
|
||||
|
||||
def fetch_by_id(conn: sqlite3.Connection, article_id: int) -> Optional[Tuple[Any, ...]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, body, content_html, status, source, account_id, error_msg,
|
||||
llm_target, extra_json, created_at, updated_at
|
||||
FROM articles WHERE id = ?
|
||||
""",
|
||||
(article_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def exists_id(conn: sqlite3.Connection, article_id: int) -> bool:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id FROM articles WHERE id = ?", (article_id,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def list_recent(conn: sqlite3.Connection, limit: int) -> List[Tuple[Any, ...]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id, title, body, content_html,
|
||||
status, source, account_id, error_msg, llm_target, extra_json,
|
||||
created_at, updated_at
|
||||
FROM articles ORDER BY updated_at DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def delete_by_id(conn: sqlite3.Connection, article_id: int) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM articles WHERE id = ?", (article_id,))
|
||||
return int(cur.rowcount)
|
||||
|
||||
|
||||
def update_feedback(
|
||||
conn: sqlite3.Connection,
|
||||
article_id: int,
|
||||
status: str,
|
||||
account_id: Optional[str],
|
||||
error_msg: Optional[str],
|
||||
updated_at: int,
|
||||
) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE articles
|
||||
SET status = ?, account_id = ?, error_msg = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, account_id, error_msg, updated_at, article_id),
|
||||
)
|
||||
113
content-manager/content_manager/db/connection.py
Normal file
113
content-manager/content_manager/db/connection.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""数据库连接与初始化(建表、文章旧库迁移、提示词种子)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from content_manager.config import get_db_path
|
||||
from content_manager.db.schema import (
|
||||
ARTICLES_TABLE_SQL,
|
||||
IMAGES_TABLE_SQL,
|
||||
PROMPT_TEMPLATE_USAGE_TABLE_SQL,
|
||||
PROMPT_TEMPLATES_TABLE_SQL,
|
||||
VIDEOS_TABLE_SQL,
|
||||
)
|
||||
from content_manager.util.timeutil import now_unix, parse_ts_to_unix
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
return sqlite3.connect(get_db_path())
|
||||
|
||||
|
||||
def _is_legacy_articles_table(cur: sqlite3.Cursor) -> bool:
|
||||
cur.execute("PRAGMA table_info(articles)")
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
return False
|
||||
for _cid, name, ctype, _nn, _d, _pk in rows:
|
||||
if name == "id" and ctype and ctype.upper() == "INTEGER":
|
||||
return False
|
||||
cur.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='articles'")
|
||||
row = cur.fetchone()
|
||||
if row and row[0] and "TEXT" in row[0] and "id" in row[0]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _migrate_legacy_articles(conn: sqlite3.Connection) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.executescript(
|
||||
"""
|
||||
CREATE TABLE _articles_migrated (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
content_html TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
account_id TEXT,
|
||||
error_msg TEXT,
|
||||
llm_target TEXT,
|
||||
extra_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"SELECT id, title, content, content_html, status, account_id, error_msg, created_at, updated_at FROM articles"
|
||||
)
|
||||
ts = now_unix()
|
||||
for row in cur.fetchall():
|
||||
_oid, title, content, content_html, status, account_id, error_msg, cat, uat = row
|
||||
body = content or ""
|
||||
ch = content_html if content_html else None
|
||||
cts = parse_ts_to_unix(cat) or ts
|
||||
uts = parse_ts_to_unix(uat) or ts
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO _articles_migrated (
|
||||
title, body, content_html, status, source, account_id, error_msg,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, 'import', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
title or "",
|
||||
body,
|
||||
ch,
|
||||
(status or "draft"),
|
||||
account_id,
|
||||
error_msg,
|
||||
cts,
|
||||
uts,
|
||||
),
|
||||
)
|
||||
cur.execute("DROP TABLE articles")
|
||||
cur.execute("ALTER TABLE _articles_migrated RENAME TO articles")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='articles'")
|
||||
if cur.fetchone():
|
||||
if _is_legacy_articles_table(cur):
|
||||
_migrate_legacy_articles(conn)
|
||||
else:
|
||||
cur.executescript(ARTICLES_TABLE_SQL)
|
||||
cur.executescript(IMAGES_TABLE_SQL)
|
||||
cur.executescript(VIDEOS_TABLE_SQL)
|
||||
cur.executescript(PROMPT_TEMPLATES_TABLE_SQL)
|
||||
cur.executescript(PROMPT_TEMPLATE_USAGE_TABLE_SQL)
|
||||
from content_manager.db.prompts_repository import seed_prompt_templates_if_empty
|
||||
|
||||
seed_prompt_templates_if_empty(conn.cursor())
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
88
content-manager/content_manager/db/images_repository.py
Normal file
88
content-manager/content_manager/db/images_repository.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""images 表:仅保存文件相对路径等元数据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
|
||||
def insert_row(
|
||||
conn: sqlite3.Connection,
|
||||
file_path: str,
|
||||
title: Optional[str],
|
||||
status: str,
|
||||
source: str,
|
||||
account_id: Optional[str],
|
||||
error_msg: Optional[str],
|
||||
extra_json: Optional[str],
|
||||
created_at: int,
|
||||
updated_at: int,
|
||||
) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO images (
|
||||
file_path, title, status, source, account_id, error_msg, extra_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(file_path, title, status, source, account_id, error_msg, extra_json, created_at, updated_at),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_file_path(conn: sqlite3.Connection, image_id: int, file_path: str, updated_at: int) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE images SET file_path = ?, updated_at = ? WHERE id = ?",
|
||||
(file_path, updated_at, image_id),
|
||||
)
|
||||
|
||||
|
||||
def fetch_by_id(conn: sqlite3.Connection, image_id: int) -> Optional[Tuple[Any, ...]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, file_path, title, status, source, account_id, error_msg, extra_json, created_at, updated_at
|
||||
FROM images WHERE id = ?
|
||||
""",
|
||||
(image_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def list_recent(conn: sqlite3.Connection, limit: int) -> List[Tuple[Any, ...]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, file_path, title, status, source, account_id, error_msg, extra_json, created_at, updated_at
|
||||
FROM images ORDER BY updated_at DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def delete_by_id(conn: sqlite3.Connection, image_id: int) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM images WHERE id = ?", (image_id,))
|
||||
return int(cur.rowcount)
|
||||
|
||||
|
||||
def update_feedback(
|
||||
conn: sqlite3.Connection,
|
||||
image_id: int,
|
||||
status: str,
|
||||
account_id: Optional[str],
|
||||
error_msg: Optional[str],
|
||||
updated_at: int,
|
||||
) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE images
|
||||
SET status = ?, account_id = ?, error_msg = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, account_id, error_msg, updated_at, image_id),
|
||||
)
|
||||
114
content-manager/content_manager/db/prompts_repository.py
Normal file
114
content-manager/content_manager/db/prompts_repository.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""提示词模板:表内数据访问与种子。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from content_manager.constants import PROMPT_TEMPLATE_SEEDS, PUBLISH_PLATFORM_CN
|
||||
from content_manager.util.timeutil import now_unix
|
||||
|
||||
|
||||
def seed_prompt_templates_if_empty(cur: sqlite3.Cursor) -> None:
|
||||
ts = now_unix()
|
||||
for platform, templates in PROMPT_TEMPLATE_SEEDS.items():
|
||||
cur.execute("SELECT COUNT(*) FROM prompt_templates WHERE platform = ?", (platform,))
|
||||
count = int(cur.fetchone()[0] or 0)
|
||||
if count > 0:
|
||||
continue
|
||||
for idx, tpl in enumerate(templates, start=1):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO prompt_templates (platform, name, template_text, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)
|
||||
""",
|
||||
(platform, f"{PUBLISH_PLATFORM_CN.get(platform, platform)}模板{idx}", tpl, ts, ts),
|
||||
)
|
||||
|
||||
|
||||
def count_by_platform(cur: sqlite3.Cursor, platform: str) -> int:
|
||||
cur.execute("SELECT COUNT(*) FROM prompt_templates WHERE platform = ?", (platform,))
|
||||
return int(cur.fetchone()[0] or 0)
|
||||
|
||||
|
||||
def fetch_active_templates(conn: sqlite3.Connection, platform: str) -> List[Tuple[Any, ...]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, platform, name, template_text
|
||||
FROM prompt_templates
|
||||
WHERE platform = ? AND is_active = 1
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(platform,),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def fetch_common_fallback(conn: sqlite3.Connection) -> List[Tuple[Any, ...]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, platform, name, template_text
|
||||
FROM prompt_templates
|
||||
WHERE platform = 'common' AND is_active = 1
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def pick_random_template(rows: List[Tuple[Any, ...]]) -> Optional[Dict[str, Any]]:
|
||||
if not rows:
|
||||
return None
|
||||
rid, p, name, text = random.choice(rows)
|
||||
return {"id": int(rid), "platform": p, "name": name, "template_text": text}
|
||||
|
||||
|
||||
def insert_usage(
|
||||
conn: sqlite3.Connection,
|
||||
template_id: int,
|
||||
llm_target: str,
|
||||
platform: str,
|
||||
topic: str,
|
||||
article_id: Optional[int],
|
||||
) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO prompt_template_usage (template_id, llm_target, platform, topic, article_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(template_id, llm_target, platform, topic, article_id, now_unix()),
|
||||
)
|
||||
|
||||
|
||||
def list_templates(
|
||||
conn: sqlite3.Connection,
|
||||
platform: Optional[str],
|
||||
limit: int,
|
||||
) -> List[Tuple[Any, ...]]:
|
||||
cur = conn.cursor()
|
||||
if platform:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, platform, name, is_active, updated_at
|
||||
FROM prompt_templates
|
||||
WHERE platform = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(platform, int(limit)),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, platform, name, is_active, updated_at
|
||||
FROM prompt_templates
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
73
content-manager/content_manager/db/schema.py
Normal file
73
content-manager/content_manager/db/schema.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""建表 SQL(不含迁移逻辑)。"""
|
||||
|
||||
ARTICLES_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
content_html TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
account_id TEXT,
|
||||
error_msg TEXT,
|
||||
llm_target TEXT,
|
||||
extra_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
IMAGES_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_path TEXT NOT NULL,
|
||||
title TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
account_id TEXT,
|
||||
error_msg TEXT,
|
||||
extra_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
VIDEOS_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_path TEXT NOT NULL,
|
||||
title TEXT,
|
||||
duration_ms INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
account_id TEXT,
|
||||
error_msg TEXT,
|
||||
extra_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
PROMPT_TEMPLATES_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS prompt_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
template_text TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
PROMPT_TEMPLATE_USAGE_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS prompt_template_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
llm_target TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
article_id INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
100
content-manager/content_manager/db/videos_repository.py
Normal file
100
content-manager/content_manager/db/videos_repository.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""videos 表:仅保存文件相对路径等元数据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
|
||||
def insert_row(
|
||||
conn: sqlite3.Connection,
|
||||
file_path: str,
|
||||
title: Optional[str],
|
||||
duration_ms: Optional[int],
|
||||
status: str,
|
||||
source: str,
|
||||
account_id: Optional[str],
|
||||
error_msg: Optional[str],
|
||||
extra_json: Optional[str],
|
||||
created_at: int,
|
||||
updated_at: int,
|
||||
) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO videos (
|
||||
file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
file_path,
|
||||
title,
|
||||
duration_ms,
|
||||
status,
|
||||
source,
|
||||
account_id,
|
||||
error_msg,
|
||||
extra_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_file_path(conn: sqlite3.Connection, video_id: int, file_path: str, updated_at: int) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE videos SET file_path = ?, updated_at = ? WHERE id = ?",
|
||||
(file_path, updated_at, video_id),
|
||||
)
|
||||
|
||||
|
||||
def fetch_by_id(conn: sqlite3.Connection, video_id: int) -> Optional[Tuple[Any, ...]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, created_at, updated_at
|
||||
FROM videos WHERE id = ?
|
||||
""",
|
||||
(video_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def list_recent(conn: sqlite3.Connection, limit: int) -> List[Tuple[Any, ...]]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, created_at, updated_at
|
||||
FROM videos ORDER BY updated_at DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def delete_by_id(conn: sqlite3.Connection, video_id: int) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM videos WHERE id = ?", (video_id,))
|
||||
return int(cur.rowcount)
|
||||
|
||||
|
||||
def update_feedback(
|
||||
conn: sqlite3.Connection,
|
||||
video_id: int,
|
||||
status: str,
|
||||
account_id: Optional[str],
|
||||
error_msg: Optional[str],
|
||||
updated_at: int,
|
||||
) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE videos
|
||||
SET status = ?, account_id = ?, error_msg = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, account_id, error_msg, updated_at, video_id),
|
||||
)
|
||||
1
content-manager/content_manager/services/__init__.py
Normal file
1
content-manager/content_manager/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 业务逻辑层(调用 db 仓储,不含 argparse)
|
||||
431
content-manager/content_manager/services/article_service.py
Normal file
431
content-manager/content_manager/services/article_service.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""文章:业务规则与编排(调用仓储 + llm-manager)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from content_manager.config import get_openclaw_root
|
||||
from content_manager.constants import PUBLISH_PLATFORM_CN, PUBLISH_PLATFORM_ALIASES
|
||||
from content_manager.db import articles_repository as ar
|
||||
from content_manager.db import prompts_repository as pr
|
||||
from content_manager.db.connection import get_conn, init_db
|
||||
from content_manager.util.timeutil import now_unix, unix_to_iso
|
||||
|
||||
|
||||
def _row_to_public_dict(row: tuple) -> Dict[str, Any]:
|
||||
rid, title, body, content_html, status, source, account_id, error_msg, llm_target, extra_json, cat, uat = row
|
||||
d: Dict[str, Any] = {
|
||||
"id": int(rid),
|
||||
"title": title,
|
||||
"content": body,
|
||||
"content_html": content_html if content_html else body,
|
||||
"status": status or "draft",
|
||||
"source": source or "manual",
|
||||
"account_id": account_id,
|
||||
"error_msg": error_msg,
|
||||
"llm_target": llm_target,
|
||||
"created_at": unix_to_iso(cat),
|
||||
"updated_at": unix_to_iso(uat),
|
||||
}
|
||||
if extra_json:
|
||||
try:
|
||||
ex = json.loads(extra_json)
|
||||
if isinstance(ex, dict):
|
||||
d["extra"] = ex
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def resolve_publish_platform(raw: Optional[str]) -> Optional[str]:
|
||||
s = (raw or "").strip().lower()
|
||||
if not s:
|
||||
return "common"
|
||||
for key, aliases in PUBLISH_PLATFORM_ALIASES.items():
|
||||
if s in {a.lower() for a in aliases}:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _choose_prompt_template(platform: str) -> Optional[Dict[str, Any]]:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = pr.fetch_active_templates(conn, platform)
|
||||
if not rows and platform != "common":
|
||||
rows = pr.fetch_common_fallback(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
return pr.pick_random_template(rows)
|
||||
|
||||
|
||||
def _build_prompt_from_template(template_text: str, topic: str, platform: str) -> str:
|
||||
platform_name = PUBLISH_PLATFORM_CN.get(platform, "通用")
|
||||
rendered = (
|
||||
template_text.replace("{topic}", topic).replace("{platform}", platform).replace("{platform_name}", platform_name)
|
||||
)
|
||||
return rendered.strip()
|
||||
|
||||
|
||||
def cmd_add(title: str, body: str, source: str = "manual", llm_target: Optional[str] = None) -> None:
|
||||
init_db()
|
||||
title = (title or "").strip() or "未命名"
|
||||
body = body or ""
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
new_id = ar.insert_article(
|
||||
conn,
|
||||
title=title,
|
||||
body=body,
|
||||
content_html=None,
|
||||
status="draft",
|
||||
source=source,
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
llm_target=llm_target,
|
||||
extra_json=None,
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"✅ 已新增文章 id={new_id} | {title}")
|
||||
|
||||
|
||||
def cmd_import_json(path: str) -> None:
|
||||
init_db()
|
||||
path = os.path.abspath(path.strip())
|
||||
if not os.path.isfile(path):
|
||||
print(f"❌ 找不到文件:{path}\n请检查路径是否正确、文件是否存在。")
|
||||
sys.exit(1)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
if isinstance(raw, dict) and "articles" in raw:
|
||||
items = raw["articles"]
|
||||
elif isinstance(raw, list):
|
||||
items = raw
|
||||
else:
|
||||
print(
|
||||
"❌ JSON 格式不对。\n"
|
||||
"正确格式二选一:① 文件里是数组 [ {\"title\":\"…\",\"body\":\"…\"}, … ]\n"
|
||||
"② 或对象 {\"articles\": [ … ] },数组里每项至少要有正文(body 或 content)。"
|
||||
)
|
||||
sys.exit(1)
|
||||
if not items:
|
||||
print("❌ JSON 里没有可导入的文章条目(数组为空)。")
|
||||
sys.exit(1)
|
||||
n = 0
|
||||
for i, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
print(f"❌ 第 {i + 1} 条不是 JSON 对象(应为 {{ \"title\":…, \"body\":… }})。")
|
||||
sys.exit(1)
|
||||
title = (item.get("title") or item.get("标题") or "").strip()
|
||||
body = item.get("body") or item.get("content") or item.get("正文") or ""
|
||||
if isinstance(body, dict):
|
||||
print(f"❌ 第 {i + 1} 条的 body/content 必须是字符串,不能是别的类型。")
|
||||
sys.exit(1)
|
||||
body = str(body)
|
||||
if not title and not body.strip():
|
||||
continue
|
||||
if not title:
|
||||
title = f"导入-{i + 1}"
|
||||
cmd_add(title, body, source="import")
|
||||
n += 1
|
||||
print(f"✅ 批量导入完成,共写入 {n} 篇")
|
||||
|
||||
|
||||
def _parse_llm_stdout(stdout: str) -> str:
|
||||
if "===LLM_START===" in stdout and "===LLM_END===" in stdout:
|
||||
chunk = stdout.split("===LLM_START===", 1)[1]
|
||||
chunk = chunk.split("===LLM_END===", 1)[0]
|
||||
return chunk.strip()
|
||||
return (stdout or "").strip()
|
||||
|
||||
|
||||
def _default_title_from_body(body: str) -> str:
|
||||
for line in body.splitlines():
|
||||
t = line.strip()
|
||||
if t:
|
||||
return t[:120] if len(t) > 120 else t
|
||||
return f"文稿-{now_unix()}"
|
||||
|
||||
|
||||
def cmd_generate(
|
||||
llm_target: str,
|
||||
topic: str,
|
||||
publish_platform: str = "common",
|
||||
title: Optional[str] = None,
|
||||
) -> None:
|
||||
llm_target = (llm_target or "").strip()
|
||||
topic = (topic or "").strip()
|
||||
publish_platform = (publish_platform or "common").strip().lower()
|
||||
if not llm_target or not topic:
|
||||
print(
|
||||
"❌ 生成参数不完整。\n"
|
||||
"请使用:python main.py article generate <模型> [发布平台] <主题或关键词>\n"
|
||||
"示例:python main.py article generate 豆包 搜狐号 RPA降本增效"
|
||||
)
|
||||
sys.exit(1)
|
||||
template = _choose_prompt_template(publish_platform)
|
||||
if not template:
|
||||
print("❌ 提示词模板库为空,请先补充模板后再执行 generate。")
|
||||
sys.exit(1)
|
||||
prompt = _build_prompt_from_template(template["template_text"], topic, publish_platform)
|
||||
script = os.path.join(get_openclaw_root(), "llm-manager", "scripts", "main.py")
|
||||
if not os.path.isfile(script):
|
||||
print(
|
||||
f"❌ 找不到大模型脚本:{script}\n"
|
||||
"请确认 llm-manager 与 content-manager 在同一上级目录(OpenClaw)下。"
|
||||
)
|
||||
sys.exit(1)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, script, "generate", llm_target, prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
out = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
||||
std = proc.stdout or ""
|
||||
has_markers = "===LLM_START===" in std and "===LLM_END===" in std
|
||||
if (proc.returncode != 0 and not has_markers) or (
|
||||
proc.returncode == 0 and not has_markers and re.search(r"(?m)^ERROR:", std)
|
||||
):
|
||||
print(
|
||||
(out.strip() or f"大模型进程退出码 {proc.returncode}")
|
||||
+ "\n❌ 生成失败:请根据上面说明处理(常见:先在「模型管理」添加并登录该平台账号,或配置 API Key)。"
|
||||
)
|
||||
sys.exit(1)
|
||||
body = _parse_llm_stdout(proc.stdout or out)
|
||||
if not body:
|
||||
print(
|
||||
"❌ 没有从大模型输出里取到正文。\n"
|
||||
"正常情况输出里应包含 ===LLM_START=== 与 ===LLM_END===;请重试或查看 llm-manager 是否正常打印。"
|
||||
)
|
||||
sys.exit(1)
|
||||
body = body.strip()
|
||||
if body.startswith("ERROR:"):
|
||||
print(out.strip())
|
||||
print(f"\n❌ 生成失败,未写入数据库。\n{body}")
|
||||
sys.exit(1)
|
||||
final_title = (title or "").strip() or _default_title_from_body(body)
|
||||
extra_payload = {
|
||||
"generate_meta": {
|
||||
"mode": "template",
|
||||
"topic": topic,
|
||||
"platform": publish_platform,
|
||||
"platform_cn": PUBLISH_PLATFORM_CN.get(publish_platform, publish_platform),
|
||||
"template_id": template["id"],
|
||||
"template_name": template["name"],
|
||||
}
|
||||
}
|
||||
init_db()
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
new_id = ar.insert_article(
|
||||
conn,
|
||||
title=final_title,
|
||||
body=body,
|
||||
content_html=None,
|
||||
status="draft",
|
||||
source="llm",
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
llm_target=llm_target,
|
||||
extra_json=json.dumps(extra_payload, ensure_ascii=False),
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
pr.insert_usage(conn, int(template["id"]), llm_target, publish_platform, topic, int(new_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print(
|
||||
f"✅ 已写入 LLM 文稿 id={new_id} | {final_title}\n"
|
||||
f" 模板:{template['name']} (id={template['id']}) | 平台:{PUBLISH_PLATFORM_CN.get(publish_platform, publish_platform)} | 主题:{topic}"
|
||||
)
|
||||
|
||||
|
||||
def cmd_prompt_list(platform: Optional[str] = None, limit: int = 30) -> None:
|
||||
init_db()
|
||||
if limit <= 0:
|
||||
limit = 30
|
||||
key = resolve_publish_platform(platform) if platform else None
|
||||
if platform and not key:
|
||||
print(f"❌ 不支持的平台:{platform}")
|
||||
print("支持:通用 / 搜狐号 / 头条号 / 公众号")
|
||||
sys.exit(1)
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = pr.list_templates(conn, key, limit)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
print("暂无提示词模板")
|
||||
return
|
||||
sep_line = "_" * 39
|
||||
for idx, (rid, p, name, active, uat) in enumerate(rows):
|
||||
print(f"id:{rid}")
|
||||
print(f"platform:{p}")
|
||||
print(f"platform_cn:{PUBLISH_PLATFORM_CN.get(p, p)}")
|
||||
print(f"name:{name}")
|
||||
print(f"is_active:{int(active)}")
|
||||
print(f"updated_at:{unix_to_iso(uat) or ''}")
|
||||
if idx != len(rows) - 1:
|
||||
print(sep_line)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_save(article_id: str, title: str, content: str) -> None:
|
||||
init_db()
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
if article_id.isdigit():
|
||||
aid = int(article_id)
|
||||
if ar.exists_id(conn, aid):
|
||||
ar.update_article_body(conn, aid, title, content, ts)
|
||||
conn.commit()
|
||||
print(f"✅ 已更新 id={aid} | {title}")
|
||||
return
|
||||
new_id = ar.insert_article(
|
||||
conn,
|
||||
title=title,
|
||||
body=content,
|
||||
content_html=None,
|
||||
status="draft",
|
||||
source="manual",
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
llm_target=None,
|
||||
extra_json=None,
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
conn.commit()
|
||||
print(f"✅ 已新建 id={new_id} | {title}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cmd_get(article_id: str) -> None:
|
||||
init_db()
|
||||
if not str(article_id).strip().isdigit():
|
||||
print("❌ 文章 id 必须是纯数字(整数)。请先 article list 查看最左一列编号。")
|
||||
sys.exit(1)
|
||||
aid = int(article_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = ar.fetch_by_id(conn, aid)
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
print("❌ 没有这篇文章:该 id 在库里不存在。请先执行 article list 核对编号。")
|
||||
sys.exit(1)
|
||||
print(json.dumps(_row_to_public_dict(row), ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_list(limit: int = 10, max_chars: int = 50) -> None:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = ar.list_recent(conn, limit)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
print("暂无文章")
|
||||
return
|
||||
|
||||
def maybe_truncate(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if len(text) > max_chars:
|
||||
return text[:max_chars] + "..."
|
||||
return text
|
||||
|
||||
sep_line = "_" * 39
|
||||
for idx, r in enumerate(rows):
|
||||
(
|
||||
rid,
|
||||
title,
|
||||
body,
|
||||
content_html,
|
||||
status,
|
||||
source,
|
||||
account_id,
|
||||
error_msg,
|
||||
llm_target,
|
||||
extra_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
) = r
|
||||
content = content_html if content_html else (body or "")
|
||||
|
||||
print(f"id:{rid}")
|
||||
print(f"title:{title or ''}")
|
||||
print("body:")
|
||||
print(maybe_truncate(body or ""))
|
||||
print("content:")
|
||||
print(maybe_truncate(content or ""))
|
||||
print(f"status:{status or ''}")
|
||||
print(f"source:{source or ''}")
|
||||
print(f"account_id:{account_id or ''}")
|
||||
print(f"error_msg:{error_msg or ''}")
|
||||
print(f"llm_target:{llm_target or ''}")
|
||||
print(f"extra_json:{extra_json or ''}")
|
||||
print(f"created_at:{unix_to_iso(created_at) or ''}")
|
||||
print(f"updated_at:{unix_to_iso(updated_at) or ''}")
|
||||
|
||||
if idx != len(rows) - 1:
|
||||
print(sep_line)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_delete(article_id: str) -> None:
|
||||
init_db()
|
||||
if not str(article_id).strip().isdigit():
|
||||
print("❌ 文章 id 必须是纯数字。请先 article list 查看。")
|
||||
sys.exit(1)
|
||||
aid = int(article_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
n = ar.delete_by_id(conn, aid)
|
||||
if n == 0:
|
||||
print("❌ 没有 id 为 {} 的文章,无法删除。".format(aid))
|
||||
sys.exit(1)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"✅ 已删除 id={aid}")
|
||||
|
||||
|
||||
def cmd_feedback(
|
||||
article_id: str,
|
||||
status: str,
|
||||
account_id: Optional[str] = None,
|
||||
error_msg: Optional[str] = None,
|
||||
) -> None:
|
||||
init_db()
|
||||
if not str(article_id).strip().isdigit():
|
||||
print("❌ 文章 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
aid = int(article_id)
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
if not ar.exists_id(conn, aid):
|
||||
print("❌ 没有 id 为 {} 的文章,无法回写状态。".format(aid))
|
||||
sys.exit(1)
|
||||
ar.update_feedback(conn, aid, status, account_id, error_msg, ts)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print("✅ 状态已更新")
|
||||
50
content-manager/content_manager/services/file_store.py
Normal file
50
content-manager/content_manager/services/file_store.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""媒体文件落盘:相对技能数据目录的路径约定。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def media_subdir(kind: str, media_id: int) -> str:
|
||||
"""kind: images | videos"""
|
||||
return f"{kind}/{media_id}"
|
||||
|
||||
|
||||
def original_basename(src_path: str) -> str:
|
||||
ext = os.path.splitext(src_path)[1]
|
||||
return f"original{ext if ext else ''}"
|
||||
|
||||
|
||||
def copy_into_skill_data(
|
||||
skill_data_dir: str,
|
||||
kind: str,
|
||||
media_id: int,
|
||||
src_path: str,
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
将源文件复制到 {skill_data_dir}/{kind}/{id}/original.ext
|
||||
返回 (relative_path, absolute_dest_path)
|
||||
"""
|
||||
sub = media_subdir(kind, media_id)
|
||||
dest_dir = os.path.join(skill_data_dir, sub.replace("/", os.sep))
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
base = original_basename(src_path)
|
||||
abs_dest = os.path.join(dest_dir, base)
|
||||
shutil.copy2(src_path, abs_dest)
|
||||
rel = f"{kind}/{media_id}/{base}".replace("\\", "/")
|
||||
return rel, abs_dest
|
||||
|
||||
|
||||
def remove_files_for_relative_path(skill_data_dir: str, relative_file_path: str) -> None:
|
||||
"""删除 relative_file_path 所在目录(整 id 目录)。"""
|
||||
rel = (relative_file_path or "").strip().replace("\\", "/")
|
||||
if not rel or "/" not in rel:
|
||||
return
|
||||
parts = rel.split("/")
|
||||
if len(parts) < 2:
|
||||
return
|
||||
id_dir = os.path.join(skill_data_dir, parts[0], parts[1])
|
||||
if os.path.isdir(id_dir):
|
||||
shutil.rmtree(id_dir, ignore_errors=True)
|
||||
171
content-manager/content_manager/services/image_service.py
Normal file
171
content-manager/content_manager/services/image_service.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""图片:业务规则(文件落盘 + 路径写入 images 表)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from content_manager.config import get_skill_data_dir, resolve_stored_path
|
||||
from content_manager.db import images_repository as ir
|
||||
from content_manager.db.connection import get_conn, init_db
|
||||
from content_manager.services import file_store
|
||||
from content_manager.util.timeutil import now_unix, unix_to_iso
|
||||
|
||||
|
||||
def _row_to_public_dict(row: tuple) -> Dict[str, Any]:
|
||||
rid, file_path, title, status, source, account_id, error_msg, extra_json, cat, uat = row
|
||||
abs_path = resolve_stored_path(str(file_path))
|
||||
d: Dict[str, Any] = {
|
||||
"id": int(rid),
|
||||
"kind": "image",
|
||||
"file_path": file_path,
|
||||
"absolute_path": abs_path,
|
||||
"title": title,
|
||||
"status": status or "draft",
|
||||
"source": source or "manual",
|
||||
"account_id": account_id,
|
||||
"error_msg": error_msg,
|
||||
"created_at": unix_to_iso(cat),
|
||||
"updated_at": unix_to_iso(uat),
|
||||
}
|
||||
if extra_json:
|
||||
try:
|
||||
ex = json.loads(extra_json)
|
||||
if isinstance(ex, dict):
|
||||
d["extra"] = ex
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def cmd_add(src_file: str, title: Optional[str] = None, source: str = "manual") -> None:
|
||||
init_db()
|
||||
src_file = os.path.abspath(src_file.strip())
|
||||
if not os.path.isfile(src_file):
|
||||
print(f"❌ 找不到文件:{src_file}")
|
||||
sys.exit(1)
|
||||
skill_data = get_skill_data_dir()
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
new_id = ir.insert_row(
|
||||
conn,
|
||||
file_path="",
|
||||
title=(title or "").strip() or None,
|
||||
status="draft",
|
||||
source=source,
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
extra_json=None,
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
rel, _abs = file_store.copy_into_skill_data(skill_data, "images", new_id, src_file)
|
||||
ir.update_file_path(conn, new_id, rel, now_unix())
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"✅ 已新增图片 id={new_id} | 路径:{rel}")
|
||||
|
||||
|
||||
def cmd_get(image_id: str) -> None:
|
||||
init_db()
|
||||
if not str(image_id).strip().isdigit():
|
||||
print("❌ 图片 id 必须是纯数字。请先 image list 查看。")
|
||||
sys.exit(1)
|
||||
iid = int(image_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = ir.fetch_by_id(conn, iid)
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
print("❌ 没有这条图片记录。")
|
||||
sys.exit(1)
|
||||
print(json.dumps(_row_to_public_dict(row), ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_list(limit: int = 20, max_chars: int = 80) -> None:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = ir.list_recent(conn, limit)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
print("暂无图片")
|
||||
return
|
||||
|
||||
def trunc(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
return s if len(s) <= max_chars else s[:max_chars] + "..."
|
||||
|
||||
sep = "_" * 39
|
||||
for idx, r in enumerate(rows):
|
||||
rid, file_path, title, status, source, account_id, error_msg, extra_json, cat, uat = r
|
||||
print(f"id:{rid}")
|
||||
print(f"file_path:{trunc(str(file_path or ''))}")
|
||||
print(f"title:{title or ''}")
|
||||
print(f"status:{status or ''}")
|
||||
print(f"source:{source or ''}")
|
||||
print(f"account_id:{account_id or ''}")
|
||||
print(f"error_msg:{error_msg or ''}")
|
||||
print(f"created_at:{unix_to_iso(cat) or ''}")
|
||||
print(f"updated_at:{unix_to_iso(uat) or ''}")
|
||||
if idx != len(rows) - 1:
|
||||
print(sep)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_delete(image_id: str) -> None:
|
||||
init_db()
|
||||
if not str(image_id).strip().isdigit():
|
||||
print("❌ 图片 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
iid = int(image_id)
|
||||
skill_data = get_skill_data_dir()
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = ir.fetch_by_id(conn, iid)
|
||||
if not row:
|
||||
print("❌ 没有 id 为 {} 的图片记录。".format(iid))
|
||||
sys.exit(1)
|
||||
rel = row[1]
|
||||
n = ir.delete_by_id(conn, iid)
|
||||
if n == 0:
|
||||
sys.exit(1)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
file_store.remove_files_for_relative_path(skill_data, str(rel))
|
||||
print(f"✅ 已删除图片 id={iid}")
|
||||
|
||||
|
||||
def cmd_feedback(
|
||||
image_id: str,
|
||||
status: str,
|
||||
account_id: Optional[str] = None,
|
||||
error_msg: Optional[str] = None,
|
||||
) -> None:
|
||||
init_db()
|
||||
if not str(image_id).strip().isdigit():
|
||||
print("❌ 图片 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
iid = int(image_id)
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
if ir.fetch_by_id(conn, iid) is None:
|
||||
print("❌ 没有 id 为 {} 的图片记录。".format(iid))
|
||||
sys.exit(1)
|
||||
ir.update_feedback(conn, iid, status, account_id, error_msg, ts)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print("✅ 状态已更新")
|
||||
179
content-manager/content_manager/services/video_service.py
Normal file
179
content-manager/content_manager/services/video_service.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""视频:业务规则(文件落盘 + 路径写入 videos 表)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from content_manager.config import get_skill_data_dir, resolve_stored_path
|
||||
from content_manager.db import videos_repository as vr
|
||||
from content_manager.db.connection import get_conn, init_db
|
||||
from content_manager.services import file_store
|
||||
from content_manager.util.timeutil import now_unix, unix_to_iso
|
||||
|
||||
|
||||
def _row_to_public_dict(row: tuple) -> Dict[str, Any]:
|
||||
rid, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, cat, uat = row
|
||||
abs_path = resolve_stored_path(str(file_path))
|
||||
d: Dict[str, Any] = {
|
||||
"id": int(rid),
|
||||
"kind": "video",
|
||||
"file_path": file_path,
|
||||
"absolute_path": abs_path,
|
||||
"title": title,
|
||||
"duration_ms": duration_ms,
|
||||
"status": status or "draft",
|
||||
"source": source or "manual",
|
||||
"account_id": account_id,
|
||||
"error_msg": error_msg,
|
||||
"created_at": unix_to_iso(cat),
|
||||
"updated_at": unix_to_iso(uat),
|
||||
}
|
||||
if extra_json:
|
||||
try:
|
||||
ex = json.loads(extra_json)
|
||||
if isinstance(ex, dict):
|
||||
d["extra"] = ex
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def cmd_add(
|
||||
src_file: str,
|
||||
title: Optional[str] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
source: str = "manual",
|
||||
) -> None:
|
||||
init_db()
|
||||
src_file = os.path.abspath(src_file.strip())
|
||||
if not os.path.isfile(src_file):
|
||||
print(f"❌ 找不到文件:{src_file}")
|
||||
sys.exit(1)
|
||||
skill_data = get_skill_data_dir()
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
new_id = vr.insert_row(
|
||||
conn,
|
||||
file_path="",
|
||||
title=(title or "").strip() or None,
|
||||
duration_ms=duration_ms,
|
||||
status="draft",
|
||||
source=source,
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
extra_json=None,
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
rel, _abs = file_store.copy_into_skill_data(skill_data, "videos", new_id, src_file)
|
||||
vr.update_file_path(conn, new_id, rel, now_unix())
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"✅ 已新增视频 id={new_id} | 路径:{rel}")
|
||||
|
||||
|
||||
def cmd_get(video_id: str) -> None:
|
||||
init_db()
|
||||
if not str(video_id).strip().isdigit():
|
||||
print("❌ 视频 id 必须是纯数字。请先 video list 查看。")
|
||||
sys.exit(1)
|
||||
vid = int(video_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = vr.fetch_by_id(conn, vid)
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
print("❌ 没有这条视频记录。")
|
||||
sys.exit(1)
|
||||
print(json.dumps(_row_to_public_dict(row), ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_list(limit: int = 20, max_chars: int = 80) -> None:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = vr.list_recent(conn, limit)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
print("暂无视频")
|
||||
return
|
||||
|
||||
def trunc(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
return s if len(s) <= max_chars else s[:max_chars] + "..."
|
||||
|
||||
sep = "_" * 39
|
||||
for idx, r in enumerate(rows):
|
||||
rid, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, cat, uat = r
|
||||
print(f"id:{rid}")
|
||||
print(f"file_path:{trunc(str(file_path or ''))}")
|
||||
print(f"title:{title or ''}")
|
||||
print(f"duration_ms:{duration_ms if duration_ms is not None else ''}")
|
||||
print(f"status:{status or ''}")
|
||||
print(f"source:{source or ''}")
|
||||
print(f"account_id:{account_id or ''}")
|
||||
print(f"error_msg:{error_msg or ''}")
|
||||
print(f"created_at:{unix_to_iso(cat) or ''}")
|
||||
print(f"updated_at:{unix_to_iso(uat) or ''}")
|
||||
if idx != len(rows) - 1:
|
||||
print(sep)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_delete(video_id: str) -> None:
|
||||
init_db()
|
||||
if not str(video_id).strip().isdigit():
|
||||
print("❌ 视频 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
vid = int(video_id)
|
||||
skill_data = get_skill_data_dir()
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = vr.fetch_by_id(conn, vid)
|
||||
if not row:
|
||||
print("❌ 没有 id 为 {} 的视频记录。".format(vid))
|
||||
sys.exit(1)
|
||||
rel = row[1]
|
||||
n = vr.delete_by_id(conn, vid)
|
||||
if n == 0:
|
||||
sys.exit(1)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
file_store.remove_files_for_relative_path(skill_data, str(rel))
|
||||
print(f"✅ 已删除视频 id={vid}")
|
||||
|
||||
|
||||
def cmd_feedback(
|
||||
video_id: str,
|
||||
status: str,
|
||||
account_id: Optional[str] = None,
|
||||
error_msg: Optional[str] = None,
|
||||
) -> None:
|
||||
init_db()
|
||||
if not str(video_id).strip().isdigit():
|
||||
print("❌ 视频 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
vid = int(video_id)
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
if vr.fetch_by_id(conn, vid) is None:
|
||||
print("❌ 没有 id 为 {} 的视频记录。".format(vid))
|
||||
sys.exit(1)
|
||||
vr.update_feedback(conn, vid, status, account_id, error_msg, ts)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print("✅ 状态已更新")
|
||||
1
content-manager/content_manager/util/__init__.py
Normal file
1
content-manager/content_manager/util/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 工具函数
|
||||
74
content-manager/content_manager/util/argparse_zh.py
Normal file
74
content-manager/content_manager/util/argparse_zh.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""argparse 中文错误说明。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
from content_manager.constants import CLI_REQUIRED_ZH
|
||||
|
||||
|
||||
def split_required_arg_names(raw: str) -> List[str]:
|
||||
s = raw.replace(" and ", ", ").strip()
|
||||
parts: List[str] = []
|
||||
for chunk in s.split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
idx = chunk.find(" --")
|
||||
if idx != -1:
|
||||
left = chunk[:idx].strip()
|
||||
flag_rest = chunk[idx + 1 :].strip().split()
|
||||
if left:
|
||||
parts.append(left)
|
||||
if flag_rest:
|
||||
parts.append(flag_rest[0])
|
||||
else:
|
||||
parts.append(chunk)
|
||||
return [p for p in parts if p]
|
||||
|
||||
|
||||
def explain_argparse_error(message: str) -> str:
|
||||
m = (message or "").strip()
|
||||
lines: List[str] = ["【命令参数不完整或写错了】请对照下面修改后再执行。"]
|
||||
if "the following arguments are required:" in m:
|
||||
raw = m.split("required:", 1)[-1].strip()
|
||||
parts = split_required_arg_names(raw)
|
||||
for p in parts:
|
||||
hint = CLI_REQUIRED_ZH.get(p)
|
||||
if not hint and p.startswith("--"):
|
||||
hint = CLI_REQUIRED_ZH.get(p.split()[0], None)
|
||||
lines.append(f" · {hint or f'还缺这一项:{p}'}")
|
||||
lines.append(" · 查看全部:python main.py -h")
|
||||
lines.append(" · 查看分组:python main.py article -h")
|
||||
return "\n".join(lines)
|
||||
if "one of the arguments" in m and "required" in m:
|
||||
lines.append(" · 本命令要求下面几组参数里「必须选其中一组」,不能都不写。")
|
||||
if "--body-file" in m and "--body" in m:
|
||||
lines.append(" · 请任选其一:--body-file 某文件路径 或 --body \"正文文字\"")
|
||||
else:
|
||||
lines.append(f" · 说明:{m}")
|
||||
lines.append(" · 查看该子命令:python main.py article add -h")
|
||||
return "\n".join(lines)
|
||||
if "unrecognized arguments:" in m:
|
||||
tail = m.split("unrecognized arguments:", 1)[-1].strip()
|
||||
lines.append(f" · 多写了不认识的参数:{tail},请删除或检查拼写。")
|
||||
lines.append(" · 查看用法:python main.py -h")
|
||||
return "\n".join(lines)
|
||||
if "invalid choice:" in m:
|
||||
lines.append(f" · {m}")
|
||||
return "\n".join(lines)
|
||||
if "expected one argument" in m:
|
||||
lines.append(f" · {m}")
|
||||
lines.append(" · 提示:--xxx 后面必须跟一个值,不要忘记。")
|
||||
return "\n".join(lines)
|
||||
lines.append(f" · {m}")
|
||||
lines.append(" · 查看帮助:python main.py -h")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class ZhArgumentParser(argparse.ArgumentParser):
|
||||
def error(self, message: str) -> None:
|
||||
print(explain_argparse_error(message), file=sys.stderr)
|
||||
self.exit(2)
|
||||
36
content-manager/content_manager/util/timeutil.py
Normal file
36
content-manager/content_manager/util/timeutil.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""时间戳工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def now_unix() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def unix_to_iso(ts: Optional[int]) -> Optional[str]:
|
||||
if ts is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds")
|
||||
except (ValueError, OSError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_ts_to_unix(val: Any) -> Optional[int]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, (int, float)):
|
||||
return int(val)
|
||||
s = str(val).strip()
|
||||
if not s:
|
||||
return None
|
||||
if s.isdigit():
|
||||
return int(s)
|
||||
try:
|
||||
return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
|
||||
except ValueError:
|
||||
return None
|
||||
Reference in New Issue
Block a user