Quellcode durchsuchen

Build local creation query demo

SamLee vor 2 Wochen
Ursprung
Commit
a85f9faaaf

+ 6 - 0
.env

@@ -212,3 +212,9 @@ CK_OSS_UPLOAD_TIMEOUT_SECONDS=60
 CK_SEARCH_DEFAULT_LIMIT=5
 CK_SEARCH_CONTENT_TYPE=图文
 CK_SEARCH_SORT_TYPE=综合
+
+
+CONTENT_AGENT_QUERY_LLM_MODEL=qwen-plus
+CONTENT_AGENT_VIDEO_LLM_PROVIDER=dashscope
+CONTENT_AGENT_VIDEO_LLM_API_KEY=sk-ws-H.RPHIMMY.iCXw.MEQCIEK5lMoyNCQqbob6F49k3onjTYOcXFN4QrMe9jcSY7T9AiBDBofse4gRk_hOSqBYKAbaXwJL7Be-9B8JZw2pFVD5oA
+CONTENT_AGENT_VIDEO_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

+ 26 - 0
PRODUCT.md

@@ -0,0 +1,26 @@
+# Product
+
+## Register
+
+product
+
+## Users
+Content knowledge builders who need to inspect, classify, and structure creator-facing knowledge from Xiaohongshu, WeChat, and Douyin results. They work in a dense review workflow where source media, model judgments, and extraction state must be visible together.
+
+## Product Purpose
+This tool turns searched posts, articles, and videos into inspectable creation-knowledge candidates, then helps operators review whether each item teaches transferable content-creation decisions. Success means users can quickly see query coverage, platform health, media availability, and AI judgment outcomes without losing the original evidence.
+
+## Brand Personality
+Precise, restrained, operational.
+
+## Anti-references
+Avoid marketing-style landing pages, decorative dashboards, oversized hero sections, and vague AI-generated explanations. The interface should not hide evidence behind ornamental cards or make status hard to scan.
+
+## Design Principles
+- Evidence first: media, source text, and judgment reason stay close together.
+- State is data: done, failed, partial, unjudged, creation, and non-creation states must be explicit.
+- Dense but readable: favor compact layouts that support repeated review.
+- Conservative judgment: ambiguous creation-knowledge boundaries should remain visible rather than silently discarded.
+
+## Accessibility & Inclusion
+Use system fonts, clear semantic color plus text labels, and responsive layouts that keep media cards readable on narrow screens. Do not rely on color alone for classification state.

+ 251 - 36
acquisition/classify.py

@@ -1,12 +1,13 @@
-"""帖子级「创作知识 / 非创作知识」分类(收紧版)+ 提取知识点。
+"""帖子级「创作知识 / 非创作知识」分类 + 提取知识点。
 
-判据已收紧到「**自媒体内容创作**」,并显式排除三类越界(见 prompts):① 应试/学术写作
+判据是「内容作品创作知识」,覆盖图文、视频、脚本、游戏视频、历史视频等内容创作场景,
+并显式排除三类越界(见 prompts):① 应试/学术写作
 ② 制作/工具操作(=制作知识)③ 学科知识/评论/作品本身。两套真实提示词:
-  图文(小红书/微信):prompts/classify_imgtext.txt(标题+正文+全部图喂 Gemini
-  视频(抖音):prompts/classify_video.txt(整段视频原生喂 Gemini,大视频先 ffmpeg 压 480p 保音轨)
+  图文(小红书/微信):prompts/classify_imgtext.txt(标题+正文+全部图喂多模态模型
+  视频(抖音):prompts/classify_video.txt(OSS/CDN URL 直喂;本地大视频 fallback 时先压 480p 保音轨)
 都输出 {is_empty, reason, knowledge}:is_empty 即创作闸;is_empty=false 时连带提炼出具体创作知识点。
 结果按 url 写 app.db 的 post_class(upsert 覆盖,可重判)。并发;与微信补下载并行安全(busy_timeout)。
-只读 prompts、走 OpenRouter——不改 skill/creation_knowledge,不解构、不入 ingest。
+只读 prompts、走 OpenRouter / Ark / Qwen——不改 skill/creation_knowledge,不解构、不入 ingest。
 用法:PYTHONPATH=. CK_ENV_FILE=.env python -m acquisition.classify [平台名...]
 """
 from __future__ import annotations
@@ -14,16 +15,19 @@ from __future__ import annotations
 import base64
 import concurrent.futures as cf
 import json
+import os
 import subprocess
 import sys
 import tempfile
+import threading
 import time
 from pathlib import Path
+from urllib.parse import urlparse
 
 import httpx
 
 from acquisition import store
-from core.config import Settings
+from core.config import Settings, load_env_file
 from core.prompts import load_prompt
 
 ROOT = Path(__file__).resolve().parent.parent
@@ -33,6 +37,33 @@ VID_WORKERS = 3                          # 视频并发(含 ffmpeg 压制,
 MAX_CARDS = 12                           # 图文最多送几张图
 COMPRESS_OVER_MB = 12                    # 视频超过此大小先压再喂
 COMPRESS_H = 480
+DEFAULT_PROVIDER_MIN_INTERVAL_SECONDS = 0.5
+DEFAULT_429_BACKOFF_SECONDS = [30.0, 90.0, 180.0]
+RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
+
+
+class _ProviderThrottle:
+    def __init__(self) -> None:
+        self._lock = threading.Lock()
+        self._next_allowed = 0.0
+
+    def wait(self, min_interval_seconds: float) -> None:
+        with self._lock:
+            now = time.monotonic()
+            sleep_for = max(0.0, self._next_allowed - now)
+            self._next_allowed = max(now, self._next_allowed) + min_interval_seconds
+        if sleep_for > 0:
+            time.sleep(sleep_for)
+
+    def penalize(self, seconds: float) -> None:
+        if seconds <= 0:
+            return
+        with self._lock:
+            self._next_allowed = max(self._next_allowed, time.monotonic() + seconds)
+
+
+_PROVIDER_THROTTLES: dict[str, _ProviderThrottle] = {}
+_PROVIDER_THROTTLES_LOCK = threading.Lock()
 
 
 def _data_url(public_path: str):
@@ -42,6 +73,14 @@ def _data_url(public_path: str):
     return "data:image/jpeg;base64," + base64.b64encode(fs.read_bytes()).decode()
 
 
+def _is_http_url(value: str) -> bool:
+    try:
+        parsed = urlparse(value)
+    except Exception:
+        return False
+    return parsed.scheme in ("http", "https") and bool(parsed.netloc)
+
+
 def _compress(mp4: Path) -> Path:
     """大视频压到 480p(保留口播音轨)→ 临时 mp4;失败回原文件。"""
     out = Path(tempfile.gettempdir()) / f"ck_{mp4.parent.name}_480.mp4"
@@ -59,26 +98,196 @@ def _compress(mp4: Path) -> Path:
     return mp4
 
 
+def _parse_judge_content(content: str) -> tuple:
+    d = json.loads(content)
+    if bool(d.get("is_empty")):
+        return 0, str(d.get("reason", ""))[:60], "", ""
+    return 1, str(d.get("reason", ""))[:60], str(d.get("knowledge", "") or ""), ""
+
+
+def _env_first(env: dict, *keys: str) -> str:
+    for key in keys:
+        value = os.getenv(key) or env.get(key)
+        if value:
+            return value
+    return ""
+
+
+def _is_qwen_model(model: str) -> bool:
+    lower = model.lower()
+    return lower.startswith("qwen") or lower.startswith("qwq")
+
+
+def _provider_key(name: str) -> str:
+    return name.split(":", 1)[0].lower()
+
+
+def _provider_throttle(provider: str) -> _ProviderThrottle:
+    with _PROVIDER_THROTTLES_LOCK:
+        throttle = _PROVIDER_THROTTLES.get(provider)
+        if throttle is None:
+            throttle = _ProviderThrottle()
+            _PROVIDER_THROTTLES[provider] = throttle
+        return throttle
+
+
+def _env_float(env: dict, default: float, *keys: str) -> float:
+    value = _env_first(env, *keys)
+    if not value:
+        return default
+    try:
+        return max(0.0, float(value))
+    except ValueError:
+        return default
+
+
+def _provider_min_interval(provider: str, env: dict) -> float:
+    prefix = provider.upper()
+    return _env_float(
+        env,
+        DEFAULT_PROVIDER_MIN_INTERVAL_SECONDS,
+        f"CLASSIFY_{prefix}_MIN_INTERVAL_SECONDS",
+        "CLASSIFY_PROVIDER_MIN_INTERVAL_SECONDS",
+    )
+
+
+def _parse_backoff_list(value: str) -> list[float]:
+    out = []
+    for part in value.split(","):
+        try:
+            seconds = float(part.strip())
+        except ValueError:
+            continue
+        if seconds > 0:
+            out.append(seconds)
+    return out or DEFAULT_429_BACKOFF_SECONDS
+
+
+def _retry_after_seconds(resp: httpx.Response) -> float | None:
+    value = resp.headers.get("retry-after")
+    if not value:
+        return None
+    try:
+        return max(0.0, float(value))
+    except ValueError:
+        return None
+
+
+def _provider_429_backoff(provider: str, env: dict, attempt: int, resp: httpx.Response) -> float:
+    retry_after = _retry_after_seconds(resp)
+    if retry_after is not None:
+        return retry_after
+    prefix = provider.upper()
+    values = _parse_backoff_list(
+        _env_first(
+            env,
+            f"CLASSIFY_{prefix}_429_BACKOFF_SECONDS",
+            "CLASSIFY_429_BACKOFF_SECONDS",
+        ) or ",".join(str(v) for v in DEFAULT_429_BACKOFF_SECONDS)
+    )
+    return values[min(attempt, len(values) - 1)]
+
+
+def _providers(settings: Settings, messages: list) -> list[tuple[str, str, dict, dict]]:
+    body = {"model": settings.video_model, "messages": messages,
+            "response_format": {"type": "json_object"}}
+    env = load_env_file(os.getenv("CK_ENV_FILE", ".env"))
+    out: list[tuple[str, str, dict, dict]] = []
+    provider = (_env_first(env, "CLASSIFY_PROVIDER") or "auto").lower()
+    explicit_model = _env_first(env, "CLASSIFY_MODEL")
+
+    qwen_key = _env_first(
+        env,
+        "QWEN_API_KEY",
+        "DASHSCOPE_API_KEY",
+        "CONTENT_AGENT_VIDEO_LLM_API_KEY",
+    )
+    if qwen_key and provider in ("auto", "qwen", "dashscope"):
+        qwen_url = _env_first(
+            env,
+            "QWEN_BASE_URL",
+            "DASHSCOPE_BASE_URL",
+            "CONTENT_AGENT_VIDEO_LLM_BASE_URL",
+        ) or "https://dashscope.aliyuncs.com/compatible-mode/v1"
+        models = []
+        for model in [
+            explicit_model,
+            _env_first(env, "QWEN_MODEL", "DASHSCOPE_MODEL"),
+            _env_first(env, "CONTENT_AGENT_VIDEO_LLM_MODEL"),
+            "qwen3.7-plus",
+            "qwen-vl-plus",
+        ]:
+            if model and _is_qwen_model(model) and model not in models:
+                models.append(model)
+        for model in models:
+            out.append((
+                f"qwen:{model}",
+                qwen_url.rstrip("/") + "/chat/completions",
+                {"Authorization": f"Bearer {qwen_key}", "Content-Type": "application/json"},
+                {"model": model, "messages": messages, "response_format": {"type": "json_object"}},
+            ))
+        if provider in ("qwen", "dashscope"):
+            return out
+
+    if settings.openrouter_api_key and provider in ("auto", "openrouter"):
+        out.append((
+            "openrouter",
+            settings.openrouter_base_url.rstrip("/") + "/chat/completions",
+            {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"},
+            body,
+        ))
+
+    ark_key = _env_first(env, "ARK_API_KEY")
+    if ark_key and provider in ("auto", "ark"):
+        ark_url = _env_first(env, "ARK_CHAT_URL") or "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
+        models = []
+        explicit = explicit_model or _env_first(env, "ARK_CHAT_MODEL")
+        if explicit:
+            models.append(explicit)
+        # Seed 2 Mini 接入点/1.6 Vision 更适合图文+视频理解;flash 作为轻量兜底。
+        models.extend(["ep-20260506151915-jqvw7", "doubao-seed-1-6-vision-250815", "doubao-seed-1-6-flash-250615"])
+        seen = set()
+        for model in models:
+            if model in seen:
+                continue
+            seen.add(model)
+            out.append((
+                f"ark:{model}",
+                ark_url,
+                {"Authorization": f"Bearer {ark_key}", "Content-Type": "application/json"},
+                {"model": model, "messages": messages, "response_format": {"type": "json_object"}},
+            ))
+    return out
+
+
 def _judge(messages: list, settings: Settings, timeout: float) -> tuple:
-    """调 Gemini(OpenRouter,强制 JSON)解析 {is_empty, reason, knowledge},带重试。
+    """调多模态模型(Qwen / OpenRouter / Ark),解析 {is_empty, reason, knowledge},带重试。
     返回 (is_creation 1/0/None, reason, knowledge, points)。"""
-    api = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
-    headers = {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"}
-    payload = {"model": settings.video_model, "messages": messages,
-               "response_format": {"type": "json_object"}}
     last = ""
-    for attempt in range(3):
-        try:
-            resp = httpx.post(api, headers=headers, json=payload, timeout=timeout)
-            if resp.status_code == 200:
-                d = json.loads(resp.json()["choices"][0]["message"]["content"])
-                if bool(d.get("is_empty")):
-                    return 0, str(d.get("reason", ""))[:60], "", ""
-                return 1, str(d.get("reason", ""))[:60], str(d.get("knowledge", "") or ""), ""
-            last = f"http {resp.status_code}"
-        except Exception as exc:
-            last = str(exc)[:50]
-        time.sleep(2 * (attempt + 1))
+    env = load_env_file(os.getenv("CK_ENV_FILE", ".env"))
+    for name, api, headers, payload in _providers(settings, messages):
+        provider = _provider_key(name)
+        throttle = _provider_throttle(provider)
+        min_interval = _provider_min_interval(provider, env)
+        for attempt in range(3):
+            try:
+                throttle.wait(min_interval)
+                resp = httpx.post(api, headers=headers, json=payload, timeout=timeout)
+                if resp.status_code == 200:
+                    return _parse_judge_content(resp.json()["choices"][0]["message"]["content"])
+                last = f"{name} http {resp.status_code}"
+                if resp.status_code == 429:
+                    throttle.penalize(_provider_429_backoff(provider, env, attempt, resp))
+                if resp.status_code in (401, 403, 404):
+                    break
+            except Exception as exc:
+                last = f"{name} {str(exc)[:50]}"
+            if "http " not in last or any(f"http {code}" in last for code in RETRYABLE_STATUS_CODES):
+                time.sleep(2 * (attempt + 1))
+            else:
+                break
+    if not last:
+        last = "missing Qwen/OpenRouter/Ark credentials"
     return None, f"判定失败: {last}", "", ""
 
 
@@ -96,20 +305,26 @@ def classify_imgtext(p: dict, settings: Settings) -> tuple:
 
 
 def classify_video(p: dict, settings: Settings) -> tuple:
-    """视频:看完整段视频,用收紧的 classify_video.txt 判 is_empty 并提取知识点。"""
+    """视频:看完整段视频,用 classify_video.txt 判 is_empty 并提取知识点。
+
+    新采集链路里抖音视频会先转存 OSS,传入 HTTP(S) CDN URL;老链路传 /data 本地 mp4。
+    """
     rel = p.get("video") or ""
-    mp4 = ROOT / rel.lstrip("/")
-    if not rel or not mp4.exists():
-        return None, "无本地视频", "", ""
-    use = _compress(mp4) if mp4.stat().st_size > COMPRESS_OVER_MB * 1048576 else mp4
-    try:
-        media = "data:video/mp4;base64," + base64.b64encode(use.read_bytes()).decode()
-    finally:
-        if use != mp4:
-            try:
-                use.unlink()
-            except Exception:
-                pass
+    if _is_http_url(rel):
+        media = rel
+    else:
+        mp4 = ROOT / rel.lstrip("/")
+        if not rel or not mp4.exists():
+            return None, "无视频", "", ""
+        use = _compress(mp4) if mp4.stat().st_size > COMPRESS_OVER_MB * 1048576 else mp4
+        try:
+            media = "data:video/mp4;base64," + base64.b64encode(use.read_bytes()).decode()
+        finally:
+            if use != mp4:
+                try:
+                    use.unlink()
+                except Exception:
+                    pass
     messages = [{"role": "system", "content": load_prompt("classify_video")},
                 {"role": "user", "content": [{"type": "text", "text": "判断这条视频是不是创作知识。"},
                                              {"type": "video_url", "video_url": {"url": media}}]}]

+ 348 - 0
acquisition/creation_search.py

@@ -0,0 +1,348 @@
+"""430 query 真实采集:搜索前 10、详情限速、媒体保存/OSS、创作知识判断。"""
+from __future__ import annotations
+
+import hashlib
+import json
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Callable, Iterable, Optional
+
+from acquisition import store
+from acquisition.classify import classify_imgtext, classify_video
+from acquisition.crawler import RateLimiter, fetch_post_detail
+from acquisition.oss import upload_stream
+from acquisition.search import search_keyword, search_weixin, search_xiaohongshu, fetch_weixin_detail
+from core.config import Settings
+from core.models import Post
+from creation_knowledge.integrations.video_extract import _default_download
+
+ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_QUERY_FILE = ROOT / "data" / "queries" / "creation_demo.json"
+PLATFORMS = ("xiaohongshu", "weixin", "douyin")
+
+Downloader = Callable[[str, str], bytes]
+
+
+class PlatformRateLimiter:
+    """把同一平台的 keyword/detail 调用合并到同一个 10-12s bucket。"""
+
+    def __init__(self, platform: str, *, min_seconds: float = 10.0, max_seconds: float = 12.0,
+                 delegate: Optional[RateLimiter] = None) -> None:
+        self.platform = platform
+        self.delegate = delegate or RateLimiter(
+            min_interval_seconds=min_seconds,
+            max_interval_seconds=max_seconds,
+        )
+
+    def wait(self, bucket: str) -> None:
+        self.delegate.wait(self.platform)
+
+
+@dataclass
+class Candidate:
+    rank: int
+    source_id: str = ""
+    url: str = ""
+    title: str = ""
+    author: str = ""
+    cover_url: str = ""
+    raw: dict | None = None
+
+
+def load_creation_queries(path: Path | str = DEFAULT_QUERY_FILE) -> list[str]:
+    """从 creation_demo.json 读取所有 keep=true 的唯一 query,保持首次出现顺序。"""
+    data = json.loads(Path(path).read_text("utf-8"))
+    seen: set[str] = set()
+    out: list[str] = []
+    for fam in data.get("families") or []:
+        for item in fam.get("items") or []:
+            q = (item.get("query") or "").strip()
+            if q and item.get("keep", True) and q not in seen:
+                seen.add(q)
+                out.append(q)
+    return out
+
+
+def prompt_version(*names: str) -> str:
+    h = hashlib.sha256()
+    for name in names:
+        p = ROOT / "prompts" / f"{name}.txt"
+        h.update(name.encode("utf-8"))
+        if p.exists():
+            h.update(p.read_bytes())
+    return h.hexdigest()[:16]
+
+
+def _hash(value: str, n: int = 16) -> str:
+    return hashlib.md5(value.encode("utf-8")).hexdigest()[:n]
+
+
+def _ext_from_bytes(data: bytes) -> str:
+    if data[:4] == b"\x89PNG":
+        return ".png"
+    if data[:3] == b"GIF":
+        return ".gif"
+    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
+        return ".webp"
+    return ".jpg"
+
+
+def _dedup(values: Iterable[str]) -> list[str]:
+    seen: set[str] = set()
+    out: list[str] = []
+    for v in values:
+        if v and v not in seen:
+            seen.add(v)
+            out.append(v)
+    return out
+
+
+def _save_images(urls: list[str], *, platform: str, base_dir: Path, public_base: str,
+                 downloader: Downloader = _default_download, max_images: int = 12) -> list[str]:
+    out: list[str] = []
+    for i, url in enumerate(_dedup(urls)[:max_images], start=1):
+        if not url.startswith("http"):
+            continue
+        try:
+            data = downloader(url, platform)
+        except Exception:
+            continue
+        if not data:
+            continue
+        base_dir.mkdir(parents=True, exist_ok=True)
+        ext = _ext_from_bytes(data)
+        name = f"image_{i}{ext}"
+        (base_dir / name).write_bytes(data)
+        out.append(f"{public_base}/{name}")
+    return out
+
+
+def _classify_and_store(conn, item_id: int, platform: str, *, title: str, body_text: str,
+                        images: list[str], video_url: str, settings: Settings,
+                        ts: Optional[int] = None) -> None:
+    if platform == "douyin":
+        version = prompt_version("classify_video")
+        is_creation, reason, knowledge, _points = classify_video(
+            {"platform": platform, "title": title, "body_text": body_text, "video": video_url},
+            settings,
+        )
+    else:
+        version = prompt_version("classify_imgtext")
+        is_creation, reason, knowledge, _points = classify_imgtext(
+            {"platform": platform, "title": title, "body_text": body_text, "images": images},
+            settings,
+        )
+    store.upsert_creation_classification(
+        conn,
+        item_id,
+        is_creation,
+        reason=reason,
+        knowledge=knowledge,
+        prompt_version=version,
+        error=reason if is_creation is None else "",
+        ts=ts,
+    )
+
+
+def search_candidates(platform: str, query: str, *, settings: Settings, limit: int,
+                      rate_limiter: PlatformRateLimiter) -> list[Candidate]:
+    if platform == "xiaohongshu":
+        rows = search_xiaohongshu(
+            query, content_type="图文", limit=limit, settings=settings, rate_limiter=rate_limiter
+        )
+        return [
+            Candidate(
+                rank=i, source_id=r.get("id") or "", url=r.get("url") or "",
+                title=r.get("title") or "", author=r.get("nick_name") or "",
+                cover_url=r.get("cover_url") or "", raw=r,
+            )
+            for i, r in enumerate(rows, start=1)
+        ]
+    if platform == "weixin":
+        rows = search_weixin(query, limit=limit, settings=settings, rate_limiter=rate_limiter)
+        return [
+            Candidate(
+                rank=i, url=r.get("url") or "", title=r.get("title") or "",
+                author=r.get("nick_name") or "", cover_url=r.get("cover_url") or "", raw=r,
+            )
+            for i, r in enumerate(rows, start=1)
+        ]
+    if platform == "douyin":
+        ids = search_keyword(
+            query, platform="douyin", content_type="视频", limit=limit,
+            settings=settings, rate_limiter=rate_limiter,
+        )
+        return [Candidate(rank=i, source_id=cid, raw={"id": cid}) for i, cid in enumerate(ids, start=1)]
+    raise ValueError(f"unsupported platform: {platform}")
+
+
+def _process_xhs(candidate: Candidate, *, query_hash: str, settings: Settings,
+                 rate_limiter: PlatformRateLimiter, downloader: Downloader) -> dict:
+    post = fetch_post_detail(candidate.source_id or candidate.url, settings=settings, rate_limiter=rate_limiter)
+    base = ROOT / "data" / "media" / "xiaohongshu" / query_hash / (post.content_id or candidate.source_id)
+    pub = f"/data/media/xiaohongshu/{query_hash}/{post.content_id or candidate.source_id}"
+    images = _save_images(post.image_urls or [candidate.cover_url], platform="xiaohongshu",
+                          base_dir=base, public_base=pub, downloader=downloader)
+    if not images:
+        raise RuntimeError("图片下载失败")
+    return {
+        "source_id": post.content_id or candidate.source_id,
+        "url": post.url or candidate.url,
+        "title": post.title or candidate.title,
+        "author": post.author_name or candidate.author,
+        "body_text": post.body_text or "",
+        "cover_url": images[0],
+        "image_urls": images,
+        "video_url": "",
+        "raw": post.raw if isinstance(post.raw, dict) else {},
+    }
+
+
+def _process_weixin(candidate: Candidate, *, query_hash: str, settings: Settings,
+                    rate_limiter: PlatformRateLimiter, downloader: Downloader) -> dict:
+    body_text, detail_images = fetch_weixin_detail(
+        candidate.url, settings=settings, rate_limiter=rate_limiter
+    )
+    h = _hash(candidate.url)
+    base = ROOT / "data" / "media" / "weixin" / query_hash / h
+    pub = f"/data/media/weixin/{query_hash}/{h}"
+    images = _save_images(detail_images or [candidate.cover_url], platform="weixin",
+                          base_dir=base, public_base=pub, downloader=downloader)
+    return {
+        "source_id": h,
+        "url": candidate.url,
+        "title": candidate.title,
+        "author": candidate.author,
+        "body_text": body_text,
+        "cover_url": images[0] if images else candidate.cover_url,
+        "image_urls": images,
+        "video_url": "",
+        "raw": candidate.raw or {},
+    }
+
+
+def _process_douyin(candidate: Candidate, *, settings: Settings,
+                    rate_limiter: PlatformRateLimiter) -> dict:
+    post: Post = fetch_post_detail(candidate.source_id, settings=settings, rate_limiter=rate_limiter)
+    if not post.video_urls:
+        raise RuntimeError("详情无视频")
+    cdn_url = upload_stream(post.video_urls[0], src_type="video", settings=settings)
+    return {
+        "source_id": post.content_id or candidate.source_id,
+        "url": post.url,
+        "title": post.title,
+        "author": post.author_name or "",
+        "body_text": post.body_text or "",
+        "cover_url": (post.image_urls or [""])[0],
+        "image_urls": post.image_urls or [],
+        "video_url": cdn_url,
+        "raw": post.raw if isinstance(post.raw, dict) else {},
+    }
+
+
+def process_candidate(platform: str, candidate: Candidate, *, query_hash: str,
+                      settings: Settings, rate_limiter: PlatformRateLimiter,
+                      downloader: Downloader = _default_download) -> dict:
+    if platform == "xiaohongshu":
+        return _process_xhs(candidate, query_hash=query_hash, settings=settings,
+                            rate_limiter=rate_limiter, downloader=downloader)
+    if platform == "weixin":
+        return _process_weixin(candidate, query_hash=query_hash, settings=settings,
+                               rate_limiter=rate_limiter, downloader=downloader)
+    if platform == "douyin":
+        return _process_douyin(candidate, settings=settings, rate_limiter=rate_limiter)
+    raise ValueError(f"unsupported platform: {platform}")
+
+
+def run_platform_query(conn, *, run_id: str, query: str, platform: str, settings: Settings,
+                       search_limit: int = 10, display_limit: int = 5,
+                       rate_limiter: Optional[PlatformRateLimiter] = None,
+                       downloader: Downloader = _default_download,
+                       sleep_fn: Callable[[float], None] = time.sleep,
+                       classify: bool = True, ts: Optional[int] = None) -> dict:
+    """执行一个 query×platform job。任何 item 失败都会落库并继续。"""
+    gate = rate_limiter or PlatformRateLimiter(platform)
+    now = int(time.time()) if ts is None else ts
+    store.update_creation_job(conn, run_id, query, platform, status="running", ts=now)
+
+    candidates: list[Candidate] = []
+    last_error = ""
+    attempts = 0
+    for attempts in range(1, 4):
+        try:
+            candidates = search_candidates(
+                platform, query, settings=settings, limit=search_limit, rate_limiter=gate
+            )
+            last_error = ""
+            break
+        except Exception as exc:
+            last_error = f"搜索失败: {str(exc)[:120]}"
+            store.update_creation_job(
+                conn, run_id, query, platform, status="running", attempts=attempts,
+                error=last_error, ts=int(time.time()),
+            )
+            if attempts < 3:
+                sleep_fn(30 if attempts == 1 else 90)
+
+    if last_error:
+        store.update_creation_job(
+            conn, run_id, query, platform, status="failed", attempts=attempts,
+            searched_count=0, display_count=0, error=last_error, ts=int(time.time()),
+        )
+        return {"platform": platform, "status": "failed", "display_count": 0, "error": last_error}
+
+    qh = _hash(query)
+    display_count = 0
+    errors: list[str] = []
+    for cand in candidates[:search_limit]:
+        if display_count >= display_limit:
+            break
+        try:
+            processed = process_candidate(
+                platform, cand, query_hash=qh, settings=settings, rate_limiter=gate,
+                downloader=downloader,
+            )
+            item_id = store.upsert_creation_item(
+                conn, run_id=run_id, query=query, platform=platform, rank=cand.rank,
+                source_id=processed.get("source_id", ""), url=processed.get("url", ""),
+                title=processed.get("title", ""), author=processed.get("author", ""),
+                body_text=processed.get("body_text", ""), cover_url=processed.get("cover_url", ""),
+                image_urls=processed.get("image_urls", []), video_url=processed.get("video_url", ""),
+                media={"source": cand.raw or {}, "candidate_rank": cand.rank},
+                raw=processed.get("raw", {}), is_displayable=True, ts=int(time.time()),
+            )
+            if classify:
+                _classify_and_store(
+                    conn, item_id, platform, title=processed.get("title", ""),
+                    body_text=processed.get("body_text", ""),
+                    images=processed.get("image_urls", []),
+                    video_url=processed.get("video_url", ""), settings=settings,
+                    ts=int(time.time()),
+                )
+            display_count += 1
+        except Exception as exc:
+            msg = f"rank {cand.rank}: {str(exc)[:120]}"
+            errors.append(msg)
+            store.upsert_creation_item(
+                conn, run_id=run_id, query=query, platform=platform, rank=cand.rank,
+                source_id=cand.source_id, url=cand.url, title=cand.title,
+                author=cand.author, cover_url=cand.cover_url, raw=cand.raw or {},
+                is_displayable=False, error=msg, ts=int(time.time()),
+            )
+
+    if display_count >= display_limit:
+        status = "done"
+        error = ""
+    elif display_count > 0:
+        status = "partial"
+        error = "; ".join(errors[-3:])
+    else:
+        status = "failed"
+        error = "; ".join(errors[-3:]) or "未获得可展示内容"
+    store.update_creation_job(
+        conn, run_id, query, platform, status=status, attempts=attempts,
+        searched_count=len(candidates), display_count=display_count,
+        error=error, ts=int(time.time()),
+    )
+    return {"platform": platform, "status": status, "display_count": display_count, "error": error}

+ 43 - 8
acquisition/query_filter.py

@@ -9,11 +9,12 @@
 from __future__ import annotations
 
 import json
+import os
 from pathlib import Path
 
 import httpx
 
-from core.config import Settings
+from core.config import Settings, load_env_file
 
 PROMPT = Path(__file__).resolve().parent / "query_filter.txt"
 VALID_MIN = 6   # 语义合法性阈值:valid ≥ 此值才算说得通;调这里即可松紧
@@ -31,15 +32,12 @@ def _filter_batch(queries: list[str], settings: Settings) -> list[dict]:
     if not queries:
         return []
     user = json.dumps([{"idx": i, "query": q} for i, q in enumerate(queries)], ensure_ascii=False)
-    api = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
-    headers = {"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"}
-    body = {"model": settings.llm_model, "messages": [
+    messages = [
         {"role": "system", "content": PROMPT.read_text("utf-8")},
-        {"role": "user", "content": user}], "response_format": {"type": "json_object"}}
+        {"role": "user", "content": user},
+    ]
     try:
-        resp = httpx.post(api, headers=headers, json=body, timeout=120)
-        resp.raise_for_status()
-        txt = resp.json()["choices"][0]["message"]["content"]
+        txt = _chat_content(settings, messages)
         # query_filter 要求输出数组;有的模型会包一层 {"result":[...]},都兜住
         data = json.loads(txt)
         arr = data if isinstance(data, list) else next((v for v in data.values() if isinstance(v, list)), [])
@@ -58,3 +56,40 @@ def _filter_batch(queries: list[str], settings: Settings) -> list[dict]:
         return out
     except Exception as exc:
         return [{"keep": True, "valid": 10, "relevant": True, "reason": f"筛选失败:{str(exc)[:30]}"} for _ in queries]
+
+
+def _chat_content(settings: Settings, messages: list[dict]) -> str:
+    """Return chat content. Prefer OpenRouter; fall back to Ark when OpenRouter is region-blocked locally."""
+    body = {"model": settings.llm_model, "messages": messages, "response_format": {"type": "json_object"}}
+    env = load_env_file(os.getenv("CK_ENV_FILE", ".env"))
+    prefer_ark = os.getenv("QUERY_FILTER_PROVIDER") == "ark" or bool(os.getenv("ARK_CHAT_MODEL"))
+    openrouter_exc: Exception | None = None
+    if settings.openrouter_api_key and not prefer_ark:
+        try:
+            resp = httpx.post(
+                settings.openrouter_base_url.rstrip("/") + "/chat/completions",
+                headers={"Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json"},
+                json=body,
+                timeout=120,
+            )
+            resp.raise_for_status()
+            return resp.json()["choices"][0]["message"]["content"]
+        except Exception as exc:
+            openrouter_exc = exc
+
+    ark_key = os.getenv("ARK_API_KEY") or env.get("ARK_API_KEY")
+    if ark_key:
+        ark_model = os.getenv("ARK_CHAT_MODEL") or env.get("ARK_CHAT_MODEL") or "doubao-seed-1-6-flash-250615"
+        ark_url = os.getenv("ARK_CHAT_URL") or env.get("ARK_CHAT_URL") or "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
+        resp = httpx.post(
+            ark_url,
+            headers={"Authorization": f"Bearer {ark_key}", "Content-Type": "application/json"},
+            json={"model": ark_model, "messages": messages, "response_format": {"type": "json_object"}},
+            timeout=120,
+        )
+        resp.raise_for_status()
+        return resp.json()["choices"][0]["message"]["content"]
+
+    if openrouter_exc:
+        raise openrouter_exc
+    raise RuntimeError("missing OpenRouter/Ark chat credentials")

+ 3 - 2
acquisition/query_filter.txt

@@ -2,8 +2,9 @@
 
 记住:每条候选都是要拿去平台【搜创作知识】用的检索词。你判断的核心 = 这条 query 本身有没有意义、值不值得拿去搜——只有"字面说得通 且 搜回来大概率是可迁移的创作方法"的,才有价值、值得搜;说不通、或搜回来跟创作知识无关的,就没价值、别拿去搜。据此逐条给每个候选打两项,只看 query 本身:
 
-【一、语义合法性 valid(0-10)】这条 query 字面的词组合本身是否成立、说得通。
-不成立的典型:动作和对象不匹配、动词用错对象类型(例如对象是抽象 / 文本类信息,却用了只适用于画面 / 音频 / 镜头类的操作动作;或把"改编 / 润色 / 剪辑"等加工动作套在"灵感 / 选题"这类还没成形的对象上)。越像能说通的正常短语分越高,说不通给低分。
+【一、语义合法性 valid(0-10)】这串词拼起来,像不像一个做内容的真人会敲进搜索框、读得通的一句话?只看"顺不顺口、像不像人话",先不管内容对不对(内容是第二项的事)。
+· 读着顺、一眼就明白在问什么 → 高分。例:「美食 选题怎么找」「开头 钩子怎么写」「三幕式 结构怎么搭」。
+· 几个词硬凑在一起、别扭拗口、没人会这么说、读完不知道在问啥 → 低分。例:「句式辞格 选题改编 直述开篇」「强硬对抗 选题策划 详细说明」——词是堆上去的,连不成一句正常的话。
 
 【二、创作相关性 relevant(true / false)】这条 query 搜回来的结果,大概率和"创作知识"相关吗?
 先用三把尺判它是不是在搜"可迁移的创作方法":

+ 349 - 0
acquisition/store.py

@@ -63,6 +63,68 @@ CREATE TABLE IF NOT EXISTS post_class (
     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 列,补上(已存在则忽略)
@@ -325,3 +387,290 @@ def list_methods(conn: sqlite3.Connection, table: str = "queries") -> list[str]:
         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

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
acquisition/web/app/dist/assets/index-CkGW0KOY.css


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
acquisition/web/app/dist/assets/index-_EYHoleW.css


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 8
acquisition/web/app/dist/assets/index-k3AVc-3u.js


Datei-Diff unterdrückt, da er zu groß ist
+ 8 - 0
acquisition/web/app/dist/assets/index-wpGdM-2q.js


+ 3 - 3
acquisition/web/app/dist/index.html

@@ -3,9 +3,9 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>创作知识 · 找帖子</title>
-    <script type="module" crossorigin src="/app/assets/index-k3AVc-3u.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-_EYHoleW.css">
+    <title>创作知识 · Query 正交 Demo</title>
+    <script type="module" crossorigin src="/app/assets/index-wpGdM-2q.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-CkGW0KOY.css">
   </head>
   <body>
     <div id="root"></div>

+ 1 - 1
acquisition/web/app/index.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>创作知识 · 找帖子</title>
+    <title>创作知识 · Query 正交 Demo</title>
   </head>
   <body>
     <div id="root"></div>

+ 1 - 1
acquisition/web/app/package.json

@@ -3,7 +3,7 @@
   "private": true,
   "version": "0.1.0",
   "type": "module",
-  "description": "找帖子阶段的可交互前端:query 生成结果 + 真实搜索结果,按 /api 分页筛选",
+  "description": "创作知识 query 正交 demo 前端",
   "scripts": {
     "dev": "vite",
     "build": "vite build",

+ 12 - 26
acquisition/web/app/src/App.jsx

@@ -1,39 +1,25 @@
 import React, { useEffect, useState } from 'react'
-import QueriesPage from './pages/QueriesPage.jsx'
-import QueryDetail from './pages/QueryDetail.jsx'
 import CreationDemo from './pages/CreationDemo.jsx'
+import CreationQueryDetail from './pages/CreationQueryDetail.jsx'
 
-// 路由:#/cdemo = 创作Query正交Demo;#/q/<query> = 某条query的真实搜索详情;其余 = 找帖子列表。
-function parseHash() {
-  const h = window.location.hash || ''
-  if (h.startsWith('#/cdemo')) return { view: 'cdemo' }
-  const m = h.match(/^#\/q\/(.+)$/)
-  return m ? { view: 'detail', query: decodeURIComponent(m[1]) } : { view: 'list' }
+function parseRoute() {
+  const hash = window.location.hash || '#/'
+  if (hash.startsWith('#/query/')) {
+    return { name: 'query', query: decodeURIComponent(hash.slice('#/query/'.length)) }
+  }
+  return { name: 'demo' }
 }
 
 export default function App() {
-  const [route, setRoute] = useState(parseHash())
+  const [route, setRoute] = useState(parseRoute)
   useEffect(() => {
-    const on = () => setRoute(parseHash())
-    window.addEventListener('hashchange', on)
-    return () => window.removeEventListener('hashchange', on)
+    const onHash = () => setRoute(parseRoute())
+    window.addEventListener('hashchange', onHash)
+    return () => window.removeEventListener('hashchange', onHash)
   }, [])
-
   return (
     <div className="wrap">
-      <div className="topnav">
-        <a className={route.view === 'cdemo' ? '' : 'on'} href="#/">找帖子</a>
-        <a className={route.view === 'cdemo' ? 'on' : ''} href="#/cdemo">创作Query正交Demo</a>
-      </div>
-      {route.view === 'cdemo' ? (
-        <CreationDemo />
-      ) : (
-        <>
-          <h1>创作知识 · 找帖子</h1>
-          <div className="sub">每条生成的 query 直接挂它搜到的真实帖子/视频 · 数据走 /api(SQLite 分页)</div>
-          {route.view === 'detail' ? <QueryDetail query={route.query} /> : <QueriesPage />}
-        </>
-      )}
+      {route.name === 'query' ? <CreationQueryDetail query={route.query} /> : <CreationDemo />}
     </div>
   )
 }

+ 0 - 50
acquisition/web/app/src/api.js

@@ -1,50 +0,0 @@
-// 后端取数:全部走 /api/*(dev 由 vite 代理到 uvicorn,线上同源)。
-// 失败不抛飞页面,返回空集,让 UI 显示「暂无数据」。
-
-async function getJSON(url) {
-  const r = await fetch(url)
-  if (!r.ok) throw new Error(`${r.status} ${url}`)
-  return r.json()
-}
-
-function qs(params) {
-  const p = new URLSearchParams()
-  for (const [k, v] of Object.entries(params)) {
-    if (v !== undefined && v !== null && v !== '') p.set(k, v)
-  }
-  const s = p.toString()
-  return s ? `?${s}` : ''
-}
-
-export async function fetchRuns() {
-  try { return (await getJSON('/api/runs')).runs || [] } catch { return [] }
-}
-
-export async function fetchMethods(table) {
-  try { return (await getJSON(`/api/methods${qs({ table })}`)).methods || [] } catch { return [] }
-}
-
-// {方法名: {轴名: 取值}} —— 人工定义轴的全部取值,给「查看人工定义的轴」弹窗用
-export async function fetchManualAxes() {
-  try { return (await getJSON('/api/manual-axes')).axes || {} } catch { return {} }
-}
-
-// 创作知识判断提示词(图文 / 视频),给「判断提示词」弹窗用
-export async function fetchJudgePrompts() {
-  try { return (await getJSON('/api/judge-prompts')).prompts || [] } catch { return [] }
-}
-
-const EMPTY = { total: 0, page: 1, size: 30, items: [] }
-
-export async function fetchQueries({ method, run_id, page, size } = {}) {
-  try { return await getJSON(`/api/queries${qs({ method, run_id, page, size })}`) } catch { return EMPTY }
-}
-
-export async function fetchSearch({ platform, method, ok, run_id, query, page, size } = {}) {
-  try { return await getJSON(`/api/search${qs({ platform, method, ok, run_id, query, page, size })}`) } catch { return EMPTY }
-}
-
-// {query文本: {total, ok}} —— 列表页给每条 query 决定「搜索结果」按钮是否可点、显示几个
-export async function fetchSearchSummary() {
-  try { return (await getJSON('/api/search/summary')).summary || {} } catch { return {} }
-}

+ 0 - 14
acquisition/web/app/src/components/Pager.jsx

@@ -1,14 +0,0 @@
-import React from 'react'
-
-// 纯分页器:上一页/下一页 + 「第 x / 共 y 页(n 条)」。数据再大也只渲染当前页。
-export default function Pager({ page, size, total, onPage }) {
-  const pages = Math.max(1, Math.ceil(total / size))
-  if (total === 0) return null
-  return (
-    <div className="pager">
-      <button disabled={page <= 1} onClick={() => onPage(page - 1)}>‹ 上一页</button>
-      <span className="at">第 {page} / 共 {pages} 页 · {total} 条</span>
-      <button disabled={page >= pages} onClick={() => onPage(page + 1)}>下一页 ›</button>
-    </div>
-  )
-}

+ 127 - 23
acquisition/web/app/src/pages/CreationDemo.jsx

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
 
 // 创作Query正交 Demo:仿"制作query"的多列 UI——左侧若干「轴列」,右侧「Query 词」。
 // 关键:左侧轴列只显示【该家族 query 实际用到的去重值】,与右侧 query 一一对应(不是整池)。
-// 阶段列按 灵感/选题/脚本 分组缩进,只显示用到的子词
+// 阶段列只显示 灵感/选题/脚本 三个裸阶段;组合方式按尾缀分组展示
 const PARTS_KEY = {       // 家族 axes 里的轴名 → 每条 query 的 parts 里的键
   '实质': '实质', '形式': '形式', '作用/感受/意图': '目的',
   '业务阶段': '业务阶段', '模态': '模态', '知识类型': '知识类型',
@@ -13,7 +13,7 @@ const AXIS_LABEL = {      // 列头显示用的标注(取值层级)
 }
 const USED_ONLY = new Set(['实质', '形式'])   // 仅这两轴只显示这批 query 用到的去重值;其余轴显示完整池
 const POOL_KEY = {                            // 完整池轴名 → axis_values 键
-  '作用/感受/意图': '目的池', '模态': '模态', '知识类型': '知识类型',
+  '作用/感受/意图': '目的池', '模态': '模态', '知识类型': '知识类型', '业务阶段': '业务阶段',
   '阶段': '阶段', '动作': '动作', '作用': '作用',   // 第六家族(老正交)的轴,显示完整池
 }
 
@@ -28,25 +28,14 @@ function usedValues(items, pk) {
 }
 
 function AxisCol({ ax, fam, axisValues }) {
-  const grouping = axisValues['业务阶段']         // {灵感:[...],选题:[...],脚本:[...]}
   let body, count
-  if (ax === '业务阶段' && grouping && !Array.isArray(grouping)) {
-    // 业务阶段:完整分组 + 缩进(不按用到与否过滤)
-    const groups = Object.entries(grouping)
-    count = groups.reduce((n, [, w]) => n + w.length, 0)
-    body = groups.map(([grp, words]) => (
-      <div key={grp}>
-        <div className="axv grp">{grp}</div>
-        {words.map((w, i) => <div className="axv child" key={i}>{w}</div>)}
-      </div>
-    ))
-  } else if (USED_ONLY.has(ax)) {
+  if (USED_ONLY.has(ax)) {
     // 实质/形式:只显示这批 query 用到的去重值
     const used = usedValues(fam.items, PARTS_KEY[ax])
     count = used.length
     body = used.map((v, i) => <div className="axv" key={i}>{v}</div>)
   } else {
-    // 作用/感受/意图、模态、知识类型:完整池
+    // 目的池/业务阶段/模态/知识类型/(老轴):完整池平铺
     const pool = axisValues[POOL_KEY[ax]] || []
     count = pool.length
     body = pool.map((v, i) => <div className="axv" key={i}>{v}</div>)
@@ -59,13 +48,69 @@ function AxisCol({ ax, fam, axisValues }) {
   )
 }
 
+// 一栏家族(竖排按钮):按尾缀分组。按钮内前缀左对齐、尾缀(后 sufLen 个轴)右对齐。
+function FamCol({ title, fams, sufLen, fi, setFi }) {
+  return (
+    <div className="famcol">
+      <div className="famcol-hd">{title}</div>
+      {fams.map(f => {
+        const pre = f.axes.slice(0, f.axes.length - sufLen).join(' × ')
+        const suf = f.axes.slice(f.axes.length - sufLen).join(' × ')
+        return (
+          <button key={f.key} className={`fbtn ${f.i === fi ? 'on' : ''}`} onClick={() => setFi(f.i)}>
+            <span className="fbtn-pre">{pre}</span>
+            <span className="fbtn-suf">{suf}</span>
+          </button>
+        )
+      })}
+    </div>
+  )
+}
+
+function rowRank(f) {
+  const hasShi = f.axes.includes('实质')
+  const hasXing = f.axes.includes('形式')
+  const hasPurpose = f.axes.includes('作用/感受/意图')
+  if (hasShi && hasXing) return 3
+  if (hasShi) return 1
+  if (hasXing) return 2
+  if (hasPurpose) return 4
+  return 5
+}
+
+function byRowRank(a, b) {
+  return rowRank(a) - rowRank(b)
+}
+
+function averageCreationStats(items, summary) {
+  const rows = items
+    .map(it => summary[it.query])
+    .filter(Boolean)
+  if (!rows.length) return null
+  const creation = rows.reduce((s, info) => s + (info.imgtext_creation_count || 0), 0)
+  const target = rows[0]?.imgtext_target_count || 10
+  const complete = rows.filter(info => (info.imgtext_classified_count || 0) >= (info.imgtext_target_count || 10)).length
+  const avg = creation / rows.length
+  return {
+    label: Number.isInteger(avg) ? String(avg) : avg.toFixed(1),
+    target,
+    count: rows.length,
+    complete,
+  }
+}
+
 export default function CreationDemo() {
   const [data, setData] = useState(null)
+  const [summary, setSummary] = useState({})
   const [fi, setFi] = useState(0)
   const [showFilter, setShowFilter] = useState(false)
   useEffect(() => {
     fetch('/data/queries/creation_demo.json?v=' + Date.now())
       .then(r => r.ok ? r.json() : null).then(setData).catch(() => setData(null))
+    fetch('/api/creation-search/summary?v=' + Date.now())
+      .then(r => r.ok ? r.json() : null)
+      .then(d => setSummary((d && d.queries) || {}))
+      .catch(() => setSummary({}))
   }, [])
 
   if (!data) {
@@ -73,20 +118,34 @@ export default function CreationDemo() {
   }
   const fam = data.families[fi]
   const kept = fam.items.filter(it => it.keep).length
+  const avgCreation = averageCreationStats(fam.items, summary)
+  const allFams = data.families.map((f, i) => ({ ...f, i }))
+  const modalFams = allFams
+    .filter(f => f.axes.includes('业务阶段'))
+    .sort(byRowRank)
+  const oldWithEffectFams = allFams
+    .filter(f => f.axes.includes('动作') && f.axes.includes('作用'))
+    .sort(byRowRank)
+  const oldNoEffectFams = allFams
+    .filter(f => f.axes.includes('动作') && !f.axes.includes('作用'))
+    .sort(byRowRank)
 
   return (
     <div>
       <h1>创作知识 · Query 正交 Demo</h1>
-      <div className="sub">7 套家族机械正交(取分类树创作支)→ LLM 按提示词只做排除(query_filter)→ 原串直接可搜(本 demo 不真搜)· 每家族抽 30 条全显示,✕ 删除线=被筛掉
+      <div className="sub">{data.families.length} 种组合方式机械正交(取分类树创作支)→ LLM 按提示词评 valid/relevant(query_filter)→ 原串直接可搜(本 demo 不真搜)· 每种组合方式最多 30 条全显示,✕ 删除线=被筛掉
         <button className="btn ghost" style={{ marginLeft: 10 }} onClick={() => setShowFilter(true)}>📋 查看筛选提示词</button>
       </div>
 
       {showFilter && <FilterPromptModal onClose={() => setShowFilter(false)} />}
 
-      <div className="nav">
-        {data.families.map((f, i) => (
-          <button key={f.key} className={i === fi ? 'on' : ''} onClick={() => setFi(i)}>{f.name}</button>
-        ))}
+      <div className="famcols">
+        <FamCol title="尾缀:模态 × 业务阶段 × 知识类型" sufLen={3}
+          fams={modalFams} fi={fi} setFi={setFi} />
+        <FamCol title="尾缀:阶段 × 动作 × 作用 × 知识类型" sufLen={4}
+          fams={oldWithEffectFams} fi={fi} setFi={setFi} />
+        <FamCol title="尾缀:阶段 × 动作 × 知识类型" sufLen={3}
+          fams={oldNoEffectFams} fi={fi} setFi={setFi} />
       </div>
 
       <div className="cdemo">
@@ -94,12 +153,29 @@ export default function CreationDemo() {
           <AxisCol key={ax} ax={ax} fam={fam} axisValues={data.axis_values} />
         ))}
         <div className="axcol qcol">
-          <div className="axhd qhd">Query 词<span className="gn">留 {kept}/{fam.items.length}</span></div>
+          <div className="axhd qhd">
+            <span>Query 词</span>
+            <span className="qhd-metrics">
+              {avgCreation && (
+                <span
+                  className={`avgcreation ${avgCreation.complete >= avgCreation.count ? 'done' : 'pending'}`}
+                  title={`按当前组 ${avgCreation.count} 个已有结果 query 计算,完整判断 ${avgCreation.complete}/${avgCreation.count}`}
+                >
+                  平均 {avgCreation.label}/{avgCreation.target}创作知识
+                </span>
+              )}
+              <span className="gn">留 {kept}/{fam.items.length}</span>
+            </span>
+          </div>
           <div className="axlist">
             {fam.items.map((it, i) => (
               <div className={`qrow ${it.keep ? 'keep' : 'drop'}`} key={i} title={it.reason || ''}>
                 <span className="qmark">{it.keep ? '✓' : '✕'}</span>
+                {typeof it.valid === 'number' && (
+                  <span className={`qvalid ${it.valid >= 6 ? 'ok' : 'low'}`}>语义{it.valid}</span>
+                )}
                 <span className="qtext">{it.query}</span>
+                <QueryResultButton query={it.query} info={summary[it.query]} />
                 {!it.keep && <span className="qreason">{it.reason}</span>}
               </div>
             ))}
@@ -110,6 +186,34 @@ export default function CreationDemo() {
   )
 }
 
+function QueryResultButton({ query, info }) {
+  const platforms = info?.platforms || {}
+  const counts = ['xiaohongshu', 'weixin', 'douyin']
+    .map(p => platforms[p]?.display_count || 0)
+  const label = info
+    ? `${counts.join('/')} 条`
+    : '未跑'
+  const cls = info ? 'qdetail on' : 'qdetail'
+  const judged = info?.imgtext_classified_count || 0
+  const target = info?.imgtext_target_count || 10
+  const creation = info?.imgtext_creation_count || 0
+  return (
+    <>
+      {info && (
+        <span
+          className={`qcreation ${judged >= target ? 'done' : 'pending'}`}
+          title={`小红书 + 微信公众号已判断 ${judged}/${target} 条`}
+        >
+          {creation}/{target}创作知识
+        </span>
+      )}
+      <a className={cls} href={`#/query/${encodeURIComponent(query)}`}>
+        查看结果<span>{label}</span>
+      </a>
+    </>
+  )
+}
+
 // 弹窗:展示机械正交后做 keep/排除 的创作筛选提示词(acquisition/query_filter.txt)
 function FilterPromptModal({ onClose }) {
   const [p, setP] = useState(null)
@@ -121,8 +225,8 @@ function FilterPromptModal({ onClose }) {
       <div className="modal wide" onClick={(e) => e.stopPropagation()}>
         <div className="modal-hd"><b>创作 query 筛选提示词</b><span className="x" onClick={onClose}>✕</span></div>
         <div className="modal-bd">
-          <p className="note">机械正交拼出的每条 query 逐条喂大模型,命中 A–E(语义不通 / 题材本身 / 制作 / 应试学术公务政企 / 作品素材)即排除
-            代码只负责打包调用 + 解析,判断全靠这条提示词。{p?.file && <code> · {p.file}</code>}</p>
+          <p className="note">机械正交拼出的每条 query 逐条喂大模型,返回语义合法性 valid 与创作相关性 relevant;代码按 valid ≥ 6 且 relevant=true 保留
+            判断口径全靠这条提示词。{p?.file && <code> · {p.file}</code>}</p>
           <pre className="prompt">{p ? p.text : '加载中…'}</pre>
         </div>
       </div>

+ 182 - 0
acquisition/web/app/src/pages/CreationQueryDetail.jsx

@@ -0,0 +1,182 @@
+import React, { useEffect, useState } from 'react'
+
+const PLATFORMS = [
+  ['xiaohongshu', '小红书', 'xhs'],
+  ['weixin', '微信公众号', 'wx'],
+  ['douyin', '抖音', 'dy'],
+]
+
+function shortText(text, n = 220) {
+  if (!text) return ''
+  return text.length > n ? text.slice(0, n) + '...' : text
+}
+
+function clsLabel(cls) {
+  if (!cls || cls.is_creation === null || cls.is_creation === undefined) {
+    return ['none', cls?.error ? '判断失败' : '未判断']
+  }
+  return cls.is_creation ? ['yes', '创作知识'] : ['no', '非创作知识']
+}
+
+function PlatformStatus({ platform }) {
+  const status = platform?.status || 'not_started'
+  const text = {
+    not_started: '未跑',
+    pending: '等待中',
+    running: '运行中',
+    done: '完成',
+    partial: '部分完成',
+    failed: '失败',
+  }[status] || status
+  return <span className={`status ${status}`}>{text}</span>
+}
+
+function Media({ item, platform, onImageClick }) {
+  if (platform === 'douyin' && item.video_url) {
+    return <video controls playsInline src={item.video_url} poster={item.cover_url || undefined} />
+  }
+  const images = item.image_urls || []
+  if (images.length > 1) {
+    return (
+      <div className="thumbstrip" aria-label="帖子图片">
+        {images.map((u, i) => (
+          <button className="thumbbtn" key={i} type="button" onClick={() => onImageClick?.(images, i, item.title)}>
+            <img className="gimg" src={u} alt={`第 ${i + 1} 张`} loading="lazy" />
+            <span>{i + 1}</span>
+          </button>
+        ))}
+      </div>
+    )
+  }
+  const src = images[0] || item.cover_url
+  return src ? (
+    <button className="coverbtn" type="button" onClick={() => onImageClick?.([src], 0, item.title)}>
+      <img className="cover" src={src} alt="" loading="lazy" />
+    </button>
+  ) : null
+}
+
+function ResultCard({ item, platform, tone, onImageClick }) {
+  const [clsTone, clsText] = clsLabel(item.classification)
+  const hasKnowledge = Boolean(item.classification?.knowledge)
+  return (
+    <div className={`lane ${tone}`}>
+      <Media item={item} platform={platform} onImageClick={onImageClick} />
+      <div className="card-main">
+        <div className="t">{item.title || '无标题'}</div>
+        <span className={`cls ${clsTone}`}>{clsText}</span>
+      </div>
+      {item.author && <div className="meta">{item.author}</div>}
+      {item.classification?.reason && <div className="meta">{item.classification.reason}</div>}
+      {item.body_text && (
+        <details className="fold">
+          <summary>正文摘要</summary>
+          <div className="bt">{shortText(item.body_text, 520)}</div>
+        </details>
+      )}
+      {hasKnowledge && (
+        <details className="fold knfold">
+          <summary>创作知识</summary>
+          <div className="knbody">{item.classification.knowledge}</div>
+        </details>
+      )}
+      {item.url && <a className="src" href={item.url} target="_blank" rel="noreferrer">打开原链接</a>}
+    </div>
+  )
+}
+
+function PlatformGroup({ id, label, tone, data, onImageClick }) {
+  const items = data?.items || []
+  const creationItems = items.filter(item => item.classification?.is_creation === 1)
+  const otherItems = items.filter(item => item.classification?.is_creation !== 1)
+  const renderRow = (rowItems, title, dim = false) => (
+    <div className="result-band">
+      <div className={`subhd ${dim ? 'dim' : ''}`}>
+        <span>{title}</span>
+        <span className="subn">{rowItems.length}</span>
+      </div>
+      {rowItems.length === 0 ? (
+        <div className="row-empty">{dim ? '暂无非创作知识或判断失败' : '暂无创作知识'}</div>
+      ) : (
+        <div className="grid result-row">
+          {rowItems.map(item => <ResultCard key={item.id} item={item} platform={id} tone={tone} onImageClick={onImageClick} />)}
+        </div>
+      )}
+    </div>
+  )
+  return (
+    <section className="pgroup">
+      <div className={`ghead ${tone}`}>
+        <span>{label}</span>
+        <PlatformStatus platform={data} />
+        <span className="gn">{items.length}/5 条</span>
+      </div>
+      {data?.error && <div className="perr">{data.error}</div>}
+      {items.length === 0 ? (
+        <div className="gnone">暂无可展示内容</div>
+      ) : (
+        <>
+          {renderRow(creationItems, '创作知识')}
+          {renderRow(otherItems, '非创作知识 / 判断失败', true)}
+        </>
+      )}
+    </section>
+  )
+}
+
+export default function CreationQueryDetail({ query }) {
+  const [data, setData] = useState(null)
+  const [err, setErr] = useState('')
+  const [lightbox, setLightbox] = useState(null)
+
+  const openImage = (images, index, title) => {
+    setLightbox({ images, index, title: title || '帖子图片' })
+  }
+
+  const moveImage = (delta) => {
+    setLightbox(prev => {
+      if (!prev) return prev
+      const next = (prev.index + delta + prev.images.length) % prev.images.length
+      return { ...prev, index: next }
+    })
+  }
+
+  useEffect(() => {
+    setData(null)
+    setErr('')
+    fetch('/api/creation-search/query?display_limit=5&query=' + encodeURIComponent(query))
+      .then(r => r.ok ? r.json() : Promise.reject(new Error('接口读取失败')))
+      .then(setData)
+      .catch(e => setErr(e.message || '读取失败'))
+  }, [query])
+
+  return (
+    <div>
+      <div className="detail-top">
+        <a className="back" href="#/">返回 Query Demo</a>
+        {data?.run_id && <span className="meta">run_id: {data.run_id}</span>}
+      </div>
+      <h1 className="dq">{query}</h1>
+      {err && <div className="empty">{err}</div>}
+      {!data && !err && <div className="empty">加载中...</div>}
+      {data && PLATFORMS.map(([id, label, tone]) => (
+        <PlatformGroup key={id} id={id} label={label} tone={tone} data={data.platforms?.[id]} onImageClick={openImage} />
+      ))}
+      {lightbox && (
+        <div className="lightbox" onClick={() => setLightbox(null)}>
+          <button className="lbx" type="button" onClick={() => setLightbox(null)}>×</button>
+          {lightbox.images.length > 1 && (
+            <button className="lbnav prev" type="button" onClick={(e) => { e.stopPropagation(); moveImage(-1) }}>‹</button>
+          )}
+          <figure className="lbfig" onClick={(e) => e.stopPropagation()}>
+            <img src={lightbox.images[lightbox.index]} alt="" />
+            <figcaption>{lightbox.title} · {lightbox.index + 1}/{lightbox.images.length}</figcaption>
+          </figure>
+          {lightbox.images.length > 1 && (
+            <button className="lbnav next" type="button" onClick={(e) => { e.stopPropagation(); moveImage(1) }}>›</button>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}

+ 0 - 131
acquisition/web/app/src/pages/QueriesPage.jsx

@@ -1,131 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import { fetchManualAxes, fetchMethods, fetchQueries, fetchSearchSummary } from '../api.js'
-import Pager from '../components/Pager.jsx'
-
-const SIZE = 30
-// 三种 query 生成方法的展示顺序(① 实质 ② 形式 ④ 多轴)
-const ORDER = ['实质 × 创作阶段 × 需求', '形式 + 载体位置', '多轴正交组合']
-
-// 生成的 query 列表(按打法筛 + 分页)。打法 = 横向三按钮,点击切面板。每条 query 末列挂
-// 「真实搜索结果」按钮:搜到结果 → 可跳转进详情;搜过没结果 / 没搜过 → 灰色不可点。
-export default function QueriesPage() {
-  const [methods, setMethods] = useState([])
-  const [method, setMethod] = useState('')
-  const [page, setPage] = useState(1)
-  const [data, setData] = useState({ total: 0, page: 1, size: SIZE, items: [] })
-  const [summary, setSummary] = useState({})
-  const [manualAxes, setManualAxes] = useState({})
-  const [showAxes, setShowAxes] = useState(false)
-
-  useEffect(() => {
-    fetchMethods('queries').then((ms) => {
-      const ordered = [...ms].sort((a, b) => (ORDER.indexOf(a) + 1 || 99) - (ORDER.indexOf(b) + 1 || 99))
-      setMethods(ordered)
-      if (ordered.length) setMethod((m) => m || ordered[0])   // 默认选第一个面板
-    })
-  }, [])
-  useEffect(() => { fetchSearchSummary().then(setSummary) }, [])
-  useEffect(() => { fetchManualAxes().then(setManualAxes) }, [])
-  useEffect(() => { if (method) fetchQueries({ method, page, size: SIZE }).then(setData) }, [method, page])
-
-  const pick = (m) => { setMethod(m); setPage(1) }
-  const axes = manualAxes[method]   // 当前面板的人工定义轴
-
-  return (
-    <div>
-      <div className="nav">
-        {methods.map((m) => (
-          <button key={m} className={method === m ? 'on' : ''} onClick={() => pick(m)}>{m}</button>
-        ))}
-      </div>
-      <div className="filters">
-        {axes && <button className="btn ghost" onClick={() => setShowAxes(true)}>📋 查看人工定义的轴</button>}
-        <span className="count">共 {data.total} 条 query</span>
-      </div>
-
-      {showAxes && axes && <AxesModal method={method} axes={axes} onClose={() => setShowAxes(false)} />}
-
-      {data.items.length === 0 ? (
-        <div className="empty">暂无数据。先跑 <code>scripts/build_query_demo.py</code> 再 <code>scripts/import_to_db.py</code></div>
-      ) : (
-        <table>
-          <thead>
-            <tr><th>正交轴</th><th>query</th><th>筛选</th><th>真实搜索结果</th></tr>
-          </thead>
-          <tbody>
-            {data.items.map((it) => {
-              const { keep, filter_reason, ...axes } = it.axes || {}   // keep/filter_reason 是过滤层打的标,单独渲染
-              const filtered = keep !== undefined
-              return (
-                <tr key={it.id} className={filtered && !keep ? 'qdrop' : ''}>
-                  <td><div className="axes">{Object.entries(axes).map(([k, v]) => (
-                    <span className="tag k" key={k}>{k}:{v}</span>
-                  ))}</div></td>
-                  <td className="q">{it.query}</td>
-                  <td className="fcell">{filtered ? (
-                    <><span className={`fbadge ${keep ? 'keep' : 'drop'}`}>{keep ? '✓ 留' : '✕ 排除'}</span>
-                      {!keep && filter_reason && <span className="freason">{filter_reason}</span>}</>
-                  ) : <span className="muted">—</span>}</td>
-                  <td><ResultBtn s={summary[it.query]} query={it.query} /></td>
-                </tr>
-              )
-            })}
-          </tbody>
-        </table>
-      )}
-
-      <Pager page={data.page} size={data.size} total={data.total} onPage={setPage} />
-    </div>
-  )
-}
-
-// 人工定义轴弹窗:列出该方法每个人工轴的全部取值。
-// 取值形状有三种:list(动作/阶段/模态)、{key:[..]}(需求点/载体位置)、{key:str}(知识类型后缀)。
-function AxesModal({ method, axes, onClose }) {
-  return (
-    <div className="modal-mask" onClick={onClose}>
-      <div className="modal" onClick={(e) => e.stopPropagation()}>
-        <div className="modal-hd">
-          <b>人工定义的轴 · {method}</b>
-          <span className="x" onClick={onClose}>✕</span>
-        </div>
-        <div className="modal-bd">
-          <p className="note">实质 / 形式 / 作用来自分类树;下面这些是人工定义的轴及全部取值。</p>
-          {Object.entries(axes).map(([name, vals]) => (
-            <div className="axrow" key={name}>
-              <div className="axname">{name}</div>
-              <div className="axvals"><AxValues vals={vals} /></div>
-            </div>
-          ))}
-        </div>
-      </div>
-    </div>
-  )
-}
-
-function AxValues({ vals }) {
-  if (Array.isArray(vals)) {
-    return <>{vals.map((v) => <span className="tag k" key={v}>{v}</span>)}</>
-  }
-  // 字典:值是数组 → 「键:a b c」;值是字符串 → 「键 → 值」
-  return (
-    <div className="axsub">
-      {Object.entries(vals).map(([k, v]) => (
-        <div className="axsubrow" key={k}>
-          <span className="axsubkey">{k}</span>
-          {Array.isArray(v)
-            ? v.map((x) => <span className="tag k" key={x}>{x}</span>)
-            : <span className="tag k">{v}</span>}
-        </div>
-      ))}
-    </div>
-  )
-}
-
-function ResultBtn({ s, query }) {
-  if (s && s.ok > 0) {
-    return <a className="btn" href={`#/q/${encodeURIComponent(query)}`}>🔎 看 {s.ok} 个结果 ›</a>
-  }
-  if (s) return <span className="btn off">搜过 · 无结果</span>
-  return <span className="btn off">未搜索</span>
-}

+ 0 - 142
acquisition/web/app/src/pages/QueryDetail.jsx

@@ -1,142 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import { fetchJudgePrompts, fetchSearch } from '../api.js'
-
-// 渠道分区展示:小红书 → 微信公众号 → 抖音;每区内「创作知识」在前、「非创作知识」单独起一行。
-const PLATFORMS = [
-  { key: 'xiaohongshu', label: '小红书', cls: 'xhs' },
-  { key: 'weixin', label: '微信公众号', cls: 'wx' },
-  { key: 'douyin', label: '抖音', cls: 'dy' },
-]
-
-// 单条 query 的真实搜索结果详情:进来即「点进该 query 生成的多个视频里」。
-export default function QueryDetail({ query }) {
-  const [data, setData] = useState({ total: 0, items: [] })
-  const [showPrompts, setShowPrompts] = useState(false)
-  const [zoom, setZoom] = useState(null)   // 点开的大图(小红书/微信原帖被反爬封,直接看本地大图)
-  useEffect(() => { fetchSearch({ query, size: 100 }).then(setData) }, [query])
-
-  const hits = data.items.filter((r) => r.ok)
-
-  return (
-    <div>
-      <div className="detail-top">
-        <a className="back" href="#/">← 返回 query 列表</a>
-        <button className="btn ghost" onClick={() => setShowPrompts(true)}>📋 创作知识判断提示词</button>
-      </div>
-      <h2 className="dq">🔎 {query}</h2>
-      <div className="sub">该 query 搜到的真实帖子/视频 · 共 {hits.length} 个 ·
-        <span className="cls yes" style={{ margin: '0 4px' }}>创作知识</span>/
-        <span className="cls no" style={{ margin: '0 4px' }}>非创作知识</span> 由模型读真实内容判定</div>
-
-      {showPrompts && <JudgePromptModal onClose={() => setShowPrompts(false)} />}
-      {zoom && (
-        <div className="lightbox" onClick={() => setZoom(null)}>
-          <img src={zoom} alt="" onClick={(e) => e.stopPropagation()} />
-          <span className="lbx" onClick={() => setZoom(null)}>✕</span>
-        </div>
-      )}
-
-      {hits.length === 0 ? (
-        <div className="empty">这条 query 没搜到带媒体的结果。</div>
-      ) : (
-        PLATFORMS.map((p) => {
-          const list = hits.filter((r) => r.platform === p.key)
-          const fail = data.items.find((r) => r.platform === p.key && !r.ok)
-          const creation = list.filter((r) => r.cls?.is_creation === 1)
-          const others = list.filter((r) => r.cls?.is_creation !== 1)   // 非创作 + 未分类
-          return (
-            <section className="pgroup" key={p.key}>
-              <div className={`ghead ${p.cls}`}>{p.label}<span className="gn">{list.length}</span></div>
-              {list.length === 0 ? (
-                <div className="gnone">未搜到{fail?.extra?.error ? `(${fail.extra.error})` : ''}</div>
-              ) : (
-                <>
-                  {creation.length > 0 && (
-                    <>
-                      <div className="subhd">创作知识 · {creation.length}</div>
-                      <div className="grid">{creation.map((r) => <Result key={r.id} r={r} cls={p.cls} onZoom={setZoom} />)}</div>
-                    </>
-                  )}
-                  {others.length > 0 && (
-                    <>
-                      <div className="subhd dim">非创作知识 · {others.length}</div>
-                      <div className="grid">{others.map((r) => <Result key={r.id} r={r} cls={p.cls} onZoom={setZoom} />)}</div>
-                    </>
-                  )}
-                </>
-              )}
-            </section>
-          )
-        })
-      )}
-    </div>
-  )
-}
-
-// 创作知识判断提示词弹窗:图文 / 视频 两套真实提示词(extract.txt / extract_video.txt)。
-function JudgePromptModal({ onClose }) {
-  const [prompts, setPrompts] = useState([])
-  const [tab, setTab] = useState(0)
-  useEffect(() => { fetchJudgePrompts().then(setPrompts) }, [])
-  return (
-    <div className="modal-mask" onClick={onClose}>
-      <div className="modal wide" onClick={(e) => e.stopPropagation()}>
-        <div className="modal-hd"><b>创作知识判断提示词</b><span className="x" onClick={onClose}>✕</span></div>
-        <div className="modal-bd">
-          <p className="note">分类直接复用 pipeline 的真实提示词:图文用 extract.txt、视频用 extract_video.txt,
-            让 Gemini 读真实内容后输出 is_empty 作为「创作 / 非创作」判据(不简化、不另造)。</p>
-          <div className="ptabs">
-            {prompts.map((p, i) => (
-              <button key={i} className={tab === i ? 'on' : ''} onClick={() => setTab(i)}>
-                {p.name} · {p.file}
-              </button>
-            ))}
-          </div>
-          {prompts[tab] && <pre className="prompt">{prompts[tab].text}</pre>}
-        </div>
-      </div>
-    </div>
-  )
-}
-
-function ClsBadge({ cls }) {
-  if (!cls || cls.is_creation === null) return <span className="cls none">未分类</span>
-  const yes = cls.is_creation === 1
-  return <span className={`cls ${yes ? 'yes' : 'no'}`} title={cls.reason || ''}>
-    {yes ? '创作知识' : '非创作知识'}
-  </span>
-}
-
-function Result({ r, cls, onZoom }) {
-  const imgs = (r.extra?.images && r.extra.images.length ? r.extra.images : (r.cover ? [r.cover] : []))
-  return (
-    <div className={`lane ${cls}`}>
-      {r.video
-        ? <video className="sv" src={r.video} controls preload="metadata" poster={r.cover || undefined} />
-        : imgs.length > 1
-          ? <div className="gallery">{imgs.map((u, i) => (
-              <img key={i} className="gimg" src={u} alt="" onClick={() => onZoom(u)} />))}</div>
-          : imgs[0] && <img className="cover" src={imgs[0]} alt="" onClick={() => onZoom(imgs[0])} />}
-      <ClsBadge cls={r.cls} />
-      <div className="t">{r.title || '(无标题)'}</div>
-      {r.extra?.body_text && <div className="bt">{r.extra.body_text}</div>}
-      {r.cls?.is_creation === 1 && r.cls?.knowledge && <Knowledge text={r.cls.knowledge} />}
-      <div className="meta">
-        {imgs.length > 1 ? `${imgs.length} 图 · ` : ''}
-        {r.extra?.nick ? `${r.extra.nick} · ` : ''}
-        {r.url && <a href={r.url} target="_blank" rel="noreferrer">打开原帖 ↗</a>}
-      </div>
-    </div>
-  )
-}
-
-// 提取出的创作知识点(可折叠,默认展开)
-function Knowledge({ text }) {
-  const [open, setOpen] = useState(true)
-  return (
-    <div className="kn">
-      <div className="knhd" onClick={() => setOpen(!open)}>📚 提取的创作知识点 <span className="kct">{open ? '收起 ▲' : '展开 ▼'}</span></div>
-      {open && <div className="knbody">{text}</div>}
-    </div>
-  )
-}

+ 86 - 25
acquisition/web/app/src/styles.css

@@ -23,6 +23,22 @@ a:hover { text-decoration: underline; }
 h1 { font-size: 22px; margin: 0 0 2px; letter-spacing: .5px; }
 .sub { color: var(--muted); font-size: 12.5px; margin-bottom: 18px; }
 
+/* 创作Demo:家族选择器左右两栏竖排,按尾缀分组 */
+.famcols { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; }
+.famcol { flex: 1 1 0; display: flex; flex-direction: column; gap: 6px; }
+.famcol-hd { font-size: 12px; font-weight: 700; color: var(--muted); padding: 0 2px 4px; }
+.fbtn { display: flex; justify-content: space-between; align-items: baseline; gap: 14px; text-align: left; padding: 9px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); font-size: 13px; color: var(--ink); cursor: pointer; }
+.fbtn:hover { border-color: var(--accent); }
+.fbtn.on { border-color: var(--accent); background: var(--accent); color: #fff; font-weight: 600; }
+.fbtn-pre { flex: 0 1 auto; }
+.fbtn-suf { flex: 0 0 auto; margin-left: auto; opacity: 0.7; }   /* 尾缀靠右、略淡 */
+.fbtn.on .fbtn-suf { opacity: 0.85; }
+
+/* 创作Query正交搜索结果:每条 query 一块 */
+.qblock { margin: 14px 0 22px; }
+.qbhd { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding-bottom: 8px; margin-bottom: 10px; border-bottom: 1px solid var(--line); }
+.qbq { font-size: 15px; font-weight: 700; color: var(--ink); }
+
 /* 页面切换顶栏 */
 .topnav { display: flex; gap: 18px; border-bottom: 1px solid var(--line); margin-bottom: 16px; }
 .topnav a { padding: 8px 2px; color: var(--muted); font-size: 14px; border-bottom: 2px solid transparent; margin-bottom: -1px; }
@@ -30,24 +46,39 @@ h1 { font-size: 22px; margin: 0 0 2px; letter-spacing: .5px; }
 
 /* 创作Query Demo:多列(轴列 + Query列) */
 .cdemo { display: flex; gap: 10px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
-.axcol { flex: 0 0 auto; width: 150px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); overflow: hidden; }
-.axcol.qcol { flex: 1 1 auto; min-width: 300px; }
+.axcol { flex: 0 0 auto; width: 114px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); overflow: hidden; }
+.axcol.qcol { flex: 1 1 auto; min-width: 560px; }
 .axhd { font-size: 13px; font-weight: 700; padding: 9px 11px; border-bottom: 1px solid var(--line); background: #fafafb;
   display: flex; align-items: center; justify-content: space-between; gap: 6px; }
 .axhd.qhd { color: var(--accent); }
 .axhd .gn { font-size: 11px; font-weight: 600; color: var(--muted); background: #f0f0f3; border-radius: 10px; padding: 1px 7px; }
+.qhd-metrics { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
+.avgcreation { font-size: 11px; font-weight: 700; border-radius: 10px; padding: 1px 8px; white-space: nowrap; }
+.avgcreation.done { color: var(--ok); background: #e6f6ee; }
+.avgcreation.pending { color: #9a6a00; background: #fff6df; }
 .axlist { max-height: 62vh; overflow: auto; }
 .axv { font-size: 12.5px; padding: 5px 11px; border-bottom: 1px solid #f3f3f5; color: #555; white-space: nowrap; }
 .axv.grp { font-weight: 700; color: #222; background: #fafafb; }
 .axv.child { padding-left: 26px; color: #666; }
 .axv.child::before { content: '·'; color: var(--muted); margin-right: 6px; }
-.qrow { display: flex; align-items: baseline; gap: 7px; padding: 6px 11px; border-bottom: 1px solid #f3f3f5; font-size: 13px; }
+.qrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 7px; padding: 6px 11px; border-bottom: 1px solid #f3f3f5; font-size: 13px; }
 .qrow.keep .qmark { color: var(--ok); font-weight: 700; }
 .qrow.drop { color: var(--muted); }
 .qrow.drop .qmark { color: var(--bad); }
 .qrow.drop .qtext { text-decoration: line-through; }
-.qtext { flex: 0 0 auto; }
-.qreason { color: var(--muted); font-size: 11.5px; }
+.qmark { flex: 0 0 auto; }
+.qvalid { flex: 0 0 auto; font-size: 11px; font-weight: 700; padding: 0 6px; border-radius: 9px; }
+.qvalid.ok { color: var(--ok); background: #e6f6ee; }
+.qvalid.low { color: var(--bad); background: #fbeaea; }
+.qtext { flex: 1 1 auto; white-space: normal; word-break: break-word; }   /* 长 query 换行显示,别截断 */
+.qreason { flex: 1 0 100%; margin-left: 20px; color: var(--muted); font-size: 11.5px; }   /* 理由独占一行、占满宽 */
+.qdetail { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line);
+  border-radius: 8px; padding: 2px 7px; font-size: 11.5px; color: var(--muted); background: #fff; }
+.qdetail.on { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); }
+.qdetail span { color: inherit; opacity: .8; }
+.qcreation { flex: 0 0 auto; font-size: 11.5px; font-weight: 700; border-radius: 9px; padding: 1px 7px; white-space: nowrap; }
+.qcreation.done { color: var(--ok); background: #e6f6ee; }
+.qcreation.pending { color: #9a6a00; background: #fff6df; }
 
 /* 顶部主导航 */
 .nav { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
@@ -135,37 +166,50 @@ td.q { font-weight: 600; }
 .card .head .q { font-weight: 700; font-size: 14.5px; }
 .card .head .m { color: var(--muted); font-size: 12px; }
 .lanes { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
-.lane { border: 1px solid var(--line); border-radius: 10px; padding: 10px; background: #fcfcfd; }
+.lane { border: 1px solid var(--line); border-radius: 10px; padding: 10px; background: #fcfcfd; min-width: 0; }
 .lane .pl { font-size: 12px; font-weight: 700; margin-bottom: 8px; }
 .lane .pl.dy { color: #111; }
 .lane .pl.wx { color: #15a36a; }
-.lane video, .lane img.cover { width: 100%; max-height: 260px; object-fit: cover; border-radius: 8px; background: #000; display: block; }
-.lane .t { font-size: 12.5px; margin: 7px 0 3px; line-height: 1.45; }
+.lane video, .lane img.cover { width: 100%; max-height: 210px; object-fit: cover; border-radius: 8px; background: #000; display: block; }
+.lane .t { flex: 1 1 auto; font-size: 12.5px; margin: 0; line-height: 1.45; font-weight: 700; min-width: 0; }
+.card-main { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin: 8px 0 3px; }
 /* 创作知识 / 非创作知识 分类角标 */
 .cls { display: inline-block; font-size: 11px; font-weight: 700; border-radius: 6px;
-  padding: 2px 8px; margin: 8px 0 2px; }
+  padding: 2px 8px; margin: 0; white-space: nowrap; }
 .cls.yes { background: #e6f7ee; color: var(--ok); }
 .cls.no { background: #fbeaea; color: var(--bad); }
 .cls.none { background: #f0f0f3; color: var(--muted); font-weight: 600; }
-/* 小红书完整帖:图片画廊 + 正文(链接会被反爬封,整帖存本地) */
-.gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
-.gallery .gimg { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; background: #f0f0f3; cursor: zoom-in; }
+/* 小红书/公众号完整图文:卡片内只放缩略条,点击进大图阅读 */
+.thumbstrip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 3px; scrollbar-width: thin; }
+.thumbbtn, .coverbtn { border: 0; padding: 0; background: transparent; cursor: zoom-in; flex: 0 0 auto; position: relative; }
+.thumbbtn .gimg { width: 72px; height: 92px; object-fit: cover; object-position: top; border-radius: 7px; background: #f0f0f3; border: 1px solid var(--line); display: block; }
+.thumbbtn span { position: absolute; right: 4px; bottom: 4px; font-size: 10px; line-height: 1; color: #fff; background: rgba(0,0,0,.58); border-radius: 8px; padding: 2px 5px; }
+.coverbtn { width: 100%; display: block; }
 .lane .cover { cursor: zoom-in; }
 
-/* 提取的创作知识点 */
-.kn { margin: 8px 0 4px; border: 1px solid var(--accent-soft); border-radius: 8px; overflow: hidden; }
-.knhd { background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 700;
-  padding: 5px 9px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
-.knhd .kct { font-weight: 600; font-size: 11px; }
-.knbody { padding: 8px 10px; font-size: 12px; line-height: 1.65; color: var(--ink); white-space: pre-wrap; }
+/* 长正文 / 知识点默认折叠,避免卡片一屏过高 */
+.fold { margin-top: 8px; border: 1px solid var(--line); border-radius: 8px; background: #fff; overflow: hidden; }
+.fold summary { cursor: pointer; list-style: none; padding: 6px 9px; font-size: 12px; font-weight: 700; color: var(--muted); display: flex; align-items: center; justify-content: space-between; }
+.fold summary::-webkit-details-marker { display: none; }
+.fold summary::after { content: '展开'; font-size: 11px; font-weight: 600; color: var(--muted); }
+.fold[open] summary::after { content: '收起'; }
+.knfold { border-color: var(--accent-soft); }
+.knfold summary { background: var(--accent-soft); color: var(--accent); }
+.knbody { padding: 9px 10px; font-size: 12px; line-height: 1.68; color: var(--ink); white-space: pre-wrap; max-height: 260px; overflow: auto; }
 
 /* 点击放大大图(小红书原帖被反爬封,直接看本地大图) */
-.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.86); z-index: 60;
-  display: flex; align-items: center; justify-content: center; padding: 24px; cursor: zoom-out; }
-.lightbox img { max-width: 96vw; max-height: 94vh; object-fit: contain; border-radius: 6px; cursor: default; }
-.lightbox .lbx { position: fixed; top: 16px; right: 22px; color: #fff; font-size: 26px; cursor: pointer; }
-.lane .bt { font-size: 11.5px; color: #555; line-height: 1.5; margin: 6px 0 4px; max-height: 78px;
-  overflow: hidden; white-space: pre-wrap; }
+.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 60;
+  display: flex; align-items: center; justify-content: center; padding: 22px 72px; cursor: zoom-out; }
+.lbfig { margin: 0; max-width: 100%; max-height: 100%; display: flex; flex-direction: column; align-items: center; gap: 8px; }
+.lightbox img { max-width: calc(100vw - 150px); max-height: calc(100vh - 76px); object-fit: contain; border-radius: 6px; cursor: default; background: #fff; }
+.lbfig figcaption { color: #f4f4f7; font-size: 12px; max-width: min(860px, 90vw); text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.lightbox .lbx { position: fixed; top: 14px; right: 20px; color: #fff; font-size: 28px; cursor: pointer; border: 0; background: transparent; padding: 4px 8px; }
+.lbnav { position: fixed; top: 50%; transform: translateY(-50%); width: 42px; height: 54px; border: 0; border-radius: 10px; background: rgba(255,255,255,.14); color: #fff; font-size: 34px; cursor: pointer; }
+.lbnav:hover { background: rgba(255,255,255,.24); }
+.lbnav.prev { left: 18px; }
+.lbnav.next { right: 18px; }
+.lane .bt { font-size: 11.8px; color: #444; line-height: 1.58; padding: 0 10px 9px; margin: 0; max-height: 190px;
+  overflow: auto; white-space: pre-wrap; }
 .lane .meta { color: var(--muted); font-size: 11.5px; }
 .lane .fail { color: var(--bad); font-size: 12.5px; padding: 18px 4px; text-align: center; }
 
@@ -189,6 +233,9 @@ td.q { font-weight: 600; }
 .back { font-size: 13px; }
 .dq { font-size: 18px; margin: 10px 0 2px; }
 .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
+.result-band + .result-band { margin-top: 16px; }
+.result-row { align-items: start; }
+.row-empty { color: var(--muted); font-size: 12.5px; padding: 8px 0 12px; border-top: 1px dashed var(--line); }
 
 /* 渠道分区:抖音一组、微信公众号一组,标题色区分 */
 .pgroup { margin-top: 22px; }
@@ -200,15 +247,29 @@ td.q { font-weight: 600; }
 .ghead .gn { font-size: 11.5px; font-weight: 600; color: var(--muted);
   background: #f0f0f3; border-radius: 10px; padding: 1px 8px; }
 .gnone { color: var(--muted); font-size: 12.5px; padding: 2px 0 6px; }
+.perr { color: var(--bad); font-size: 12px; margin: -6px 0 10px; }
+.status { font-size: 11.5px; font-weight: 700; border-radius: 10px; padding: 1px 8px; background: #f0f0f3; color: var(--muted); }
+.status.done { background: #e6f6ee; color: var(--ok); }
+.status.partial, .status.running { background: #fff6df; color: #9a6a00; }
+.status.failed { background: #fbeaea; color: var(--bad); }
+.src { display: inline-block; margin-top: 8px; font-size: 12px; }
 /* 平台区内:创作知识 / 非创作知识 小标题(各自起一行) */
 .subhd { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700;
   color: var(--ink); margin: 14px 0 8px; }
 .subhd::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
 .subhd.dim { color: var(--muted); }
 .subhd.dim::before { background: var(--bad); }
+.subn { font-size: 11px; font-weight: 700; color: var(--muted); background: #f0f0f3; border-radius: 10px; padding: 1px 7px; }
 /* 卡片左侧细色条,进一步区分渠道归属 */
 .lane.dy { border-left: 3px solid #111; }
 .lane.wx { border-left: 3px solid var(--ok); }
 .lane.xhs { border-left: 3px solid #ff2442; }
 
-@media (max-width: 720px) { .lanes { grid-template-columns: 1fr; } }
+@media (max-width: 720px) {
+  .lanes { grid-template-columns: 1fr; }
+  .lightbox { padding: 18px 12px 48px; }
+  .lightbox img { max-width: 96vw; max-height: 82vh; }
+  .lbnav { bottom: 10px; top: auto; transform: none; }
+  .lbnav.prev { left: 26px; }
+  .lbnav.next { right: 26px; }
+}

+ 28 - 2
acquisition/web_api.py

@@ -23,7 +23,10 @@ router = APIRouter(prefix="/api")
 _ROOT = Path(__file__).resolve().parent.parent
 
 # 分类「创作知识 / 非创作知识」用的真实判断提示词(图文 / 视频),前端弹窗展示。读 is_empty 为判据。
-_JUDGE_PROMPTS = [("图文(小红书 / 微信公众号)", "extract.txt"), ("视频(抖音)", "extract_video.txt")]
+_JUDGE_PROMPTS = [
+    ("图文(小红书 / 微信公众号)", "classify_imgtext.txt"),
+    ("视频(抖音)", "classify_video.txt"),
+]
 
 # 各方法里【人工定义】的轴及其全部取值(实质/形式/作用来自分类树,不在此)。单一来源 = query.py。
 MANUAL_AXES = {
@@ -35,7 +38,7 @@ MANUAL_AXES = {
 
 @router.get("/judge-prompts")
 def judge_prompts() -> dict:
-    """分类创作知识用的真实提示词(图文 extract.txt / 视频 extract_video.txt),原样返回供前端展示。"""
+    """分类创作知识用的真实提示词(图文 / 视频),原样返回供前端展示。"""
     out = []
     for label, fn in _JUDGE_PROMPTS:
         try:
@@ -111,3 +114,26 @@ def search_summary() -> dict:
         return {"summary": store.search_summary(conn)}
     finally:
         conn.close()
+
+
+@router.get("/creation-search/summary")
+def creation_search_summary(run_id: Optional[str] = None) -> dict:
+    """430 query 真实采集状态:{query: {platforms, done, failed, display_count}}。"""
+    conn = store.connect()
+    try:
+        return store.creation_search_summary(conn, run_id=run_id)
+    finally:
+        conn.close()
+
+
+@router.get("/creation-search/query")
+def creation_search_query(query: str, run_id: Optional[str] = None,
+                          display_limit: int = 5) -> dict:
+    """单条 query 的三平台详情,每个平台最多 display_limit 条可展示内容。"""
+    conn = store.connect()
+    try:
+        return store.get_creation_query_detail(
+            conn, query, run_id=run_id, display_limit=display_limit
+        )
+    finally:
+        conn.close()

+ 376 - 0
docs/acquisition-runtime-boundary.md

@@ -0,0 +1,376 @@
+# Acquisition Demo Runtime Boundary
+
+本文整理当前 acquisition demo 在 `acquisition/` 目录以外真实使用的输入、输出、配置和外部接口。调查时间:2026-06-30。
+
+## 当前状态
+
+- 已尝试关闭历史 SubAgent `019f1683-1368-7b20-8d07-8ce7532ab34e`,工具返回 `not found`,当前没有可管理的活跃 SubAgent。
+- 当前前端服务入口是 `creation_knowledge.api:app`,本地通常跑在 `http://localhost:8126/app/#/`。
+- 当前 demo 主链路不是旧的“找帖子”页面,而是“创作 query 正交 demo”:
+  - query 生成:`scripts/build_creation_demo.py`
+  - 真实采集:`scripts/run_creation_search.py`
+  - AI 补判:`scripts/classify_creation_items.py`
+  - API + 静态托管:`creation_knowledge/api.py` + `acquisition/web_api.py`
+  - 前端:`acquisition/web/app/src/App.jsx`、`CreationDemo.jsx`、`CreationQueryDetail.jsx`
+
+## 目录外真实依赖
+
+### `core/`
+
+`acquisition` 依赖 `core` 作为共享底座:
+
+- `core/config.py`
+  - 读取 `.env` 或系统环境变量。
+  - 组装 `Settings`,供搜索、详情、OSS、模型判断、静态目录使用。
+- `core/prompts.py`
+  - 从项目根 `prompts/<name>.txt` 读取 prompt。
+- `core/llm.py`
+  - 走 OpenRouter `/chat/completions`,用于旧 query 生成/部分 query 过滤。
+- `core/models.py`
+  - `Post` / `Card` 数据模型,被 crawler/detail parse 使用。
+- `core/embedding.py`
+  - 走火山 Ark embedding。当前 acquisition demo 主链路不直接用,旧/解构链路和 scope 工具可能用。
+- `core/db.py`
+  - PostgreSQL/Greenplum 连接工具。当前 demo 主链路主要用 SQLite;旧/正式入库链路会用。
+
+### `creation_knowledge/`
+
+当前 acquisition demo 借用了 `creation_knowledge` 的服务壳和媒体下载工具:
+
+- `creation_knowledge/api.py`
+  - FastAPI app。
+  - 挂载 `/app` 到 `acquisition/web/app/dist`。
+  - 挂载 `/data` 到 `Settings.data_dir`,默认 `data`。
+  - 挂载 `/frames` 到 `Settings.frames_dir`,默认 `runtime/frames`。
+  - include `acquisition.web_api.router`。
+- `creation_knowledge/integrations/video_extract.py`
+  - `acquisition.creation_search` 只复用 `_default_download` 下载图片/媒体。
+  - 旧视频解构链路会使用更多 video extract 逻辑。
+
+### `scripts/`
+
+当前 demo 的主要操作入口在 `scripts/`,不在 `acquisition/` 内:
+
+- `scripts/build_creation_demo.py`
+  - 读 `scope_trees/trees_index.json`。
+  - 调 `acquisition.query_filter.filter_queries`。
+  - 写 `data/queries/creation_demo.json`。
+- `scripts/run_creation_search.py`
+  - 读 `data/queries/creation_demo.json`。
+  - 写 `data/app.db` 的 `creation_*` 表。
+  - 下载小红书/公众号图片到 `data/media/...`。
+  - 抖音视频调用 OSS 上传接口,保存 CDN URL 到 SQLite。
+- `scripts/classify_creation_items.py`
+  - 从 `data/app.db` 读取未判断/失败的 item。
+  - 调 `acquisition.classify` 使用 Qwen / Ark / OpenRouter。
+  - 写 `creation_item_classifications`。
+
+旧链路仍存在但不是当前 demo 主路径:
+
+- `scripts/decompose.py`
+  - 读 `创作知识提取-skill/extraction/phase1-frame.md`、`phase2-scope.md`。
+  - 读 `prompts/gate_admit.txt`、`gate_refute.txt`、`gate_tiebreak.txt` 等。
+  - 输出 `outputs/`、`web/frameworks*.json`、`web/payloads*.json` 等旧/后续解构产物。
+- `scripts/run_search.py`
+  - 旧“找帖子”链路,写 `data/search_results.json`、`data/search/...`。
+
+## Prompt 与 Skill
+
+### 当前 demo 直接使用的 prompt
+
+- `acquisition/query_filter.txt`
+  - query 机械正交后做 valid/relevant 过滤。
+  - `scripts/build_creation_demo.py` 间接调用。
+  - 前端 `/api/filter-prompt` 会原样展示。
+- `prompts/classify_imgtext.txt`
+  - 小红书、微信公众号图文判断“是不是创作知识”。
+  - `acquisition.classify.classify_imgtext` 读取。
+  - 前端 `/api/judge-prompts` 会展示。
+- `prompts/classify_video.txt`
+  - 抖音视频判断“是不是创作知识”。
+  - `acquisition.classify.classify_video` 读取。
+  - 当前列表里的 `x/10创作知识` 暂不把抖音计入分母。
+
+### 旧/解构链路使用的 prompt
+
+- `prompts/extract.txt`
+- `prompts/extract_video.txt`
+- `prompts/gate_admit.txt`
+- `prompts/gate_refute.txt`
+- `prompts/gate_tiebreak.txt`
+- `prompts/gate_how_*`
+- `prompts/gate_why_refute.txt`
+- `prompts/normalize_scope.txt`
+- `prompts/query_gen.txt`
+- `prompts/form_query_gen.txt`
+
+### 创作知识提取 skill
+
+`创作知识提取-skill/` 当前不是 acquisition demo 主链路的直接运行依赖。它仍然是后续“真实解构/组装 payload”的方法论来源:
+
+- `scripts/decompose.py` 直接读取:
+  - `创作知识提取-skill/extraction/phase1-frame.md`
+  - `创作知识提取-skill/extraction/phase2-scope.md`
+- skill 内还包含 schema、taxonomy、lint/ingest/scope-link 工具。
+
+## 数据输入与输出
+
+### Query 输入
+
+- `scope_trees/trees_index.json`
+  - `scripts/build_creation_demo.py` 读取。
+  - 生成 query 的实质/形式/作用/感受/意图树输入。
+- `scope_trees/trees.json`
+  - 当前 demo 主路径不直接读。
+- `scope_trees/trees_embeddings.npy`
+  - 当前 demo 主路径不直接读;scope/embedding 工具可能用。
+
+### Query 输出
+
+- `data/queries/creation_demo.json`
+  - 当前前端首页直接 fetch:`/data/queries/creation_demo.json`。
+  - `scripts/run_creation_search.py` 默认读取它,作为 430 query 输入。
+  - 当前大小约 140KB。
+
+### SQLite
+
+- `data/app.db`
+  - 当前 demo 的结构化结果库。
+  - SQLite,当前约 52MB。
+  - 当前主表:
+    - `creation_search_runs`
+    - `creation_search_jobs`
+    - `creation_search_items`
+    - `creation_item_classifications`
+  - 旧表目前为空:
+    - `queries`
+    - `search_results`
+    - `post_class`
+
+当前调查时行数:
+
+- `creation_search_runs`: 3
+- `creation_search_jobs`: 1302
+- `creation_search_items`: 6009
+- `creation_item_classifications`: 4290
+- `queries`: 0
+- `search_results`: 0
+- `post_class`: 0
+
+`creation_search_items` 存标题、正文、原帖 URL、本地图片 URL、OSS 视频 CDN URL、raw JSON 等;不是巨大 JSON 文件。
+
+### 媒体输出
+
+- `data/media/xiaohongshu/<query_hash>/<content_id>/image_*.webp|jpg|png`
+- `data/media/weixin/<query_hash>/<article_hash>/image_*.webp|jpg|png|gif`
+- 抖音视频不落本地,写 OSS CDN URL 到 SQLite `creation_search_items.video_url`。
+
+当前体量:
+
+- `data/media`: 约 5.4GB
+- `data/app.db`: 约 52MB
+- `data/queries`: 约 140KB
+- `data/` 总计:约 5.5GB
+
+### Runtime 输出
+
+- `runtime/logs/...`
+  - 全量采集、分类、web 服务日志。
+- `runtime/frames`
+  - 旧/视频帧链路使用;FastAPI 会挂载为 `/frames`。
+
+## `.env` 配置
+
+配置读取优先级是:真实环境变量 > `CK_ENV_FILE` 指定文件 > 默认 `.env` > 代码默认值。
+
+当前 demo 主链路实际会读取这些 key:
+
+### 基础
+
+- `CK_ENV_FILE`
+- `CK_DATA_DIR`
+- `CK_FRAMES_DIR`
+- `CK_MAX_CARDS`
+
+### 爬虫接口
+
+- `CONTENTFIND_API_CRAWAPI_BASE_URL`
+- `CONTENTFIND_API_CRAWAPI_KEY`
+- `CONTENTFIND_API_CRAWAPI_TIMEOUT_SECONDS`
+
+### 抖音独立后端
+
+- `CK_DOUYIN_BASE_URL`
+- `CK_DOUYIN_ACCOUNT_ID`
+- `CK_DOUYIN_COOKIE_BATCH`
+- `CK_DOUYIN_RATIO`
+
+### OSS 转存
+
+- `CK_OSS_UPLOAD_URL`
+- `CK_OSS_UPLOAD_TIMEOUT_SECONDS`
+
+### OpenRouter / Gemini
+
+- `OPENROUTER_BASE_URL`
+- `OPENROUTER_API_KEY`
+- `OPEN_ROUTER_API_KEY`
+- `CONTENT_AGENT_VIDEO_LLM_MODEL`
+- `GEMINI_API_KEY`
+
+### Qwen / DashScope
+
+- `QWEN_API_KEY`
+- `QWEN_BASE_URL`
+- `QWEN_MODEL`
+- `DASHSCOPE_API_KEY`
+- `DASHSCOPE_BASE_URL`
+- `DASHSCOPE_MODEL`
+- `CONTENT_AGENT_VIDEO_LLM_API_KEY`
+- `CONTENT_AGENT_VIDEO_LLM_BASE_URL`
+
+### Ark / 豆包 / 火山
+
+- `ARK_API_KEY`
+- `ARK_CHAT_URL`
+- `ARK_CHAT_MODEL`
+- `ARK_EMBEDDING_EP`
+- `ARK_EMBEDDING_URL`
+- `ARK_EMBEDDING_DIM`
+
+### 分类限流
+
+- `CLASSIFY_PROVIDER`
+- `CLASSIFY_MODEL`
+- `CLASSIFY_PROVIDER_MIN_INTERVAL_SECONDS`
+- `CLASSIFY_QWEN_MIN_INTERVAL_SECONDS`
+- `CLASSIFY_ARK_MIN_INTERVAL_SECONDS`
+- `CLASSIFY_429_BACKOFF_SECONDS`
+- `CLASSIFY_QWEN_429_BACKOFF_SECONDS`
+- `CLASSIFY_ARK_429_BACKOFF_SECONDS`
+
+### Query 过滤
+
+- `QUERY_FILTER_PROVIDER`
+
+### PG / 后续正式入库
+
+当前 acquisition demo 主要用 SQLite,但 `Settings` 仍要求/读取 PG 配置:
+
+- `OPEN_AIGC_PG_HOST`
+- `OPEN_AIGC_PG_PORT`
+- `OPEN_AIGC_PG_USER`
+- `OPEN_AIGC_PG_PASSWORD`
+- `OPEN_AIGC_PG_DB_NAME`
+- `CK_PG_SCHEMA`
+
+## 外部接口
+
+### 小红书
+
+- 搜索:`POST {CONTENTFIND_API_CRAWAPI_BASE_URL}/crawler/xiao_hong_shu/keyword`
+- 详情:`POST {CONTENTFIND_API_CRAWAPI_BASE_URL}/crawler/xiao_hong_shu/detail`
+
+当前 demo:
+
+- 搜索前 10 条。
+- 逐条拉详情。
+- 下载图文图片到本地 `data/media/xiaohongshu/...`。
+
+### 微信公众号
+
+- 搜索:`POST {CONTENTFIND_API_CRAWAPI_BASE_URL}/crawler/wei_xin/keyword`
+- 详情:`POST {CONTENTFIND_API_CRAWAPI_BASE_URL}/crawler/wei_xin/detail`
+
+当前 demo:
+
+- 搜索前 10 条。
+- 逐条拉详情。
+- 下载图文图片到本地 `data/media/weixin/...`。
+
+### 抖音
+
+- 搜索:`POST {CK_DOUYIN_BASE_URL}/crawler/dou_yin/keyword`
+- 详情:`POST {CK_DOUYIN_BASE_URL}/crawler/dou_yin/detail`
+
+当前 demo:
+
+- 请求体带 `account_id` 和 `cookie_batch`。
+- 搜索前 10 条。
+- 逐条拉详情。
+- 第一个 `video_url` 调 OSS 转存。
+- 本地不保存抖音视频文件。
+
+### OSS 转存
+
+- `POST {CK_OSS_UPLOAD_URL}`
+- 当前默认:`http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream`
+- 请求体:
+  - `src_url`
+  - `src_type`: `image` 或 `video`
+- 返回 `oss_object.cdn_url`。
+
+当前 demo 只强制用于抖音视频。文档 `docs/oss-media-transfer.md` 记录过:小红书图片、公众号图片、抖音视频都实测可转存;文字正文不能直接用该接口存,需要另做 JSON/HTML/Markdown 文件上传接口。
+
+### 多模态模型
+
+- OpenRouter:`{OPENROUTER_BASE_URL}/chat/completions`
+- Qwen/DashScope:`{QWEN_BASE_URL}/chat/completions`
+- Ark/豆包:默认 `https://ark.cn-beijing.volces.com/api/v3/chat/completions`
+
+当前 demo 判断逻辑:
+
+- `classify_imgtext`: 标题 + 正文前 1500 字 + 本地图片 base64 data URL。
+- `classify_video`: HTTP(S) CDN video URL 直接传模型;本地 mp4 是兼容 fallback。
+- Qwen / Ark / OpenRouter 按 provider 选择和可用密钥兜底。
+- provider 级 limiter 和 429 backoff 在单进程内生效。
+
+## 前端依赖
+
+前端不是独立后端,它由 FastAPI 静态托管:
+
+- 构建源:`acquisition/web/app`
+- 构建产物:`acquisition/web/app/dist`
+- 入口:`/app`
+- 路由:
+  - `#/`:Query Demo
+  - `#/query/<encoded-query>`:单 query 详情
+
+前端直接请求:
+
+- `/data/queries/creation_demo.json`
+- `/api/creation-search/summary`
+- `/api/creation-search/query?query=...`
+- `/api/filter-prompt`
+- `/api/judge-prompts`
+- `/data/media/...`
+
+## 正式开发迁移建议
+
+如果另起正式目录,不建议把当前 5.5GB `data/` 直接复制为默认输入输出。建议拆成四类可配置路径:
+
+1. `QUERY_SOURCE_PATH`
+   - 当前对应 `data/queries/creation_demo.json`。
+   - 正式版可以换成数据库表、对象存储 JSON、或正式 query 生成服务。
+
+2. `RESULT_DB_PATH`
+   - 当前固定在 `acquisition.store.DB_PATH = data/app.db`。
+   - 正式版应改成 env 可配置,例如 `CK_SQLITE_PATH` 或直接切 PG。
+
+3. `MEDIA_ROOT`
+   - 当前由 `CK_DATA_DIR` 控制,默认 `data`。
+   - 当前媒体实际写死在 `ROOT / data / media/...` 的代码也需要改成使用 `settings.data_dir`,否则迁目录会漏。
+
+4. `PROMPT_ROOT`
+   - 当前 `core/prompts.py` 固定读项目根 `prompts/`。
+   - 正式版建议显式配置 prompt 目录或把 prompt 版本入库。
+
+优先改造点:
+
+- `acquisition.store.DB_PATH` 改成可配置,不再硬绑定 `data/app.db`。
+- `acquisition.creation_search._process_xhs/_process_weixin` 的本地媒体目录改成 `settings.data_dir`。
+- `load_creation_queries` 默认路径改为参数/环境配置。
+- `core.prompts.PROMPTS_DIR` 改成可配置,或在启动时固定 prompt snapshot。
+- 把 crawler/OSS/model provider 封装成正式 adapter,避免 `.env` key 分散。
+- 将旧链路 `scripts/run_search.py`、旧表 `queries/search_results/post_class` 与当前 `creation_*` demo 数据模型分离归档。
+

+ 140 - 0
docs/oss-media-transfer.md

@@ -0,0 +1,140 @@
+# OSS 媒体转存实测
+
+日期:2026-06-29
+
+## 结论
+
+当前 `.env` 已配置 OSS 转存接口:
+
+```text
+CK_OSS_UPLOAD_URL=http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream
+CK_OSS_UPLOAD_TIMEOUT_SECONDS=60
+```
+
+接口请求体:
+
+```json
+{
+  "src_url": "远程媒体直链",
+  "src_type": "image 或 video"
+}
+```
+
+返回结构里 `oss_object.cdn_url` 是可用 CDN 地址。当前本地封装在 `acquisition/oss.py`:
+
+- `upload_stream(src_url, src_type="image"|"video")`:成功返回 CDN URL,失败抛错。
+- `to_oss(src_url, src_type)`:成功返回 CDN URL,失败返回原 URL,用于降级。
+
+实测结果:小红书图片、微信公众号图片、抖音视频都可以直接把原始媒体 URL 丢给该接口转存到 CDN。
+
+## 图文和文字的处理边界
+
+小红书和微信公众号的“图文”需要拆开处理:
+
+- 图片:每张图片都有远程 URL,可以逐张调用 `upload_stream(..., src_type="image")` 转成 CDN URL。
+- 文字:正文是 `body_text` 字段,不是媒体 URL;该 `upload_stream` 接口不支持直接上传纯文本。
+
+因此完整图文帖的存储结构应是:
+
+```json
+{
+  "title": "...",
+  "body_text": "...",
+  "images": ["https://res.cybertogether.net/crawler/image/xxx.webp"],
+  "source_url": "原帖 URL"
+}
+```
+
+如果以后希望正文也进 OSS,需要另做一层:把正文打成 JSON/HTML/Markdown 文件,再用支持文件/字节上传的接口;不能直接复用当前 `src_url + src_type=image|video` 的转存接口。
+
+## 实测记录
+
+### 接口样例视频
+
+源 URL:
+
+```text
+https://res-bj.cybertogether.net/crawler/test2.mp4
+```
+
+请求:
+
+```json
+{
+  "src_url": "https://res-bj.cybertogether.net/crawler/test2.mp4",
+  "src_type": "video"
+}
+```
+
+结果:成功。
+
+CDN URL:
+
+```text
+https://res.cybertogether.net/crawler/video/f7950e5639acb0cc393edce6edcb63e8.mp4
+```
+
+### 小红书
+
+测试 query:`人生感悟 选题`
+
+样本:
+
+- 标题:`分享|女性成长赛道的热门选题🔥小白直接抄`
+- content_id:`69bd387c000000001d01e68a`
+- 详情接口返回图片数:8
+- 正文字数:115
+- 转存类型:`image`
+
+结果:成功。
+
+CDN URL:
+
+```text
+https://res.cybertogether.net/crawler/image/8de0ca10016540ebf31eee6428691155.webp
+```
+
+### 微信公众号
+
+测试 query:`短视频脚本 怎么写`
+
+样本:
+
+- 标题:`短视频脚本写不好?三个习惯让你的稿子开口就抓人`
+- 详情接口返回图片数:6
+- 正文字数:2794
+- 转存类型:`image`
+
+结果:成功。
+
+CDN URL:
+
+```text
+https://res.cybertogether.net/crawler/image/d7f4306fba37fafd2fe191554bcf7b05.gif
+```
+
+### 抖音
+
+测试 query:`短视频脚本 怎么写`
+
+样本:
+
+- 标题:`不会写脚本才能干好新媒体运营#新媒体运营...`
+- content_id:`7624537985144543729`
+- 详情接口返回视频数:1
+- 转存类型:`video`
+
+结果:成功。
+
+CDN URL:
+
+```text
+https://res.cybertogether.net/crawler/video/2d4ec0766f8de5bef9b3271aa1c2c7f5.mp4
+```
+
+## 后续实现建议
+
+1. 搜索到帖子后,不再把媒体下载到本地 `data/search`。
+2. 对小红书/微信公众号图文:拉详情后遍历 `image_urls`,逐张转存 OSS,正文继续保存在结构化记录里。
+3. 对抖音:取详情里的首个 `video_url`,用 `src_type="video"` 转存 OSS;封面图如需要展示,也可以用 `src_type="image"` 转存。
+4. 数据库或 JSON 记录只保存 CDN URL、正文、标题、原帖 URL、平台、query、正交方法等轻量字段。

+ 28 - 13
prompts/classify_imgtext.txt

@@ -1,27 +1,42 @@
-你在判断一篇图文帖(小红书 / 微信公众号,含标题、正文、图片)是不是「创作知识」——能教别人"如何创作【自媒体 / 社媒内容】"的可迁移方法、原理、清单。请把图片也看完(知识常在图里)再判断。
+你在判断一篇图文帖(小红书 / 微信公众号,含标题、正文、图片)是不是【图文/视频内容创作】的可迁移知识。请把图片也看完(知识常在图里)再判断。
 
 <什么算创作知识>
-创作知识 = 教你"怎么让一条**面向大众的自媒体内容**(图文 / 短视频 / 脚本 / 封面 / 标题)被点开、被看完、被转化、并把账号做大"的**可迁移方法**。典型:
-- 选题 / 钩子开头 / 脚本与结构 / 标题公式 / 封面设计 / 叙事与呈现 / 账号定位 / 起号涨粉 / 数据复盘。
-- 必须是**方法 / 原理 / 公式 / 清单**(能套用、能迁移),不是一篇具体作品、不是一份素材本身。
+创作知识 = 能迁移、能复用、能教别人"怎么创作内容作品"的方法、原理、结构或清单。判断必须同时过两条轴,缺一不算:
+
+【轴A·产出物是不是内容】
+这条知识帮你创作出来的成品,必须是图文 / 视频 / 文章 / 脚本 / 剧本 / 小说 / 播客 / 课程 / 解说等可被阅读、观看、收听或使用的**内容作品**。题材不限,卡的是产出物。
+- 算:产出"关于某题材的内容"——游戏实况/解说视频、历史视频脚本、知识科普文章、电商带货视频 idea、小说结构。
+- 不算:产出"题材本身"——游戏玩法/关卡设计、产品开发、电商选品/运营、行业分析、做菜本身。
+
+【轴B·是创作不是制作】
+它教的是"怎么构思 / 选题 / 写 / 结构 / 呈现 / 判断"(创作决策),不是"用某 App/AI/软件把成品做出来"(制作/工具操作)。
+- 算:选题/构思方法、脚本结构、标题公式、封面设计判断、叙事与呈现、人物冲突、节奏设计、表达方式、账号定位、数据复盘。
+- 不算:打开某 App/小程序/AI → 输入/粘贴 → 生成/扩写 → 设参数 → 导出/保存;排版、秀米、剪辑、调色、导出等编辑器操作。
+- 特别强调:即使用 AI/App 生成的是文案、图片或视频,只要核心是"让工具替你产出成品",就算制作,不算创作;除非它真的在教如何拆解创作需求、建立可迁移的判断和方法。
+
+【两轴 AND】轴A、轴B 都过才算创作知识;任一不过就判非创作。
 </什么算创作知识>
 
 <一票否决·命中任一即判非创作(is_empty=true)>
 整篇主体只要落在下面任一类,就判**非创作**,别拔高、别勉强:
-1. 【应试 / 学术写作】申论、高考/中考作文、作文或申论范文、人物/作文**素材库**、答题模板、解题套路、考研/学术论文、公文 / 讲话稿 / 材料写作。
-   —— 目标是"拿分数 / 合规范",不是"做面向大众的自媒体内容、争夺注意力"。即便都在"写",也不算。
-2. 【制作 / 工具操作 = 制作知识,不是创作】用 AI / 某 App / 小程序 / 提示词 / 参数把内容生成或做出来(文生视频、AI 写文案/出图、提示词框架、参数设置),以及排版 / 秀米 / 剪辑 / 调色 / 导出等编辑器操作。
-   —— 这是"怎么把成品**做出来**(制作 / 执行)",不是"怎么**构思出**更好的内容(创作)"。
-3. 【学科知识 / 评论 / 作品本身】只是在讲某学科知识(历史 / 政治 / 外交 / 经济本身)、输出某个观点 / 感悟 / 人生道理、一篇时政评论或一份具体作品、一份纯素材 / 范文合集。
-   —— 这是"内容 / 作品本身",不是"教你怎么创作内容的方法"。
+1. 【题材本身 / 越界对象】教的是做那个东西本身,而不是做关于它的内容:游戏玩法/关卡设计、产品开发、电商选品/运营、行业分析、做菜本身等。
+   —— 同样讲游戏:做游戏 = 不算;做游戏视频/解说脚本 = 算。同样讲电商:做选品运营 = 不算;做电商带货视频 idea = 算。
+2. 【制作 / 工具操作】用 AI / App / 小程序 / 软件 / 提示词 / 参数把内容生成或做出来(文生视频、AI 写文案/出图、提示词框架、参数设置),以及排版 / 秀米 / 剪辑 / 调色 / 导出等编辑器操作。
+   —— 这是"怎么把成品做出来(制作 / 执行)",不是"怎么构思出更好的内容(创作)"。
+3. 【应试 / 学术写作】申论、高考/中考作文、作文或申论范文、人物/作文**素材库**、答题模板、解题套路、考研/学术论文、公文 / 讲话稿 / 材料写作。
+   —— 目标是"拿分数 / 合规范",不是"创作内容作品"。即便都在"写",也不算;除非它明确抽象出可迁移到内容创作的叙事、结构、表达方法。
+4. 【学科知识 / 评论 / 作品本身】只是在讲某学科知识(历史 / 政治 / 外交 / 经济本身)、输出某个观点 / 感悟 / 人生道理、一篇时政评论或一份具体作品、一份纯素材 / 范文合集。
+   —— 这是"内容 / 作品本身",不是"教你怎么创作内容的方法"。例如"一段历史事实"不算;"怎么把历史事实讲成一期视频/文章"算。
 </一票否决>
 
 <判定>
-- 口诀:教的是"怎么让面向大众的内容被点开 / 看完 / 转化 / 涨粉"的可迁移方法 → 算(is_empty=false);"应试达标 / 用工具把成品做出来 / 某学科观点或作品本身 / 纯素材范文" → 不算(is_empty=true)。
-- 范围内但拿不准有没有方法 → 算(false);但"是不是上面三类越界"要**果断**,明显越界就判非创作(true)。
+- 先复述"这帖到底在教什么",再按轴A/轴B判断:教的是"怎么创作内容作品、怎么组织表达、怎么让内容被理解/吸引/看完/记住/传播"的可迁移方法 → 算(is_empty=false)。
+- "做题材本身 / 应试达标 / 用工具把成品做出来 / 某学科知识或观点本身 / 纯素材范文 / 具体作品" → 不算(is_empty=true)。
+- 范围内但拿不准有没有方法 → 可以算(false),交后续再筛;但"在不在范围内"要果断,明显越界就判非创作(true)。
+- 拿不准、模棱两可、半创作半制作时,除非核心知识明确落在创作决策上,否则倾向判非创作(true)。
 - 若 is_empty=false,把帖子里教的**具体创作知识点**忠实提炼出来(分条、不编造、不拔高)。
 
 只输出一个 JSON 对象:
 {"is_empty": true/false,
- "reason": "一句话理由:是「教了哪种自媒体创作方法」,还是「属于应试/制作/学科/作品本身的哪一类」(≤30字)",
+ "reason": "一句话理由:是「教了哪种内容创作方法」,还是「属于应试/制作/学科/作品本身的哪一类」(≤30字)",
  "knowledge": "is_empty=false 时:帖子里教的具体创作知识点全文(分条);is_empty=true 时:空字符串"}

+ 28 - 11
prompts/classify_video.txt

@@ -1,25 +1,42 @@
-你在判断一条短视频是不是「创作知识」——能教别人"如何创作【自媒体 / 社媒内容】"的可迁移方法、原理、清单。请看完整段视频(听口播、看画面与字幕)再判断。
+你在判断一条短视频是不是【图文/视频内容创作】的可迁移知识。请看完整段视频(听口播、看画面与字幕)再判断。
 
 <什么算创作知识>
-创作知识 = 教你"怎么让一条**面向大众的自媒体内容**(短视频 / 图文 / 脚本 / 封面 / 标题)被点开、被看完、被转化、并把账号做大"的**可迁移方法**。典型:
-- 选题 / 钩子开头 / 脚本与结构 / 标题公式 / 封面设计 / 叙事与呈现 / 账号定位 / 起号涨粉 / 数据复盘。
-- 必须是**方法 / 原理 / 公式 / 清单**(能套用、能迁移),不是一条具体作品、不是一份素材本身。
+创作知识 = 能迁移、能复用、能教别人"怎么创作内容作品"的方法、原理、结构或清单。判断必须同时过两条轴,缺一不算:
+
+【轴A·产出物是不是内容】
+这条知识帮你创作出来的成品,必须是短视频 / 图文 / 文章 / 脚本 / 剧本 / 小说 / 播客 / 课程 / 解说等可被阅读、观看、收听或使用的**内容作品**。题材不限,卡的是产出物。
+- 算:产出"关于某题材的内容"——游戏实况/解说视频、历史视频脚本、知识科普视频、电商带货视频 idea、小说结构。
+- 不算:产出"题材本身"——游戏玩法/关卡设计、产品开发、电商选品/运营、行业分析、做菜本身。
+
+【轴B·是创作不是制作】
+它教的是"怎么构思 / 选题 / 写 / 结构 / 呈现 / 判断"(创作决策),不是"用某 App/AI/软件把成品做出来"(制作/工具操作)。
+- 算:选题/构思方法、脚本结构、标题公式、封面设计判断、叙事与呈现、人物冲突、节奏设计、表达方式、账号定位、数据复盘。
+- 不算:打开某 App/小程序/AI → 输入/粘贴 → 生成/扩写 → 设参数 → 导出/保存;剪辑、调色、导出、排版等编辑器操作。
+- 特别强调:即使用 AI/App 生成的是文案、图片或视频,只要核心是"让工具替你产出成品",就算制作,不算创作;除非它真的在教如何拆解创作需求、建立可迁移的判断和方法。
+
+【两轴 AND】轴A、轴B 都过才算创作知识;任一不过就判非创作。
 </什么算创作知识>
 
 <一票否决·命中任一即判非创作(is_empty=true)>
 整条视频主体只要落在下面任一类,就判**非创作**,别拔高、别勉强:
-1. 【一个人讲观点 / 道理 / 感悟 = 作品本身】对着镜头讲人生观 / 价值观 / 人生感悟 / 某个道理 / 某个话题(如"人生的意义""如何看待 XX""我最有帮助的改变")——这是在表达观点 / 内容本身,不是教别人怎么创作内容。**不要**把"一个人在表达观点"拔高成"教你做观点输出 / 口播内容"。
-2. 【应试 / 学术写作】申论、高考/中考作文、作文或申论范文、人物/作文素材、答题模板、解题套路、考研/学术论文、公文 / 讲话稿。目标是拿分数 / 合规范,不是做自媒体内容。
-3. 【制作 / 工具操作 = 制作知识,不是创作】用 AI / 某 App / 小程序 / 提示词 / 参数把内容生成或做出来(文生视频、AI 写文案/出图、提示词框架),以及剪辑 / 调色 / 导出 / 排版等操作。这是"怎么把成品做出来(制作)",不是"怎么构思出更好的内容(创作)"。
-4. 【学科知识 / 评论 / 作品本身】只是在讲某学科知识(历史 / 政治 / 外交本身)、一篇时政评论、纯叙事讲故事 / 抒情励志 / 个人经历分享、纯素材合集。是内容 / 作品本身,不是创作方法。
+1. 【题材本身 / 越界对象】教的是做那个东西本身,而不是做关于它的内容:游戏玩法/关卡设计、产品开发、电商选品/运营、行业分析、做菜本身等。
+   —— 同样讲游戏:做游戏 = 不算;做游戏视频/解说脚本 = 算。同样讲电商:做选品运营 = 不算;做电商带货视频 idea = 算。
+2. 【制作 / 工具操作】用 AI / App / 小程序 / 软件 / 提示词 / 参数把内容生成或做出来(文生视频、AI 写文案/出图、提示词框架、参数设置),以及剪辑 / 调色 / 导出 / 排版等编辑器操作。
+   —— 这是"怎么把成品做出来(制作 / 执行)",不是"怎么构思出更好的内容(创作)"。
+3. 【一个人讲观点 / 道理 / 感悟 = 作品本身】对着镜头讲人生观 / 价值观 / 人生感悟 / 某个道理 / 某个话题(如"人生的意义""如何看待 XX""我最有帮助的改变")。
+   —— 这是在表达观点 / 内容本身,不是教别人怎么创作内容。不要把"一个人在表达观点"拔高成"教你做观点输出 / 口播内容";但如果它明确在教观点类内容的选题、结构、表达、节奏或判断标准,则算创作知识。
+4. 【应试 / 学术写作】申论、高考/中考作文、作文或申论范文、人物/作文素材、答题模板、解题套路、考研/学术论文、公文 / 讲话稿。目标是拿分数 / 合规范,不是创作内容作品;除非它明确抽象出可迁移到内容创作的叙事、结构、表达方法。
+5. 【学科知识 / 评论 / 作品本身】只是在讲某学科知识(历史 / 政治 / 外交本身)、一篇时政评论、纯叙事讲故事 / 抒情励志 / 个人经历分享、纯素材合集。是内容 / 作品本身,不是创作方法。例如"一段历史事实"不算;"怎么把历史事实讲成一期视频/文章"算。
 </一票否决>
 
 <判定>
-- 口诀:教的是"怎么让面向大众的内容被点开 / 看完 / 转化 / 涨粉"的可迁移方法 → 算(is_empty=false);"讲观点/道理 / 应试达标 / 用工具把成品做出来 / 某学科观点或作品本身" → 不算(is_empty=true)。
-- 范围内但拿不准有没有方法 → 算(false);但"是不是上面四类越界(尤其是讲观点 / 讲道理)"要**果断**,明显越界就判非创作(true)。
+- 先复述"这条视频到底在教什么",再按轴A/轴B判断:教的是"怎么创作内容作品、怎么组织表达、怎么让内容被理解/吸引/看完/记住/传播"的可迁移方法 → 算(is_empty=false)。
+- "做题材本身 / 讲观点或道理本身 / 应试达标 / 用工具把成品做出来 / 某学科知识或作品本身 / 纯素材合集" → 不算(is_empty=true)。
+- 范围内但拿不准有没有方法 → 可以算(false),交后续再筛;但"在不在范围内"要果断,明显越界就判非创作(true)。
+- 拿不准、模棱两可、半创作半制作时,除非核心知识明确落在创作决策上,否则倾向判非创作(true)。
 - 若 is_empty=false,把视频里教的**具体创作知识点**忠实提炼出来(分条、不编造、不拔高)。
 
 只输出一个 JSON 对象:
 {"is_empty": true/false,
- "reason": "一句话理由:是「教了哪种自媒体创作方法」,还是「属于讲观点/应试/制作/学科/作品本身的哪一类」(≤30字)",
+ "reason": "一句话理由:是「教了哪种内容创作方法」,还是「属于讲观点/应试/制作/学科/作品本身的哪一类」(≤30字)",
  "knowledge": "is_empty=false 时:视频里教的具体创作知识点全文(分条);is_empty=true 时:空字符串"}

+ 92 - 66
scripts/build_creation_demo.py

@@ -1,19 +1,20 @@
-"""创作知识 query 正交 demo:5 套家族机械正交 → LLM 只做排除(query_filter.txt) → 存 JSON。
+"""创作知识 query 正交 demo:多种正交组合 → LLM 评 valid/relevant(query_filter.txt) → 存 JSON。
 
 不真实搜,只产 query 供前端看。轴严格取分类树的"创作支":
   实质 = 实质树·理念支(排除表象)   形式 = 形式树·架构支(排除呈现)
   目的池 = 作用树 + 感受树 + 意图树 全部合一(随机取)
-  阶段意图 = 灵感/选题/脚本 展开成创作者真会搜的词(选题/开头/钩子/标题/封面/文案…)——脊柱,每族必带
-  模态 = 视频/图   知识类型 = 怎么做/有哪些/为什么
-脊柱(每条都带):… 阶段意图 + 模态 + 知识类型;前面配 实质/形式/目的 之一或组合
-5 家族:① 实质×阶段 ② 形式×阶段 ③ 实质×形式×阶段 ④ 目的池×阶段 ⑤ 纯阶段,各 20 条
-每条原串过 query_filter.txt(keep/排除),存 keep+reason 供前端展示。
+  业务阶段 = 灵感/选题/脚本(只这三个裸阶段,不展开)——脊柱,每族必带
+  模态 = 视频/图   知识类型 = 怎么做/有哪些/为什么
+新尾缀:模态 × 业务阶段 × 知识类型;老尾缀对照:阶段 × 动作 × [作用] × 知识类型
+实质 / 形式 / 目的等轴从同一张 MASTER 主表投影,方便控制变量横向对比
+每条原串过 query_filter.txt(valid + relevant),存 keep+valid+reason 供前端展示。
 用法:PYTHONPATH=. CK_ENV_FILE=.env python scripts/build_creation_demo.py
 """
 from __future__ import annotations
 
 import json
 import random
+import sys
 from pathlib import Path
 
 from acquisition.query import ACTIONS, _nonleaf_d4   # 老正交动作轴 + 4级非叶子取作用节点
@@ -27,15 +28,9 @@ OUT = ROOT / "data" / "queries" / "creation_demo.json"
 PER = 30
 BATCH_N = 30        # 全 demo 统一抽这么多个「实质 / 形式」,各族共用同一批;PER=BATCH_N 保证整批都用上、左右一一对应
 KTYPE = ["怎么做", "有哪些", "为什么"]
-MODALITY = ["视频", "图片"]   # 被创作内容的形态(与教学帖本身格式无关),正交进所有家族
-# 创作阶段意图轴(脊柱):灵感/选题/脚本 展开成创作者真会搜的词(query构造.md)。真实数据里
-# "脚本"0次、但 开头/钩子/标题/封面/选题 各几十次——故用展开词,不用三个干阶段词。
-STAGE_INTENT = {
-    "灵感": ["找素材", "内容方向", "案例拆解", "灵感", "拆解", "复盘"],
-    "选题": ["选题", "爆款选题", "选题方向"],
-    "脚本": ["脚本", "文案", "开头", "钩子", "结构", "标题", "封面", "结尾"],
-}
-INTENT = [w for ws in STAGE_INTENT.values() for w in ws]   # 扁平成一个池,随机取
+MODALITY = ["视频", "图文"]   # 被创作内容的形态(与教学帖本身格式无关),正交进所有家族
+# 业务阶段(脊柱):只留 3 个裸阶段,不再展开成 query构造.md 的衍生词(找素材/开头/钩子/标题…全去掉)
+INTENT = ["灵感", "选题", "脚本"]
 
 
 def _segs(p):
@@ -84,86 +79,117 @@ def _nonleaf(idx, source_type, depths=(3, 4), under=None):
 def main():
     settings = Settings.from_env()
     rng = random.Random(7)
+    DRY = "--dry" in sys.argv          # 干跑:跳过 LLM 筛选(全 keep),只验证生成结构/控制变量
     idx = json.loads(TREES.read_text("utf-8"))
     SHI = _nonleaf(idx, "实质", depths=(3, 4), under="理念")   # 类目层,非元素
     XING = _nonleaf(idx, "形式", depths=(3, 4), under="架构")  # 类目层,非元素
     POOL = _leaves(idx, "作用") + _leaves(idx, "感受") + _leaves(idx, "意图")
     print(f"实质 {len(SHI)} / 形式 {len(XING)} / 目的池 {len(POOL)} / 业务阶段 {len(INTENT)}")
 
-    # 全 demo 统一「一批实质 / 一批形式」——各家族都取同一批、且顺序一致,方便切页签横向比较
+    # 统一批:30 实质 / 30 形式 / 30 目的(作用感受意图),全 demo 共用
     SHI_BATCH = rng.sample(SHI, min(BATCH_N, len(SHI)))
     XING_BATCH = rng.sample(XING, min(BATCH_N, len(XING)))
-    print(f"统一批: 实质×{len(SHI_BATCH)} 形式×{len(XING_BATCH)}")
-
-    def pick(seq):
-        return rng.choice(seq)
-
-    def shi(i):    # 按 query 序号轮转,保证每族都覆盖整批、首次出现顺序一致
-        return SHI_BATCH[i % len(SHI_BATCH)]
-
-    def xing(i):
-        return XING_BATCH[i % len(XING_BATCH)]
-
-    # 第六、第七家族(老正交方案):实质 / 形式 一律沿用上面的统一批(shi/xing,与 f1/f2 完全一致、同序,便于横向对比);
-    # 阶段=灵感/选题/脚本、动作=构思/策划/组织/撰写/改编/润色(+无动作变体)、作用取4级非叶子。query 形如「(实质) 叙事组织 脚本撰写 趣味互动 有哪些」
-    F6_ZY = _nonleaf_d4("作用", 10)
-    F6_STAGE_ACT = [(s, a) for s in OLD_STAGES for a in ACTIONS] + [("", "")]   # +「无动作」=老方案的 /
-
-    def _old_tail(i):
+    POOL_BATCH = rng.sample(POOL, min(BATCH_N, len(POOL)))
+    F6_ZY = _nonleaf_d4("作用", 10)                                       # 老正交的"作用"(4级非叶子)
+    F6_STAGE_ACT = [(s, a) for s in OLD_STAGES for a in ACTIONS] + [("", "")]   # 阶段×动作 + 无动作变体
+    print(f"统一批: 实质×{len(SHI_BATCH)} 形式×{len(XING_BATCH)} 目的×{len(POOL_BATCH)}")
+
+    # 主表:第 i 行把【所有轴】取值一次定死;各组合方式只是从这行挑自己用到的轴(投影),严格控制变量
+    # ——同一个实质(在第 i 行)无论出现在哪种组合里,配的模态/业务阶段/知识类型都相同。
+    MASTER = []
+    for i in range(PER):
         st, ac = F6_STAGE_ACT[i % len(F6_STAGE_ACT)]
-        zy_ = F6_ZY[i % len(F6_ZY)]
-        suf = KTYPE[i % len(KTYPE)]
-        seg = (st + ac) if ac else ""                       # 脚本撰写 / 空(动作=/ 时连阶段一起省)
-        return st, ac, zy_, suf, seg
-
-    def gen6(i):   # 形式×阶段×动作×作用×知识类型,形式=共享批(同 f2)
-        f_ = xing(i)
-        st, ac, zy_, suf, seg = _old_tail(i)
-        q = " ".join([f_] + ([seg] if seg else []) + [zy_, suf])
-        return {"parts": {"形式": f_, "阶段": st or "/", "动作": ac or "/", "作用": zy_, "知识类型": suf}, "query": q}
-
-    def gen7(i):   # 实质×形式×阶段×动作×作用×知识类型,实质=共享批(同 f1)、形式=共享批(同 f2)
-        s_, f_ = shi(i), xing(i)
-        st, ac, zy_, suf, seg = _old_tail(i)
-        q = " ".join([s_, f_] + ([seg] if seg else []) + [zy_, suf])
-        return {"parts": {"实质": s_, "形式": f_, "阶段": st or "/", "动作": ac or "/", "作用": zy_, "知识类型": suf}, "query": q}
-
-    # 每家族:生成器 + 用到的轴。家族名 = axes 用「×」连接,直接反映正交结构(见下方循环)。
-    # axes 顺序即前端列顺序;业务阶段=脊柱每族必带,模态+知识类型收尾
+        MASTER.append({
+            "实质": SHI_BATCH[i % len(SHI_BATCH)],
+            "形式": XING_BATCH[i % len(XING_BATCH)],
+            "目的": POOL_BATCH[i % len(POOL_BATCH)],
+            "模态": MODALITY[i % len(MODALITY)],
+            "业务阶段": INTENT[i % len(INTENT)],
+            "知识类型": KTYPE[(i // 3) % len(KTYPE)],     # 与业务阶段解耦,纯尾缀族也能多出几种
+            "阶段": st or "/", "动作": ac or "/", "_st": st, "_ac": ac,
+            "作用": F6_ZY[i % len(F6_ZY)],
+        })
+
+    def proj(i, keys):       # 新脊柱族:从主表第 i 行取这些 parts 键(query 由 order 拼)
+        row = MASTER[i]
+        return {"parts": {k: row[k] for k in keys}}
+
+    def gen_old(i, *, shi=False, xing=False, purpose=False, zy=True):    # 老正交族:[实质] [形式] [目的] (阶段+动作) [作用] 知识类型
+        r = MASTER[i]
+        seg = (r["_st"] + r["_ac"]) if r["_ac"] else ""               # 脚本撰写 / 空
+        head = ([r["实质"]] if shi else []) + ([r["形式"]] if xing else []) + ([r["目的"]] if purpose else [])
+        tail = ([r["作用"]] if zy else []) + [r["知识类型"]]
+        q = " ".join(head + ([seg] if seg else []) + tail)
+        parts = {}
+        if shi:
+            parts["实质"] = r["实质"]
+        if xing:
+            parts["形式"] = r["形式"]
+        if purpose:
+            parts["目的"] = r["目的"]
+        parts["阶段"], parts["动作"] = r["阶段"], r["动作"]
+        if zy:
+            parts["作用"] = r["作用"]
+        parts["知识类型"] = r["知识类型"]
+        return {"parts": parts, "query": q}
+
+    # 每种组合方式:生成器 + 用到的轴。name = axes 用「×」连接,直接反映正交结构。
+    # axes 顺序即前端列顺序;前 5 种使用新尾缀,后 6 种用于对照老尾缀。
     families = [
         {"key": "f1", "axes": ["实质", "模态", "业务阶段", "知识类型"],
-         "gen": lambda i: {"parts": {"实质": shi(i), "业务阶段": pick(INTENT), "模态": pick(MODALITY), "知识类型": pick(KTYPE)}}},
+         "gen": lambda i: proj(i, ["实质", "模态", "业务阶段", "知识类型"])},
         {"key": "f2", "axes": ["形式", "模态", "业务阶段", "知识类型"],
-         "gen": lambda i: {"parts": {"形式": xing(i), "业务阶段": pick(INTENT), "模态": pick(MODALITY), "知识类型": pick(KTYPE)}}},
+         "gen": lambda i: proj(i, ["形式", "模态", "业务阶段", "知识类型"])},
         {"key": "f4", "axes": ["作用/感受/意图", "模态", "业务阶段", "知识类型"],
-         "gen": lambda i: {"parts": {"目的": pick(POOL), "业务阶段": pick(INTENT), "模态": pick(MODALITY), "知识类型": pick(KTYPE)}}},
+         "gen": lambda i: proj(i, ["目的", "模态", "业务阶段", "知识类型"])},
         {"key": "f3", "axes": ["实质", "形式", "模态", "业务阶段", "知识类型"],
-         "gen": lambda i: {"parts": {"实质": shi(i), "形式": xing(i), "业务阶段": pick(INTENT), "模态": pick(MODALITY), "知识类型": pick(KTYPE)}}},
+         "gen": lambda i: proj(i, ["实质", "形式", "模态", "业务阶段", "知识类型"])},
         {"key": "f5", "axes": ["模态", "业务阶段", "知识类型"],
-         "gen": lambda i: {"parts": {"业务阶段": pick(INTENT), "模态": pick(MODALITY), "知识类型": pick(KTYPE)}}},
-        {"key": "f6", "axes": ["形式", "阶段", "动作", "作用", "知识类型"], "gen": gen6},
-        {"key": "f7", "axes": ["实质", "形式", "阶段", "动作", "作用", "知识类型"], "gen": gen7},
+         "gen": lambda i: proj(i, ["模态", "业务阶段", "知识类型"])},
+        # 老正交·尾缀A:阶段 × 动作 × 作用 × 知识类型(五种正交)
+        {"key": "a_shi", "axes": ["实质", "阶段", "动作", "作用", "知识类型"],
+         "gen": lambda i: gen_old(i, shi=True, zy=True)},
+        {"key": "a_xing", "axes": ["形式", "阶段", "动作", "作用", "知识类型"],
+         "gen": lambda i: gen_old(i, xing=True, zy=True)},
+        {"key": "a_both", "axes": ["实质", "形式", "阶段", "动作", "作用", "知识类型"],
+         "gen": lambda i: gen_old(i, shi=True, xing=True, zy=True)},
+        {"key": "a_purpose", "axes": ["作用/感受/意图", "阶段", "动作", "作用", "知识类型"],
+         "gen": lambda i: gen_old(i, purpose=True, zy=True)},
+        {"key": "a_tail", "axes": ["阶段", "动作", "作用", "知识类型"],
+         "gen": lambda i: gen_old(i, zy=True)},
+        # 老正交·尾缀B:阶段 × 动作 × 知识类型(抽掉作用,五种正交)
+        {"key": "b_shi", "axes": ["实质", "阶段", "动作", "知识类型"],
+         "gen": lambda i: gen_old(i, shi=True, zy=False)},
+        {"key": "b_xing", "axes": ["形式", "阶段", "动作", "知识类型"],
+         "gen": lambda i: gen_old(i, xing=True, zy=False)},
+        {"key": "b_both", "axes": ["实质", "形式", "阶段", "动作", "知识类型"],
+         "gen": lambda i: gen_old(i, shi=True, xing=True, zy=False)},
+        {"key": "b_purpose", "axes": ["作用/感受/意图", "阶段", "动作", "知识类型"],
+         "gen": lambda i: gen_old(i, purpose=True, zy=False)},
+        {"key": "b_tail", "axes": ["阶段", "动作", "知识类型"],
+         "gen": lambda i: gen_old(i, zy=False)},
     ]
     # 各部件按固定顺序拼成原串(内容维度在前,模态贴题材后,业务阶段+知识类型收尾)
     order = ["实质", "形式", "目的", "模态", "业务阶段", "知识类型"]
 
-    # 业务阶段值存「分组结构」(STAGE_INTENT),前端按 灵感/选题/脚本 分组+缩进展示;其余轴是扁平数组
-    out = {"axis_values": {"实质": SHI, "形式": XING, "目的池": POOL, "业务阶段": STAGE_INTENT,
+    # 业务阶段只剩 灵感/选题/脚本 三个,扁平数组(前端按池子平铺,不再分组缩进)
+    out = {"axis_values": {"实质": SHI, "形式": XING, "目的池": POOL, "业务阶段": INTENT,
                            "模态": MODALITY, "知识类型": KTYPE,
-                           "阶段": OLD_STAGES, "动作": ACTIONS, "作用": F6_ZY},   # 第六家族的老轴
+                           "阶段": OLD_STAGES, "动作": ACTIONS, "作用": F6_ZY},   # 老尾缀对照
            "families": []}
     for fam in families:
         name = " × ".join(fam["axes"])   # 家族名直接反映正交结构
         seen, items = set(), []
-        while len(items) < PER and len(seen) < PER * 40:
-            g = fam["gen"](len(items))                # 序号轮转实质/形式批
+        for i in range(PER):                          # 单趟过主表、去重(纯尾缀族会自然少于 PER)
+            g = fam["gen"](i)
             parts = g["parts"]
-            q = g.get("query") or " ".join(parts[k] for k in order if k in parts)   # f6 自带 query 串
+            q = g.get("query") or " ".join(parts[k] for k in order if k in parts)   # f6/f7 自带 query 串
             if q in seen:
                 continue
             seen.add(q)
             items.append({"query": q, "parts": parts})
-        verdicts = filter_queries([it["query"] for it in items], settings)
+        verdicts = ([{"keep": True, "valid": None, "relevant": True, "reason": "(dry:未过筛)"} for _ in items]
+                    if DRY else filter_queries([it["query"] for it in items], settings))
         for it, v in zip(items, verdicts):
             it.update(v)
         kept = sum(1 for it in items if it["keep"])

+ 135 - 0
scripts/classify_creation_items.py

@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+"""补跑 430 query 采集结果的创作知识判断。
+
+示例:
+  CK_ENV_FILE=.env PYTHONPATH=. .venv/bin/python scripts/classify_creation_items.py \
+    --run-id creation-search-full-20260629 --platform xiaohongshu,weixin --workers 4
+
+  CK_ENV_FILE=.env PYTHONPATH=. .venv/bin/python scripts/classify_creation_items.py \
+    --run-id creation-search-full-20260629 --platform xiaohongshu --provider qwen --model qwen3.7-plus --workers 4
+
+  CK_ENV_FILE=.env PYTHONPATH=. .venv/bin/python scripts/classify_creation_items.py \
+    --run-id creation-search-full-20260629 --platform weixin --provider ark --model ep-20260506151915-jqvw7 --workers 4
+"""
+from __future__ import annotations
+
+import argparse
+import concurrent.futures as cf
+import os
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+if str(ROOT) not in sys.path:
+    sys.path.insert(0, str(ROOT))
+
+from acquisition import store
+from acquisition.classify import classify_imgtext, classify_video
+from acquisition.creation_search import PLATFORMS, prompt_version
+from core.config import Settings
+
+
+def _parse_platforms(value: str | None) -> list[str] | None:
+    if not value:
+        return None
+    out = [p.strip() for p in value.split(",") if p.strip()]
+    bad = [p for p in out if p not in PLATFORMS]
+    if bad:
+        raise SystemExit(f"unsupported platform(s): {', '.join(bad)}")
+    return out
+
+
+def _classify_one(item: dict, settings: Settings) -> tuple[int, int | None, str, str, str, str]:
+    platform = item["platform"]
+    if platform == "douyin":
+        version = prompt_version("classify_video")
+        is_creation, reason, knowledge, _points = classify_video(
+            {
+                "platform": platform,
+                "title": item.get("title") or "",
+                "body_text": item.get("body_text") or "",
+                "video": item.get("video_url") or "",
+            },
+            settings,
+        )
+    else:
+        version = prompt_version("classify_imgtext")
+        is_creation, reason, knowledge, _points = classify_imgtext(
+            {
+                "platform": platform,
+                "title": item.get("title") or "",
+                "body_text": item.get("body_text") or "",
+                "images": item.get("image_urls") or [],
+            },
+            settings,
+        )
+    return item["id"], is_creation, reason, knowledge, version, reason if is_creation is None else ""
+
+
+def main() -> None:
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--run-id", help="默认使用最新 creation_search_run")
+    ap.add_argument("--platform", help="平台,多个用逗号分隔;默认全部")
+    ap.add_argument("--limit", type=int, default=0, help="最多补判多少条;0=不限制")
+    ap.add_argument("--workers", type=int, default=3)
+    ap.add_argument("--only-missing", action="store_true", help="只补完全未分类,不重试失败分类")
+    ap.add_argument("--provider", choices=["auto", "openrouter", "ark", "qwen", "dashscope"], help="本次补判使用的模型通道")
+    ap.add_argument("--model", help="本次补判使用的模型名/方舟接入点,如 qwen3.7-plus 或 ep-...")
+    args = ap.parse_args()
+
+    if args.provider:
+        os.environ["CLASSIFY_PROVIDER"] = args.provider
+    if args.model:
+        os.environ["CLASSIFY_MODEL"] = args.model
+
+    settings = Settings.from_env(os.getenv("CK_ENV_FILE", ".env"))
+    platforms = _parse_platforms(args.platform)
+    conn = store.connect()
+    rows = store.creation_items_to_classify(
+        conn,
+        run_id=args.run_id,
+        platforms=platforms,
+        retry_failed=not args.only_missing,
+        limit=args.limit if args.limit > 0 else None,
+    )
+    print(f"待补判 {len(rows)} 条 platforms={','.join(platforms or PLATFORMS)} workers={args.workers}")
+    if not rows:
+        conn.close()
+        return
+
+    done = fail = 0
+    with cf.ThreadPoolExecutor(max_workers=max(1, args.workers)) as ex:
+        futs = {ex.submit(_classify_one, item, settings): item for item in rows}
+        for fut in cf.as_completed(futs):
+            item = futs[fut]
+            try:
+                item_id, is_creation, reason, knowledge, version, error = fut.result()
+            except Exception as exc:
+                item_id = item["id"]
+                is_creation = None
+                reason = f"判定失败: {str(exc)[:80]}"
+                knowledge = ""
+                version = ""
+                error = reason
+            store.upsert_creation_classification(
+                conn,
+                item_id,
+                is_creation,
+                reason=reason,
+                knowledge=knowledge,
+                prompt_version=version,
+                error=error,
+                ts=int(time.time()),
+            )
+            done += 1
+            if is_creation is None:
+                fail += 1
+            if done % 20 == 0 or done == len(rows):
+                print(f"  {done}/{len(rows)} done failed={fail}", flush=True)
+    conn.close()
+    print(f"完成:{done} 条,失败 {fail} 条")
+
+
+if __name__ == "__main__":
+    main()

+ 162 - 0
scripts/run_creation_search.py

@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""真实运行 430 query 搜索/详情/媒体/分类。
+
+示例:
+  PYTHONPATH=. CK_ENV_FILE=.env python scripts/run_creation_search.py \
+    --query "公共安全 视频 灵感 怎么做" --search-limit 10 --display-limit 5
+"""
+from __future__ import annotations
+
+import argparse
+import concurrent.futures as cf
+import datetime as dt
+import os
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+if str(ROOT) not in sys.path:
+    sys.path.insert(0, str(ROOT))
+
+from acquisition import store
+from acquisition.creation_search import PLATFORMS, load_creation_queries, run_platform_query
+from core.config import Settings
+
+
+def _run_id() -> str:
+    return "creation-search-" + dt.datetime.now().strftime("%Y%m%d-%H%M%S")
+
+
+def _parse_platforms(value: str | None) -> list[str]:
+    if not value:
+        return list(PLATFORMS)
+    out = [p.strip() for p in value.split(",") if p.strip()]
+    bad = [p for p in out if p not in PLATFORMS]
+    if bad:
+        raise SystemExit(f"unsupported platform(s): {', '.join(bad)}")
+    return out
+
+
+def run_platform_workers(*, run_id: str, queries: list[str], platforms: list[str],
+                         settings: Settings, search_limit: int, display_limit: int,
+                         classify: bool, skip_done: bool,
+                         db_path=store.DB_PATH) -> int:
+    """Run long-lived platform workers. Return failed platform-job count."""
+
+    def _platform_worker(platform: str) -> tuple[str, int, int]:
+        c = store.connect(db_path)
+        ok = fail = 0
+        current_q = ""
+        try:
+            for i, q in enumerate(queries, start=1):
+                current_q = q
+                if skip_done and store.creation_job_is_done(
+                    c, run_id, q, platform, display_limit=display_limit
+                ):
+                    ok += 1
+                    print(f"[{platform}] {i}/{len(queries)} skip done {q}", flush=True)
+                    continue
+                print(f"[{platform}] {i}/{len(queries)} {q}", flush=True)
+                res = run_platform_query(
+                    c,
+                    run_id=run_id,
+                    query=q,
+                    platform=platform,
+                    settings=settings,
+                    search_limit=search_limit,
+                    display_limit=display_limit,
+                    classify=classify,
+                )
+                if res["status"] in ("done", "partial"):
+                    ok += 1
+                else:
+                    fail += 1
+                print(
+                    f"[{platform}] {res['status']} display={res['display_count']} "
+                    f"{res.get('error') or ''}",
+                    flush=True,
+                )
+        except Exception as exc:
+            fail += 1
+            msg = f"worker异常: {str(exc)[:160]}"
+            if current_q:
+                store.update_creation_job(
+                    c, run_id, current_q, platform, status="failed",
+                    error=msg, ts=int(time.time()),
+                )
+            print(f"[{platform}] {msg}", flush=True)
+        finally:
+            c.close()
+        return platform, ok, fail
+
+    failed = 0
+    with cf.ThreadPoolExecutor(max_workers=len(platforms)) as ex:
+        futs = [ex.submit(_platform_worker, p) for p in platforms]
+        for fut in cf.as_completed(futs):
+            platform, ok, fail = fut.result()
+            failed += fail
+            print(f"[{platform}] finished ok={ok} failed={fail}")
+    return failed
+
+
+def main() -> None:
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--run-id", default=_run_id())
+    ap.add_argument("--query", help="只跑这一条 query;不传则读取 creation_demo.json")
+    ap.add_argument("--limit-queries", type=int, default=0, help="从 query 文件取前 N 条;0=不截断")
+    ap.add_argument("--platform", help="只跑指定平台;多个用逗号分隔")
+    ap.add_argument("--search-limit", type=int, default=10)
+    ap.add_argument("--display-limit", type=int, default=5)
+    ap.add_argument("--query-file", default=str(Path("data/queries/creation_demo.json")))
+    ap.add_argument("--no-classify", action="store_true", help="只采集不调用 AI 判断")
+    ap.add_argument("--resume", action="store_true", help="保留同 run_id 已有结果,继续未完成 job")
+    ap.add_argument("--skip-done", action="store_true", help="跳过已 done 且 display_count>=display_limit 的 job")
+    args = ap.parse_args()
+
+    settings = Settings.from_env(os.getenv("CK_ENV_FILE", ".env"))
+    queries = [args.query.strip()] if args.query else load_creation_queries(args.query_file)
+    if args.limit_queries and args.limit_queries > 0:
+        queries = queries[:args.limit_queries]
+    platforms = _parse_platforms(args.platform)
+    if not queries:
+        raise SystemExit("no queries to run")
+
+    conn = store.connect()
+    if args.resume and store.creation_run_exists(conn, args.run_id):
+        print(f"resume run_id={args.run_id}")
+    else:
+        store.create_creation_run(
+            conn,
+            args.run_id,
+            total_queries=len(queries),
+            note=f"platforms={','.join(platforms)} search_limit={args.search_limit} display_limit={args.display_limit}",
+            ts=int(time.time()),
+        )
+    for q in queries:
+        for p in platforms:
+            store.ensure_creation_job(conn, args.run_id, q, p, ts=int(time.time()))
+    conn.close()
+
+    print(f"run_id={args.run_id} queries={len(queries)} platforms={','.join(platforms)}")
+    print("平台接口限速:同平台搜索/详情共享 10-12s 间隔;OSS/本地下载/AI 判断不占平台闸。")
+
+    failed = run_platform_workers(
+        run_id=args.run_id,
+        queries=queries,
+        platforms=platforms,
+        settings=settings,
+        search_limit=args.search_limit,
+        display_limit=args.display_limit,
+        classify=not args.no_classify,
+        skip_done=args.skip_done or args.resume,
+    )
+
+    c = store.connect()
+    store.finish_creation_run(c, args.run_id, status="finished" if failed == 0 else "partial", ts=int(time.time()))
+    c.close()
+    print(f"done run_id={args.run_id} failed_platform_jobs={failed}")
+
+
+if __name__ == "__main__":
+    main()

+ 314 - 0
tests/test_creation_search.py

@@ -0,0 +1,314 @@
+from __future__ import annotations
+
+import httpx
+
+import acquisition.classify as classify_module
+from acquisition import store
+from acquisition.classify import _providers, classify_video
+from acquisition.creation_search import Candidate, PlatformRateLimiter, run_platform_query
+from acquisition.crawler import RateLimiter
+from core.config import PgConfig, Settings
+from scripts import run_creation_search as run_cli
+
+
+def _settings() -> Settings:
+    return Settings(
+        pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
+        crawler_base_url="http://crawler.test",
+        crawler_key="",
+        crawler_timeout=30,
+        video_model="m",
+        gemini_api_key="",
+        openrouter_base_url="http://openrouter.test",
+        openrouter_api_key="k",
+        llm_model="m",
+        max_cards=12,
+        frames_dir="f",
+        douyin_ratio="540p",
+        data_dir="data",
+    )
+
+
+def test_creation_store_summary_and_detail(tmp_path):
+    c = store.connect(tmp_path / "app.db")
+    store.create_creation_run(c, "r1", total_queries=1, ts=1)
+    store.ensure_creation_job(c, "r1", "q", "douyin", ts=1)
+    item_id = store.upsert_creation_item(
+        c,
+        run_id="r1",
+        query="q",
+        platform="douyin",
+        rank=1,
+        source_id="dy1",
+        url="https://www.douyin.com/video/1",
+        title="标题",
+        cover_url="https://cover.test/a.jpg",
+        video_url="https://cdn.test/v.mp4",
+        is_displayable=True,
+        ts=2,
+    )
+    store.upsert_creation_classification(
+        c, item_id, 1, reason="教你做视频", knowledge="先选题再写脚本", prompt_version="abc", ts=3
+    )
+    xhs_creation = store.upsert_creation_item(
+        c, run_id="r1", query="q", platform="xiaohongshu", rank=1,
+        title="小红书创作帖", image_urls=["/data/x1.jpg"], is_displayable=True, ts=3,
+    )
+    xhs_other = store.upsert_creation_item(
+        c, run_id="r1", query="q", platform="xiaohongshu", rank=2,
+        title="小红书非创作帖", image_urls=["/data/x2.jpg"], is_displayable=True, ts=3,
+    )
+    xhs_hidden = store.upsert_creation_item(
+        c, run_id="r1", query="q", platform="xiaohongshu", rank=3,
+        title="非展示候选", image_urls=["/data/x3.jpg"], is_displayable=False, ts=3,
+    )
+    wx_creation = store.upsert_creation_item(
+        c, run_id="r1", query="q", platform="weixin", rank=1,
+        title="公众号创作帖", image_urls=["/data/w1.jpg"], is_displayable=True, ts=3,
+    )
+    store.upsert_creation_classification(c, xhs_creation, 1, reason="ok", knowledge="k", ts=3)
+    store.upsert_creation_classification(c, xhs_other, 0, reason="no", knowledge="", ts=3)
+    store.upsert_creation_classification(c, xhs_hidden, 1, reason="hidden", knowledge="hidden", ts=3)
+    store.upsert_creation_classification(c, wx_creation, 1, reason="ok", knowledge="k", ts=3)
+    store.update_creation_job(
+        c, "r1", "q", "douyin", status="done", attempts=1,
+        searched_count=10, display_count=1, ts=4
+    )
+
+    summary = store.creation_search_summary(c)
+    assert summary["run_id"] == "r1"
+    q_summary = summary["queries"]["q"]
+    assert q_summary["platforms"]["douyin"]["display_count"] == 1
+    assert q_summary["imgtext_creation_count"] == 2
+    assert q_summary["imgtext_classified_count"] == 3
+    assert q_summary["imgtext_total_count"] == 3
+    assert q_summary["imgtext_target_count"] == 10
+
+    detail = store.get_creation_query_detail(c, "q")
+    item = detail["platforms"]["douyin"]["items"][0]
+    assert item["video_url"] == "https://cdn.test/v.mp4"
+    assert item["classification"]["is_creation"] == 1
+    assert item["classification"]["knowledge"] == "先选题再写脚本"
+
+
+def test_platform_rate_limiter_shares_keyword_and_detail_bucket():
+    now = {"t": 0.0}
+    sleeps = []
+
+    def now_fn():
+        return now["t"]
+
+    def sleep_fn(seconds):
+        sleeps.append(seconds)
+        now["t"] += seconds
+
+    base = RateLimiter(
+        min_interval_seconds=10.0,
+        max_interval_seconds=10.0,
+        now_fn=now_fn,
+        sleep_fn=sleep_fn,
+    )
+    gate = PlatformRateLimiter("douyin", delegate=base)
+    gate.wait("douyin_keyword")
+    now["t"] += 1.0
+    gate.wait("douyin_detail")
+
+    assert sleeps == [9.0]
+
+
+def test_classify_video_accepts_cdn_url(monkeypatch):
+    seen = {}
+
+    def fake_judge(messages, settings, timeout):
+        seen["url"] = messages[1]["content"][1]["video_url"]["url"]
+        return 1, "ok", "knowledge", ""
+
+    monkeypatch.setattr("acquisition.classify._judge", fake_judge)
+    res = classify_video({"video": "https://cdn.test/video.mp4"}, _settings())
+
+    assert res == (1, "ok", "knowledge", "")
+    assert seen["url"] == "https://cdn.test/video.mp4"
+
+
+def test_qwen_provider_uses_dashscope_credentials(monkeypatch):
+    monkeypatch.setenv("CLASSIFY_PROVIDER", "qwen")
+    monkeypatch.setenv("CLASSIFY_MODEL", "qwen3.7-plus")
+    monkeypatch.setenv("QWEN_API_KEY", "sk-test")
+    monkeypatch.setenv("QWEN_BASE_URL", "https://dashscope.test/compatible-mode/v1")
+    monkeypatch.setenv("CONTENT_AGENT_VIDEO_LLM_MODEL", "google/gemini-3-flash-preview")
+
+    providers = _providers(_settings(), [{"role": "user", "content": "ping"}])
+
+    assert [p[0] for p in providers] == ["qwen:qwen3.7-plus", "qwen:qwen-vl-plus"]
+    assert providers[0][1] == "https://dashscope.test/compatible-mode/v1/chat/completions"
+    assert providers[0][3]["model"] == "qwen3.7-plus"
+
+
+def test_provider_limiter_and_429_backoff_are_provider_scoped(monkeypatch):
+    assert classify_module._provider_min_interval("qwen", {"CLASSIFY_QWEN_MIN_INTERVAL_SECONDS": "0.25"}) == 0.25
+    assert classify_module._provider_min_interval("ark", {"CLASSIFY_ARK_MIN_INTERVAL_SECONDS": "0.75"}) == 0.75
+
+    now = {"t": 0.0}
+    sleeps = []
+    calls = {"n": 0}
+    classify_module._PROVIDER_THROTTLES.clear()
+    monkeypatch.setenv("CLASSIFY_PROVIDER", "qwen")
+    monkeypatch.setenv("CLASSIFY_MODEL", "qwen3.7-plus")
+    monkeypatch.setenv("QWEN_API_KEY", "sk-test")
+    monkeypatch.setenv("QWEN_BASE_URL", "https://dashscope.test/compatible-mode/v1")
+    monkeypatch.setenv("CLASSIFY_QWEN_MIN_INTERVAL_SECONDS", "0")
+    monkeypatch.setenv("CLASSIFY_QWEN_429_BACKOFF_SECONDS", "9,30,90")
+    monkeypatch.setattr(classify_module.time, "monotonic", lambda: now["t"])
+
+    def fake_sleep(seconds):
+        sleeps.append(seconds)
+        now["t"] += seconds
+
+    def fake_post(*args, **kwargs):
+        calls["n"] += 1
+        if calls["n"] == 1:
+            return httpx.Response(429, request=httpx.Request("POST", "https://dashscope.test"))
+        return httpx.Response(
+            200,
+            json={"choices": [{"message": {"content": '{"is_empty": false, "reason": "ok", "knowledge": "k"}'}}]},
+            request=httpx.Request("POST", "https://dashscope.test"),
+        )
+
+    monkeypatch.setattr(classify_module.time, "sleep", fake_sleep)
+    monkeypatch.setattr(classify_module.httpx, "post", fake_post)
+
+    result = classify_module._judge([{"role": "user", "content": "ping"}], _settings(), timeout=1)
+
+    assert result == (1, "ok", "k", "")
+    assert calls["n"] == 2
+    assert sleeps == [2, 7.0]
+
+
+def test_run_platform_query_skips_bad_items_and_fills_display_limit(tmp_path, monkeypatch):
+    c = store.connect(tmp_path / "app.db")
+    store.create_creation_run(c, "r1", total_queries=1, ts=1)
+    store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
+
+    def fake_search(platform, query, *, settings, limit, rate_limiter):
+        assert platform == "weixin"
+        return [Candidate(rank=i, url=f"https://mp.test/{i}", title=f"t{i}") for i in range(1, 8)]
+
+    def fake_process(platform, candidate, *, query_hash, settings, rate_limiter, downloader):
+        if candidate.rank == 1:
+            raise RuntimeError("detail failed")
+        return {
+            "source_id": str(candidate.rank),
+            "url": candidate.url,
+            "title": candidate.title,
+            "body_text": "正文",
+            "cover_url": "/data/a.jpg",
+            "image_urls": ["/data/a.jpg"],
+            "video_url": "",
+            "raw": {},
+        }
+
+    monkeypatch.setattr("acquisition.creation_search.search_candidates", fake_search)
+    monkeypatch.setattr("acquisition.creation_search.process_candidate", fake_process)
+    monkeypatch.setattr("acquisition.creation_search._classify_and_store", lambda *a, **k: None)
+
+    res = run_platform_query(
+        c,
+        run_id="r1",
+        query="q",
+        platform="weixin",
+        settings=_settings(),
+        search_limit=7,
+        display_limit=5,
+        classify=True,
+        sleep_fn=lambda _: None,
+    )
+
+    assert res["status"] == "done"
+    assert res["display_count"] == 5
+    detail = store.get_creation_query_detail(c, "q")
+    assert len(detail["platforms"]["weixin"]["items"]) == 5
+
+
+def test_creation_job_done_and_classification_queue(tmp_path):
+    c = store.connect(tmp_path / "app.db")
+    store.create_creation_run(c, "r1", total_queries=1, ts=1)
+    store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
+    assert not store.creation_job_is_done(c, "r1", "q", "weixin", display_limit=5)
+    store.update_creation_job(c, "r1", "q", "weixin", status="done", display_count=5, ts=2)
+    assert store.creation_job_is_done(c, "r1", "q", "weixin", display_limit=5)
+
+    missing = store.upsert_creation_item(
+        c, run_id="r1", query="q", platform="weixin", rank=1,
+        title="missing", image_urls=["/data/a.jpg"], is_displayable=True, ts=3,
+    )
+    failed = store.upsert_creation_item(
+        c, run_id="r1", query="q", platform="weixin", rank=2,
+        title="failed", image_urls=["/data/b.jpg"], is_displayable=True, ts=3,
+    )
+    done = store.upsert_creation_item(
+        c, run_id="r1", query="q", platform="weixin", rank=3,
+        title="done", image_urls=["/data/c.jpg"], is_displayable=True, ts=3,
+    )
+    store.upsert_creation_classification(c, failed, None, reason="bad", error="bad", ts=4)
+    store.upsert_creation_classification(c, done, 1, reason="ok", knowledge="k", ts=4)
+
+    retry_rows = store.creation_items_to_classify(c, run_id="r1", platforms=["weixin"], retry_failed=True)
+    missing_rows = store.creation_items_to_classify(c, run_id="r1", platforms=["weixin"], retry_failed=False)
+    assert {r["id"] for r in retry_rows} == {missing, failed}
+    assert {r["id"] for r in missing_rows} == {missing}
+
+
+def test_run_workers_isolates_worker_exception(tmp_path, monkeypatch):
+    db = tmp_path / "app.db"
+    c = store.connect(db)
+    store.create_creation_run(c, "r1", total_queries=1, ts=1)
+    for p in ["xiaohongshu", "weixin"]:
+        store.ensure_creation_job(c, "r1", "q", p, ts=1)
+    c.close()
+
+    def fake_run(conn, *, platform, run_id, query, display_limit, **kwargs):
+        if platform == "xiaohongshu":
+            raise RuntimeError("boom")
+        store.update_creation_job(
+            conn, run_id, query, platform, status="done",
+            searched_count=10, display_count=display_limit, ts=2,
+        )
+        return {"platform": platform, "status": "done", "display_count": display_limit, "error": ""}
+
+    monkeypatch.setattr(run_cli, "run_platform_query", fake_run)
+    failed = run_cli.run_platform_workers(
+        run_id="r1", queries=["q"], platforms=["xiaohongshu", "weixin"],
+        settings=_settings(), search_limit=10, display_limit=5,
+        classify=False, skip_done=False, db_path=db,
+    )
+
+    c = store.connect(db)
+    summary = store.creation_search_summary(c, run_id="r1")["queries"]["q"]["platforms"]
+    assert failed == 1
+    assert summary["xiaohongshu"]["status"] == "failed"
+    assert "worker异常" in summary["xiaohongshu"]["error"]
+    assert summary["weixin"]["status"] == "done"
+
+
+def test_run_workers_skip_done_does_not_rerun(tmp_path, monkeypatch):
+    db = tmp_path / "app.db"
+    c = store.connect(db)
+    store.create_creation_run(c, "r1", total_queries=1, ts=1)
+    store.ensure_creation_job(c, "r1", "q", "weixin", ts=1)
+    store.update_creation_job(c, "r1", "q", "weixin", status="done", display_count=5, ts=2)
+    c.close()
+
+    calls = []
+
+    def fake_run(*args, **kwargs):
+        calls.append(kwargs)
+        return {"platform": "weixin", "status": "done", "display_count": 5, "error": ""}
+
+    monkeypatch.setattr(run_cli, "run_platform_query", fake_run)
+    failed = run_cli.run_platform_workers(
+        run_id="r1", queries=["q"], platforms=["weixin"],
+        settings=_settings(), search_limit=10, display_limit=5,
+        classify=False, skip_done=True, db_path=db,
+    )
+    assert failed == 0
+    assert calls == []

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.