Add OpenClaw skills, platform kit, and template docs

Made-with: Cursor
This commit is contained in:
2026-04-04 10:35:02 +08:00
parent e37b03c00f
commit 35f4758da2
83 changed files with 8971 additions and 0 deletions

View File

@@ -0,0 +1 @@
# content-manager 技能:文章 / 图片 / 视频 分层包

View File

@@ -0,0 +1 @@
# CLI解析参数并调用 services

View 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

View 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))

View 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}”撰写可直接发公众号的文章,要求原创、完整、可读。",
],
}

View File

@@ -0,0 +1,3 @@
from content_manager.db.connection import get_conn, init_db
__all__ = ["get_conn", "init_db"]

View 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),
)

View 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()

View 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),
)

View 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())

View 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
);
"""

View 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),
)

View File

@@ -0,0 +1 @@
# 业务逻辑层(调用 db 仓储,不含 argparse

View 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("✅ 状态已更新")

View 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)

View 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("✅ 状态已更新")

View 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("✅ 状态已更新")

View File

@@ -0,0 +1 @@
# 工具函数

View 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)

View 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