Explorar o código

创作知识:多平台取数 + 媒体本地落盘 + 框架视图前端;拆解框架与入库格式定稿

- 多平台取数:crawler 支持小红书/抖音/快手/B站(B站留桩),detect_platform_and_id 分发
- 媒体本地落盘 data/<run>/<platform>/<post>/;前端段卡内联播放、图片走本地
- pipeline:小红书视频帖 skip(无直链)、幂等先清后写(clear_items)
- Web:新增完整创作框架视图(frameworks.json),旧 pipeline 视图存为 index_pipeline.html
- 新增《创作知识拆解框架.md》:创作vs制作、完整框架粒度、Ingest Payload 格式(How/What/Why)、维度归属(业务阶段/创作阶段/作用域/动作)
- 数据接口与来源:同步 CFA 更新 + 新增 embedding接口.md(火山 Doubao-embedding-vision 实测)
- 配套 scripts 与 tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SamLee hai 1 mes
pai
achega
cff663acbc

+ 4 - 0
.gitignore

@@ -17,6 +17,10 @@ dist/
 .next/
 .vite/
 
+# 媒体落盘 / 运行产物 —— 体积大,本地保留不入库
+data/
+runtime/
+
 # 杂项
 .DS_Store
 *.log

+ 6 - 2
creation_knowledge/api.py

@@ -84,12 +84,16 @@ def get_prompts() -> dict:
     }
 
 
-# 单页前端挂在最后(catch-all),不影响上面的 /api 路由
 # 视频帧静态服务(card.url = /frames/<post_id>/<file>)
 _frames_dir = Path(_settings().frames_dir)
 _frames_dir.mkdir(parents=True, exist_ok=True)
 app.mount("/frames", StaticFiles(directory=str(_frames_dir)), name="frames")
 
-# 单页前端挂在最后(catch-all),不影响上面的 /api、/frames
+# 媒体静态服务(card.url = /data/<run_id>/<platform>/<post_id>/<file>)
+_data_dir = Path(_settings().data_dir or "data")
+_data_dir.mkdir(parents=True, exist_ok=True)
+app.mount("/data", StaticFiles(directory=str(_data_dir)), name="data")
+
+# 单页前端挂在最后(catch-all),不影响上面的 /api、/frames、/data
 if WEB_DIR.exists():
     app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")

+ 7 - 2
creation_knowledge/cli.py

@@ -15,6 +15,8 @@ def build_parser() -> argparse.ArgumentParser:
     run.add_argument("--env-file", default=".env", help="环境变量文件路径")
     run.add_argument("--ingest", action="store_true",
                      help="开启真实入库(默认关闭,只组装+存库)")
+    run.add_argument("--run-id", default=None,
+                     help="运行批次 id(媒体落盘目录 data/<run_id>/...,默认时间戳)")
     return parser
 
 
@@ -25,13 +27,16 @@ def main() -> int:
             args.urls,
             env_file=args.env_file,
             ingest_enabled=True if args.ingest else False,
+            run_id=args.run_id,
         )
         for r in results:
             print(json.dumps(r, ensure_ascii=False))
         ok = sum(1 for r in results if r["status"] == "done")
         rej = sum(1 for r in results if r["status"] == "rejected")
-        fail = len(results) - ok - rej
-        print(f"\n汇总:done={ok} rejected={rej} failed={fail} / 共 {len(results)}")
+        skip = sum(1 for r in results if r["status"] == "skipped")
+        fail = len(results) - ok - rej - skip
+        print(f"\n汇总:done={ok} rejected={rej} skipped={skip} failed={fail} "
+              f"/ 共 {len(results)}")
     return 0
 
 

+ 2 - 0
creation_knowledge/config.py

@@ -90,6 +90,7 @@ class Settings:
     max_cards: int
     frames_dir: str
     douyin_ratio: str  # 视频下载偏好码率(控成本/体积),如 540p
+    data_dir: str  # 媒体本地落盘根目录;空字符串=关闭媒体保存(解耦开关)
 
     @classmethod
     def from_env(cls, env_file: str | Path = ".env") -> "Settings":
@@ -127,4 +128,5 @@ class Settings:
             max_cards=int(env_value("CK_MAX_CARDS", file_env, "12")),
             frames_dir=env_value("CK_FRAMES_DIR", file_env, "runtime/frames"),
             douyin_ratio=env_value("CK_DOUYIN_RATIO", file_env, "540p"),
+            data_dir=env_value("CK_DATA_DIR", file_env, "data"),
         )

+ 55 - 18
creation_knowledge/integrations/crawler.py

@@ -1,13 +1,14 @@
-"""帖子详情拉取:小红书 detail 接口
+"""帖子详情拉取:多平台 detail 接口(小红书/抖音/快手/B站)
 
-POST {base}/crawler/xiao_hong_shu/detail  body={"content_id": ...}  无 token,限流 ≥15s。
-逻辑(限流 + code==0 信封校验)对齐 ContentFindAgentNew 的 crawapi_http.post_crawapi_json,
-但不依赖其错误体系,保持本项目自包含
+crawler.aiddit.com 各平台 detail 返回字段已归一化(channel_content_id/title/content_type/
+body_text/image_url_list/video_url_list/channel_account_*),所以 parse 一套复用;差异只在
+detail path、URL→content_id 解析、平台名/id 前缀。详见 数据接口与来源/视频音频取数实测.md
 
 拆成两层:parse_detail_response 纯函数(可离线用 fixture 测)+ fetch_post_detail 负责 HTTP。
 """
 from __future__ import annotations
 
+import re
 import time
 from typing import Any, Callable, Optional
 from urllib.parse import urljoin
@@ -17,9 +18,44 @@ import httpx
 from creation_knowledge.config import Settings
 from creation_knowledge.models import Card, Post
 
-DETAIL_PATH = "/crawler/xiao_hong_shu/detail"
 RATE_LIMIT_SECONDS = 15.0
 
+# 平台路由:detail path + Post.id 前缀
+PLATFORMS = {
+    "xiaohongshu": {"path": "/crawler/xiao_hong_shu/detail", "prefix": "xhs"},
+    "douyin": {"path": "/crawler/dou_yin/detail", "prefix": "dy"},
+    "kuaishou": {"path": "/crawler/kuai_shou/detail", "prefix": "ks"},
+    "bilibili": {"path": "/crawler/bilibili/detail", "prefix": "bili"},
+}
+
+
+def _last_seg(s: str) -> str:
+    return s.split("?", 1)[0].rstrip("/").split("/")[-1]
+
+
+def detect_platform_and_id(content_id_or_url: str) -> tuple[str, str]:
+    """从 URL(或裸 id)识别平台 + content_id。不支持短链(v.douyin.com 等需先跟随重定向)。"""
+    s = content_id_or_url.strip()
+    low = s.lower()
+    if "xiaohongshu.com" in low or "/explore/" in low or "/discovery/item/" in low:
+        return "xiaohongshu", parse_content_id(s)
+    if "douyin.com" in low:
+        m = (re.search(r"modal_id=(\d+)", s) or re.search(r"/video/(\d+)", s)
+             or re.search(r"/note/(\d+)", s) or re.search(r"(\d{15,})", s))
+        return "douyin", m.group(1) if m else _last_seg(s)
+    if "kuaishou.com" in low or "gifshow.com" in low:
+        m = re.search(r"/(?:fw/photo|short-video|photo|f)/([A-Za-z0-9_-]+)", s)
+        return "kuaishou", m.group(1) if m else _last_seg(s)
+    if "bilibili.com" in low or "b23.tv" in low:
+        m = re.search(r"(BV[0-9A-Za-z]+)", s)
+        return "bilibili", m.group(1) if m else _last_seg(s)
+    # 裸 id 兜底:BV→B站;纯长数字→抖音;其余→小红书(向后兼容)
+    if s.startswith("BV"):
+        return "bilibili", s
+    if re.fullmatch(r"\d{15,}", s):
+        return "douyin", s
+    return "xiaohongshu", parse_content_id(s)
+
 
 class CrawlerError(RuntimeError):
     pass
@@ -78,8 +114,9 @@ def _video_urls(inner: dict) -> list[str]:
     return out
 
 
-def parse_detail_response(response: dict, fallback_content_id: str = "") -> Post:
-    """把 detail 接口返回(信封 {code,msg,data:{data:{...}}})解析成 Post。"""
+def parse_detail_response(response: dict, *, platform: str = "xiaohongshu",
+                          fallback_content_id: str = "") -> Post:
+    """把 detail 接口返回(信封 {code,msg,data:{data:{...}}})解析成 Post。字段各平台归一。"""
     if not isinstance(response, dict):
         raise CrawlerError("bad_response: not a dict")
     code = response.get("code")
@@ -89,16 +126,15 @@ def parse_detail_response(response: dict, fallback_content_id: str = "") -> Post
     if not inner:
         raise CrawlerError("empty_detail: data.data is empty")
 
+    prefix = PLATFORMS.get(platform, PLATFORMS["xiaohongshu"])["prefix"]
     content_id = inner.get("channel_content_id") or fallback_content_id
-    link = inner.get("content_link") or (
-        f"https://www.xiaohongshu.com/explore/{content_id}" if content_id else ""
-    )
+    link = inner.get("content_link") or ""
     images = _image_urls(inner)
-    # 图文帖:每张图就是一张卡片(1-based;视频帧卡片在 pipeline 抽帧后补入)
+    # 图文帖:每张图一张卡片(1-based);视频帖的段卡由 extract_video 在提取时写入,覆盖封面卡
     cards = [Card(index=i, kind="image", url=u) for i, u in enumerate(images, start=1)]
     return Post(
-        id=f"xhs_{content_id}",
-        platform="xiaohongshu",
+        id=f"{prefix}_{content_id}",
+        platform=platform,
         url=link,
         content_id=content_id,
         title=inner.get("title") or "",
@@ -122,19 +158,20 @@ def fetch_post_detail(
     rate_limiter: Optional[RateLimiter] = None,
     env_file: str = ".env",
 ) -> Post:
-    """真实拉取一条小红书帖子详情,返回 Post。"""
+    """真实拉取一条帖子详情,自动识别平台(小红书/抖音/快手/B站),返回 Post。"""
     settings = settings or Settings.from_env(env_file)
-    content_id = parse_content_id(content_id_or_url)
+    platform, content_id = detect_platform_and_id(content_id_or_url)
     if not content_id:
         raise CrawlerError(f"cannot parse content_id from: {content_id_or_url}")
+    cfg = PLATFORMS[platform]
 
     rate_limiter = rate_limiter or RateLimiter()
-    rate_limiter.wait("xhs_detail")
+    rate_limiter.wait(f"{platform}_detail")
 
     owns_client = http_client is None
     client = http_client or httpx.Client()
     try:
-        url = urljoin(settings.crawler_base_url, DETAIL_PATH)
+        url = urljoin(settings.crawler_base_url, cfg["path"])
         resp = client.post(
             url,
             json={"content_id": content_id},
@@ -151,4 +188,4 @@ def fetch_post_detail(
         if owns_client:
             client.close()
 
-    return parse_detail_response(data, fallback_content_id=content_id)
+    return parse_detail_response(data, platform=platform, fallback_content_id=content_id)

+ 21 - 4
creation_knowledge/integrations/db.py

@@ -123,6 +123,15 @@ class CkStore:
                 )
             conn.commit()
 
+    def clear_items(self, post_id: str) -> int:
+        """删除某帖的全部知识片段,返回删除行数。用于重跑幂等(先清后写)。"""
+        with self._connection_factory() as conn:
+            with conn.cursor() as cur:
+                cur.execute("DELETE FROM ck_knowledge_item WHERE post_id=%s", (post_id,))
+                n = cur.rowcount
+            conn.commit()
+            return n
+
     def delete_post(self, post_id: str) -> None:
         """删除帖子及其片段(用于校验探测的清理)。"""
         with self._connection_factory() as conn:
@@ -150,12 +159,20 @@ class CkStore:
         )
 
     def posts_overview(self, limit: int = 100) -> list[dict]:
-        """列表页用:每帖基础字段 + 知识片段数。"""
+        """列表页用:每帖基础字段 + 标题/类型/批次(run) + 知识片段数。
+
+        title/content_type 取自 raw 信封;run 从首张卡片本地路径 /data/<run>/... 解析,
+        无媒体则回退处理日期。GROUP BY 主键即可(PG12 函数依赖)。
+        """
         return self._fetch_all(
-            "SELECT p.id, p.platform, p.url, p.stage, p.created_at, p.updated_at, "
-            "count(k.id) AS item_count "
+            "SELECT p.id, p.platform, p.url, p.stage, "
+            "p.raw->'data'->'data'->>'title' AS title, "
+            "p.raw->'data'->'data'->>'content_type' AS content_type, "
+            "COALESCE(NULLIF(split_part(p.cards->0->>'url','/',3),''), "
+            "         to_char(p.created_at,'YYYY-MM-DD')) AS run, "
+            "p.created_at, p.updated_at, count(k.id) AS item_count "
             "FROM ck_post p LEFT JOIN ck_knowledge_item k ON k.post_id = p.id "
-            "GROUP BY p.id, p.platform, p.url, p.stage, p.created_at, p.updated_at "
+            "GROUP BY p.id "
             "ORDER BY p.created_at DESC LIMIT %s",
             (limit,),
         )

+ 23 - 1
creation_knowledge/integrations/video_extract.py

@@ -13,6 +13,7 @@ import base64
 import logging
 import os
 import re
+from pathlib import Path
 from typing import Any, Callable, Optional
 
 import httpx
@@ -29,6 +30,7 @@ _REFERER = {
     "kuaishou": "https://www.kuaishou.com/",
     "bilibili": "https://www.bilibili.com/",
     "shipinhao": "https://channels.weixin.qq.com/",
+    "xiaohongshu": "https://www.xiaohongshu.com/",
 }
 _IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15"
 
@@ -79,12 +81,25 @@ def extract_video(
     video_path: Optional[str] = None,
     downloader: Optional[Callable[[str, str], bytes]] = None,
     timeout: float = 600.0,
+    save_path: Optional[Path] = None,
+    public_url: Optional[str] = None,
 ) -> ExtractedContent:
-    """对视频帖做原生整段提炼,就地写好 post.cards(段卡),返回 ExtractedContent。"""
+    """对视频帖做原生整段提炼,就地写好 post.cards(段卡),返回 ExtractedContent。
+
+    save_path/public_url 给定时:把下载到的整段 mp4 写到 save_path(单次下载,复用
+    内存字节),并把每张段卡 url 设为 public_url(整段共用,前端按 start/end 加 #t 播片段)。
+    两者默认 None=不落盘、行为不变。
+    """
     key = settings.openrouter_api_key
     if not key:
         raise VideoExtractError("missing OPENROUTER_API_KEY")
 
+    # B站留接口位:DASH 分轨(video m4s + voice_data 音轨 m4s)需 curl 不跟随重定向下载
+    # 两轨 + ffmpeg 合并,单 URL 下载器拿不到音频。待接入;可先传入预合并的本地 video_path。
+    if post.platform == "bilibili" and not (video_path and os.path.exists(video_path)):
+        raise VideoExtractError(
+            "B站视频暂未接入:需取 voice_data 音轨 + ffmpeg 合并(见 数据接口与来源/接口台账/B站.md)")
+
     # 1) 拿到 mp4 字节
     if video_path and os.path.exists(video_path):
         data = open(video_path, "rb").read()
@@ -103,6 +118,12 @@ def extract_video(
     if not data:
         raise VideoExtractError("视频字节为空")
 
+    # 1.5) 可选落盘:复用内存字节,单次下载即保存(避免二次下载/抖音链接过期)
+    if save_path is not None:
+        save_path.parent.mkdir(parents=True, exist_ok=True)
+        Path(save_path).write_bytes(data)
+        logger.info("video_extract 保存整段视频 %s (%d bytes)", save_path, len(data))
+
     # 2) base64 + 3) OpenRouter 原生视频
     data_url = "data:video/mp4;base64," + base64.b64encode(data).decode()
     prompt = load_prompt("extract_video").format()
@@ -132,6 +153,7 @@ def extract_video(
         if not isinstance(seg, dict):
             continue
         cards.append(Card(index=i, kind="segment",
+                          url=public_url,  # 整段视频公网路径;前端按 start/end 加 #t 播片段
                           start=_mmss_to_sec(seg.get("start")),
                           end=_mmss_to_sec(seg.get("end"))))
         card_extracts.append(CardExtract(index=i, content=_seg_content(seg)))

+ 74 - 0
creation_knowledge/media.py

@@ -0,0 +1,74 @@
+"""媒体本地落盘:把图文帖的图片、视频帖的整段 mp4 存到 data/<run_id>/<platform>/<post_id>/。
+
+解耦设计:只在 pipeline 默认 dispatch 里、且 settings.data_dir 非空时调用;视频落盘在
+extract_video 内(复用其内存字节),这里只管图片下载 + 卡片 url 改写为本地 /data 路径。
+"""
+from __future__ import annotations
+
+import datetime
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable
+
+from creation_knowledge.integrations.video_extract import _default_download
+from creation_knowledge.models import Post
+
+logger = logging.getLogger(__name__)
+
+Downloader = Callable[[str, str], bytes]
+
+
+def run_id() -> str:
+    """运行批次 id,默认时间戳到秒。"""
+    return datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+
+
+@dataclass
+class MediaTarget:
+    dir: Path           # 本地目录 data/<run_id>/<platform>/<post_id>/
+    public_base: str    # 浏览器前缀 /data/<run_id>/<platform>/<post_id>
+
+
+def media_target(data_dir: str, batch: str, platform: str, post_id: str) -> MediaTarget:
+    rel = f"{batch}/{platform}/{post_id}"
+    return MediaTarget(dir=Path(data_dir) / rel, public_base=f"/data/{rel}")
+
+
+def _ext_from_bytes(b: bytes) -> str:
+    """按文件头嗅探扩展名(不依赖 Content-Type,便于注入式测试)。"""
+    if b[:4] == b"\x89PNG":
+        return ".png"
+    if b[:3] == b"GIF":
+        return ".gif"
+    if b[:4] == b"RIFF" and b[8:12] == b"WEBP":
+        return ".webp"
+    return ".jpg"
+
+
+def save_images(
+    post: Post,
+    media_dir: Path,
+    public_base: str,
+    *,
+    downloader: Downloader = _default_download,
+) -> None:
+    """下载图文帖每张远程图片 → media_dir/image_<index>.<ext>,并把 card.url 改写为本地路径。
+
+    单张失败只告警跳过(不影响其余卡片与整条流水线)。
+    """
+    media_dir = Path(media_dir)
+    for card in post.cards:
+        if card.kind != "image" or not card.url or not card.url.startswith("http"):
+            continue
+        try:
+            data = downloader(card.url, post.platform)
+        except Exception as exc:  # 单张图失败不致命
+            logger.warning("图片下载失败 idx=%s %s: %s", card.index, card.url, exc)
+            continue
+        if not data:
+            continue
+        media_dir.mkdir(parents=True, exist_ok=True)
+        fname = f"image_{card.index}{_ext_from_bytes(data)}"
+        (media_dir / fname).write_bytes(data)
+        card.url = f"{public_base}/{fname}"

+ 36 - 2
creation_knowledge/pipeline.py

@@ -7,6 +7,7 @@ from __future__ import annotations
 import logging
 from typing import Callable, Optional
 
+from creation_knowledge import media
 from creation_knowledge.config import Settings
 from creation_knowledge.integrations.crawler import CrawlerError, fetch_post_detail
 from creation_knowledge.integrations.db import CkStore
@@ -28,6 +29,19 @@ FetchFn = Callable[[str], Post]
 ExtractFn = Callable[[Post], ExtractedContent]
 
 
+def _skip_reason(post: Post) -> Optional[str]:
+    """拿不到可提炼素材的帖子 → 跳过(不报错、不留脏数据)。在提取前判定。
+
+    - video_no_direct_url:声明 video 但无视频直链(小红书视频帖结构性如此,详情只给封面)。
+    - empty_media:既无图也无视频(接口降级 / 空帖)。
+    """
+    if (post.content_type or "").lower() == "video" and not post.video_urls:
+        return "video_no_direct_url"
+    if not post.image_urls and not post.video_urls:
+        return "empty_media"
+    return None
+
+
 def _process_one(
     url: str,
     *,
@@ -45,6 +59,13 @@ def _process_one(
         return {"url": url, "status": "fetch_failed", "error": str(exc)}
     store.upsert_post(post)  # 图文卡片在此落;视频段卡在提取后补落
 
+    # 1.5) 跳过判定:素材够不着的帖子直接 skip(如小红书视频帖)
+    skip = _skip_reason(post)
+    if skip:
+        store.clear_items(post.id)  # 幂等:本帖若是重跑,清掉上轮派生的片段
+        store.update_stage(post.id, "skipped")
+        return {"url": url, "post_id": post.id, "status": "skipped", "reason": skip}
+
     # 2) 多模态提取(图文=逐图;视频=原生整段视频→段卡,extract 内写 post.cards)
     try:
         content = extract_fn(post)
@@ -54,6 +75,7 @@ def _process_one(
     if post.cards:
         store.upsert_post(post)  # 视频段卡落库(图文重复 upsert 无害)
     store.set_extracted(post.id, content.model_dump())  # stage=extracted
+    store.clear_items(post.id)  # 幂等:重跑先清旧片段,再写本轮(rejected 路径也保持干净)
 
     # 3) 筛选
     screening = screen_post(post, content, chat=chat)
@@ -89,6 +111,7 @@ def run_pipeline(
     settings: Optional[Settings] = None,
     env_file: str = ".env",
     ingest_enabled: Optional[bool] = None,
+    run_id: Optional[str] = None,
     store: Optional[CkStore] = None,
     fetch_fn: Optional[FetchFn] = None,
     extract_fn: Optional[ExtractFn] = None,
@@ -96,16 +119,27 @@ def run_pipeline(
 ) -> list[dict]:
     settings = settings or Settings.from_env(env_file)
     ingest_enabled = settings.ingest_enabled if ingest_enabled is None else ingest_enabled
+    batch = run_id or media.run_id()
     store = store or CkStore(settings.pg)
     fetch_fn = fetch_fn or (lambda url: fetch_post_detail(url, settings=settings))
     if extract_fn is None:
         _image_client = GeminiExtractor.from_env(env_file=env_file)
 
         def _dispatch_extract(post: Post) -> ExtractedContent:
+            # settings.data_dir 非空 → 媒体落盘到 data/<batch>/<platform>/<post_id>/(解耦开关)
+            tgt = (media.media_target(settings.data_dir, batch, post.platform, post.id)
+                   if settings.data_dir else None)
             # 视频帖 → 原生整段视频(OpenRouter base64);图文帖 → 逐图提取
             if (post.content_type or "").lower() == "video" or post.video_urls:
-                return extract_video(post, settings=settings)
-            return _image_client.extract(post)
+                return extract_video(
+                    post, settings=settings,
+                    save_path=(tgt.dir / "video.mp4") if tgt else None,
+                    public_url=(f"{tgt.public_base}/video.mp4") if tgt else None,
+                )
+            content = _image_client.extract(post)
+            if tgt:  # 图文帖:下载图片到本地并把 card.url 改写为 /data 路径
+                media.save_images(post, tgt.dir, tgt.public_base)
+            return content
 
         extract_fn = _dispatch_extract
     chat = chat or default_chat(env_file)

+ 46 - 0
scripts/inspect_multi.py

@@ -0,0 +1,46 @@
+"""读回多平台跑批结果:确认平台/阶段/卡片类型/段卡时间区间/知识点溯源。"""
+from __future__ import annotations
+
+import json
+import sys
+
+from creation_knowledge.config import PgConfig
+from creation_knowledge.integrations.db import CkStore
+
+
+def main() -> int:
+    store = CkStore(PgConfig.from_env(".env"))
+    ids = sys.argv[1:] or [
+        "xhs_67e4bdf50000000006028a59",
+        "dy_7612631899479648866",
+        "ks_3xepqgwddgbickc",
+    ]
+    for pid in ids:
+        p = store.read_post(pid)
+        if not p:
+            print(f"{pid}: NOT FOUND")
+            continue
+        cards = p.get("cards") or []
+        kinds: dict = {}
+        for c in cards:
+            kinds[c.get("kind")] = kinds.get(c.get("kind"), 0) + 1
+        seg = [c for c in cards if c.get("kind") == "segment"]
+        seg_times = ["{}-{}".format(c.get("start"), c.get("end")) for c in seg[:4]]
+        items = store.read_items(pid)
+        srcs = []
+        for it in items[:4]:
+            d = it["item"] if isinstance(it["item"], dict) else json.loads(it["item"])
+            srcs.append({"title": (d.get("title") or "")[:18],
+                         "source_cards": d.get("source_cards")})
+        print(f"\n{pid}")
+        print(f"  platform={p.get('platform')} stage={p.get('stage')} "
+              f"url={(p.get('url') or '')[:50]}")
+        print(f"  cards={kinds} seg_times={seg_times}")
+        print(f"  items={len(items)}")
+        for s in srcs:
+            print(f"    - {s['title']!r} source_cards={s['source_cards']}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 61 - 0
scripts/probe_bilibili.py

@@ -0,0 +1,61 @@
+"""B站取数验证:DASH 分轨(视频 m4s + 音频 m4s)→ 下载 → ffmpeg 合并 → 验证有视频+音频。"""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+
+import httpx
+import imageio_ffmpeg
+
+CRAW = "http://crawler.aiddit.com"
+
+
+def dl(url, path):
+    # 用 curl,不跟随重定向(避免跟到 B站 mcdn 的 P2P 死节点,httpx 会卡在那)
+    subprocess.run(["curl", "-s", "--connect-timeout", "15", "-m", "180",
+                    "-A", "Mozilla/5.0", "-e", "https://www.bilibili.com/",
+                    "-o", path, url], check=True)
+    return os.path.getsize(path)
+
+
+def main():
+    cid = sys.argv[1] if len(sys.argv) > 1 else "BV1qd4y1x76w"
+    ff = imageio_ffmpeg.get_ffmpeg_exe()
+    d = ((httpx.post(f"{CRAW}/crawler/bilibili/detail", json={"content_id": cid},
+                     timeout=40).json().get("data") or {}).get("data")) or {}
+    v = d["video_url_list"][0]["video_url"]
+    a = (d.get("voice_data") or {}).get("play_url")
+    print("title:", (d.get("title") or "")[:30])
+    print("视频轨:", v.split("?")[0].split("/")[-1])
+    print("音频轨:", a.split("?")[0].split("/")[-1] if a else "无 voice_data.play_url")
+    if not a:
+        print("→ 没有音频轨,B站这条只有视频"); return 0
+
+    nv = dl(v, "/tmp/bili_v.m4s")
+    na = dl(a, "/tmp/bili_a.m4s")
+    print(f"下载: 视频 {nv//1024//1024}MB, 音频 {na//1024}KB")
+
+    r = subprocess.run([ff, "-y", "-i", "/tmp/bili_v.m4s", "-i", "/tmp/bili_a.m4s",
+                        "-c", "copy", "/tmp/bili_merged.mp4"], capture_output=True, text=True)
+    import os
+    if not os.path.exists("/tmp/bili_merged.mp4"):
+        print("合并失败:", r.stderr[-300:]); return 1
+    sz = os.path.getsize("/tmp/bili_merged.mp4")
+    err = subprocess.run([ff, "-hide_banner", "-i", "/tmp/bili_merged.mp4"],
+                         capture_output=True, text=True).stderr
+    streams = [l.strip() for l in err.splitlines() if "Stream #" in l]
+    print(f"合并后 mp4: {sz//1024//1024}MB")
+    print("流:")
+    for s in streams:
+        print("  ", s[:90])
+    has_v = any("Video" in s for s in streams)
+    has_a = any("Audio" in s for s in streams)
+    print(f"\n结论:视频={'✅' if has_v else '❌'}  音频={'✅' if has_a else '❌'}  "
+          f"→ {'B站可拿到视频+音频,合并即可原生提炼' if has_v and has_a else 'B站缺轨'}")
+    print("===BILIDONE===")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 120 - 0
scripts/probe_platforms.py

@@ -0,0 +1,120 @@
+"""平台数据可下载性 + Gemini 直读 URL 能力 一次测全。
+
+各平台 detail → 图片/视频字段 → 实下载探测;最后测:远程视频 URL 直接喂 Gemini(不 base64)。
+用法:python scripts/probe_platforms.py <env_file>
+"""
+from __future__ import annotations
+
+import sys
+import time
+
+import httpx
+
+CRAW = "http://crawler.aiddit.com"
+IOS = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15"
+REFERER = {"douyin": "https://www.douyin.com/", "kuaishou": "https://www.kuaishou.com/",
+           "bilibili": "https://www.bilibili.com/", "xiaohongshu": "https://www.xiaohongshu.com/"}
+
+PROBES = [
+    ("小红书·图文", "/crawler/xiao_hong_shu/detail", "67e4bdf50000000006028a59", "xiaohongshu"),
+    ("小红书·视频", "/crawler/xiao_hong_shu/detail", "6976f6fd000000001a01eede", "xiaohongshu"),
+    ("抖音·视频", "/crawler/dou_yin/detail", "7612631899479648866", "douyin"),
+    ("B站·视频", "/crawler/bilibili/detail", "BV1qd4y1x76w", "bilibili"),
+    ("快手·视频", "/crawler/kuai_shou/detail", "3xepqgwddgbickc", "kuaishou"),
+]
+
+
+def load_env(p):
+    e = {}
+    for ln in open(p):
+        ln = ln.strip()
+        if ln and not ln.startswith("#") and "=" in ln:
+            k, v = ln.split("=", 1)
+            e[k.strip()] = v.strip().strip('"').strip("'")
+    return e
+
+
+def detail(path, cid):
+    return httpx.post(CRAW + path, json={"content_id": cid}, timeout=40).json()
+
+
+def inner(r):
+    d = ((r.get("data") or {}).get("data")) or {}
+    if isinstance(d, list):
+        return d[0] if d else {}
+    return d if isinstance(d, dict) else {}
+
+
+def vurl_of(d):
+    vl = d.get("video_url_list")
+    if isinstance(vl, list) and vl:
+        return vl[0].get("video_url") if isinstance(vl[0], dict) else vl[0]
+    return None
+
+
+def main():
+    env_file = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1].endswith(".env") else ".env"
+    env = load_env(env_file)
+    or_key = env.get("OPENROUTER_API_KEY") or env.get("OPEN_ROUTER_API_KEY")
+    or_base = (env.get("OPENROUTER_BASE_URL") or "https://openrouter.ai/api/v1").rstrip("/")
+    model = env.get("CONTENT_AGENT_VIDEO_LLM_MODEL") or "google/gemini-3-flash-preview"
+
+    for name, path, cid, plat in PROBES:
+        try:
+            r = detail(path, cid)
+            d = inner(r)
+            il = d.get("image_url_list") or []
+            mb = d.get("multi_bitrate")
+            vurl = vurl_of(d)
+            print(f"\n[{name}] code={r.get('code')} type={d.get('content_type')} "
+                  f"title={(d.get('title') or '')[:26]}")
+            print(f"  图片={len(il)}  视频链接={'有' if vurl else '无'}  "
+                  f"多码率={list(mb.keys()) if isinstance(mb, dict) else mb}")
+            if vurl:
+                try:
+                    with httpx.stream("GET", vurl, headers={"User-Agent": IOS,
+                                      "Referer": REFERER[plat], "Range": "bytes=0-524287"},
+                                      timeout=60, follow_redirects=True) as resp:
+                        chunk = next(resp.iter_bytes(), b"")
+                        print(f"  ↓视频: http={resp.status_code} "
+                              f"type={resp.headers.get('content-type')} magic={chunk[4:12]!r}")
+                except Exception as e:
+                    print("  ↓视频失败:", str(e)[:80])
+            elif il:
+                u = il[0] if isinstance(il[0], str) else il[0].get("image_url")
+                try:
+                    resp = httpx.get(u, timeout=30, follow_redirects=True)
+                    print(f"  ↓图片: http={resp.status_code} "
+                          f"type={resp.headers.get('content-type')} {len(resp.content)}B")
+                except Exception as e:
+                    print("  ↓图片失败:", str(e)[:80])
+            time.sleep(16)
+        except Exception as e:
+            print(f"[{name}] 失败: {e}")
+
+    print("\n=== 关键测试:远程视频 URL 直接喂 Gemini(不下载、不 base64)===")
+    d = inner(detail("/crawler/dou_yin/detail", "7612631899479648866"))
+    vurl = vurl_of(d)
+    body = {"model": model, "messages": [{"role": "user", "content": [
+        {"type": "text", "text": "这视频在讲什么?一句话。"},
+        {"type": "video_url", "video_url": {"url": vurl}}]}]}
+    try:
+        g = httpx.post(f"{or_base}/chat/completions",
+                       headers={"Authorization": f"Bearer {or_key}", "Content-Type": "application/json"},
+                       json=body, timeout=180)
+        print("  抖音远程URL直喂: HTTP", g.status_code)
+        if g.status_code == 200:
+            j = g.json()
+            print("  video_tokens=", j.get("usage", {}).get("video_tokens"),
+                  "→ 远程 URL 可直接处理!")
+            print("  回答:", j["choices"][0]["message"]["content"][:120])
+        else:
+            print("  失败:", g.text[:240], "→ 远程 URL 不行,必须下载+base64")
+    except Exception as e:
+        print("  异常:", str(e)[:150])
+    print("\n===PROBEDONE===")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 4 - 4
sql/creation_knowledge.sql

@@ -13,14 +13,14 @@ SET search_path TO creation_knowledge;
 
 -- 每帖一行:各环节产物存 JSONB,stage 记录处理到哪一步
 CREATE TABLE IF NOT EXISTS ck_post (
-    id          text        NOT NULL,                 -- xhs_<content_id>,同帖唯一,复用为 ingest source.id
-    platform    text,                                 -- xiaohongshu
+    id          text        NOT NULL,                 -- xhs_/dy_/ks_/bili_<content_id>,同帖唯一,复用为 ingest source.id
+    platform    text,                                 -- xiaohongshu/douyin/kuaishou/bilibili
     url         text,                                 -- 原始链接
     raw         jsonb,                                 -- fetch_post_detail 原始响应
-    cards       jsonb,                                 -- 统一卡片列表 [{index,kind,url,timestamp}](图/帧)
+    cards       jsonb,                                 -- 统一卡片列表 [{index,kind,url,timestamp,start,end}](图/帧/段
     extracted   jsonb,                                -- extract_content 多模态产物
     screening   jsonb,                                -- screen_post 结果 {passed,score,reason}
-    stage       text        NOT NULL DEFAULT 'fetched', -- fetched/extracted/screened/split/done/rejected/failed
+    stage       text        NOT NULL DEFAULT 'fetched', -- fetched/extracted/screened/split/done/rejected/failed/skipped
     created_at  timestamptz NOT NULL DEFAULT now(),
     updated_at  timestamptz NOT NULL DEFAULT now(),
     PRIMARY KEY (id)

+ 33 - 0
tests/test_crawler.py

@@ -7,6 +7,8 @@ from pathlib import Path
 import pytest
 
 from creation_knowledge.integrations.crawler import (
+    PLATFORMS,
+    detect_platform_and_id,
     parse_content_id,
     parse_detail_response,
 )
@@ -64,3 +66,34 @@ def test_parse_content_id_from_url():
            "?xsec_token=ABC=&xsec_source=pc_like")
     assert parse_content_id(url) == "67e4bdf50000000006028a59"
     assert parse_content_id("67e4bdf50000000006028a59") == "67e4bdf50000000006028a59"
+
+
+@pytest.mark.parametrize("url,platform,cid", [
+    # 长链
+    ("https://www.xiaohongshu.com/explore/67e4bdf50000000006028a59?xsec_token=A",
+     "xiaohongshu", "67e4bdf50000000006028a59"),
+    ("https://www.xiaohongshu.com/discovery/item/680659e8000000001a007a11",
+     "xiaohongshu", "680659e8000000001a007a11"),
+    ("https://www.douyin.com/video/7612631899479648866", "douyin", "7612631899479648866"),
+    ("https://www.douyin.com/search/x?modal_id=7612631899479648866&type=general",
+     "douyin", "7612631899479648866"),
+    ("https://www.gifshow.com/fw/photo/3xepqgwddgbickc", "kuaishou", "3xepqgwddgbickc"),
+    ("https://www.kuaishou.com/short-video/3xepqgwddgbickc", "kuaishou", "3xepqgwddgbickc"),
+    ("https://www.bilibili.com/video/BV1qd4y1x76w/?spm_id_from=333", "bilibili", "BV1qd4y1x76w"),
+    # 裸 id 兜底
+    ("67e4bdf50000000006028a59", "xiaohongshu", "67e4bdf50000000006028a59"),
+    ("7612631899479648866", "douyin", "7612631899479648866"),
+    ("BV1qd4y1x76w", "bilibili", "BV1qd4y1x76w"),
+])
+def test_detect_platform_and_id(url, platform, cid):
+    assert detect_platform_and_id(url) == (platform, cid)
+
+
+def test_parse_detail_platform_prefix():
+    """同一 parse 函数 + platform 参数 → 平台名与 id 前缀正确。"""
+    response = _load("67e4bdf50000000006028a59")
+    for platform, expected_prefix in [(p, c["prefix"]) for p, c in PLATFORMS.items()]:
+        post = parse_detail_response(response, platform=platform,
+                                     fallback_content_id="67e4bdf50000000006028a59")
+        assert post.platform == platform
+        assert post.id == f"{expected_prefix}_67e4bdf50000000006028a59"

+ 69 - 0
tests/test_media.py

@@ -0,0 +1,69 @@
+"""媒体落盘离线测试:注入假 downloader,断言图片落盘 + card.url 改写为 /data 路径。"""
+from __future__ import annotations
+
+from creation_knowledge import media
+from creation_knowledge.models import Card, Post
+
+PNG = b"\x89PNG\r\n\x1a\n" + b"fakepng"
+JPG = b"\xff\xd8\xff\xe0" + b"fakejpg"
+
+
+def _post():
+    return Post(
+        id="xhs_1", platform="xiaohongshu", url="u", content_id="1",
+        image_urls=["http://cdn/a", "http://cdn/b"],
+        cards=[Card(index=1, kind="image", url="http://cdn/a"),
+               Card(index=2, kind="image", url="http://cdn/b")],
+    )
+
+
+def test_media_target():
+    t = media.media_target("data", "20260616-101010", "douyin", "dy_9")
+    assert str(t.dir).endswith("data/20260616-101010/douyin/dy_9")
+    assert t.public_base == "/data/20260616-101010/douyin/dy_9"
+
+
+def test_save_images_writes_and_rewrites(tmp_path):
+    bodies = {"http://cdn/a": PNG, "http://cdn/b": JPG}
+    calls = []
+
+    def dl(url, platform):
+        calls.append((url, platform))
+        return bodies[url]
+
+    post = _post()
+    media.save_images(post, tmp_path, "/data/B/xiaohongshu/xhs_1", downloader=dl)
+
+    assert (tmp_path / "image_1.png").read_bytes() == PNG  # 文件头嗅探出 .png
+    assert (tmp_path / "image_2.jpg").read_bytes() == JPG
+    assert post.cards[0].url == "/data/B/xiaohongshu/xhs_1/image_1.png"
+    assert post.cards[1].url == "/data/B/xiaohongshu/xhs_1/image_2.jpg"
+    assert calls == [("http://cdn/a", "xiaohongshu"), ("http://cdn/b", "xiaohongshu")]
+
+
+def test_save_images_skips_failures(tmp_path):
+    """单张下载失败只跳过,不抛、不改这张卡 url。"""
+    def dl(url, platform):
+        if url.endswith("b"):
+            raise RuntimeError("boom")
+        return JPG
+
+    post = _post()
+    media.save_images(post, tmp_path, "/data/B/xiaohongshu/xhs_1", downloader=dl)
+
+    assert post.cards[0].url == "/data/B/xiaohongshu/xhs_1/image_1.jpg"
+    assert post.cards[1].url == "http://cdn/b"  # 失败的保持原样
+
+
+def test_save_images_ignores_local_and_segment(tmp_path):
+    """已是本地路径 / 非 image 卡不处理。"""
+    post = Post(id="x", platform="douyin", url="u", content_id="1",
+                cards=[Card(index=1, kind="segment", url="/data/x/video.mp4", start=0, end=3),
+                       Card(index=2, kind="image", url="/data/x/image_2.jpg")])
+
+    def dl(url, platform):
+        raise AssertionError("不应被调用")
+
+    media.save_images(post, tmp_path, "/data/x", downloader=dl)
+    assert post.cards[0].url == "/data/x/video.mp4"
+    assert post.cards[1].url == "/data/x/image_2.jpg"

+ 51 - 3
tests/test_pipeline_e2e.py

@@ -9,21 +9,21 @@ from pathlib import Path
 
 from creation_knowledge.config import PgConfig, Settings
 from creation_knowledge.integrations.crawler import parse_detail_response
-from creation_knowledge.models import ExtractedContent
+from creation_knowledge.models import ExtractedContent, Post
 from creation_knowledge.pipeline import run_pipeline
 
 FIXTURES = Path(__file__).parent / "fixtures"
 CID = "67e4bdf50000000006028a59"
 
 
-def _settings() -> Settings:
+def _settings(data_dir: str = "") -> Settings:
     return Settings(
         pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
         crawler_base_url="http://x", crawler_key="", crawler_timeout=30,
         video_model="m", gemini_api_key="", openrouter_base_url="http://x",
         openrouter_api_key="k", llm_model="m", knowhub_api="http://x",
         ingest_enabled=False, max_cards=12, frames_dir="runtime/frames",
-        douyin_ratio="540p",
+        douyin_ratio="540p", data_dir=data_dir,
     )
 
 
@@ -51,6 +51,11 @@ class FakeStore:
     def update_item_ingest(self, iid, status, kid):
         self.items[iid - 1].update(ingest_status=status, knowledge_id=kid)
 
+    def clear_items(self, pid):
+        before = len(self.items)
+        self.items = [it for it in self.items if it["post_id"] != pid]
+        return before - len(self.items)
+
 
 def _fetch(url):
     resp = json.loads((FIXTURES / f"xhs_case_{CID}.json").read_text("utf-8"))
@@ -107,3 +112,46 @@ def test_pipeline_rejected_path():
     assert r["status"] == "rejected"
     assert store.posts[f"xhs_{CID}"]["stage"] == "rejected"
     assert store.items == []
+
+
+def _extract_must_not_run(post):
+    raise AssertionError("skip 的帖子不应进入提取")
+
+
+def test_pipeline_skip_xhs_video():
+    """小红书视频帖:content_type=video 但无视频直链 → skip,不进提取/筛选。"""
+    store = FakeStore()
+    post = Post(id="xhs_vid1", platform="xiaohongshu", url="u", content_id="vid1",
+                content_type="video", video_urls=[], image_urls=["http://cover.jpg"])
+    results = run_pipeline(
+        ["vid1"], settings=_settings(), ingest_enabled=False, store=store,
+        fetch_fn=lambda url: post, extract_fn=_extract_must_not_run, chat=_chat_pass,
+    )
+    r = results[0]
+    assert r["status"] == "skipped" and r["reason"] == "video_no_direct_url"
+    assert store.posts["xhs_vid1"]["stage"] == "skipped"
+    assert store.items == []
+
+
+def test_pipeline_skip_empty_media():
+    """既无图也无视频 → skip(empty_media)。"""
+    store = FakeStore()
+    post = Post(id="xhs_empty", platform="xiaohongshu", url="u", content_id="empty",
+                content_type="normal", video_urls=[], image_urls=[])
+    results = run_pipeline(
+        ["empty"], settings=_settings(), ingest_enabled=False, store=store,
+        fetch_fn=lambda url: post, extract_fn=_extract_must_not_run, chat=_chat_pass,
+    )
+    assert results[0]["status"] == "skipped" and results[0]["reason"] == "empty_media"
+    assert store.items == []
+
+
+def test_pipeline_idempotent_rerun():
+    """同帖重跑:知识片段先清后写,不叠加。"""
+    store = FakeStore()
+    for _ in range(3):
+        run_pipeline(
+            [CID], settings=_settings(), ingest_enabled=False, store=store,
+            fetch_fn=_fetch, extract_fn=_extract, chat=_chat_pass,
+        )
+    assert len(store.items) == 1, "重跑三次仍应只有 1 条(先清后写)"

+ 17 - 1
tests/test_video_extract.py

@@ -30,7 +30,7 @@ def _settings() -> Settings:
         video_model="google/gemini-3-flash-preview", gemini_api_key="",
         openrouter_base_url="https://openrouter.ai/api/v1", openrouter_api_key="k",
         llm_model="m", knowhub_api="x", ingest_enabled=False, max_cards=12,
-        frames_dir="runtime/frames", douyin_ratio="540p")
+        frames_dir="runtime/frames", douyin_ratio="540p", data_dir="")
 
 
 def _fake_post():
@@ -70,3 +70,19 @@ def test_video_extract_makes_segment_cards(tmp_path):
     assert any(p["type"] == "video_url"
                and p["video_url"]["url"].startswith("data:video/mp4;base64,")
                for p in parts)
+
+
+def test_video_extract_saves_and_sets_url(tmp_path):
+    """传 save_path/public_url:整段视频落盘 + 段卡 url 指向整段。"""
+    vf = tmp_path / "src.mp4"
+    vf.write_bytes(b"\x00\x00\x00\x18ftypmp42fakevideobytes")
+    save_path = tmp_path / "out" / "video.mp4"
+    fake, _ = _fake_post()
+    post = Post(id="dy_1", platform="douyin", url="u", content_id="1",
+                content_type="video", video_urls=["http://x/play"])
+
+    extract_video(post, settings=_settings(), http_post=fake, video_path=str(vf),
+                  save_path=save_path, public_url="/data/B/douyin/dy_1/video.mp4")
+
+    assert save_path.exists() and save_path.read_bytes() == vf.read_bytes()
+    assert all(c.url == "/data/B/douyin/dy_1/video.mp4" for c in post.cards)

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 121 - 0
web/frameworks.json


+ 210 - 240
web/index.html

@@ -1,279 +1,243 @@
 <!doctype html>
 <html lang="zh">
 <head>
-<meta charset="utf-8" />
-<meta name="viewport" content="width=device-width, initial-scale=1" />
-<title>创作知识 · 流水线可视化</title>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>创作知识的搜索和解构</title>
 <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
 <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
 <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
 <style>
   :root{
-    --bg:#f3efe4;        /* 暖黄页面背景 */
-    --panel:#ffffff;     /* 白卡 */
-    --panel2:#faf7ef;    /* 略暖的内卡 */
-    --line:#e7e2d4;      /* 浅暖描边 */
-    --line2:#ece7d9;
-    --fg:#2c2c2a;        /* 近黑正文 */
-    --mut:#6f6e68;       /* 次要文字 */
-    --tert:#9a978d;      /* 提示文字 */
-    --ac:#185fa5;        /* 蓝 */
-    --what:#185fa5; --why:#534ab7; --how:#0f6e56;
-    --ok:#0f6e56; --no:#a32d2d; --warn:#854f0b;
+    --bg:#f3efe4; --panel:#fff; --panel2:#faf7ef; --line:#e7e2d4; --line2:#ece7d9;
+    --fg:#2c2c2a; --mut:#6f6e68; --tert:#9a978d; --ac:#185fa5;
   }
   *{box-sizing:border-box}
   body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.6 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif}
-  header{padding:14px 20px;background:var(--panel);border-bottom:1px solid var(--line);font-weight:600;display:flex;gap:10px;align-items:center}
-  header .sub{color:var(--mut);font-weight:400;font-size:13px}
-  .wrap{display:flex;height:calc(100vh - 51px)}
-  .side{width:300px;background:var(--panel);border-right:1px solid var(--line);overflow:auto;flex:none}
-  .main{flex:1;overflow:auto;padding:20px}
-  .pitem{padding:12px 16px;border-bottom:1px solid var(--line);cursor:pointer}
+  header{padding:12px 20px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:10px;align-items:baseline}
+  header h0{font-size:16px;font-weight:650;margin:0}
+  header .sub{color:var(--mut);font-weight:400;font-size:12.5px}
+  .pbtn{margin-left:auto;background:#1e293b;color:#fff;border:0;border-radius:7px;padding:6px 13px;font-size:12.5px;cursor:pointer;font-family:inherit}
+  .pbtn:hover{background:#334155}
+  .legend{background:var(--panel2);border-bottom:1px solid var(--line);padding:8px 20px;font-size:11.5px;color:var(--mut);display:flex;gap:18px;flex-wrap:wrap;align-items:center}
+  .legend .grp{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
+  .legend .gl{font-weight:700;color:var(--fg)}
+  .legend .it{white-space:nowrap}
+  .wrap{display:flex;height:calc(100vh - 50px - 38px)}
+  .side{width:320px;background:var(--panel);border-right:1px solid var(--line);overflow:auto;flex:none}
+  .sidecap{padding:9px 14px;font-size:11px;color:var(--tert);font-weight:600;border-bottom:1px solid var(--line);background:var(--panel2);position:sticky;top:0}
+  .main{flex:1;overflow:auto;padding:18px 26px}
+  .pitem{padding:11px 14px;border-bottom:1px solid var(--line);cursor:pointer}
   .pitem:hover{background:#f7f3ea}
-  .pitem.sel{background:#eef4fb;border-left:3px solid var(--ac)}
-  .pitem .id{font-size:12px;color:var(--mut);word-break:break-all}
-  .pitem .row{display:flex;gap:8px;align-items:center;margin-top:4px}
-  .badge{display:inline-block;padding:1px 9px;border-radius:10px;font-size:12px;background:#efeadd;color:#5f5e5a}
-  .badge.ok{background:#e1f5ee;color:var(--ok)}
-  .badge.no{background:#fcebeb;color:var(--no)}
-  .badge.ac{background:#e6f1fb;color:var(--ac)}
-  .badge.warn{background:#faeeda;color:var(--warn)}
-  .stage{background:var(--panel);border:1px solid var(--line);border-radius:12px;margin-bottom:16px;overflow:hidden}
-  .stage>h3{margin:0;padding:11px 16px;background:#f7f3ea;border-bottom:1px solid var(--line);font-size:13px;font-weight:600;display:flex;gap:8px;align-items:center}
-  .stage>h3 .n{width:20px;height:20px;border-radius:50%;background:#3f86d6;color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px}
-  .stage .body{padding:14px 16px}
-  .kv{color:var(--mut);font-size:12px;margin:0 0 4px}
-  .imgs{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
-  .imgs img{width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--line);cursor:zoom-in;transition:transform .1s}
-  .imgs img:hover{transform:scale(1.05);border-color:var(--ac)}
-  .card{background:var(--panel2);border:1px solid var(--line2);border-radius:10px;padding:12px 14px;margin-bottom:10px}
-  .kptitle{margin:0;font-size:15px}
-  .part{margin-top:12px}
-  .part ~ .part{border-top:1px solid var(--line2);padding-top:12px}
-  .phead{font-size:12px;color:var(--mut);font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:8px}
-  .pn{display:inline-flex;width:18px;height:18px;border-radius:50%;background:#e6f1fb;color:#185fa5;align-items:center;justify-content:center;font-size:11px;flex:none}
-  .ktype{font-size:12px;padding:2px 9px;border-radius:8px;background:#e6f1fb;color:#0c447c;white-space:nowrap;margin-left:auto}
-  .chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:0}
-  pre{white-space:pre-wrap;word-break:break-word;background:#f6f2e8;border:1px solid var(--line);border-radius:6px;padding:10px;font-size:12px;color:#444441;margin:6px 0 0;max-height:260px;overflow:auto}
-  details summary{cursor:pointer;color:var(--mut);font-size:12px}
-  .whh{margin:8px 0;padding-left:10px;border-left:2px solid var(--line)}
-  .whh.w{border-color:var(--what)} .whh.y{border-color:var(--why)} .whh.h{border-color:var(--how)}
-  .whh b{font-size:12px}
-  .whh.w b{color:var(--what)} .whh.y b{color:var(--why)} .whh.h b{color:var(--how)}
-  .sect{border-top:1px solid var(--line2);margin-top:12px;padding-top:10px;display:flex;flex-direction:column;gap:10px}
-  .srow{display:flex;align-items:flex-start;gap:10px}
-  .slabel{font-size:13px;color:var(--mut);min-width:62px;flex:none;padding-top:2px}
-  .scope{display:flex;align-items:baseline;gap:8px}
-  .scn{font-size:12px;padding:2px 8px;border-radius:8px;flex:none}
-  .foot{border-top:1px solid var(--line2);margin-top:10px;padding-top:8px;display:flex;gap:18px;flex-wrap:wrap}
-  .link{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:#e6f1fb;border:1px solid #9cc3ee;border-radius:8px;color:var(--ac);text-decoration:none;font-size:13px;margin-bottom:10px}
-  .crit{background:var(--panel2);border:1px solid var(--line2);border-radius:8px;padding:10px 14px;margin-bottom:12px}
-  .crit .t{color:var(--mut);font-size:12px;margin-bottom:6px}
-  .crit ul{margin:0;padding-left:18px} .crit li{margin:3px 0;font-size:13px}
-  .lb{position:fixed;inset:0;background:rgba(0,0,0,.8);display:flex;align-items:center;justify-content:center;z-index:50;cursor:zoom-out}
+  .pitem.sel{background:#eef4fb;box-shadow:inset 3px 0 0 var(--ac)}
+  .pitem .ftitle{font-size:13.5px;font-weight:600;line-height:1.45}
+  .pitem .r{display:flex;gap:5px;align-items:center;margin-top:5px;flex-wrap:wrap}
+  .pbg{font-size:10.5px;font-weight:700;padding:1px 6px;border-radius:3px;background:#ffe4e6;color:#be123c}
+  .pitem .src{font-size:11px;color:var(--tert);margin-top:3px}
+  .chip{font-size:11px;padding:1px 8px;border-radius:10px;white-space:nowrap}
+  .c-stage{background:#ede9fe;color:#5b21b6}
+  .c-type{background:#e6f1fb;color:#185fa5}
+  .c-step{font-size:11px;padding:0 7px;border-radius:4px;background:#eef2f7;color:#475569;font-weight:600}
+  .sc{font-size:11px;padding:1px 8px;border-radius:4px;margin:0 4px 4px 0;display:inline-block}
+  .sc-实质{background:#fee2e2;color:#991b1b}.sc-形式{background:#dbeafe;color:#1e40af}
+  .sc-感受{background:#fce7f3;color:#9d174d}.sc-作用{background:#dcfce7;color:#166534}.sc-意图{background:#fef3c7;color:#92400e}
+  .dh{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:2px}
+  .dh h1{font-size:20px;margin:0;font-weight:700}
+  .dh .acts{margin-left:auto;display:flex;gap:8px;align-items:center}
+  .dh a{color:var(--ac);text-decoration:none;font-size:12.5px}
+  .minib{background:#eef2f7;color:#334155;border:1px solid var(--line);border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;font-family:inherit}
+  .minib:hover{background:#e2e8f0}
+  .ptitle{color:var(--mut);font-size:12.5px;margin-bottom:12px}
+  .meta{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:16px}
+  /* 下游字段名作主标签 */
+  .flab{display:inline-block;font-size:11px;font-weight:700;color:#fff;background:#0f766e;border-radius:5px;padding:1px 9px;margin-bottom:7px}
+  .block{margin-bottom:18px}
+  .purpose{background:var(--panel);border:1px solid var(--line);border-left:3px solid #0f766e;border-radius:8px;padding:11px 14px;font-size:14.5px;font-weight:600;line-height:1.55}
+  .want{background:var(--panel2);border:1px dashed var(--line);border-radius:8px;padding:8px 12px;font-size:13px}
+  .want b{color:var(--ac)}
+  .step{background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:12px;overflow:hidden}
+  .step-h{display:flex;align-items:center;gap:9px;padding:9px 14px;background:var(--panel2);border-bottom:1px solid var(--line2)}
+  .step-n{background:#0f766e;color:#fff;width:auto;min-width:46px;height:22px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;flex:none;padding:0 8px;font-weight:600}
+  .step-intent{font-size:14px;font-weight:600;flex:1}
+  .step-intent .vlabel{color:var(--tert);font-weight:400;font-size:12px}
+  .step-tags{padding:8px 14px 0;display:flex;gap:5px;flex-wrap:wrap;align-items:center}
+  .rowlab{display:flex;gap:8px;padding:9px 14px 0}
+  .rl{font-size:10px;font-weight:700;border-radius:4px;padding:1px 7px;flex:none;height:18px;line-height:16px}
+  .rl-how{background:#e6f1fb;color:#185fa5}
+  .rl-out{background:#dcfce7;color:#15803d}
+  .directive{font-size:13px;line-height:1.72;white-space:pre-wrap;color:#33312c;flex:1}
+  .outtxt{font-size:12.5px;line-height:1.6;color:#166534;flex:1}
+  .from{font-size:11px;color:var(--tert);margin-left:auto}
+  details.box{margin-top:14px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}
+  details.box>summary{padding:9px 14px;cursor:pointer;font-size:12.5px;color:var(--mut);list-style:none}
+  details.box>summary::before{content:'▶ ';color:var(--tert)}
+  details.box[open]>summary::before{content:'▼ '}
+  .box .bd{padding:4px 14px 12px}
+  .drop{font-size:12px;color:var(--mut);margin:4px 0;line-height:1.6}
+  .imgs{display:flex;flex-wrap:wrap;gap:7px;padding:4px 14px 12px}
+  .imgs img{width:96px;height:96px;object-fit:cover;border-radius:6px;border:1px solid var(--line);cursor:zoom-in}
+  .lb{position:fixed;inset:0;background:rgba(0,0,0,.82);display:flex;align-items:center;justify-content:center;z-index:50;cursor:zoom-out}
   .lb img{max-width:92vw;max-height:92vh;border-radius:8px}
-  a{color:var(--ac)}
-  .empty{color:var(--mut);padding:40px;text-align:center}
-  .sub2{color:var(--mut);font-size:12px;margin:-2px 0 8px}
-  .sx{margin-left:auto;display:flex;gap:6px}
-  .pbtn{font-size:12px;padding:2px 10px;border-radius:7px;background:#fff;border:1px solid var(--line);color:var(--mut);cursor:pointer;font-family:inherit}
-  .pbtn:hover{border-color:var(--ac);color:var(--ac)}
-  .pmodal{background:var(--panel);border:1px solid var(--line);border-radius:12px;max-width:760px;width:90vw;max-height:86vh;overflow:auto;padding:16px 18px;cursor:auto}
-  .pmhead{display:flex;align-items:center;gap:10px;margin-bottom:6px}
-  .pmhead b{font-size:15px}
-  .pmmodel{font-size:12px;color:var(--ac);background:#eef4fb;padding:1px 8px;border-radius:7px}
-  .pmx{margin-left:auto;cursor:pointer;color:var(--mut);font-size:16px;line-height:1}
-  .pmlabel{font-size:12px;color:var(--mut);font-weight:600;margin:12px 0 4px}
-  .pmnote{font-size:12px;color:var(--warn);background:#faeeda;padding:6px 10px;border-radius:6px;margin-top:4px}
-  .srcrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}
-  .srcrow .lbl{font-size:12px;color:var(--mut);flex:none}
-  .srcthumb{position:relative}
-  .srcthumb img{width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid var(--line);cursor:zoom-in}
-  .srcthumb img:hover{border-color:var(--ac)}
-  .srcthumb .cn{position:absolute;left:2px;bottom:2px;font-size:10px;background:rgba(0,0,0,.6);color:#fff;padding:0 4px;border-radius:4px}
-  .cardthumb{position:relative}
-  .cardthumb .cn{position:absolute;left:3px;bottom:3px;font-size:10px;background:rgba(0,0,0,.6);color:#fff;padding:0 5px;border-radius:4px}
-  .evrow{display:flex;gap:8px;align-items:baseline;margin:5px 0;font-size:13px}
-  .evcard{font-size:11px;padding:1px 7px;border-radius:6px;background:#e6f1fb;color:#185fa5;cursor:zoom-in;flex:none}
-  .segchip{display:inline-flex;align-items:center;font-size:12px;padding:4px 10px;border-radius:8px;background:#eef4fb;color:#185fa5;border:1px solid #cfe0f5;flex:none}
+  .empty{color:var(--tert);padding:40px;text-align:center}
+  .ov{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:60}
+  .modal{background:#fff;width:780px;max-width:92vw;max-height:86vh;border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
+  .modal h2{margin:0;padding:13px 18px;background:#1e293b;color:#fff;font-size:14px;display:flex;align-items:center}
+  .modal h2 .x{margin-left:auto;cursor:pointer;font-size:18px}
+  .modal .mbody{padding:14px 18px;overflow:auto}
+  .modal pre{margin:0;font:12px/1.62 ui-monospace,"SF Mono",Menlo,monospace;color:#1f2937;white-space:pre-wrap}
+  .modal .fk{font-size:11px;color:var(--tert);margin:10px 0 3px;font-weight:700}
+  .modal .fv{font-size:13px;color:#1f2937;margin-bottom:4px}
+  .modal .cwrap{background:#f7f7f5;border:1px solid var(--line);border-radius:7px;padding:11px 13px;margin-top:4px}
 </style>
 </head>
 <body>
 <div id="root"></div>
 <script type="text/babel">
 const {useState,useEffect} = React;
-const j = (u)=>fetch(u).then(r=>r.json());
-const SCOPE_CN = {substance:"实质",form:"形式",feeling:"感受",effect:"作用",intent:"意图"};
-const SCOPE_COLOR = {
-  substance:["#e6f1fb","#0c447c"], form:["#e1f5ee","#0f6e56"],
-  feeling:["#eeedfe","#3c3489"], effect:["#faeeda","#854f0b"],
-  intent:["#faece7","#993c1d"]};
+const j = u => fetch(u).then(r=>r.json());
 
-function Badge({kind,children}){return <span className={"badge "+(kind||"")}>{children}</span>;}
+const PROMPT = `# 创作知识提取(需求驱动 · 完整框架粒度)
 
-function PostList({posts,sel,onSel}){
-  return <div className="side">
-    {posts.map(p=>{
-      const k = p.stage==="done"?"ok":p.stage==="rejected"?"no":p.stage==="failed"?"no":"warn";
-      return <div key={p.id} className={"pitem"+(sel===p.id?" sel":"")} onClick={()=>onSel(p.id)}>
-        <div className="id">{p.id}</div>
-        <div className="row"><Badge kind={k}>{p.stage}</Badge><Badge kind="ac">{p.item_count} 条知识</Badge></div>
-      </div>;
-    })}
-  </div>;
-}
+产物:一颗知识 = 一个完整创作框架
+= 一个 purpose(把某种输入一路做成一个可交付成品)+ 一条不可跳步的 steps 链。
+一帖通常只出 1-2 颗。绝不把框架内部的步骤/要点拆成独立知识。
 
-function Stage({n,title,sub,extra,children}){
-  return <div className="stage"><h3><span className="n">{n}</span>{title}{extra&&<span className="sx">{extra}</span>}</h3>
-    <div className="body">{sub&&<div className="sub2">{sub}</div>}{children}</div></div>;
-}
+## 提取四步
+1. 数闭环:找帖里所有“输入→可交付成品”的完整链,几条算几颗。
+2. 去重叠:若某链成品其实是另一条链的中间产物,并入后者。
+3. 压清单:所有“N选1 / 并列要素”压进所属 step,不按项数拆颗、不拆成多步。
+4. 定步数:每颗按“几次不可省的形态转换”定 steps(2-6),动词只用 决策/分析/构建/撰写/打磨。
+
+## 一颗的结构
+- title:6-12字名词短语,以“框架/创作/写作”收尾。
+- purpose:「按「A→B→C」N步,将X写成可直接交付的Y」。
+- 每 step:intent="<双字动词>,<任务>";directive=把怎么做的全部细节压进来(清单+判据+正反例+阈值+反模式);output=交付物验收规格。
+
+## 只要创作,剔除制作(按“设计决策 vs 工艺执行”判,不看词面)
+保留:景别/角度/运镜/构图/字幕/配乐/结构/时长。剔除:器材/打光/剪辑软件操作/调色参数/导出。
 
-function PromptBtn({label,onClick}){
-  return <button className="pbtn" onClick={onClick}>📄 {label||"提示词"}</button>;
+## 标签
+知识类型(What/Why/How)、阶段(灵感/选题/脚本)、作用域(实质/形式/感受/作用/意图,可多选)。`;
+
+// 把一颗框架拍平成下游消费的 content 文本(与 id 1005 同款)
+function buildContent(fw){
+  let s = "目标:" + (fw.purpose||"") + "\n";
+  (fw.steps||[]).forEach((st,i)=>{
+    s += "步骤"+(i+1)+"(目的:"+(st.intent||"")+")\n";
+    s += "  指引:"+(st.directive||"")+"\n";
+    s += "  产出:"+(st.output||"")+"\n";
+  });
+  return s.replace(/\n+$/,"");
 }
 
-function PromptModal({item,onClose}){
-  if(!item) return null;
-  return <div className="lb" onClick={onClose}>
-    <div className="pmodal" onClick={e=>e.stopPropagation()}>
-      <div className="pmhead"><b>{item.label} · 提示词</b><span className="pmmodel">{item.model}</span><span className="pmx" onClick={onClose}>✕</span></div>
-      {item.note&&<div className="pmnote">{item.note}</div>}
-      <div className="pmlabel">System(角色设定)</div><pre>{item.system}</pre>
-      <div className="pmlabel">User(输入模板)</div><pre>{item.user}</pre>
-    </div>
+function Scopes({list}){ return (list||[]).map((s,i)=><span key={i} className={"sc sc-"+s}>{s}</span>); }
+function Tags({stage,type,scopes}){ return <React.Fragment>
+  {type&&<span className="chip c-type">类型·{type}</span>}
+  {stage&&<span className="chip c-stage">阶段·{stage}</span>}
+  <Scopes list={scopes}/>
+</React.Fragment>; }
+
+function Legend(){
+  return <div className="legend">
+    <div className="grp"><span className="gl">怎么读:</span><span className="it">左边一颗知识 → 右边是它下发给下游的格式:<b>目标</b> + <b>步骤</b>(目的/指引/产出)。点右上「下游记录」看实际 content。</span></div>
+    <div className="grp"><span className="gl">类型</span><span className="it"><b>What</b>是什么</span><span className="it"><b>Why</b>为什么</span><span className="it"><b>How</b>怎么做</span></div>
+    <div className="grp"><span className="gl">阶段</span><span className="it">灵感·找方向</span><span className="it">选题·定写什么</span><span className="it">脚本·组织表达</span></div>
+    <div className="grp"><span className="gl">作用域</span><span className="sc sc-实质">实质讲什么</span><span className="sc sc-形式">形式怎么呈现</span><span className="sc sc-感受">感受勾情绪</span><span className="sc sc-作用">作用起功能</span><span className="sc sc-意图">意图创作者目的</span></div>
   </div>;
 }
 
-function fmtTs(s){s=Math.floor(s||0);return String(Math.floor(s/60)).padStart(2,'0')+":"+String(s%60).padStart(2,'0');}
-function cardTime(c){
-  if(!c) return "";
-  if(c.kind==="segment"&&c.start!=null) return fmtTs(c.start)+"–"+fmtTs(c.end);
-  if(c.kind==="frame"&&c.timestamp!=null) return fmtTs(c.timestamp);
-  return "";
+function PostList({posts,sel,onSel}){
+  return <div className="side">
+    <div className="sidecap">知识列表 · 每条 = 一颗完整创作框架</div>
+    {posts.map(p=>{ const fw=p.framework||{};
+      return <div key={p.post_id} className={"pitem"+(sel===p.post_id?" sel":"")} onClick={()=>onSel(p.post_id)}>
+        <div className="ftitle">{fw.title||p.title}</div>
+        <div className="r"><span className="pbg">小红书</span><span className="chip c-type">{fw["知识类型"]}</span><span className="chip c-stage">{fw["阶段跨度"]}</span><span className="c-step">{(fw.steps||[]).length}步</span></div>
+        <div className="src">原帖:{p.title}</div>
+      </div>;
+    })}
+  </div>;
 }
 
-function KPCard({it,idx,cardMap,onZoom}){
-  const item=it.item||{}, deco=it.deconstruction||{}, payload=it.ingest_payload||{};
-  const types=(item.knowledge_types||[]).map(t=>t.toUpperCase()).join(" + ");
-  const stages=deco.stages||[], scopes=deco.scopes||[];
-  const srcCards=(item.source_cards||[]).map(n=>cardMap[n]).filter(Boolean);
-  const evidence=item.evidence||[];
-  return <div className="card">
-    <h4 className="kptitle">{idx}. {item.title||"(无标题)"}</h4>
-
-    {srcCards.length>0 && <div className="srcrow">
-      <span className="lbl">来源卡片</span>
-      {srcCards.map(c=> c.url
-        ? <span className="srcthumb" key={c.index}>
-            <img src={c.url} title={"卡片"+c.index} onClick={()=>onZoom(c.url)} onError={e=>e.target.style.display='none'}/>
-            <span className="cn">{cardTime(c)||("卡片"+c.index)}</span></span>
-        : <span className="segchip" key={c.index}>片段{c.index}{cardTime(c)?" · "+cardTime(c):""}</span>
-      )}
-    </div>}
-
-    <div className="part">
-      <div className="phead"><span className="pn">1</span>知识类型拆解<span className="ktype">{types||"—"}</span></div>
-      {item.what&&<div className="whh w"><b>What · 是什么</b><div>{item.what}</div></div>}
-      {item.why&&<div className="whh y"><b>Why · 为什么</b><div>{item.why}</div></div>}
-      {item.how&&<div className="whh h"><b>How · 怎么做</b><div>{item.how}</div></div>}
+function Step({s,i}){
+  return <div className="step">
+    <div className="step-h">
+      <span className="step-n">步骤{i+1}</span>
+      <span className="step-intent"><span className="vlabel">目的:</span>{s.intent}</span>
+    </div>
+    <div className="step-tags">
+      {s["阶段"]&&<span className="chip c-stage">阶段·{s["阶段"]}</span>}
+      {s["类型"]&&<span className="chip c-type">类型·{s["类型"]}</span>}
+      <Scopes list={s["作用域"]}/>
+      {s["出处"]&&s["出处"].length>0 && <span className="from">来源 {s["出处"].join(" · ")}</span>}
     </div>
+    <div className="rowlab"><span className="rl rl-how">指引</span><div className="directive">{s.directive}</div></div>
+    {s.output && <div className="rowlab" style={{paddingBottom:12}}><span className="rl rl-out">产出</span><div className="outtxt">{s.output}</div></div>}
+  </div>;
+}
 
-    <div className="part">
-      <div className="phead"><span className="pn">2</span>解构 · 阶段</div>
-      <div className="chips">
-        {stages.length? stages.map(s=><Badge key={s}>{s}</Badge>) : <span className="kv">—</span>}
-        {stages.length>1 && <span className="kv">({stages.length} 个阶段都 cover)</span>}
+function Detail({post,onZoom,onRecord}){
+  if(!post) return <div className="empty">← 左边选一颗知识</div>;
+  const fw=post.framework||{};
+  const imgs=[]; for(let n=1;n<=(post.img_count||0);n++) imgs.push(post.img_base+"/image_"+n+".webp");
+  return <div>
+    <div className="dh">
+      <h1>{fw.title}</h1>
+      <div className="acts">
+        <button className="minib" onClick={()=>onRecord(post)}>📥 下游记录</button>
+        <a href={post.url} target="_blank" rel="noreferrer">🔗 原帖</a>
       </div>
     </div>
+    <div className="ptitle">原帖:{post.title} · 小红书</div>
+    <div className="meta"><Tags stage={fw["阶段跨度"]} type={fw["知识类型"]} scopes={fw["作用域并集"]}/></div>
 
-    <div className="part">
-      <div className="phead"><span className="pn">3</span>解构 · 作用域</div>
-      <div style={{display:'flex',flexDirection:'column',gap:6}}>
-        {scopes.length? scopes.map((s,i)=>{
-          const c=SCOPE_COLOR[s.scope_type]||["#efeadd","#5f5e5a"];
-          return <div className="scope" key={i}>
-            <span className="scn" style={{background:c[0],color:c[1]}}>{SCOPE_CN[s.scope_type]||s.scope_type}</span>
-            <span>{s.value}</span></div>;
-        }) : <span className="kv">—</span>}
-      </div>
+    <div className="block">
+      <span className="flab">目标</span>
+      <div className="purpose">{fw.purpose}</div>
     </div>
 
-    <div className="foot">
-      {evidence.length>0 &&
-        <details><summary>原文证据({evidence.length})</summary>
-          <div>{evidence.map((e,i)=>{
-            const text=typeof e==="string"?e:(e.text||"");
-            const card=(typeof e==="object"&&e)?e.card:null;
-            const c=card!=null?cardMap[card]:null;
-            return <div className="evrow" key={i}>
-              {c&&(c.url
-                ? <span className="evcard" onClick={()=>onZoom(c.url)}>卡片{card}</span>
-                : <span className="evcard" style={{cursor:"default"}} title="视频片段">片段{card}{cardTime(c)?" · "+cardTime(c):""}</span>)}
-              <span>{text}</span></div>;
-          })}</div>
-        </details>}
-      <details><summary>入库数据(ingest payload)</summary><pre>{JSON.stringify(payload,null,2)}</pre></details>
+    {fw["想要"] && <div className="block"><span className="flab">想要</span><div className="want">{fw["想要"]}</div></div>}
+
+    <div className="block">
+      <span className="flab">步骤({(fw.steps||[]).length})· 目的 / 指引 / 产出</span>
+      {(fw.steps||[]).map((s,i)=><Step key={i} s={s} i={i}/>)}
     </div>
+
+    {post.dropped&&post.dropped.length>0 && <details className="box">
+      <summary>被剔除的「制作 / 素材」({post.dropped.length})— 不算创作知识</summary>
+      <div className="bd">{post.dropped.map((d,i)=><div className="drop" key={i}>• {typeof d==="string"?d:(d.item||JSON.stringify(d))}{d.reason?(" — "+d.reason):""}</div>)}</div>
+    </details>}
+
+    <details className="box">
+      <summary>原帖图片({imgs.length})· 知识从这里提炼</summary>
+      <div className="imgs">{imgs.map((u,i)=><img key={i} src={u} onClick={()=>onZoom(u)} onError={e=>e.target.style.display='none'}/>)}</div>
+    </details>
   </div>;
 }
 
-function Pipeline({postId,onZoom,prompts,onPrompt}){
-  const [post,setPost]=useState(null);
-  const [items,setItems]=useState([]);
-  useEffect(()=>{ if(!postId)return; setPost(null);setItems([]);
-    j("/api/posts/"+postId).then(setPost);
-    j("/api/posts/"+postId+"/items").then(setItems);
-  },[postId]);
-  if(!postId) return <div className="empty">← 左边选一个帖子</div>;
-  if(!post) return <div className="empty">加载中…</div>;
-  const inner=(post.raw&&post.raw.data&&post.raw.data.data)||{};
-  const scr=post.screening||{};
-  const rawImgs=(inner.image_url_list||[]).map(x=>x.image_url||x);
-  // 统一卡片:优先 post.cards(含图/帧),旧数据回退 image_url_list
-  const cards=(post.cards&&post.cards.length)? post.cards
-    : rawImgs.map((u,i)=>({index:i+1,kind:"image",url:u}));
-  const cardMap={}; cards.forEach(c=>{cardMap[c.index]=c;});
-  return <div>
-    <Stage n="1" title="原文">
-      <a className="link" href={post.url} target="_blank" rel="noreferrer">🔗 打开原帖</a>
-      <p className="kv">作者 {inner.channel_account_name||"—"} · 类型 {inner.content_type||"—"} · {cards.length} 张卡片</p>
-      <div><b>{inner.title||"(无标题)"}</b></div>
-      <pre>{inner.body_text||"(正文为空,知识在卡片里)"}</pre>
-      <div className="imgs">{cards.map(c=> c.url
-        ? <span className="cardthumb" key={c.index}>
-            <img src={c.url} title={"卡片"+c.index} onClick={()=>onZoom(c.url)} onError={e=>e.target.style.display='none'}/>
-            <span className="cn">{c.index}{cardTime(c)?" · "+cardTime(c):""}</span></span>
-        : <span className="segchip" key={c.index}>片段{c.index}{cardTime(c)?" · "+cardTime(c):""}</span>
-      )}</div>
-    </Stage>
-
-    <Stage n="2" title="筛选" sub="判断这篇帖子值不值得提取成创作知识"
-      extra={prompts.screen && <PromptBtn label="提示词" onClick={()=>onPrompt('screen')}/>}>
-      <div className="crit">
-        <div className="t">筛选标准(4 条都满足才通过)</div>
-        <ul>
-          <li>内容不是空的</li>
-          <li>讲的是"怎么做内容"的方法或经验,不是一条作品本身(比如一段文案、一张图)</li>
-          <li>至少包含一种:是什么(What)/ 为什么(Why)/ 怎么做(How)</li>
-          <li>至少能拆出一条能用的创作知识</li>
-        </ul>
-      </div>
-      <div className="chips">
-        <Badge kind={scr.passed?"ok":"no"}>{scr.passed?"✓ 通过":"✕ 淘汰"}</Badge>
-        <Badge kind="ac">评分 {scr.score}/10</Badge>
+function RecordModal({post,onClose}){
+  const fw=post.framework||{};
+  const content=buildContent(fw);
+  const record={ title: fw.title, purpose: fw.purpose, content };
+  return <div className="ov" onClick={onClose}>
+    <div className="modal" onClick={e=>e.stopPropagation()}>
+      <h2>下游消费记录(这颗知识下发给「脚本构建 Agent」的样子)<span className="x" onClick={onClose}>×</span></h2>
+      <div className="mbody">
+        <div className="fk">title</div><div className="fv">{record.title}</div>
+        <div className="fk">purpose</div><div className="fv">{record.purpose}</div>
+        <div className="fk">content(拍平后的全文,下游逐字消费)</div>
+        <div className="cwrap"><pre>{content}</pre></div>
       </div>
-      <div className="kv" style={{marginTop:6}}>判断理由</div>
-      <div>{scr.reason||"—"}</div>
-    </Stage>
+    </div>
+  </div>;
+}
 
-    <Stage n="3" title={"知识点("+items.length+" 条)"}
-      sub="每条 = 知识类型 + What/Why/How + 对应阶段 + 作用域"
-      extra={<React.Fragment>
-        {prompts.split && <PromptBtn label="拆分提示词" onClick={()=>onPrompt('split')}/>}
-        {prompts.deconstruct && <PromptBtn label="解构提示词" onClick={()=>onPrompt('deconstruct')}/>}
-      </React.Fragment>}>
-      {items.length? items.map((it,i)=><KPCard key={i} it={it} idx={i+1} cardMap={cardMap} onZoom={onZoom}/>) : <div className="kv">无(被淘汰或未拆出)</div>}
-    </Stage>
+function PromptModal({onClose}){
+  return <div className="ov" onClick={onClose}>
+    <div className="modal" onClick={e=>e.stopPropagation()}>
+      <h2>这套知识是怎么提取出来的(提示词)<span className="x" onClick={onClose}>×</span></h2>
+      <div className="mbody"><pre>{PROMPT}</pre></div>
+    </div>
   </div>;
 }
 
@@ -281,18 +245,24 @@ function App(){
   const [posts,setPosts]=useState([]);
   const [sel,setSel]=useState(null);
   const [zoom,setZoom]=useState(null);
-  const [prompts,setPrompts]=useState({});
-  const [pm,setPm]=useState(null);
-  useEffect(()=>{ j("/api/posts").then(ps=>{setPosts(ps); if(ps[0])setSel(ps[0].id);}); },[]);
-  useEffect(()=>{ j("/api/prompts").then(r=>{const m={};(r.items||[]).forEach(it=>m[it.key]=it);setPrompts(m);}); },[]);
+  const [pm,setPm]=useState(false);
+  const [rec,setRec]=useState(null);
+  useEffect(()=>{ j("/frameworks.json").then(d=>{const ps=d.posts||[];setPosts(ps); if(ps[0])setSel(ps[0].post_id);}); },[]);
+  const post=posts.find(p=>p.post_id===sel);
   return <div>
-    <header>创作知识 <span className="sub">· 帖子知识流水线({posts.length} 帖)</span></header>
+    <header>
+      <h0>创作知识的搜索和解构</h0>
+      <span className="sub">· 一帖一颗完整框架({posts.length} 颗)· 已按下游消费格式呈现</span>
+      <button className="pbtn" onClick={()=>setPm(true)}>📄 提取提示词</button>
+    </header>
+    <Legend/>
     <div className="wrap">
       <PostList posts={posts} sel={sel} onSel={setSel}/>
-      <div className="main"><Pipeline postId={sel} onZoom={setZoom} prompts={prompts} onPrompt={setPm}/></div>
+      <div className="main">{posts.length? <Detail post={post} onZoom={setZoom} onRecord={setRec}/> : <div className="empty">加载中…</div>}</div>
     </div>
     {zoom&&<div className="lb" onClick={()=>setZoom(null)}><img src={zoom}/></div>}
-    {pm&&<PromptModal item={prompts[pm]} onClose={()=>setPm(null)}/>}
+    {pm&&<PromptModal onClose={()=>setPm(false)}/>}
+    {rec&&<RecordModal post={rec} onClose={()=>setRec(null)}/>}
   </div>;
 }
 ReactDOM.createRoot(document.getElementById("root")).render(<App/>);

+ 350 - 0
web/index_pipeline.html

@@ -0,0 +1,350 @@
+<!doctype html>
+<html lang="zh">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>创作知识 · 流水线可视化</title>
+<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
+<style>
+  :root{
+    --bg:#f3efe4;        /* 暖黄页面背景 */
+    --panel:#ffffff;     /* 白卡 */
+    --panel2:#faf7ef;    /* 略暖的内卡 */
+    --line:#e7e2d4;      /* 浅暖描边 */
+    --line2:#ece7d9;
+    --fg:#2c2c2a;        /* 近黑正文 */
+    --mut:#6f6e68;       /* 次要文字 */
+    --tert:#9a978d;      /* 提示文字 */
+    --ac:#185fa5;        /* 蓝 */
+    --what:#185fa5; --why:#534ab7; --how:#0f6e56;
+    --ok:#0f6e56; --no:#a32d2d; --warn:#854f0b;
+  }
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.6 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif}
+  header{padding:14px 20px;background:var(--panel);border-bottom:1px solid var(--line);font-weight:600;display:flex;gap:10px;align-items:center}
+  header .sub{color:var(--mut);font-weight:400;font-size:13px}
+  .wrap{display:flex;height:calc(100vh - 51px)}
+  .side{width:300px;background:var(--panel);border-right:1px solid var(--line);overflow:auto;flex:none}
+  .main{flex:1;overflow:auto;padding:20px}
+  .pitem{padding:12px 16px;border-bottom:1px solid var(--line);cursor:pointer}
+  .pitem:hover{background:#f7f3ea}
+  .pitem.sel{background:#eef4fb;border-left:3px solid var(--ac)}
+  .pitem .id{font-size:12px;color:var(--mut);word-break:break-all}
+  .pitem .row{display:flex;gap:8px;align-items:center;margin-top:4px}
+  .badge{display:inline-block;padding:1px 9px;border-radius:10px;font-size:12px;background:#efeadd;color:#5f5e5a}
+  .badge.ok{background:#e1f5ee;color:var(--ok)}
+  .badge.no{background:#fcebeb;color:var(--no)}
+  .badge.ac{background:#e6f1fb;color:var(--ac)}
+  .badge.warn{background:#faeeda;color:var(--warn)}
+  .plhead{position:sticky;top:0;background:var(--panel);border-bottom:1px solid var(--line);padding:8px 12px 8px;z-index:2}
+  .plsum{font-size:12px;color:var(--mut);margin:2px 2px 6px} .plsum b{color:var(--fg)}
+  .plf-g{display:flex;align-items:center;gap:4px;flex-wrap:wrap;margin:4px 0}
+  .plf-l{font-size:11px;color:var(--tert);width:28px;flex:none}
+  .plf{border:1px solid var(--line);background:#fff;border-radius:12px;padding:2px 9px;font-size:11.5px;cursor:pointer;font-family:inherit;color:var(--fg)}
+  .plf.on{background:#2c2c2a;color:#fff;border-color:#2c2c2a}
+  .pl-r1{display:flex;align-items:center;gap:6px}
+  .pbg{font-size:10.5px;font-weight:700;padding:1px 6px;border-radius:3px}
+  .pb-xhs{background:#ffe4e6;color:#be123c}.pb-dy{background:#e5e7eb;color:#111}.pb-ks{background:#ffedd5;color:#c2410c}.pb-bili{background:#fce7f3;color:#be185d}.pb-sph{background:#dcfce7;color:#15803d}.pb-x{background:#eee;color:#555}
+  .pl-ty{font-size:12px;color:var(--mut)}
+  .pl-dot{width:8px;height:8px;border-radius:50%;margin-left:auto}
+  .pl-dot.done{background:#16a34a}.pl-dot.skip{background:#d97706}.pl-dot.fail{background:#dc2626}.pl-dot.other{background:#cbd5e1}
+  .pl-ti{font-size:13px;font-weight:550;line-height:1.4;margin-top:3px}
+  .pl-mt{font-size:11px;color:var(--tert);margin-top:2px}
+  .stage{background:var(--panel);border:1px solid var(--line);border-radius:12px;margin-bottom:16px;overflow:hidden}
+  .stage>h3{margin:0;padding:11px 16px;background:#f7f3ea;border-bottom:1px solid var(--line);font-size:13px;font-weight:600;display:flex;gap:8px;align-items:center}
+  .stage>h3 .n{width:20px;height:20px;border-radius:50%;background:#3f86d6;color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px}
+  .stage .body{padding:14px 16px}
+  .kv{color:var(--mut);font-size:12px;margin:0 0 4px}
+  .imgs{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
+  .segwrap{display:flex;flex-direction:column;gap:3px;margin-top:4px}
+  .segvid{width:240px;max-width:100%;border-radius:6px;border:1px solid var(--line);background:#000}
+  .segcap{font-size:11px;color:var(--mut)}
+  .imgs img{width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--line);cursor:zoom-in;transition:transform .1s}
+  .imgs img:hover{transform:scale(1.05);border-color:var(--ac)}
+  .card{background:var(--panel2);border:1px solid var(--line2);border-radius:10px;padding:12px 14px;margin-bottom:10px}
+  .kptitle{margin:0;font-size:15px}
+  .part{margin-top:12px}
+  .part ~ .part{border-top:1px solid var(--line2);padding-top:12px}
+  .phead{font-size:12px;color:var(--mut);font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:8px}
+  .pn{display:inline-flex;width:18px;height:18px;border-radius:50%;background:#e6f1fb;color:#185fa5;align-items:center;justify-content:center;font-size:11px;flex:none}
+  .ktype{font-size:12px;padding:2px 9px;border-radius:8px;background:#e6f1fb;color:#0c447c;white-space:nowrap;margin-left:auto}
+  .chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:0}
+  pre{white-space:pre-wrap;word-break:break-word;background:#f6f2e8;border:1px solid var(--line);border-radius:6px;padding:10px;font-size:12px;color:#444441;margin:6px 0 0;max-height:260px;overflow:auto}
+  details summary{cursor:pointer;color:var(--mut);font-size:12px}
+  .whh{margin:8px 0;padding-left:10px;border-left:2px solid var(--line)}
+  .whh.w{border-color:var(--what)} .whh.y{border-color:var(--why)} .whh.h{border-color:var(--how)}
+  .whh b{font-size:12px}
+  .whh.w b{color:var(--what)} .whh.y b{color:var(--why)} .whh.h b{color:var(--how)}
+  .sect{border-top:1px solid var(--line2);margin-top:12px;padding-top:10px;display:flex;flex-direction:column;gap:10px}
+  .srow{display:flex;align-items:flex-start;gap:10px}
+  .slabel{font-size:13px;color:var(--mut);min-width:62px;flex:none;padding-top:2px}
+  .scope{display:flex;align-items:baseline;gap:8px}
+  .scn{font-size:12px;padding:2px 8px;border-radius:8px;flex:none}
+  .foot{border-top:1px solid var(--line2);margin-top:10px;padding-top:8px;display:flex;gap:18px;flex-wrap:wrap}
+  .link{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:#e6f1fb;border:1px solid #9cc3ee;border-radius:8px;color:var(--ac);text-decoration:none;font-size:13px;margin-bottom:10px}
+  .crit{background:var(--panel2);border:1px solid var(--line2);border-radius:8px;padding:10px 14px;margin-bottom:12px}
+  .crit .t{color:var(--mut);font-size:12px;margin-bottom:6px}
+  .crit ul{margin:0;padding-left:18px} .crit li{margin:3px 0;font-size:13px}
+  .lb{position:fixed;inset:0;background:rgba(0,0,0,.8);display:flex;align-items:center;justify-content:center;z-index:50;cursor:zoom-out}
+  .lb img{max-width:92vw;max-height:92vh;border-radius:8px}
+  a{color:var(--ac)}
+  .empty{color:var(--mut);padding:40px;text-align:center}
+  .sub2{color:var(--mut);font-size:12px;margin:-2px 0 8px}
+  .sx{margin-left:auto;display:flex;gap:6px}
+  .pbtn{font-size:12px;padding:2px 10px;border-radius:7px;background:#fff;border:1px solid var(--line);color:var(--mut);cursor:pointer;font-family:inherit}
+  .pbtn:hover{border-color:var(--ac);color:var(--ac)}
+  .pmodal{background:var(--panel);border:1px solid var(--line);border-radius:12px;max-width:760px;width:90vw;max-height:86vh;overflow:auto;padding:16px 18px;cursor:auto}
+  .pmhead{display:flex;align-items:center;gap:10px;margin-bottom:6px}
+  .pmhead b{font-size:15px}
+  .pmmodel{font-size:12px;color:var(--ac);background:#eef4fb;padding:1px 8px;border-radius:7px}
+  .pmx{margin-left:auto;cursor:pointer;color:var(--mut);font-size:16px;line-height:1}
+  .pmlabel{font-size:12px;color:var(--mut);font-weight:600;margin:12px 0 4px}
+  .pmnote{font-size:12px;color:var(--warn);background:#faeeda;padding:6px 10px;border-radius:6px;margin-top:4px}
+  .srcrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}
+  .srcrow .lbl{font-size:12px;color:var(--mut);flex:none}
+  .srcthumb{position:relative}
+  .srcthumb img{width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid var(--line);cursor:zoom-in}
+  .srcthumb img:hover{border-color:var(--ac)}
+  .srcthumb .cn{position:absolute;left:2px;bottom:2px;font-size:10px;background:rgba(0,0,0,.6);color:#fff;padding:0 4px;border-radius:4px}
+  .cardthumb{position:relative}
+  .cardthumb .cn{position:absolute;left:3px;bottom:3px;font-size:10px;background:rgba(0,0,0,.6);color:#fff;padding:0 5px;border-radius:4px}
+  .evrow{display:flex;gap:8px;align-items:baseline;margin:5px 0;font-size:13px}
+  .evcard{font-size:11px;padding:1px 7px;border-radius:6px;background:#e6f1fb;color:#185fa5;cursor:zoom-in;flex:none}
+  .segchip{display:inline-flex;align-items:center;font-size:12px;padding:4px 10px;border-radius:8px;background:#eef4fb;color:#185fa5;border:1px solid #cfe0f5;flex:none}
+</style>
+</head>
+<body>
+<div id="root"></div>
+<script type="text/babel">
+const {useState,useEffect} = React;
+const j = (u)=>fetch(u).then(r=>r.json());
+const SCOPE_CN = {substance:"实质",form:"形式",feeling:"感受",effect:"作用",intent:"意图"};
+const SCOPE_COLOR = {
+  substance:["#e6f1fb","#0c447c"], form:["#e1f5ee","#0f6e56"],
+  feeling:["#eeedfe","#3c3489"], effect:["#faeeda","#854f0b"],
+  intent:["#faece7","#993c1d"]};
+
+function Badge({kind,children}){return <span className={"badge "+(kind||"")}>{children}</span>;}
+
+const PLAT={xiaohongshu:["小红书","pb-xhs"],douyin:["抖音","pb-dy"],kuaishou:["快手","pb-ks"],bilibili:["B站","pb-bili"],shipinhao:["视频号","pb-sph"]};
+const statBucket=s=>s==="done"?"done":s==="skipped"?"skip":(s==="failed"||s==="rejected")?"fail":"other";
+function PostList({posts,sel,onSel}){
+  const [fp,setFp]=useState("all"),[fs,setFs]=useState("all");
+  const plats=[...new Set(posts.map(p=>p.platform))];
+  const shown=posts.filter(p=>(fp==="all"||p.platform===fp)&&(fs==="all"||statBucket(p.stage)===fs));
+  const Chip=({on,onClick,children})=><button className={"plf"+(on?" on":"")} onClick={onClick}>{children}</button>;
+  return <div className="side">
+    <div className="plhead">
+      <div className="plsum">显示 <b>{shown.length}</b> / {posts.length} 条</div>
+      <div className="plf-g"><span className="plf-l">平台</span>
+        <Chip on={fp==="all"} onClick={()=>setFp("all")}>全部</Chip>
+        {plats.map(p=><Chip key={p} on={fp===p} onClick={()=>setFp(p)}>{(PLAT[p]||[p])[0]}</Chip>)}</div>
+      <div className="plf-g"><span className="plf-l">状态</span>
+        <Chip on={fs==="all"} onClick={()=>setFs("all")}>全部</Chip>
+        {[["done","已完成"],["skip","已跳过"],["fail","失败"]].map(([v,l])=><Chip key={v} on={fs===v} onClick={()=>setFs(v)}>{l}</Chip>)}</div>
+    </div>
+    {shown.map(p=>{
+      const pb=PLAT[p.platform]||[p.platform,"pb-x"], st=statBucket(p.stage);
+      const vid=(p.content_type||"").toLowerCase()==="video";
+      return <div key={p.id} className={"pitem"+(sel===p.id?" sel":"")} onClick={()=>onSel(p.id)}>
+        <div className="pl-r1"><span className={"pbg "+pb[1]}>{pb[0]}</span>
+          <span className="pl-ty" title={vid?"视频":"图文"}>{vid?"▶":"▤"}</span>
+          <span className={"pl-dot "+st} title={p.stage}></span></div>
+        <div className="pl-ti">{p.title||p.id}</div>
+        <div className="pl-mt">{p.item_count} 个知识点 · {p.run||"—"}</div>
+      </div>;
+    })}
+  </div>;
+}
+
+function Stage({n,title,sub,extra,children}){
+  return <div className="stage"><h3><span className="n">{n}</span>{title}{extra&&<span className="sx">{extra}</span>}</h3>
+    <div className="body">{sub&&<div className="sub2">{sub}</div>}{children}</div></div>;
+}
+
+function PromptBtn({label,onClick}){
+  return <button className="pbtn" onClick={onClick}>📄 {label||"提示词"}</button>;
+}
+
+function PromptModal({item,onClose}){
+  if(!item) return null;
+  return <div className="lb" onClick={onClose}>
+    <div className="pmodal" onClick={e=>e.stopPropagation()}>
+      <div className="pmhead"><b>{item.label} · 提示词</b><span className="pmmodel">{item.model}</span><span className="pmx" onClick={onClose}>✕</span></div>
+      {item.note&&<div className="pmnote">{item.note}</div>}
+      <div className="pmlabel">System(角色设定)</div><pre>{item.system}</pre>
+      <div className="pmlabel">User(输入模板)</div><pre>{item.user}</pre>
+    </div>
+  </div>;
+}
+
+function fmtTs(s){s=Math.floor(s||0);return String(Math.floor(s/60)).padStart(2,'0')+":"+String(s%60).padStart(2,'0');}
+function cardTime(c){
+  if(!c) return "";
+  if(c.kind==="segment"&&c.start!=null) return fmtTs(c.start)+"–"+fmtTs(c.end);
+  if(c.kind==="frame"&&c.timestamp!=null) return fmtTs(c.timestamp);
+  return "";
+}
+// 段卡:图片用缩略图,视频段卡用内联 <video> 只播 [start,end] 区间
+const isSeg=c=>!!c&&c.kind==="segment";
+const segSrc=c=>c.url+"#t="+(c.start||0)+","+(c.end||0);
+function SegVideo({c}){
+  const end=c.end;
+  return <video className="segvid" src={segSrc(c)} controls preload="metadata"
+    onTimeUpdate={e=>{ if(end!=null && e.target.currentTime>=end) e.target.pause(); }}/>;
+}
+
+function KPCard({it,idx,cardMap,onZoom}){
+  const item=it.item||{}, deco=it.deconstruction||{}, payload=it.ingest_payload||{};
+  const types=(item.knowledge_types||[]).map(t=>t.toUpperCase()).join(" + ");
+  const stages=deco.stages||[], scopes=deco.scopes||[];
+  const srcCards=(item.source_cards||[]).map(n=>cardMap[n]).filter(Boolean);
+  const evidence=item.evidence||[];
+  return <div className="card">
+    <h4 className="kptitle">{idx}. {item.title||"(无标题)"}</h4>
+
+    {srcCards.length>0 && <div className="srcrow">
+      <span className="lbl">来源卡片</span>
+      {srcCards.map(c=> (c.url&&!isSeg(c))
+        ? <span className="srcthumb" key={c.index}>
+            <img src={c.url} title={"卡片"+c.index} onClick={()=>onZoom(c.url)} onError={e=>e.target.style.display='none'}/>
+            <span className="cn">{cardTime(c)||("卡片"+c.index)}</span></span>
+        : <span className="segchip" key={c.index}>片段{c.index}{cardTime(c)?" · "+cardTime(c):""}</span>
+      )}
+    </div>}
+
+    <div className="part">
+      <div className="phead"><span className="pn">1</span>知识类型拆解<span className="ktype">{types||"—"}</span></div>
+      {item.what&&<div className="whh w"><b>What · 是什么</b><div>{item.what}</div></div>}
+      {item.why&&<div className="whh y"><b>Why · 为什么</b><div>{item.why}</div></div>}
+      {item.how&&<div className="whh h"><b>How · 怎么做</b><div>{item.how}</div></div>}
+    </div>
+
+    <div className="part">
+      <div className="phead"><span className="pn">2</span>解构 · 阶段</div>
+      <div className="chips">
+        {stages.length? stages.map(s=><Badge key={s}>{s}</Badge>) : <span className="kv">—</span>}
+        {stages.length>1 && <span className="kv">({stages.length} 个阶段都 cover)</span>}
+      </div>
+    </div>
+
+    <div className="part">
+      <div className="phead"><span className="pn">3</span>解构 · 作用域</div>
+      <div style={{display:'flex',flexDirection:'column',gap:6}}>
+        {scopes.length? scopes.map((s,i)=>{
+          const c=SCOPE_COLOR[s.scope_type]||["#efeadd","#5f5e5a"];
+          return <div className="scope" key={i}>
+            <span className="scn" style={{background:c[0],color:c[1]}}>{SCOPE_CN[s.scope_type]||s.scope_type}</span>
+            <span>{s.value}</span></div>;
+        }) : <span className="kv">—</span>}
+      </div>
+    </div>
+
+    <div className="foot">
+      {evidence.length>0 &&
+        <details><summary>原文证据({evidence.length})</summary>
+          <div>{evidence.map((e,i)=>{
+            const text=typeof e==="string"?e:(e.text||"");
+            const card=(typeof e==="object"&&e)?e.card:null;
+            const c=card!=null?cardMap[card]:null;
+            return <div className="evrow" key={i}>
+              {c&&((c.url&&!isSeg(c))
+                ? <span className="evcard" onClick={()=>onZoom(c.url)}>卡片{card}</span>
+                : <span className="evcard" style={{cursor:"default"}} title="视频片段">片段{card}{cardTime(c)?" · "+cardTime(c):""}</span>)}
+              <span>{text}</span></div>;
+          })}</div>
+        </details>}
+      <details><summary>入库数据(ingest payload)</summary><pre>{JSON.stringify(payload,null,2)}</pre></details>
+    </div>
+  </div>;
+}
+
+function Pipeline({postId,onZoom,prompts,onPrompt}){
+  const [post,setPost]=useState(null);
+  const [items,setItems]=useState([]);
+  useEffect(()=>{ if(!postId)return; setPost(null);setItems([]);
+    j("/api/posts/"+postId).then(setPost);
+    j("/api/posts/"+postId+"/items").then(setItems);
+  },[postId]);
+  if(!postId) return <div className="empty">← 左边选一个帖子</div>;
+  if(!post) return <div className="empty">加载中…</div>;
+  const inner=(post.raw&&post.raw.data&&post.raw.data.data)||{};
+  const scr=post.screening||{};
+  const rawImgs=(inner.image_url_list||[]).map(x=>x.image_url||x);
+  // 统一卡片:优先 post.cards(含图/帧),旧数据回退 image_url_list
+  const cards=(post.cards&&post.cards.length)? post.cards
+    : rawImgs.map((u,i)=>({index:i+1,kind:"image",url:u}));
+  const cardMap={}; cards.forEach(c=>{cardMap[c.index]=c;});
+  return <div>
+    <Stage n="1" title="原文">
+      <a className="link" href={post.url} target="_blank" rel="noreferrer">🔗 打开原帖</a>
+      <p className="kv">作者 {inner.channel_account_name||"—"} · 类型 {inner.content_type||"—"} · {cards.length} 张卡片</p>
+      <div><b>{inner.title||"(无标题)"}</b></div>
+      <pre>{inner.body_text||"(正文为空,知识在卡片里)"}</pre>
+      <div className="imgs">{cards.map(c=>
+        (isSeg(c)&&c.url)
+        ? <div className="segwrap" key={c.index}>
+            <SegVideo c={c}/>
+            <span className="segcap">片段{c.index} · {cardTime(c)}</span></div>
+        : c.url
+        ? <span className="cardthumb" key={c.index}>
+            <img src={c.url} title={"卡片"+c.index} onClick={()=>onZoom(c.url)} onError={e=>e.target.style.display='none'}/>
+            <span className="cn">{c.index}{cardTime(c)?" · "+cardTime(c):""}</span></span>
+        : <span className="segchip" key={c.index}>片段{c.index}{cardTime(c)?" · "+cardTime(c):""}</span>
+      )}</div>
+    </Stage>
+
+    <Stage n="2" title="筛选" sub="判断这篇帖子值不值得提取成创作知识"
+      extra={prompts.screen && <PromptBtn label="提示词" onClick={()=>onPrompt('screen')}/>}>
+      <div className="crit">
+        <div className="t">筛选标准(4 条都满足才通过)</div>
+        <ul>
+          <li>内容不是空的</li>
+          <li>讲的是"怎么做内容"的方法或经验,不是一条作品本身(比如一段文案、一张图)</li>
+          <li>至少包含一种:是什么(What)/ 为什么(Why)/ 怎么做(How)</li>
+          <li>至少能拆出一条能用的创作知识</li>
+        </ul>
+      </div>
+      <div className="chips">
+        <Badge kind={scr.passed?"ok":"no"}>{scr.passed?"✓ 通过":"✕ 淘汰"}</Badge>
+        <Badge kind="ac">评分 {scr.score}/10</Badge>
+      </div>
+      <div className="kv" style={{marginTop:6}}>判断理由</div>
+      <div>{scr.reason||"—"}</div>
+    </Stage>
+
+    <Stage n="3" title={"知识点("+items.length+" 条)"}
+      sub="每条 = 知识类型 + What/Why/How + 对应阶段 + 作用域"
+      extra={<React.Fragment>
+        {prompts.split && <PromptBtn label="拆分提示词" onClick={()=>onPrompt('split')}/>}
+        {prompts.deconstruct && <PromptBtn label="解构提示词" onClick={()=>onPrompt('deconstruct')}/>}
+      </React.Fragment>}>
+      {items.length? items.map((it,i)=><KPCard key={i} it={it} idx={i+1} cardMap={cardMap} onZoom={onZoom}/>) : <div className="kv">无(被淘汰或未拆出)</div>}
+    </Stage>
+  </div>;
+}
+
+function App(){
+  const [posts,setPosts]=useState([]);
+  const [sel,setSel]=useState(null);
+  const [zoom,setZoom]=useState(null);
+  const [prompts,setPrompts]=useState({});
+  const [pm,setPm]=useState(null);
+  useEffect(()=>{ j("/api/posts").then(ps=>{setPosts(ps); if(ps[0])setSel(ps[0].id);}); },[]);
+  useEffect(()=>{ j("/api/prompts").then(r=>{const m={};(r.items||[]).forEach(it=>m[it.key]=it);setPrompts(m);}); },[]);
+  return <div>
+    <header>创作知识的搜索和解构</header>
+    <div className="wrap">
+      <PostList posts={posts} sel={sel} onSel={setSel}/>
+      <div className="main"><Pipeline postId={sel} onZoom={setZoom} prompts={prompts} onPrompt={setPm}/></div>
+    </div>
+    {zoom&&<div className="lb" onClick={()=>setZoom(null)}><img src={zoom}/></div>}
+    {pm&&<PromptModal item={prompts[pm]} onClose={()=>setPm(null)}/>}
+  </div>;
+}
+ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
+</script>
+</body>
+</html>

+ 9 - 0
创作知识-重构设计.md

@@ -479,6 +479,15 @@ for url in post_urls:
 - **抽帧降级**:`video_frames.py` 保留为可选缩略图/兜底,不接主流程。
 - **已知限制**:原生默认 1 FPS 采样,快速小字可能漏;段卡默认无缩略图(纯时间段)。
 
+### 10.4.1 媒体本地落盘 + 前端片段播放
+
+把**抖音 + 小红书**处理过的帖子的图片、视频**真实下载保存**到项目 `data/` 目录,前端从本地展示:图文帖显示真实图片(不再依赖平台图床、规避防盗链/过期)、视频段卡能**内联播放该时间段的片段**。
+
+- 存储按**运行批次**:`data/<run_id>/<platform>/<post_id>/`(`image_N.jpg` + `video.mp4`),`run_id` 默认时间戳、`cli run --run-id` 可指定;重跑生成新批次、保留历史。§2 案例媒体即落于此。
+- 播放:段卡共用整段 `video.mp4`,前端用 `#t=start,end` 只播该段,**不做 ffmpeg 切片、零转码**。
+- 工程上解耦:媒体逻辑由 `CK_DATA_DIR` 开关控制(默认开、置空即关),对提取/筛选/解构零影响。技术细节见技术架构 §12.1。
+- 范围:本轮仅抖音+小红书;不新建搜索爬取(沿用传入的帖子链接);B站/快手不在本轮。
+
 ### 10.5 卡片上限
 
 提取一次最多送 `MAX_CARDS`(默认 12,env `CK_MAX_CARDS` 可调)张卡片;超出截断并记日志(不静默丢卡)。解决了早期 `MAX_IMAGES=6` 漏卡的问题。

+ 202 - 0
创作知识拆解框架.md

@@ -0,0 +1,202 @@
+# 创作知识拆解框架
+
+## 目的
+
+本文件是研究如何从帖子或者视频里面,拆解出合理的"创作知识"。
+
+1. **创作知识**
+   是指能够指导用户按照知识的思路和顺序流程,去创作好一个内容框架的知识。
+2. **制作知识**
+   我们要把创作知识与"制作知识"区分开来。制作知识是指生产内容时的流程工序,或者是针对具体品类内容的特殊流程、环节及具体的制作步骤等。
+
+> 一句话:我们只收**指导"怎么想、怎么判断、怎么搭框架"**的知识(创作);剔除**指导"怎么执行、怎么操作、怎么做出来"**的知识(制作)。
+
+---
+
+## 一、一颗知识的粒度:一个完整创作框架
+
+**一颗知识 = 一个完整创作框架** = 一个 `purpose`(把某种输入一路做成一个可交付成品)+ 一条**不可跳步**的 `steps` 链。**一帖通常只出 1-2 颗。** 绝不把框架内部的步骤 / 要点拆成独立知识。
+
+- **判 step(不单列)**:①产出半成品 ②被同帖另一块当输入消费 ③拿走则本颗成品残缺。
+- **判独立颗**:①有自己的 purpose 句 ②产出能独立交付 ③脱离本框架换题材照样跑通。
+- **清单 / 导图型帖子**:一堆并列要素(N 选 1)是"一个 step 内的菜单",压进所属 step,不按项数拆颗。
+
+> 这条是我们踩过坑后收敛的:早期把一帖拆成 5-6 个碎片是错的,碎片其实是同一个框架内部的步骤 / directive 细节。
+
+---
+
+## 二、How / What / Why 的关系
+
+**How 是骨架,What / Why 多数是骨架上的"节点性质",不单独入库。**
+
+- 一颗 **How 知识 = 一个完整框架**(purpose + 有序 steps),是**顶层入库单位**。
+- 框架里每个**步骤 / 产出节点**自带一个类型:
+  - 产出"一个有**构成**的东西"(如 爆款标题 = 痛点 / 对比 / 爽感)→ **What**
+  - "**按步骤**做出某物" → **How**
+  - directive 里"**为什么这样选 / 原理 / 标准**" → **Why**
+- 所以 **What / Why 绝大多数时候活在 How 框架的 `content` 里**(某步的产出类型 + 指引里的判断依据),不单独成条。
+
+**例外(纯 What / 纯 Why 帖)**:有的帖子没有"怎么做"的工序,只是**列举 / 界定**(纯 What)或**只讲原理**(纯 Why)。这种没有 steps,套不进 purpose+steps,单独成一颗,用 §4.2 的**简化格式**。实践里大多数创作教学帖都能归到**一个 How 框架**。
+
+**一帖 → 多颗**:通常 1 帖 → 1 颗 How;偶尔含两条独立闭环 → 2 颗 How,或纯清单 / 纯原理 → 1 颗 What/Why。多颗共享同一个 `source.id`。
+
+---
+
+## 三、最终输出格式 = Ingest Payload
+
+知识最终**收敛成 `POST /api/v1/knowledge/ingest` 的请求体**(见《ingest-api.md》)。**How 知识的形态已定死**;What / Why 用简化版。
+
+### 3.1 How 知识 → 完整 IngestPayload
+
+| ingest 字段 | How 知识填什么 | 来源 |
+|---|---|---|
+| `source.id` | `xhs_<content_id>`(同帖多颗共享) | 帖子 |
+| `source.{source_type,title,author,source_metadata}` | post / 原帖标题 / 作者 / {platform,url,date} | 帖子 |
+| `title` | 框架名(如"撕裂共识选题框架") | 提取 |
+| `content` | **目标(=purpose)+ 步骤N(目的 / 指引 / 产出)拍平全文** | 提取(purpose+steps) |
+| `dim_creations` | `["创作"]`(写死) | — |
+| `dim_attributes` | `["how工序"]`(知识类型 How + 形态 工序) | 提取 |
+| `scopes` | `[{scope_type, value}]` 五棵树**带值**,整颗 = 各步 scope 值去重并集 | 提取(+回扣树) |
+| `custom_ext` | `{key:"业务阶段"}×N` + `{key:"创作阶段"}×N` + `{key:"动作"}×N`(创作阶段固定 5 词、动作每步提炼具体手法,str 多值) | 业务阶段 + 创作阶段 + 各步手法 |
+
+**示例(撕裂共识选题框架):**
+```jsonc
+{
+  "source": { "id":"xhs_699308fa0000000016009697", "source_type":"post",
+              "title":"真正能爆的选题…", "author":"…",
+              "source_metadata": { "platform":"小红书", "url":"…", "date":"…" } },
+  "title": "撕裂共识选题框架",
+  "content": "目标:按「列共识→定失灵场景→撕裂裂缝写选题→用羞耻感验收」4步…\n步骤1(目的:分析,锁定赛道里被反复重复的正确共识)\n  指引:…\n  产出:…\n步骤2…",
+  "dim_creations": ["创作"],
+  "dim_attributes": ["how工序"],
+  "scopes": [
+    { "scope_type":"substance", "value":"赛道共识" },
+    { "scope_type":"effect",    "value":"撕裂共识" },
+    { "scope_type":"feeling",   "value":"羞耻感/冒犯" },
+    { "scope_type":"intent",    "value":"引爆传播" }
+  ],
+  "custom_ext": [
+    { "key":"业务阶段", "type":"str", "value":"选题" },
+    { "key":"创作阶段", "type":"str", "value":"定向" },
+    { "key":"创作阶段", "type":"str", "value":"构思" },
+    { "key":"创作阶段", "type":"str", "value":"成文" },
+    { "key":"创作阶段", "type":"str", "value":"打磨" },
+    { "key":"动作", "type":"str", "value":"列赛道共识" },
+    { "key":"动作", "type":"str", "value":"定失灵场景" },
+    { "key":"动作", "type":"str", "value":"撕裂共识写选题" },
+    { "key":"动作", "type":"str", "value":"羞耻感验收" }
+  ]
+}
+```
+
+### 3.2 What / Why 知识 → 简化版 IngestPayload(本项目自定义)
+
+**用同一个 ingest 信封**,只有三处不同:①`content` 不是工序而是"构成 / 原理";②`dim_attributes` 换成 `what* / why*`;③`custom_ext` 只有 `业务阶段`(无 `创作阶段` / `动作`,因为没步骤)。`source / title / dim_creations / scopes` 都和 How 一样。
+
+| | What 知识 | Why 知识 |
+|---|---|---|
+| `content` | `界定:<一句话这是什么>` + `构成:- 要素1:… - 要素2:…` | `主张:<核心观点>` + `依据:<原理/标准/平台逻辑>` + `对创作的影响:<取舍含义>` |
+| `dim_attributes` | `["what构成"]`(或 what清单 / what界定) | `["why原理"]`(或 why标准 / why心法) |
+| `custom_ext` | `[{key:"业务阶段",…}]` | `[{key:"业务阶段",…}]` |
+| 其余 | 同 How(source/title/dim_creations=["创作"]/scopes 带值) | 同 How |
+
+**What 示例:**
+```jsonc
+{
+  "source": { "id":"xhs_…", "source_type":"post", "title":"…" },
+  "title": "短视频脚本三要素",
+  "content": "界定:一份短视频脚本的内容骨架由三类要素构成。\n构成:\n- 人物:谁出镜、出镜形象\n- 场景:室内 / 室外\n- 事件:整条脚本的故事内容",
+  "dim_creations": ["创作"],
+  "dim_attributes": ["what构成"],
+  "scopes": [ {"scope_type":"substance","value":"脚本要素"}, {"scope_type":"form","value":"分镜骨架"} ],
+  "custom_ext": [ {"key":"业务阶段","type":"str","value":"脚本"} ]
+}
+```
+
+**Why 示例:**
+```jsonc
+{
+  "source": { "id":"xhs_…", "source_type":"post", "title":"…" },
+  "title": "标题决定打开,与内容质量无关",
+  "content": "主张:标题的唯一作用是让人有欲望点开;点不点开,和内容好不好没关系。\n依据:信息流里用户先看到的是标题,内容再好没人点开等于零(平台分发逻辑)。\n对创作的影响:把"起标题"当成独立的、第一优先的创作决策,而非内容概括。",
+  "dim_creations": ["创作"],
+  "dim_attributes": ["why原理"],
+  "scopes": [ {"scope_type":"effect","value":"吸引点击"}, {"scope_type":"intent","value":"提升打开率"} ],
+  "custom_ext": [ {"key":"业务阶段","type":"str","value":"选题"} ]
+}
+```
+
+---
+
+## 四、分类维度与归属(谁定的 / 落到 ingest 哪)
+
+| 维度 | 取值 | 谁定的 | 落到 ingest |
+|---|---|---|---|
+| **知识类型** | What / Why / How | 下游系统(means_knowledge) | `dim_attributes`(how工序 / what构成 / why原理) |
+| **业务阶段** | 灵感 / 选题 / 脚本(固定 3) | **你** | `custom_ext`(key=业务阶段,多值) |
+| **创作阶段** | 定向 / 构思 / 结构 / 成文 / 打磨(固定 5) | **建议(可改)** | `custom_ext`(key=创作阶段,每步一个,多值) |
+| **作用域** | 实质 / 形式 / 意图 / 作用 / 感受(**带值**) | **你**(5 棵分类树) | `scopes`(scope_type + value) |
+| **创作 / 制作** | 写死 **创作** | 项目共识 | `dim_creations`(恒 `["创作"]`) |
+| **动作(手法)** | **开放 · 从帖子内容提炼的具体创作手法**(撕裂共识 / 塑三维人物 / 套三幕结构…),不锁词表 | **你**(决定放开) | `custom_ext`(key=动作,str 多值;ingest 自动生成语义向量 → 可语义检索,聚成"手法库") |
+
+**业务阶段判断标准(你定,固定 3):** 灵感 = 帮助发现方向 / 素材 / 切口 / 洞察;选题 = 帮助判断写什么 / 拍什么 / 从哪个角度切入(标题也算选题);脚本 = 帮助组织表达顺序 / 文案 / 镜头 / 结构。整颗框架常跨业务阶段,可多值。
+
+**创作阶段判断标准(固定 5,建议版):** 定向 = 定主题 / 方向 / 受众;构思 = 生成核心创意 / 钩子 / 角度 / 冲突;结构 = 搭骨架 / 大纲 / 顺序;成文 = 把结构写成具体文字 / 镜头;打磨 = 修改 / 精炼 / 优化 / 定稿。**逐步标**(每个 step 一个),与「业务阶段」分工:业务阶段是粗的业务环节,创作阶段是框架内每步的细工序位。**固定不开放**——开放留给「动作」(动作=具体招式、创作阶段=粗类别,两轴互补)。
+
+**作用域 5 类判别(带值时各给一个具体词):** 实质=讲什么 / 形式=怎么呈现 / 感受=勾什么情绪 / 作用=起什么表达功能 / 意图=创作者图什么。**值要回扣到 5 棵树的现有节点(对得上就复用,对不上才新建)**,避免同义词污染(详见下一步"提取流程")。
+
+**动作(手法)为什么放开:** 创作的价值就在那个"具体招式"(撕裂共识 / 凿裂缝 / 塑三维人物),锁成抽象 5 词(决策/撰写…)会把最有信息量的东西抹掉。所以 `动作` 从帖子内容提炼、用帖子自己的语言;ingest 对 str 自动 embed,等于建一个可语义检索的"创作手法库"。代价是失去精确 enum 过滤——但对手法,语义搜 > 精确筛,划算("在哪步、干什么"的导航由 `业务阶段` + `创作阶段` + `intent` 已覆盖)。
+
+---
+
+## 五、拆解原则:创作 vs 制作的边界
+
+**收**(创作知识):该类内容普适的创作路径 / 方法 / 工序、垂类技巧、形式偏好与禁忌——能一步步指导创作、且**产出因人而异**。
+
+**剔除**:
+- **制作知识 = 纯工艺执行**——器材 / 光圈ISO / 打光 / 收音设备 / 剪辑软件操作 / 调色参数 / 导出压制("拿着方案去操作设备软件实现它")。如 §一 的「即梦 · 指定姿势」示例。
+- **具体素材 / 案例**——"此类内容具体讲什么"的内容本身。
+- **过于宽泛**——如"内容要有吸引力",无法约束某个具体创作决策。
+
+**关键边界(按"设计决策 vs 工艺执行"判,不看词面):** 景别 / 角度 / 运镜 / 构图 / 字幕 / 配乐 / 时长 这些**名字像制作、实为"把创意翻译成镜头语言"的设计决策**,写在脚本 / 分镜表上 → **保留为框架内 step**;只有"拿方案操作设备软件"那层才剔除。
+
+<details><summary>典型制作知识示例(对照用 · 即梦人偶姿势,原始 JSON)</summary>
+
+**例:指定姿势·人偶姿势**——用「即梦」把 人物参考图 + 3D人偶姿势图 + 背景图 + 一段写死提示词,生成指定姿势成品图。绑定具体工具 + 写死物料 + 产出收敛 → 制作,剔除。
+
+```json
+{
+  "id": "p4", "name": "指定姿势·人偶姿势",
+  "purpose": "以人物参考图+3D人偶姿势图+背景图三图联合输入,精准控制人物姿态与场景合成",
+  "category": "产物创造",
+  "steps": [ { "id":"s1", "via":"即梦", "action":"生成/元素生成", "effect":"主体生成",
+    "directive":"大透视图角度,人物比例合理", "substance":"人物", "form":"写实、低饱和、自然光" } ],
+  "tools_used": ["即梦"]
+}
+```
+</details>
+
+---
+
+## 六、下游怎么消费(背景 · 与我们打标无关)
+
+提炼出的知识进库后,由**脚本构建主 Agent 的「Knowledge」取数 subagent** 检索使用(只读、给候选、不写创作表)。主 Agent 构建脚本创作表分阶段:
+
+```
+起步(全局) → 产生脉络(段落骨架/主维度) → 产生元素(子元素/从维度) → 将元素填入脉络(full_description)
+```
+
+> ⚠️ 这里的"全局 / 产生脉络 / 产生元素 / 填入脉络"是**消费侧的 scope**(这条知识在脚本构建的哪个阶段有用),**和我们提取时打标的"作用域(实质/形式/感受/作用/意图,5 棵树)是两回事**,别混。下游消费时会逐条照录 step 的 intent / directive,不省略、不改写、不补具体场景。
+
+---
+
+## 七、下一步:怎么一道流程把它提取准(待设计)
+
+提准的杠杆(按性价比):
+1. **回扣分类树(scopes 值的命门)**:判 scope 值时先从对应树检索候选节点,"对得上用现有、对不上才新建",复用 Pattern 回扣 / match-paths 基建,避免造同义词污染 5 棵树。
+2. **Few-shot 金标**:给提示词一个"创作知识 → 完整 IngestPayload"的样例(API 文档只有制作例子)。
+3. **拆成聚焦小步**:①出框架+步骤(content) → ②创作/制作筛 → ③类型/阶段标 → ④scopes 值(带回扣)。避免一个大提示词全干(曾因复杂 schema 死循环)。
+4. **受控词表 enum**:业务阶段 3 选 / 创作阶段 5 选 / scope_type 5 选 / dim_creations 写死,消灭乱标。(`动作` 例外——不锁,从内容提炼具体手法,靠语义向量检索)
+5. **自检 / 对抗一遍**:查粒度(1 颗非碎)、directive 忠实(不编原文没有的例子)、scope 值合理。
+
+*本文件为创作知识拆解 + 入库的设计依据,后续提示词与提取流程以此为准。*

+ 8 - 0
技术文档/开发顺序.md

@@ -124,6 +124,14 @@ tests/{test_crawler,test_extractor,test_stages,test_pipeline_e2e}.py
 - **验证**:`tests/test_video_extract.py`(注入假响应断言段卡 start/end + 请求含 base64 video_url);真机用本地 mp4 注入跑通 extract→split→落库。
 - **限制**:1 FPS 采样快速小字可能漏;段卡默认无缩略图。
 
+### M12 · 媒体本地落盘 + 前端真实展示 ✅(已实现,抖音+小红书)
+- **文件**:新增 `creation_knowledge/media.py`(run_id/media_target/save_images);`video_extract.py`(extract_video 加 save_path/public_url 可选参,整段落盘 + 段卡 url);`pipeline.py`(run_id + 默认 dispatch 接媒体,`CK_DATA_DIR` 开关);`cli.py`(--run-id);`config.py`(data_dir);`api.py`(/data 静态挂载);`web/index.html`(段卡内联 `<video #t>`、图片走本地);`.gitignore`(data/ runtime/)。
+- **布局**:`data/<run_id>/<platform>/<post_id>/`(image_N.jpg + video.mp4,按运行批次)。
+- **播放**:段卡共用整段 mp4,前端 `#t=start,end` 只播该段,不切片、零转码。
+- **解耦**:`settings.data_dir` 非空才触发;注入式 extract_fn 不触发;DB/提取/筛选/解构零改动。
+- **验证**:`tests/test_media.py`(save_images 注入下载器断言落盘 + url 改写);`test_video_extract.py` 加 save_path 用例;`test_pipeline_e2e.py` 加 data_dir e2e;真机重跑 5 小红书 + 1 抖音,`data/` 出真图真视频、前端图文显示 + 抖音段卡可播。
+- **范围/非目标**:仅抖音+小红书;不建搜索爬取;不做 ffmpeg 切片;不上 OSS;不加 DB run_id 列(run 在路径里)。
+
 ## 3. 可延展点(现在不做,留好接口)
 
 - **多平台**:`fetch_post_detail` 按 `platform` 分发不同 crawler path,下游环节不动。

+ 11 - 1
技术文档/技术架构.md

@@ -218,7 +218,7 @@ FastAPI(`creation_knowledge/api.py`),`uvicorn creation_knowledge.api:app`
 
 把图文每张图、视频每帧统一成「卡片」(1-based),每条知识溯源到来源卡片。详见业务设计 §10。
 
-- 卡片三种 `kind`:`image`(图文每张图)/ `frame`(抽帧,已降级)/ `segment`(原生视频的时间段,带 `start`/`end` 秒、`url` 可空)
+- 卡片三种 `kind`:`image`(图文每张图)/ `frame`(抽帧,已降级)/ `segment`(原生视频的时间段,带 `start`/`end` 秒)。`url`:开启媒体落盘后(§12.1)image 卡指向本地 `/data/.../image_N.jpg`、segment 卡指向整段 `/data/.../video.mp4`;未开启时 image 卡为平台 CDN URL、segment 卡为空
 - 数据模型:`Post.cards: [Card{index,kind,url?,timestamp?,start?,end?}]`;`ExtractedContent.cards: [{index,content}]`;`KnowledgeItem.source_cards: [int]`;`evidence: [{text, card}]`。
 - 归因在提取步:图文给每图打 `【卡片N】` 标签按卡输出;视频原生提炼时模型按时间段输出(每段=一张段卡);`split` 据此填 source_cards + evidence.card。视频的溯源即"溯源到 02:07–03:43 这一段"。
 - 存储:`ck_post` 加 `cards jsonb` 列(迁移 `ALTER TABLE … ADD COLUMN IF NOT EXISTS cards jsonb`);source_cards/evidence 在 `ck_knowledge_item.item` JSONB 内,无需改列。
@@ -236,3 +236,13 @@ FastAPI(`creation_knowledge/api.py`),`uvicorn creation_knowledge.api:app`
 - 网络(实测,开发机 47.245.103.121 新加坡):下载抖音 CDN(douyinvod.com)、调 OpenRouter/Gemini、连 RDS、调 crawler **均通** → 运行期"下载→提炼→落库"可全在这台云端一条龙(已真机在线跑通)。唯一不通的是内网 GitLab(仅构建期 rsync 取代码用,不影响运行)。下载仍做成可注入/可传本地文件,便于将来换平台/换网络环境。其他平台(B站/快手/视频号)视频 CDN 未逐个实测。
 - 抽帧 `integrations/video_frames.py` **保留但不接主流程**,仅作可选缩略图/兜底。
 - 已知限制:原生视频默认 1 FPS 采样,快速小字可能漏(口播教程无碍);段卡默认无缩略图(纯时间段)。`MAX_CARDS` 仅约束图片卡,不限视频段数。
+
+## 12.1 媒体本地存储(local media)
+
+把图文帖的图片、视频帖的整段 mp4 **真实下载落盘**到项目内 `data/`,前端从本地加载(图片不再依赖平台图床/防盗链,视频段卡可内联播放)。**仅抖音 + 小红书**接入;B站/快手不在本轮。
+
+- 模块 `creation_knowledge/media.py`:`run_id()` 时间戳批次;`media_target()` 算目录 + `/data` 公网前缀;`save_images()` 下载图文每张图→改写 `card.url` 为本地路径(按文件头嗅探扩展名,单张失败只告警跳过)。
+- 目录布局(**按运行批次**):`data/<run_id>/<platform>/<post_id>/`,下放 `image_<idx>.jpg` 与 `video.mp4`。`run_id` 默认时间戳,可 `cli run --run-id` 覆盖;重跑生成新批次目录、保留历史,DB 里 `card.url` 指向最新一次。
+- 视频落盘在 `extract_video`(复用其内存字节,单次下载,避免抖音链接过期):`save_path`/`public_url` 两个**默认 None** 的可选参数;段卡 `url=/data/.../video.mp4`,整段共用,前端按 `start/end` 加 `#t=start,end` 只播该片段(HTML5 Media Fragments,零转码,抖音/小红书均 H.264/AAC)。
+- 接线点:`pipeline.run_pipeline` 默认 dispatch 闭包,仅当 `settings.data_dir` 非空时触发(`CK_DATA_DIR` 默认 `data`;置空=关闭,**解耦开关**,注入式 `extract_fn` 不触发)。DB 写入零改动(`Card.url` 经现有 `upsert_post` 落 `cards` JSONB)。
+- 服务:`api.py` 加 `/data` 静态挂载(同 `/frames`)。`data/`、`runtime/` 已 gitignore。

+ 7 - 0
数据接口与来源/00_数据接口总览.md

@@ -742,6 +742,13 @@ Platform 阶段回答:同一个 Query 在平台上用什么动作执行,返
 
 本节记录 2026-06-11 对 `crawler.aiddit.com` 10 个平台接口的真实抓包实测。机器可读台账见 [crawler_endpoints.registry.json](crawler_endpoints.registry.json)(接口级,共 26 条 endpoint),原始 capture 在 `/tmp/aiddit_captures/`。所有接口共用 `CONTENTFIND_API_CRAWAPI_BASE_URL`,限流间隔 ≥15s。
 
+2026-06-17 追加快手/视频号 URL 解包与 OSS v2 批量验证:完整 raw 和临时 URL 只保存在 gitignore 下的 `data/platform_url_probe/20260617_115219/`,提交文档只保留脱敏摘要。
+
+- 快手:10 个 query,60 条 item,50 条 selected 正片 URL;selected 来源为 detail/search 各 25 条。20 条 selected URL 轻量验证均为 `video/mp4` 且 HTTP 200/206;no-referer 调 OSS v2 后 20/20 成功。
+- 视频号:10 个 query,18 页,46 条 item,41 条 selected 正片 URL;selected 全部来自 `$.search.video_url_list[0].video_url`,host 为 `findermp.video.qq.com`。20 条 selected URL 轻量验证均为 `video/mp4` 且 HTTP 200/206;no-referer 调 OSS v2 后 20/20 成功。
+- 两个平台使用生产当前 payload(把 `referer` dict 传给 OSS v2)时均 20/20 `oss_upload_response_invalid`;抽样 body 显示 `status=10000`、`oss_object=null`,错误包含 `dict object has no attribute encode`。去掉 `referer` 字段后同一批 selected URL 成功转存,因此历史 invalid 更像 OSS v2 payload 兼容问题,不是 URL 字段拆错。
+- URL 解包规则必须先做候选分类和轻量验证,不能再把 `video_url_list[0]` 当唯一事实;快手推荐 detail 正片优先、search fallback,视频号推荐 findermp 正片优先,同时过滤 image/avatar/BGM/page/ad/material。
+
 状态标签:✅=本批实测 code=0 可用;⛔=失败/超时/不可达(附错误码);—=本平台无该能力或本批未抓到独立接口。
 
 ### 9.1 平台能力矩阵

+ 42 - 0
数据接口与来源/embedding接口.md

@@ -0,0 +1,42 @@
+# Embedding(文本向量化)接口 · 实测
+
+> 实测可用:**2026-06-17**。用途:「作用域回扣」(scope-link)——把 5 棵分类树节点 + 提取出的候选作用域值向量化,做余弦最近邻对齐(对得上复用现有节点原名、对不上新建)。
+
+## 服务
+- 提供方:**火山方舟(Volcengine Ark)**
+- 模型:**Doubao-embedding-vision**(多模态向量化,支持文本 / 图片;本项目只用文本)
+- 接入方式:预置推理接入点(用 **ep-id** 调用,系统自动匹配预置服务)
+
+## 调用契约
+
+| 项 | 值 |
+|---|---|
+| Endpoint | `POST https://ark.cn-beijing.volces.com/api/v3/embeddings/multimodal` |
+| Auth | `Authorization: Bearer <ARK_API_KEY>`(密钥存 `.env`,**不入库 / 不入文档**) |
+| `model` | `ep-20260617180207-vmwg6` |
+| 输入 | `{"model":"ep-...","input":[{"type":"text","text":"<文本>"}]}` |
+| 输出 | `data.embedding` = 长度 **2048** 的浮点向量 |
+
+请求示例:
+```bash
+curl https://ark.cn-beijing.volces.com/api/v3/embeddings/multimodal \
+  -H "Authorization: Bearer $ARK_API_KEY" -H "Content-Type: application/json" \
+  -d '{"model":"ep-20260617180207-vmwg6","input":[{"type":"text","text":"撕裂共识"}]}'
+```
+
+## 关键坑(实测)
+- **vision 版必须走 `/embeddings/multimodal`**;标准 `/api/v3/embeddings`(input 为字符串数组)会返回 `InvalidParameter`。
+- 多模态接口是**单条调用**(一次请求 = 一个向量),批量需多次请求(可并发)。
+- 火山方舟用「**预置推理接入点**」即可(用模型 ID / ep-id 直接调,无需自建模型接入点);模型必须先在该 key 所属**项目**开通。
+- 图片向量化:把 input 项换成 `{"type":"image_url","image_url":{"url":"<url 或 data url>"}}`(同一接口,本项目暂不用)。
+
+## env(密钥放 .env,gitignore)
+```
+ARK_API_KEY=<火山方舟 API Key>
+ARK_EMBEDDING_EP=ep-20260617180207-vmwg6
+ARK_EMBEDDING_URL=https://ark.cn-beijing.volces.com/api/v3/embeddings/multimodal
+ARK_EMBEDDING_DIM=2048
+```
+
+## 在本项目的用途
+`scope-link`:① 一次性把 global_category(5 棵树 ~5000 节点)的节点名向量化、缓存成本地 `.npy`;② 提取出候选作用域值时即时向量化 → 与缓存做余弦最近邻 → 取 top-K 交模型判定「对齐现有 or 新建」。规模小(~20MB),**内存 + numpy 暴力最近邻即可,无需向量数据库**。

+ 18 - 2
数据接口与来源/walk_policy.json

@@ -7,8 +7,7 @@
     "max_total_actions_per_run": { "value": 60, "provenance": "拍板 2026-06-11:沿用 v1 各边合计上限 60 作全局闸", "tbd": false },
     "max_depth": { "value": 3, "provenance": "v1 各边 max_depth=3", "tbd": false },
     "max_reseed_rounds": { "value": 1, "provenance": "拍板 2026-06-11:单层回灌(tag→query 不二次回灌),v1 等价", "tbd": false },
-    "gemini_calls_per_run_cap": { "value": 200, "provenance": "拍板 2026-06-12:单 run 上限约 $4.4(@$0.022/条),远高于典型 N≈20-40,封顶失控;M7 真负标定", "tbd": false },
-    "gemini_max_workers": { "value": 4, "provenance": "拍板 2026-06-12:判定批并发度(IO 密集,不经 crawapi 限流);M7 实测调", "tbd": false }
+    "gemini_max_workers": { "value": 2, "provenance": "拍板 2026-06-16:4核8G服务器资源收敛;判定总并发2,ffmpeg随单条链路自然不超过2", "tbd": false }
   },
   "edge_budgets": [
     { "edge_id": "query_next_page",  "max_total_actions": 3,  "max_per_query": 1,    "max_pages": null, "provenance": "对齐 v1 实际硬限:rows[:3] 每 run ≤3 条翻页 query,每 query 仅 page_002 一跳;原理想值(30/2/3)留 M7 标定" },
@@ -26,8 +25,25 @@
     "_provenance": "现状行为来自 walk_engine v1 + E2E 实测(KEEP 内容 author_to_works success(low_budget));M4 拍板 2026-06-11",
     "ADD_TO_CONTENT_POOL":     { "author_to_works": "allow",            "video_to_hashtag": "allow", "decision_to_asset": "allow" },
     "KEEP_CONTENT_FOR_REVIEW": { "author_to_works": "allow_low_budget", "video_to_hashtag": { "value": "deny", "tbd": false, "note": "拍板 2026-06-11:从严 deny(v1 等价,防漂移)" }, "decision_to_asset": "deny" },
+    "TECHNICAL_RETRY_REQUIRED": { "author_to_works": "deny", "video_to_hashtag": "deny", "decision_to_asset": "deny", "note": "技术链路失败不触发业务游走或资产沉淀" },
     "REJECT_CONTENT":          { "author_to_works": "deny", "video_to_hashtag": "deny", "decision_to_asset": "deny", "note": "即 path_stop:rule_blocked/failed 不开任何出边" }
   },
+  "v4_walk_gate": {
+    "requires_allow_walk": true,
+    "source_field": "rule_decisions.jsonl[].decision_replay_data.allow_walk",
+    "deny_reason_code": "v4_allow_walk_denied",
+    "applies_to_edges": [
+      "query_next_page",
+      "hashtag_to_query",
+      "author_to_works"
+    ],
+    "raw_payload_fields": [
+      "decision_id",
+      "allow_walk",
+      "allow_walk_reason",
+      "walk_gate_snapshot"
+    ]
+  },
   "reseed_quality_gate": {
     "rule": "回灌边(reseed)仅从 gate=keep_only 放行的内容采集种子;V3 下 keep_only 的判据 = 判定 decision_action 经 edge_permissions 查表",
     "tag_quality": "仅强相关 tag 可扩散(沿用 hashtag node 现有约束);弱平台(快手 topic_list 常空、youtube 仅正文正则)允许正则兜底但同样过质量门"

+ 128 - 0
数据接口与来源/快手视频号_URL解包与OSS验证报告_20260617.md

@@ -0,0 +1,128 @@
+# 快手/视频号 URL 解包与 OSS 验证报告(2026-06-17)
+
+## 1. 目标与边界
+
+本轮目标是先把快手/视频号搜索与详情回包里的 URL 结构解析清楚,再用 OSS v2 批量验证“推荐规则选中的正片 URL 是否真的可归档”。本轮不修改生产 `play_url` 抽取逻辑,只做事实核验、交叉验证、诊断工具和文档更新。
+
+完整原始响应、临时 URL、OSS 原始响应只保存在 gitignore 下的 `data/platform_url_probe/20260617_115219/`。本文只记录脱敏摘要、字段路径、host、HTTP 状态、Range 状态和统计。
+
+## 2. Sub-agent 交叉结论
+
+### 需求审计
+
+- 本轮先结构解析,再对 selected URL 做 OSS v2 批量验证。
+- 历史 run 固定为快手 `v1_run_94448252f5cd`、视频号 `v1_run_35f4f7ccd1cc`。
+- 视频号也要扫描广告/素材 MP4,不能默认只有快手有广告素材。
+- 本轮不修改生产 `play_url` 抽取逻辑,不提交完整临时 URL。
+
+### 代码审计
+
+- 快手当前生产抽取:`content_agent/integrations/kuaishou.py` 的 `_extract_play_url()` 取 `video_url_list[0].video_url`。
+- 视频号当前生产抽取:`content_agent/integrations/shipinhao.py` 取 `video_url_list[0].video_url`。
+- builder 只把 client 归一化后的 `play_url` 写入 media record。
+- 现有 `platform_raw_payload` 只保留 `channel_content_id/channel_account_id`,不能完整反查原始字段路径。
+- OSS 当前会把 `play_url` 传给 `oss_upload.upload_video_from_env()`;Gemini 优先用 `oss_url`,否则 fallback 到 `play_url`。
+
+### 历史 run 审计
+
+- 快手 `TECHNICAL_RETRY_REQUIRED=14`:8 条 `no_play_url`,6 条 Gemini/OpenRouter 错误;技术重试直接原因不是 OSS。
+- 视频号 `TECHNICAL_RETRY_REQUIRED=6`:决策层直接原因是 Gemini/OpenRouter;但 30 条 media 全部为 `oss_upload_pending + oss_upload_response_invalid`。
+- 两个历史 run 都不能从 runtime 反查 selected URL 的原始字段路径。
+
+### 文档/台账审计
+
+- 旧台账说明快手/视频号直链可下载,但没有把“直链可下载”“Gemini 可下载”“OSS 可转存”拆开记录。
+- 快手文档建议下载带 Range/Referer/UA,但当前 `video_fetch` 快手默认头与文档不完全一致。
+- 视频号直链需 `Referer: https://channels.weixin.qq.com/` 用于本机下载;OSS v2 是否接受 headers/referer 需要单独验证。
+
+## 3. 历史 run 对照
+
+### 快手 `v1_run_94448252f5cd`
+
+- 决策统计:`ADD_TO_CONTENT_POOL=8`、`KEEP_CONTENT_FOR_REVIEW=9`、`REJECT_CONTENT=6`、`TECHNICAL_RETRY_REQUIRED=14`。
+- 媒体状态:`oss_uploaded=29`、`unavailable=8`。
+- OSS/媒体错误:`oss_upload_http_error=1`、`no_play_url=8`。
+- play_url host:`cc1.xyydnode.com=7`、`v5.oskwai.com=5`、`v4.kwaicdn.com=8`、`v23-3.kwaicdn.com=9`。
+- 字段路径证据不足:历史 runtime 无完整上游 raw item。
+
+### 视频号 `v1_run_35f4f7ccd1cc`
+
+- 决策统计:`ADD_TO_CONTENT_POOL=10`、`KEEP_CONTENT_FOR_REVIEW=5`、`REJECT_CONTENT=9`、`TECHNICAL_RETRY_REQUIRED=6`。
+- 媒体状态:`oss_upload_pending=30`。
+- OSS/媒体错误:`oss_upload_response_invalid=30`。
+- play_url host:`findermp.video.qq.com=30`。
+- 字段路径证据不足:历史 runtime 无完整上游 raw item。
+
+## 4. 真实接口结构探测
+
+### 快手
+
+- 真实搜索 query:10 个。
+- 搜索 item 样本:60 条。
+- URL 候选统计:`page=89`、`image=80`、`video_candidate=75`、`audio=1`。
+- selected URL:50 条。
+- selected 路径:`$.detail.video_url_list[0].video_url=25`、`$.search.video_url_list[0].video_url=25`。
+- selected host:`tymov2.a.kwimgs.com=17`、`hwmov.a.yximgs.com=7`、`v23-3.kwaicdn.com=5`、`v2.oskwai.com=6`、`ucmov.a.kwimgs.com=5`、`dxsc.oppass.cn=4`、`v4.kwaicdn.com=3`、`cc1.xyydnode.com=2`、`v1.oskwai.com=1`。
+
+### 视频号
+
+- 真实搜索 query:10 个。
+- 搜索页数:18 页。
+- 搜索 item 样本:46 条。
+- URL 候选统计:`image=95`、`video_candidate=41`、`audio=2`。
+- selected URL:41 条。
+- selected 路径:`$.search.video_url_list[0].video_url=41`。
+- selected host:`findermp.video.qq.com=41`。
+- 本轮保留 25011 retry:例如 `怀旧经典歌曲` page1 前两次 25011,第三次成功;`怀旧金曲` 重试后仍 25011,最终 item_count=0。
+
+## 5. OSS v2 批量验证
+
+### 第一组:生产当前 payload 形态
+
+生产当前链路会把 `referer` 作为 dict 字段传给 OSS v2。
+
+- 快手:20 条 selected URL 均 HTTP 200,但没有 `oss_object.cdn_url`,分类为 `oss_upload_response_invalid`。
+- 视频号:20 条 selected URL 均 HTTP 200,但没有 `oss_object.cdn_url`,分类为 `oss_upload_response_invalid`。
+- 抽样复打快手 selected URL 时,OSS body 为 `status=10000`、`oss_object=null`,错误包含 `dict object has no attribute encode`。
+
+### 第二组:no-referer payload 复验
+
+同一批 raw 样本重新抽 selected URL,去掉 `referer` 字段后调用 OSS v2,并发 2。
+
+- 快手:20/20 成功返回 `oss_object.cdn_url`。耗时约 0.891s 到 119.923s。
+- 视频号:20/20 成功返回 `oss_object.cdn_url`。耗时约 1.078s 到 19.248s。
+
+结论:本轮推荐规则选出的 selected URL 能被 OSS v2 成功归档。历史/生产里的 `oss_upload_response_invalid` 更像 OSS v2 payload 兼容问题,不是 URL 字段拆错。
+
+## 6. 推荐解包规则
+
+### 快手
+
+- 优先 detail 正片 URL:`$.detail.video_url_list[].video_url`。
+- detail 不可用时 fallback search 正片 URL:`$.search.video_url_list[].video_url`。
+- 不默认 `video_url_list[0]` 永远正确;多个候选按路径、HTTP 200/206、`content_type=video/mp4`、Range、duration、host 稳定性排序。
+- 过滤 `content_link`、`image_url_list`、头像、`bgm_data.play_url`、广告/素材字段。
+
+### 视频号
+
+- 优先 `$.search.video_url_list[].video_url` 中的 findermp 正片 URL。
+- 同样过滤封面、头像、音频/BGM、页面链接、广告/素材字段。
+- 广告关键词匹配不能用裸 `ad` 子串,否则会误伤 `stodownload/head` 这类正常 URL。
+- 本机下载/轻量验证继续带 `Referer: https://channels.weixin.qq.com/`;OSS v2 转存 payload 不应直接传 dict 型 `referer`。
+
+## 7. 生产改造建议
+
+1. 先修 OSS v2 payload:不要把 `referer` dict 直接传给 OSS v2。若上传服务需要 headers,需约定可编码字段格式,例如字符串 referer 或 `headers` object。
+2. 再补 runtime raw payload:记录 `selected_path`、`selected_host`、`probe_status`、`content_type`、`content_range`、`oss_payload_mode`、`oss_failure_type`。
+3. 最后改平台 adapter:快手 detail 优先、search fallback;视频号 findermp 正片优先;统一做 URL 候选分类过滤。
+
+## 8. 验证命令
+
+```bash
+.venv/bin/python -m pytest tests/test_platform_video_url_candidates.py -q
+CONTENT_AGENT_OSS_UPLOAD_URL=http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream \
+  .venv/bin/python tests/test_platform_video_url_candidates.py \
+  --real-probe --probe-oss --items-per-query 6 \
+  --kuaishou-detail-limit-per-query 3 \
+  --oss-limit-per-platform 20 --oss-timeout-seconds 180
+```

+ 49 - 0
数据接口与来源/接口台账/快手.md

@@ -8,6 +8,55 @@
 
 ---
 
+## 2026-06-17 URL 解包与 OSS v2 批量验证
+
+本节来自 V4 诊断脚本 `tests/test_platform_video_url_candidates.py` 的真实探测。完整原始响应和临时 URL 仅保存在 gitignore 下的 `data/platform_url_probe/20260617_115219/`,本文只记录脱敏统计、字段路径、host 和状态。
+
+### 探测规模
+
+- 真实搜索 query:10 个。
+- 搜索 item 样本:60 条。
+- 抽取 URL 候选:`page=89`、`image=80`、`video_candidate=75`、`audio=1`。
+- 推荐规则选中正片 URL:50 条。
+- 其中 20 条 selected URL 做 OSS v2 批量验证。
+- 历史对照 run:`v1_run_94448252f5cd`。
+
+### 历史 run 对照
+
+- 历史 run 决策:`ADD_TO_CONTENT_POOL=8`、`KEEP_CONTENT_FOR_REVIEW=9`、`REJECT_CONTENT=6`、`TECHNICAL_RETRY_REQUIRED=14`。
+- 媒体状态:`oss_uploaded=29`、`unavailable=8`。
+- 失败侧:8 条 `no_play_url` 没有 `play_url/oss_url`;1 条 media 侧出现 `oss_upload_http_error`,但技术重试直接原因仍是 Gemini timeout。
+- 历史 runtime 不能反查原始字段路径:`platform_raw_payload` 只保留 `channel_content_id/channel_account_id`,没有完整 `video_url_list/image_url_list/content_link/bgm_data`。
+
+### 推荐 URL 解包规则
+
+- 递归抽取 search/detail 中所有 URL,不再把 `video_url_list[0]` 当作唯一事实。
+- 快手优先 detail 中的正片 URL:`$.detail.video_url_list[].video_url`。
+- detail 不存在或不可用时,fallback 到 search 正片 URL:`$.search.video_url_list[].video_url`。
+- 明确排除:`content_link` 页面链接、`image_url_list` 封面/图片、`avatar` 头像、`bgm_data.play_url` 音频、广告/素材字段。
+- 多候选排序看字段路径、HTTP 200/206、`content_type=video/mp4`、Range 支持、host 稳定性和 duration;不默认取第一个。
+
+### 本轮真实字段与 host
+
+- selected 字段路径:`$.detail.video_url_list[0].video_url=25`,`$.search.video_url_list[0].video_url=25`。
+- selected host 分布:`tymov2.a.kwimgs.com=17`、`hwmov.a.yximgs.com=7`、`v23-3.kwaicdn.com=5`、`v2.oskwai.com=6`、`ucmov.a.kwimgs.com=5`、`dxsc.oppass.cn=4`、`v4.kwaicdn.com=3`、`cc1.xyydnode.com=2`、`v1.oskwai.com=1`。
+- 所有 selected 样本轻量验证均为可下载视频:HTTP `200/206`,`content_type=video/mp4`,Range 返回字节范围或完整小文件。
+
+### OSS v2 验证结论
+
+- 使用生产当前 payload 形态(把 `referer` 作为 dict 字段传给 OSS v2)时:20/20 返回 HTTP 200 但 `oss_object.cdn_url` 缺失,分类为 `oss_upload_response_invalid`。
+- 抽样复打发现 OSS body 为 `status=10000`、`oss_object=null`,错误原因包含 `dict object has no attribute encode`。
+- 使用同一批 selected URL、去掉 `referer` 字段后做 no-referer 复验:20/20 成功返回 `oss_object.cdn_url`。
+- 结论:本轮 selected URL 规则能选出可被 OSS v2 归档的正片 URL;历史/生产里的 invalid 更像 OSS v2 payload 兼容问题,不是 URL 字段拆错。
+
+### 生产改造建议
+
+- 第一优先级:调整 OSS v2 调用 payload,不再把 `referer` dict 直接传给 v2;如 OSS 侧确需 headers,应与上传服务约定字符串/headers 字段格式后再传。
+- 第二优先级:生产 runtime 里补充 URL 候选摘要:`selected_path`、`selected_host`、`probe_status`、`content_type`、`content_range`、`oss_payload_mode`、`oss_failure_type`。
+- 第三优先级:后续再改平台 adapter 的 `play_url` 抽取逻辑,改为 detail 优先、候选分类过滤、search fallback。
+
+---
+
 ## 一、关键词搜索 PLT_KUAISHOU_KEYWORD
 
 ### ① 身份

+ 58 - 0
数据接口与来源/接口台账/视频号.md

@@ -9,6 +9,64 @@
 
 ---
 
+## 2026-06-17 URL 解包与 OSS v2 批量验证
+
+本节来自 V4 诊断脚本 `tests/test_platform_video_url_candidates.py` 的真实探测。完整原始响应和临时 URL 仅保存在 gitignore 下的 `data/platform_url_probe/20260617_115219/`,本文只记录脱敏统计、字段路径、host 和状态。
+
+### 探测规模
+
+- 真实搜索 query:10 个。
+- 搜索请求页数:18 页(page1 + cursor page2,部分 query 无结果或无 page2)。
+- 搜索 item 样本:46 条。
+- 抽取 URL 候选:`image=95`、`video_candidate=41`、`audio=2`。
+- 推荐规则选中正片 URL:41 条。
+- 其中 20 条 selected URL 做 OSS v2 批量验证。
+- 历史对照 run:`v1_run_35f4f7ccd1cc`。
+
+### 历史 run 对照
+
+- 历史 run 决策:`ADD_TO_CONTENT_POOL=10`、`KEEP_CONTENT_FOR_REVIEW=5`、`REJECT_CONTENT=9`、`TECHNICAL_RETRY_REQUIRED=6`。
+- 媒体状态:30 条全部为 `oss_upload_pending`,失败原因全部为 `oss_upload_response_invalid`。
+- 6 条技术重试的决策直接原因均在 Gemini/OpenRouter 链路;但这 6 条 media 侧同时带有 OSS invalid 症状。
+- 历史 runtime 不能反查原始字段路径:`platform_raw_payload` 只保留 `channel_content_id/channel_account_id`,没有完整 `video_url_list/image_url_list/content_link/bgm_data`。
+
+### 25011 与翻页观察
+
+- 本轮真实探测保留了 25011 retry 事件。
+- 例:`怀旧经典歌曲` page1 前两次返回 `25011: 视频号接口异常: 获取搜索结果失败`,第 3 次返回 `code=0` 且 `item_count=9`。
+- 例:`歌词同步滚动呈现` page1 第 1 次返回 25011,重试后成功。
+- 例:`怀旧金曲` page1 重试后仍为 25011,最终 item_count=0。
+- page 返回数量不固定:本轮见到 0、9、11、12 条;page2 也可能返回 0、9、11、12 条。
+
+### 推荐 URL 解包规则
+
+- 递归抽取搜索 item 中所有 URL,视频号也按广告/素材规则扫描,不能假设没有广告 MP4。
+- 优先选择 `$.search.video_url_list[].video_url` 中的 findermp 正片 URL。
+- 明确排除:`image_url_list` 封面/图片、`channel_account_avatar` 头像、音频/BGM、页面链接、广告/素材字段。
+- 广告关键词不能用裸 `ad` 子串匹配,否则会误伤 `stodownload/head` 这类正常视频号 URL;应按字段路径 token 或分隔符匹配。
+- 下载/轻量验证需带 `Referer: https://channels.weixin.qq.com/`。
+
+### 本轮真实字段与 host
+
+- selected 字段路径:`$.search.video_url_list[0].video_url=41`。
+- selected host:`findermp.video.qq.com=41`。
+- 所有 selected 样本轻量验证均为可下载视频:HTTP `200/206`,`content_type=video/mp4`,Range 返回字节范围或完整小文件。
+
+### OSS v2 验证结论
+
+- 使用生产当前 payload 形态(把 `referer` 作为 dict 字段传给 OSS v2)时:20/20 返回 HTTP 200 但 `oss_object.cdn_url` 缺失,分类为 `oss_upload_response_invalid`。
+- 快手抽样复打证明该 invalid 与 `referer` dict payload 强相关;去掉 `referer` 后 OSS v2 能返回 `oss_object.cdn_url`。
+- 使用同一批视频号 selected URL、去掉 `referer` 字段后做 no-referer 复验:20/20 成功返回 `oss_object.cdn_url`。
+- 结论:视频号 selected URL 本身可下载、可转存;历史 30/30 invalid 更像 OSS v2 payload 兼容问题,不是 findermp URL 字段拆错。
+
+### 生产改造建议
+
+- 第一优先级:调整 OSS v2 调用 payload,不再把 `referer` dict 直接传给 v2;如 OSS 侧确需 headers,应与上传服务约定字符串/headers 字段格式后再传。
+- 第二优先级:保留视频号 25011 retry 事件到 query failure / raw payload,区分“无结果”和“重试耗尽”。
+- 第三优先级:生产 runtime 里补充 URL 候选摘要:`selected_path`、`selected_host`、`probe_status`、`content_type`、`content_range`、`oss_payload_mode`、`oss_failure_type`。
+
+---
+
 ## 1. 关键词搜索(PLT_SHIPINHAO_KEYWORD)
 
 ### ① 身份

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio