redbeacon 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. redbeacon/__init__.py +5 -0
  2. redbeacon/cli.py +191 -0
  3. redbeacon/config.py +102 -0
  4. redbeacon/config_keys.py +55 -0
  5. redbeacon/database.py +258 -0
  6. redbeacon/render_xhs_v2.py +769 -0
  7. redbeacon/routers/__init__.py +0 -0
  8. redbeacon/routers/_runtime.py +82 -0
  9. redbeacon/routers/accounts.py +92 -0
  10. redbeacon/routers/config.py +110 -0
  11. redbeacon/routers/content.py +114 -0
  12. redbeacon/routers/feishu.py +132 -0
  13. redbeacon/routers/generate.py +50 -0
  14. redbeacon/routers/login.py +146 -0
  15. redbeacon/routers/logs.py +18 -0
  16. redbeacon/routers/publish.py +25 -0
  17. redbeacon/routers/readiness.py +97 -0
  18. redbeacon/routers/status.py +39 -0
  19. redbeacon/routers/strategy.py +52 -0
  20. redbeacon/routers/topics.py +182 -0
  21. redbeacon/schemas.py +304 -0
  22. redbeacon/services/__init__.py +0 -0
  23. redbeacon/services/feishu_api.py +303 -0
  24. redbeacon/services/image_gen.py +206 -0
  25. redbeacon/services/mcp_manager.py +74 -0
  26. redbeacon/services/proxy_service.py +99 -0
  27. redbeacon/services/xhs/__init__.py +17 -0
  28. redbeacon/services/xhs/login.py +183 -0
  29. redbeacon/services/xhs/publish.py +879 -0
  30. redbeacon/services/xhs/session.py +230 -0
  31. redbeacon/tasks/__init__.py +0 -0
  32. redbeacon/tasks/feishu_sync.py +160 -0
  33. redbeacon/tasks/generate.py +772 -0
  34. redbeacon/tasks/publish.py +598 -0
  35. redbeacon/utils/__init__.py +0 -0
  36. redbeacon/utils/crypto.py +28 -0
  37. redbeacon/utils/logger.py +38 -0
  38. redbeacon/utils/paths.py +22 -0
  39. redbeacon-0.1.0.dist-info/METADATA +162 -0
  40. redbeacon-0.1.0.dist-info/RECORD +44 -0
  41. redbeacon-0.1.0.dist-info/WHEEL +5 -0
  42. redbeacon-0.1.0.dist-info/entry_points.txt +2 -0
  43. redbeacon-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. redbeacon-0.1.0.dist-info/top_level.txt +1 -0
redbeacon/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """RedBeacon — 小红书自动化 CLI。
2
+
3
+ 入口:`redbeacon` 命令(pyproject.toml 声明),实现在 redbeacon.cli:main。
4
+ """
5
+ __version__ = "0.1.0"
redbeacon/cli.py ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """RedBeacon CLI — skill 入口。
3
+
4
+ 约定:
5
+ - 成功:exit 0 + stdout JSON
6
+ - 失败:exit ≠0 + stderr {"error": str}
7
+ - 输出 shape 见 redbeacon.schemas
8
+ - 子命令实现在 redbeacon.routers.<feature>
9
+
10
+ 新增子命令:在 routers/ 下加文件 + 在 _build_parser/_DISPATCH 注册即可。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from redbeacon import __version__
20
+ from redbeacon.utils.paths import default_data_dir, default_log_dir
21
+
22
+ DATA_DIR = str(os.environ.get("REDBEACON_DATA_DIR", default_data_dir()))
23
+ LOG_DIR = str(os.environ.get("REDBEACON_LOG_DIR", default_log_dir()))
24
+
25
+ from redbeacon.utils.logger import init_logger
26
+ from redbeacon import database
27
+
28
+ init_logger(LOG_DIR)
29
+ database.init_db(DATA_DIR)
30
+
31
+ from redbeacon.routers import (
32
+ accounts as r_accounts,
33
+ login as r_login,
34
+ strategy as r_strategy,
35
+ topics as r_topics,
36
+ content as r_content,
37
+ generate as r_generate,
38
+ publish as r_publish,
39
+ feishu as r_feishu,
40
+ config as r_config,
41
+ status as r_status,
42
+ logs as r_logs,
43
+ readiness as r_readiness,
44
+ )
45
+
46
+
47
+ # ── CLI Parser ────────────────────────────────────────────────────────────────
48
+
49
+ def _build_parser() -> argparse.ArgumentParser:
50
+ p = argparse.ArgumentParser(prog="redbeacon", description="RedBeacon CLI — 小红书自动化")
51
+ p.add_argument("--version", action="version", version=f"redbeacon {__version__}")
52
+ sp = p.add_subparsers(dest="cmd", required=True)
53
+
54
+ # accounts
55
+ acc = sp.add_parser("accounts")
56
+ acc_sp = acc.add_subparsers(dest="sub", required=True)
57
+ acc_sp.add_parser("list")
58
+ _add_account_arg(acc_sp.add_parser("get"))
59
+ acc_sp.add_parser("create")
60
+ _add_account_arg(acc_sp.add_parser("delete"))
61
+ acc_pat = acc_sp.add_parser("patch")
62
+ _add_account_arg(acc_pat)
63
+ acc_pat.add_argument("--data", required=True, help='JSON: {"field": "value"}')
64
+
65
+ # login
66
+ login = sp.add_parser("login")
67
+ login_sp = login.add_subparsers(dest="sub", required=True)
68
+ for sub in ("start", "verify", "status", "delete"):
69
+ _add_account_arg(login_sp.add_parser(sub))
70
+
71
+ # strategy
72
+ strat = sp.add_parser("strategy")
73
+ strat_sp = strat.add_subparsers(dest="sub", required=True)
74
+ _add_account_arg(strat_sp.add_parser("get"))
75
+ sp2 = strat_sp.add_parser("patch")
76
+ _add_account_arg(sp2)
77
+ sp2.add_argument("--data", required=True, help='JSON: {"field": "value"}')
78
+
79
+ # topics
80
+ top = sp.add_parser("topics")
81
+ top_sp = top.add_subparsers(dest="sub", required=True)
82
+ tl = top_sp.add_parser("list")
83
+ _add_account_arg(tl)
84
+ tl.add_argument("--type", default=None)
85
+ tl.add_argument("--used", type=int, default=None)
86
+ tl.add_argument("--limit", type=int, default=100)
87
+ tl.add_argument("--offset", type=int, default=0)
88
+ ta = top_sp.add_parser("add"); _add_account_arg(ta)
89
+ ta.add_argument("--content", required=True)
90
+ ta.add_argument("--type", required=True)
91
+ tb = top_sp.add_parser("batch"); _add_account_arg(tb)
92
+ tb.add_argument("--type", required=True)
93
+ tb.add_argument("--text", default=None, help="换行分隔的选题,不传则从 stdin 读取")
94
+ ti = top_sp.add_parser("inspire"); _add_account_arg(ti)
95
+ ti.add_argument("--text", required=True)
96
+ _add_account_arg(top_sp.add_parser("stats"))
97
+ tr = top_sp.add_parser("reset"); _add_account_arg(tr)
98
+ tr.add_argument("--type", default=None)
99
+ _add_account_arg(top_sp.add_parser("types"))
100
+ _add_account_arg(top_sp.add_parser("types-init"))
101
+
102
+ # content
103
+ cont = sp.add_parser("content")
104
+ cont_sp = cont.add_subparsers(dest="sub", required=True)
105
+ cl = cont_sp.add_parser("list"); _add_account_arg(cl)
106
+ cl.add_argument("--status", default=None)
107
+ cl.add_argument("--limit", type=int, default=20)
108
+ cl.add_argument("--offset", type=int, default=0)
109
+ for sub in ("get", "approve", "reject", "edit"):
110
+ s = cont_sp.add_parser(sub); _add_account_arg(s)
111
+ s.add_argument("--id", type=int, required=True)
112
+ if sub == "reject":
113
+ s.add_argument("--comment", default="")
114
+ elif sub == "edit":
115
+ s.add_argument("--title", default=None)
116
+ s.add_argument("--body", default=None)
117
+ s.add_argument("--tags", default=None, help='JSON array: ["#tag1","#tag2"]')
118
+ cont_sp.add_parser("feishu-push")
119
+
120
+ # generate
121
+ gen = sp.add_parser("generate"); _add_account_arg(gen)
122
+ gen.add_argument("--topic", default=None)
123
+ gen.add_argument("--image-mode", default=None, dest="image_mode",
124
+ choices=["cards", "ai", "both"])
125
+ gen.add_argument("--content-type", default=None, dest="content_type")
126
+ gen.add_argument("--pillar", default=None)
127
+
128
+ # publish
129
+ _add_account_arg(sp.add_parser("publish"))
130
+
131
+ # feishu
132
+ fei = sp.add_parser("feishu")
133
+ fei_sp = fei.add_subparsers(dest="sub", required=True)
134
+ fs = fei_sp.add_parser("sync")
135
+ fs.add_argument("--account-id", type=int, default=None, dest="account_id")
136
+ fsu = fei_sp.add_parser("setup"); _add_account_arg(fsu)
137
+ fsu.add_argument("--app-token", default=None, dest="app_token")
138
+ fsu.add_argument("--table-id", default=None, dest="table_id")
139
+ _add_account_arg(fei_sp.add_parser("test"))
140
+
141
+ # config
142
+ conf = sp.add_parser("config")
143
+ conf_sp = conf.add_subparsers(dest="sub", required=True)
144
+ cg = conf_sp.add_parser("get"); cg.add_argument("key")
145
+ cs = conf_sp.add_parser("set"); cs.add_argument("key"); cs.add_argument("value")
146
+ for sub in ("list", "models", "test-ai", "test-feishu", "feishu-users"):
147
+ conf_sp.add_parser(sub)
148
+
149
+ # readiness / status / logs
150
+ sp.add_parser("readiness")
151
+ sp.add_parser("status")
152
+ log_p = sp.add_parser("logs")
153
+ log_p.add_argument("--tail", type=int, default=150)
154
+
155
+ return p
156
+
157
+
158
+ def _add_account_arg(parser: argparse.ArgumentParser) -> None:
159
+ parser.add_argument("--account-id", type=int, required=True, dest="account_id")
160
+
161
+
162
+ # ── Dispatch ──────────────────────────────────────────────────────────────────
163
+
164
+ _DISPATCH = {
165
+ "accounts": r_accounts.dispatch,
166
+ "login": r_login.dispatch,
167
+ "strategy": r_strategy.dispatch,
168
+ "topics": r_topics.dispatch,
169
+ "content": r_content.dispatch,
170
+ "generate": r_generate.dispatch,
171
+ "publish": r_publish.dispatch,
172
+ "feishu": r_feishu.dispatch,
173
+ "config": r_config.dispatch,
174
+ "status": r_status.dispatch,
175
+ "logs": r_logs.dispatch,
176
+ "readiness": r_readiness.dispatch,
177
+ }
178
+
179
+
180
+ def main():
181
+ args = _build_parser().parse_args()
182
+ fn = _DISPATCH.get(args.cmd)
183
+ if fn is None:
184
+ import json
185
+ print(json.dumps({"error": f"未知命令:{args.cmd}"}, ensure_ascii=False), file=sys.stderr)
186
+ sys.exit(1)
187
+ fn(args)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
redbeacon/config.py ADDED
@@ -0,0 +1,102 @@
1
+ """全局配置读写(settings 表)。
2
+
3
+ 支持两种 key 形态:
4
+ - 字符串:"ai_api_key"
5
+ - Key 常量:config_keys.CK_AI_API_KEY(推荐,附带默认值、加密标志)
6
+
7
+ 敏感字段(is encrypted=True)写入时自动加密,读取时自动解密。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Union
13
+
14
+ from redbeacon.database import conn
15
+ from redbeacon.utils.crypto import encrypt, decrypt
16
+ from redbeacon.config_keys import Key, ENCRYPTED_KEY_NAMES, ALL_KEYS
17
+
18
+ _log = logging.getLogger("config")
19
+
20
+ KeyArg = Union[str, Key]
21
+
22
+
23
+ def _name_and_default(key: KeyArg, default: str) -> tuple[str, str]:
24
+ if isinstance(key, Key):
25
+ return key.name, (default if default else key.default)
26
+ return key, default
27
+
28
+
29
+ def get(key: KeyArg, default: str = "") -> str:
30
+ name, default = _name_and_default(key, default)
31
+ c = conn()
32
+ row = c.execute(
33
+ "SELECT value, is_encrypted FROM settings WHERE key=?", (name,)
34
+ ).fetchone()
35
+ c.close()
36
+ if row is None:
37
+ return default
38
+ value = row["value"]
39
+ if row["is_encrypted"] and value:
40
+ try:
41
+ value = decrypt(value)
42
+ except Exception:
43
+ _log.warning(
44
+ "配置项 '%s' 解密失败(可能是机器迁移导致密钥不匹配),"
45
+ "请重新保存该字段", name,
46
+ )
47
+ c2 = conn()
48
+ c2.execute(
49
+ "UPDATE settings SET value='', updated_at=datetime('now') WHERE key=?",
50
+ (name,),
51
+ )
52
+ c2.commit()
53
+ c2.close()
54
+ return default
55
+ return value
56
+
57
+
58
+ def set(key: KeyArg, value: str) -> None:
59
+ name = key.name if isinstance(key, Key) else key
60
+ is_enc = 1 if name in ENCRYPTED_KEY_NAMES else 0
61
+ stored = encrypt(value) if is_enc and value else value
62
+ c = conn()
63
+ c.execute(
64
+ """INSERT INTO settings (key, value, is_encrypted, updated_at)
65
+ VALUES (?, ?, ?, datetime('now'))
66
+ ON CONFLICT(key) DO UPDATE SET
67
+ value=excluded.value,
68
+ is_encrypted=excluded.is_encrypted,
69
+ updated_at=excluded.updated_at""",
70
+ (name, stored, is_enc),
71
+ )
72
+ c.commit()
73
+ c.close()
74
+
75
+
76
+ _SENTINEL = "__SET__" # 表示"已设置但不回传明文"
77
+
78
+
79
+ def get_all_public() -> dict:
80
+ """返回所有配置;加密字段已设置则返回哨兵值 __SET__,未设置或解密失败返回 ""。"""
81
+ c = conn()
82
+ rows = c.execute("SELECT key, value, is_encrypted FROM settings").fetchall()
83
+ c.close()
84
+ result: dict[str, str] = {}
85
+ for r in rows:
86
+ if r["is_encrypted"]:
87
+ if not r["value"]:
88
+ result[r["key"]] = ""
89
+ else:
90
+ try:
91
+ decrypt(r["value"])
92
+ result[r["key"]] = _SENTINEL
93
+ except Exception:
94
+ result[r["key"]] = ""
95
+ else:
96
+ result[r["key"]] = r["value"]
97
+ return result
98
+
99
+
100
+ def registered_keys() -> tuple[Key, ...]:
101
+ """供 CLI / 文档使用:枚举所有注册的 key。"""
102
+ return ALL_KEYS
@@ -0,0 +1,55 @@
1
+ """所有 settings 表的 key 集中定义。
2
+
3
+ 新增 key 必须在此声明(key 名、默认值、是否加密、说明)。
4
+ 读取统一走 `cfg.get(CK_*)`,不再传字符串。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import NamedTuple
9
+
10
+ from redbeacon.utils.paths import default_data_dir
11
+
12
+
13
+ class Key(NamedTuple):
14
+ name: str
15
+ default: str
16
+ encrypted: bool
17
+ desc: str
18
+
19
+
20
+ # ── AI ────────────────────────────────────────────────────────────────────────
21
+ CK_AI_API_KEY = Key("ai_api_key", "", True, "OpenAI 兼容 API Key(加密)")
22
+ CK_AI_BASE_URL = Key("ai_base_url", "https://api.openai.com/v1", False, "OpenAI 兼容 Base URL")
23
+ CK_AI_MODEL = Key("ai_model", "gpt-4o-mini", False, "文案生成默认模型")
24
+ CK_IMAGE_MODEL = Key("image_model", "", False, "图片生成默认模型")
25
+
26
+ # ── 飞书 ──────────────────────────────────────────────────────────────────────
27
+ CK_FEISHU_APP_ID = Key("feishu_app_id", "", False, "飞书应用 App ID")
28
+ CK_FEISHU_APP_SECRET = Key("feishu_app_secret", "", True, "飞书应用 App Secret(加密)")
29
+ CK_FEISHU_USER_ID = Key("feishu_user_id", "", False, "默认通知接收 user ID(账号级覆盖此值)")
30
+ # 注:feishu_app_token / feishu_table_id 已迁移到 account 表,此处不再列出
31
+
32
+ # ── 发布全局开关 ──────────────────────────────────────────────────────────────
33
+ CK_PUBLISH_IS_ORIGINAL = Key("publish_is_original", "false", False, "默认是否勾选原创声明")
34
+ CK_PUBLISH_IS_AI_GENERATED = Key("publish_is_ai_generated", "true", False, "默认是否勾选 AI 生成声明")
35
+ CK_PUBLISH_VISIBILITY = Key("publish_visibility", "公开可见", False, "默认可见范围")
36
+
37
+ # ── 浏览器 / 代理 ─────────────────────────────────────────────────────────────
38
+ CK_MCP_VISIBLE = Key("mcp_visible", "false", False, "发布时浏览器可见(true 则 headless=False)")
39
+ CK_PROXY_AUTO_ROTATE = Key("proxy_auto_rotate", "false", False, "每次发布前自动换 IP")
40
+ CK_PROXY_API_URL = Key("proxy_api_url", "", False, "代理获取 API URL")
41
+ CK_PROXY_SPEED_TEST = Key("proxy_speed_test", "false", False, "代理 IP 测速过滤")
42
+
43
+ # ── 路径 ──────────────────────────────────────────────────────────────────────
44
+ CK_DATA_DIR = Key("data_dir", str(default_data_dir()), False, "数据目录(被 REDBEACON_DATA_DIR 环境变量覆盖)")
45
+
46
+
47
+ ALL_KEYS: tuple[Key, ...] = (
48
+ CK_AI_API_KEY, CK_AI_BASE_URL, CK_AI_MODEL, CK_IMAGE_MODEL,
49
+ CK_FEISHU_APP_ID, CK_FEISHU_APP_SECRET, CK_FEISHU_USER_ID,
50
+ CK_PUBLISH_IS_ORIGINAL, CK_PUBLISH_IS_AI_GENERATED, CK_PUBLISH_VISIBILITY,
51
+ CK_MCP_VISIBLE, CK_PROXY_AUTO_ROTATE, CK_PROXY_API_URL, CK_PROXY_SPEED_TEST,
52
+ CK_DATA_DIR,
53
+ )
54
+
55
+ ENCRYPTED_KEY_NAMES: frozenset[str] = frozenset(k.name for k in ALL_KEYS if k.encrypted)
redbeacon/database.py ADDED
@@ -0,0 +1,258 @@
1
+ import sqlite3
2
+ from contextlib import contextmanager
3
+ from pathlib import Path
4
+ from typing import Iterator
5
+
6
+ DB_PATH: Path | None = None
7
+
8
+
9
+ def init_db(data_dir: str) -> None:
10
+ """初始化数据库路径,创建所有表。启动时调用一次。"""
11
+ global DB_PATH
12
+ p = Path(data_dir)
13
+ p.mkdir(parents=True, exist_ok=True)
14
+ DB_PATH = p / "redbeacon.db"
15
+ _create_tables()
16
+
17
+
18
+ def conn() -> sqlite3.Connection:
19
+ if DB_PATH is None:
20
+ raise RuntimeError("数据库未初始化,请先调用 init_db()")
21
+ c = sqlite3.connect(DB_PATH, timeout=30)
22
+ c.row_factory = sqlite3.Row
23
+ c.execute("PRAGMA journal_mode=WAL") # 允许并发读
24
+ c.execute("PRAGMA foreign_keys=ON")
25
+ return c
26
+
27
+
28
+ @contextmanager
29
+ def cursor(commit: bool = False) -> Iterator[sqlite3.Connection]:
30
+ """连接上下文管理器:进入返回 sqlite3.Connection,退出自动 close。
31
+
32
+ commit=True 时退出前 commit(写入操作)。异常时不会 commit。
33
+ 使用:
34
+ with database.cursor() as c:
35
+ row = c.execute("SELECT ...").fetchone()
36
+ with database.cursor(commit=True) as c:
37
+ c.execute("INSERT ...")
38
+ """
39
+ c = conn()
40
+ try:
41
+ yield c
42
+ if commit:
43
+ c.commit()
44
+ finally:
45
+ c.close()
46
+
47
+
48
+ def _seed_defaults(c: sqlite3.Connection) -> None:
49
+ """写入默认配置行,已存在的 key 不覆盖。"""
50
+ defaults = [
51
+ ("publish_is_original", "false", 0),
52
+ ("publish_is_ai_generated", "true", 0),
53
+ ("publish_visibility", "公开可见", 0),
54
+ ]
55
+ for key, value, is_enc in defaults:
56
+ c.execute(
57
+ """INSERT OR IGNORE INTO settings (key, value, is_encrypted, updated_at)
58
+ VALUES (?, ?, ?, datetime('now'))""",
59
+ (key, value, is_enc),
60
+ )
61
+ c.commit()
62
+
63
+
64
+ def _migrate(c: sqlite3.Connection) -> None:
65
+ """添加新列(幂等,忽略已存在错误)。"""
66
+ migrations = [
67
+ ("content_queue", "tags", "TEXT NOT NULL DEFAULT '[]'"),
68
+ ("content_queue", "image_prompt", "TEXT"),
69
+ ("content_queue", "content_type", "TEXT"),
70
+ # 账号级飞书配置(每账号对应一张飞书表格)
71
+ ("account", "display_name", "TEXT"),
72
+ ("account", "feishu_app_token", "TEXT"),
73
+ ("account", "feishu_table_id", "TEXT"),
74
+ ("account", "feishu_user_id", "TEXT"),
75
+ # MCP 启动模式(1=无头,0=有头显示浏览器)
76
+ ("account", "mcp_headless", "INTEGER NOT NULL DEFAULT 1"),
77
+ # 账号级自动生成配置
78
+ ("account", "auto_generate_enabled", "INTEGER NOT NULL DEFAULT 1"),
79
+ ("account", "generate_schedule_json", "TEXT"),
80
+ # 图片模板选择模式:specific=使用激活模板,random=每次随机选一个
81
+ ("image_strategy", "template_mode", "TEXT NOT NULL DEFAULT 'specific'"),
82
+ ]
83
+ for table, col, typedef in migrations:
84
+ try:
85
+ c.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
86
+ c.commit()
87
+ except Exception as e:
88
+ if "duplicate column" not in str(e).lower():
89
+ import logging as _log
90
+ _log.getLogger("database").warning(f"[migrate] {table}.{col}: {e}")
91
+
92
+ # 数据迁移:将全局 settings 中的飞书表格参数迁移到 account 表第一行
93
+ _migrate_feishu_to_account(c)
94
+
95
+
96
+ def _migrate_feishu_to_account(c: sqlite3.Connection) -> None:
97
+ """一次性将 settings 里的 feishu_app_token/table_id/user_id 迁移到 account id=1。"""
98
+ acc = c.execute("SELECT id, feishu_app_token FROM account WHERE id=1").fetchone()
99
+ if acc is None or acc["feishu_app_token"]:
100
+ return # 没有账号,或已迁移过
101
+
102
+ keys = ("feishu_app_token", "feishu_table_id", "feishu_user_id")
103
+ vals: dict[str, str] = {}
104
+ for key in keys:
105
+ row = c.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
106
+ if row and row["value"]:
107
+ vals[key] = row["value"]
108
+
109
+ if not vals:
110
+ return
111
+
112
+ sets = ", ".join(f"{k}=?" for k in vals)
113
+ c.execute(f"UPDATE account SET {sets} WHERE id=1", list(vals.values()))
114
+ # 从 settings 删除已迁移的 key(避免歧义)
115
+ for key in vals:
116
+ c.execute("DELETE FROM settings WHERE key=?", (key,))
117
+ c.commit()
118
+
119
+
120
+ def _create_tables() -> None:
121
+ c = conn()
122
+ c.executescript("""
123
+ -- 全局配置(API Key 等加密存储)
124
+ CREATE TABLE IF NOT EXISTS settings (
125
+ key TEXT PRIMARY KEY,
126
+ value TEXT NOT NULL DEFAULT '',
127
+ is_encrypted INTEGER NOT NULL DEFAULT 0,
128
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
129
+ );
130
+
131
+ -- 小红书账号(免费版只有一条,id=1)
132
+ CREATE TABLE IF NOT EXISTS account (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ nickname TEXT,
135
+ xhs_user_id TEXT,
136
+ cookie_file TEXT, -- cookie 文件路径(相对 data_dir)
137
+ proxy TEXT, -- 可选代理,格式 http://host:port
138
+ mcp_port INTEGER NOT NULL DEFAULT 18060,
139
+ mcp_pid INTEGER, -- 当前 xiaohongshu-mcp 进程 PID
140
+ login_status TEXT NOT NULL DEFAULT 'unknown', -- logged_in / logged_out / unknown
141
+ last_login_check TEXT,
142
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
143
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
144
+ );
145
+
146
+ -- 账号策略(来自 Skill 输出的 JSON,带版本号)
147
+ CREATE TABLE IF NOT EXISTS strategy (
148
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
149
+ account_id INTEGER NOT NULL REFERENCES account(id),
150
+ version INTEGER NOT NULL DEFAULT 1,
151
+ data TEXT NOT NULL, -- 完整策略 JSON
152
+ niche TEXT, -- 冗余字段,方便查询
153
+ posting_freq TEXT, -- 冗余字段,方便 scheduler 读取
154
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
155
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
156
+ );
157
+
158
+ -- 提示词(文案风格 / 配图风格,各最多 3 条免费版)
159
+ CREATE TABLE IF NOT EXISTS prompt (
160
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
161
+ account_id INTEGER NOT NULL REFERENCES account(id),
162
+ type TEXT NOT NULL, -- copy / image
163
+ name TEXT NOT NULL, -- 主文案风格 / 主配图风格 / ...
164
+ prompt_text TEXT NOT NULL,
165
+ notes TEXT,
166
+ version INTEGER NOT NULL DEFAULT 1,
167
+ is_active INTEGER NOT NULL DEFAULT 1,
168
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
169
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
170
+ );
171
+
172
+ -- 内容队列(AI 生成 → 待审核 → 已发布)
173
+ CREATE TABLE IF NOT EXISTS content_queue (
174
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
175
+ account_id INTEGER NOT NULL REFERENCES account(id),
176
+ topic TEXT NOT NULL,
177
+ pillar_name TEXT, -- 归属的内容支柱
178
+ title TEXT,
179
+ body TEXT, -- Markdown 正文
180
+ images TEXT, -- JSON 数组,图片文件路径列表
181
+ visual_theme TEXT, -- 渲染时使用的视觉主题
182
+ prompt_version INTEGER, -- 生成时使用的 prompt 版本号
183
+ feishu_record_id TEXT, -- 飞书多维表格行 ID,用于状态同步
184
+ status TEXT NOT NULL DEFAULT 'pending_review',
185
+ -- pending_review / approved / rejected / published / failed
186
+ review_comment TEXT, -- 审核意见(用户填写)
187
+ scheduled_at TEXT, -- 计划发布时间
188
+ published_at TEXT,
189
+ xhs_note_id TEXT, -- 发布成功后小红书返回的笔记 ID
190
+ error_msg TEXT, -- 发布失败原因
191
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
192
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
193
+ );
194
+
195
+ -- 发布历史日志
196
+ CREATE TABLE IF NOT EXISTS publish_log (
197
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
198
+ content_id INTEGER NOT NULL REFERENCES content_queue(id),
199
+ account_id INTEGER NOT NULL REFERENCES account(id),
200
+ xhs_note_id TEXT,
201
+ status TEXT NOT NULL, -- success / failed
202
+ error_msg TEXT,
203
+ published_at TEXT NOT NULL DEFAULT (datetime('now'))
204
+ );
205
+
206
+ -- 内容类型(干货/获客/故事/痛点解析等,每种有独立提示词模板)
207
+ CREATE TABLE IF NOT EXISTS content_type (
208
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
209
+ account_id INTEGER NOT NULL REFERENCES account(id),
210
+ name TEXT NOT NULL,
211
+ prompt_template TEXT NOT NULL DEFAULT '',
212
+ is_active INTEGER NOT NULL DEFAULT 1,
213
+ sort_order INTEGER NOT NULL DEFAULT 0,
214
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
215
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
216
+ );
217
+
218
+ -- 选题库(用户管理的选题/痛点问题,生成时原子消费)
219
+ CREATE TABLE IF NOT EXISTS topic (
220
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
221
+ account_id INTEGER NOT NULL REFERENCES account(id),
222
+ content_type TEXT NOT NULL DEFAULT '干货',
223
+ content TEXT NOT NULL,
224
+ is_used INTEGER NOT NULL DEFAULT 0,
225
+ used_at TEXT,
226
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
227
+ );
228
+
229
+ -- 图片策略(每账号一行,多种生成模式配置)
230
+ CREATE TABLE IF NOT EXISTS image_strategy (
231
+ account_id INTEGER PRIMARY KEY REFERENCES account(id),
232
+ mode TEXT NOT NULL DEFAULT 'cards',
233
+ prompt_template TEXT NOT NULL DEFAULT '',
234
+ card_theme TEXT NOT NULL DEFAULT 'default',
235
+ reference_images TEXT NOT NULL DEFAULT '[]',
236
+ ai_model TEXT,
237
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
238
+ );
239
+
240
+ -- AI 图片提示词模板(每账号多套,每套含多个 item)
241
+ CREATE TABLE IF NOT EXISTS image_template (
242
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
243
+ account_id INTEGER NOT NULL REFERENCES account(id),
244
+ name TEXT NOT NULL,
245
+ is_active INTEGER NOT NULL DEFAULT 0,
246
+ items TEXT NOT NULL DEFAULT '[]',
247
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
248
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
249
+ );
250
+ """)
251
+ c.commit()
252
+
253
+ # ── 写入默认配置(已存在的 key 不覆盖)────────────────────────────────────────
254
+ _seed_defaults(c)
255
+
256
+ # ── 渐进迁移:为旧表补充新字段 ───────────────────────────────────────────────
257
+ _migrate(c)
258
+ c.close()