"""SQLite 数据层:query 生成结果 + 真实搜索结果,统一进 data/app.db。 为什么不再用散落 json:量大了「一次性 fetch 整个 json」撑不住,要能分页/筛选/多 run 累积。 设计: queries —— 一条生成的 query 一行;各打法字段不同,统一塞进 axes(json) 列 search_results —— 一条「query × 平台」一行(把 douyin/weixin 嵌套拍平),方便按平台/成功筛 媒体文件仍躺 data/search/,库里只存相对路径(cover/video)。 纯 stdlib sqlite3,无新依赖。写入幂等:同 run 重导先清该 run。 """ from __future__ import annotations import json import os import sqlite3 from pathlib import Path from typing import Any, Iterable, Optional from core.config import load_env_file ROOT = Path(__file__).resolve().parent.parent DB_PATH = ROOT / "data" / "app.db" _SCHEMA = """ CREATE TABLE IF NOT EXISTS queries ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, method TEXT NOT NULL, -- 打法名(实质 × 创作阶段 × 需求 等) query TEXT NOT NULL, axes TEXT NOT NULL DEFAULT '{}', -- 该打法的正交轴 json(实质/形式/阶段…) created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS ix_queries_run ON queries(run_id); CREATE INDEX IF NOT EXISTS ix_queries_method ON queries(method); CREATE TABLE IF NOT EXISTS search_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, method TEXT NOT NULL, query TEXT NOT NULL, platform TEXT NOT NULL, -- douyin / weixin ok INTEGER NOT NULL, -- 1=搜到且有媒体, 0=失败/空 title TEXT, url TEXT, cover TEXT, -- /data/search/... 相对路径 video TEXT, -- 抖音才有 extra TEXT NOT NULL DEFAULT '{}', -- nick/error 等附加 json created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS ix_sr_run ON search_results(run_id); CREATE INDEX IF NOT EXISTS ix_sr_platform ON search_results(platform); CREATE INDEX IF NOT EXISTS ix_sr_method ON search_results(method); CREATE TABLE IF NOT EXISTS runs ( run_id TEXT PRIMARY KEY, kind TEXT NOT NULL, -- queries / search note TEXT, created_at INTEGER NOT NULL ); -- 帖子级「创作知识 / 非创作知识」分类(按 url,一帖判一次)。acquisition/classify.py 写入。 CREATE TABLE IF NOT EXISTS post_class ( url TEXT PRIMARY KEY, is_creation INTEGER, -- 1=创作知识 / 0=非 reason TEXT, knowledge TEXT, -- 提取出的完整创作知识(创作帖才有) points TEXT, -- 结构化知识点 json(按卡片/分段) created_at INTEGER NOT NULL ); -- 430 query 真实采集:run / query×platform job / item / AI 判断 CREATE TABLE IF NOT EXISTS creation_search_runs ( run_id TEXT PRIMARY KEY, status TEXT NOT NULL, total_queries INTEGER NOT NULL DEFAULT 0, note TEXT, started_at INTEGER NOT NULL, finished_at INTEGER ); CREATE TABLE IF NOT EXISTS creation_search_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, query TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, searched_count INTEGER NOT NULL DEFAULT 0, display_count INTEGER NOT NULL DEFAULT 0, error TEXT, started_at INTEGER, finished_at INTEGER, UNIQUE(run_id, query, platform) ); CREATE INDEX IF NOT EXISTS ix_csj_query ON creation_search_jobs(query); CREATE INDEX IF NOT EXISTS ix_csj_run_platform ON creation_search_jobs(run_id, platform); CREATE TABLE IF NOT EXISTS creation_search_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, query TEXT NOT NULL, platform TEXT NOT NULL, rank INTEGER NOT NULL, source_id TEXT, url TEXT, title TEXT, author TEXT, body_text TEXT, cover_url TEXT, image_urls TEXT NOT NULL DEFAULT '[]', video_url TEXT, media_json TEXT NOT NULL DEFAULT '{}', raw_json TEXT NOT NULL DEFAULT '{}', is_displayable INTEGER NOT NULL DEFAULT 0, error TEXT, created_at INTEGER NOT NULL, UNIQUE(run_id, query, platform, rank) ); CREATE INDEX IF NOT EXISTS ix_csi_query ON creation_search_items(query); CREATE INDEX IF NOT EXISTS ix_csi_run_platform ON creation_search_items(run_id, platform); CREATE TABLE IF NOT EXISTS creation_item_classifications ( item_id INTEGER PRIMARY KEY, is_creation INTEGER, reason TEXT, knowledge TEXT, prompt_version TEXT, error TEXT, classified_at INTEGER NOT NULL, FOREIGN KEY(item_id) REFERENCES creation_search_items(id) ON DELETE CASCADE ); """ # 旧库迁移:post_class 早期没有 knowledge/points 列,补上(已存在则忽略) _MIGRATIONS = [ "ALTER TABLE post_class ADD COLUMN knowledge TEXT", "ALTER TABLE post_class ADD COLUMN points TEXT", ] def resolve_db_path(db_path: Path | str | None = None, *, env_file: str | Path | None = None) -> Path: """Resolve the SQLite path at call time. `DB_PATH` stays as the default new-data location for compatibility, while CK_SQLITE_PATH can point old demo/API runs at legacy_data/app.db. """ if db_path: p = Path(db_path) else: source = load_env_file(env_file or os.getenv("CK_ENV_FILE", ".env")) raw = os.getenv("CK_SQLITE_PATH") or source.get("CK_SQLITE_PATH") or str(DB_PATH) p = Path(raw) return p if p.is_absolute() else ROOT / p def connect(db_path: Path | str | None = None) -> sqlite3.Connection: """打开(必要时建)库,建表,行按 dict 取。busy_timeout 让多进程并发写不报锁。""" p = resolve_db_path(db_path) p.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(p), timeout=30) conn.row_factory = sqlite3.Row conn.execute("PRAGMA busy_timeout=30000") # 撞锁等 30s 而非报错(与微信补下载并发安全) conn.executescript(_SCHEMA) for sql in _MIGRATIONS: try: conn.execute(sql) except sqlite3.OperationalError: pass # 列已存在 return conn def _now(ts: Optional[int]) -> int: # 调用方传入时间戳(脚本侧用 int(time.time()));缺省 0,避免本模块碰 Date/random。 return int(ts) if ts is not None else 0 def _record_run(conn: sqlite3.Connection, run_id: str, kind: str, note: str, ts: int) -> None: conn.execute( "INSERT OR REPLACE INTO runs(run_id, kind, note, created_at) VALUES(?,?,?,?)", (run_id, kind, note, ts), ) # ---- 写入 ---------------------------------------------------------------- # 哪些键是「轴」(除 query 外的结构化字段),存进 axes blob _NON_AXIS = {"query"} def insert_queries(conn: sqlite3.Connection, run_id: str, method: str, items: Iterable[dict], *, ts: Optional[int] = None) -> int: """写一批生成的 query。items=[{query, <各轴>...}]。同 (run_id,method) 先清后写(幂等)。""" t = _now(ts) conn.execute("DELETE FROM queries WHERE run_id=? AND method=?", (run_id, method)) rows = [] for it in items: q = (it or {}).get("query") if not q: continue axes = {k: v for k, v in it.items() if k not in _NON_AXIS} rows.append((run_id, method, q, json.dumps(axes, ensure_ascii=False), t)) conn.executemany( "INSERT INTO queries(run_id, method, query, axes, created_at) VALUES(?,?,?,?,?)", rows) _record_run(conn, run_id, "queries", method, t) conn.commit() return len(rows) def _flatten_search(rec: dict) -> list[dict]: """把一条 {method, query, douyin/weixin/xiaohongshu:{ok:[...],error}} 拍成「每个结果一行」。 某渠道有结果 → 每个结果一行 ok=1;空/失败 → 一行 ok=0 记 error(保留「搜过但无结果」状态)。 只处理记录里实际出现的渠道(向后兼容只有抖音/微信的旧记录)。""" out = [] method, query = rec.get("method", ""), rec.get("query", "") for platform in ("douyin", "weixin", "xiaohongshu"): if platform not in rec: continue v = rec.get(platform) or {} hits = v.get("ok") or [] if hits: for h in hits: extra = {k: h[k] for k in ("nick", "images", "body_text") if k in h} out.append({"platform": platform, "ok": 1, "title": h.get("title"), "url": h.get("url"), "cover": h.get("cover"), "video": h.get("video"), "extra": extra}) else: out.append({"platform": platform, "ok": 0, "extra": {"error": v.get("error") or "未搜到"}}) return [{**r, "method": method, "query": query} for r in out] def insert_search_results(conn: sqlite3.Connection, run_id: str, records: Iterable[dict], *, ts: Optional[int] = None) -> int: """写一批真实搜索结果。records 为 run_search.py 产出的嵌套结构,按平台拍平入库。""" t = _now(ts) conn.execute("DELETE FROM search_results WHERE run_id=?", (run_id,)) rows = [] for rec in records: for r in _flatten_search(rec): rows.append((run_id, r["method"], r["query"], r["platform"], r["ok"], r.get("title"), r.get("url"), r.get("cover"), r.get("video"), json.dumps(r.get("extra", {}), ensure_ascii=False), t)) conn.executemany( "INSERT INTO search_results(run_id, method, query, platform, ok, title, url, " "cover, video, extra, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows) _record_run(conn, run_id, "search", f"{len(rows)} rows", t) conn.commit() return len(rows) # ---- 读取(分页 + 筛选)--------------------------------------------------- def _page(conn: sqlite3.Connection, base: str, where: list[str], params: list[Any], page: int, size: int) -> dict: """通用分页:返回 {total, page, size, items}。""" size = max(1, min(int(size), 200)) page = max(1, int(page)) clause = (" WHERE " + " AND ".join(where)) if where else "" total = conn.execute(f"SELECT COUNT(*) FROM ({base}{clause})", params).fetchone()[0] rows = conn.execute(f"{base}{clause} ORDER BY id LIMIT ? OFFSET ?", params + [size, (page - 1) * size]).fetchall() return {"total": total, "page": page, "size": size, "items": [dict(r) for r in rows]} def list_queries(conn: sqlite3.Connection, *, method: Optional[str] = None, run_id: Optional[str] = None, page: int = 1, size: int = 30) -> dict: where, params = [], [] if method: where.append("method=?"); params.append(method) if run_id: where.append("run_id=?"); params.append(run_id) res = _page(conn, "SELECT * FROM queries", where, params, page, size) for it in res["items"]: it["axes"] = json.loads(it.get("axes") or "{}") return res def list_search(conn: sqlite3.Connection, *, platform: Optional[str] = None, method: Optional[str] = None, ok: Optional[bool] = None, run_id: Optional[str] = None, query: Optional[str] = None, page: int = 1, size: int = 30) -> dict: where, params = [], [] if platform: where.append("platform=?"); params.append(platform) if method: where.append("method=?"); params.append(method) if ok is not None: where.append("ok=?"); params.append(1 if ok else 0) if run_id: where.append("run_id=?"); params.append(run_id) if query: where.append("query=?"); params.append(query) # 按 query 文本取该条的全部搜索结果 res = _page(conn, "SELECT * FROM search_results", where, params, page, size) cm = class_map(conn, {it.get("url") for it in res["items"] if it.get("url")}) for it in res["items"]: it["extra"] = json.loads(it.get("extra") or "{}") it["cls"] = cm.get(it.get("url")) # 创作知识分类(无则 None=未分类) return res # ---- 帖子级创作知识分类 ---------------------------------------------------- def get_unclassified_posts(conn: sqlite3.Connection) -> list[dict]: """库里有结果、但还没分类的去重帖子(按 url)。带 title/body_text/本地图片,供 classify 用。""" rows = conn.execute( "SELECT url, platform, title, cover, extra FROM search_results " "WHERE ok=1 AND url IS NOT NULL AND url NOT IN (SELECT url FROM post_class) " "GROUP BY url" ).fetchall() out = [] for r in rows: extra = json.loads(r["extra"] or "{}") imgs = extra.get("images") or ([r["cover"]] if r["cover"] else []) out.append({"url": r["url"], "platform": r["platform"], "title": r["title"] or "", "body_text": extra.get("body_text", ""), "images": imgs}) return out def upsert_class(conn: sqlite3.Connection, url: str, is_creation: int, reason: str, ts: int, knowledge: str = "", points: str = "") -> None: conn.execute( "INSERT OR REPLACE INTO post_class(url, is_creation, reason, knowledge, points, created_at) " "VALUES(?,?,?,?,?,?)", (url, is_creation, reason, knowledge, points, ts)) conn.commit() def posts_to_classify(conn: sqlite3.Connection, platforms: list[str]) -> list[dict]: """指定平台的全部去重帖(ok=1),带 title/body_text/本地图/本地视频,供(重)分类。 不管是否已分类——上层 upsert 覆盖,便于换更忠实的判法重判。""" qs = ",".join("?" * len(platforms)) rows = conn.execute( f"SELECT url, platform, title, cover, video, extra FROM search_results " f"WHERE ok=1 AND url IS NOT NULL AND platform IN ({qs}) GROUP BY url", platforms ).fetchall() out = [] for r in rows: e = json.loads(r["extra"] or "{}") imgs = e.get("images") or ([r["cover"]] if r["cover"] else []) out.append({"url": r["url"], "platform": r["platform"], "title": r["title"] or "", "body_text": e.get("body_text", ""), "images": imgs, "video": r["video"]}) return out def class_map(conn: sqlite3.Connection, urls) -> dict: """{url: {is_creation, reason, knowledge, points}},给取数时挂分类 + 提取的知识点。""" urls = [u for u in urls if u] if not urls: return {} qs = ",".join("?" * len(urls)) rows = conn.execute( f"SELECT url, is_creation, reason, knowledge, points FROM post_class WHERE url IN ({qs})", urls ).fetchall() out = {} for r in rows: out[r["url"]] = {"is_creation": r["is_creation"], "reason": r["reason"], "knowledge": r["knowledge"] or "", "points": json.loads(r["points"]) if r["points"] else []} return out def class_counts(conn: sqlite3.Connection) -> dict: y = conn.execute("SELECT COUNT(*) FROM post_class WHERE is_creation=1").fetchone()[0] n = conn.execute("SELECT COUNT(*) FROM post_class WHERE is_creation=0").fetchone()[0] return {"creation": y, "non_creation": n} # ---- 微信正文补下载 -------------------------------------------------------- def weixin_urls_missing_body(conn: sqlite3.Connection) -> list[str]: """所有微信帖(distinct url, ok=1)中 extra 还没补正文(body_text)的,供补下载。""" rows = conn.execute( "SELECT url, extra FROM search_results WHERE platform='weixin' AND ok=1 " "AND url IS NOT NULL GROUP BY url" ).fetchall() return [r["url"] for r in rows if not json.loads(r["extra"] or "{}").get("body_text")] def update_post_content(conn: sqlite3.Connection, url: str, body_text: str, images: list[str]) -> None: """把正文 + 本地图片写回该 url 的所有行的 extra(其余字段保留)。""" for r in conn.execute("SELECT id, extra FROM search_results WHERE url=?", (url,)).fetchall(): e = json.loads(r["extra"] or "{}") e["body_text"] = body_text if images: e["images"] = images conn.execute("UPDATE search_results SET extra=? WHERE id=?", (json.dumps(e, ensure_ascii=False), r["id"])) conn.commit() def search_summary(conn: sqlite3.Connection) -> dict: """每条 query 搜到几个结果:{query文本: {total, ok}}。给列表页判断按钮显不显示、显几个。""" rows = conn.execute( "SELECT query, COUNT(*) AS total, SUM(ok) AS ok_n FROM search_results GROUP BY query" ).fetchall() return {r["query"]: {"total": r["total"], "ok": r["ok_n"] or 0} for r in rows} def list_runs(conn: sqlite3.Connection) -> list[dict]: rows = conn.execute("SELECT * FROM runs ORDER BY created_at DESC, run_id").fetchall() return [dict(r) for r in rows] def list_methods(conn: sqlite3.Connection, table: str = "queries") -> list[str]: """某表里有哪些 method(给前端筛选下拉)。""" if table not in ("queries", "search_results"): raise ValueError(table) rows = conn.execute(f"SELECT DISTINCT method FROM {table} ORDER BY method").fetchall() return [r[0] for r in rows] # ---- 430 query 真实采集 ---------------------------------------------------- CREATION_PLATFORMS = ("xiaohongshu", "weixin", "douyin") def create_creation_run(conn: sqlite3.Connection, run_id: str, *, total_queries: int, note: str = "", ts: Optional[int] = None, status: str = "running") -> None: t = _now(ts) conn.execute( "INSERT OR REPLACE INTO creation_search_runs" "(run_id, status, total_queries, note, started_at, finished_at) VALUES(?,?,?,?,?,NULL)", (run_id, status, int(total_queries), note, t), ) conn.commit() def finish_creation_run(conn: sqlite3.Connection, run_id: str, *, status: str = "finished", ts: Optional[int] = None) -> None: conn.execute( "UPDATE creation_search_runs SET status=?, finished_at=? WHERE run_id=?", (status, _now(ts), run_id), ) conn.commit() def creation_run_exists(conn: sqlite3.Connection, run_id: str) -> bool: row = conn.execute( "SELECT 1 FROM creation_search_runs WHERE run_id=? LIMIT 1", (run_id,) ).fetchone() return row is not None def ensure_creation_job(conn: sqlite3.Connection, run_id: str, query: str, platform: str, *, ts: Optional[int] = None) -> int: t = _now(ts) conn.execute( "INSERT OR IGNORE INTO creation_search_jobs" "(run_id, query, platform, status, attempts, started_at) VALUES(?,?,?,?,?,?)", (run_id, query, platform, "pending", 0, t), ) conn.commit() row = conn.execute( "SELECT id FROM creation_search_jobs WHERE run_id=? AND query=? AND platform=?", (run_id, query, platform), ).fetchone() return int(row["id"]) def update_creation_job(conn: sqlite3.Connection, run_id: str, query: str, platform: str, *, status: str, attempts: Optional[int] = None, searched_count: Optional[int] = None, display_count: Optional[int] = None, error: Optional[str] = None, ts: Optional[int] = None) -> None: ensure_creation_job(conn, run_id, query, platform, ts=ts) sets = ["status=?"] params: list[Any] = [status] if attempts is not None: sets.append("attempts=?"); params.append(int(attempts)) if searched_count is not None: sets.append("searched_count=?"); params.append(int(searched_count)) if display_count is not None: sets.append("display_count=?"); params.append(int(display_count)) if error is not None: sets.append("error=?"); params.append(error) if status in ("done", "failed", "partial"): sets.append("finished_at=?"); params.append(_now(ts)) elif status == "running": sets.append("started_at=?"); params.append(_now(ts)) params.extend([run_id, query, platform]) conn.execute( f"UPDATE creation_search_jobs SET {', '.join(sets)} " "WHERE run_id=? AND query=? AND platform=?", params, ) conn.commit() def creation_job_is_done(conn: sqlite3.Connection, run_id: str, query: str, platform: str, *, display_limit: int = 5) -> bool: row = conn.execute( "SELECT status, display_count FROM creation_search_jobs " "WHERE run_id=? AND query=? AND platform=?", (run_id, query, platform), ).fetchone() return bool(row and row["status"] == "done" and int(row["display_count"] or 0) >= display_limit) def upsert_creation_item(conn: sqlite3.Connection, *, run_id: str, query: str, platform: str, rank: int, source_id: str = "", url: str = "", title: str = "", author: str = "", body_text: str = "", cover_url: str = "", image_urls: Optional[list[str]] = None, video_url: str = "", media: Optional[dict] = None, raw: Optional[dict] = None, is_displayable: bool = False, error: str = "", ts: Optional[int] = None) -> int: t = _now(ts) conn.execute( "INSERT INTO creation_search_items" "(run_id, query, platform, rank, source_id, url, title, author, body_text, cover_url, " "image_urls, video_url, media_json, raw_json, is_displayable, error, created_at) " "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) " "ON CONFLICT(run_id, query, platform, rank) DO UPDATE SET " "source_id=excluded.source_id, url=excluded.url, title=excluded.title, " "author=excluded.author, body_text=excluded.body_text, cover_url=excluded.cover_url, " "image_urls=excluded.image_urls, video_url=excluded.video_url, media_json=excluded.media_json, " "raw_json=excluded.raw_json, is_displayable=excluded.is_displayable, " "error=excluded.error, created_at=excluded.created_at", ( run_id, query, platform, int(rank), source_id, url, title, author, body_text, cover_url, json.dumps(image_urls or [], ensure_ascii=False), video_url, json.dumps(media or {}, ensure_ascii=False), json.dumps(raw or {}, ensure_ascii=False), 1 if is_displayable else 0, error, t, ), ) conn.commit() row = conn.execute( "SELECT id FROM creation_search_items WHERE run_id=? AND query=? AND platform=? AND rank=?", (run_id, query, platform, int(rank)), ).fetchone() return int(row["id"]) def upsert_creation_classification(conn: sqlite3.Connection, item_id: int, is_creation: Optional[int], reason: str = "", knowledge: str = "", prompt_version: str = "", error: str = "", ts: Optional[int] = None) -> None: conn.execute( "INSERT INTO creation_item_classifications" "(item_id, is_creation, reason, knowledge, prompt_version, error, classified_at) " "VALUES(?,?,?,?,?,?,?) " "ON CONFLICT(item_id) DO UPDATE SET " "is_creation=excluded.is_creation, reason=excluded.reason, knowledge=excluded.knowledge, " "prompt_version=excluded.prompt_version, error=excluded.error, classified_at=excluded.classified_at", (int(item_id), is_creation, reason, knowledge, prompt_version, error, _now(ts)), ) conn.commit() def latest_creation_run_id(conn: sqlite3.Connection) -> Optional[str]: row = conn.execute( "SELECT run_id FROM creation_search_runs ORDER BY started_at DESC, run_id DESC LIMIT 1" ).fetchone() return row["run_id"] if row else None def creation_search_summary(conn: sqlite3.Connection, run_id: Optional[str] = None) -> dict: rid = run_id or latest_creation_run_id(conn) if not rid: return {"run_id": None, "queries": {}} def default_query_entry() -> dict: return { "platforms": {}, "done": 0, "failed": 0, "display_count": 0, "imgtext_creation_count": 0, "imgtext_classified_count": 0, "imgtext_total_count": 0, "imgtext_target_count": 10, } rows = conn.execute( "SELECT query, platform, status, attempts, searched_count, display_count, error " "FROM creation_search_jobs WHERE run_id=? ORDER BY query, platform", (rid,), ).fetchall() out: dict[str, dict] = {} for r in rows: q = r["query"] ent = out.setdefault(q, default_query_entry()) p = { "status": r["status"], "attempts": r["attempts"], "searched_count": r["searched_count"], "display_count": r["display_count"], "error": r["error"], } ent["platforms"][r["platform"]] = p ent["display_count"] += r["display_count"] or 0 if r["status"] == "done": ent["done"] += 1 if r["status"] == "failed": ent["failed"] += 1 count_rows = conn.execute( "SELECT i.query, COUNT(*) AS total_count, " "SUM(CASE WHEN c.item_id IS NOT NULL THEN 1 ELSE 0 END) AS classified_count, " "SUM(CASE WHEN c.is_creation=1 THEN 1 ELSE 0 END) AS creation_count " "FROM creation_search_items i " "LEFT JOIN creation_item_classifications c ON c.item_id=i.id " "WHERE i.run_id=? AND i.is_displayable=1 " "AND i.platform IN ('xiaohongshu', 'weixin') " "GROUP BY i.query", (rid,), ).fetchall() for r in count_rows: ent = out.setdefault(r["query"], default_query_entry()) ent["imgtext_creation_count"] = int(r["creation_count"] or 0) ent["imgtext_classified_count"] = int(r["classified_count"] or 0) ent["imgtext_total_count"] = int(r["total_count"] or 0) return {"run_id": rid, "queries": out} def get_creation_query_detail(conn: sqlite3.Connection, query: str, *, run_id: Optional[str] = None, display_limit: int = 5) -> dict: rid = run_id or latest_creation_run_id(conn) platforms = {p: {"status": "not_started", "items": [], "error": None} for p in CREATION_PLATFORMS} if not rid: return {"run_id": None, "query": query, "platforms": platforms} job_rows = conn.execute( "SELECT platform, status, attempts, searched_count, display_count, error " "FROM creation_search_jobs WHERE run_id=? AND query=?", (rid, query), ).fetchall() for r in job_rows: platforms[r["platform"]].update({ "status": r["status"], "attempts": r["attempts"], "searched_count": r["searched_count"], "display_count": r["display_count"], "error": r["error"], }) item_rows = conn.execute( "SELECT i.*, c.is_creation, c.reason AS class_reason, c.knowledge, " "c.prompt_version, c.error AS class_error, c.classified_at " "FROM creation_search_items i " "LEFT JOIN creation_item_classifications c ON c.item_id=i.id " "WHERE i.run_id=? AND i.query=? AND i.is_displayable=1 " "ORDER BY i.platform, i.rank", (rid, query), ).fetchall() per_platform_counts = {p: 0 for p in CREATION_PLATFORMS} for r in item_rows: p = r["platform"] if p not in platforms or per_platform_counts[p] >= display_limit: continue item = dict(r) item["image_urls"] = json.loads(item.get("image_urls") or "[]") item["media"] = json.loads(item.pop("media_json") or "{}") item.pop("raw_json", None) # 原始回包很大;详情 API 只返回前端展示需要的字段 item["classification"] = { "is_creation": item.pop("is_creation"), "reason": item.pop("class_reason") or "", "knowledge": item.pop("knowledge") or "", "prompt_version": item.pop("prompt_version") or "", "error": item.pop("class_error") or "", "classified_at": item.pop("classified_at"), } platforms[p]["items"].append(item) per_platform_counts[p] += 1 return {"run_id": rid, "query": query, "platforms": platforms} def creation_items_to_classify(conn: sqlite3.Connection, *, run_id: Optional[str] = None, platforms: Optional[list[str]] = None, retry_failed: bool = True, limit: Optional[int] = None) -> list[dict]: rid = run_id or latest_creation_run_id(conn) if not rid: return [] where = ["i.run_id=?", "i.is_displayable=1"] params: list[Any] = [rid] if platforms: qs = ",".join("?" * len(platforms)) where.append(f"i.platform IN ({qs})") params.extend(platforms) if retry_failed: where.append("(c.item_id IS NULL OR c.is_creation IS NULL)") else: where.append("c.item_id IS NULL") sql = ( "SELECT i.id, i.platform, i.title, i.body_text, i.image_urls, i.video_url " "FROM creation_search_items i " "LEFT JOIN creation_item_classifications c ON c.item_id=i.id " f"WHERE {' AND '.join(where)} ORDER BY i.platform, i.id" ) if limit is not None and int(limit) > 0: sql += " LIMIT ?" params.append(int(limit)) rows = conn.execute(sql, params).fetchall() out = [] for r in rows: d = dict(r) d["image_urls"] = json.loads(d.get("image_urls") or "[]") out.append(d) return out