Procházet zdrojové kódy

新增 query 搜索 + OSS 转存 + run 隔离产出(入口二)

- search.py:关键词搜索 → content_id(翻页/去重/截断),复用 crawler.RateLimiter
- oss.py:媒体直链 → OSS cdn_url,to_oss 兜底永不抛(绕防盗链/临时签名链时效)
- config.py:Settings 加 5 字段(带默认)+读 CK_OSS_*/CK_SEARCH_* env
- video_extract.py:extract_video 加 oss_video_url,非空跳过下载+base64(已验证 Gemini 直读 OSS http 视频链)
- decompose.py:QUERIES 常量 + 抽 process_one + expand_queries(跨query去重) + read_one 媒体走 OSS + slugify + 写 web/runs/<slug>.json + manifest;main 双入口 `python scripts/decompose.py queries`
- web/index.html:run 选择器(fetch /runs/index.json,默认回退 frameworks.json)
- 测试:test_oss/test_search/test_decompose_helpers + 视频直喂测试(共 50 passed)
- 文档:开发文档/产品文档.md + 技术文档.md;框架 md 移入 开发文档/
- 清理:删 2 个过时 subagent;pyproject 补 numpy(scope_link 实际依赖)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SamLee před 3 týdny
rodič
revize
6d0ea90ada

+ 0 - 18
.claude/agents/extraction-reviewer.md

@@ -1,18 +0,0 @@
----
-name: extraction-reviewer
-description: 审「真实产出的颗」质量——读 web/frameworks.json 的 knowledges[],逐颗核对粒度、类型判定、组件颗判据、作用域不串味、创作非制作。给问题清单 + 改进建议。用于 v2 产出的质量交叉验证。
-tools: Read, Bash, Grep, Glob
-model: sonnet
----
-
-你是「解构质量评审员」。读 `web/frameworks.json` 真实产出的 `knowledges[]`(约 9 帖、~24 颗),逐颗按《创作知识拆解框架.md》核对质量,给具体问题(哪帖哪颗哪处)+ 建议。**只读。**
-
-评审维度:
-1. **粒度**:how 颗是不是一个完整框架(非碎片、非把内部步骤拆成独立颗);有没有「硬造假 how」(本该是 what 清单却掰成 how)。
-2. **类型判定**:how/what/why 判得对吗?纯构成/清单→what(概要+kind+body)、纯原理→why(一段阐述)、有不可跳步工序→how。what 的 kind(子集/多维关系/序列)判对吗、子集的维度拆分规则是单一轴或"类型枚举"吗;why 的阐述是否忠实整合、没擅自加"怎么用"。
-3. **组件颗判据**:标 role=组件 的 what/why,parent 指向的真的是一个 how 颗吗?它「脱离这个 how 还独立可复用」吗(够格单列)?有没有把一次性指令也单列(不够格)?有没有该单列却漏的?
-4. **作用域不串味**:抽查若干作用域值,实质/形式/感受/作用/意图 有没有站错列(实质=对象 vs 作用=功能;作用=手段 vs 意图=目的;感受=观众 vs 意图=作者)。值是否原子短词、非泛词。
-5. **创作非制作**:content / 步骤里有没有混进纯工艺执行(器材/软件操作/参数/导出)。
-6. **忠实**:directive 里的原帖示例是否像原文保留(`『』`/`例:`),有没有疑似编造。
-
-输出:按帖列「问题点」(定位 + 一句问题 + 建议),最后给一个总体质量判断(可用 / 需小修 / 需重做 哪几颗)。

+ 0 - 20
.claude/agents/framework-auditor.md

@@ -1,20 +0,0 @@
----
-name: framework-auditor
-description: 审计「创作知识拆解 v2」是否在真实代码+数据+前端里彻底落实——读《创作知识拆解框架.md》v2 作为规约,逐条核对 skill phase 文档、frameworks.json/payloads.json、web/index.html、scripts/decompose.py,给 PASS/FAIL + 证据。用于 v2 实现的交叉验证。
-tools: Read, Bash, Grep, Glob
-model: sonnet
----
-
-你是「框架落实审计员」。规约 = `创作知识拆解框架.md`(v2)。逐条核对实现是否真落实,每条给 PASS/FAIL + 文件:行 或命令输出证据。**只读,不改。**
-
-核对清单:
-1. **三 lane 对等**:`创作知识提取-skill/extraction/phase1-frame.md` 是否对 How/What/Why 各给完整成形模板(how:目标+steps;what:概要+kind+维度拆分规则+body条目;why:阐述一段[是什么+为什么,忠实整合,不含"怎么用"]),而非 how 默认+兜底。
-2. **类型闸 + 组件颗**:phase1-frame 是否先判类型、再「how 抽骨架 → 内部 what/why 单列为组件颗(parent cross-ref) → orphan → 无 how 则纯 what/why」。
-3. **作用域分流**:phase2-scope 是否 how 逐步 / what·why 颗级。
-4. **schema 落地**:`web/frameworks.json` 是否 `posts[].knowledges[]`(不是单 `framework`);每颗有 type/role/parent;至少出现「how 主颗 + what 组件颗(带 parent)」+ 某帖有 orphan 主颗(what/why)。统计:几帖、共几颗、类型分布、组件颗数。
-5. **payload 按类型分拼**:`web/payloads.json` 每颗一条;how 颗 custom_ext 有创作阶段/动作,what 颗业务阶段+概要、why 颗业务阶段(+组件颗有「出自」);what 的 content 是结构化对象 {name,kind,维度拆分规则,body}、why 的 content 是阐述一段字符串;dim_attributes 与 type 一致。跑 `python3 创作知识提取-skill/tools/lint-payload.py web/payloads.json`,须 0 ERROR。
-6. **前端三渲染**:`web/index.html` 是否按 knowledges[] 渲染、how→工序表/what→构成卡(概要+kind+body条目表)/why→阐述卡(一段)、组件颗 cross-ref 可跳、定位 drawer + 提示词溯源。grep 关键组件(HowTable/WhatCard/WhyCard/kref)。
-7. **无 how-only 残留**:grep 确认没有"只认 post.framework 单颗"的旧逻辑残留在 web/index.html 或 decompose。
-8. **decompose 引擎**:`scripts/decompose.py` 是否把 phase 文档当 prompt 喂 LLM(不另写提示词)、① extractor 读图 → ② 判颗 → ③ 作用域定位 → ⑤ 组装。
-
-最后给「残留清单」:任何没落实/半落实/与规约不符的点(没有就写「无」)。

+ 16 - 0
creation_knowledge/config.py

@@ -88,6 +88,12 @@ class Settings:
     frames_dir: str
     douyin_ratio: str  # 视频下载偏好码率(控成本/体积),如 540p
     data_dir: str  # 媒体本地落盘根目录;空字符串=关闭媒体保存(解耦开关)
+    # query 搜索 + OSS 转存(默认值必填:tests/test_video_extract.py 用关键字构造 Settings 且不传这些)
+    oss_upload_url: str = "http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream"
+    oss_upload_timeout_seconds: int = 60
+    search_default_limit: int = 5
+    search_content_type: str = "图文"
+    search_sort_type: str = "综合"
 
     @classmethod
     def from_env(cls, env_file: str | Path = ".env") -> "Settings":
@@ -122,4 +128,14 @@ class Settings:
             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"),
+            oss_upload_url=env_value(
+                "CK_OSS_UPLOAD_URL", file_env,
+                "http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream",
+            ),
+            oss_upload_timeout_seconds=int(
+                env_value("CK_OSS_UPLOAD_TIMEOUT_SECONDS", file_env, "60")
+            ),
+            search_default_limit=int(env_value("CK_SEARCH_DEFAULT_LIMIT", file_env, "5")),
+            search_content_type=env_value("CK_SEARCH_CONTENT_TYPE", file_env, "图文"),
+            search_sort_type=env_value("CK_SEARCH_SORT_TYPE", file_env, "综合"),
         )

+ 80 - 0
creation_knowledge/integrations/oss.py

@@ -0,0 +1,80 @@
+"""OSS 转存:把帖子媒体直链转存到公网 CDN,返回 cdn_url(绕过防盗链 / 临时签名链时效)。
+
+接口(实测 2026-06-26):
+    POST settings.oss_upload_url  body{src_url, src_type:"video"|"image"}
+      → {status:0, oss_object:{cdn_url:"https://res.cybertogether.net/...", oss_object_key, ...}}
+
+纯 parser(parse_oss_response)可离线测;upload_stream 负责 HTTP(失败抛 OssError);
+to_oss 是兜底包装——任何失败返回原 src_url、永不抛,供 decompose 降级不阻断。
+"""
+from __future__ import annotations
+
+from typing import Any, Callable, Optional
+
+import httpx
+
+from creation_knowledge.config import Settings
+
+
+class OssError(RuntimeError):
+    pass
+
+
+def parse_oss_response(response: Any) -> str:
+    """{status:0, oss_object:{cdn_url}} → cdn_url。status!=0 或缺 cdn_url 抛 OssError。"""
+    if not isinstance(response, dict):
+        raise OssError("bad_response: not a dict")
+    status = response.get("status")
+    if status not in (0, "0"):
+        raise OssError(f"business_error: status={status} msg={response.get('msg')}")
+    obj = response.get("oss_object") or {}
+    cdn = obj.get("cdn_url")
+    if not cdn:
+        raise OssError("empty_cdn_url: oss_object.cdn_url missing")
+    return cdn
+
+
+def upload_stream(
+    src_url: str,
+    *,
+    src_type: str,
+    settings: Optional[Settings] = None,
+    http_post: Callable[..., Any] = httpx.post,
+    env_file: str = ".env",
+) -> str:
+    """转存一条直链到 OSS,返回 cdn_url;HTTP/解析失败抛 OssError。"""
+    settings = settings or Settings.from_env(env_file)
+    if src_type not in ("video", "image"):
+        raise OssError(f"bad_src_type: {src_type}")
+    try:
+        resp = http_post(
+            settings.oss_upload_url,
+            json={"src_url": src_url, "src_type": src_type},
+            headers={"Content-Type": "application/json"},
+            timeout=settings.oss_upload_timeout_seconds,
+        )
+        resp.raise_for_status()
+        data = resp.json()
+    except httpx.HTTPError as exc:
+        raise OssError(f"http_error: {exc}") from exc
+    except ValueError as exc:
+        raise OssError("bad_json") from exc
+    return parse_oss_response(data)
+
+
+def to_oss(
+    src_url: str,
+    src_type: str,
+    *,
+    settings: Optional[Settings] = None,
+    http_post: Callable[..., Any] = httpx.post,
+    env_file: str = ".env",
+) -> str:
+    """兜底包装:成功返回 cdn_url,任何失败(含空入参)返回原 src_url,永不抛。"""
+    if not src_url:
+        return src_url
+    try:
+        return upload_stream(src_url, src_type=src_type, settings=settings,
+                             http_post=http_post, env_file=env_file)
+    except Exception:
+        return src_url

+ 102 - 0
creation_knowledge/integrations/search.py

@@ -0,0 +1,102 @@
+"""关键词搜索:query → content_id 列表(翻页 + 去重 + 截断)。
+
+接口(实测 2026-06-26,host = settings.crawler_base_url):
+    POST /crawler/xiao_hong_shu/keyword
+      body{keyword, content_type:"图文"|"视频", sort_type:"综合", publish_time:"", cursor:""}
+      → {code:0, data:{has_more, next_cursor, data:[{id:<content_id>, note_card:{...预览...}}]}}
+
+搜索只给预览;全文/全图/视频仍需 crawler.fetch_post_detail 逐 id 取详情。
+parse_search_response 纯函数可离线测;search_keyword 负责 HTTP + 翻页。
+抖音 /keyword 实测 code:10000(疑需 cookie/鉴权)→ 抛 SearchError,由调用方跳过。
+"""
+from __future__ import annotations
+
+from typing import Any, Optional
+from urllib.parse import urljoin
+
+import httpx
+
+from creation_knowledge.config import Settings
+from creation_knowledge.integrations.crawler import RateLimiter
+
+SEARCH_PATHS = {
+    "xiaohongshu": "/crawler/xiao_hong_shu/keyword",
+    "douyin": "/crawler/dou_yin/keyword",   # TODO: 实测 code 10000,需 cookie/鉴权
+}
+
+
+class SearchError(RuntimeError):
+    pass
+
+
+def parse_search_response(response: Any, *, platform: str = "xiaohongshu") -> tuple[list[str], bool, str]:
+    """信封 {code,msg,data:{has_more,next_cursor,data:[...]}} → (content_ids, has_more, next_cursor)。
+    code!=0 抛 SearchError(如抖音 10000)。"""
+    if not isinstance(response, dict):
+        raise SearchError("bad_response: not a dict")
+    code = response.get("code")
+    if code not in (0, "0"):
+        raise SearchError(f"business_error: code={code} msg={response.get('msg')}")
+    data = response.get("data") or {}
+    items = data.get("data") or []
+    ids = [it.get("id") for it in items if isinstance(it, dict) and it.get("id")]
+    return ids, bool(data.get("has_more")), str(data.get("next_cursor") or "")
+
+
+def search_keyword(
+    keyword: str,
+    *,
+    platform: str = "xiaohongshu",
+    content_type: str = "图文",
+    sort_type: str = "综合",
+    limit: int = 5,
+    publish_time: str = "",
+    settings: Optional[Settings] = None,
+    http_client: Any = None,
+    rate_limiter: Optional[RateLimiter] = None,
+    env_file: str = ".env",
+    max_pages: int = 10,
+) -> list[str]:
+    """搜索 → content_id 列表(翻页直到够 limit 或 has_more=False;按 id 去重、截断 limit)。
+    复用 crawler.RateLimiter('{platform}_keyword' bucket)。失败抛 SearchError。"""
+    settings = settings or Settings.from_env(env_file)
+    path = SEARCH_PATHS.get(platform)
+    if not path:
+        raise SearchError(f"unsupported platform: {platform}")
+    rate_limiter = rate_limiter or RateLimiter()
+
+    owns_client = http_client is None
+    client = http_client or httpx.Client()
+    out: list[str] = []
+    seen: set[str] = set()
+    cursor = ""
+    try:
+        for _ in range(max_pages):
+            rate_limiter.wait(f"{platform}_keyword")
+            url = urljoin(settings.crawler_base_url, path)
+            body = {"keyword": keyword, "content_type": content_type,
+                    "sort_type": sort_type, "publish_time": publish_time, "cursor": cursor}
+            try:
+                resp = client.post(url, json=body,
+                                   headers={"Content-Type": "application/json"},
+                                   timeout=settings.crawler_timeout)
+                resp.raise_for_status()
+                data = resp.json()
+            except httpx.HTTPError as exc:
+                raise SearchError(f"http_error: {exc}") from exc
+            except ValueError as exc:
+                raise SearchError("bad_json") from exc
+            ids, has_more, next_cursor = parse_search_response(data, platform=platform)
+            for cid in ids:
+                if cid not in seen:
+                    seen.add(cid)
+                    out.append(cid)
+                    if len(out) >= limit:
+                        return out
+            if not has_more or not next_cursor or next_cursor == cursor:
+                break
+            cursor = next_cursor
+    finally:
+        if owns_client:
+            client.close()
+    return out[:limit]

+ 43 - 32
creation_knowledge/integrations/video_extract.py

@@ -83,53 +83,64 @@ def extract_video(
     timeout: float = 600.0,
     save_path: Optional[Path] = None,
     public_url: Optional[str] = None,
+    oss_video_url: Optional[str] = None,
 ) -> ExtractedContent:
     """对视频帖做原生整段提炼,就地写好 post.cards(段卡),返回 ExtractedContent。
 
     save_path/public_url 给定时:把下载到的整段 mp4 写到 save_path(单次下载,复用
     内存字节),并把每张段卡 url 设为 public_url(整段共用,前端按 start/end 加 #t 播片段)。
     两者默认 None=不落盘、行为不变。
+
+    oss_video_url 给定时:直接把该公网直链喂模型(已验证 Gemini 可直读 OSS http 视频链),
+    跳过下载/落盘/base64;段卡 url 默认用它。默认 None=走原 下载+base64 路径(fixture/兜底)。
     """
     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()
-        logger.info("video_extract 用本地文件 %s (%d bytes)", video_path, len(data))
+    if oss_video_url:
+        # OSS 直喂:公网直链直接进 video_url,跳过下载+落盘+base64
+        media_url = oss_video_url
+        public_url = public_url or oss_video_url   # 段卡指向 OSS 直链
     else:
-        if not post.video_urls:
-            raise VideoExtractError(f"post {post.id} 无 video_urls 且未提供 video_path")
-        url = post.video_urls[0]
-        if post.platform == "douyin" and "ratio=" in url:  # 偏好较小码率控成本
-            url = re.sub(r"ratio=[^&]+", f"ratio={settings.douyin_ratio}", url)
-        dl = downloader or _default_download
-        try:
-            data = dl(url, post.platform)
-        except Exception as exc:
-            raise VideoExtractError(f"视频下载失败: {exc}") from exc
-    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()
+        # 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()
+            logger.info("video_extract 用本地文件 %s (%d bytes)", video_path, len(data))
+        else:
+            if not post.video_urls:
+                raise VideoExtractError(f"post {post.id} 无 video_urls 且未提供 video_path")
+            url = post.video_urls[0]
+            if post.platform == "douyin" and "ratio=" in url:  # 偏好较小码率控成本
+                url = re.sub(r"ratio=[^&]+", f"ratio={settings.douyin_ratio}", url)
+            dl = downloader or _default_download
+            try:
+                data = dl(url, post.platform)
+            except Exception as exc:
+                raise VideoExtractError(f"视频下载失败: {exc}") from exc
+        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
+        media_url = "data:video/mp4;base64," + base64.b64encode(data).decode()
+
+    # 3) OpenRouter 原生视频
     prompt = load_prompt("extract_video").format()
     body = {"model": settings.video_model, "messages": [{"role": "user", "content": [
         {"type": "text", "text": prompt},
-        {"type": "video_url", "video_url": {"url": data_url}},
+        {"type": "video_url", "video_url": {"url": media_url}},
     ]}]}
     try:
         resp = http_post(

+ 1 - 0
pyproject.toml

@@ -11,6 +11,7 @@ dependencies = [
   "uvicorn>=0.30.0",
   "pytest>=8.2.0",
   "imageio-ffmpeg>=0.4.9",
+  "numpy>=1.26",
 ]
 
 [tool.pytest.ini_options]

+ 164 - 75
scripts/decompose.py

@@ -13,6 +13,8 @@ from __future__ import annotations
 import json
 import os
 import re
+import sys
+import time
 from pathlib import Path
 
 from creation_knowledge.config import Settings
@@ -20,6 +22,8 @@ from creation_knowledge.integrations import video_extract
 from creation_knowledge.integrations.crawler import fetch_post_detail, parse_detail_response
 from creation_knowledge.integrations.extractor import GeminiExtractor
 from creation_knowledge.integrations.llm import chat_json
+from creation_knowledge.integrations.oss import to_oss, upload_stream
+from creation_knowledge.integrations.search import search_keyword
 from creation_knowledge.prompts import load_prompt
 from scripts.scope_link import ScopeLinker
 
@@ -56,6 +60,12 @@ SOURCES = [
     {"cid": "7589257893544165455", "platform": "douyin", "from": "live"},        # 抖音视频(倒数第2)
     {"cid": "6a33655e000000000f0055af", "platform": "xiaohongshu", "from": "live"},  # 无知识 Voodoo(最后)
 ]
+# 入口二:按关键词搜索召回(与 SOURCES 并存)。跑法:python scripts/decompose.py queries
+# 每条 {query, platform, content_type, limit};limit 缺省取 settings.search_default_limit。
+# 抖音 /keyword 实测 code:10000(需鉴权),本迭代只上小红书。
+QUERIES = [
+    {"query": "分镜脚本", "platform": "xiaohongshu", "content_type": "图文", "limit": 5},
+]
 SRC2CN = {"substance": "实质", "form": "形式", "feeling": "感受", "effect": "作用", "intent": "意图"}
 TYPE2ATTR = {"how": "how", "what": "what", "why": "why"}
 CSTAGE = {"定向", "构思", "结构", "成文", "打磨"}  # 创作阶段受控 5 值
@@ -77,40 +87,37 @@ def load_post(src: dict, settings: Settings):
 
 
 # ---------- ① 读懂(图文/视频分流) ----------
-def _download_images(post) -> list[str]:
-    """live 图文帖:把 CDN 图下载到本地 /data(避开小红书防盗链),返回本地公开路径;
-    单张失败则退回 CDN 直链(至少有 url)。复用 video_extract 的下载器(iOS UA + 平台 Referer)。"""
-    base = DATA / "xiaohongshu" / post.id
-    base.mkdir(parents=True, exist_ok=True)
-    out = []
-    for n, url in enumerate(post.image_urls, 1):
-        dst = base / f"image_{n}.webp"
-        pub = f"/data/demo/xiaohongshu/{post.id}/image_{n}.webp"
-        try:
-            if not dst.exists():
-                dst.write_bytes(video_extract._default_download(url, post.platform))
-            out.append(pub)
-        except Exception:
-            out.append(url)
-    return out
-
-
 def read_one(src: dict, post, settings: Settings, extractor: GeminiExtractor):
-    cid = src["cid"]
-    if post.video_urls:  # 视频帖:下载 mp4 + 原生整段提炼
-        save = DATA / "douyin" / f"dy_{cid}" / "video.mp4"
-        pub = f"/data/demo/douyin/dy_{cid}/video.mp4"
-        ec = video_extract.extract_video(post, settings=settings, save_path=save, public_url=pub)
+    live = src["from"] == "live"
+    if post.video_urls:  # 视频帖
+        oss_url = ""
+        if live:
+            try:  # live:转存视频到 OSS 拿公网直链直喂(已验证 Gemini 可直读);失败→显式回退
+                oss_url = upload_stream(post.video_urls[0], src_type="video", settings=settings)
+            except Exception as exc:
+                print(f"  ⚠ 视频 OSS 转存失败,回退下载+base64:{exc}")
+        if oss_url:  # 转存成功:OSS 直链直喂,跳过下载+base64
+            ec = video_extract.extract_video(post, settings=settings, oss_video_url=oss_url, public_url=oss_url)
+            pub = oss_url
+        else:        # fixture / 转存失败:下载 mp4 + base64 兜底(落盘按平台分目录,post.id 已含平台前缀)
+            save = DATA / post.platform / post.id / "video.mp4"
+            pub = f"/data/demo/{post.platform}/{post.id}/video.mp4"
+            ec = video_extract.extract_video(post, settings=settings, save_path=save, public_url=pub)
         media = {"type": "video", "video_url": pub, "images": []}
         cmap = {c.index: c.content for c in ec.cards}
         cards = [{"index": c.index, "content": cmap.get(c.index, ""), "video_url": pub,
                   "start": c.start, "end": c.end} for c in post.cards]  # 段卡:时间戳 + 读到的内容
     else:                # 图文帖:读图
-        ec = extractor.extract(post)
-        if src["from"] == "fixture":
+        if live:  # live 帖:每张 OSS 转存绕防盗链,cdn_url 写回 post 再喂 extractor(直读 card.url)
+            imgs = [to_oss(u, "image", settings=settings) for u in post.image_urls]
+            post.image_urls = imgs
+            for c in post.cards:
+                if c.kind == "image" and 1 <= c.index <= len(imgs):
+                    c.url = imgs[c.index - 1]
+            ec = extractor.extract(post)
+        else:     # fixture:读图后用预下载的本地 /data 路径展示
+            ec = extractor.extract(post)
             imgs = [f"/data/demo/xiaohongshu/{post.id}/image_{n}.webp" for n in range(1, len(post.image_urls) + 1)]
-        else:
-            imgs = _download_images(post)  # live 帖:下载 CDN 图到本地 /data,避开防盗链
         media = {"type": "image", "video_url": None, "images": imgs}
         cmap = {c.index: c.content for c in ec.cards}
         cards = [{"index": n, "content": cmap.get(n, ""), "image_url": imgs[n - 1] if n <= len(imgs) else None}
@@ -433,58 +440,140 @@ def build_payload(post, k: dict, how_titles: dict | None = None) -> dict:
             "scopes": scopes, "custom_ext": ext}
 
 
-def main() -> None:
-    settings = Settings.from_env()
-    extractor = GeminiExtractor.from_env()
-    linker = ScopeLinker()
-    posts_out, payloads = [], []
-    for src in SOURCES:
-        cid = src["cid"]
-        print(f"\n=== {src['platform']} {cid[:10]} ({src['from']}) ===")
+def process_one(src: dict, settings: Settings, extractor: GeminiExtractor,
+                linker: ScopeLinker) -> tuple[dict, list[dict]]:
+    """跑通一帖:取数→读懂→闸→拆颗→作用域→组装。返回 (post_out_entry, payloads)。
+
+    从 main 循环体原样抽出(零行为变更);SOURCES 与 QUERIES 两条入口共用。"""
+    cid = src["cid"]
+    print(f"\n=== {src['platform']} {cid[:10]} ({src['from']}) ===")
+    try:
+        post = load_post(src, settings)
+        read, is_empty, media, cards = read_one(src, post, settings, extractor)
+    except Exception as exc:
+        print(f"  ✗ 取数/读懂失败:{exc}")
+        return ({"post_id": cid, "source_id": cid, "title": f"(取数失败 {cid})",
+                 "platform": src["platform"], "url": "", "media": {"type": "image", "images": []},
+                 "cards": [], "error": str(exc)[:200], "knowledges": []}, [])
+    meta = {"post_id": cid, "source_id": post.id, "title": post.title or "",
+            "platform": post.platform, "url": post.url, "media": media, "cards": cards}
+    if is_empty:  # ① 总闸:纯展示/无可提取
+        print(f"  ① 读懂 {len(read)} 字 → ① 判纯展示/无知识,跳过")
+        return ({**meta, "no_knowledge": True, "knowledges": []}, [])
+    in_scope, gate_why = creation_gate(read)  # ①.5 创作判定闸(创作 vs 制作 vs 越界)
+    if not in_scope:
+        print(f"  ① 读懂 {len(read)} 字 → ①.5 闸判【非创作】({gate_why})→ 整帖排除")
+        return ({**meta, "no_knowledge": True, "knowledges": []}, [])
+    print(f"  ① 读懂 {len(read)} 字({media['type']})· ①.5 闸:创作({gate_why})")
+    knowledges = drop_fake_whys(fix_fake_hows(shape(post, read)))   # ② 拆颗 → ②.5 假how根治 → ②.7 why轻闸
+    how_ids = {k.get("id") for k in knowledges if k.get("type") == "how"}
+    how_titles = {k.get("id"): k.get("title") for k in knowledges if k.get("type") == "how"}
+    for k in knowledges:
+        k["业务阶段"] = [b for b in (k.get("业务阶段") or []) if b in ("灵感", "选题", "脚本")]  # 守卫:只留合法业务阶段
+        if k.get("type") == "what" and k.get("kind") not in KINDS:  # 守卫:what.kind 受控值,LLM 偶发变体归一
+            k["kind"] = KIND_FIX.get((k.get("kind") or "").strip(), k.get("kind"))
+        for s in k.get("steps", []):  # 守卫:创作阶段只留合法 5 值,非法(如"定稿/输出")丢弃
+            if s.get("创作阶段") not in CSTAGE:
+                s["创作阶段"] = None
+        if k.get("role") == "组件" and (k.get("parent") or {}).get("how_id") not in how_ids:  # 守卫:组件 parent 必指向同帖 how
+            k["role"] = "主"; k["parent"] = None
+    print(f"  ② {len(knowledges)} 颗:" + ", ".join(f"{k.get('type')}/{k.get('role')}" for k in knowledges))
+    apply_scopes(knowledges, nounify_scopes(scope_candidates(knowledges)), linker)  # ③名词化+①兜底 → 定位
+    print("  ③⑤ 作用域定位 + 组装")
+    return ({**meta, "knowledges": knowledges},
+            [build_payload(post, k, how_titles) for k in knowledges])
+
+
+# ---------- 入口二:query 搜索召回 + run 隔离产出 ----------
+def expand_queries(queries: list[dict], settings: Settings) -> list[dict]:
+    """逐 query 搜索 → content_id;跨 query 按 id 去重。搜索失败跳过该 query(不阻断整批)。"""
+    seen: set[str] = set()
+    out: list[dict] = []
+    for q in queries:
+        platform = q.get("platform", "xiaohongshu")
         try:
-            post = load_post(src, settings)
-            read, is_empty, media, cards = read_one(src, post, settings, extractor)
+            ids = search_keyword(
+                q["query"], platform=platform,
+                content_type=q.get("content_type") or settings.search_content_type,
+                sort_type=q.get("sort_type") or settings.search_sort_type,
+                limit=q.get("limit") or settings.search_default_limit,
+                settings=settings)
         except Exception as exc:
-            print(f"  ✗ 取数/读懂失败:{exc}")
-            posts_out.append({"post_id": cid, "source_id": cid, "title": f"(取数失败 {cid})",
-                              "platform": src["platform"], "url": "", "media": {"type": "image", "images": []},
-                              "cards": [], "error": str(exc)[:200], "knowledges": []})
+            print(f"  ✗ 搜索失败 [{q.get('query')}]:{exc}")
             continue
-        meta = {"post_id": cid, "source_id": post.id, "title": post.title or "",
-                "platform": post.platform, "url": post.url, "media": media, "cards": cards}
-        if is_empty:  # ① 总闸:纯展示/无可提取
-            print(f"  ① 读懂 {len(read)} 字 → ① 判纯展示/无知识,跳过")
-            posts_out.append({**meta, "no_knowledge": True, "knowledges": []})
-            continue
-        in_scope, gate_why = creation_gate(read)  # ①.5 创作判定闸(创作 vs 制作 vs 越界)
-        if not in_scope:
-            print(f"  ① 读懂 {len(read)} 字 → ①.5 闸判【非创作】({gate_why})→ 整帖排除")
-            posts_out.append({**meta, "no_knowledge": True, "knowledges": []})
-            continue
-        print(f"  ① 读懂 {len(read)} 字({media['type']})· ①.5 闸:创作({gate_why})")
-        knowledges = drop_fake_whys(fix_fake_hows(shape(post, read)))   # ② 拆颗 → ②.5 假how根治 → ②.7 why轻闸
-        how_ids = {k.get("id") for k in knowledges if k.get("type") == "how"}
-        how_titles = {k.get("id"): k.get("title") for k in knowledges if k.get("type") == "how"}
-        for k in knowledges:
-            k["业务阶段"] = [b for b in (k.get("业务阶段") or []) if b in ("灵感", "选题", "脚本")]  # 守卫:只留合法业务阶段
-            if k.get("type") == "what" and k.get("kind") not in KINDS:  # 守卫:what.kind 受控值,LLM 偶发变体归一
-                k["kind"] = KIND_FIX.get((k.get("kind") or "").strip(), k.get("kind"))
-            for s in k.get("steps", []):  # 守卫:创作阶段只留合法 5 值,非法(如"定稿/输出")丢弃
-                if s.get("创作阶段") not in CSTAGE:
-                    s["创作阶段"] = None
-            if k.get("role") == "组件" and (k.get("parent") or {}).get("how_id") not in how_ids:  # 守卫:组件 parent 必指向同帖 how
-                k["role"] = "主"; k["parent"] = None
-        print(f"  ② {len(knowledges)} 颗:" + ", ".join(f"{k.get('type')}/{k.get('role')}" for k in knowledges))
-        apply_scopes(knowledges, nounify_scopes(scope_candidates(knowledges)), linker)  # ③名词化+①兜底 → 定位
-        print("  ③⑤ 作用域定位 + 组装")
-        posts_out.append({**meta, "knowledges": knowledges})
-        payloads += [build_payload(post, k, how_titles) for k in knowledges]
-    suffix = os.environ.get("OUT", "")  # OUT=_v2 → 写 frameworks_v2.json,不覆盖原版
-    (ROOT / f"web/frameworks{suffix}.json").write_text(
-        json.dumps({"count": len(posts_out), "posts": posts_out}, ensure_ascii=False, indent=1), encoding="utf-8")
-    (ROOT / f"web/payloads{suffix}.json").write_text(
+        new = 0
+        for cid in ids:
+            if cid in seen:
+                continue
+            seen.add(cid)
+            out.append({"cid": cid, "platform": platform, "from": "live", "query": q["query"]})
+            new += 1
+        print(f"  🔎 [{q['query']}] 命中 {len(ids)} → 新增 {new}(去重后)")
+    return out
+
+
+def slugify(text: str) -> str:
+    """run 文件名:保留中英文字与数字,其余压成 -。空则 'run'。"""
+    s = re.sub(r"[^\w一-鿿]+", "-", text or "").strip("-").lower()
+    return s or "run"
+
+
+def write_run(posts_out: list[dict], payloads: list[dict], queries: list[dict]) -> str:
+    """写 web/runs/<slug>.json + <slug>.payloads.json + 追加 web/runs/index.json 清单。
+    同名 slug 已存在则加时间戳保留历史。返回最终 slug。"""
+    runs_dir = ROOT / "web" / "runs"
+    runs_dir.mkdir(parents=True, exist_ok=True)
+    slug = slugify("-".join(q["query"] for q in queries))
+    if (runs_dir / f"{slug}.json").exists():
+        slug = f"{slug}-{int(time.time())}"
+    (runs_dir / f"{slug}.json").write_text(
+        json.dumps({"count": len(posts_out), "posts": posts_out, "queries": queries},
+                   ensure_ascii=False, indent=1), encoding="utf-8")
+    (runs_dir / f"{slug}.payloads.json").write_text(
         json.dumps(payloads, ensure_ascii=False, indent=2), encoding="utf-8")
-    print(f"\nwrote {len(posts_out)} posts, {len(payloads)} payloads → web/frameworks{suffix}.json")
+    idx_path = runs_dir / "index.json"
+    try:
+        idx = json.loads(idx_path.read_text("utf-8")) if idx_path.exists() else []
+    except Exception:
+        idx = []
+    idx = [r for r in idx if r.get("slug") != slug]  # 同 slug 去重
+    idx.insert(0, {"slug": slug, "queries": [q["query"] for q in queries],
+                   "count": len(posts_out), "created_at": int(time.time())})
+    idx_path.write_text(json.dumps(idx, ensure_ascii=False, indent=1), encoding="utf-8")
+    print(f"\nwrote {len(posts_out)} posts, {len(payloads)} payloads → web/runs/{slug}.json")
+    return slug
+
+
+def _run(srcs: list[dict], settings: Settings, extractor: GeminiExtractor,
+         linker: ScopeLinker) -> tuple[list[dict], list[dict]]:
+    posts_out, payloads = [], []
+    for src in srcs:
+        entry, pl = process_one(src, settings, extractor, linker)
+        posts_out.append(entry)
+        payloads += pl
+    return posts_out, payloads
+
+
+def main() -> None:
+    settings = Settings.from_env()
+    extractor = GeminiExtractor.from_env()
+    linker = ScopeLinker()
+    mode = sys.argv[1] if len(sys.argv) > 1 else "sources"
+    if mode == "queries":  # 入口二:搜索召回 → run 隔离产出(不动 frameworks.json 样本)
+        srcs = expand_queries(QUERIES, settings)
+        if not srcs:
+            print("没有召回到任何帖子,结束。")
+            return
+        posts_out, payloads = _run(srcs, settings, extractor, linker)
+        write_run(posts_out, payloads, QUERIES)
+    else:                  # 入口一(默认):写死 SOURCES → web/frameworks.json
+        posts_out, payloads = _run(SOURCES, settings, extractor, linker)
+        suffix = os.environ.get("OUT", "")  # OUT=_v2 → 写 frameworks_v2.json,不覆盖原版
+        (ROOT / f"web/frameworks{suffix}.json").write_text(
+            json.dumps({"count": len(posts_out), "posts": posts_out}, ensure_ascii=False, indent=1), encoding="utf-8")
+        (ROOT / f"web/payloads{suffix}.json").write_text(
+            json.dumps(payloads, ensure_ascii=False, indent=2), encoding="utf-8")
+        print(f"\nwrote {len(posts_out)} posts, {len(payloads)} payloads → web/frameworks{suffix}.json")
 
 
 if __name__ == "__main__":

+ 36 - 0
tests/fixtures/xhs_search_分镜脚本.json

@@ -0,0 +1,36 @@
+{
+  "code": 0,
+  "msg": null,
+  "data": {
+    "has_more": true,
+    "next_cursor": "2",
+    "data": [
+      {
+        "id": "6a2bce890000000006034be4",
+        "model_type": "note",
+        "note_card": {
+          "type": "normal",
+          "display_title": "接定制分镜头,有成品分镜。",
+          "desc": "白菜价出分镜成品,分镜头加剧本。",
+          "image_list": [
+            {"image_url": "https://ci.xiaohongshu.com/a?imageView2/2/w/1080/format/webp"}
+          ],
+          "interact_info": {"liked_count": 26}
+        }
+      },
+      {
+        "id": "68282529000000002100e28c",
+        "model_type": "note",
+        "note_card": {
+          "type": "normal",
+          "display_title": "分镜脚本怎么写",
+          "desc": "新手向分镜脚本教程。",
+          "image_list": [
+            {"image_url": "https://ci.xiaohongshu.com/b?imageView2/2/w/1080/format/webp"}
+          ],
+          "interact_info": {"liked_count": 88}
+        }
+      }
+    ]
+  }
+}

+ 51 - 0
tests/test_decompose_helpers.py

@@ -0,0 +1,51 @@
+"""decompose 入口二的纯逻辑离线测:slugify + expand_queries 跨 query 去重。"""
+from __future__ import annotations
+
+import scripts.decompose as d
+from creation_knowledge.config import PgConfig, Settings
+
+
+def _settings() -> Settings:
+    return Settings(
+        pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
+        crawler_base_url="x", crawler_key="", crawler_timeout=30,
+        video_model="m", gemini_api_key="",
+        openrouter_base_url="b", openrouter_api_key="k",
+        llm_model="m", max_cards=12, frames_dir="f", douyin_ratio="540p", data_dir="")
+
+
+def test_slugify():
+    assert d.slugify("分镜脚本") == "分镜脚本"      # 中文保留
+    assert d.slugify("A B!c") == "a-b-c"            # 空格/标点→- 且小写
+    assert d.slugify("") == "run"                   # 空→run
+    assert d.slugify("!!!") == "run"                # 全标点→run
+
+
+def test_expand_queries_dedup(monkeypatch):
+    ret = {"q1": ["a", "b"], "q2": ["b", "c"]}  # q1/q2 共享 id "b"
+
+    def fake_search(keyword, **kw):
+        return ret[keyword]
+
+    monkeypatch.setattr(d, "search_keyword", fake_search)
+    srcs = d.expand_queries(
+        [{"query": "q1", "platform": "xiaohongshu"},
+         {"query": "q2", "platform": "xiaohongshu"}], _settings())
+
+    assert [s["cid"] for s in srcs] == ["a", "b", "c"]          # b 去重,只出现一次
+    assert all(s["from"] == "live" for s in srcs)
+    assert srcs[0]["query"] == "q1" and srcs[2]["query"] == "q2"  # b 归属首次命中的 q1
+
+
+def test_expand_queries_skips_failed(monkeypatch):
+    def fake_search(keyword, **kw):
+        if keyword == "bad":
+            raise RuntimeError("search down")
+        return ["x"]
+
+    monkeypatch.setattr(d, "search_keyword", fake_search)
+    srcs = d.expand_queries(
+        [{"query": "bad", "platform": "xiaohongshu"},
+         {"query": "ok", "platform": "xiaohongshu"}], _settings())
+
+    assert [s["cid"] for s in srcs] == ["x"]  # bad 整条跳过,不阻断 ok

+ 90 - 0
tests/test_oss.py

@@ -0,0 +1,90 @@
+"""OSS 转存离线测试:纯 parser + upload_stream + to_oss 兜底(注入 http_post)。"""
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from creation_knowledge.config import PgConfig, Settings
+from creation_knowledge.integrations.oss import (
+    OssError, parse_oss_response, to_oss, upload_stream,
+)
+
+
+def _settings() -> Settings:
+    return Settings(
+        pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
+        crawler_base_url="x", crawler_key="", crawler_timeout=30,
+        video_model="m", gemini_api_key="",
+        openrouter_base_url="b", openrouter_api_key="k",
+        llm_model="m", max_cards=12, frames_dir="f", douyin_ratio="540p", data_dir="",
+        oss_upload_url="http://oss.test/upload_stream", oss_upload_timeout_seconds=60)
+
+
+class _Resp:
+    def __init__(self, payload): self._p = payload
+    def raise_for_status(self): return None
+    def json(self): return self._p
+
+
+def test_parse_ok():
+    assert parse_oss_response(
+        {"status": 0, "oss_object": {"cdn_url": "https://res/x.mp4"}}) == "https://res/x.mp4"
+
+
+def test_parse_status_error():
+    with pytest.raises(OssError):
+        parse_oss_response({"status": 1, "msg": "fail"})
+
+
+def test_parse_missing_cdn():
+    with pytest.raises(OssError):
+        parse_oss_response({"status": 0, "oss_object": {}})
+
+
+def test_upload_stream_posts_and_returns_cdn():
+    captured = {}
+
+    def post(url, json=None, headers=None, timeout=None):
+        captured["url"] = url
+        captured["json"] = json
+        return _Resp({"status": 0, "oss_object": {"cdn_url": "https://res/y.webp"}})
+
+    cdn = upload_stream("http://src/y.webp", src_type="image",
+                        settings=_settings(), http_post=post)
+    assert cdn == "https://res/y.webp"
+    assert captured["url"] == "http://oss.test/upload_stream"
+    assert captured["json"] == {"src_url": "http://src/y.webp", "src_type": "image"}
+
+
+def test_upload_stream_bad_src_type():
+    with pytest.raises(OssError):
+        upload_stream("http://src/a", src_type="audio", settings=_settings())
+
+
+def test_to_oss_fallback_on_http_error():
+    def post(url, json=None, headers=None, timeout=None):
+        raise httpx.ConnectError("boom")
+
+    # 失败返回原 url,永不抛
+    assert to_oss("http://src/z.mp4", "video",
+                  settings=_settings(), http_post=post) == "http://src/z.mp4"
+
+
+def test_to_oss_fallback_on_business_error():
+    def post(url, json=None, headers=None, timeout=None):
+        return _Resp({"status": 500, "msg": "boom"})
+
+    assert to_oss("http://src/z.mp4", "video",
+                  settings=_settings(), http_post=post) == "http://src/z.mp4"
+
+
+def test_to_oss_success():
+    def post(url, json=None, headers=None, timeout=None):
+        return _Resp({"status": 0, "oss_object": {"cdn_url": "https://res/z.mp4"}})
+
+    assert to_oss("http://src/z.mp4", "video",
+                  settings=_settings(), http_post=post) == "https://res/z.mp4"
+
+
+def test_to_oss_empty_url_passthrough():
+    assert to_oss("", "image", settings=_settings()) == ""

+ 90 - 0
tests/test_search.py

@@ -0,0 +1,90 @@
+"""关键词搜索离线测试:纯 parser + search_keyword(去重/翻页/截断/SearchError)。"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from creation_knowledge.config import PgConfig, Settings
+from creation_knowledge.integrations.crawler import RateLimiter
+from creation_knowledge.integrations.search import (
+    SearchError, parse_search_response, search_keyword,
+)
+
+FIX = Path(__file__).parent / "fixtures"
+
+
+def _settings() -> Settings:
+    return Settings(
+        pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
+        crawler_base_url="http://crawler.test", crawler_key="", crawler_timeout=30,
+        video_model="m", gemini_api_key="",
+        openrouter_base_url="b", openrouter_api_key="k",
+        llm_model="m", max_cards=12, frames_dir="f", douyin_ratio="540p", data_dir="")
+
+
+def _no_wait() -> RateLimiter:
+    return RateLimiter(min_interval_seconds=0.0)
+
+
+class _Resp:
+    def __init__(self, payload): self._p = payload
+    def raise_for_status(self): return None
+    def json(self): return self._p
+
+
+class _FakeClient:
+    """按调用次序返回预置 payload;记录每次 post 的 body。"""
+    def __init__(self, pages): self.pages = list(pages); self.calls = []; self.closed = False
+    def post(self, url, json=None, headers=None, timeout=None):
+        self.calls.append(json)
+        return _Resp(self.pages.pop(0))
+    def close(self): self.closed = True
+
+
+def test_parse_from_fixture():
+    resp = json.loads((FIX / "xhs_search_分镜脚本.json").read_text("utf-8"))
+    ids, has_more, cursor = parse_search_response(resp)
+    assert ids == ["6a2bce890000000006034be4", "68282529000000002100e28c"]
+    assert has_more is True and cursor == "2"
+
+
+def test_parse_business_error_raises():
+    with pytest.raises(SearchError):
+        parse_search_response({"code": 10000, "msg": "未知错误", "data": None})
+
+
+def test_search_dedup_and_limit():
+    # page1 含重复 id,page2 提供更多;limit=3 → 截断且去重
+    page1 = {"code": 0, "data": {"has_more": True, "next_cursor": "2",
+             "data": [{"id": "a"}, {"id": "a"}, {"id": "b"}]}}
+    page2 = {"code": 0, "data": {"has_more": True, "next_cursor": "3",
+             "data": [{"id": "b"}, {"id": "c"}, {"id": "d"}]}}
+    client = _FakeClient([page1, page2])
+    out = search_keyword("分镜", settings=_settings(), http_client=client,
+                         rate_limiter=_no_wait(), limit=3)
+    assert out == ["a", "b", "c"]
+
+
+def test_search_stops_on_no_more():
+    page1 = {"code": 0, "data": {"has_more": False, "next_cursor": "",
+             "data": [{"id": "a"}, {"id": "b"}]}}
+    client = _FakeClient([page1])
+    out = search_keyword("分镜", settings=_settings(), http_client=client,
+                         rate_limiter=_no_wait(), limit=10)
+    assert out == ["a", "b"]   # has_more=False → 不再翻页,不报错
+    assert client.calls[0]["keyword"] == "分镜"
+
+
+def test_search_business_error_raises():
+    client = _FakeClient([{"code": 10000, "msg": "未知错误", "data": None}])
+    with pytest.raises(SearchError):
+        search_keyword("x", settings=_settings(), http_client=client,
+                       rate_limiter=_no_wait())
+
+
+def test_search_unsupported_platform():
+    with pytest.raises(SearchError):
+        search_keyword("x", platform="bilibili", settings=_settings(),
+                       rate_limiter=_no_wait())

+ 25 - 0
tests/test_video_extract.py

@@ -86,3 +86,28 @@ def test_video_extract_saves_and_sets_url(tmp_path):
 
     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)
+
+
+def test_extract_video_oss_direct_feed():
+    """传 oss_video_url:不下载、不 base64,请求体直接用该 http 直链;段卡 url 指向它。"""
+    fake, captured = _fake_post()
+    downloads = []
+
+    def spy_downloader(url, platform):
+        downloads.append(url)
+        return b"should-not-be-called"
+
+    post = Post(id="dy_2", platform="douyin", url="u", content_id="2",
+                content_type="video", video_urls=["http://cdn/raw.mp4?sig=tmp"])
+
+    out = extract_video(post, settings=_settings(), http_post=fake,
+                        downloader=spy_downloader,
+                        oss_video_url="https://res.cybertogether.net/crawler/video/x.mp4")
+
+    assert downloads == []  # 未触发任何下载
+    parts = captured["json"]["messages"][0]["content"]
+    vparts = [p for p in parts if p["type"] == "video_url"]
+    assert vparts and vparts[0]["video_url"]["url"] == "https://res.cybertogether.net/crawler/video/x.mp4"
+    assert not vparts[0]["video_url"]["url"].startswith("data:")  # 不是 base64
+    assert out.cards and len(post.cards) == 2
+    assert all(c.url == "https://res.cybertogether.net/crawler/video/x.mp4" for c in post.cards)

+ 576 - 0
uv.lock

@@ -0,0 +1,576 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+    "python_full_version >= '3.12'",
+    "python_full_version < '3.12'",
+]
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.6.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "creation-knowledge"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+    { name = "fastapi" },
+    { name = "httpx" },
+    { name = "imageio-ffmpeg" },
+    { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+    { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+    { name = "psycopg2-binary" },
+    { name = "pydantic" },
+    { name = "pytest" },
+    { name = "uvicorn" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "fastapi", specifier = ">=0.115.0" },
+    { name = "httpx", specifier = ">=0.27.0" },
+    { name = "imageio-ffmpeg", specifier = ">=0.4.9" },
+    { name = "numpy", specifier = ">=1.26" },
+    { name = "psycopg2-binary", specifier = ">=2.9.9" },
+    { name = "pydantic", specifier = ">=2.8.0" },
+    { name = "pytest", specifier = ">=8.2.0" },
+    { name = "uvicorn", specifier = ">=0.30.0" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.138.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-doc" },
+    { name = "pydantic" },
+    { name = "starlette" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/5e8defe249899c0dc900643695fc07829a67fc88b4ff2cdb03fcbdbf5a4b/fastapi-0.138.1.tar.gz", hash = "sha256:96e3702dce09ee0dce48856135620d3d865ca684a79fe7513fd7b13a12f82862", size = 419646, upload-time = "2026-06-25T15:40:42.115Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182, upload-time = "2026-06-25T15:40:40.771Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "certifi" },
+    { name = "httpcore" },
+    { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "imageio-ffmpeg"
+version = "0.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" },
+    { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" },
+    { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.6"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.12'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
+    { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
+    { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
+    { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
+    { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
+    { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
+    { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
+    { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
+    { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
+    { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
+    { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
+    { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
+    { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
+    { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
+    { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
+    { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
+    { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
+    { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
+    { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
+    { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
+    { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
+    { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
+    { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
+    { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
+    { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
+    { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
+    { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
+    { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
+    { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
+    { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
+    { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.12'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" },
+    { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" },
+    { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" },
+    { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" },
+    { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" },
+    { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" },
+    { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" },
+    { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" },
+    { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" },
+    { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" },
+    { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" },
+    { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" },
+    { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" },
+    { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" },
+    { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" },
+    { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "psycopg2-binary"
+version = "2.9.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d5/19/d4ce60954f3bb9d8e3bc5e5c4d1f2487de2d3851bf2391d54954c9df12a6/psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", size = 3712338, upload-time = "2026-04-20T23:34:03.961Z" },
+    { url = "https://files.pythonhosted.org/packages/53/71/c85409ee0d78890f0660eff262e815e7dd2bb741a17611d82e9e8cd9dc5e/psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", size = 3822407, upload-time = "2026-04-20T23:34:05.977Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/ed/60486c2c7f0d4d1ede2bfb1ed27e2498477ce646bc7f6b2759906303117e/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", size = 4578425, upload-time = "2026-04-20T23:34:08.246Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/b9/656cb03fad9f4f49f2145c334b1126ee75189929ca4e6187d485a2d59951/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", size = 4273709, upload-time = "2026-04-20T23:34:10.974Z" },
+    { url = "https://files.pythonhosted.org/packages/99/66/08cf0da0e25cc6fb142c89be45fc8418792858f0c4cbff5e24530ff02cd6/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", size = 5893779, upload-time = "2026-04-20T23:34:13.905Z" },
+    { url = "https://files.pythonhosted.org/packages/17/d7/eecd9ce8e146d3721115d82d3836efdbb712187e4590325df549989d18f4/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", size = 4109308, upload-time = "2026-04-20T23:34:16.761Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/2e/b1dc289b362cc8d45697b57eefbd673186f49a4ea0906928988e3affcc98/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", size = 3654405, upload-time = "2026-04-20T23:34:19.303Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/e4/4c4aea6473214dbdbd0fbba11aa4691e76dc01722c55724c5951719865ff/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", size = 3299187, upload-time = "2026-04-20T23:34:21.206Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/5d/b03b99986446a4f57b170ed9a2579fb7ff9783ca0fa5226b19db99737fee/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", size = 3047716, upload-time = "2026-04-20T23:34:23.077Z" },
+    { url = "https://files.pythonhosted.org/packages/14/86/382ee4afbd1d97500c9d2862b20c2fdeddf4b7335e984df3fb4309f64108/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", size = 3349237, upload-time = "2026-04-20T23:34:25.211Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/16/9a57c75ba1eda7165c017342f526810d5f5a12647dde749c99ae9a7141d7/psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", size = 2757036, upload-time = "2026-04-20T23:34:27.77Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" },
+    { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" },
+    { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" },
+    { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" },
+    { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" },
+    { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" },
+    { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" },
+    { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" },
+    { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" },
+    { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" },
+    { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" },
+    { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" },
+    { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" },
+    { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" },
+    { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-types" },
+    { name = "pydantic-core" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
+    { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
+    { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
+    { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
+    { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
+    { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
+    { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+    { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+    { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+    { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+    { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+    { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+    { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+    { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+    { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+    { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+    { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+    { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+    { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+    { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+    { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+    { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+    { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+    { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+    { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+    { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+    { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+    { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+    { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+    { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+    { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+    { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+    { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
+    { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
+    { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+    { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+    { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
+    { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
+    { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.49.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
+]

+ 15 - 2
web/index.html

@@ -352,6 +352,8 @@ function App(){
   const [items,setItems]=useState([]);
   const [sel,setSel]=useState(0);
   const [scope,setScope]=useState(null);
+  const [runs,setRuns]=useState([]);     // web/runs/index.json 清单(搜索批次)
+  const [run,setRun]=useState(null);     // 选中的 run slug;null=默认样本 frameworks.json
   const [pm,setPm]=useState(null);
   const [json,setJson]=useState(null);
   const [zoom,setZoom]=useState(null);
@@ -359,8 +361,13 @@ function App(){
   const [hl,setHl]=useState(false);
   const [gloss,setGloss]=useState(false);
   const [cstage,setCstage]=useState(false);
-  useEffect(()=>{Promise.all([j("/frameworks.json"),j("/payloads.json").catch(()=>[])]).then(([f,pl])=>{
-    setItems((f.posts||[]).map(p=>({...p,payloads:(pl||[]).filter(x=>(x.source||{}).id===(p.source_id||("xhs_"+p.post_id)))})));});},[]);
+  useEffect(()=>{j("/runs/index.json").then(r=>setRuns(Array.isArray(r)?r:[])).catch(()=>setRuns([]));},[]);
+  useEffect(()=>{
+    const fw=run?("/runs/"+run+".json"):"/frameworks.json";
+    const pls=run?("/runs/"+run+".payloads.json"):"/payloads.json";
+    Promise.all([j(fw),j(pls).catch(()=>[])]).then(([f,pl])=>{
+      setSel(0);setScope(null);
+      setItems((f.posts||[]).map(p=>({...p,payloads:(pl||[]).filter(x=>(x.source||{}).id===(p.source_id||("xhs_"+p.post_id)))})));});},[run]);
   useEffect(()=>{document.body.classList.toggle("hl",hl);},[hl]);
   function openPrompt(key){
     const st=STEPMAP[key]; if(!st) return;
@@ -383,6 +390,12 @@ function App(){
   return <div className="app">
     <header className="top">
       <h1>创作知识的解构</h1>
+      {runs.length>0 && <div className="picker"><span className="lab">批次:</span>
+        <select className="runsel" value={run||""} onChange={e=>setRun(e.target.value||null)}>
+          <option value="">样本(frameworks)</option>
+          {runs.map((r,i)=><option key={i} value={r.slug}>{(r.queries||[]).join("+")||r.slug}({r.count}帖)</option>)}
+        </select>
+      </div>}
       <div className="picker"><span className="lab">帖子:</span>
         {items.map((x,i)=><div key={i} className={"pill"+(sel===i?" on":"")} onClick={()=>{setSel(i);setScope(null);}}>{x.title.slice(0,16)}<span className="n">{(x.knowledges||[]).length}颗</span></div>)}
       </div>

+ 102 - 0
开发文档/产品文档.md

@@ -0,0 +1,102 @@
+# 创作知识解构 · 产品文档
+
+> 配套:本目录《技术文档.md》(工程实现)、《创作知识拆解框架.md》(方法论设计依据)。
+> 本文讲**产品全貌 + 下一迭代要补的新能力**,不讲代码细节。
+
+---
+
+## 一、产品是什么
+
+**一句话**:把一篇创作帖(小红书 / 抖音的图文或视频教程),自动拆成 **N 颗**可被下游单独检索复用的「创作知识」,每颗按自己的类型成形,收敛成入库请求体,并在前端按类型可视化。
+
+- **输入**:一篇创作内容(正文 + 图 / 视频)。
+- **输出**:一个或多个 ingest payload(**一颗知识 = 一个 payload**,一帖通常 1–N 颗,共享同一 `source.id`),落到本地 JSON 供前端查看、供下游入库检索。
+
+### 单位:颗
+
+「颗」= 一条独立可交付、能被下游**单独检索复用**的创作知识。一帖 → N 颗(可混类型)。
+
+### 三类对等 lane(无主从)
+
+| 类型 | 它是什么 | 成形 |
+|---|---|---|
+| **How 工序** | 不可跳步地把输入做成成品 | 目标 + 有序步骤(输入/方法/产出) |
+| **What 构成** | 界定 / 列举 / 分类某创作对象 | 概要 + kind(子集/多维关系/序列)+ 扁平条目 |
+| **Why 原理** | 解释某做法 / 判断背后的道理 | 一段「阐述」(是什么 + 为什么,忠实整合原帖) |
+
+> 边界:只收**指导「怎么想 / 怎么判断 / 怎么搭框架」**的创作知识;剔除**「怎么操作设备软件」**的纯工艺(制作:器材/打光/剪辑/调色/导出)。
+
+---
+
+## 二、给谁用、解决什么
+
+**直接使用者**:内容创作知识库的建设方 —— 把散落在帖子里的创作方法论结构化、标签化、可检索化。
+
+**下游消费者**:「脚本构建主 Agent」的取数 subagent。主 Agent 分阶段构建脚本(起步 → 产生脉络 → 产生元素 → 填入脉络),各阶段**只读检索**复用这些知识颗。
+
+> ⚠️ 两种「阶段 / 作用域」别混:
+> - 我们解构时打的**作用域**(实质 / 形式 / 感受 / 作用 / 意图,5 棵分类树)—— 描述这颗知识本身。
+> - 下游消费时的**消费 scope**(这条知识在脚本构建哪个阶段有用)—— 是另一回事。
+
+---
+
+## 三、端到端用户流
+
+### 现状(已落地)
+
+```
+人工选定帖子 ID(写死 9 个) → 取数读懂 → 拆颗+判类型+成形+打标签 → 作用域定位 → 组装 payload
+  → web/frameworks.json + web/payloads.json → 前端一页多颗、按类型渲染
+```
+
+痛点:**入口靠人肉填 content_id**,无法按主题量产;live 帖媒体受平台防盗链限制,要本地下载中转。
+
+### 新能力(下一迭代)
+
+```
+输入关键词(query) → 自动搜索召回一批帖 → 逐帖取数
+  → OSS 转存帖子媒体(图/视频)拿到公网 CDN 直链 → 直接把直链丢给模型分析
+  → 同一套拆解管线 → 结果落到独立的 run JSON(web/runs/<slug>.json)
+  → 前端「run 选择器」切换查看不同主题批次
+```
+
+从「我知道是哪几篇 → 填 ID」升级为「我想要某主题的创作知识 → 给关键词 → 自动产出一批」。
+
+---
+
+## 四、新能力的价值
+
+| 维度 | 现状 | 新能力后 |
+|---|---|---|
+| **召回** | 手工填 content_id,一次几篇 | 关键词搜索,按主题批量召回 |
+| **媒体可达** | 本地下载躲防盗链 / 视频下载+base64 转码 | OSS 转存成公网 CDN 直链,模型直接读 |
+| **时效** | 抖音等临时签名链易过期 | 取数后**立即转存**固化,绕过时效 |
+| **产物组织** | 单文件、9 帖样本 | 每个主题批次独立 run,互不覆盖、可回看历史 |
+
+> 关键验证:已实测**模型能直接读 OSS 上的 http 视频链**——视频不必再下载到本地、不必 base64 转码,省带宽省时间。
+
+---
+
+## 五、质量护栏(为什么产出可信)
+
+产物不是「LLM 一把梭」,而是多道**对抗投票闸**层层过滤,边界一律取保守:
+
+- **创作 vs 制作闸**:admit + refute 两票判定,分歧上 tiebreak,边界**倾向排除**——把「用 AI 一键生成视频」这类工具操作帖挡在外面。
+- **假 how 根治**:代码信号(步骤是否真有先后依赖)+ 对抗投票,把「7 种叙事结构」这种被硬串成步骤的离散构成,重拆回 What/Why。
+- **why 轻闸**:放之四海皆准的废话直接 drop;疑似错分类的标记待人工,不丢内容。
+- **作用域定位复用**:每个标签值用向量最近邻对到 1855 个真实分类树节点——对得上复用原名(不重复造词),对不上才新建(顺手丰富树)。
+
+设计原则:**失败不丢内容**(API 抖动有兜底、判错只标记不删)、**先判类型再成形**(不默认 how、不硬造假 how)。
+
+---
+
+## 六、现状与路线
+
+**已落地**:9 帖样本 → 24 颗(What 15 / Why 5 / How 4),0 ERROR;前端按类型渲染 + 作用域定位 drawer + 提示词溯源。
+
+**本次迭代**:query 搜索 + OSS 转存 + run 隔离产出(先上小红书)。
+
+**后续路线**:
+- 抖音 / 更多平台搜索接入(抖音 keyword 接口需鉴权,待打通)。
+- 真正入库 `POST /api/v1/knowledge/ingest`(当前止于本地 JSON)。
+- payload schema 与代码产出对齐(`dim_attributes` 等细节,见技术文档「已知问题」)。

+ 0 - 0
创作知识拆解框架.md → 开发文档/创作知识拆解框架.md


+ 249 - 0
开发文档/技术文档.md

@@ -0,0 +1,249 @@
+# 创作知识解构 · 技术文档
+
+> 配套:本目录《产品文档.md》《创作知识拆解框架.md》。
+> 本文 = **A 现有系统架构** + **B 下一迭代详细开发步骤** + **C 测试核验** + **D 实施顺序** + **E 已知问题**。
+> 所有 `文件:行号` 可点开核对(行号对应当前 main,实现时以实际为准)。
+
+---
+
+# A. 现有系统架构
+
+## A.1 总览
+
+一条 Python 编排流水线,把帖子拆成知识颗并组装 ingest payload。入口 `scripts/decompose.py`,方法论真源是 `创作知识提取-skill/`(phase 文档直接当 LLM system prompt 喂),产物给 `web/index.html` 渲染。
+
+```
+① 读懂 → ①.5 创作闸 → ② 成形 → ②.5 假how根治 → ②.7 why轻闸 → ③ 作用域 → ⑤ 组装
+```
+
+主循环:[`scripts/decompose.py:436`](../scripts/decompose.py) `main()`。每帖逐阶段跑,任一总闸不过则短路跳过。
+
+## A.2 流水线各阶段
+
+| 阶段 | 函数 / 位置 | 干什么 | 用的 prompt |
+|---|---|---|---|
+| ① 读懂 | `read_one` [decompose.py:98](../scripts/decompose.py) | 图文→`GeminiExtractor` 读图;视频→`video_extract` 下 mp4+base64 喂 Gemini | `extract.txt` / `extract_video.txt` |
+| ① 总闸 | [decompose.py:453](../scripts/decompose.py) | `is_empty`(纯展示/无知识)→ 短路 | — |
+| ①.5 创作闸 | `creation_gate` [decompose.py:142](../scripts/decompose.py) | admit+refute 两票,分歧 tiebreak,边界倾向排除 | `gate_admit/refute/tiebreak.txt` |
+| ② 成形 | `shape` [decompose.py:126](../scripts/decompose.py) | 一次出全部颗+类型+轻标签,作用域留空 | `phase1-frame.md` |
+| ②.5 假how | `fix_fake_hows` [decompose.py:216](../scripts/decompose.py) | 代码信号+对抗投票;假how→`reshape_nonhow` 重拆 what/why | `gate_how_admit/refute/tiebreak.txt` |
+| ②.7 why闸 | `drop_fake_whys` [decompose.py:232](../scripts/decompose.py) | 废话 drop;疑似错分类标 `inferred` 保留 | `gate_why_refute.txt` |
+| 守卫 | [decompose.py:466](../scripts/decompose.py) | 业务阶段/创作阶段/kind 受控值归一、组件 parent 校验 | — |
+| ③ 作用域 | `scope_candidates`→`nounify_scopes`→`apply_scopes` [decompose.py:270](../scripts/decompose.py) | 5 类带值候选 → 名词化 → 连接词拆原子 → 向量定位 | `phase2-scope.md` / `normalize_scope.txt` |
+| ⑤ 组装 | `build_payload` [decompose.py:398](../scripts/decompose.py) | content 按类型分拼、scopes 并集、custom_ext | — |
+
+**关键常量**([decompose.py:48-64](../scripts/decompose.py)):`SOURCES`(写死 9 帖入口)、`CSTAGE`(创作阶段 5 值)、`KINDS`(what.kind 3 值)、`REUSE_THRESHOLD=0.90`、`OUT` env 后缀(`OUT=_v2`→写 `frameworks_v2.json` 不覆盖)。
+
+## A.3 关键模块
+
+| 模块 | 职责 | 要点 |
+|---|---|---|
+| `integrations/crawler.py` | 取数 | `PLATFORMS` 路由 detail path([:24](../creation_knowledge/integrations/crawler.py));`fetch_post_detail(content_id)`([:153](../creation_knowledge/integrations/crawler.py));`parse_detail_response` 纯函数可离线测;`RateLimiter` 15s/bucket([:64](../creation_knowledge/integrations/crawler.py)) |
+| `integrations/extractor.py` | 读图 | `GeminiExtractor`,**图片直传 `image_url`**([:105](../creation_knowledge/integrations/extractor.py)),走 OpenRouter |
+| `integrations/video_extract.py` | 读视频 | `extract_video` 下载 mp4 → **base64 data_url** 喂模型([:128](../creation_knowledge/integrations/video_extract.py));签名 已有 `save_path/public_url`([:76](../creation_knowledge/integrations/video_extract.py));`_default_download` 带 iOS UA + 平台 Referer 躲防盗链 |
+| `integrations/llm.py` | 文本 LLM | `chat_json(system,user)` 强约束 JSON,**3 轮重试 + self-repair**([:46](../creation_knowledge/integrations/llm.py)),注入式 `http_post` 可离线测 |
+| `embedding.py` | 向量 | `embed_text` 走火山方舟 Doubao multimodal,2048 维 |
+| `scripts/scope_link.py` | 作用域定位 | `ScopeLinker.link(value, source_type, top_k)` numpy 暴力余弦最近邻;树缓存 `scope_trees/`(trees_index.json + trees_embeddings.npy,**1855 节点**:实质972/形式624/作用185/感受46/意图28) |
+| `config.py` | 配置 | `Settings.from_env`([:93](../creation_knowledge/config.py));`crawler_base_url` 默认 `http://crawler.aiddit.com` |
+
+## A.4 数据契约
+
+### frameworks.json(前端主数据,[web/frameworks.json](../web/frameworks.json))
+```
+{ count, posts:[ {
+    post_id, source_id, title, platform, url,
+    media:{ type:"image"|"video", video_url, images:[] },
+    cards:[ {index, content, image_url|video_url, start, end} ],
+    knowledges:[ {
+      id, type:"how"|"what"|"why", role:"主"|"组件", parent:null|{how_id,step}, title, 业务阶段:[],
+      // how:  purpose, steps:[{id,input,directive,output,出处,创作阶段,动作,作用域:[]}]
+      // what: 概要, kind, 维度拆分规则, body:[{item_name,item_desc,作用域:[]}]
+      // why:  阐述
+      作用域:[ {scope_type,value,candidate,link:"复用"|"新建",score,top:[{name,score,path}]} ]
+    } ]
+} ] }
+```
+
+### payloads.json(入库体,[web/payloads.json](../web/payloads.json))
+```
+[ { source:{id,source_type:"post",title,author,source_metadata:{platform,url}},
+    title, content, dim_creations:["创作"], dim_attributes:["how"|"what"|"why"],
+    scopes:[{scope_type,value}], custom_ext:[{key,type:"str",value}] } ]
+```
+- `content`:How=拍平字符串;What=`{name,kind,维度拆分规则,body[]}`;Why=`{name,desc}`([`build_content` decompose.py:362](../scripts/decompose.py))。
+
+### Ingest schema(最终裁判)
+`创作知识提取-skill/format/ingest-payload-{how,what,why}.schema.json` —— 字段约束与受控词表的权威定义。
+
+## A.5 前端数据绑定([web/index.html](../web/index.html))
+
+- 加载:[`index.html:362`](../web/index.html) `Promise.all([j("/frameworks.json"), j("/payloads.json")])`,`cache:"no-store"`。
+- 渲染:HowTable(工序表)/ WhatCard(构成卡)/ WhyCard(阐述卡);组件颗 cross-ref 跳转;作用域 drawer(候选→top-K→复用/新建);提示词溯源(点开产出该阶段的 prompt)。
+- payload 按 `source.id` 匹配挂到帖子([:363](../web/index.html))。
+- `web/prompt-read`→`../prompts`、`web/prompt-skill`→`../创作知识提取-skill/extraction` 是**符号链接**(前端静态读 prompt)。
+
+## A.6 运行环境
+
+- Python 3.12,**uv** 管理(`.venv` / `uv.lock`)。跑:`PYTHONPATH=. uv run python scripts/decompose.py`;测:`uv run pytest`。
+- 须在能访问内网 `crawler.aiddit.com` 的环境(云端 / VPN)跑取数。
+- 测试全离线:`pytest` 用 fixture + 注入假 `http_post`,`testpaths=["tests"]`、`pythonpath=["."]`(pyproject.toml)。
+
+---
+
+# B. 下一迭代 · 详细开发步骤
+
+**目标**:`query 搜索 → OSS 转存 → 模型直读 CDN → run 隔离产出`。**仅小红书**。已实测:小红书搜索 `code:0`、OSS `status:0` 回 `cdn_url`、Gemini 直读 OSS http 视频链 HTTP 200。新 env 已写入 `.env`(`CK_OSS_UPLOAD_URL` 等 5 个)。
+
+## B.0 实测确认的接口契约
+
+**搜索**(host = `settings.crawler_base_url`):
+```
+POST /crawler/xiao_hong_shu/keyword
+body { keyword, content_type:"图文"|"视频", sort_type:"综合", publish_time:"", cursor:"" }
+→ { code:0, data:{ has_more, next_cursor, data:[ {id:<content_id>, note_card:{...预览...}} ] } }
+```
+content_id = `data.data[].id`;翻页 = `has_more` / `next_cursor`;搜索只给预览,**全文/全图/视频仍需 `fetch_post_detail` 逐 id 取详情**。
+
+**OSS 转存**:
+```
+POST http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream
+body { src_url, src_type:"video"|"image" }
+→ { status:0, oss_object:{ cdn_url:"https://res.cybertogether.net/...", oss_object_key } }
+```
+成功 = `status==0`,取 `oss_object.cdn_url`。
+
+## B.1 新增 2 模块(`creation_knowledge/integrations/`)
+
+风格对齐 crawler:**纯 parser 函数(离线可测)+ 注入式 http**。
+
+### `search.py`
+```python
+SEARCH_PATHS = {
+    "xiaohongshu": "/crawler/xiao_hong_shu/keyword",
+    "douyin":      "/crawler/dou_yin/keyword",   # TODO: 实测 code 10000,疑需 cookie/鉴权
+}
+class SearchError(RuntimeError): ...
+
+def parse_search_response(response: dict, *, platform="xiaohongshu") -> list[dict]:
+    """纯函数。信封 {code,msg,data:{has_more,next_cursor,data:[...]}}。
+    取 data.data[].id(+note_card 预览);code!=0 抛 SearchError。"""
+
+def search_keyword(keyword, *, platform="xiaohongshu", content_type="图文",
+                   sort_type="综合", limit=5, publish_time="",
+                   settings=None, http_client=None, rate_limiter=None, env_file=".env") -> list[str]:
+    """搜索 → content_id 列表(翻页直到够 limit 或 has_more=False;内部按 id 去重、截断 limit)。
+    用 settings.crawler_base_url + SEARCH_PATHS[platform];复用 crawler.RateLimiter('{platform}_keyword')。"""
+```
+
+### `oss.py`
+```python
+class OssError(RuntimeError): ...
+
+def parse_oss_response(response: dict) -> str:
+    """纯函数。{status:0,oss_object:{cdn_url}} → cdn_url;status!=0 或缺字段抛 OssError。"""
+
+def upload_stream(src_url, *, src_type, settings=None, http_post=httpx.post, env_file=".env") -> str:
+    """POST settings.oss_upload_url body{src_url,src_type};timeout=settings.oss_upload_timeout_seconds。"""
+
+def to_oss(src_url, src_type, *, settings, http_post=httpx.post) -> str:
+    """兜底包装:成功返回 cdn_url,任何失败返回原 src_url(永不抛)——decompose 用它,OSS 挂了降级不阻断。"""
+```
+
+## B.2 改 4 处
+
+### ① `config.py` — Settings 加 5 字段(**必须带默认值**)
+```python
+# dataclass 字段(加在 data_dir 后)
+oss_upload_url: str = "http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream"
+oss_upload_timeout_seconds: int = 60
+search_default_limit: int = 5
+search_content_type: str = "图文"
+search_sort_type: str = "综合"
+# from_env 加读取
+oss_upload_url=env_value("CK_OSS_UPLOAD_URL", file_env, "http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream"),
+oss_upload_timeout_seconds=int(env_value("CK_OSS_UPLOAD_TIMEOUT_SECONDS", file_env, "60")),
+search_default_limit=int(env_value("CK_SEARCH_DEFAULT_LIMIT", file_env, "5")),
+search_content_type=env_value("CK_SEARCH_CONTENT_TYPE", file_env, "图文"),
+search_sort_type=env_value("CK_SEARCH_SORT_TYPE", file_env, "综合"),
+```
+> ⚠️ **默认值是硬约束**:[`tests/test_video_extract.py:27`](../tests/test_video_extract.py) 用关键字逐字段构造 `Settings(...)` 且不传新字段,无默认会立刻断测。
+
+### ② `video_extract.py` — `extract_video` 加 `oss_video_url`(增量、向后兼容)
+```python
+def extract_video(post, *, ..., oss_video_url: Optional[str] = None) -> ExtractedContent:
+    if oss_video_url:
+        media_part = {"type": "video_url", "video_url": {"url": oss_video_url}}   # 跳过下载+base64
+        public_url = public_url or oss_video_url                                  # 段卡指向 OSS url
+    else:
+        ... 原 下载→save_path→base64 ...                                          # fixture/兜底不变
+        media_part = {"type": "video_url", "video_url": {"url": data_url}}
+```
+
+### ③ `decompose.py` — 入口 + 媒体 + 产出
+- **QUERIES 常量**(`SOURCES` 后,并存):`QUERIES = [{"query":"分镜脚本","platform":"xiaohongshu","content_type":"图文","limit":5}]`。
+- **抽 `process_one(src, settings, extractor, linker) -> (post_meta, payloads)`**:把 `main()` 循环体([:445-481](../scripts/decompose.py))原样抽出,**零行为变更**;先用 SOURCES 跑出与现 `frameworks.json` diff 为空验证 parity。
+- **`expand_queries(QUERIES, settings)`**:逐 query `search_keyword` → ids;`seen` 集**跨 query 去重**;搜索失败打印并跳过该 query。产出 `[{cid,platform,from:"live",query}]`。
+- **`read_one` 媒体改 OSS**(取数后**立即**转存躲时效):
+  - 图文([:108-117](../scripts/decompose.py)):live 帖每张 `to_oss(url,"image",settings=...)` 替掉 `_download_images`;cdn_url 写回 `post.image_urls` / `post.cards[*].url` **再喂** `extractor.extract`(extractor 直读 `card.url`)。fixture 仍走本地 `/data` 路径。
+  - 视频([:100-107](../scripts/decompose.py)):`oss_url = to_oss(post.video_urls[0],"video",...)` → `extract_video(post,...,oss_video_url=oss_url)`;`media={"type":"video","video_url":oss_url,...}`。`to_oss` 失败返回原 url → 传 `oss_video_url=None` 回 base64 兜底。
+  - 媒体类型:复用现有 `if post.video_urls:` 判定。
+- **run 隔离产出**:`slugify(text)`(中英→`-`,去空);写 `web/runs/<slug>.json`(同名存在则加 `-{timestamp}` 保留历史)+ 配套 `web/runs/<slug>.payloads.json`(前端按 `source.id` 配对,须同行)+ 追加 `web/runs/index.json` 清单 `[{slug,query,count,created_at}]`。**不覆盖 `web/frameworks.json`**。
+
+### ④ `web/index.html` — run 选择器(小改)
+- data-load `useEffect`([:361](../web/index.html))加:fetch `/runs/index.json` 填一个 `<select>`/pill 行(近 header)。
+- 选中某 run → 加载 `/runs/<slug>.json` + `/runs/<slug>.payloads.json`;**默认不选仍加载 `/frameworks.json`**(9 帖样本不动)。
+- 渲染树形状兼容(run posts 与 frameworks posts 同形),卡片组件零改。
+
+## B.3 跨切面
+
+| 关注点 | 处理 |
+|---|---|
+| 去重 | `search_keyword` 内按 id;`expand_queries` 的 `seen` 集跨 query |
+| 时效 | 取数后**立即** OSS 转存(抖音/CDN 临时签名链) |
+| 限流 | 复用 `RateLimiter` 15s(搜索 `{platform}_keyword`、详情 `{platform}_detail`);OSS 用 `oss_upload_timeout_seconds`=60 |
+| 兜底 | OSS 失败→原 url;搜索失败→跳过该 query;视频 OSS 失败→回 base64;详情失败→现有 error-post 路径不变 |
+
+---
+
+# C. 测试与核验
+
+## C.1 必须保持绿(`uv run pytest`,全离线)
+`test_crawler` / `test_extractor` / `test_video_extract` / `test_media` / `test_video_frames`。
+保障:`Settings` 新字段带默认(护住 [test_video_extract.py:27](../tests/test_video_extract.py) 的 `Settings(...)`)+ `extract_video` 改动为增量(`oss_video_url=None` 默认走原路)。
+
+## C.2 新增离线单测(沿用 `_Resp` + 注入式 http 模式)
+- **`test_search.py`**:`parse_search_response` 取 id;`search_keyword` 去重+翻页+截断(假 client 返回重复 id + `has_more`);`code 10000` → `SearchError`。新增 fixture `tests/fixtures/xhs_search_*.json`(裸 `{code,msg,data}`)。
+- **`test_oss.py`**:`parse_oss_response` 取 cdn_url;`status!=0` → `OssError`;`to_oss` 注入异常 → 返回原 url。
+- **`test_decompose_helpers.py`**:`slugify`(中英/防碰撞);`expand_queries` 跨 query 去重(monkeypatch `search_keyword` 返回重叠 id)。
+- **`test_video_extract.py` 补** `test_extract_video_oss_direct_feed`:传 `oss_video_url`,断言**未调用下载** + 请求体 `video_url.url` == 该 http url(用假 `http_post` 捕获)。
+
+## C.3 端到端手测(真网,跑一次)
+1. 真实小红书 `QUERIES` → `PYTHONPATH=. uv run python scripts/decompose.py`。
+2. 确认 `web/runs/<slug>.json` + `.payloads.json` + `index.json` 生成;`media.images`/`media.video_url` 是 `res.cybertogether.net` cdn_url。
+3. 起 `web/` 静态服务 → run 选择器列出新 run → 图/视频无 403 → 三类知识渲染如常。
+
+---
+
+# D. 实施顺序(每步独立可验证)
+
+1. `config.py` 加字段(带默认)+ 读 env → `pytest` 全绿(证默认护住旧构造)。
+2. `oss.py` + `test_oss.py`(独立)。
+3. `search.py` + `test_search.py` + fixture(独立)。
+4. `video_extract.py` 加 `oss_video_url` → 视频测试绿 + 新直喂测试。
+5. `decompose.py` 抽 `process_one` 零行为重构 → SOURCES 跑出与现 `frameworks.json` diff 为空。
+6. `decompose.py` 接入 QUERIES/expand/OSS/slug/run 写盘+manifest → helper 测 + 小红书 dry run。
+7. `web/index.html` run 选择器 + manifest 加载,默认回退 frameworks.json。
+8. 端到端手测。
+
+> 步骤 2–4 互相独立可并行;每步可单独提交、单独测。
+
+---
+
+# E. 已知问题 / 暂缓项
+
+| 问题 | 现象 | 处置 |
+|---|---|---|
+| **dim_attributes 不一致** | 代码发 `["why"]`([`TYPE2ATTR` decompose.py:60](../scripts/decompose.py)),但 `format/ingest-payload-why.schema.json` 要 `const:"why原理"`(how/what 同类:`how工序`/`what构成`) | 本迭代**不改**,待入库 ingest 时统一对齐(确定以哪边为准) |
+| **抖音搜索鉴权** | `POST /crawler/dou_yin/keyword` 实测 `code:10000 未知错误` | 标 TODO;`search.py` 让抖音抛 `SearchError`,`expand_queries` 干净跳过;本迭代只上小红书 |
+| **OSS 串行延迟** | 每张图一次 OSS POST(60s timeout),多图帖串行慢 | 暂接受;后续可并行/限流 |
+| **B站视频** | `video_extract` 对 B站抛错(需 voice_data 音轨 + ffmpeg 合并) | 与本迭代无关,沿用现状 |