store.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. """SQLite 数据层:query 生成结果 + 真实搜索结果,统一进 data/app.db。
  2. 为什么不再用散落 json:量大了「一次性 fetch 整个 json」撑不住,要能分页/筛选/多 run 累积。
  3. 设计:
  4. queries —— 一条生成的 query 一行;各打法字段不同,统一塞进 axes(json) 列
  5. search_results —— 一条「query × 平台」一行(把 douyin/weixin 嵌套拍平),方便按平台/成功筛
  6. 媒体文件仍躺 data/search/,库里只存相对路径(cover/video)。
  7. 纯 stdlib sqlite3,无新依赖。写入幂等:同 run 重导先清该 run。
  8. """
  9. from __future__ import annotations
  10. import json
  11. import sqlite3
  12. from pathlib import Path
  13. from typing import Any, Iterable, Optional
  14. ROOT = Path(__file__).resolve().parent.parent
  15. DB_PATH = ROOT / "data" / "app.db"
  16. _SCHEMA = """
  17. CREATE TABLE IF NOT EXISTS queries (
  18. id INTEGER PRIMARY KEY AUTOINCREMENT,
  19. run_id TEXT NOT NULL,
  20. method TEXT NOT NULL, -- 打法名(实质 × 创作阶段 × 需求 等)
  21. query TEXT NOT NULL,
  22. axes TEXT NOT NULL DEFAULT '{}', -- 该打法的正交轴 json(实质/形式/阶段…)
  23. created_at INTEGER NOT NULL
  24. );
  25. CREATE INDEX IF NOT EXISTS ix_queries_run ON queries(run_id);
  26. CREATE INDEX IF NOT EXISTS ix_queries_method ON queries(method);
  27. CREATE TABLE IF NOT EXISTS search_results (
  28. id INTEGER PRIMARY KEY AUTOINCREMENT,
  29. run_id TEXT NOT NULL,
  30. method TEXT NOT NULL,
  31. query TEXT NOT NULL,
  32. platform TEXT NOT NULL, -- douyin / weixin
  33. ok INTEGER NOT NULL, -- 1=搜到且有媒体, 0=失败/空
  34. title TEXT,
  35. url TEXT,
  36. cover TEXT, -- /data/search/... 相对路径
  37. video TEXT, -- 抖音才有
  38. extra TEXT NOT NULL DEFAULT '{}', -- nick/error 等附加 json
  39. created_at INTEGER NOT NULL
  40. );
  41. CREATE INDEX IF NOT EXISTS ix_sr_run ON search_results(run_id);
  42. CREATE INDEX IF NOT EXISTS ix_sr_platform ON search_results(platform);
  43. CREATE INDEX IF NOT EXISTS ix_sr_method ON search_results(method);
  44. CREATE TABLE IF NOT EXISTS runs (
  45. run_id TEXT PRIMARY KEY,
  46. kind TEXT NOT NULL, -- queries / search
  47. note TEXT,
  48. created_at INTEGER NOT NULL
  49. );
  50. -- 帖子级「创作知识 / 非创作知识」分类(按 url,一帖判一次)。acquisition/classify.py 写入。
  51. CREATE TABLE IF NOT EXISTS post_class (
  52. url TEXT PRIMARY KEY,
  53. is_creation INTEGER, -- 1=创作知识 / 0=非
  54. reason TEXT,
  55. knowledge TEXT, -- 提取出的完整创作知识(创作帖才有)
  56. points TEXT, -- 结构化知识点 json(按卡片/分段)
  57. created_at INTEGER NOT NULL
  58. );
  59. -- 430 query 真实采集:run / query×platform job / item / AI 判断
  60. CREATE TABLE IF NOT EXISTS creation_search_runs (
  61. run_id TEXT PRIMARY KEY,
  62. status TEXT NOT NULL,
  63. total_queries INTEGER NOT NULL DEFAULT 0,
  64. note TEXT,
  65. started_at INTEGER NOT NULL,
  66. finished_at INTEGER
  67. );
  68. CREATE TABLE IF NOT EXISTS creation_search_jobs (
  69. id INTEGER PRIMARY KEY AUTOINCREMENT,
  70. run_id TEXT NOT NULL,
  71. query TEXT NOT NULL,
  72. platform TEXT NOT NULL,
  73. status TEXT NOT NULL,
  74. attempts INTEGER NOT NULL DEFAULT 0,
  75. searched_count INTEGER NOT NULL DEFAULT 0,
  76. display_count INTEGER NOT NULL DEFAULT 0,
  77. error TEXT,
  78. started_at INTEGER,
  79. finished_at INTEGER,
  80. UNIQUE(run_id, query, platform)
  81. );
  82. CREATE INDEX IF NOT EXISTS ix_csj_query ON creation_search_jobs(query);
  83. CREATE INDEX IF NOT EXISTS ix_csj_run_platform ON creation_search_jobs(run_id, platform);
  84. CREATE TABLE IF NOT EXISTS creation_search_items (
  85. id INTEGER PRIMARY KEY AUTOINCREMENT,
  86. run_id TEXT NOT NULL,
  87. query TEXT NOT NULL,
  88. platform TEXT NOT NULL,
  89. rank INTEGER NOT NULL,
  90. source_id TEXT,
  91. url TEXT,
  92. title TEXT,
  93. author TEXT,
  94. body_text TEXT,
  95. cover_url TEXT,
  96. image_urls TEXT NOT NULL DEFAULT '[]',
  97. video_url TEXT,
  98. media_json TEXT NOT NULL DEFAULT '{}',
  99. raw_json TEXT NOT NULL DEFAULT '{}',
  100. is_displayable INTEGER NOT NULL DEFAULT 0,
  101. error TEXT,
  102. created_at INTEGER NOT NULL,
  103. UNIQUE(run_id, query, platform, rank)
  104. );
  105. CREATE INDEX IF NOT EXISTS ix_csi_query ON creation_search_items(query);
  106. CREATE INDEX IF NOT EXISTS ix_csi_run_platform ON creation_search_items(run_id, platform);
  107. CREATE TABLE IF NOT EXISTS creation_item_classifications (
  108. item_id INTEGER PRIMARY KEY,
  109. is_creation INTEGER,
  110. reason TEXT,
  111. knowledge TEXT,
  112. prompt_version TEXT,
  113. error TEXT,
  114. classified_at INTEGER NOT NULL,
  115. FOREIGN KEY(item_id) REFERENCES creation_search_items(id) ON DELETE CASCADE
  116. );
  117. """
  118. # 旧库迁移:post_class 早期没有 knowledge/points 列,补上(已存在则忽略)
  119. _MIGRATIONS = [
  120. "ALTER TABLE post_class ADD COLUMN knowledge TEXT",
  121. "ALTER TABLE post_class ADD COLUMN points TEXT",
  122. ]
  123. def connect(db_path: Path | str = DB_PATH) -> sqlite3.Connection:
  124. """打开(必要时建)库,建表,行按 dict 取。busy_timeout 让多进程并发写不报锁。"""
  125. p = Path(db_path)
  126. p.parent.mkdir(parents=True, exist_ok=True)
  127. conn = sqlite3.connect(str(p), timeout=30)
  128. conn.row_factory = sqlite3.Row
  129. conn.execute("PRAGMA busy_timeout=30000") # 撞锁等 30s 而非报错(与微信补下载并发安全)
  130. conn.executescript(_SCHEMA)
  131. for sql in _MIGRATIONS:
  132. try:
  133. conn.execute(sql)
  134. except sqlite3.OperationalError:
  135. pass # 列已存在
  136. return conn
  137. def _now(ts: Optional[int]) -> int:
  138. # 调用方传入时间戳(脚本侧用 int(time.time()));缺省 0,避免本模块碰 Date/random。
  139. return int(ts) if ts is not None else 0
  140. def _record_run(conn: sqlite3.Connection, run_id: str, kind: str, note: str, ts: int) -> None:
  141. conn.execute(
  142. "INSERT OR REPLACE INTO runs(run_id, kind, note, created_at) VALUES(?,?,?,?)",
  143. (run_id, kind, note, ts),
  144. )
  145. # ---- 写入 ----------------------------------------------------------------
  146. # 哪些键是「轴」(除 query 外的结构化字段),存进 axes blob
  147. _NON_AXIS = {"query"}
  148. def insert_queries(conn: sqlite3.Connection, run_id: str, method: str,
  149. items: Iterable[dict], *, ts: Optional[int] = None) -> int:
  150. """写一批生成的 query。items=[{query, <各轴>...}]。同 (run_id,method) 先清后写(幂等)。"""
  151. t = _now(ts)
  152. conn.execute("DELETE FROM queries WHERE run_id=? AND method=?", (run_id, method))
  153. rows = []
  154. for it in items:
  155. q = (it or {}).get("query")
  156. if not q:
  157. continue
  158. axes = {k: v for k, v in it.items() if k not in _NON_AXIS}
  159. rows.append((run_id, method, q, json.dumps(axes, ensure_ascii=False), t))
  160. conn.executemany(
  161. "INSERT INTO queries(run_id, method, query, axes, created_at) VALUES(?,?,?,?,?)", rows)
  162. _record_run(conn, run_id, "queries", method, t)
  163. conn.commit()
  164. return len(rows)
  165. def _flatten_search(rec: dict) -> list[dict]:
  166. """把一条 {method, query, douyin/weixin/xiaohongshu:{ok:[...],error}} 拍成「每个结果一行」。
  167. 某渠道有结果 → 每个结果一行 ok=1;空/失败 → 一行 ok=0 记 error(保留「搜过但无结果」状态)。
  168. 只处理记录里实际出现的渠道(向后兼容只有抖音/微信的旧记录)。"""
  169. out = []
  170. method, query = rec.get("method", ""), rec.get("query", "")
  171. for platform in ("douyin", "weixin", "xiaohongshu"):
  172. if platform not in rec:
  173. continue
  174. v = rec.get(platform) or {}
  175. hits = v.get("ok") or []
  176. if hits:
  177. for h in hits:
  178. extra = {k: h[k] for k in ("nick", "images", "body_text") if k in h}
  179. out.append({"platform": platform, "ok": 1, "title": h.get("title"),
  180. "url": h.get("url"), "cover": h.get("cover"),
  181. "video": h.get("video"), "extra": extra})
  182. else:
  183. out.append({"platform": platform, "ok": 0, "extra": {"error": v.get("error") or "未搜到"}})
  184. return [{**r, "method": method, "query": query} for r in out]
  185. def insert_search_results(conn: sqlite3.Connection, run_id: str,
  186. records: Iterable[dict], *, ts: Optional[int] = None) -> int:
  187. """写一批真实搜索结果。records 为 run_search.py 产出的嵌套结构,按平台拍平入库。"""
  188. t = _now(ts)
  189. conn.execute("DELETE FROM search_results WHERE run_id=?", (run_id,))
  190. rows = []
  191. for rec in records:
  192. for r in _flatten_search(rec):
  193. rows.append((run_id, r["method"], r["query"], r["platform"], r["ok"],
  194. r.get("title"), r.get("url"), r.get("cover"), r.get("video"),
  195. json.dumps(r.get("extra", {}), ensure_ascii=False), t))
  196. conn.executemany(
  197. "INSERT INTO search_results(run_id, method, query, platform, ok, title, url, "
  198. "cover, video, extra, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
  199. _record_run(conn, run_id, "search", f"{len(rows)} rows", t)
  200. conn.commit()
  201. return len(rows)
  202. # ---- 读取(分页 + 筛选)---------------------------------------------------
  203. def _page(conn: sqlite3.Connection, base: str, where: list[str], params: list[Any],
  204. page: int, size: int) -> dict:
  205. """通用分页:返回 {total, page, size, items}。"""
  206. size = max(1, min(int(size), 200))
  207. page = max(1, int(page))
  208. clause = (" WHERE " + " AND ".join(where)) if where else ""
  209. total = conn.execute(f"SELECT COUNT(*) FROM ({base}{clause})", params).fetchone()[0]
  210. rows = conn.execute(f"{base}{clause} ORDER BY id LIMIT ? OFFSET ?",
  211. params + [size, (page - 1) * size]).fetchall()
  212. return {"total": total, "page": page, "size": size, "items": [dict(r) for r in rows]}
  213. def list_queries(conn: sqlite3.Connection, *, method: Optional[str] = None,
  214. run_id: Optional[str] = None, page: int = 1, size: int = 30) -> dict:
  215. where, params = [], []
  216. if method:
  217. where.append("method=?"); params.append(method)
  218. if run_id:
  219. where.append("run_id=?"); params.append(run_id)
  220. res = _page(conn, "SELECT * FROM queries", where, params, page, size)
  221. for it in res["items"]:
  222. it["axes"] = json.loads(it.get("axes") or "{}")
  223. return res
  224. def list_search(conn: sqlite3.Connection, *, platform: Optional[str] = None,
  225. method: Optional[str] = None, ok: Optional[bool] = None,
  226. run_id: Optional[str] = None, query: Optional[str] = None,
  227. page: int = 1, size: int = 30) -> dict:
  228. where, params = [], []
  229. if platform:
  230. where.append("platform=?"); params.append(platform)
  231. if method:
  232. where.append("method=?"); params.append(method)
  233. if ok is not None:
  234. where.append("ok=?"); params.append(1 if ok else 0)
  235. if run_id:
  236. where.append("run_id=?"); params.append(run_id)
  237. if query:
  238. where.append("query=?"); params.append(query) # 按 query 文本取该条的全部搜索结果
  239. res = _page(conn, "SELECT * FROM search_results", where, params, page, size)
  240. cm = class_map(conn, {it.get("url") for it in res["items"] if it.get("url")})
  241. for it in res["items"]:
  242. it["extra"] = json.loads(it.get("extra") or "{}")
  243. it["cls"] = cm.get(it.get("url")) # 创作知识分类(无则 None=未分类)
  244. return res
  245. # ---- 帖子级创作知识分类 ----------------------------------------------------
  246. def get_unclassified_posts(conn: sqlite3.Connection) -> list[dict]:
  247. """库里有结果、但还没分类的去重帖子(按 url)。带 title/body_text/本地图片,供 classify 用。"""
  248. rows = conn.execute(
  249. "SELECT url, platform, title, cover, extra FROM search_results "
  250. "WHERE ok=1 AND url IS NOT NULL AND url NOT IN (SELECT url FROM post_class) "
  251. "GROUP BY url"
  252. ).fetchall()
  253. out = []
  254. for r in rows:
  255. extra = json.loads(r["extra"] or "{}")
  256. imgs = extra.get("images") or ([r["cover"]] if r["cover"] else [])
  257. out.append({"url": r["url"], "platform": r["platform"], "title": r["title"] or "",
  258. "body_text": extra.get("body_text", ""), "images": imgs})
  259. return out
  260. def upsert_class(conn: sqlite3.Connection, url: str, is_creation: int, reason: str, ts: int,
  261. knowledge: str = "", points: str = "") -> None:
  262. conn.execute(
  263. "INSERT OR REPLACE INTO post_class(url, is_creation, reason, knowledge, points, created_at) "
  264. "VALUES(?,?,?,?,?,?)", (url, is_creation, reason, knowledge, points, ts))
  265. conn.commit()
  266. def posts_to_classify(conn: sqlite3.Connection, platforms: list[str]) -> list[dict]:
  267. """指定平台的全部去重帖(ok=1),带 title/body_text/本地图/本地视频,供(重)分类。
  268. 不管是否已分类——上层 upsert 覆盖,便于换更忠实的判法重判。"""
  269. qs = ",".join("?" * len(platforms))
  270. rows = conn.execute(
  271. f"SELECT url, platform, title, cover, video, extra FROM search_results "
  272. f"WHERE ok=1 AND url IS NOT NULL AND platform IN ({qs}) GROUP BY url", platforms
  273. ).fetchall()
  274. out = []
  275. for r in rows:
  276. e = json.loads(r["extra"] or "{}")
  277. imgs = e.get("images") or ([r["cover"]] if r["cover"] else [])
  278. out.append({"url": r["url"], "platform": r["platform"], "title": r["title"] or "",
  279. "body_text": e.get("body_text", ""), "images": imgs, "video": r["video"]})
  280. return out
  281. def class_map(conn: sqlite3.Connection, urls) -> dict:
  282. """{url: {is_creation, reason, knowledge, points}},给取数时挂分类 + 提取的知识点。"""
  283. urls = [u for u in urls if u]
  284. if not urls:
  285. return {}
  286. qs = ",".join("?" * len(urls))
  287. rows = conn.execute(
  288. f"SELECT url, is_creation, reason, knowledge, points FROM post_class WHERE url IN ({qs})", urls
  289. ).fetchall()
  290. out = {}
  291. for r in rows:
  292. out[r["url"]] = {"is_creation": r["is_creation"], "reason": r["reason"],
  293. "knowledge": r["knowledge"] or "",
  294. "points": json.loads(r["points"]) if r["points"] else []}
  295. return out
  296. def class_counts(conn: sqlite3.Connection) -> dict:
  297. y = conn.execute("SELECT COUNT(*) FROM post_class WHERE is_creation=1").fetchone()[0]
  298. n = conn.execute("SELECT COUNT(*) FROM post_class WHERE is_creation=0").fetchone()[0]
  299. return {"creation": y, "non_creation": n}
  300. # ---- 微信正文补下载 --------------------------------------------------------
  301. def weixin_urls_missing_body(conn: sqlite3.Connection) -> list[str]:
  302. """所有微信帖(distinct url, ok=1)中 extra 还没补正文(body_text)的,供补下载。"""
  303. rows = conn.execute(
  304. "SELECT url, extra FROM search_results WHERE platform='weixin' AND ok=1 "
  305. "AND url IS NOT NULL GROUP BY url"
  306. ).fetchall()
  307. return [r["url"] for r in rows if not json.loads(r["extra"] or "{}").get("body_text")]
  308. def update_post_content(conn: sqlite3.Connection, url: str, body_text: str,
  309. images: list[str]) -> None:
  310. """把正文 + 本地图片写回该 url 的所有行的 extra(其余字段保留)。"""
  311. for r in conn.execute("SELECT id, extra FROM search_results WHERE url=?", (url,)).fetchall():
  312. e = json.loads(r["extra"] or "{}")
  313. e["body_text"] = body_text
  314. if images:
  315. e["images"] = images
  316. conn.execute("UPDATE search_results SET extra=? WHERE id=?",
  317. (json.dumps(e, ensure_ascii=False), r["id"]))
  318. conn.commit()
  319. def search_summary(conn: sqlite3.Connection) -> dict:
  320. """每条 query 搜到几个结果:{query文本: {total, ok}}。给列表页判断按钮显不显示、显几个。"""
  321. rows = conn.execute(
  322. "SELECT query, COUNT(*) AS total, SUM(ok) AS ok_n FROM search_results GROUP BY query"
  323. ).fetchall()
  324. return {r["query"]: {"total": r["total"], "ok": r["ok_n"] or 0} for r in rows}
  325. def list_runs(conn: sqlite3.Connection) -> list[dict]:
  326. rows = conn.execute("SELECT * FROM runs ORDER BY created_at DESC, run_id").fetchall()
  327. return [dict(r) for r in rows]
  328. def list_methods(conn: sqlite3.Connection, table: str = "queries") -> list[str]:
  329. """某表里有哪些 method(给前端筛选下拉)。"""
  330. if table not in ("queries", "search_results"):
  331. raise ValueError(table)
  332. rows = conn.execute(f"SELECT DISTINCT method FROM {table} ORDER BY method").fetchall()
  333. return [r[0] for r in rows]
  334. # ---- 430 query 真实采集 ----------------------------------------------------
  335. CREATION_PLATFORMS = ("xiaohongshu", "weixin", "douyin")
  336. def create_creation_run(conn: sqlite3.Connection, run_id: str, *,
  337. total_queries: int, note: str = "", ts: Optional[int] = None,
  338. status: str = "running") -> None:
  339. t = _now(ts)
  340. conn.execute(
  341. "INSERT OR REPLACE INTO creation_search_runs"
  342. "(run_id, status, total_queries, note, started_at, finished_at) VALUES(?,?,?,?,?,NULL)",
  343. (run_id, status, int(total_queries), note, t),
  344. )
  345. conn.commit()
  346. def finish_creation_run(conn: sqlite3.Connection, run_id: str, *,
  347. status: str = "finished", ts: Optional[int] = None) -> None:
  348. conn.execute(
  349. "UPDATE creation_search_runs SET status=?, finished_at=? WHERE run_id=?",
  350. (status, _now(ts), run_id),
  351. )
  352. conn.commit()
  353. def creation_run_exists(conn: sqlite3.Connection, run_id: str) -> bool:
  354. row = conn.execute(
  355. "SELECT 1 FROM creation_search_runs WHERE run_id=? LIMIT 1", (run_id,)
  356. ).fetchone()
  357. return row is not None
  358. def ensure_creation_job(conn: sqlite3.Connection, run_id: str, query: str, platform: str,
  359. *, ts: Optional[int] = None) -> int:
  360. t = _now(ts)
  361. conn.execute(
  362. "INSERT OR IGNORE INTO creation_search_jobs"
  363. "(run_id, query, platform, status, attempts, started_at) VALUES(?,?,?,?,?,?)",
  364. (run_id, query, platform, "pending", 0, t),
  365. )
  366. conn.commit()
  367. row = conn.execute(
  368. "SELECT id FROM creation_search_jobs WHERE run_id=? AND query=? AND platform=?",
  369. (run_id, query, platform),
  370. ).fetchone()
  371. return int(row["id"])
  372. def update_creation_job(conn: sqlite3.Connection, run_id: str, query: str, platform: str,
  373. *, status: str, attempts: Optional[int] = None,
  374. searched_count: Optional[int] = None,
  375. display_count: Optional[int] = None,
  376. error: Optional[str] = None, ts: Optional[int] = None) -> None:
  377. ensure_creation_job(conn, run_id, query, platform, ts=ts)
  378. sets = ["status=?"]
  379. params: list[Any] = [status]
  380. if attempts is not None:
  381. sets.append("attempts=?"); params.append(int(attempts))
  382. if searched_count is not None:
  383. sets.append("searched_count=?"); params.append(int(searched_count))
  384. if display_count is not None:
  385. sets.append("display_count=?"); params.append(int(display_count))
  386. if error is not None:
  387. sets.append("error=?"); params.append(error)
  388. if status in ("done", "failed", "partial"):
  389. sets.append("finished_at=?"); params.append(_now(ts))
  390. elif status == "running":
  391. sets.append("started_at=?"); params.append(_now(ts))
  392. params.extend([run_id, query, platform])
  393. conn.execute(
  394. f"UPDATE creation_search_jobs SET {', '.join(sets)} "
  395. "WHERE run_id=? AND query=? AND platform=?",
  396. params,
  397. )
  398. conn.commit()
  399. def creation_job_is_done(conn: sqlite3.Connection, run_id: str, query: str, platform: str,
  400. *, display_limit: int = 5) -> bool:
  401. row = conn.execute(
  402. "SELECT status, display_count FROM creation_search_jobs "
  403. "WHERE run_id=? AND query=? AND platform=?",
  404. (run_id, query, platform),
  405. ).fetchone()
  406. return bool(row and row["status"] == "done" and int(row["display_count"] or 0) >= display_limit)
  407. def upsert_creation_item(conn: sqlite3.Connection, *, run_id: str, query: str, platform: str,
  408. rank: int, source_id: str = "", url: str = "", title: str = "",
  409. author: str = "", body_text: str = "", cover_url: str = "",
  410. image_urls: Optional[list[str]] = None, video_url: str = "",
  411. media: Optional[dict] = None, raw: Optional[dict] = None,
  412. is_displayable: bool = False, error: str = "",
  413. ts: Optional[int] = None) -> int:
  414. t = _now(ts)
  415. conn.execute(
  416. "INSERT INTO creation_search_items"
  417. "(run_id, query, platform, rank, source_id, url, title, author, body_text, cover_url, "
  418. "image_urls, video_url, media_json, raw_json, is_displayable, error, created_at) "
  419. "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
  420. "ON CONFLICT(run_id, query, platform, rank) DO UPDATE SET "
  421. "source_id=excluded.source_id, url=excluded.url, title=excluded.title, "
  422. "author=excluded.author, body_text=excluded.body_text, cover_url=excluded.cover_url, "
  423. "image_urls=excluded.image_urls, video_url=excluded.video_url, media_json=excluded.media_json, "
  424. "raw_json=excluded.raw_json, is_displayable=excluded.is_displayable, "
  425. "error=excluded.error, created_at=excluded.created_at",
  426. (
  427. run_id, query, platform, int(rank), source_id, url, title, author, body_text,
  428. cover_url, json.dumps(image_urls or [], ensure_ascii=False), video_url,
  429. json.dumps(media or {}, ensure_ascii=False), json.dumps(raw or {}, ensure_ascii=False),
  430. 1 if is_displayable else 0, error, t,
  431. ),
  432. )
  433. conn.commit()
  434. row = conn.execute(
  435. "SELECT id FROM creation_search_items WHERE run_id=? AND query=? AND platform=? AND rank=?",
  436. (run_id, query, platform, int(rank)),
  437. ).fetchone()
  438. return int(row["id"])
  439. def upsert_creation_classification(conn: sqlite3.Connection, item_id: int,
  440. is_creation: Optional[int], reason: str = "",
  441. knowledge: str = "", prompt_version: str = "",
  442. error: str = "", ts: Optional[int] = None) -> None:
  443. conn.execute(
  444. "INSERT INTO creation_item_classifications"
  445. "(item_id, is_creation, reason, knowledge, prompt_version, error, classified_at) "
  446. "VALUES(?,?,?,?,?,?,?) "
  447. "ON CONFLICT(item_id) DO UPDATE SET "
  448. "is_creation=excluded.is_creation, reason=excluded.reason, knowledge=excluded.knowledge, "
  449. "prompt_version=excluded.prompt_version, error=excluded.error, classified_at=excluded.classified_at",
  450. (int(item_id), is_creation, reason, knowledge, prompt_version, error, _now(ts)),
  451. )
  452. conn.commit()
  453. def latest_creation_run_id(conn: sqlite3.Connection) -> Optional[str]:
  454. row = conn.execute(
  455. "SELECT run_id FROM creation_search_runs ORDER BY started_at DESC, run_id DESC LIMIT 1"
  456. ).fetchone()
  457. return row["run_id"] if row else None
  458. def creation_search_summary(conn: sqlite3.Connection, run_id: Optional[str] = None) -> dict:
  459. rid = run_id or latest_creation_run_id(conn)
  460. if not rid:
  461. return {"run_id": None, "queries": {}}
  462. def default_query_entry() -> dict:
  463. return {
  464. "platforms": {},
  465. "done": 0,
  466. "failed": 0,
  467. "display_count": 0,
  468. "imgtext_creation_count": 0,
  469. "imgtext_classified_count": 0,
  470. "imgtext_total_count": 0,
  471. "imgtext_target_count": 10,
  472. }
  473. rows = conn.execute(
  474. "SELECT query, platform, status, attempts, searched_count, display_count, error "
  475. "FROM creation_search_jobs WHERE run_id=? ORDER BY query, platform",
  476. (rid,),
  477. ).fetchall()
  478. out: dict[str, dict] = {}
  479. for r in rows:
  480. q = r["query"]
  481. ent = out.setdefault(q, default_query_entry())
  482. p = {
  483. "status": r["status"],
  484. "attempts": r["attempts"],
  485. "searched_count": r["searched_count"],
  486. "display_count": r["display_count"],
  487. "error": r["error"],
  488. }
  489. ent["platforms"][r["platform"]] = p
  490. ent["display_count"] += r["display_count"] or 0
  491. if r["status"] == "done":
  492. ent["done"] += 1
  493. if r["status"] == "failed":
  494. ent["failed"] += 1
  495. count_rows = conn.execute(
  496. "SELECT i.query, COUNT(*) AS total_count, "
  497. "SUM(CASE WHEN c.item_id IS NOT NULL THEN 1 ELSE 0 END) AS classified_count, "
  498. "SUM(CASE WHEN c.is_creation=1 THEN 1 ELSE 0 END) AS creation_count "
  499. "FROM creation_search_items i "
  500. "LEFT JOIN creation_item_classifications c ON c.item_id=i.id "
  501. "WHERE i.run_id=? AND i.is_displayable=1 "
  502. "AND i.platform IN ('xiaohongshu', 'weixin') "
  503. "GROUP BY i.query",
  504. (rid,),
  505. ).fetchall()
  506. for r in count_rows:
  507. ent = out.setdefault(r["query"], default_query_entry())
  508. ent["imgtext_creation_count"] = int(r["creation_count"] or 0)
  509. ent["imgtext_classified_count"] = int(r["classified_count"] or 0)
  510. ent["imgtext_total_count"] = int(r["total_count"] or 0)
  511. return {"run_id": rid, "queries": out}
  512. def get_creation_query_detail(conn: sqlite3.Connection, query: str, *,
  513. run_id: Optional[str] = None, display_limit: int = 5) -> dict:
  514. rid = run_id or latest_creation_run_id(conn)
  515. platforms = {p: {"status": "not_started", "items": [], "error": None} for p in CREATION_PLATFORMS}
  516. if not rid:
  517. return {"run_id": None, "query": query, "platforms": platforms}
  518. job_rows = conn.execute(
  519. "SELECT platform, status, attempts, searched_count, display_count, error "
  520. "FROM creation_search_jobs WHERE run_id=? AND query=?",
  521. (rid, query),
  522. ).fetchall()
  523. for r in job_rows:
  524. platforms[r["platform"]].update({
  525. "status": r["status"],
  526. "attempts": r["attempts"],
  527. "searched_count": r["searched_count"],
  528. "display_count": r["display_count"],
  529. "error": r["error"],
  530. })
  531. item_rows = conn.execute(
  532. "SELECT i.*, c.is_creation, c.reason AS class_reason, c.knowledge, "
  533. "c.prompt_version, c.error AS class_error, c.classified_at "
  534. "FROM creation_search_items i "
  535. "LEFT JOIN creation_item_classifications c ON c.item_id=i.id "
  536. "WHERE i.run_id=? AND i.query=? AND i.is_displayable=1 "
  537. "ORDER BY i.platform, i.rank",
  538. (rid, query),
  539. ).fetchall()
  540. per_platform_counts = {p: 0 for p in CREATION_PLATFORMS}
  541. for r in item_rows:
  542. p = r["platform"]
  543. if p not in platforms or per_platform_counts[p] >= display_limit:
  544. continue
  545. item = dict(r)
  546. item["image_urls"] = json.loads(item.get("image_urls") or "[]")
  547. item["media"] = json.loads(item.pop("media_json") or "{}")
  548. item.pop("raw_json", None) # 原始回包很大;详情 API 只返回前端展示需要的字段
  549. item["classification"] = {
  550. "is_creation": item.pop("is_creation"),
  551. "reason": item.pop("class_reason") or "",
  552. "knowledge": item.pop("knowledge") or "",
  553. "prompt_version": item.pop("prompt_version") or "",
  554. "error": item.pop("class_error") or "",
  555. "classified_at": item.pop("classified_at"),
  556. }
  557. platforms[p]["items"].append(item)
  558. per_platform_counts[p] += 1
  559. return {"run_id": rid, "query": query, "platforms": platforms}
  560. def creation_items_to_classify(conn: sqlite3.Connection, *, run_id: Optional[str] = None,
  561. platforms: Optional[list[str]] = None,
  562. retry_failed: bool = True,
  563. limit: Optional[int] = None) -> list[dict]:
  564. rid = run_id or latest_creation_run_id(conn)
  565. if not rid:
  566. return []
  567. where = ["i.run_id=?", "i.is_displayable=1"]
  568. params: list[Any] = [rid]
  569. if platforms:
  570. qs = ",".join("?" * len(platforms))
  571. where.append(f"i.platform IN ({qs})")
  572. params.extend(platforms)
  573. if retry_failed:
  574. where.append("(c.item_id IS NULL OR c.is_creation IS NULL)")
  575. else:
  576. where.append("c.item_id IS NULL")
  577. sql = (
  578. "SELECT i.id, i.platform, i.title, i.body_text, i.image_urls, i.video_url "
  579. "FROM creation_search_items i "
  580. "LEFT JOIN creation_item_classifications c ON c.item_id=i.id "
  581. f"WHERE {' AND '.join(where)} ORDER BY i.platform, i.id"
  582. )
  583. if limit is not None and int(limit) > 0:
  584. sql += " LIMIT ?"
  585. params.append(int(limit))
  586. rows = conn.execute(sql, params).fetchall()
  587. out = []
  588. for r in rows:
  589. d = dict(r)
  590. d["image_urls"] = json.loads(d.get("image_urls") or "[]")
  591. out.append(d)
  592. return out