From 7eabb49194b539bfe344194e4194f43575fb31ed Mon Sep 17 00:00:00 2001
From: Cai <cai@nbcai.cc>
Date: Sun, 09 Aug 2026 23:15:34 +0800
Subject: [PATCH] feat(project): update collection and media tooling
---
ai-infodev-2/worklog/2026-08-04-bili-dynamic-collector-mvp.md | 118
ana-data/news-青枫浦上Q/20260803-141800_text_这里就是底部坚持住_1232223909666881541.txt | 8
ai-video-downloader/worklog/2026-08-05-extension-ingress-v002-submitted.md | 11
ana-data/news-青枫浦上Q/20260805-091521_article_8.5早盘_1232888061922115591.txt | 263
ana-data/news-青枫浦上Q/20260804-135100_text_做交易要吃的下饭睡得着觉_1232588032685637632.txt | 8
dev-doc/project-doc/B站完整视频下载桥接工具.md | 117
dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002.md | 335
ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2300-heartbeat.md | 13
dev/project-dev/test/bili_authenticated_extension/background_contract.mjs | 203
ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-build-source-approval.json | 9
dev/project-dev/bili_authenticated_extension/background.js | 388
ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1517-heartbeat.md | 28
ana-data/news-青枫浦上Q/20260806-233453_image_8.6复盘_1233480647120519170.png | 3
ana-data/news-青枫浦上Q/20260806-105340_image_市场目前接力情绪很好_1233284483106471972.jpg | 3
dev-doc/project-doc/本地视频语音转写工具.md | 9
dev-doc/开发事项计划.md | 317
ai-infodev/worklog/2026-08-06-视频转文字助手EOF弹窗来源阻断.md | 22
dev/project-dev/bili_authenticated_extension/dependencies/dependency-artifact-manifest.json | 24
ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-canonical-tree-hash-source-approval.json | 9
ana-data/news-青枫浦上Q/20260804-091300_article_8.4早盘_1232516577286422548.txt | 79
dev/project-dev/test/monitor_media_host_responsiveness.ps1 | 974 ++
ana-data/news-青枫浦上Q/20260806-105340_text_市场目前接力情绪很好_1233284483106471972.txt | 14
dev/project-dev/test/bili_authenticated_extension/__init__.py | 12
dev/project-dev/test/bili_authenticated_extension/test_job.py | 108
ai-video-downloader/worklog/2026-08-05-plugin-full-video-narrow-scope-understanding.md | 15
dev-doc/project-doc/B站已登录会话完整视频扩展入口.md | 350
ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2059-heartbeat.md | 9
dev-doc/project-doc/慧博精确研报批量执行器.md | 27
dev/project-dev/test/bili_authenticated_extension/test_native_host.py | 1360 ++
dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003.md | 48
dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002.md | 158
dev/project-dev/test/bili_authenticated_extension/sleep_tree_helper.py | 62
ana-data/news-青枫浦上Q/20260803-221300_text_8.3复盘_1232346363423883282.txt | 8
dev/project-dev/bili_authenticated_extension/sidepanel.css | 9
dev/project-dev/test/bili_authenticated_extension/test_protocol.py | 236
ana-data/news-青枫浦上Q/20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg | 3
ai-infodev-2/worklog/2026-08-06-bili-authenticated-session-hold3-continuation.md | 228
dev/project-dev/bili_authenticated_extension/native_host.py | 819 +
ana-data/news-青枫浦上Q/20260802-211111_video-cover_流水淘沙周复盘_BV1HA3o6oEJJ.webp | 3
dev/project-dev/transcribe_media.py | 118
dev/project-dev/bili_authenticated_extension/worker.py | 914 +
ana-data/news-青枫浦上Q/20260803-193903_video-cover_A股缩量磨底阶段_BV1DVMX6XEPq.webp | 3
ai-video-downloader/worklog/2026-08-06-1254-hourly-status.md | 58
dev-doc/开发执行日志.md | 607 +
dev-doc/开发事项总纲.md | 332
dev-doc/project-doc/目录导读.md | 22
dev/project-dev/test/bili_authenticated_extension/test_worker.py | 479 +
ana-data/news-青枫浦上Q/20260804-224300_article_8.4复盘_1232725342775410720.txt | 140
dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V002.md | 70
ai-video-downloader/worklog/2026-08-04-role-initialization-first-read.md | 23
dev-doc/开发审计报告.md | 1597 +++
dev/project-dev/test/test_hibor_batch_executor.py | 119
ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1617-heartbeat.md | 32
ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-prevalidation-source-approval.json | 9
ana-data/news-青枫浦上Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.txt | 1247 ++
dev-doc/project-doc/视频转文字助手EOF稳定性修复.md | 63
dev/project-dev/bili_authenticated_extension/protocol.py | 355
ana-data/news-青枫浦上Q/20260805-230034_article_8.5复盘_1233100718643085351.txt | 49
dev/project-dev/test/test_bili_video_download_bridge.py | 511 +
dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001.md | 286
dev/project-dev/test/test_bili_dynamic_collector.py | 653 +
ana-data/news-青枫浦上Q/20260803-085700_text_8.3早盘_1232141287338016792.txt | 8
ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0200-heartbeat.md | 21
ai-yanbao-collector/工作说明.md | 6
ai-inforev/worklog/2026-08-06-bili-authenticated-session-build-approval.json | 12
ai-inforev/worklog/2026-08-06-bili-authenticated-session-source-approval.json | 9
dev/project-dev/bili_authenticated_extension/manifest.json | 28
dev/project-dev/bili_authenticated_extension/native-host-manifest.template.json | 9
dev/project-dev/bili_authenticated_extension/install_native_host.ps1 | 451
dev-doc/project-doc/B站博主动态采集辅助工具.md | 193
ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-authenticated-session.md | 16
ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0300-heartbeat.md | 12
dev/project-dev/bili_authenticated_extension/config.example.json | 18
dev/project-dev/bili_dynamic_collector.example.json | 38
ana-data/news-青枫浦上Q/manifest.jsonl | 47
ana-data/news-青枫浦上Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.json | 3132 ++++++
ana-data/news-青枫浦上Q/20260805-114706_text_见微知著_1232927167618220050.txt | 15
dev/project-dev/bili_authenticated_extension/__init__.py | 9
ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json | 12
dev-doc/project-doc/B站博主动态采集辅助工具HOLD5修复实现与验证证据.md | 97
ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0000-heartbeat.md | 16
dev-doc/开发问题记录.md | 30
dev/project-dev/hibor_batch_example.json | 2
ana-data/news-青枫浦上Q/20260804-104800_text_轮动行情_1232541024240271379.txt | 8
ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1751-heartbeat.md | 10
ai-video-downloader/worklog/2026-08-08-authenticated-session-source-pass-build003-started.md | 115
ai-yanbao-collector/worklog/2026-08-06-新模拟器适配与BATCH031续跑.md | 50
ai-infodev/worklog/2026-08-06-慧博新模拟器最小适配.md | 10
ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-1035-status.md | 22
dev-doc/ana-doc/股票估值流水线说明.md | 2
ai-video-downloader/worklog/2026-08-07-hourly-status.md | 34
ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-source-approval.json | 9
dev/project-dev/bili_video_download_bridge.py | 806 +
dev/project-dev/bili_authenticated_extension/job.py | 415
dev/project-dev/bili_dynamic_collector.py | 1479 +++
dev/project-dev/bili_authenticated_extension/sidepanel.js | 71
dev/project-dev/bili_authenticated_extension/constants.py | 83
ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2200-heartbeat.md | 10
ai-video-downloader/worklog/2026-08-07-authenticated-session-build-blocked.md | 76
dev/project-dev/extract_ppt_slides.py | 367
dev/project-dev/test/fixtures/bili_dynamic_collector/dynamic_items.json | 42
dev/project-dev/bili_authenticated_extension/sidepanel.html | 27
dev-doc/project-doc/会议录屏PPT页面提取工具.md | 41
dev/project-dev/bili_authenticated_extension/build_host.ps1 | 724 +
ana-data/news-青枫浦上Q/20260803-130400_text_8.3午盘_1232204861433446409.txt | 8
dev/project-dev/bili_authenticated_extension/dependencies/yt_dlp-2026.7.4-py3-none-any.whl | 3
ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-heartbeat.md | 21
ai-infodev/worklog/2026-08-05-bili-chrome-download-bridge.md | 21
dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001.md | 163
ana-data/news-青枫浦上Q/20260806-090105_article_8.6早盘_1233255470622310421.txt | 90
dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json | 118
ai-video-downloader/draft/2026-08-04-哔哩哔哩动态采集需求.md | 62
ai-video-downloader/worklog/2026-08-04-REQ-BILI-DYNAMIC-COLLECTOR-first-run.md | 54
ai-video-downloader/项目问题反馈.md | 3
ana-data/news-青枫浦上Q/20260803-095700_text_多点耐心_1232156727764320257.txt | 8
ana-data/news-青枫浦上Q/20260805-095345_text_控制好仓位_1232897957496356868.txt | 15
dev/project-dev/test/test_transcribe_media.py | 111
ana-data/news-青枫浦上Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.srt | 2495 +++++
dev/project-dev/test/test_extract_ppt_slides.py | 996 ++
dev/project-dev/bili_video_download_bridge.example.json | 16
ana-data/news-青枫浦上Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.audio.flac | 3
ai-video-downloader/worklog/2026-08-05-extension-ingress-v001-design-hold4.md | 13
ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0100-heartbeat.md | 17
ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json | 9
dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md | 191
dev/project-dev/hibor_batch_executor.py | 97
ana-data/news-青枫浦上Q/20260802-222800_article_8.2复盘_1231979117971243025.txt | 122
ai-infodev/worklog/2026-08-05-bili-authenticated-session-download.md | 21
ai-video-downloader/工作说明.md | 74
ana-data/news-青枫浦上Q/20260809-140809_video-cover_均线_BV189u16KEPe.jpg | 3
ai-video-downloader/worklog/2026-08-04-role-scope-expansion-bilibili-dynamic.md | 25
131 files changed, 27,367 insertions(+), 71 deletions(-)
diff --git a/ai-infodev-2/worklog/2026-08-04-bili-dynamic-collector-mvp.md b/ai-infodev-2/worklog/2026-08-04-bili-dynamic-collector-mvp.md
new file mode 100644
index 0000000..84b7587
--- /dev/null
+++ b/ai-infodev-2/worklog/2026-08-04-bili-dynamic-collector-mvp.md
@@ -0,0 +1,118 @@
+# B 站博主动态采集辅助工具 MVP 开发工作记录
+
+- 日期:2026-08-04
+- AI:`infodev-2`
+- 角色:`dev.developer.project.secondary`
+- 事项:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`
+- 上游:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 请求方 Codex task:`019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5`
+- 共享审核员:`dev.reviewer.project / inforev / 019fb7c5-e1fd-7df0-8ec9-c87edea4215d`
+
+## 1. 接手与分类
+
+已读取需求、项目规范、项目配置、common/本地编码与审计规范、project 开发工作区说明,并核对本角色写入范围。事项限定为一个 stdlib 本地 CLI、一个示例配置、一个测试文件、一份脱敏 fixture 和一页用法,不联网、不控制浏览器、不并发执行业务、不下载内容、不写正式采集目录,按“有需求文档的轻量实现”执行,不创建重型编码方案。
+
+正式 `F:\video\<博主名>\` 与 `ana-data/news-<博主名>/` 的路径授权扩展仍由 project.admin 处理。本开发不等待该配置,只在测试临时目录和 `dev/tmp/bili-dynamic-collector-mvp/` 验证。
+
+## 2. 实现
+
+- `dev/project-dev/bili_dynamic_collector.py`
+ - `check`:最近时间窗、动态/opus/BV/URL 去重、Windows 安全 stem、待办 JSON、append-only manifest。
+ - `move-completed`:明确映射、下载完成度、下载目录边界、链接/reparse、后缀、重名和唯一性预检;partial + fsync + SHA-256 + hard-link no-overwrite 提交;失败追加 `MOVE_FAILED`,成功后再删除源。
+ - `handoff`:复核目标范围和 SHA-256,生成内容寻址且 `sent=false` 的 Codex 原生任务草稿,不自动发送。
+ - 全入口拒绝认证/会话字段;无网络或浏览器依赖。
+- 示例:`dev/project-dev/bili_dynamic_collector.example.json`,只指向开发 tmp。
+- 测试/fixture:`dev/project-dev/test/test_bili_dynamic_collector.py`;`dev/project-dev/test/fixtures/bili_dynamic_collector/dynamic_items.json`。
+- 说明:`dev-doc/project-doc/B站博主动态采集辅助工具.md`;目录入口已补充。
+
+## 3. 验证
+
+- py_compile:PASS。
+- CLI help:PASS。
+- 目标 unittest:`6/6 PASS / 0.610s`。
+- project flat discover:`69/69 PASS / 49.873s`。
+- 离线 CLI smoke:首次 check=`TODO_GENERATED/new=3`;55 字节非媒体 fixture move=`VIDEO_MOVED`;handoff=`HANDOFF_DRAFT_READY/sent=false`;相同输入复跑=`NO_NEW_ITEMS/new=0`。
+- overlap safety:同 state 重叠调用一方按 `E_STATE_LOCKED` 停止,结束后无残留锁,串行重跑成功。
+- `git diff --check`:PASS。
+- `mbx validate --project project-info --governance`:`OK/projects=1/warnings=0`。
+- 证据:`dev/tmp/bili-dynamic-collector-mvp/selfcheck.md`。
+
+## 4. 需求—代码自检
+
+- 72 小时配置、稳定标识去重、manifest 字段与状态、Windows 时间命名:已实现并测试。
+- 重跑跳过、失败可重试、重名停止、下载目录到目标目录安全移动:已实现并测试。
+- 视频处理员交接摘要和定时检查入口:已实现;草稿不自动发送。
+- 账号口令/cookie/token、访问控制绕过、浏览器扩展自动化、真实下载和正式采集写入:代码中不存在,smoke 动作为 0。
+- 既有 `ana-data/news-青枫浦上Q/` 与其他正式采集内容:未读取、未修改。
+
+## 5. 当前状态与限制
+
+当前状态:`PENDING_SHARED_REVIEWER_LIGHT_IMPLEMENTATION_REVIEW`。
+
+限制:动态正文、原图和扩展任务仍由人工/浏览器扩展取得;目标文件系统不支持同目录 hard-link 时安全停止;正式路径授权完成前只使用开发 tmp/fixture 配置。未发送媒体处理交接,未创建定时任务。
+
+## 6. 独立安全审查终态
+
+- 回执:`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-REVIEW-RESULT-20260804-001`。
+- 结论:`HOLD_5_TERMINAL_SNAPSHOT`;目标 `6/6`、project `69/69`、编码/治理/UTF-8 均通过,但 5 个实质阻断未关闭。
+- 阻断:实现实际满足重型门禁但无已审设计;映射选择器不强制全一致且批量同源可先部分提交;源删除失败的 exit 4 在原映射重跑时降级;manifest 不经过秘密字段 gate;生成的原生 handoff 草稿缺少必填身份与 scope/evidence/expected_action。
+- 可保留:源码、文档、fixture、自检和测试作为隔离开发快照;`check` 只允许在 dev/tmp/TemporaryDirectory 对完全可信、无秘密、人工确认的一实体一来源输入做演示。
+- 禁止:不得把工具作为已验收采集器交付;不得对真实下载或正式目录运行 `move-completed`,不得调度/并发生产使用,不得发送当前 handoff Markdown,不得依赖 manifest 秘密保护或 retained-source 重试语义,不得写 `F:\video` 或正式 `ana-data`。
+- 按请求方即时收尾约束,不开启后继设计、返修复审、授权或外部动作。最终状态:`TERMINAL_HOLD_DEVELOPMENT_SNAPSHOT_ONLY`。
+
+## 7. 请求方恢复 HOLD/5 最小修复
+
+- 新授权:终态交付后,请求方明确“继续推进”,只修独立审核 F1—F5,不扩大产品范围。
+- 分类已纠正为重型;已创建 `dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md`。
+- V001 只冻结 exact selector/同源批次零副作用、retained-source 告警粘性、manifest 统一 secret gate 和完整稳定 native handoff envelope;网络、浏览器、下载和正式路径继续排除。
+- 当前代码、配置、测试和 fixture 未修改;状态=`V001_DESIGN_REVIEW_PENDING`。
+
+## 8. V001 设计复审 HOLD/1 与 V002 限定修订
+
+- 原共享审核员返回 `HOLD/1`;重型分类、选择器一致性方向、manifest 秘密字段检查和规范原生 handoff 均已通过。
+- 唯一冲突是终态重跑与 `source_file` 物理身份预检的先后关系。V002 固定为:全批选择器与实体唯一性先行;随后读取最新状态;仅 `ACTIVE_MOVE` 进入源/目标预检;已移动和 retained-source 终态均从 manifest 投影且不依赖当前源是否存在。
+- 代码、配置、测试和 fixture 继续冻结,等待 V002 限定设计 PASS。
+
+## 9. V002 限定复审 HOLD/1 与 V003 最终收紧
+
+- 原顺序冲突已经关闭;唯一剩余项是 V002 的“无移动终态”措辞可能扩大活动状态。
+- V003 废止该措辞,`ACTIVE_MOVE` 只允许精确的 `TODO_QUEUED` 和 `MOVE_FAILED`;处理阶段、缺失/空白状态及未知状态全部在阶段 B 前以 `E_STATUS`、exit 3 零副作用停止。
+- 追加一组参数化 CLI 入口反例合同;实现仍保持冻结,等待最后一次限定设计复审。
+
+## 10. V003 PASS 与五项实现
+
+- V003 最终限定设计审核=`PASS/0`;V001+V002+V003 成为唯一实施合同,随后才修改代码/配置/测试。
+- F2:所有 selector 单独命中且全一致;整批 entity 唯一后才分类;活动行做 source identity/target 预检,任何预检失败不写 manifest/目标/源。
+- F3:retained-source 在源存在或后续人工删源的相同 mapping 重跑中均保持 exit 4、原目标/hash/失败原因和 manifest bytes。
+- F4:manifest 每行在 schema/identity/status 前走递归 secret gate,三个命令共用。
+- F5:配置化精确 route,生成完整 deterministic native handoff,`--now` 不影响相同证据的 handoff bytes,仍不自动发送。
+- 验证:目标 `11/11 PASS / 1.393s`;新鲜闭环 `1/1 PASS / 0.226s`;project `74/74 PASS / 48.657s`;外部动作 0。
+- 当前等待同一共享审核员一次限定实现复审。
+
+## 11. 限定实现复审 HOLD/2
+
+- 独立正向复验目标 11/11、新鲜闭环 1/1、project 74/74 全 PASS,五项主要修复与无外部动作边界保留。
+- 剩余两项 fail-closed 阻断:终态持久证据缺失仍可能成功投影;manifest 标量换行可注入原生 handoff。
+- 按请求方“PASS/0 或明确剩余阻断返回”的单回合约束,本轮不再修改代码或自动开第二次实现复审;状态=`HOLD_2_ISOLATED_DEVELOPMENT_SNAPSHOT_ONLY`。
+
+## 12. HOLD/2 持续授权与 fail-closed 返修
+
+- 请求方明确要求继续至 PASS/0;无需新设计,只修审核 F1/F2。
+- 新增 terminal 状态证据校验与 handoff 全消费标量控制字符/格式校验;错误只返回安全字段路径。
+- 新增 19 组参数化反例,逐组验证 exit 3、`E_MANIFEST`、业务树/manifest/目标不变、无残留且 stdout 不含污染值。
+- 目标 13/13、新鲜闭环 1/1、project 76/76 PASS;外部动作和 fixture 修改均为 0。
+- 当前等待原共享审核员限定复审;若仍有同边界问题按新授权继续最小闭环。
+
+## 13. HOLD/2 fail-closed 限定复审 PASS/0
+
+- 回执=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-REREVIEW-RESULT-20260805-001`;结论=`PASS/0`、阻断=`0`。
+- F1 terminal persisted evidence fail-closed 与 F2 handoff manifest 标量控制字符/格式注入均 CLOSED;原阻断反例 2/2、目标 13/13、project 76/76 均由审核员独立复跑通过。
+- 审计=`DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-LIMITED-IMPLEMENTATION-REREVIEW-20260805-001`;回执时文件=`250254/43FD12548388E3760DCB52644273E54318591D44CE3D83E42013E1699F8D64B2`。
+- 当前=`PASS_0_PROJECT_TOOL_DELIVERABLE`;开发与复审链关闭,不追加功能或真实运行。网络、浏览器、下载、自动 native send、调度、`F:\video` 和正式 `ana-data` 仍需另行明确授权。
+
+## 14. 请求方接收关闭
+
+- 回执=`HANDOFF-VIDEO-DOWNLOADER-INFODEV2-BILI-DYNAMIC-COLLECTOR-PASS0-ACK-20260805-001 / ACCEPTED`;关联最终交付=`HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-DYNAMIC-COLLECTOR-MVP-PASS0-FINAL-20260805-001`。
+- 请求方确认 PASS/0 工具与证据完整,本事项状态=`ACCEPTED_CLOSED`;不再修改代码、测试、配置或 fixture。
+- 请求方声明当前博主真实浏览器/下载、`F:\video\青枫浦上Q`、正式 `ana-data`、原生转写交接和每小时调度已有既有授权;执行由视频下载员负责,开发员本轮动作仍为 0。
+- 后续新缺陷另行回传,当前保持空闲。
diff --git a/ai-infodev-2/worklog/2026-08-06-bili-authenticated-session-hold3-continuation.md b/ai-infodev-2/worklog/2026-08-06-bili-authenticated-session-hold3-continuation.md
new file mode 100644
index 0000000..381fcc9
--- /dev/null
+++ b/ai-infodev-2/worklog/2026-08-06-bili-authenticated-session-hold3-continuation.md
@@ -0,0 +1,228 @@
+# 已登录会话完整视频入口 HOLD/3 同事项续派记录
+
+- 时间:2026-08-06
+- 事项:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`
+- 身份:`dev.developer.project.secondary / infodev-2`
+- 续派:项目管理员在原 owner usage limit 后将同一事项唯一实施 ownership 临时续派给本角色;不是新事项、平行实现或新审核链。
+
+## 本轮范围
+
+只修审核 HOLD/3 的 F2-R1、F4-R1、F5-R1:并发 start lease 与一次性 `prepare_id`;正式 PyInstaller 6.15.0 onefile 用法;source/build 树外 reviewer/admin approval receipt 信任锚。冻结 bridge、F1/F3 和既有产品边界不修改。
+
+## 完成与验证
+
+- 延迟 Cookie-store 双击反例:读取 store 1 次、读取 Cookie 1 次、携密 start 1 次,重复请求 `E_BUSY`,首任务不终止。
+- build/install 在关键副作用前校验外部信任锚;同目录自洽重签攻击仍 fail closed;正式信任 receipt 未由开发员创建或自签。
+- 正式用法仅保留 pinned PyInstaller `6.15.0 --onefile --noupx`,真实构建和安装均为 0。
+- 扩展测试 31/31 PASS;冻结 bridge 21/21 PASS;project discover 130 tests / OK / 6 skipped。
+- py_compile、Node、PowerShell parser、JSON、严格 UTF-8、无 BOM、无尾随空格、凭据字面量扫描、治理校验均 PASS。
+
+## 边界与状态
+
+真实 build/install、HKCU、Chrome、Cookie/session、network/download、真实媒体、`F:\video`、正式 `ana-data`、转写均为 0。当前状态为 `FROZEN_PENDING_HOLD3_LIMITED_IMPLEMENTATION_REREVIEW`,下一步只提交原共享审核员一次限定复审。
+
+## 限定复审终态
+
+- 回执:`HANDOFF-INFOREV-INFODEV2-BILI-AUTHENTICATED-SESSION-HOLD3-MINIMAL-OFFLINE-REPAIR-REREVIEW-RESULT-20260806-001`
+- 结论:`PASS/0`,三项阻断全部关闭,冻结合同未回退。
+- 审计:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-HOLD3-MINIMAL-OFFLINE-REPAIR-REREVIEW-20260806-001`。
+- reviewer source approval 只允许 exact source manifest 的后续受控 build;install/HKCU 仍待唯一 EXE 与 schema2 receipt 的 hash-only 限定确认。
+- 本续修回合未执行 build、install 或其他外部动作。状态=`HOLD3_MINIMAL_OFFLINE_REPAIR_ACCEPTED`。
+
+## 受控 build 预检
+
+项目管理员随后授权一次 exact-source 受控 build。批准回执与 source manifest 复算一致,预定输出目录不存在;但固定目标环境无 PyInstaller,本机未找到精确 PyInstaller 6.15.0。按合同在 build 脚本调用前 fail closed,`build_count=0`,未生成 EXE/receipt/work/spec,未联网安装依赖。当前阻断为 `E_BUILD_DEPENDENCY_PYINSTALLER_6_15_0_UNAVAILABLE`,等待管理员提供或授权固定离线构建环境;install/HKCU 仍未授权。
+
+管理员已独立复核所有本机 Python/Conda/PATH/离线缓存,确认没有精确 6.15.0 候选,并将状态定为 `BLOCKER_CONFIRMED_BUILD_REMAINS_FROZEN`。当前停止且不重复 preflight;管理员另行申请预置离线环境或新的明确供应链授权。
+
+## exact-source 受控 build
+
+管理端预置离线 PyInstaller 6.15.0 环境并恢复原单次授权。预检对 Python/PyInstaller、7 个 wheels、provision receipt、reviewer approval、15 个 source 文件及空输出目录全部通过。`build_host.ps1` 只执行一次,exit 0,墙钟 20.839 秒,`build_count=1`。
+
+- EXE:`dev/tmp/bili-auth-controlled-build-20260806-001/project-info-bili-auth-native-host.exe|8608281|F0D3CE2EF12D7D305B8F765169CBF5430DF9F0D36D03FF4447A85378AFF8A26F`
+- receipt:`dev/tmp/bili-auth-controlled-build-20260806-001/build-artifact-manifest.json|995|9B989A32C92A4EB89911C9D5151F31B416BA8F265785CE575FDD1A23FBEDF63D`
+- 输出仅两项,work/spec 已清,approval/source/receipt/EXE 链、MZ 与 receipt 编码通过,构建控制台无秘密/签名 URL 命中。
+
+当前=`PENDING_EXACT_BUILD_HASH_ONLY_CONFIRMATION`。未执行 EXE、安装或 HKCU;未接触 Chrome/Cookie/session/network/download/media/`F:\video`/正式 `ana-data`/转写。
+
+## exact-build hash-only 终态
+
+原审核员返回 `PASS/0`,exact build identity gate 已满足。reviewer-owned 树外 approval=`ai-inforev/worklog/2026-08-06-bili-authenticated-session-build-approval.json|594|244097BC466D969E046F0E64E9D3A1213F5AB64E12C9358DF0807B4BA0F8F816`,仅用于 installer 身份门禁。审核及本线程均未执行 EXE、安装或 HKCU;实际安装必须由管理员/请求方另行明确调度。
+
+## exact-build 安装预检阻断
+
+管理员授权同一操作者执行一次 exact-build 安装。输入、source/build approvals、两文件输出集合、目标安装目录与 HKCU 键均通过只读身份/缺省状态复核;随后使用获准 PyInstaller 6.15.0 环境的 archive viewer 递归检查 EXE,未执行 EXE。
+
+静态归档包含 `bili_authenticated_extension.worker`,但 `yt_dlp` 条目为 0;构建环境的 `yt-dlp` distribution 亦为 `ABSENT`,`build_host.ps1` 没有 hidden-import/collect 合同。worker 在发送 `READY_PLUGIN_DISABLED` 前必须 import `yt_dlp`,因此该 exact build 无法通过安装后的静态可用性门禁。按 fail-closed 合同在安装前停止,`install_attempt_count=0`,安装目录和 HKCU 键继续不存在,EXE/Chrome/Cookie/session/network/download/media/`F:\video`/正式 `ana-data`/转写动作均为 0。
+
+机器可读证据:`dev/tmp/bili-auth-install-preflight-20260806-001/preflight-blocked.json`。当前状态=`BLOCKED_BEFORE_INSTALL/E_BUILD_RUNTIME_DEPENDENCY_MISSING`;需要原任务链批准修正构建合同并产生新的 exact build,当前 approval 不得用于替换字节。
+
+## yt-dlp 构建合同最小修复
+
+项目管理员确认旧 exact build 的 `E_BUILD_RUNTIME_DEPENDENCY_MISSING`,并在同一 task、同一 owner、同一审核链授权最小修复。旧 EXE/receipt/approval 保持原字节且固定为 `NOT_INSTALLABLE / SUPERSEDED_FOR_INSTALL`;未覆盖、删除或执行。
+
+- 仅执行一次有界 PyPI 获取命令,得到 `yt_dlp-2026.7.4-py3-none-any.whl|3184705|F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266`。未获取媒体、会话或 provider 数据。
+- wheel metadata 中无无条件 `Requires-Dist`;项目私有 dependency manifest=`723/2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`,冻结单 wheel 闭包。
+- `build_host.ps1` 新增副作用前依赖门禁、离线 `.deps` 安装、`collect-all/copy-metadata` 和 receipt 前递归归档检查;`install_native_host.ps1` 同步拒绝旧 receipt 及不完整依赖绑定。
+- source manifest=`3029/D1CE8C00A50CF17E45FB915577369BADA4AD9937335290852F393DC6BD053FCF`,17/17 文件绑定通过。
+- 验证:目标=`31/31 PASS / 184.223s`;冻结 bridge=`21/21 PASS / 0.138s`;project=`133 tests / OK / 6 skipped / 172.091s`。第一次误用 bridge 专用环境运行 project discover,因缺少无关的 numpy/mysql 依赖失败;换用项目基础环境完整复跑通过。
+
+当前新 build_count=0;install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/正式 `ana-data`/转写均为 0。下一步只向原审核员提交 source+dependency manifest 限定复审;开发员不自签 source approval。
+
+## yt-dlp 构建合同 HOLD/2 限定修复
+
+原审核员返回 `HOLD/2`,仅要求关闭实际 source tree 精确集合与 builder 环境注入。已按原链完成:
+
+- build 与 installer 在任何 Python/输出/复制/HKCU 前递归枚举实际源码树,除 manifest 自身外必须大小写敏感地精确等于 17 个冻结文件;根级/嵌套额外文件和 reparse 均 fail closed。
+- build 显式绑定管理员预置的 Python、PyInstaller executable 与树外 provision receipt;首次 Python 调用前移除全部继承 `PYTHON*`,全程使用同一精确 Python 的 isolated 调用。伪 PyInstaller/伪 pip `PYTHONPATH` 注入不会执行伪模块或产生输出。
+- production 无测试旁路;合成成功只在 `TemporaryDirectory` 修改脚本副本。source manifest 更新为 `3029/E01C1320F8C7677B7435B4C8B87EA3688BA652585648D7CFD1F88D0DADB26958`。
+- 验证:扩展=`31/31 PASS / 113.250s`;冻结 bridge=`21/21 PASS / 0.195s`;project=`133 tests / OK / 6 skipped / 171.664s`;聚焦 F1/F2、installer、source manifest 反例全部 PASS。
+- 本轮测试生成的缓存目录已移入 `dev/tmp` 隔离,实际产品源码树恢复精确集合。replacement build/install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/正式 `ana-data`/转写仍为 0。
+
+当前=`PENDING_HOLD2_SOURCE_REREVIEW`。只向原审核员提交同链限定复审;PASS 和新树外 source approval 前不构建。
+
+## HOLD/2 PASS 与单次 replacement build 阻断
+
+原审核员返回 `PASS/0`,F1/F2 关闭;新树外 source approval=`373/26AF51848717C47A2915D633B258269EA79213A94BAFEFF9CE7D30CDC2A77F96`,精确绑定 source manifest=`3029/E01C1320F8C7677B7435B4C8B87EA3688BA652585648D7CFD1F88D0DADB26958`。复算通过后按管理员既有授权只调用一次 replacement build。
+
+离线 wheel 安装到临时 `.deps` 成功,但 PyInstaller spec 的 `copy_metadata('yt-dlp')` 未在其 metadata 搜索路径看到该临时 distribution,构建以 `PackageNotFoundError`/exit 1 失败。attempt_count=1,未重试;输出目录已清理为不存在,未形成 EXE 或 schema 2 receipt。旧不可安装构建保持原字节,source 仍为 17/17 且无 reparse;install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/正式 `ana-data`/转写均为 0。
+
+当前=`BLOCKED_AFTER_SINGLE_BUILD_ATTEMPT/E_BUILD_METADATA_NOT_DISCOVERABLE`。单次构建授权已消耗,等待管理员在原链决定最小 source 修订与新的 source review/build authorization。
+
+## metadata discovery 最小修复
+
+管理员已在同一 task/reviewer 链授权修复和“source PASS 后一次新洁净构建”,未授权安装。实现只改 `build_host.ps1` 与对应离线测试:
+
+- `.deps` 必须位于 owned output 根下、全树非 reparse,并按 pinned wheel、pip `INSTALLER/REQUESTED` 与安装 RECORD 精确验证文件集和内容;唯一 distribution 必须为 `yt-dlp 2026.7.4`,额外 distribution/PyInstaller module、缺件、篡改与错误 owner 均 fail closed。
+- exact Python 继续用 `-I`;只在进程内把已验证 `.deps` 插入 `sys.path`,真实确认 metadata version 后再 `run_module('PyInstaller')`。继承 `PYTHON*` 和外部路径仍不会恢复。
+- metadata 聚焦=`1/1 PASS / 51.241s`;新旧门禁合并=`2/2 PASS / 128.251s`;扩展=`32 tests / OK / 5 skipped / 150.469s`;冻结 bridge=`21/21 PASS / 0.181s`;project=`134 tests / OK / 6 skipped / 203.739s`。
+
+当前 build=`25586/F5E9FC58313B2CB765772BF3A5C8605BCA9DD91A1FF74D0F03DBEB3174949845`;source manifest=`3029/AC852D40CF2B6FE891BE25D2E6E068A876EAC60E07EE6E9DB6826AC1064B29B8`。修复后 build_count=0;install/HKCU/EXE/Chrome/Cookie/session/network/media/正式路径仍为0。状态=`PENDING_METADATA_REPAIR_SOURCE_REREVIEW`。
+
+## metadata 完整性门前代码执行 HOLD/1 闭环
+
+原审核员确认第 14 节实现仍在完整性验证前导入临时 `.deps/yt_dlp`,独立 marker 反例实际执行。已只删除该旧 identity probe;现在 `.deps` 必须先通过 canonical owner、全树非 reparse、wheel-derived exact 文件集/哈希、RECORD、`INSTALLER/REQUESTED` 和唯一版本检查,随后 PyInstaller launcher 才是首次 `sys.path` 插入和依赖代码执行点。
+
+新增 post-pip 篡改反例向 `yt_dlp/__init__.py` 追加写 marker 代码;入口返回非零并清理 output/receipt,marker 保持不存在。冻结:build=`25093/43816E7932A785321B545073FEAD9EB38198E5ED3B07F2469062895ADC41238F`;source manifest=`3029/463182C6980734B72831BDF9B387160F8AD21A03B52CBAB4AC74CA98CEB1B7AE`;test=`59374/55F7F4ECA14E370BAB1D5D1CA154BE8BFD1E602C5460B69670A376751C5E08EA`。
+
+验证:聚焦=`1/1 PASS / 54.068s`;扩展=`32 tests / OK / 5 skipped / 149.629s`;bridge=`21/21 PASS / 0.204s`;project=`134 tests / OK / 6 skipped / 205.302s`。本轮 build/install/HKCU/EXE/Chrome/Cookie/session/network/media/正式路径动作均为 0;候选输出 `002` 不存在。状态=`PENDING_F1_PREVALIDATION_SOURCE_REREVIEW`。
+
+机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/prevalidation-code-execution-closure-20260807.json|2686|A3BA1EDB3C5C236C886B17821095481541DA1CAA7A349C3F4C25D279AF6BADF2`;治理=`OK/projects=1/warnings=0`。
+
+## F1 PASS 后单次 replacement build 阻断
+
+原审核员返回 `PASS/0`,新树外 source approval=`373/54514FCD70D2ED85F33E48433B13241F845A329A3DBD2C089AF416F1D67B2CEB` 绑定 manifest=`3029/463182C6980734B72831BDF9B387160F8AD21A03B52CBAB4AC74CA98CEB1B7AE`。全部精确输入和新输出缺省状态复算通过,`-ValidateOnly` 亦通过。
+
+唯一一次 `002` build 中,离线 yt-dlp 安装和 PyInstaller EXE 组装完成,但 receipt 前静态归档门报告缺少固定 yt-dlp package metadata,exit=1、约69.7s。attempt_count=1、retry=0;输出根回滚 absent,accepted EXE/receipt 均为0,未送 hash-only 审核。证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-002-blocked-20260807.json|2322|3E184118BEE03C8AA6D536B10C0B41F2B795FF8D4D56D72911B065CA9D50A257`。
+
+历史不可安装产物保持原字节;install/HKCU/EXE/Chrome/Cookie/session/network/media/正式路径动作均为0。状态=`BLOCKED_AFTER_SINGLE_BUILD_ATTEMPT/E_BUILD_ARCHIVE_METADATA_MISSING`,不自行修复或重试。
+
+## onefile 归档 metadata 合同最小修复
+
+管理员授权后离线确认:`copy_metadata('yt-dlp')` 返回完整 `yt_dlp-2026.7.4.dist-info` 目录;旧阻断来自 `archive_viewer -r` 对 Windows 反斜杠的 `repr` 转义与单分隔符正则不匹配,不是 collection 缺失。
+
+已把 receipt 前门禁改为 PyInstaller 6.15.0 `CArchiveReader` 结构化遍历 CArchive/PYZ。归档路径必须可规范化且无重名;六个冻结模块锚点必须齐全;唯一 metadata 根、7 个相对文件、data 类型、逐文件 bytes、`Name=yt-dlp`、`Version=2026.7.4` 与稳定树哈希 `7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C` 均须匹配。installer 同步拒绝缺少新字段的旧 schema 2 receipt。
+
+冻结:build=`32216/4E25D57D8133670D25BACF0E2A38D1C673DB9576413B22EBDC28304CAB6B2A33`;installer=`22467/DBF1A828DC343DCA4B52283B8EE189962007581E42D03D213A55461CE05CFEF6`;source manifest=`3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`;test=`62074/66B70D38E983806B82A2D00A1F741A79445E9C9CF80EA2C0951EED2935C1CC2B`。
+
+验证:聚焦=`1/1 PASS / 167.713s`;扩展=`32 tests / OK / 5 skipped / 239.755s`;bridge=`21/21 PASS / 0.147s`;project=`134 tests / OK / 6 skipped / 295.036s`;PowerShell parser=`PASS/0`。缺失、重复、错 Name/Version、残缺、嵌套、替代分发均失败关闭;正常 Windows 路径通过。
+
+本轮 production/replacement build、install、HKCU、EXE、Chrome/Cookie/session、网络/下载/媒体与正式路径动作均为 0;`003` 输出不存在。机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-contract-repair-20260807.json`;状态=`PENDING_ARCHIVE_METADATA_SOURCE_REREVIEW`。
+
+## archive metadata source 复审容量阻断
+
+复审请求已可靠入箱=`msg_20260807042629035_d5f27466`。原审核员独立复算 source `17/17`,并完成聚焦归档合同测试=`1/1 PASS / 156.385s`;随后其 turn `019fd8c1-e9de-78e3-b9c5-0758309b621e` 因 usage limit 以 `systemError` 终止,未形成正式 PASS/HOLD、审计或新树外 source approval。
+
+容量阻断已升级项目管理员=`msg_20260807045308506_36bf3f9a`,但管理员 turn `019fd8da-3bb5-7c70-9fcd-6a97b65a1253` 同样因 usage limit 终止,未形成恢复或重路由决定。两处均报告可重试时间为 `2026-08-08 13:31 +08:00`。
+
+部分测试通过不替代正式 approval。source manifest 继续冻结为 `3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`,候选 `003` 仍不存在,replacement build/install/HKCU/EXE/真实动作均为0。证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-source-review-capacity-blocked-20260807.json`;状态=`BLOCKED_REVIEW_CAPACITY`。
+
+## archive metadata source PASS 后单次 build-003 阻断
+
+容量恢复后,原审核员返回 `PASS/0`,树外 source approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-source-approval.json|373|A46A8D29A42B85E0DB83AA4EC5153B298BE9C24684637C6B7864AD23EF5B41E8`,精确绑定 source manifest=`3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`。构建前 exact source/approval/dependency/wheel/Python/PyInstaller/provision receipt 与输出 absent 均匹配,`-ValidateOnly=PASS`。
+
+唯一一次授权 build-003 在 `2026-08-08T14:04:44+08:00` 启动,离线依赖安装和 PyInstaller onefile 组装完成,但 receipt 前结构化门返回 `E_ARCHIVE_METADATA_TYPE`,exit=1,墙钟 `81.921s`。脚本已回滚新输出根;accepted EXE/receipt=`0/0`,attempt=`1`、retry=`0`。
+
+离线只读定位证明这是类型门的 Windows false rejection:固定 PyInstaller 6.15.0 `building/api.py` 在 `os.access(src_name, X_OK)` 为真时把 DATA 写成 CArchive type `b`;本机已验证 metadata 临时文件 `X_OK=true`、mode=`0o100666`,而 wheel 原始 mode=`0o644`。`CArchiveReader` 返回该 decoded type code;当前实现只接受 `x`,因此在内容精确校验前误拒绝。未修改 source,也未再次构建。
+
+证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-003-blocked-20260808.json`。install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/正式 `ana-data`/转写均为0;历史不可安装产物保持原字节。状态=`BLOCKED_AFTER_SINGLE_BUILD_ATTEMPT/E_BUILD_ARCHIVE_METADATA_TYPE_FALSE_REJECTION`,等待管理员在原链授权最小类型合同修复、复审与新的单次构建。
+
+## archive metadata type 合同最小修复
+
+管理员通过 `HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-TYPE-CONTRACT-REPAIR-BUILD004-AUTH-20260808-001` 限定授权。实现只在唯一 metadata 根及 7 个精确相对文件集合已经通过后,接受 PyInstaller DATA type `x` 或 Windows 因 `os.access(..., X_OK)` 提升的 `b`;其他 type 全部 `E_ARCHIVE_METADATA_TYPE`。`b` 不会扩展为任意 binary/module/script/path/data 许可。逐文件 bytes、Name/Version 与 tree hash 门保持不变;实际 type 集合写入 schema 2 receipt 的 `archive_verification.metadata_type_codes`,installer 同步要求非空且仅含 `b/x`。
+
+新增真实 PyInstaller `CArchiveWriter` 的 `x/b` 正例;合成入口逐一拒绝 `Z/a/d/l/m/n/o/s/z`,并覆盖错误路径下的 `b`。既有 path/file-set/Name/Version 反例继续保留,新增 payload bytes 漂移与 installer receipt tree-hash 漂移反例。一次并发测试产生的两份 `__pycache__` 被 source-tree 门正确拒绝,随后可恢复地隔离到 `dev/tmp/bili-auth-source-pycache-quarantine-type-repair-20260808`;串行复跑时 source=`17/17 exact`。
+
+冻结:build=`33020/3739436920FCEA8BA68DC04BBDAB0F346C08216F49D89A097C50075912A258A0`;installer=`22741/53A1D1AFD9E4571A2491AE66C5E721E77A2201DBFAA29C9094E42CE0CB684C2A`;source manifest=`3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`;test=`69608/2E7D6BE3B9607A34A90B283F2CDDBC65A31CD2AF02026EB97E8CED6C153075AB`。
+
+验证:real writer=`1/1 PASS / 2.086s`;installer+writer=`2/2 PASS / 17.198s`;完整类型/构建门=`1/1 PASS / 370.155s`;冻结 bridge=`21/21 PASS / 0.225s`;project=`135 tests / OK / 6 skipped / 538.519s`;PowerShell parser=`PASS/0`。机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-type-contract-repair-20260808.json`。
+
+build-004=`0`,候选 `dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004` 不存在;install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/正式 `ana-data`/转写均为0。状态=`PENDING_ARCHIVE_METADATA_TYPE_SOURCE_REREVIEW`;新 reviewer-owned source approval 前不得构建。
+
+## source PASS 后唯一 build-004 成功
+
+原审核员返回 `PASS/0`,树外 source approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json|373|102B30405B81137BD3AC9B81CFA39A6A7BB21531115FB3F361F821B2A093335E`,精确绑定 source manifest=`3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`。构建前 source 17/17、reparse=0、dependency/wheel、exact Python/PyInstaller/provision receipt、新输出 absent 和 `-ValidateOnly` 均通过。
+
+管理员既有单次条件授权下,build-004 唯一调用 exit=`0`、墙钟=`86.8s`、attempt=`1`、retry=`0`。输出根=`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004`,仅有两个普通非 reparse 文件,无 `.build/.spec/.deps`:
+
+- EXE=`project-info-bili-auth-native-host.exe|21343775|DBF155BCC05B4DF55592D0A4B56612F8BE9C990B3B9AF7691F6A11648866B0B0`;只做 `MZ` 静态检查,未执行。
+- receipt=`build-artifact-manifest.json|2874|0B36AA7DCA3AD80F8A370E96D50CAAFB386291EE2741305C7797ED663F5C2B27`;strict UTF-8/schema2/无 BOM/无重复键/无尾空白,文件项和 source/dependency/builder 绑定匹配。
+- receipt 归档门=`PASS`;modules 6、metadata files 7、type codes=`[b]`、metadata tree SHA-256=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。
+
+机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-004-success-20260808.json`。install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/正式 `ana-data`/转写均为0。状态=`PENDING_EXACT_BUILD_HASH_ONLY_CONFIRMATION`;不得自行安装,等待原审核员 hash-only PASS 与新 install-exact-build approval。
+
+## build-004 hash-only HOLD/1
+
+原审核员静态复算确认 EXE、receipt、source approval、builder/dependency 和精确输出集合均与提交一致,但结论为 `HOLD/1`。唯一阻断=`F1_BUILD004_RECEIPT_INSTALLER_METADATA_TREE_HASH_MISMATCH`:receipt 与 CArchiveReader 均得到 7 个 yt-dlp metadata 文件、type=`[b]`、tree hash=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`;同一受批 source 内 installer=`22741/53A1D1AFD9E4571A2491AE66C5E721E77A2201DBFAA29C9094E42CE0CB684C2A` 仍只接受 `7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`,因此会在 copy/HKCU 前失败关闭。
+
+build-004 EXE/receipt 保留为冻结失败证据,状态=`NOT_INSTALLABLE`;审核员未创建、复用或更新 install-exact-build approval。正式审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-HASH-ONLY-CONFIRMATION-20260808-001|376110|5EE85855516E114029ADFF537E23DEFFBB47DBF557E94AEE9AC4DCCB8C65E993`。机器终态证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-004-hash-review-hold-20260808.json`。
+
+后续必须先由管理员在同链授权最小 source 修复,使 build/installer 对同一 7 文件采用同一 canonical tree-hash 算法,并补 production-equivalent installer preinstall 正例;再走 source 复审、新 source approval、新 absent-output build 与新 hash-only 确认。本轮 build-004 retry/install/HKCU/EXE execution/真实动作均为0。
+
+## canonical seven-file tree-hash 最小 source 修复
+
+管理员通过 `HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-REPAIR-BUILD005-AUTH-20260808-001` 授权同链修复。单一 golden contract 现位于 `source-artifact-manifest.json#archive_metadata_contract`:唯一 root、固定 7 文件、`Name=yt-dlp`、`Version=2026.7.4`、type=`b|x`、canonical 序列算法及最终 `32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70` 只从同一受批 manifest 提供;builder 复算后写 receipt,installer 从同一 manifest 做 preinstall 精确比较,产品脚本中旧 `7C9A...105C` 常量已为0处。
+
+离线诊断证明 pip 生成 console launcher 的 ZIP 时间戳会改变 metadata `RECORD`。合同固定标准 distlib `SOURCE_DATE_EPOCH=1786207924`,精确 wheel 的隔离 pip 安装可复现 build-004 七文件:RECORD=`91484/433ED247B6625B8960AD19676BD04EC689640CCE050604A925A3EC013AF07D4F`,tree=`32F1...E70`。builder 仅在 pip 子进程期间设置该值并在 finally 恢复原进程环境。
+
+installer production-equivalent ValidateOnly 正例使用 canonical receipt 通过且 install/registry 副作用为0;历史 `7C9A...105C`、全零和任意漂移值均在 copy/HKCU 前失败。真实 CArchiveWriter `b/x` 保持通过。验证:聚焦=`2/2 PASS / 22.274s`;合成 builder 反例=`1/1 PASS / 348.093s`;bridge=`21/21 PASS / 0.232s`;最终 project=`135 tests / OK / 6 skipped / 489.917s`。首次 project run 唯一失败来自 test seam 选择运行器 Python,修正为 exact builder 后闭环。
+
+冻结:build=`35414/6184EBA087470BB5DBD6FEC29EB22068F8119FBDE2ED640715E8317B5A928C04`;installer=`24079/AA9667A3CD765B21058F234FD82DFDB9621161246041EC595E4BCB17DDCFDE86`;source manifest=`3681/333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535`;test=`70133/8913DC78EBB1780C29A518D7AC4DE90E4C8B0E8B8FF82106A3461326A3389045`;source=`17/17 exact/reparse0`。机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/canonical-tree-hash-contract-repair-20260808.json`。
+
+build-005 attempt=`0`、候选 output absent;install/HKCU/EXE execution/真实动作均为0。状态=`PENDING_CANONICAL_TREE_HASH_ONLY_SOURCE_REREVIEW`;正式 PASS/新 reviewer-owned source approval 前不得构建。
+
+## canonical source PASS 后唯一 build-005
+
+原审核员已返回 `PASS/0`,正式审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-SOURCE-REREVIEW-20260808-001|381790|9478849A3E760B3B38FFA79B9BFDB8F45BEA8FB0D6CBCB702852F65CB6A81501`;新的树外 source approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-canonical-tree-hash-source-approval.json|373|E0D621EB2C5E8149FE9B775CF7EA2F2ECB17C3FC3A6D4911D67CF2D24ACBD3EF`,精确绑定 manifest=`3681/333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535`。
+
+构建前复算 source approval、manifest、dependency manifest、wheel、固定 Python/PyInstaller/provision receipt 与历史 build-004 均匹配;build-005 输出根不存在。`-ValidateOnly` 返回 `VALIDATION_PASS_ONLY` 且未创建输出。随后消费管理员授权的唯一一次 build-005:attempt=`1`、retry=`0`、exit=`0`、wall=`66.8s`。
+
+冻结输出根=`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005`,精确包含两个普通非 reparse 文件、目录=0、`.build/.spec/.deps`=0:
+- EXE=`project-info-bili-auth-native-host.exe|21343716|CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE`,仅静态 `MZ` 检查,执行=0。
+- schema2 receipt=`build-artifact-manifest.json|2874|CC6663A15CDEB375167900730A9F956C9B09839558586E5DFC3D7FC57728ACC0`,strict JSON/UTF-8、无 BOM/重复键/尾随空白;唯一 EXE 项和 source/dependency/wheel/builder 绑定精确匹配,metadata=`7 files/type [b]/tree 32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。
+
+机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-005-success-20260808.json|4496|3FB4557AD81556068B27F6EBA10017883B511C52DDD66AA505150AAD4977CF57`。历史 build-004 EXE/receipt 哈希未变并继续为 NOT_INSTALLABLE 历史证据。install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription 均为0。当前状态=`PENDING_BUILD005_HASH_ONLY_CONFIRMATION`;新的 install-exact-build approval 与管理员安装调度形成前不得安装。
+
+## build-005 hash-only PASS/0
+
+原审核员独立静态复算后返回 `PASS/0`、阻断0,结论=`BUILD005_EXACT_BUILD_HASH_ONLY_CONFIRMED`。正式审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-005-HASH-ONLY-CONFIRMATION-20260808-001|386985|2F0DBEACE8584940D219CF89BAFD0F5AE6F5C4288B29C137CAC915776A3F7FEA`。审核员确认精确两文件、EXE/MZ、schema2 receipt、source/dependency/builder 绑定及 CArchive 七文件/type `[b]`/Name/Version/modules/canonical tree 全部匹配。
+
+新的树外 install-exact-build approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json|596|4D18A32DE9DFEB6B90C1A398087BD36CEAE09418D54E1F0DC485D6B95C962A44`,精确绑定 source manifest、receipt 与 EXE。终态机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-005-hash-review-pass-20260808.json|2454|3E4BC53C8419C7D43992F10811A685C5E810F760EA2EA25B0BE087D904F148BE`。
+
+该 PASS/approval 只关闭 exact-build identity 门禁,不授权当前回合安装、HKCU 或 EXE 执行。当前 install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription 仍为0;保持 build-005/receipt/source approval/install approval 不可变,等待管理员或请求方另行明确安装调度。
+
+## build-005 安装前验证失败关闭
+
+收到管理员唯一安装授权 `HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD005-INSTALL-EXACT-BUILD-AUTH-20260808-001` 后,先完成 exact EXE/receipt/source manifest/source approval/install approval、配置、目标目录与 HKCU 前像的只读复算。生成的无秘密配置=`dev/tmp/bili-auth-install-build005-20260808-001/config.json|1265|2F09B69AC9B8B82F90EB16673F71D3418AB73A94092C1785E5F697F50E6F2B50`。
+
+按合同先运行不带 `-Install` 的 installer。该 `VALIDATION_PASS_ONLY` 阶段以 `Actual source tree file set does not exactly match the approved manifest.` 失败关闭:批准清单为17文件,当前实物为19文件,额外项仅为 `__pycache__/__init__.cpython-312.pyc` 与 `__pycache__/constants.cpython-312.pyc`;批准的17文件 bytes/SHA 均未漂移,reparse=0。未删除、忽略或修复额外文件,未重试。
+
+机器证据=`dev/tmp/bili-auth-install-build005-20260808-001/install-preflight-blocked-20260808.json|2269|413AD21E4C836E90E86D7E66CD3C580C24AC8E7BCCD2708A21F108E6E5075A86`。install attempt=`0`、retry=`0`,安装根与 HKCU 均保持缺省;EXE execution/Chrome/Cookie/session/network/download/media/`F:\video` 写入/formal `ana-data`/transcription 均为0。终态=`BLOCKED/E_SOURCE_TREE_SET_DRIFT`。
+
+## build-005 精确缓存清理后受控安装成功
+
+管理员续发 `HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-BUILD005-INSTALL-PREFLIGHT-CACHE-CLEANUP-RESUME-20260808-001`,仅授权对上述两个 `.pyc` 精确复算后删除。两文件普通、非 reparse、bytes/SHA 全匹配;只删除这两文件和删除后的空 `__pycache__` 目录。清理后源码树恢复 `17/17 exact`,missing/extra/hash mismatch/reparse=`0/0/0/0`,全部 source/build/approval/config 哈希保持原值。
+
+第二次且最后一次 `VALIDATION_PASS_ONLY` 返回 PASS,安装根与 HKCU 保持缺省。随后消费唯一实际安装:attempt=`1`、retry=`0`,2026-08-08 21:31:52+08:00 至 21:31:56+08:00 成功。安装根=`C:\Users\Cai\AppData\Local\project-info\bili-auth-native-host\1.0.0+20260805.v002`,精确三文件且目录/reparse=0;installed EXE=`21343716/CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE`,config=`1265/2F09B69AC9B8B82F90EB16673F71D3418AB73A94092C1785E5F697F50E6F2B50`,native manifest=`433/A050B24FBFC879BEB25044D775AC43DC95F7BCDBDCCF153BF4198EF6B8E68B51`。
+
+只读健康检查 PASS:host=`com.project_info.bili_auth_ingress`,allowed origin=`chrome-extension://oidmclckpdmpabbfedplkbdplmfcenbb/`,HKCU 目标仅一个默认 String 值并精确指向安装 manifest;回滚合同静态存在,目标 BV 文件仍为0。机器证据=`dev/tmp/bili-auth-install-build005-20260808-001/install-success-20260808.json|3713|2349894EE33541E54A7452C054C248239E735403F3D4B4ED5649586C3C897AAB`。EXE execution/Chrome/Cookie/session/network/download/media/`F:\video` 写入/formal `ana-data`/transcription 均为0;runtime use 仍未授权。
diff --git a/ai-infodev/worklog/2026-08-05-bili-authenticated-session-download.md b/ai-infodev/worklog/2026-08-05-bili-authenticated-session-download.md
new file mode 100644
index 0000000..fda3dee
--- /dev/null
+++ b/ai-infodev/worklog/2026-08-05-bili-authenticated-session-download.md
@@ -0,0 +1,21 @@
+# Bilibili 已登录会话完整媒体接管开发记录(2026-08-05)
+
+- 事项:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;上游=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 用户授权事实:登录 Chrome 中 `BV1HA3o6oEJJ` 可完整播放 `52:14` 且显示“充电中”;600 秒无凭据预览继续隔离且不得转写。
+- Chrome 只读诊断:请求方已验证 `locator.downloadMedia` 无下载事件、`cua_download_media` 不受支持、页面资产无完整媒体;本开发会话尝试打开临时规范页仍超时,临时 agent tab 已 finalize,未点击、未下载。
+- 扩展公开实现只读回放:commit=`2d4d91b5426f374c6cae6741afc2b06dcd6d1f5b`;manifest 1.0.5 只有 side panel,无 commands/外部任务入口;按钮链为“添加任务 → 进行中/失败重试 → 已完成 → 下载 TXT”。后台固定 `bestaudio/best`、转 MP3,结果 API 只返回 TXT;Cookie 会写临时文件。localhost `127.0.0.1:8001/health` 只读检查=`HTTP 200`,native host 只负责服务启动/状态。
+- 结论:扩展按钮链不产生完整视频,不能作为验收步骤;在最新“不得读取/导出 Cookie、token 或签名媒体 URL”边界下,不实现 `--cookies-from-browser`。当前唯一外部阻断是缺少安全、受支持的完整媒体出口。
+- 最小开发动作:在既有 bridge 增加本地完整媒体接管后半程,自动完成稳定性、partial、精确 BVID、公开远端时长、FFprobe 音视频、时长门禁、SHA-256、CreateNew、映射与失败清理。开发阶段只用临时桩文件,不写正式目录。
+- 实现冻结:code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`。
+- 验证:py_compile、root/subcommand help PASS;目标=`21/21 PASS / 0.141s`;owner 范围 project=`97/97 PASS / 49.818s`;治理=`OK/projects1/warnings0`。全 project 唯一错误来自 secondary 正在迁移的股票台账测试旧类名,不修改其文件。
+- 外部动作:认证信息读取、签名 URL、下载、真实媒体、正式目录写入全部为 0;状态=`PENDING_LIMITED_IMPLEMENTATION_REVIEW_WITH_SINGLE_INGRESS_BLOCKER`。
+- 限定审核=`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-BACKHALF-IMPLEMENTATION-REVIEW-RESULT-20260805-001 / PASS/0`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BACKHALF-LIMITED-IMPLEMENTATION-REVIEW-20260805-001`。审核确认后半程安全合同通过,完整文件入口阻断继续保留;状态=`PASS_0_LOCAL_BACKHALF_READY_DELIVER_REQUESTER_WITH_INGRESS_BLOCKER`。
+- 请求方原生交付=`HANDOFF-INFODEV-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-BACKHALF-PASS-DELIVERY-20260805-001` 已发送;提供精确扩展 TXT 按钮链、入口 STOP 条件和后半程命令,开发线程真实下载/正式路径写入仍为 0。
+- 请求方回执=`HANDOFF-VIDEO-DOWNLOADER-INFODEV-BILI-AUTHENTICATED-SESSION-BACKHALF-RECEIPT-20260805-001 / RECEIVED_PASS_INGRESS_BLOCKED`;已核对冻结哈希和 CLI 边界。开发交付终态=`ACCEPTED_CLOSED_LOCAL_BACKHALF_INGRESS_BLOCKED`,不再修改或评审;后续完整文件出现后的真实接管由视频下载员执行。
+- 管理员随后以 `HANDOFF-INFOADMIN-INFODEV-BILI-PLUGIN-FULL-VIDEO-NARROW-SCOPE-ASSIGN-20260805-001` 在同一 task 内窄范围续开扩展入口阶段,并确认本线程为唯一 owner。V001 安全设计固定 exact BVID 的项目自有 side panel/Native Messaging、无 loopback、`io.StringIO` CookieJar、yt-dlp+FFmpeg stream-copy 和冻结后半程;设计 PASS 前不实现。真实秘密、Chrome、下载、注册表和正式路径动作保持 0;状态=`PENDING_CONSOLIDATED_SECURITY_DESIGN_REVIEW_IMPLEMENTATION_FROZEN`。
+- V001 独立审核=`HOLD/4`;V002 已按同一链补齐固定 extension public key/ID 与 wire schema、真实禁用插件/输出隔离、一次解析同一下载对象/协议与 MKV 门禁、reader-worker 有界超时和 Windows Job Object。产品实现和真实动作继续为 0;状态=`PENDING_V002_FOCUSED_DESIGN_REREVIEW_IMPLEMENTATION_FROZEN`。
+- V002 复审=`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-DESIGN-REREVIEW-RESULT-20260805-001 / PASS/0`;按 V001+V002 完成 `dev/project-dev/bili_authenticated_extension/` 最小离线实现、测试与用法。源清单=`2510/91E5E28CC21F9E214FE3A6D351D883AF3F12DA11C4DD9828D79934108D9367EC`;测试树=`7 files/1A97EF2CD9A4D7FA64D49A3890F22730622087C09DCFD5990A25B95230C7471B`;用法=`10499/9789F2B7CE0141E726A47CA396D89873BCCFD4260C1AA4210C766A92FB0ABCCA`。
+- 离线验证:目标环境 `28/28 PASS / 12.306s`,冻结 bridge `21/21 PASS / 0.197s`,全项目 `127 tests / OK / 6 skipped / 61.658s`;py_compile、Node 语法、JSON、PowerShell parser、严格 UTF-8/no BOM/no trailing whitespace、CLI help、diff 与治理 `OK/projects1/warnings0` 全部 PASS。真实 Cookie/session/Chrome、扩展/host 安装、HKCU、network/download、真实媒体、`F:\video`、正式 `ana-data` 和转写均为 0;状态=`PENDING_V002_LIMITED_IMPLEMENTATION_REVIEW`。
+- 首轮实现审核=`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-REVIEW-RESULT-20260806-001 / HOLD/5`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-REVIEW-20260806-001|318608|ACE96B2F28BAC4118275B84BDD7DA5DCB399459371EC7A3C9DCCEABEAF3704C0`。
+- F1-F5 已离线最小闭环:验证并保留 `LOCALAPPDATA` 后再清空环境,真实 worker 在 secret 前完成 config/stage/stale cleanup;第二次点击先做 fresh secret-free prepare/lease;本地阶段逐点 cancel 且共享 commit 事件仲裁 CANCELED/COMPLETE;PyInstaller 改为 onefile 并生成完整 receipt;installer 绑定 bundled exact source/build receipt,以 CreateNew+复读安装并将注册与目录纳入同一回滚。临时文件 registry provider 覆盖成功、重复、篡改和六个失败点,真实 HKCU 为 0。
+- 修复快照:source manifest=`2511/CE43F1BD7AB87EFAA6B98EB456EC3938E4DDFEBBAB36268E774746F307EF7C0A`;tests tree=`7 files/612 bytes/8BDA96E3CFE867E0E2906BE9CEF13B1CD39AAF1D5969A7B0A63EC38630D4A0E1`;usage=`11513/C4B6B0F2E4EE32D4FFA3E4AA633C56E701777B9ABD6EA3DC9012D80BC8915D69`。目标=`30/30 PASS / 27.613s`;冻结 bridge=`21/21 PASS / 0.204s`;project=`129 tests / OK / 6 skipped / 80.102s`;静态与治理全过。真实构建/安装/Chrome/Cookie/network/download/media/正式路径仍为 0;状态=`PENDING_F1_F5_LIMITED_IMPLEMENTATION_REREVIEW`。
diff --git a/ai-infodev/worklog/2026-08-05-bili-chrome-download-bridge.md b/ai-infodev/worklog/2026-08-05-bili-chrome-download-bridge.md
new file mode 100644
index 0000000..5330ba4
--- /dev/null
+++ b/ai-infodev/worklog/2026-08-05-bili-chrome-download-bridge.md
@@ -0,0 +1,21 @@
+# Bilibili 完整视频下载桥接开发记录(2026-08-05)
+
+- 任务:`DEV-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-20260805-001`
+- 上游:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 目标:让两个已知 BVID 的完整音视频原件安全落盘;封面、m4s、扩展任务或仅 TXT 均不算完成。
+- 边界:不修改 secondary 的动态采集器、配置、测试与修复证据;开发阶段不写 `F:\video\青枫浦上Q` 或正式 `ana-data`。
+- 现场:Chrome 标签页元数据和普通动态页可读,复杂视频页控制超时;扩展服务健康,但公开实现只下载最佳音频并转 MP3/TXT,没有完整视频输出接口。
+- 决策:独立最小 CLI 直接处理规范公开 URL;强制忽略 yt-dlp 配置,显式禁用 Cookie、浏览器 Cookie 和缓存,也不启用默认关闭的 netrc。若公开无认证访问失败则明确停止,不读取认证秘密、不绕过访问控制。
+- 安全发布:隔离暂存,FFprobe 验证视频流+音频流+正时长,计算 SHA-256,以 no-overwrite/CreateNew 语义发布媒体和来源映射;中断回滚已创建正式产物。
+- 计划验证:py_compile、目标 unittest、project 全回归、CLI help、无媒体 `probe`;真实 `download` 留给视频下载员执行。
+- 环境:创建 `project-info-bili-download-bridge`(Python 3.12)。与扩展一致的 yt-dlp 2026.3.17 无 Cookie探测两项均 `HTTP 412`;升级当前稳定版 2026.7.4 后两项均 PASS,未下载媒体。
+- 探测结果:`BV1DVMX6XEPq` 标题“A股缩量磨底阶段。”、`1206.067 s`;`BV1HA3o6oEJJ` 标题“流水淘沙不暂停,前波未灭后波生。(周复盘)”、`3133.95 s`。
+- 回归:目标 unittest=`11/11 PASS`;project 首轮既有资源采样器窗口密度波动 1 项失败,同一冻结代码完整重跑=`87/87 PASS / 55.019 s`。
+- 审核路由:审核请求与补充证据已入箱;审核员旧 rollout 缺少 session metadata,指定 message agent step 在执行审核前失败。已升级 project.admin,保留独立审核为“无结论”,按上游轻量工具口径先交请求方普通功能验收。
+- 真实验收缺陷:第二条远端总时长 `3133.95 s`,实际无认证下载仅 `600.133313 s` 会员预览;旧实现只检查本地 `duration>0`,错误发布 COMPLETE。请求方已把错误产物可恢复隔离,未转写;第一条 `1206.067664 vs 1206.067 s` 已验证一致并正式交接。开发线程没有读写正式目录或隔离目录。
+- 修复:发布前强制 `abs(local-remote) <= max(3.0 s, remote*0.1%)`;超过即以“可能预览或错误分 P”失败,正式媒体/映射均不创建,暂存清理。映射新增 remote/local/delta/tolerance 四项证据;probe 明确改标 `METADATA_PASS_ONLY`。
+- 边界:`_type=playlist`、存在 entries 的多视频结果,以及 `needs_auth/premium_only/private/subscriber_only` 统一 fail-closed;规范 URL 仍拒绝 `?p=` 等查询参数。
+- 修复验证:目标=`15/15 PASS / 0.093 s`;误预览端到端桩确认正式输出 `0`、暂存 `0`;第一次 project 回归因同时有真实转写占用重型媒体 mutex 被 3 项明确拒绝,任务结束后重跑=`91/91 PASS / 54.225 s`;无再次真实下载。
+- 限定复审最终回执=`HANDOFF-INFOREV-INFODEV-BILI-BRIDGE-DURATION-GATE-REREVIEW-RESULT-20260805-001 / PASS/0`,阻断数 0;冻结 code=`541CD9A57E23976614B80CF731C6D4D49361A29BFBC029E9053C8E7093EC18F1`,tests=`BDE24A5BE26BC10F534EA1BF7235B8066EDA6425BB432C06AC67BBD560EFA168`。
+- 已按原链交付请求方,消息=`msg_20260805183258435_048db111`;可靠 inbox 成功,可见调度排队。开发线程停止代码变更和真实下载,等待请求方接收;预览隔离、第一条已验收视频与全部外部动作边界不变。
+- 首条交付中的代码/测试文件名笔误已由 `msg_20260805183550141_a01a1463` 纠正;实际路径为 `dev/project-dev/bili_video_download_bridge.py`、`dev/project-dev/test/test_bili_video_download_bridge.py`,本地只读复核字节数/哈希与审核冻结证据一致。
diff --git "a/ai-infodev/worklog/2026-08-06-\346\205\247\345\215\232\346\226\260\346\250\241\346\213\237\345\231\250\346\234\200\345\260\217\351\200\202\351\205\215.md" "b/ai-infodev/worklog/2026-08-06-\346\205\247\345\215\232\346\226\260\346\250\241\346\213\237\345\231\250\346\234\200\345\260\217\351\200\202\351\205\215.md"
new file mode 100644
index 0000000..d93559d
--- /dev/null
+++ "b/ai-infodev/worklog/2026-08-06-\346\205\247\345\215\232\346\226\260\346\250\241\346\213\237\345\231\250\346\234\200\345\260\217\351\200\202\351\205\215.md"
@@ -0,0 +1,10 @@
+# 慧博新模拟器最小适配记录(2026-08-06)
+
+- 事项:`DEV-PROJECT-INFO-HIBOR-NEW-EMULATOR-ADAPTER-20260806-001`。
+- 结论:保留现有精确研报 JSON 与顺序执行架构,只在批次输出创建前增加设备解析。推荐显式 `emulator-5554`;`auto` 只在 ADB 恰好有一个 ready 记录时成功。
+- 迁移:旧任务 JSON 只需把 `runtime.device_serial` 从 `127.0.0.1:21503` 改为 `emulator-5554` 或 `auto`,无需变更 schema、items 或归档路径。
+- 共享通道:`/sdcard/Pictures` 与 `F:\hb` 仅记录为可选已验证映射;正式工具继续使用内核 ADB 直拉,没有新增复制实现。
+- 验证:目标 8/8、project 133 tests/OK/6 skipped;只读解析返回 `emulator-5554`,缓存根存在。
+- 边界:未打开新研报、未下载、未消耗额度、未写正式归档或 BATCH-031、未修改缓存原件或 `dev/ana-dev`。
+- 状态:`IMPLEMENTED_PENDING_REQUESTER_ACCEPTANCE`。
+- 请求方回执:`HANDOFF-YANBAO-INFODEV-HIBOR-NEW-EMULATOR-MINIMAL-ADAPTER-ACCEPTANCE-20260806-001 / ACCEPTED`;终态=`ACCEPTED_CLOSED`。MemuIME 输入兼容另由 ana 核心修复事项处理,本线程不接管。
diff --git "a/ai-infodev/worklog/2026-08-06-\350\247\206\351\242\221\350\275\254\346\226\207\345\255\227\345\212\251\346\211\213EOF\345\274\271\347\252\227\346\235\245\346\272\220\351\230\273\346\226\255.md" "b/ai-infodev/worklog/2026-08-06-\350\247\206\351\242\221\350\275\254\346\226\207\345\255\227\345\212\251\346\211\213EOF\345\274\271\347\252\227\346\235\245\346\272\220\351\230\273\346\226\255.md"
new file mode 100644
index 0000000..afd0ee2
--- /dev/null
+++ "b/ai-infodev/worklog/2026-08-06-\350\247\206\351\242\221\350\275\254\346\226\207\345\255\227\345\212\251\346\211\213EOF\345\274\271\347\252\227\346\235\245\346\272\220\351\230\273\346\226\255.md"
@@ -0,0 +1,22 @@
+# 视频转文字助手 EOF 弹窗来源阻断
+
+- task=`DEV-PROJECT-INFO-VIDEO-TEXT-HOST-WRITE-EOF-STABILITY-20260806-001`
+- assignment=`HANDOFF-INFOADMIN-INFODEV-VIDEO-TEXT-HOST-WRITE-EOF-STABILITY-ASSIGN-20260806-001`
+- time=`2026-08-06`
+
+已完成注册表、安装树、扩展身份、上游 v1.0.5 full commit 与 active PyInstaller 归档的只读冻结。Active `native-host.exe` 内嵌 `host_win` 代码对象与固定提交中 `host_win.py` 归一化指纹一致,均为 `DB5F1CE9DE4695B9D72F1060D16BA70C0979C85A724D83BC9FC4B052EF54D753`。
+
+截图为 Electron/Node `WriteWrap` 堆栈,但 active host 是 Python,且 active `send_message()` 已捕获两次写入与 flush 的普通异常。因此不能将上游 macOS/Node `host.cjs` 的未保护写入当作当前 Windows 弹窗的修复对象。
+
+为避免替换错误二进制并产生虚假 PASS,本事项在源码替换前 fail closed。唯一需要的补充证据是下次弹窗时实际进程的 PID/父 PID/可执行文件路径或等价 WER 记录;不收集命令行或任何认证秘密。
+
+完整证据见 `dev-doc/project-doc/视频转文字助手EOF稳定性修复.md`。
+
+## 2026-08-06 管理员范围纠正
+
+- 回执=`HANDOFF-INFOADMIN-INFODEV-VIDEO-TEXT-HOST-WRITE-EOF-SCOPE-CORRECTION-20260806-001 / EVIDENCE_AWAITING_NO_PRODUCT_MUTATION`。
+- 管理员接受 provenance 反证,并撤回 active Python host 的因果声明。
+- 不修补、不构建、不安装、不替换;不建常驻 watcher,不捕获命令行、认证数据或媒体。
+- 仅弹窗真实复发且上游提供 PID/父 PID/可执行路径或等价 WER 记录时,进行一次有界只读身份核对。
+- 本事项已解除 authenticated-download release blocker 属性。
+- 当前状态=`EVIDENCE_AWAITING_NO_PRODUCT_MUTATION`。
diff --git a/ai-inforev/worklog/2026-08-06-bili-authenticated-session-build-approval.json b/ai-inforev/worklog/2026-08-06-bili-authenticated-session-build-approval.json
new file mode 100644
index 0000000..daafbea
--- /dev/null
+++ b/ai-inforev/worklog/2026-08-06-bili-authenticated-session-build-approval.json
@@ -0,0 +1,12 @@
+{
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "install-exact-build",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_sha256": "039BE7791B517290F13458151A19B400C8DE516963FADB7354E22C680BE89709",
+ "build_artifact_manifest_bytes": 995,
+ "build_artifact_manifest_sha256": "9B989A32C92A4EB89911C9D5151F31B416BA8F265785CE575FDD1A23FBEDF63D",
+ "host_executable_bytes": 8608281,
+ "host_executable_sha256": "F0D3CE2EF12D7D305B8F765169CBF5430DF9F0D36D03FF4447A85378AFF8A26F"
+}
diff --git a/ai-inforev/worklog/2026-08-06-bili-authenticated-session-source-approval.json b/ai-inforev/worklog/2026-08-06-bili-authenticated-session-source-approval.json
new file mode 100644
index 0000000..cdbe606
--- /dev/null
+++ b/ai-inforev/worklog/2026-08-06-bili-authenticated-session-source-approval.json
@@ -0,0 +1,9 @@
+{
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "controlled-build-source-manifest",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_bytes": 2511,
+ "source_artifact_manifest_sha256": "039BE7791B517290F13458151A19B400C8DE516963FADB7354E22C680BE89709"
+}
diff --git a/ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-build-source-approval.json b/ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-build-source-approval.json
new file mode 100644
index 0000000..d90aabc
--- /dev/null
+++ b/ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-build-source-approval.json
@@ -0,0 +1,9 @@
+{
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "controlled-build-source-manifest",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_bytes": 3029,
+ "source_artifact_manifest_sha256": "E01C1320F8C7677B7435B4C8B87EA3688BA652585648D7CFD1F88D0DADB26958"
+}
diff --git a/ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-prevalidation-source-approval.json b/ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-prevalidation-source-approval.json
new file mode 100644
index 0000000..0bf9471
--- /dev/null
+++ b/ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-prevalidation-source-approval.json
@@ -0,0 +1,9 @@
+{
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "controlled-build-source-manifest",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_bytes": 3029,
+ "source_artifact_manifest_sha256": "463182C6980734B72831BDF9B387160F8AD21A03B52CBAB4AC74CA98CEB1B7AE"
+}
diff --git a/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-source-approval.json b/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-source-approval.json
new file mode 100644
index 0000000..d094877
--- /dev/null
+++ b/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-source-approval.json
@@ -0,0 +1,9 @@
+{
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "controlled-build-source-manifest",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_bytes": 3029,
+ "source_artifact_manifest_sha256": "508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910"
+}
diff --git a/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json b/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json
new file mode 100644
index 0000000..8b4d417
--- /dev/null
+++ b/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json
@@ -0,0 +1,9 @@
+{
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "controlled-build-source-manifest",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_bytes": 3029,
+ "source_artifact_manifest_sha256": "21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3"
+}
diff --git a/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-canonical-tree-hash-source-approval.json b/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-canonical-tree-hash-source-approval.json
new file mode 100644
index 0000000..7782b5c
--- /dev/null
+++ b/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-canonical-tree-hash-source-approval.json
@@ -0,0 +1,9 @@
+{
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "controlled-build-source-manifest",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_bytes": 3681,
+ "source_artifact_manifest_sha256": "333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535"
+}
diff --git a/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json b/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json
new file mode 100644
index 0000000..56d3af6
--- /dev/null
+++ b/ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json
@@ -0,0 +1,12 @@
+{
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "install-exact-build",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_sha256": "333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535",
+ "build_artifact_manifest_bytes": 2874,
+ "build_artifact_manifest_sha256": "CC6663A15CDEB375167900730A9F956C9B09839558586E5DFC3D7FC57728ACC0",
+ "host_executable_bytes": 21343716,
+ "host_executable_sha256": "CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE"
+}
diff --git "a/ai-video-downloader/draft/2026-08-04-\345\223\224\345\223\251\345\223\224\345\223\251\345\212\250\346\200\201\351\207\207\351\233\206\351\234\200\346\261\202.md" "b/ai-video-downloader/draft/2026-08-04-\345\223\224\345\223\251\345\223\224\345\223\251\345\212\250\346\200\201\351\207\207\351\233\206\351\234\200\346\261\202.md"
new file mode 100644
index 0000000..5ddf5ac
--- /dev/null
+++ "b/ai-video-downloader/draft/2026-08-04-\345\223\224\345\223\251\345\223\224\345\223\251\345\212\250\346\200\201\351\207\207\351\233\206\351\234\200\346\261\202.md"
@@ -0,0 +1,62 @@
+# 哔哩哔哩博主动态采集需求(MVP)
+
+- 需求编号:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 需求方:`case_analysis.video_downloader`
+- 首个博主:`青枫浦上Q`
+- 动态入口:`https://space.bilibili.com/1420210197/dynamic`
+- 原则:快速落地、实用优先、少设计;不绕过登录、验证码、会员、DRM 或其他访问控制。
+
+## 1. 目标
+
+先回补最近 72 小时内的动态内容,随后定时刷新并只处理新增内容。动态类型包括纯文字、专栏文章、图片和视频。
+
+## 2. 采集与交付
+
+1. 纯文字动态和专栏文章:获取完整正文,保存为 UTF-8 `.txt`。
+2. 图片:下载原图;同一动态多图按发布时间和序号命名。
+3. 视频:打开播放页,通过用户已安装的“视频转文字助手”扩展添加下载任务;任务失败时在扩展任务列表重试。任务完成后下载文件并移动到 `F:\video\<博主名>\`。
+4. 视频处理:通过 Codex 原生任务通信把视频文件交给 `case_analysis.media_processor`;处理后的文本放到 `E:\mb-ms-doc\project-info\ana-data\news-<博主名>\`。
+5. 文章、纯文字动态和图片也放到 `E:\mb-ms-doc\project-info\ana-data\news-<博主名>\`。
+6. 不覆盖既有文件;目标目录内已有无关文件必须保持不变。
+
+## 3. 命名和去重
+
+1. 文件名采用 `YYYYMMDD-HHmmss_<类型>_<短标题>`;同一时间多文件追加 `_01`、`_02`。标题需要去除 Windows 非法字符并控制长度。
+2. 以动态 ID / opus ID / BV 号和来源 URL 作为去重主键,不以标题作为唯一依据。
+3. 每个博主维护一份轻量 `manifest.jsonl`,至少记录:博主、动态 ID、内容类型、发布时间、标题、来源 URL、本地文件、SHA-256、采集时间、状态、失败原因、视频处理状态和文本路径。
+4. 重跑时已成功项目直接跳过;失败项目允许重试并追加状态,不重复生成成功文件。
+
+## 4. 开发范围
+
+开发一个轻量本地辅助工具,重点解决:
+
+1. 博主配置、最近时间窗、动态 ID 去重和 manifest 状态维护。
+2. Windows 安全文件名和时间命名。
+3. 对浏览器或人工导出的动态清单生成待办队列;不要把账号口令、cookie、token 或浏览器会话写入脚本和配置。
+4. 扫描浏览器下载目录中的已完成视频,按 BV 号或任务映射安全移动到博主视频目录,并计算 SHA-256;目标重名时停止而非覆盖。
+5. 生成给视频处理员的 Codex 原生交接摘要,但实际发送由视频下载员确认后执行。
+6. 提供可供 Codex 定时任务调用的“检查状态/生成新增待办”入口;浏览器扩展的添加任务、失败重试和完成列表下载仍由浏览器交互完成。
+
+## 5. 停止边界
+
+出现登录提示、验证码、付费墙、订阅/会员异常、DRM、访问控制、来源合法性不明、目标不唯一、下载文件无法对应动态或可能覆盖既有文件时立即停止并报告。不得读取或保存密码、cookie、token、验证码或其他认证秘密。
+
+## 6. MVP 验收
+
+1. 首次运行能处理一个博主和最近 72 小时时间窗。
+2. 相同输入重复运行不重复生成待办或文件。
+3. 文件名符合 Windows 规则并保留发布时间。
+4. manifest 能追溯来源、状态、本地文件与视频处理状态。
+5. 目标文件存在时不覆盖;失败项可重试。
+6. 有最小单元/合同测试,覆盖去重、命名、状态迁移、重名停止和下载目录到目标目录的安全移动。
+7. 不修改 `ana-data/news-青枫浦上Q/` 内既有无关文件。
+
+## 7. 当前已知样本
+
+2026-08-04 浏览器只读盘点显示,最近两天至少包含:
+
+- 专栏文章:`8.4(早盘)`、`8.2(复盘)`。
+- 视频:`A股缩量磨底阶段。`(BV1DVMX6XEPq)、`流水淘沙不暂停,前波未灭后波生。(周复盘)`(BV1HA3o6oEJJ)。
+- 多条纯文字动态,包括 8.3 复盘、午盘和短动态。
+
+开发阶段只使用最小脱敏 fixture,不复制大段博主正文或下载媒体文件。
diff --git a/ai-video-downloader/worklog/2026-08-04-REQ-BILI-DYNAMIC-COLLECTOR-first-run.md b/ai-video-downloader/worklog/2026-08-04-REQ-BILI-DYNAMIC-COLLECTOR-first-run.md
new file mode 100644
index 0000000..89b7734
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-04-REQ-BILI-DYNAMIC-COLLECTOR-first-run.md
@@ -0,0 +1,54 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 首次执行记录
+
+- 日期:2026-08-04
+- 角色:`case_analysis.video_downloader`
+- 博主:青枫浦上Q(UID `1420210197`)
+- 动态页:<https://space.bilibili.com/1420210197/dynamic>
+- 归档目录:`ana-data/news-青枫浦上Q/`
+- 视频原件目录:`F:\video\青枫浦上Q\`
+
+## 已完成
+
+- 已复核扩权后的工作说明、项目配置清单和案例分析规范。
+- 已建立精确授权的视频原件目录 `F:\video\青枫浦上Q\`。
+- 已完成最近 72 小时的公开元数据盘点,共 11 条:7 条纯文字、2 篇文章、2 个视频。
+- 已归档并目视核验 2 张公开视频封面(仅作 `video-cover`,不计作视频原件或独立图片交付),均计算 SHA-256;未覆盖既有文件。
+- 已保存 7 条纯文字动态和 3 篇文章全文为 UTF-8 TXT;其中新增文章 `8.4(复盘)`(opus `1232725342775410720`)是在首次盘点后发现的更新。
+- 已建立并复核 `manifest.jsonl`:12 条记录,10 条 `SAVED`、2 条 `VIDEO_DOWNLOAD_PENDING_EXTENSION`,文件大小与 SHA-256 对照错误为 0。
+- 已建立每 6 小时运行一次的当前任务 heartbeat“青枫浦上Q动态增量采集”(automation id `q`)。
+- 已将轻量去重、manifest、哈希、并发锁和安全移动辅助工具需求交给日常开发员-2实施。
+- 未修改 `ana-data/news-青枫浦上Q/` 中既有文件。
+
+### 已归档封面
+
+- `20260803-193903_video-cover_A股缩量磨底阶段_BV1DVMX6XEPq.webp`
+ - SHA-256:`3316590CC2CA332B1188C99BE48CF2E8DED7DC66F3972A0B65A528D74362CC9E`
+- `20260802-211111_video-cover_流水淘沙周复盘_BV1HA3o6oEJJ.webp`
+ - SHA-256:`F71BAA83F76E6B3577C544C61FE8E70EF02504247D939E81F2703C4C5189970C`
+
+## 首批队列
+
+| 类型 | 标识 | 发布时间(Asia/Shanghai) | 标题/摘要 |
+|---|---|---|---|
+| 纯文字 | `1232588032685637632` | 2026-08-04 13:51 | 做交易要吃的下饭,睡得着觉。 |
+| 纯文字 | `1232541024240271379` | 2026-08-04 10:48 | 轮动行情。 |
+| 文章 | `1232516577286422548` | 2026-08-04 09:13 | 8.4(早盘) |
+| 纯文字 | `1232346363423883282` | 2026-08-03 22:13 | 8.3(复盘) |
+| 视频 | `BV1DVMX6XEPq` | 2026-08-03 19:39:03 | A股缩量磨底阶段。(20:07) |
+| 纯文字 | `1232223909666881541` | 2026-08-03 14:18 | 这里就是底部,坚持住。 |
+| 纯文字 | `1232204861433446409` | 2026-08-03 13:04 | 8.3(午盘) |
+| 纯文字 | `1232156727764320257` | 2026-08-03 09:57 | 多点耐心 |
+| 纯文字 | `1232141287338016792` | 2026-08-03 08:57 | 8.3(早盘) |
+| 文章 | `1231979117971243025` | 2026-08-02 22:28 | 8.2(复盘) |
+| 视频 | `BV1HA3o6oEJJ` | 2026-08-02 21:11:11 | 流水淘沙不暂停,前波未灭后波生。(周复盘)(52:14) |
+
+## 当前停止点
+
+- Chrome、Codex Chrome 扩展和 Native Messaging 诊断均为正常;现有标签页列表可读取,新 Chrome 窗口和受控标签页也可建立。
+- 重建 Chrome 控制后,动态页与文章页已可读取,正文采集已完成;B 站页面读取超时不再是当前阻断。
+- 直接访问“视频转文字助手”的 `chrome-extension://` 页面被浏览器安全策略明确拒绝,未尝试绕过或改用间接执行。
+- 两条目标视频均已确认可在登录页正常播放,不是登录、会员或 DRM 阻断;浏览器媒体盘点识别到分离的 `.m4s` 流,但安全导出能力拒绝该资产类型,未改用直链绕过。
+- 当前仅余 Chrome 侧栏中的“视频转文字助手 → 添加任务”需要人工点击;已打开并保持两条目标播放页。完成列表产生文件后,由本角色继续下载、移动、哈希和原生交接。
+- 开发辅助工具首次审核为 `HOLD/5`;用户随后明确要求继续推进,五项阻断已交回日常开发员-2做最小修复,未用未审工具接触真实目录。
+
+状态:`TEXT_COMPLETE / VIDEO_PENDING_EXTENSION_QUEUE`
diff --git a/ai-video-downloader/worklog/2026-08-04-role-initialization-first-read.md b/ai-video-downloader/worklog/2026-08-04-role-initialization-first-read.md
new file mode 100644
index 0000000..573b1a4
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-04-role-initialization-first-read.md
@@ -0,0 +1,23 @@
+# 视频下载员角色初始化首读记录
+
+- 日期:2026-08-04
+- AI:`video-downloader`
+- 当前唯一角色:`case_analysis.video_downloader`
+- 当前任务 ID:`019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5`
+- 初始化交接:`HANDOFF-MGADMIN-VIDEO-DOWNLOADER-ROLE-INITIALIZATION-20260804-001`
+- 记录性质:私有过程记录,不是下载交付或审核结论
+
+## 理解确认
+
+1. 本角色只负责合法可访问的视频/音频原件下载、归档、校验、manifest 和终态反馈;普通下载由任务安排者验收,本角色不是审核员。
+2. 下载请求必须给出准确来源、唯一下载目标和交付位置。默认临时落点为 `ana-data/tmp/<task_id>/`;请求者明确指定正式案例落点时,才使用 `ana-data/cases/` 下对应目录。原件不得静默覆盖。
+3. 每次下载交付应包含原始文件、记录任务与来源入口等字段的 manifest,以及成功、失败、缺口、落点和校验结果说明。
+4. 遇到登录、验证码、付费墙、订阅、DRM、访问控制、来源合法性不明、目标不唯一或可能覆盖既有原件时立即停止并反馈;不得捕获或保存认证秘密,不得绕过访问控制。
+5. 与 `case_analysis.media_processor` 职责分离:本角色不做剪辑、转码、OCR、转写、字幕、内容分析、研究结论或自审;上述处理工作交给视频处理员或任务请求者。
+6. 角色间交接默认使用 Codex 原生 task/thread 通信,核对准确目标后单次投递;不自动回退旧 MB-X inbox/route/session 路径。
+7. `mbx.project.yaml` 与项目配置清单已核对:角色、AI、私有目录、任务 ID、`active` 状态、`codex_app_native_thread` 通信模式及 `ana-data/cases`、`ana-data/tmp`、`ai-video-downloader` 目标范围一致。
+8. 本次仅完成初始化阅读与理解记录,未执行下载,未修改正式案例资料。
+
+## 阻塞
+
+无阻塞,等待正式下载任务。
diff --git a/ai-video-downloader/worklog/2026-08-04-role-scope-expansion-bilibili-dynamic.md b/ai-video-downloader/worklog/2026-08-04-role-scope-expansion-bilibili-dynamic.md
new file mode 100644
index 0000000..30e2b3c
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-04-role-scope-expansion-bilibili-dynamic.md
@@ -0,0 +1,25 @@
+# B 站动态采集角色范围变化理解记录
+
+- 日期:2026-08-04
+- 需求编号:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 管理交接:`HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-SCOPE-EXPANSION-TERMINAL-20260804-001`
+- 当前角色:`case_analysis.video_downloader`
+- 状态:`SCOPE_CONFIRMED / READY_TO_EXECUTE`
+
+## 理解确认
+
+1. 当前职责已覆盖用户明确授权博主的纯文字动态、文章正文、图片原件和视频原件采集,以及去重、SHA-256、manifest、失败记录和处理交接。
+2. 首个授权博主为 `青枫浦上Q`。文字、文章、图片、manifest 和视频转写文本落到 `ana-data/news-青枫浦上Q/`;视频原件只落到 `F:\video\青枫浦上Q\`。不得据此写入其他博主目录或整个 `F:\video\`。
+3. 去重以动态 ID、opus ID、BV 号和来源 URL 为主键,不以标题作为唯一依据;任何既有文件均不得静默覆盖。
+4. 视频下载员负责采集与原件,`case_analysis.media_processor` 负责视频/音频/字幕处理和转写;本角色不自行转码、OCR、转写、分析、研究或审核。
+5. 浏览器已有登录状态不等于有权读取认证秘密。遇到登录提示、验证码、会员/订阅限制、付费墙、DRM、其他访问控制、来源或目标不明确、文件无法对应动态、可能覆盖既有文件时立即停止并反馈。
+6. 角色通信默认使用 Codex 原生 task/thread;正式交接只向核准目标投递一次,不自动回退 legacy MB-X inbox/route/session。
+7. 本次权限变化已由项目管理员登记并通过治理校验;执行当前用户任务无需再次申请相同范围。
+
+## 当前任务口径
+
+本轮按“广撒网收集 / 数据存储整理”执行,只采集和归档最近 72 小时内容,不对博主观点作研究结论或暗线判断;优先快速形成可追溯的原始内容与 manifest。
+
+## 阻塞
+
+无权限阻塞。文章专栏和会员视频如在浏览器中出现访问控制提示,按停止边界逐项反馈。
diff --git a/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1517-heartbeat.md b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1517-heartbeat.md
new file mode 100644
index 0000000..4541a38
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1517-heartbeat.md
@@ -0,0 +1,28 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 15:17 增量检查
+
+- 检查时间:2026-08-05 15:17(Asia/Shanghai)
+- 博主:青枫浦上Q(UID `1420210197`)
+- 执行模式:广撒网收集 / 数据存储整理
+
+## 本轮归档
+
+- `20260805-091521_article_8.5早盘_1232888061922115591.txt`
+ - 36,197 bytes
+ - SHA-256:`0DEDDD5A24CF59CEFB7CC3ED32E21D17A0B16FC5F589E77681B7A90D84D01AE0`
+- `20260805-095345_text_控制好仓位_1232897957496356868.txt`
+ - 365 bytes
+ - SHA-256:`697983AD520403F55DE030D59BA3E07D9E44F9D46C07E9F99F07EE4E7EBC7AA6`
+- `20260805-114706_text_见微知著_1232927167618220050.txt`
+ - 868 bytes
+ - SHA-256:`980386FB11F13E77F6FE3C4B10EE25DB87397B53A70E3A91BDACD4D1BF05AD05`
+
+三份 TXT 均从已登录 Chrome 的可见正文提取,按发布时间和稳定 ID 命名;严格 UTF-8 解码通过,U+FFFD 为 0。`manifest.jsonl` 已追加两条由 `CONTENT_ACCESS_PENDING_CHROME` 转为 `SAVED` 的事件,以及一条新 `SAVED` 事件。动态卡片未显示需单独归档的配图。
+
+## 视频状态
+
+- `F:\video\青枫浦上Q` 与 `C:\Users\Cai\Downloads` 仍无两个目标 BV 的视频原件或临时下载文件。
+- 视频处理员保持 `READY_BLOCKED_BY_SOURCE`;封面 WEBP 不作为媒体输入。
+- 日常开发员确认现有“视频转文字助手”侧栏主要产出音频/文本,不能把扩展任务或分离的 M4S 当作完整视频交付;最小无 Cookie 下载桥接已通过首轮 10/10,正在隔离环境做无媒体探测,尚未授权其接触正式目录。
+- 日常开发员-2 的采集工具返修已完成目标 13/13、project 76/76,审核员正在落盘 PASS/0;正式回执前不用于真实移动。
+
+状态:`CONTENT_SAVED_3 / VIDEO_PENDING_DOWNLOAD_BRIDGE / TRANSCRIPTION_BLOCKED_BY_SOURCE`
diff --git a/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1617-heartbeat.md b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1617-heartbeat.md
new file mode 100644
index 0000000..a80ffe4
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1617-heartbeat.md
@@ -0,0 +1,32 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001|16:17 心跳
+
+- 检查时间:2026-08-05 16:17—17:05(Asia/Shanghai)
+- 对象:青枫浦上Q(UID 1420210197)
+- 动态增量:公开 RSS 与现有 manifest 对比后无新增;最新仍为 `1232927167618220050`(2026-08-05 11:47:06+08:00)。
+- 开发交付:动态采集辅助工具收到 PASS/0 最终交付并已确认;完整视频下载桥的 11 项目标测试通过,两条 BV 无凭据元数据探测均通过。
+
+## 视频闭环
+
+1. `BV1DVMX6XEPq` 已下载为 `F:\video\青枫浦上Q\BV1DVMX6XEPq.mp4`:94,746,500 bytes;SHA-256 `3C6591AB65F1A821790D5C815C5D8A1B647C15CD0A980683A442D28F39105737`;FFprobe 为 1206.067664 秒,AV1 视频 + AAC 音频,与远端 1206.067 秒一致。
+2. 已通过 Codex 原生消息 `HANDOFF-VIDEO-DOWNLOADER-MEDIA-BV1DVMX6XEPq-20260805-001` 交给 `case_analysis.media_processor`,原件禁止覆盖、改名、移动或删除。
+3. `BV1HA3o6oEJJ` 远端元数据为 3133.95 秒,但公开无凭据下载仅得到 600.133313 秒预览。该文件不满足完整原件合同,已连同 mapping 可恢复隔离到 `F:\video\青枫浦上Q\.quarantine\`,严禁进入转写。
+4. 第二条完整内容需要现有授权会员会话;本轮未读取 Cookie、token 或认证秘密,未绕过会员或访问控制。
+
+## manifest
+
+- 追加 `BV1DVMX6XEPq` 完整下载与转写交接事件。
+- 追加 `BV1HA3o6oEJJ` 认证边界阻断及 600 秒预览隔离证据。
+- 既有文件未覆盖;不完整预览仍可恢复,仅移出正式媒体根目录。
+
+## 转写回执
+
+- 视频处理员回传 `HANDOFF-MEDIA-VIDEO-DOWNLOADER-BV1DVMX6XEPq-TRANSCRIPTION-RESULT-20260805-001`,状态 `TRANSCRIPTION_PASS_COMPLETE`;已用同一 handoff_id 正式回执接受。
+- TXT:`ana-data/news-青枫浦上Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.txt`,24,739 bytes,SHA-256 `272E9564708394338CCF293990223A0DAA334A1494F3903CEBE0DA50C8C447CE`。
+- 同目录 SRT/JSON/FLAC 均已生成;624 个分段在 TXT/SRT/JSON 中一致,语言 `zh`,模型 `large-v3`,设备 `cuda`,计算类型 `float16`。
+- 原视频与 mapping 经处理前后复核保持不变,`.quarantine` 未被扫描或处理。
+
+## 下载桥缺陷处置
+
+- 日常开发员已修复时长一致性 fail-open:容差为 `max(3 秒, 远端时长 × 0.1%)`;3133.95 秒远端与 600.133313 秒本地现在 fail closed 且零发布,1206.067 与 1206.067664 秒正常通过。
+- 修复后目标测试 15/15、项目回归 91/91 PASS;代码 SHA-256 `541CD9A57E23976614B80CF731C6D4D49361A29BFBC029E9053C8E7093EC18F1`。
+- 独立复审因 `dev.reviewer.project` 会话缺少 session metadata 暂时无法启动;已向项目管理员发送 `HANDOFF-VIDEO-DOWNLOADER-INFOADMIN-BILI-BRIDGE-REVIEWER-SESSION-REPAIR-20260805-001` 请求恢复 reviewer 会话并触发限定复审。
diff --git a/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1751-heartbeat.md b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1751-heartbeat.md
new file mode 100644
index 0000000..33455c7
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-1751-heartbeat.md
@@ -0,0 +1,10 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001|17:51 心跳
+
+- 检查时间:2026-08-05 17:51—17:55(Asia/Shanghai)
+- 对象:青枫浦上Q(UID 1420210197)
+- 公开 API:B 站返回 HTTP 412 风控;未用其失败响应判定增量,也未尝试绕过。
+- 已登录 Chrome:重新导航并刷新动态页,顶部仍为已归档的“见微知著”(stable_id `1232927167618220050`),其下为“控制好仓位”和“8.5(早盘)”;相对 manifest 无新增文字、文章、图片或视频。
+- 文件动作:未新增、覆盖或移动正式内容;manifest 保持 20 行,最新业务事件仍为 `BV1DVMX6XEPq` 的 `TRANSCRIPTION_PASS_COMPLETE`。
+- 视频边界:`BV1HA3o6oEJJ` 的 600 秒预览继续隔离,未转写、未重新下载。
+- 协作结果:项目管理员恢复 `dev.reviewer.project` 后,限定复审已 `PASS/0`、阻断项 0;15/15 目标测试、91/91 项目回归及独立好/坏时长、多 P、认证反例均通过。代码 SHA-256 `541CD9A57E23976614B80CF731C6D4D49361A29BFBC029E9053C8E7093EC18F1`。
+- 已正式回执接受 `HANDOFF-INFOREV-VIDEO-DOWNLOADER-BILI-BRIDGE-DURATION-GATE-REREVIEW-RESULT-20260805-001`;修复链关闭,不新建替代审核链。
diff --git a/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2059-heartbeat.md b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2059-heartbeat.md
new file mode 100644
index 0000000..0f126eb
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2059-heartbeat.md
@@ -0,0 +1,9 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001:20:59 心跳检查
+
+- 检查时间:2026-08-05 20:59(Asia/Shanghai)。
+- 范围:仅青枫浦上Q(UID 1420210197)。
+- 基线:`ana-data/news-青枫浦上Q/manifest.jsonl` 共 20 行;最新已保存动态为 `1232927167618220050`(见微知著,发布时间 2026-08-05 11:47:06+08:00)。
+- Chrome 现场:刷新后的动态列表顶部仍依次为“见微知著”“控制好仓位”“8.5(早盘)”,未见发布时间晚于 11:47 的新动态。
+- 下载入口:`C:\Users\Cai\Downloads` 自 19:30 起无新的 mp4/mkv/mov/webm/txt/crdownload/part 文件。
+- 视频状态:`BV1DVMX6XEPq` 原件与转写保持完成;`BV1HA3o6oEJJ` 仍只有隔离的 600 秒预览,未取得 52:14 完整原件,不更新完成状态、不交付转写。
+- 动作:无正式资料新增或覆盖;manifest 保持不变;Chrome 动态页保留供下次心跳继续检查。
diff --git a/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2200-heartbeat.md b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2200-heartbeat.md
new file mode 100644
index 0000000..24f17a8
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2200-heartbeat.md
@@ -0,0 +1,10 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001:22:00 心跳检查
+
+- 检查时间:2026-08-05 22:00(Asia/Shanghai)。
+- 范围:仅青枫浦上Q(UID 1420210197)。
+- 基线:manifest 仍为 20 行;最近已确认保存动态是 `1232927167618220050`(见微知著,11:47:06)。
+- Chrome:目标动态页刷新后,页面级 DOM/读取控制连续两次超时,未取得足以判断“有新增/无新增”的新快照;不使用其他浏览器、公共搜索或认证秘密绕过。
+- 只读健康检查:Chrome 正在运行;ChatGPT Chrome 扩展已安装且启用;native-host manifest 与注册表路径正确。故障限定为当前 Bilibili 动态页控制超时,不是扩展缺失。
+- 下载目录:20:50 后无新的 mp4/mkv/mov/webm/txt/crdownload/part;`F:\video\青枫浦上Q` 仍只有已完成的 BV1DVMX6XEPq 与其 mapping,BV1HA3o6oEJJ 完整原件未出现。
+- 动作:manifest 和正式资料保持不变;未把本轮控制失败误报为“无新增”。
+- blocker:需要下一次心跳重试页面读取;若持续失败,按 Chrome 插件规范需先由用户允许打开一个新的 Chrome 窗口后再做恢复尝试。
diff --git a/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2300-heartbeat.md b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2300-heartbeat.md
new file mode 100644
index 0000000..b87e9ba
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-2300-heartbeat.md
@@ -0,0 +1,13 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 23:00 小时巡检
+
+- 巡检时间:2026-08-05 23:00(Asia/Shanghai)
+- 对象:青枫浦上Q(UID `1420210197`)
+- manifest:`ana-data/news-青枫浦上Q/manifest.jsonl`,20 行,读取前最后修改时间 `2026-08-05T17:48:50+08:00`
+- manifest 当前最新已保存动态:`1232927167618220050`,发布时间 `2026-08-05T11:47:06+08:00`
+- 结果:`HELD_BY_CHROME_CONTROL_READ_TIMEOUT`
+
+本轮通过已登录 Chrome 定位到精确动态页 `https://space.bilibili.com/1420210197/dynamic`,标签页标题与 UID 匹配。刷新、DOM 快照和可视截图读取均在受控浏览器通道超时;轻量标签页枚举可用,但无法取得足够页面事实判断 11:47 之后是否有新增动态。未改写 manifest,未猜测新增内容,未读取认证秘密,未切换无登录来源绕过会话。
+
+完整视频入口方面,同一开发任务已完成 V001 安全设计并提交 `dev.reviewer.project`;当前仍在设计审核,尚无 PASS。真实认证下载、正式视频写入、600 秒预览转写和新媒体交接均为 0。
+
+下一次小时巡检继续尝试已登录 Chrome;若设计与实现审核先行通过,再按精确 BVID 门禁执行完整视频入口。
diff --git a/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-authenticated-session.md b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-authenticated-session.md
new file mode 100644
index 0000000..27a95e7
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-authenticated-session.md
@@ -0,0 +1,16 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001:已登录会话下载路径核对
+
+- 时间:2026-08-05 20:00(Asia/Shanghai)
+- 目标:青枫浦上Q(UID 1420210197),视频 BV1HA3o6oEJJ。
+- 已验证:用户已登录的 Chrome 页面显示“充电中”,播放器可见完整时长 52:14;会员/充电权限本身不是阻塞。
+- 前序 600.133313 秒文件仅为预览,继续保留在 `.quarantine`,不得发布或转写。
+- 当前 Chrome 页面控制面可操作网页内容,但不能控制 Chrome 顶栏扩展及其侧栏;页面媒体下载接口未产生完整文件。
+- “视频转文字助手”公开实现的完成项只导出 TXT,后台只获取音频并转 MP3,没有完整音视频原件出口;扩展完成不得记为视频原件完成。
+- 不读取、导出、回显或持久化 Cookie、token、密码、验证码及签名媒体 URL。
+- 开发员已补充 `accept-browser-file` 自动后半程:只接收已由授权界面完整落盘的本地媒体,执行稳定性、partial、FFprobe 音视频轨、远近端时长、SHA-256、CreateNew 发布与映射校验;目标测试 21/21 PASS,限定审核进行中。
+- 当前状态:权限已确认;完整视频原件仍未落盘,manifest 不登记完成,media_processor 不启动 BV1HA3o6oEJJ 转写。
+- 2026-08-05 21:10 接收开发终态:`PASS_LOCAL_FILE_BACKHALF_COMPLETE_INGRESS_BLOCKED`,独立复审 `PASS/0`。
+- 冻结文件的字节数、SHA-256 与 CLI 帮助已现场核对一致;`accept-browser-file` 不含 Cookie/profile/token 参数。
+- 已向开发线程发送 receipt:`HANDOFF-VIDEO-DOWNLOADER-INFODEV-BILI-AUTHENTICATED-SESSION-BACKHALF-RECEIPT-20260805-001`,状态 `RECEIVED_PASS_INGRESS_BLOCKED`;开发事项可关闭,完整 AV 入口阻断继续由下载链记录。
+- 用户要求继续参考“视频转文字助手”实现解决完整视频入口。公开源码复核确认可复用 side panel、任务队列、失败重试、localhost/native host 和 Chrome 下载列表,但现有后端固定 `bestaudio/best → MP3 → TXT`,且 `chrome.cookies.getAll` 后把 Cookie 发到 localhost 并写 Netscape 临时文件。
+- 已向 `project.admin` 发送狭义范围登记请求:`HANDOFF-VIDEO-DOWNLOADER-INFOADMIN-BILI-PLUGIN-FULL-VIDEO-SCOPE-REQUEST-20260805-001`。拟议方案为扩展内部生成完整 muxed AV、认证数据不向 Codex/日志/项目文件暴露、优先内存或受保护临时通道,随后接入既有 `accept-browser-file` 门禁;审批前不实施、不下载。
diff --git a/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-heartbeat.md b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-heartbeat.md
new file mode 100644
index 0000000..d805796
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-REQ-BILI-DYNAMIC-COLLECTOR-heartbeat.md
@@ -0,0 +1,21 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 增量检查记录
+
+- 检查时间:2026-08-05 10:34(Asia/Shanghai)
+- 博主:青枫浦上Q(UID `1420210197`)
+- 对照清单:`ana-data/news-青枫浦上Q/manifest.jsonl`
+
+## 本轮发现
+
+- 新专栏:`1232888061922115591`,发布时间 2026-08-05 09:15:21。
+- 新纯文字动态:`1232897957496356868`,发布时间 2026-08-05 09:53:45。
+- 两个稳定 ID 均不在既有 manifest 中;已追加发现记录,状态为 `CONTENT_ACCESS_PENDING_CHROME`,未创建空白或不完整 TXT。
+
+## 停止边界与保全
+
+- Chrome 标签页元数据可以列出,但受控标签页的读取、截图、新建和导航均连续超时,无法从已登录会话取得会员正文。
+- B 站未登录公共接口返回反自动化拒绝;未读取 Cookie、认证秘密或签名参数,未绕过登录、会员、验证码、DRM 或访问控制。
+- 公开 RSS 只用于确认新动态 ID、类型和发布时间,未把其“每日咨询专属”占位文本冒充正文。
+- `C:\Users\Cai\Downloads` 与 `F:\video\青枫浦上Q` 未发现目标视频或下载临时文件;既有两条视频仍保持 `VIDEO_DOWNLOAD_PENDING_EXTENSION`,本轮没有生成伪视频、移动文件或发送转写交接。
+- 开发辅助工具最新复审仍为 `HOLD/2`,未用于正式目录。
+
+状态:`DISCOVERED_2 / CONTENT_PENDING_CHROME / VIDEO_PENDING_EXTENSION`
diff --git a/ai-video-downloader/worklog/2026-08-05-extension-ingress-v001-design-hold4.md b/ai-video-downloader/worklog/2026-08-05-extension-ingress-v001-design-hold4.md
new file mode 100644
index 0000000..02108d5
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-extension-ingress-v001-design-hold4.md
@@ -0,0 +1,13 @@
+# 扩展完整视频入口 V001 设计审核 HOLD/4 处理记录
+
+- 事项:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`
+- 审核回执:`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001-DESIGN-REVIEW-RESULT-20260805-001`
+- 审核结论:`HOLD/4`
+- 审计入口:`dev-doc/开发审计报告.md`
+- 状态:`RETURNED_FOR_V002_SAME_CHAIN`
+
+V001 尚未冻结四项关键安全合同:固定扩展身份及完整 wire schema;在 yt-dlp import/首次实例化前禁用插件并覆盖真实包秘密泄漏哨兵;同一次解析对象下载及 FFmpeg argv 无签名 URL/认证材料;Windows Job Object 下的并发、超时、取消、崩溃和后代进程全灭。
+
+已将 F1-F4 一次性通过 Codex 原生任务退回唯一 owner `dev.developer.project / infodev`,要求在原 task 制作 V002 并回到同一 `dev.reviewer.project` 审核链;同时向审核员发送接收回执。未新建任务、开发员或审核链。
+
+在 V002 独立 PASS 且后续实现审核 PASS 前,真实认证、Chrome 扩展安装、网络下载、媒体生成、`F:\video\青枫浦上Q\` 正式写入及转写交接保持冻结;`600.133313 s` 预览继续隔离。
diff --git a/ai-video-downloader/worklog/2026-08-05-extension-ingress-v002-submitted.md b/ai-video-downloader/worklog/2026-08-05-extension-ingress-v002-submitted.md
new file mode 100644
index 0000000..343871e
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-extension-ingress-v002-submitted.md
@@ -0,0 +1,11 @@
+# 扩展完整视频入口 V002 设计提交接收记录
+
+- 事项:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`
+- 开发交接:`HANDOFF-INFODEV-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-SUBMITTED-20260805-001`
+- V002:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002.md`
+- 请求方只读核验:`23356 bytes`;SHA-256 `8526D942228B77C8A13FE8D6AC57B33925C59547FF25273E4A668DF2677564C2`;严格 UTF-8 PASS;无 BOM。
+- 状态:`V002_SUBMITTED_FOCUSED_REREVIEW_PENDING`
+
+已确认 V002 包含 F1—F4 对应章节、固定 expected extension ID `oidmclckpdmpabbfedplkbdplmfcenbb`、`YTDLP_NO_PLUGINS`/broker-worker 隔离、同一 `download_info`、`HttpFD|DashSegmentsFD` allowlist、CreateProcess 前秘密/URL 审计门禁,以及 Windows Job Object kill-on-close 与真实 sleeping-child 测试合同。
+
+请求方不替代 `dev.reviewer.project` 做设计判定。V002 复审 PASS 前,implementation/test/extension/host/install、真实 Cookie/签名 URL、Chrome/registry/network/download/media、`F:\video\青枫浦上Q\` 和正式 `ana-data` 动作继续为 0;`600.133313 s` 预览继续隔离。
diff --git a/ai-video-downloader/worklog/2026-08-05-plugin-full-video-narrow-scope-understanding.md b/ai-video-downloader/worklog/2026-08-05-plugin-full-video-narrow-scope-understanding.md
new file mode 100644
index 0000000..7de8cdc
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-05-plugin-full-video-narrow-scope-understanding.md
@@ -0,0 +1,15 @@
+# B 站完整视频扩展内部会话窄范围理解反馈
+
+- 事项:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 决策交接:`HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-PLUGIN-FULL-VIDEO-SCOPE-DECISION-20260805-001`
+- 角色:`case_analysis.video_downloader`
+- 状态:`PASS(已完成范围复读;真实下载继续冻结至同链设计与实现复审通过)`
+
+已重新读取 `ai-video-downloader/工作说明.md`、`项目规范.md`、`项目配置清单.md` 和 `ana-doc/案例分析规范.md`。理解如下:
+
+1. 长期采集类型仍为纯文字、文章正文、图片原件和视频原件;项目内正式落点为 `ana-data/news-青枫浦上Q/`,视频原件落点仅为 `F:\video\青枫浦上Q\`。
+2. 本次例外只覆盖 `青枫浦上Q / BV1HA3o6oEJJ`。扩展及受信本机服务可在运行内存中短暂使用认证会话,但 Codex 和视频下载员不得读取、保存、检查或接收 Cookie、token、会话标识及签名媒体地址。
+3. 扩展必须在独立实现审核通过后,内部使用 `bestvideo+bestaudio/best` 与 ffmpeg 合并完整 AV;loopback 仅允许 `127.0.0.1`、精确扩展来源、内存单次能力值,不允许通配 CORS、远程监听或请求体日志。
+4. 完整文件仍须通过 `accept-browser-file` 的稳定性、无 partial、FFprobe 音视频、约 `3133.95 s` 时长、SHA-256 和 CreateNew mapping 门禁;`600.133313 s` 预览继续隔离,不能转写或冒充原件。
+5. 视频下载员只负责原件取得、归档、校验、manifest 和 Codex 原生交接;`case_analysis.media_processor` 负责转写。当前只接收同一开发链 `dev.developer.project` 与 `dev.reviewer.project` 的正式 PASS,不新增重复开发或审核链。
+6. 未发现文档冲突;无需再次请求用户授权。若真实运行出现登录失效、验证码、DRM、无法证明完整播放、目标不唯一或覆盖风险,必须 fail closed 并记录具体阻塞。
diff --git a/ai-video-downloader/worklog/2026-08-06-1254-hourly-status.md b/ai-video-downloader/worklog/2026-08-06-1254-hourly-status.md
new file mode 100644
index 0000000..cce933c
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-06-1254-hourly-status.md
@@ -0,0 +1,58 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 小时巡检
+
+- 巡检时间:2026-08-06 12:54(Asia/Shanghai)
+- 目标:青枫浦上Q(UID 1420210197)
+- 既有 manifest:22 条;最新已归档为 `1233255470622310421` / `8.6(早盘)`
+
+## 新增发现
+
+已登录 Chrome 的动态页顶部出现一条约 2 小时前发布的新动态,正文可见内容为:
+
+> 市场目前接力情绪很好
+>
+> 上证指数突破3916则视为右侧,pcb涨的最好因为有涨价催化,今天科技很强,早盘没有加的就不要追高,依旧是看反弹,买阴不买阳,目前科技还是保持5成仓就好。
+
+页面同时显示一张配图。
+
+## 当前阻塞
+
+- Chrome 页面可以打开并截图,但 DOM/页面读取通道超时,未能取得精确动态 ID、精确发布时间和原图 URL。
+- 站点公开动态接口被客户端拦截或返回错误,不能作为可靠替代来源。
+- 因去重键和原图来源尚未确定,本轮未向正式 `ana-data/news-青枫浦上Q/` 写入猜测文件,manifest 保持不变。
+- 已完成视频下载入口的受控 build 尚未启动:本机无合同固定的离线 PyInstaller 6.15.0,`build_count=0`。管理端已请求预置离线环境或新的供应链授权。
+
+## 边界
+
+未读取或导出 Cookie/token;未下载或转写预览媒体;未安装扩展/native host,未写 HKCU,未修改 `F:\video\青枫浦上Q` 或正式案例资料。
+
+## 21:47 补充闭环
+
+- 从已登录 Chrome 的动态详情导航取得唯一编号 `1233284483106471972`;公开 opus 页面元数据给出 `pub_ts=1785984820`,换算为 `2026-08-06 10:53:40 +08:00`。
+- 正文按页面可见内容归档为 `20260806-105340_text_市场目前接力情绪很好_1233284483106471972.txt`,419 字节,SHA-256 `C8B034BBE7EA7C55D23E880B66329CCB7503B579D6C0062DD5289949C5466B2D`。
+- 原图归档为 `20260806-105340_image_市场目前接力情绪很好_1233284483106471972.jpg`,136317 字节,SHA-256 `BE6C82F18A49F3F7E9BF6129BDA5BF41836336514F617C9EF47A7D61DDD226F9`。
+- `manifest.jsonl` 新增同一 stable_id 的 `SAVED` 记录;巡检时顶部仍为该动态,没有再出现更新内容。
+- `yt-dlp==2026.7.4` 构建合同修复已完成源码与离线回归并提交原审核链,replacement build 仍为 0,等待 source PASS。
+- VideoTextHost 1.0.5 的 `write EOF` 弹窗已由项目管理员批准为相邻阻断缺陷任务,单独交给 `infodev`;真实 Host 替换仍需实现审核与精确工件批准。
+
+## 22:44 小时巡检
+
+- 发现新增充电专属视频 `BV1AkuH64EZh`,标题“行情震荡向上。”,精确发布时间 `2026-08-06 22:30:10 +08:00`,页面总时长 `41:18`(2478 秒)。
+- 原始封面已归档为 `20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg`,360053 字节,SHA-256 `41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D`。
+- `manifest.jsonl` 新增该 BV 的视频占位记录,状态为 `VIDEO_DOWNLOAD_BLOCKED_RUNTIME_STABILITY_GATE`。
+- 未启动已确认会产生前台 `write EOF` 弹框的 VideoTextHost 1.0.5。EOF 稳定性修复已由 `infodev` 进入实现;完整视频认证入口的 `yt-dlp` 构建合同修复仍在原审核员源码复审中,replacement build_count 仍为 0。
+- 未产生或接受预览文件,未读取/导出 Cookie、token 或签名媒体 URL;`F:\video\青枫浦上Q` 保持不变。
+
+## 23:51 小时巡检
+
+- 发现新增文章“8.6(复盘)”,动态页显示约 23:33 发布;可见摘要开头为“今天是一根分歧没做透的震荡日。冲高回落兑现了预期内的分歧,但力度明显弱于市场预期,量能不缩反增,承接充足。真正值得记一笔的不是早盘那波回落,而是下午一点四十之后一直到尾盘的抢筹。”
+- Chrome 页面可见内容已确认,但详情点击、截图与 DOM 深读通道连续超时,未取得唯一 opus/dynamic ID、精确发布时间和全文;因此未写入猜测文件或 manifest 占位,待下一轮继续补齐。
+- EOF 溯源产生反证:活动 Native Host 精确映射为 Python `host_win.py`,与 Electron/Node `WriteWrap.onWriteComplete` 弹窗堆栈不符。项目管理员已撤回 Host 因果归因,禁止修改现有 Host;下次弹窗仅捕获 PID、父 PID 和 EXE 路径或等价 WER 身份。
+- 按管理员决定,为 `BV1AkuH64EZh` 追加普通同 stable_id 状态更正:`VIDEO_DOWNLOAD_BLOCKED_AUTH_SESSION_SOURCE_REVIEW_HOLD2`。视频下载当前仅被认证会话源码复审 HOLD/2 阻塞;两个阻断为实际源树等集门禁和清洁 builder 环境门禁。
+- 未调用插件、未下载视频、未写 `F:\video\青枫浦上Q`,未读取认证秘密。
+
+## 01:15 小时巡检
+
+- 已补齐文章“8.6(复盘)”的唯一标识与精确时间:opus `1233480647120519170`、`cv52135841`,发布时间 `2026-08-06 23:34:53 +08:00`。动态页顶部仍为该文章,未发现午夜后的更新。
+- 原始封面已归档为 `20260806-233453_image_8.6复盘_1233480647120519170.png`,233531 字节,SHA-256 `939F5B0A8F8F15E212680B5E3A4FF6F0AD1DE66DE76900BE03B558A097927745`。
+- 已在 `manifest.jsonl` 追加文章占位,状态 `ARTICLE_TEXT_BLOCKED_LOGGED_IN_CHROME_BRIDGE_TIMEOUT`。已登录 Chrome 确认文章标签页能够加载,但 DOM、页面导出及同会话只读请求均使 Chrome 控制桥超时并重置;未将公开搜索摘要冒充全文,也未读取或导出 Cookie/token/签名请求数据。
+- 认证视频入口的 HOLD/2 两项源码修复已由 `infodev-2` 提交同一审核链限定复审;管理端确认复审仍在进行,尚无 PASS 或新的 source approval,replacement build_count 仍为 0。因此 `BV1AkuH64EZh` 的正式下载继续冻结,既有文件不变。
diff --git a/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0000-heartbeat.md b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0000-heartbeat.md
new file mode 100644
index 0000000..dd7f60b
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0000-heartbeat.md
@@ -0,0 +1,16 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 00:00 小时巡检
+
+- 巡检时间:2026-08-06 00:00(Asia/Shanghai)
+- 对象:青枫浦上Q(UID `1420210197`)
+- 动态入口:`https://space.bilibili.com/1420210197/dynamic`
+- 结果:`NEW_ITEM_DETECTED_DETAIL_ID_PENDING_CHROME_READ`
+
+已登录 Chrome 的全新受控标签页成功打开目标动态页并取得页面快照。相对 `ana-data/news-青枫浦上Q/manifest.jsonl` 当前最新已保存动态 `1232927167618220050 / 2026-08-05T11:47:06+08:00`,页面首卡确认新增文章 `8.5(复盘)`,页面显示发布时间 `2026-08-05 23:00`,正文在页面可见。
+
+本轮无法稳定取得该卡片的 opus ID/source URL:页面后续 DOM/控件读取多次超时;B 站公开接口被客户端拦截,未尝试读取 Cookie、导出会话或绕过校验。由于稳定 ID 和完整正文边界未冻结,本轮不猜测文件名、不写不完整 TXT、不追加误导性 manifest。下一轮优先补齐该文章的稳定 URL、完整正文、SHA-256 和 manifest。
+
+同轮收到 V002 设计复审 `PASS/0`:`V001_PLUS_V002_IMPLEMENTATION_READY_OFFLINE_ONLY`。已在原 task 将最小离线实现授权投递唯一 owner `dev.developer.project / infodev`,开发员已开始固定 yt-dlp 模块哈希与 API 形状;真实 Cookie/session/Chrome、扩展安装、HKCU、网络下载、隔离预览、`F:\video\青枫浦上Q\`、正式 `ana-data` 和转写动作仍为 0。
+
+- V002 复审:`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-DESIGN-REREVIEW-RESULT-20260805-001`
+- 审计入口:`dev-doc/开发审计报告.md`
+- 审计 SHA-256:`D4552A6590C978E7BEE4437CBD5B889E9C0B2DF0DF85608DFA00B05CA560E91D`
diff --git a/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0100-heartbeat.md b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0100-heartbeat.md
new file mode 100644
index 0000000..27cf4da
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0100-heartbeat.md
@@ -0,0 +1,17 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 01:00 小时巡检
+
+- 巡检时间:2026-08-06 01:00(Asia/Shanghai)
+- 对象:青枫浦上Q,UID `1420210197`
+- 动态入口:`https://space.bilibili.com/1420210197/dynamic`
+- 结果:`NEW_ARTICLE_BODY_CONFIRMED_STABLE_ID_PENDING`
+
+本轮继续使用已登录 Chrome 核对目标动态页。相对 `ana-data/news-青枫浦上Q/manifest.jsonl`,页面首卡仍为尚未归档的文章 `8.5(复盘)`,页面显示发布时间为 `2026-08-05 23:00`。本轮已读取到从“今日指数低开后全天震荡走高”开始、至免责声明结束的完整可见正文,确认正文不是摘要,也没有把图片、封面或预览内容误当文章正文。
+
+专栏搜索无法精确匹配该博主的这条动态;动态页的语义快照可读,但针对卡片属性、链接和点击的后续控制多次超时,因此仍未取得可核验的 opus/dynamic ID 与 canonical source URL。未读取或导出 Cookie、token、签名媒体 URL,也未绕过登录或访问控制。为避免猜测稳定 ID、错误命名或污染 manifest,本轮没有写正式 TXT、没有追加 manifest,下一轮继续优先补齐该文章的稳定链接与正式归档。
+
+同轮检查开发链:V002 `PASS/0` 后的离线实现已进入首轮测试;固定身份/协议、side panel 双击闭环、内存 Cookie 转换、yt-dlp 插件门、单次解析/downloader allowlist、局部 FFmpeg 合并、Worker Job Object 与冻结后半程调用已编码。当前仅运行临时合成媒体与无网络休眠进程测试;真实会话、Chrome 安装、网络下载、`F:\video\青枫浦上Q` 与正式 `ana-data` 写入仍未启动。
+
+- manifest 变更:0
+- 正式内容文件变更:0
+- 视频/转写变更:0
+- 停止边界:stable ID/canonical source URL 尚未取得
diff --git a/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0200-heartbeat.md b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0200-heartbeat.md
new file mode 100644
index 0000000..6eb0983
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0200-heartbeat.md
@@ -0,0 +1,21 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 02:00 小时巡检
+
+- 巡检时间:2026-08-06 02:00(Asia/Shanghai)
+- 对象:青枫浦上Q,UID `1420210197`
+- 动态入口:`https://space.bilibili.com/1420210197/dynamic`
+- 结果:`NEW_ARTICLE_SAVED`
+
+本轮通过已登录 Chrome 重新读取动态页,页面最新项仍为 `8.5(复盘)`,没有发现比该项更晚的动态。动态卡片原始结构给出 `data-url=//www.bilibili.com/opus/1233100718643085351`,据此冻结 stable ID 与 canonical source URL;详情页正文从首段至免责声明完整可见。
+
+发布时间按 B 站动态 ID 的高 32 位时间字段恢复:`floor(1233100718643085351 / 2^32) + 1498838400 = 1785942034`,得到 `2026-08-05T23:00:34+08:00`。常量 `1498838400` 已使用 manifest 中三条既有、具精确发布时间的动态 ID 独立交叉验证,三条结果完全一致。
+
+正式交付:
+
+- 文件:`ana-data/news-青枫浦上Q/20260805-230034_article_8.5复盘_1233100718643085351.txt`
+- 字节:`9768`
+- SHA-256:`071872E70883A193643011C268E0C317C4827D0BF7BFEFAB97674D2878F3818E`
+- manifest:新增 1 条,当前 21 行;21/21 JSON 解析通过
+- 去重:stable ID 1 条、canonical source URL 1 条
+- UTF-8:严格解码通过,无 BOM
+
+本轮没有新增视频,因此未创建下载或转写交接。`BV1HA3o6oEJJ` 的 600 秒预览仍只保留在隔离区,不作为完整原件。认证下载组件离线实现当前 21 项门禁全通过,Win32 后代进程强杀链已实测通过;开发员正在补交付说明并运行完整项目回归,尚未安装或执行真实认证下载。
diff --git a/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0300-heartbeat.md b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0300-heartbeat.md
new file mode 100644
index 0000000..4a48a52
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-0300-heartbeat.md
@@ -0,0 +1,12 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 03:00 小时巡检
+
+- 巡检时间:2026-08-06 03:00(Asia/Shanghai)
+- 对象:青枫浦上Q,UID `1420210197`
+- 动态入口:`https://space.bilibili.com/1420210197/dynamic`
+- 结果:`NO_NEW_DYNAMIC`
+
+已登录 Chrome 动态页顶部仍为 `8.5(复盘)`,显示时间 `昨天 23:00`,canonical source URL 为 `https://www.bilibili.com/opus/1233100718643085351`。该 stable ID 已在上轮保存并登记;页面顶部没有比它更晚的动态,文章卡片正文区域图片数为 0,因此本轮没有新增 TXT、图片或 manifest 记录。
+
+正式目录保持 21 条 manifest 记录;最新文件仍为 `20260805-230034_article_8.5复盘_1233100718643085351.txt`。`F:\video\青枫浦上Q` 未出现新原件,`BV1HA3o6oEJJ` 的 600 秒预览继续隔离且未转写。
+
+认证下载组件的离线实现已经落盘:源清单、项目自有 side panel、Native Messaging host、内存 Cookie 会话、单次解析/下载器门禁、FFmpeg 本地合并、Windows Job Object 与冻结后半程均已实现。开发验证为目标环境 `28/28 PASS`、冻结 bridge `21/21 PASS`、全项目 `127 tests / OK / 6 skipped`,严格 UTF-8、语法、JSON、PowerShell parser、CLI、diff 与治理均通过;真实认证、安装、注册表、网络下载和正式路径动作仍为 0,当前等待限定实现复审。
diff --git a/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-1035-status.md b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-1035-status.md
new file mode 100644
index 0000000..5a09fd9
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-06-REQ-BILI-DYNAMIC-COLLECTOR-1035-status.md
@@ -0,0 +1,22 @@
+# REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 10:35 进展核对
+
+- 对象:青枫浦上Q,UID `1420210197`
+- 动态入口:`https://space.bilibili.com/1420210197/dynamic`
+- 结果:`NEW_ARTICLE_SAVED / VIDEO_INGRESS_HOLD3_CAPACITY_ESCALATED`
+
+已登录 Chrome 动态页发现新增文章 `8.6(早盘)`,卡片给出 canonical source URL `https://www.bilibili.com/opus/1233255470622310421`,详情页正文完整可见,正文区域图片数为 0。按既有动态 ID 时间字段规则得到发布时间 `2026-08-06T09:01:05+08:00`。
+
+正式交付:
+
+- 文件:`ana-data/news-青枫浦上Q/20260806-090105_article_8.6早盘_1233255470622310421.txt`
+- 字节:`12910`
+- SHA-256:`AD022C6D7AA17F5107762B541E4BD8CC38BB285363DD1043EA91562B0ECA8751`
+- manifest:新增 1 条,当前 22 行;22/22 JSON 解析通过
+- 去重:stable ID 1 条、canonical source URL 1 条
+- UTF-8:严格解码通过,无 BOM
+
+视频侧没有新增完整原件:`F:\video\青枫浦上Q` 仍只有已完成并转写的 `BV1DVMX6XEPq.mp4` 与 mapping;`BV1HA3o6oEJJ` 的 600 秒预览仍在隔离区,未当作完整视频、未转写。
+
+认证下载组件限定复审结果为 `HOLD/3`:F2-R1 并发双击会重复读取/发送会话秘密,F4-R1 正式用法仍存在 one-directory 与实际 `--onefile` 的矛盾,F5-R1 缺少独立于可变源码树的审核信任锚。F1、F3、F4 构建代码路径、F5 安装事务回滚已通过并冻结。审计:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-F1-F5-REREVIEW-20260806-001`,`dev-doc/开发审计报告.md|328122|FBE94CCE8A55602207EB99F80240C90EC87988035EF06995395BA2F8F28B79EF`。
+
+唯一开发线程续修 turn 因 usage limit 失败,提示 `2026-08-08 13:04` 后可重试。已向 project.admin 提交容量阻断,要求在保留同一 task、同一冻结 bridge 和同一审核链的前提下决定等待原 owner 或续派有容量的既有开发角色。真实 build/install/HKCU/Chrome Cookie/network/download/media/正式视频与转写动作仍为 0。
diff --git a/ai-video-downloader/worklog/2026-08-07-authenticated-session-build-blocked.md b/ai-video-downloader/worklog/2026-08-07-authenticated-session-build-blocked.md
new file mode 100644
index 0000000..c7aed26
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-07-authenticated-session-build-blocked.md
@@ -0,0 +1,76 @@
+# 认证会话 replacement build 阻塞回执
+
+- 需求:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 开发任务:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`
+- 接收回执:`HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-BLOCKED-20260807-001`
+- 当前状态:`BUILD_FAILED_CLEANLY_ADMIN_DECISION_PENDING`
+
+## 核验结果
+
+1. 原 HOLD/2 源码限定复审已 `PASS/0`,审核结论为 `HOLD2_F1_F2_CLOSED_SOURCE_APPROVED`;该 PASS 只放行一次受控 replacement build,不放行安装或真实下载。
+2. 唯一一次 replacement build 已消耗。离线安装 `yt-dlp==2026.7.4` 到临时 `.deps` 成功,但 PyInstaller spec 执行 `copy_metadata('yt-dlp')` 时未发现临时 distribution metadata,返回 `PackageNotFoundError`,构建以 exit 1 干净失败且未重试。
+3. replacement 输出目录已回滚为不存在,replacement EXE 和 schema 2 receipt 均为 0;旧 EXE/receipt 仍只作为历史证据,不可安装。
+4. 证据:`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-blocked-20260807.json`,`3253` 字节,SHA-256 `34EEC108F0F27384F605C67AB4529682463196B8C0A5F03CC96E595DD61D19FA`。
+
+## 下载员边界与后续
+
+- 当前不可安装、不可执行 EXE,也不可启动 Chrome 认证会话或真实媒体下载;`F:\video\青枫浦上Q` 与正式 `ana-data` 均不因本回执发生变化。
+- 等待 `project.admin` 对同一任务的最小源码修订、新 source 复审及新的一次构建作出决定。下载员不自行重试构建,不新建平行开发或审核链。
+- 因 replacement build 未形成,`BV1AkuH64EZh` 继续保持 `VIDEO_DOWNLOAD_BLOCKED_AUTH_SESSION_SOURCE_REVIEW_HOLD2` 的既有历史记录;后续状态变化只追加更正,不重写 manifest 历史。
+
+## 01:30 管理端决定
+
+- 决定回执:`HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-REBUILD-DECISION-20260807-001`。
+- 管理端已授权同一 owner 在同一任务内完成 `.deps` metadata 可见性的最小源码修复,并沿既有 `inforev` 审核链提交;只有新 source `PASS` 和新的树外 source approval 形成后,才条件放行新的一次 clean build。
+- 真实运行边界不变:install/HKCU/EXE/Chrome/Cookie/session/media network/download、`F:\video`、正式 `ana-data` 与转写均未放行。EOF 弹窗溯源事项继续与本构建阻塞解耦。
+- 已为 `BV1AkuH64EZh` 在 manifest 追加最新状态 `VIDEO_DOWNLOAD_BLOCKED_YTDLP_REPLACEMENT_BUILD_REPAIR_PENDING`,仅更正当前阻塞原因,不覆盖 HOLD/2 历史。
+
+## 02:27 metadata 修复提交
+
+- 开发回执:`HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-SOURCE-REVIEW-SUBMITTED-20260807-001`。
+- `.deps` metadata-path 最小修复已完成并提交原审核员限定复审;扩展 `32` 项、冻结 bridge `21` 项、项目回归 `134` 项通过。
+- 修复证据:`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/metadata-repair-source-rereview-20260807.json`,`4345` 字节,SHA-256 `217CD836934DD4C31B2C6A67DDCBE84B85B11AD7E584B0EDDCA0AF311E46B108`。
+- 当前仍为 `SOURCE_REREVIEW_PENDING`,修复后新构建计数为 0;审核 PASS 与新的树外 source approval 前不得构建,安装和真实认证下载仍为 0。
+- 已在 `BV1AkuH64EZh` 的 manifest 历史追加 `VIDEO_DOWNLOAD_BLOCKED_YTDLP_METADATA_REPAIR_SOURCE_REREVIEW_PENDING`,不覆盖前序构建阻塞记录。
+
+## 02:28 管理端进度同步
+
+- 管理端回执 `HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-SOURCE-REVIEW-PROGRESS-20260807-001` 确认修复快照与 machine evidence 精确匹配,原 `inforev` 正在执行同链 source rereview。
+- 输出 `-002` 不存在,post-repair build count 仍为 0;无新任务或平行审核链。该同步未改变当前 manifest 状态或真实运行冻结边界。
+
+## 03:22 F1-only 复审进度
+
+- 管理端回执:`HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-PREVALIDATION-F1-SOURCE-REREVIEW-PROGRESS-20260807-001`。
+- F1-only 冻结源码快照与 machine evidence 已由管理端确认精确匹配;证据显示 marker 未执行、篡改输出/receipt 不存在、候选输出 `-002` 不存在、replacement build count 为 0。
+- 原 `inforev` 正在执行同一审核链复审,尚无 PASS 或新的 reviewer-owned source approval。当前 manifest 的 `VIDEO_DOWNLOAD_BLOCKED_YTDLP_METADATA_REPAIR_SOURCE_REREVIEW_PENDING` 仍准确,因此不重复追加同状态记录。
+- build/install/HKCU/EXE/Chrome/Cookie/session/network/download/media、`F:\video`、正式 `ana-data` 与转写继续冻结;无新任务或审核链。
+
+## 03:38 replacement build-002 阻断
+
+- 开发回执:`HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-002-BLOCKED-20260807-001`;关联审核结果 `HANDOFF-INFOREV-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-PREVALIDATION-F1-SOURCE-REREVIEW-RESULT-20260807-001`。
+- F1 source 修复已获 reviewer `PASS/0`,新 source approval 与构建前输入复算通过。唯一一次 clean build-002 中,离线 yt-dlp 安装和 PyInstaller EXE 组装完成,但 receipt 前静态归档门未找到固定 yt-dlp package metadata,故 exit 1。
+- attempt count=`1`、retry=`0`;输出 `dev/tmp/bili-auth-controlled-build-ytdlp-20260807-002` 已回滚为不存在,accepted EXE=`0`、schema 2 receipt=`0`,没有可安装产物。
+- 阻断证据:`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-002-blocked-20260807.json`,`2322` 字节,SHA-256 `3E184118BEE03C8AA6D536B10C0B41F2B795FF8D4D56D72911B065CA9D50A257`。
+- 已为 `BV1AkuH64EZh` 追加 manifest 状态 `VIDEO_DOWNLOAD_BLOCKED_YTDLP_REPLACEMENT_BUILD_002_ARCHIVE_METADATA_MISSING`;等待管理端沿原链决定后继最小修订与新授权,本角色不重试、不安装。
+
+## 03:40 build-002 后继决定
+
+- 管理端回执:`HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-BLOCKER-DECISION-20260807-001`。
+- 同一 owner/reviewer 链获准先区分“包内确实缺失 metadata”与“validator 漏判”,再对精确 archive-metadata 合同做最小源码修复并取得新的 source PASS/approval。
+- 只有上述门禁完成后才可尝试一次 clean build-003;当前不授权 build-003、安装、EXE 执行、Chrome/Cookie/session 或真实媒体采集。
+- 已为 `BV1AkuH64EZh` 追加 `VIDEO_DOWNLOAD_BLOCKED_YTDLP_ARCHIVE_METADATA_CONTRACT_REPAIR_PENDING`,保留 build-002 失败历史。
+
+## 10:20 archive-metadata source 复审容量恢复决定
+
+- 管理端回执:`HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-ARCHIVE-METADATA-SOURCE-REVIEW-CAPACITY-RECOVERY-DECISION-20260807-001`,关联本角色升级 `HANDOFF-VIDEO-DOWNLOADER-INFOADMIN-BILI-ARCHIVE-METADATA-SOURCE-REVIEW-CAPACITY-BLOCKER-20260807-001`。
+- 当前没有获授权的替代审核员;原 `inforev` 任务继续保留。此前 usage-limit `systemError` 不是审核结论。
+- 管理端已安排 `2026-08-08T13:35:00+08:00` 的同链恢复检查;仅在 verdict、audit 和 reviewer-owned source approval 仍缺失时恢复原审核员,不创建新任务或平行审核链。
+- 已追加 manifest 状态 `VIDEO_DOWNLOAD_BLOCKED_YTDLP_ARCHIVE_METADATA_SOURCE_REVIEW_RESUME_SCHEDULED`。build-003 仍不存在且禁止执行;安装、认证浏览器运行、媒体下载、`F:\video`、正式 `ana-data` 与转写均未放行。
+
+## 11:03 用户确认自有工具路线
+
+- 产品决定:不再把第三方“视频转文字助手”作为完整视频下载主链或运行依赖;现有插件只作为已经验证过的交互和会话使用原理参考,不复制其专有代码、服务或接口。
+- 延续既有 `DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`,不新建平行任务。项目自有实现为 `dev/project-dev/bili_authenticated_extension/` 下的 Chrome side panel + Native Messaging host + worker。
+- 自有工具只覆盖本事项所需窄功能:用户可见的精确 BVID 二次点击、Chrome 已登录会话在运行内存中的短生命周期使用、`bestvideo+bestaudio/best`、FFmpeg 合并、FFprobe 完整 AV/时长校验、SHA-256、CreateNew mapping 与处理交接。
+- 复用固定版本的 `yt-dlp`、FFmpeg 和 PyInstaller 等成熟开源基础组件;不自行重造媒体解析器、下载协议栈或封装器,也不形成通用下载器、跨平台产品或第三方插件兼容层。
+- 当前门禁不变:先等原审核链完成 archive-metadata source PASS/approval,再 build-003、hash-only 确认、受控安装和真实视频闭环;不会因路线确认提前构建或下载。
diff --git a/ai-video-downloader/worklog/2026-08-07-hourly-status.md b/ai-video-downloader/worklog/2026-08-07-hourly-status.md
new file mode 100644
index 0000000..e7b5eed
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-07-hourly-status.md
@@ -0,0 +1,34 @@
+# 2026-08-07 小时巡检
+
+## 01:39 巡检
+
+- 目标:青枫浦上Q(UID `1420210197`);基线为 `ana-data/news-青枫浦上Q/manifest.jsonl` 27 条记录。
+- Chrome 扩展的轻量连接检查正常,能列出目标动态页;但目标页 DOM 读取和截图均在只读阶段超时并重置页面控制通道。为避免影响电脑使用,本轮未继续重复触发。
+- 哔哩哔哩公开动态 HTML 未提供可核验的动态列表;因此本轮没有证据证明 `2026-08-06 23:34:53 +08:00` 的文章 `1233480647120519170` 之后出现新动态,也没有把“未能读取”误记为“确定无更新”。
+- “8.6(复盘)”的付费正文仍只允许从已登录会话读取;未使用公开搜索摘要拼接全文,未读取或导出 Cookie、token、签名 URL。
+- 视频侧最新状态仍为 `VIDEO_DOWNLOAD_BLOCKED_YTDLP_REPLACEMENT_BUILD_REPAIR_PENDING`。本轮未调用“视频转文字助手”、未下载媒体、未修改 `F:\video\青枫浦上Q`,未向视频处理员交付新原件。
+
+下一轮继续先做轻量连接检查;只有页面内容通道恢复后,才核对新增动态并补取文章全文。
+
+## 02:41 巡检
+
+- 仅尝试一次动态页可见 DOM 读取,页面内容通道仍超时并重置;随后已收束浏览器标签,没有继续截图、深读或插件操作。
+- 因无法取得稳定可见内容,本轮仍不能可靠断言是否有新增动态;manifest 保持 28 条,不写猜测记录。
+- metadata-path 修复仍处于原审核员 `SOURCE_REREVIEW_PENDING`,审核目录尚无 PASS 或新树外 source approval;post-repair build count 仍为 0。
+- 未下载视频、未读取认证秘密、未修改 `F:\video\青枫浦上Q`,也没有新的媒体原件可交给视频处理员。
+
+## 03:43 巡检
+
+- 为避免连续超时继续影响电脑使用,本轮没有触发 Chrome 页面内容读取或插件操作,改用哔哩哔哩官方公开索引做低干扰核验。
+- 官方视频搜索页的服务端数据确认,最新视频仍为 `BV1AkuH64EZh`(“行情震荡向上。”,发布时间 `2026-08-06T22:30:10+08:00`),未发现发布时间更晚的视频。
+- 官方文章搜索页与空间文章页只返回页面外壳,无法核验 `2026-08-06T23:34:53+08:00` 的文章 `1233480647120519170` 之后是否有新增文字、图片或文章;未用公开摘要替代会员正文。
+- 完整视频工具链仍处于 yt-dlp archive metadata 合同最小修复待审阶段;未观察到新的 source PASS 或 build-003 产物。
+- manifest 保持 30 条;未下载媒体、未修改 `F:\video\青枫浦上Q`、未读取或导出认证秘密,也没有新的完整原件可交给视频处理员。
+
+## 09:59 巡检(10:09 完成)
+
+- 已登录 Chrome 中精确目标动态页仍能定位,但本轮唯一一次只读 DOM 获取再次超时;随后立即释放页面控制,没有截图、点击插件或下载,避免继续影响电脑使用。
+- 哔哩哔哩官方视频搜索索引仍把 `BV1AkuH64EZh`(发布时间 `2026-08-06T22:30:10+08:00`)列为最新视频,未发现更晚的视频;公开文章和空间页面仍不能可靠核验 `1233480647120519170` 之后的文字、图片或文章增量。
+- archive-metadata 合同修复已经完成,原审核员独立完成 source `17/17` 与聚焦测试 `1/1`,但审核回合因 usage limit 终止,未形成正式 PASS/HOLD、审计或新的树外 source approval;候选 build-003 不存在。
+- 已通过 Codex 原生通信将同链审核容量阻塞升级给 `project.admin`,要求恢复原审核员或明确重派唯一审核员,不创建平行任务。
+- 为 `BV1AkuH64EZh` 追加状态记录 `VIDEO_DOWNLOAD_BLOCKED_YTDLP_ARCHIVE_METADATA_SOURCE_REVIEW_CAPACITY`;manifest 现为 31 条。视频目录仍只有既有 `BV1DVMX6XEPq` 原件和 mapping,没有新原件可交给视频处理员。
diff --git a/ai-video-downloader/worklog/2026-08-08-authenticated-session-source-pass-build003-started.md b/ai-video-downloader/worklog/2026-08-08-authenticated-session-source-pass-build003-started.md
new file mode 100644
index 0000000..e8ca080
--- /dev/null
+++ b/ai-video-downloader/worklog/2026-08-08-authenticated-session-source-pass-build003-started.md
@@ -0,0 +1,115 @@
+# 已登录会话下载工具:源码复审通过并启动 build-003
+
+- 时间:2026-08-08T14:05:52+08:00
+- 需求:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 开发任务:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`
+- 状态:源码复审 `PASS/0`,唯一开发员已启动一次 clean build-003;尚未形成可安装终态。
+
+## 核验
+
+- 审计:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-CONTRACT-SOURCE-REREVIEW-20260808-001`
+- 审计文件:`dev-doc/开发审计报告.md`,365615 bytes,SHA-256 `805261B7950700B8633C8B66A1D95E77915DB1CD43725D517A8BEC633EBA7D71`
+- 源清单:3029 bytes,SHA-256 `508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`
+- 审核员树外批准:`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-source-approval.json`,373 bytes,SHA-256 `A46A8D29A42B85E0DB83AA4EC5153B298BE9C24684637C6B7864AD23EF5B41E8`
+- build-003:`dev/tmp/bili-auth-controlled-build-ytdlp-20260807-003/` 已出现 `.build/.deps/.spec`,并检测到相关构建进程;当前不得解释为构建成功。
+
+## 边界
+
+等待 build-003 终态、exact-build hash-only PASS 和新的 install-exact-build approval。此前不安装、不写 HKCU、不执行候选 EXE,不启动 Chrome/Cookie/session 下载,不写入新视频原件或转写结果。
+
+## 14:48 终态修正
+
+build-003 已失败关闭,不再处于运行中。唯一一次构建完成 PyInstaller onefile 组装,但 receipt 前静态归档门因 Windows 上 metadata 的 CArchive decoded type 为 `b`、当前门禁只接受 `x` 而误拒绝;脚本已回滚整个输出目录,accepted EXE/receipt=`0/0`,未重试。
+
+- 错误:`E_BUILD_ARCHIVE_METADATA_TYPE_FALSE_REJECTION`
+- 证据:`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-003-blocked-20260808.json`,3516 bytes,SHA-256 `0FA8D0D9719D19C819F607E383D12B93021CE718489A742F740FBFEE13BBCC73`
+- 当前边界:等待同任务最小类型合同修复、源码复审、新 source approval 和新的单次构建授权;安装及真实下载继续冻结。
+
+## 14:52 管理端后继决定
+
+project.admin 已沿原任务链授权唯一 owner 仅修复 PyInstaller archive metadata type 合同:精确固定 metadata DATA entry 可接受 `x` 或 Windows-promoted `b`,其余 exact 路径、文件集合、字节、Name/Version 和 tree hash 门禁必须保持不变,并补 real-writer 与负向类型/漂移测试。
+
+build-003 授权已消耗。只有同链 source `PASS/0` 和新的 reviewer-owned source approval 形成后,才可尝试一次 clean build-004;安装、HKCU、候选 EXE 执行、Chrome 会话、真实下载、正式媒体和转写继续冻结。
+
+## 15:49 类型合同修复完成
+
+唯一开发员已完成最小修复及离线验证:仅精确 metadata 根的 7 个固定文件允许 DATA type `x` 或 Windows-promoted `b`,其余 type、路径/文件集/bytes/Name/Version/tree-hash 漂移继续失败关闭。real-writer、installer、完整类型门、冻结 bridge 与项目回归均通过。
+
+- source manifest:3029 bytes,SHA-256 `21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`
+- 证据:`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-type-contract-repair-20260808.json`,4012 bytes,SHA-256 `5880E90521F1B382C8B546A5814CBEC5DDEC826897F1EC498EEDE0C18C4263EB`
+- 当前状态:`PENDING_ARCHIVE_METADATA_TYPE_SOURCE_REREVIEW`;build-004 计数仍为 0,候选输出不存在。
+
+## 16:51 类型合同源码复审通过
+
+原审核员完成同链聚焦复审并给出 `PASS/0`,结论为 `ARCHIVE_METADATA_TYPE_CONTRACT_CLOSED_SOURCE_APPROVED`。新的 reviewer-owned source approval 精确绑定 source manifest `3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`。
+
+- 审计:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-TYPE-CONTRACT-SOURCE-REREVIEW-20260808-001`
+- approval:`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json`,373 bytes,SHA-256 `102B30405B81137BD3AC9B81CFA39A6A7BB21531115FB3F361F821B2A093335E`
+- 当前允许:唯一 owner 使用 exact source 执行一次 clean build-004。
+- 当前仍禁止:安装、HKCU、候选 EXE 执行、Chrome/Cookie/session、真实下载、正式媒体和转写;build-004 产出后仍需 exact-build hash-only PASS 与新 install-exact-build approval。
+
+## 16:52 build-004 已启动
+
+已观察到 `dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004/` 的 `.build/.deps/.spec` staging 目录和相关离线构建进程。该状态只表示构建进行中,不表示 EXE/receipt 已接受;等待正式终态回执。
+
+## 16:56 build-004 静态构建成功
+
+build-004 输出现已收敛为两个文件,staging 目录不存在;schema 2 receipt 的 `archive_verification.status=PASS`,metadata type codes=`[b]`。候选 EXE 未执行、未安装。
+
+- EXE:`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004/project-info-bili-auth-native-host.exe`,21343775 bytes,SHA-256 `DBF155BCC05B4DF55592D0A4B56612F8BE9C990B3B9AF7691F6A11648866B0B0`
+- receipt:`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004/build-artifact-manifest.json`,2874 bytes,SHA-256 `0B36AA7DCA3AD80F8A370E96D50CAAFB386291EE2741305C7797ED663F5C2B27`
+- 当前状态:等待原审核员 exact-build hash-only 确认及新的 install-exact-build approval;此前安装、运行、Chrome 会话和真实下载继续冻结。
+
+## 17:26 build-004 hash-only HOLD/1
+
+原审核员正式结论为 `HOLD/1 / BUILD004_NOT_INSTALLABLE_NO_INSTALL_APPROVAL`。EXE 与 receipt 自身 bytes/hash 及静态 archive 内容一致,但 receipt/静态复算 metadata tree SHA-256 为 `32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`,受批 installer 固定要求 `7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`,因此 preinstall 必然在 copy/HKCU 前失败。
+
+- 审核消息:`msg_20260808172457767_a0494086`
+- 结论:未创建 install-exact-build approval;build-004 EXE/receipt 仅保留为 `NOT_INSTALLABLE` 冻结证据。
+- 下一步:等待 project.admin 沿同链授权最小 tree-hash 合同修复、新 source 复审和新的构建生命周期。
+
+## 17:33 build-005 前置修复授权
+
+project.admin 已授权原 task/infodev-2/inforev 链仅统一七文件 canonical tree-hash 合同,并增加 production-equivalent installer preinstall 正负例。build-004 保持 `NOT_INSTALLABLE` 历史证据,不得修改、安装、重试或复用旧 approval。
+
+- canonical current:`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`
+- stale installer constant:`7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`
+- build-005 当前计数为 0、输出不存在;同链 source PASS 与新 reviewer approval 前不得构建。
+
+## 20:00 canonical tree-hash 源码复审通过
+
+原审核员完成同链复审并给出 `PASS/0 / CANONICAL_TREE_HASH_CONTRACT_CLOSED_SOURCE_APPROVED`。build 与 installer 现共同读取 source manifest 中的唯一 golden canonical 定义,tree SHA-256 固定为 `32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`;产品代码中旧 `7C9ABE...105C` 匹配数为 0。
+
+- source manifest:3681 bytes,SHA-256 `333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535`
+- source approval:`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-canonical-tree-hash-source-approval.json`,373 bytes,SHA-256 `E0D621EB2C5E8149FE9B775CF7EA2F2ECB17C3FC3A6D4911D67CF2D24ACBD3EF`
+- 当前允许:唯一 owner 执行一次新的 clean build-005;当前输出仍不存在。
+- 当前仍禁止:安装、EXE 执行、Chrome 会话、真实下载、正式媒体和转写;build-005 后仍需 hash-only PASS 与新 install approval。
+
+## 20:01 build-005 静态构建成功
+
+build-005 已收敛为两个普通文件,staging 目录和构建进程均不存在。schema 2 receipt 绑定 canonical metadata tree SHA-256 `32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`,archive type codes=`[b]`;候选 EXE 未执行、未安装。
+
+- EXE:`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005/project-info-bili-auth-native-host.exe`,21343716 bytes,SHA-256 `CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE`
+- receipt:`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005/build-artifact-manifest.json`,2874 bytes,SHA-256 `CC6663A15CDEB375167900730A9F956C9B09839558586E5DFC3D7FC57728ACC0`
+- 当前状态:等待原审核员 exact-build hash-only 确认和新的 install-exact-build approval;真实运行仍冻结。
+
+## 20:45 build-005 exact-build PASS/0
+
+原审核员完成 hash-only 复核并给出 `PASS/0 / BUILD005_EXACT_BUILD_HASH_ONLY_CONFIRMED`,同时创建新的 reviewer-owned `install-exact-build` approval,精确绑定 source manifest、build-005 receipt 和 EXE。
+
+- install approval:`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json`,596 bytes,SHA-256 `4D18A32DE9DFEB6B90C1A398087BD36CEAE09418D54E1F0DC485D6B95C962A44`
+- 当前状态:`NOT_INSTALLED`。等待 project.admin 或请求方另行明确受控安装调度;批准文件本身不授权本回合安装、HKCU 或 EXE 执行。
+
+## 21:45 build-005 安装成功与首次目标运行预检阻塞
+
+受控安装终态已确认:安装根为 `C:\Users\Cai\AppData\Local\project-info\bili-auth-native-host\1.0.0+20260805.v002`,三项普通文件及 HKCU Native Messaging 默认值复算匹配;项目管理员随后仅授权对 `BV1HA3o6oEJJ` 执行一次精确目标受控运行。
+
+本角色完成只读前置核对:目标目录存在,正式媒体与 mapping 均不存在,partial 文件数为 0;安装 EXE、配置、Native Host manifest、host 名和 allowed origin 均与交接证据一致。Chrome 控制通道连接正常,但其安全策略禁止访问 `chrome-extension://oidmclckpdmpabbfedplkbdplmfcenbb/sidepanel.html`,且当前控制面不能从工具栏核验固定扩展是否已安装、启用并与审核快照一致。
+
+因此按授权序列 fail closed:未执行安装 EXE,未访问 Cookie/session,未发起网络或媒体下载,未写入 `F:\video\青枫浦上Q`,实际 runtime/download attempt 均为 0。已登记状态 `VIDEO_DOWNLOAD_BLOCKED_EXTENSION_IDENTITY_VISIBILITY`,等待用户可见的固定扩展存在/启用证据或管理端批准的更安全控制面;不得自行重试。
+
+## 21:48 管理端确认扩展未加载
+
+project.admin 完成只读 Chrome profile 回读:`Default/Extensions` 下不存在固定 ID `oidmclckpdmpabbfedplkbdplmfcenbb`,`Default` 的 Preferences 中也无该 ID,且没有其他 `Profile *` 配置。该证据把前一条“控制面不可见”的不确定性收敛为“项目扩展未加载”。
+
+当前 Native Messaging Host 安装仍精确且健康,但首次精确目标运行正式终止为 `BLOCKED_EXTENSION_NOT_LOADED`。EXE、认证会话、网络、下载、正式媒体和 mapping 副作用均为 0;当前授权不允许安装或加载扩展,也不允许重试。下一门禁为对 exact reviewed Chrome extension 的独立受控安装/加载调度。
diff --git "a/ai-video-downloader/\345\267\245\344\275\234\350\257\264\346\230\216.md" "b/ai-video-downloader/\345\267\245\344\275\234\350\257\264\346\230\216.md"
new file mode 100644
index 0000000..286c0f1
--- /dev/null
+++ "b/ai-video-downloader/\345\267\245\344\275\234\350\257\264\346\230\216.md"
@@ -0,0 +1,74 @@
+# 视频下载员工作说明
+
+创建人员:project.admin / infoadmin
+文件职责:记录视频下载员在 project-info 中的角色入口、动态采集职责、正式落点、权限边界和任务路由。
+管理规范/模板:`../../全局规范.md`;`../项目规范.md`;`../项目配置清单.md`;`../../common/ai-workplace/AI工作空间创建指南.md`。
+引用文件:`../项目配置清单.md`;`../项目执行日志.md`;`../项目变更记录.md`;`../../common/ai-workplace/AI技能使用规范.md`;`../ana-doc/案例分析规范.md`。
+记录方式:当前配置说明;角色、权限或工作入口变化时覆盖更新,历史写入项目变更记录。
+
+## 1. 身份与能力
+
+- 项目:`project-info`
+- 角色:`case_analysis.video_downloader`
+- AI / 私有工作空间:`video-downloader` / `ai-video-downloader/`
+- 任务:`019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5`
+- 指派来源:用户;2026-08-04 明确要求长期采集其已授权访问的 B 站关注博主动态。
+
+本角色负责合法可访问来源的动态盘点与增量采集,覆盖纯文字、文章正文、图片原件和视频原件,并完成去重、归档、SHA-256、manifest、处理交接和终态反馈。普通、可逆采集由任务安排者验收,不逐文件增加审核。
+
+## 2. 当前角色
+
+| AI | 职责身份 | 正式范围 | 审核角色 |
+|---|---|---|---|
+| `video-downloader` | `case_analysis.video_downloader` | `ana-data/cases/`、`ana-data/tmp/`、`ana-data/news-<博主名>/`、`F:\video\<博主名>\`、`ai-video-downloader/` | 否 |
+
+## 3. 动态采集与正式交付
+
+1. `ana-data/news-<博主名>/`:保存纯文字动态、UTF-8 文章正文、图片原件、轻量 `manifest.jsonl`,以及视频处理员产出的转写文本入口。
+2. `F:\video\<博主名>\`:只保存该已授权博主的视频原件;跨磁盘是因为用户指定将大体积视频原件与项目文档数据分离。
+3. 当前首个授权实例:`青枫浦上Q`;动态入口 `https://space.bilibili.com/1420210197/dynamic`;项目落点 `ana-data/news-青枫浦上Q/`;视频原件落点 `F:\video\青枫浦上Q\`。
+4. 以动态 ID、opus ID、BV 号和来源 URL 作为去重键;不以标题作为唯一依据。文件名应安全、可追踪,不静默覆盖既有文件。
+5. manifest 至少记录博主、内容 ID、类型、发布时间、标题、来源 URL、本地文件、SHA-256、采集时间、状态、失败原因、视频处理状态和转写文本路径。
+6. 其他普通下载仍可使用 `ana-data/tmp/<task_id>/`;请求者明确给出正式案例落点时可使用 `ana-data/cases/`。
+
+新增博主必须由用户或已登记请求明确给出来源和授权子目录;不得把 `ana-data/news-*` 或 `F:\video\<博主名>\` 理解为对任意账号、任意目录或整个 `F:\video\` 的泛化授权。
+
+## 4. 与视频处理员的职责分离
+
+- 视频下载员:动态列表盘点、文章/文字提取、图片和视频原件下载、文件归档、去重、哈希、manifest、失败重试记录和 Codex 原生交接。
+- `case_analysis.media_processor`:视频、音频、字幕处理与转写;转写文本按交接目标放到 `ana-data/news-<博主名>/`。
+- 视频下载员不自行剪辑、转码、OCR、转写、内容分析、研究结论或审核;交接后只维护处理状态和文本路径。
+
+## 5. 权限与停止边界
+
+1. 只采集用户明确授权访问的内容;浏览器已有登录状态不等于可以读取或保存密码、cookie、token、验证码或其他认证秘密。
+2. 遇到登录提示、验证码、付费墙、订阅/会员限制、DRM、访问控制、来源合法性不明、目标不唯一、文件无法对应动态或可能覆盖既有文件时立即停止并反馈。
+3. 不绕过任何访问控制,不修改目标目录内既有无关文件,不把临时草稿当正式交付。
+4. 外部视频路径是明确的跨磁盘例外;只能写已授权博主子目录。新增博主或不同外部落点未登记时,先请求 project.admin 扩展边界。
+
+### 5.1 已授权会员内容的扩展内部会话例外
+
+本例外仅适用于用户已经明确授权访问、且浏览器界面能够正常完整播放的精确内容;当前实例限 `青枫浦上Q / BV1HA3o6oEJJ`。它允许视频下载员使用经过独立审核的用户可见扩展流程取得完整音视频文件,但不把认证秘密读取权授予 Codex 或视频下载员角色:
+
+1. Cookie、token、会话标识和平台内部签名媒体地址只允许在扩展与其本机受信服务的运行内存中短生命周期使用;不得向 Codex、任务窗口、命令行参数、项目文件、配置、manifest、日志、异常栈、测试证据或角色交接暴露其值。
+2. 原生消息通道优先。若使用 loopback 服务,只能绑定 `127.0.0.1`,精确允许已登记扩展来源,使用内存中的单次随机能力值,禁止通配 CORS、远程监听、请求体日志和调试回显。
+3. 认证材料优先保存在内存 CookieJar;如果底层组件确实需要临时文件,只允许当前用户独占 ACL、排他创建、随机文件名、使用后立即删除并带启动时残留清理的 OS 保护临时通道。禁止持久 Netscape cookie 文件、固定 cookie 路径、缓存复用或把路径写入产物。
+4. 下载流程只允许扩展内部使用 `bestvideo+bestaudio/best` 并由 ffmpeg 合并为一个完整 AV 文件;内部解析出的签名分片地址不得导出、记录、复制到项目文件或交给 Codex。
+5. 不得绕过 DRM、验证码、登录或平台访问控制,不得扩大到未明确授权的账号、博主或 BVID。授权状态失效、内容无法完整播放、出现 DRM 或会话证据不足时必须 fail closed。
+6. 扩展只负责产生完整本地 AV 文件;随后必须复用既有 `accept-browser-file` 门禁,确认文件稳定、无 partial、FFprobe 同时含音视频、时长约 `3133.95 s`、SHA-256 和 CreateNew mapping 完整,才可写入正式视频目录。
+7. 开发与审核只使用合成秘密、fixture 和临时目录,不使用真实 Cookie、真实签名 URL 或正式视频。真实运行仍由视频下载员在独立实现审核通过后按用户既有授权执行。
+
+## 6. 通信与上下文保护
+
+1. 默认使用 Codex 原生任务通信;正式交接使用唯一 `<codex_native_handoff>`,一次只投递准确目标一次。发送成功只表示 `dispatch_accepted`,不等于已观察或完成。
+2. legacy MB-X inbox/route/session 仅在人工或管理角色明确启用兼容模式时使用,不自动回退。
+3. 主体内容、manifest 和证据落文件;任务窗口只展示摘要、关键路径、状态和缺口,不回显大文件、长日志或批量媒体。
+4. 技能声明、状态变更记录和失败越权处理按 `../../common/ai-workplace/AI技能使用规范.md` 执行。
+
+## 7. 首次阅读反馈
+
+本次角色范围变化后,重新读取本文件、`项目规范.md`、`项目配置清单.md` 和 `ana-doc/案例分析规范.md`,在 `worklog/` 新增一份简短理解反馈,确认动态类型、两类正式落点、与视频处理员的分工、停止边界和 Codex 原生通信规则;发现冲突时反馈 project.admin,不自行扩大权限。
+
+## 8. 审核边界
+
+`video-downloader` 不是审核角色。采集与下载结果由任务安排者做普通验收;不得自称审核员,也不为普通公开采集、校验、manifest 或安全重跑增加独立审核链。涉及访问控制、凭据、不可逆外部写入或目录边界不清时停止并升级。
diff --git "a/ai-video-downloader/\351\241\271\347\233\256\351\227\256\351\242\230\345\217\215\351\246\210.md" "b/ai-video-downloader/\351\241\271\347\233\256\351\227\256\351\242\230\345\217\215\351\246\210.md"
new file mode 100644
index 0000000..d7a6f1f
--- /dev/null
+++ "b/ai-video-downloader/\351\241\271\347\233\256\351\227\256\351\242\230\345\217\215\351\246\210.md"
@@ -0,0 +1,3 @@
+# 视频下载员项目问题反馈
+
+当前无开放问题。访问控制或目标不明确按任务终态直接反馈,不在此堆积普通失败记录。
diff --git "a/ai-yanbao-collector/worklog/2026-08-06-\346\226\260\346\250\241\346\213\237\345\231\250\351\200\202\351\205\215\344\270\216BATCH031\347\273\255\350\267\221.md" "b/ai-yanbao-collector/worklog/2026-08-06-\346\226\260\346\250\241\346\213\237\345\231\250\351\200\202\351\205\215\344\270\216BATCH031\347\273\255\350\267\221.md"
new file mode 100644
index 0000000..65183e2
--- /dev/null
+++ "b/ai-yanbao-collector/worklog/2026-08-06-\346\226\260\346\250\241\346\213\237\345\231\250\351\200\202\351\205\215\344\270\216BATCH031\347\273\255\350\267\221.md"
@@ -0,0 +1,50 @@
+# 2026-08-06 新模拟器适配与 BATCH-031 研报续跑
+
+## 1. 环境与流程更新
+
+- 当前 ADB 设备:`emulator-5554`。
+- 慧博包名:`cn.com.hibor`。
+- 缓存目录:`/sdcard/Android/data/cn.com.hibor/files/myfile/`,保持不变。
+- 可选共享通道:模拟器 `/sdcard/Pictures` 对应主机 `F:\hb`。
+- 共享映射、缓存到共享目录复制、设备端与主机端字节数及 SHA-256 一致性已验证;临时探针已清理,模拟器缓存原件未修改。
+- 正式文档更新终态:`HANDOFF-INFOADMIN-YANBAO-HIBOR-NEW-EMULATOR-PATHS-DOC-TERMINAL-20260806-001 / COMPLETED`。
+- 批量执行器最小适配:支持显式 `emulator-5554` 和仅单一在线设备时的 `auto`,普通功能验收为 `ACCEPTED`。
+- 采集内核输入兼容与失败关闭修复:`HANDOFF-DEV-LAOYANCAI-YANBAO-HIBOR-NEW-EMULATOR-CORE-INPUT-FIX-TERMINAL-20260806-001 / PASS`;定向测试 `5/5 PASS`、完整离线测试 `59/59 PASS`,独立审核 `PASS/0/0`。
+
+## 2. BATCH-031 隔离续跑
+
+- 原任务:`TASK-DEFENSE-REPORT-COLLECTION-BATCH031-20260806-001`。
+- 完整续跑任务:`TASK-DEFENSE-REPORT-COLLECTION-BATCH031-COMPLETE-20260806-001`。
+- 交付根:`ana-data/tmp/TASK-DEFENSE-REPORT-COLLECTION-BATCH031-COMPLETE-20260806-001/`。
+- 结果:请求 `8`,成功 `8`,失败 `0`,访问阻断 `0`。
+- PDF 数量:`8`;总字节:`29027310`。
+- PDF 魔数、可打开性、页数、字节数、SHA-256:`8/8 PASS`。
+- 最终 manifest:`ana-data/tmp/TASK-DEFENSE-REPORT-COLLECTION-BATCH031-COMPLETE-20260806-001/manifest.csv`。
+- 最终交付说明:`ana-data/tmp/TASK-DEFENSE-REPORT-COLLECTION-BATCH031-COMPLETE-20260806-001/delivery.md`。
+- 最终终态:`ana-data/tmp/TASK-DEFENSE-REPORT-COLLECTION-BATCH031-COMPLETE-20260806-001/report_collection_terminal.json`。
+- 对请求方终态:`HANDOFF-YANBAO-JUNYAN-DEFENSE-BATCH031-REPORT-COLLECTION-COMPLETE-20260806-001 / SUCCESS / 8_OF_8`。
+
+## 3. 额度事实
+
+- 日期:`2026-08-06 / Asia/Shanghai`。
+- 起点:`confirmed=11`、`uncertain=7`、累计 `18`。
+- 本次新增正常额度:`1`;其余 `7` 份复用前序保守不确定消耗事件,不重复计数。
+- 终点:`confirmed=12`、`uncertain=7`、累计 `19`、正常安全余量 `6`。
+- 未使用硬停止缓冲。
+- 额度账本:`ana-data/tmp/report-collection-control/daily_quota_2026-08-06.csv`。
+
+## 4. 请求方普通验收
+
+- 验收消息:`report_collection_ordinary_acceptance`。
+- 前序:`HANDOFF-YANBAO-JUNYAN-DEFENSE-BATCH031-REPORT-COLLECTION-COMPLETE-20260806-001`。
+- 验收方:`case_analysis.analyst.defense / junyan`。
+- 状态:`ACCEPTED_ISOLATED`。
+- 验收事实:终态 `SUCCESS`;items/manifest/PDF=`8/8/8`;总字节匹配;PDF 魔数、可打开性、页数、字节和 SHA-256 均 `8/8 PASS`;未使用硬停止缓冲;未写正式案例、未解析正文、未形成研究结论。
+
+## 5. 固定边界
+
+- 该包保持隔离的临时输入属性。
+- 不修改、不重开已经关闭的 BATCH-031 正式 source/fact/classification/conclusion 链。
+- 不把该包吸收到 BATCH-032 正式执行。
+- 不新增审核链。
+- 采集员不解析正文、不生成研究结论、不自审;后续使用由请求方负责。
diff --git "a/ai-yanbao-collector/\345\267\245\344\275\234\350\257\264\346\230\216.md" "b/ai-yanbao-collector/\345\267\245\344\275\234\350\257\264\346\230\216.md"
index 9a06732..a019883 100644
--- "a/ai-yanbao-collector/\345\267\245\344\275\234\350\257\264\346\230\216.md"
+++ "b/ai-yanbao-collector/\345\267\245\344\275\234\350\257\264\346\230\216.md"
@@ -69,11 +69,11 @@
3. 下载原始文件时保持来源字节不变,不静默覆盖已有文件;按 `ana-doc/研报体系/研报采集角色说明.md` 归档并生成 manifest。
4. 下载结束后,将成功项、失败项、文件路径和 manifest 路径回传给任务安排者。谁安排下载,谁做普通结果验收;这不是必须提交固定审核角色的独立审计,采集员无需等待管理端或审核员批准。
5. 普通命名、下载失败、页面返回、缓存延迟或 manifest 缺项可在同一任务内修复和重做,不需要为每个尝试另行申请管理授权。
-6. 慧博 APP 来源按 `cn.com.hibor` 的固定缓存目录 `/sdcard/Android/data/cn.com.hibor/files/myfile/` 处理。采集员可直接使用 ADB、截图/OCR、模板匹配、坐标点击、键盘输入或等价本机自动化方式搜索、筛选、打开、截图并触发缓存。
+6. 慧博 APP 来源按 `cn.com.hibor` 的固定缓存目录 `/sdcard/Android/data/cn.com.hibor/files/myfile/` 处理。ADB 设备串号每次运行时发现并核对,或使用任务显式 `-s <serial>`;当前已验证实例是 `emulator-5554`,但不得把旧 `127.0.0.1:21503` 当作固定默认。采集员可直接使用 ADB、截图/OCR、模板匹配、坐标点击、键盘输入或等价本机自动化方式搜索、筛选、打开、截图并触发缓存。
7. 搜索、点击、打开、截图、下载、缓存盘点、复制、校验和重试都是普通采集权限,不需要逐次审批、独立审核、A001 或逐点击证据包。
8. 任务安排者可以给出查询词、分析师名单、最低页数、数量、机构或日期。未给名单时按任务要求和页面可见客观字段选择;只有目标实质不唯一、页面状态无法判断或出现访问控制时才暂停询问。
9. 登录、验证码、付费提示、权限提示或其他访问控制必须交回人工;不得取得认证秘密或绕过限制。
-10. 不得改动模拟器原件。项目副本通过 `%PDF-` 魔数校验后才补 `.pdf`,并须核对远端/本地 SHA-256、可打开性和 manifest。正式容器未明确时使用 `ana-data/tmp/<task_id>/`。
+10. 不得改动模拟器原件。ADB 从缓存只读复制是默认路径;已验证的 `/sdcard/Pictures` ↔ `F:\hb` 共享通道只作为快速传输或回退,不要求每次双路径复制,也不得作为正式交付入口。项目副本通过 `%PDF-` 魔数校验后才补 `.pdf`;全程唯一名/CreateNew、不覆盖,直读路径核对缓存/项目 bytes 与 SHA,使用共享通道时再核对缓存/共享/项目三端 bytes 与 SHA。正式权威仍是项目内 PDF 原件与 manifest;正式容器未明确时使用 `ana-data/tmp/<task_id>/`。
11. 当前已验证的 ADB、截图和坐标操作可直接用于采集,不必等待专用脚本。可复用脚本属于 `dev/ana-dev` 下的普通本地工具,由 `dev.developer.ana.cai` 开发维护,可在已登记事项内测试、修复和重跑;不要求管理端逐次 A001、多轮重审或普通源文件字节级预冻结。
12. 慧博来源的完整操作步骤、manifest 字段和真实 STOP 边界以 `ana-doc/研报体系/研报采集角色说明.md` 第 4.1 节为准;未知标题、机构、日期和 URL 保持 `UNKNOWN`,不得猜测。
13. 首次阅读本文件后,在 `ai-yanbao-collector/worklog/` 记录理解反馈和疑问;发现项目规范、流程、目录或权限缺口时写入 `ai-yanbao-collector/项目问题反馈.md`。
@@ -109,7 +109,7 @@
3. 未提供分析师名单时不得主观判断“知名”;默认同一机构只取 1 份,优先机构和分析师多样性。
4. 同分依次比较硬条件满足度、总分、页数、日期、搜索结果原始顺序。默认最多扫描 20 屏或 100 个不重复候选;不足时回传缺口,不用弱相关资料凑数。
5. 单候选 UI/缓存普通失败最多重试 2 次。登录、验证码、付费、订阅、权限或目标实质不唯一时停止并交回人工。
-6. 固定闭环为:缓存基线 → APP 搜索 → 候选客观评分 → 详情复核 → 打开原文 → 缓存差分 → ADB 只读复制 → `%PDF-` → 远端/本地 SHA-256 → 可打开性/页数 → CreateNew 归档 → manifest → 安排者验收。
+6. 固定闭环为:缓存基线 → APP 搜索 → 候选客观评分 → 详情复核 → 打开原文 → 缓存差分 → ADB 只读复制(默认,或经 `/sdcard/Pictures` ↔ `F:\hb` 共享通道快速传输/回退)→ `%PDF-` → 相应两端或三端 bytes/SHA-256 → 可打开性/页数 → CreateNew 归档 → manifest → 安排者验收。
7. 详细硬过滤、评分、并列、复制、校验和 manifest 口径以 `ana-doc/研报体系/研报采集角色说明.md` 第 4.2 节为准。
## 6.5. 公开能力与每日额度执行规则
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260802-211111_video-cover_\346\265\201\346\260\264\346\267\230\346\262\231\345\221\250\345\244\215\347\233\230_BV1HA3o6oEJJ.webp" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260802-211111_video-cover_\346\265\201\346\260\264\346\267\230\346\262\231\345\221\250\345\244\215\347\233\230_BV1HA3o6oEJJ.webp"
new file mode 100644
index 0000000..f7bd1c3
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260802-211111_video-cover_\346\265\201\346\260\264\346\267\230\346\262\231\345\221\250\345\244\215\347\233\230_BV1HA3o6oEJJ.webp"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f71baa83f76e6b3577c544c61fe8e70ef02504247d939e81f2703c4c5189970c
+size 13450
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260802-222800_article_8.2\345\244\215\347\233\230_1231979117971243025.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260802-222800_article_8.2\345\244\215\347\233\230_1231979117971243025.txt"
new file mode 100644
index 0000000..edb86ec
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260802-222800_article_8.2\345\244\215\347\233\230_1231979117971243025.txt"
@@ -0,0 +1,122 @@
+标题:8.2(复盘)
+博主:青枫浦上Q
+发布时间:2026-08-02 22:28:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1231979117971243025
+内容类型:文章
+
+七月收官日这根阳线,如果只看收盘涨跌,很容易读成反转;但把它放回整月的坐标里——上证累计下跌6.4%、深证成指下跌16.21%、创业板指下跌23%并创下逾十年最大单月跌幅——性质就完全不同了。这是一根由外力驱动、日内自我修正的修复阳线,涨幅由开盘价给出,分歧由午后走势给出。
+
+一、指数定环境:普涨的表象,冲高回落的实质
+
+1|收盘数据与日内形变
+
+据交易所数据,7月31日上证指数收报3832.26点,涨0.72%;深证成指收报13578.93点,涨2.21%;创业板指收报3343.96点,涨3.06%;科创50收报1635.96点,涨2.99%,北证50涨3.26%,沪深300涨0.85%。全天成交约2.56万亿元,较前一日放量约2000亿元,为连续第5个交易日站上2万亿。全市场上涨4691只、下跌728只,无一只跌停。
+
+据行情数据,早盘沪指高开0.76%、深成指高开3.04%、创业板指高开5.15%,科创50盘中一度涨超8%,半日成交1.79万亿;午后指数一路回落,科创50涨幅收窄至不到3%。同花顺数据显示,全天113只个股炸板,封板率仅46.92%,炸板率超过53%,收盘价低于开盘价的个股多达1341家。也就是说,绝大部分涨幅是竞价给的,而不是日内买出来的。
+
+双创并非收跌,而是高开后大幅回吐,创业板指从高开5.15%收敛至收涨3.06%。回落是相对于开盘位置的回落,不是相对于前一日收盘的下跌——这个区别决定了后面把它归为“情绪退潮”还是“分歧确认”。
+
+2|七月的坐标系:科技杀、红利涨
+
+7月全月申万31个一级行业中21个上涨,涨幅前三为石油石化21.02%、煤炭17.16%、食品饮料13.48%;下跌的10个行业中跌幅前三为电子23.82%、建筑材料20.71%、机械设备15.07%,通信与电力设备跌幅均超10%。三级子行业中玻璃玻纤、电子化学品跌幅均超40%。据Wind数据,截至7月31日收盘,剔除当月新股后A股总市值约106.63万亿元,较6月末蒸发约12.4万亿元。
+
+这组数据界定了7月31日的性质:整个7月是“杀科技、买红利”的一个月,而当日恰好是这一格局的第一次反向——银行、食品饮料、非银金融、石油石化四个行业下跌,传媒、计算机、综合、通信、机械设备涨幅居前。一天的反向不构成风格切换,但它至少说明前期的单向定价出现了裂缝。
+
+3|外盘传导:只负责给一个开盘价
+
+据海外市场消息,7月30日美股纳指涨约680点,微软收涨15.5%创18年来最大单日涨幅,半导体与存储板块深夜集体走强,同时Meta跌超8%,科技巨头内部分化明显。个人判断,这是一次典型的外盘传导:外盘只负责给A股一个开盘价,开盘之后市场就回到自己的节奏——当日高开5.15%、收涨3.06%的创业板指正是这句话的标准注脚。同时要注意,美股当晚本身就是分化的,微软大涨与Meta重挫并存,这个分化后来在A股的AI应用与AI硬件之间同样出现了。
+
+位置决定意义。在一个月蒸发12万亿市值之后,一次无跌停、近4700只个股上涨的普涨,其意义是情绪修复而非趋势确立。个人倾向于认为,最恐慌的阶段确实过去了,但“不恐慌”与“可趋势加仓”之间还隔着量能与结构两道验证。
+
+二、板块定方向:三层结构与四条AI支线
+
+当日的板块表现不能只用“AI应用大涨”一句话概括。按科技主攻、上游涨价、防御补充的三层拆解,再把科技主攻这一层向下切成四条支线,才能看清哪些是有催化的、哪些是被映射的、哪些只是名字沾边。以下逐层梳理。
+
+第一层 · 科技主攻:AI应用的四条支线
+
+支线一 · AI视频与短剧(Seedance链):催化来自Seedance 2.5。据公开报道,该模型早在6月23日火山引擎FORCE大会上即已发布,7月初面向企业商用,7月31日的动作是正式上线并陆续接入即梦AI、豆包专业版。把它当作“7月31日横空出世的新模型”会高估事件的边际增量;把它当作影视级视频生成进入规模化交付阶段的确认,更贴近事实。
+
+这条线上的标的需要按绑定强度分三档来读。第一档是与火山引擎生态有明确技术或业务绑定的:德才股份(旗下奇想无限入驻火山引擎生态并联合推出“漫剧AI坊”)、风语筑(即梦AI作为独家AIGC技术支持方参与非遗数字化与新文旅)、引力传媒(营销业务中已大量应用包括即梦在内的外部AI工具)。第二档是与红果短剧有内容合作但与模型本身关系较间接的:中文在线(红果短剧app上线短剧超450部)、欢瑞世纪、捷成股份(为红果提供真人短剧、AI写实剧、AI漫剧等内容)。第三档是仅处于洽谈阶段的:视觉中国(已与即梦展开业务合作洽谈)。掌阅科技则以字节持股与版权、内容生产、广告商业化、云服务合作为绑定形式。个人判断,第三档的表述强度最弱,“洽谈”与“落地”在财报上的差别是全部。
+
+支线二 · 企业级Agent(腾讯WorkBuddy链):这是当日结构最完整、也最容易被叙事夸大的一条。市场流传的“四大天王”是博思软件(腾讯为第二大股东,电子票夹微信小程序注册用户1.3亿以上)、普联软件(明略科技拟入主,腾讯持有明略科技约27%股权)、泛微网络(腾讯持股约5%的OA协同厂商,为腾讯战略投资对象、WorkBuddy AI生态首批共创伙伴)、华宇软件(元典法律数据MCP接入腾讯智能体WorkBuddy,WorkBuddy可调用其法律法规)。
+
+同样要分档。股权绑定档:博思软件、泛微网络,以及经明略科技间接关联的普联软件。生态接入档:华宇软件(MCP接入)、值得买(“什么值得买购物管家”Skills上线WorkBuddy)、视声智能(腾讯云WorkBuddy与其KNX生态合作启动)、老百姓(腾讯林芝入股,与腾讯健康合作推出企业级综合问答智能体)、英派斯(投资李未可并成立合资公司进军AI眼镜,李未可已发布首款接入WorkBuddy的产品)。第三档则是仅作为用户或宣讲案例出现的,例如富临运业——材料描述的是与腾讯云合办的一站式服务日上“重点讲解腾讯WorkBuddy助手的优势与适配场景”。使用方不等于供应方,讲解场景不构成收入关联,这一档在基本面上其实是空的。
+
+关于WorkBuddy本身的数据,材料称其月访问量2097万登顶榜首、比字节系和阿里系所有产品加起来还多,17家产品合计6062万月访问量。个人暂未见到可核对的原始统计口径与统计机构,暂记为待核实。个人判断,即便数据成立,从“访问量登顶”到“A股伙伴产生可确认收入”之间,还隔着接入、分成、结算三道完整的商业化链条,股权关系不等于收入关系。
+
+支线三 · MCP协议:据公开报道,MCP新一版规范(版本号2026-07-28)于7月28日发布,是该协议自2024年11月问世以来的第五个规格版本,也是幅度最大的一次;核心变动是从双向有状态协议改写为请求/回应式的无状态架构,服务器不再需要维持会话,可直接部署在Serverless与边缘计算环境,同时引入版本化扩展框架与OAuth 2.1/OIDC企业级身份认证。材料称“最重大和最颠覆的更新、支持Agent大规模生产级部署”,方向判断成立,但时间应更正为7月28日而非月末。
+
+市场梳理的MCP概念方向包括中科金财、恒锋信息、浪潮软件、东方材料、彩讯股份、鼎捷数智、合合信息、汉得信息、深信服、网宿科技等。个人判断,协议无状态化真正利好的是能够承接生产级Agent部署的基础设施与工程能力方——也就是云、CDN、边缘计算与有真实企业客户的软件商,网宿科技、深信服这类的业务形态与协议变化的关系比“概念挂名”更实。这条线本质上是“协议层利好经由映射层定价”,弹性大但基本面锚弱,属于典型的题材而非主线。
+
+支线四 · AI营销与AI软件(所谓核心股):这一档是当日自由市值最大的一组,包括昆仑万维、蓝色光标、利欧股份、易点天下、天娱数科,以及天龙集团、每日互动、鼎捷数智等。逻辑落点是AI重塑营销投放与内容生产的全链路:蓝色光标的鲁班平台以数据加AI构建标准化行业化解决方案,利欧股份的“奇思妙想”第一代AIGC营销产品处于全员测试,易点天下依托视觉大模型重构素材创意、投放管理、预算分配与效果数据四大核心,天娱数科面向元宇宙营销与短视频营销打造多款AI工具。
+
+昆仑万维的催化为“公司发布SkyReels-A3模型,支持任意时长的音频驱动数字人创作”——据公开报道,SkyReels-A3实际发布于2025年8月11日,是昆仑万维SkyWork AI技术发布周的首日产品,但距今已近一年。昆仑万维当前真正可跟踪的变量,是SkyReels短剧平台的商业化进展与AI短剧内容的分发数据,而非模型发布本身。
+
+软件端则是这轮AI应用行情里唯一有财报证据的一层。据卖方数据,Snowflake FY27Q1营收13.9亿美元同比增长33%,RPO达92亿美元同比增长38%,并与亚马逊签订为期五年、总额60亿美元的基础设施合作协议;Salesforce的Agentforce对应ARR达12亿美元同比增长205%,叠加Data 360与Informatica Cloud,AI及数据相关ARR合计34亿美元同比增幅超200%;金蝶国际预计2026年上半年营收36.08至36.39亿元同比增长13%至14%,归母净利润4000至6000万元,去年同期为亏损9800万元。以上为材料转述的卖方口径,未逐项独立核实。
+
+个人判断,这一层是“从买宏大叙事转向买兑现证据”最直接的落点:市场对“大模型替代软件”的担忧在这轮财报季被部分证伪,AI开始被验证为能抬升合同价值与订阅收入的增量,而非替代项。如果8月的AI主线要成立,最终必须由这一层的国内对应标的用中报来接棒,而不是靠概念清单轮动。对应的国内方向包括金山办公、合合信息、鼎捷数智、中控技术、万兴科技、汉得信息、税友股份,以及港股的金蝶国际。
+
+第二层 · 上游涨价:存储传导至设计端,这是全场最“实”的一层
+
+7月31日晚,多家芯片设计公司的中报预告集中落地。据公司公告,富瀚微预计上半年营收14亿至15亿元同比增长103.48%至118.01%,归母净利润2.7亿至3.5亿元同比增长1072.72%至1420.19%;瑞芯微7月7日预告上半年营收28.70亿至29.10亿元同比增长40.28%至42.24%,归母净利润8.50亿至9.10亿元同比增长60.03%至71.33%;星宸科技7月18日预告上半年营收约26.2亿元。三家的共同背景是全球存储芯片价格大幅上涨,叠加PCB、阻容等上游元器件供应紧张,设计企业顺势上调产品售价,形成成本推动与需求拉动的双重涨价。
+
+这一层的业绩确定性最高,但估值弹性最低——市场会给涨价传导型业绩一个周期性折价,因为“涨价能持续多久”这个问题没有答案。读这类预告的正确顺序是:先确认涨价贡献了多少,再确认新品与新场景能不能在涨价见顶前接上。
+
+第三层 · AI硬件与算力:补跌未尽,修复未确认
+
+据当日盘面,存储芯片板块大幅高开,兆易创新、德明利、太极实业竞价一字涨停,随后相继炸板,德明利早盘炸板后持续回落、尾盘翻绿。CPO板块反弹,罗博特科涨超16%;半导体产业链盛景微涨停。另据统计,7月跌幅超50%的个股中AI产业链扎堆,涵盖德明利、普冉股份、兆易创新、佰维存储、江波龙、香农芯创、长光华芯、长飞光纤、亨通光电、国科微、烽火通信、太辰光、立昂微、仕佳光子等;7月跌幅超40%的个股中百元股多达67只,高价股是杀跌重灾区。
+
+情绪龙头炸板后深幅回落,是分歧最直接的读数。个人判断,硬件端目前处在“补跌尚未完全出清、修复尚未获得确认”的中间地带,短期难以承担主线角色。可跟踪的相对强势分支是国产超节点与端侧AI两条线能否走出独立节奏——若下周科技硬件能结束此前同涨同跌的纯情绪行情,出现分支间的强弱分化,那才是硬件端真正的右侧信号。
+
+算力租赁方向,行云科技连续三次签署补充协议上修租金、下游大厂与模型厂商愿意为算力涨价兜底,并提示利通电子等具备交付能力的服务商。个人判断,若租金上修属实,其意义在于算力供需仍紧,这与“AI上游资本开支逻辑反转”的看空叙事恰好构成对冲,值得作为一个反向验证变量持续跟踪。
+
+第四层 · 国产大模型与云:叙事从“卖模型”转向“用模型”
+
+据媒体报道,月之暗面已于7月29日确认完成F轮融资,金额超35亿美元,投后估值350亿美元;因超目标金额三倍多提前关闭,原定8月开始的G轮(Pre-IPO轮)已提前启动,投前估值升至500亿美元。此前7月16日晚该公司发布Kimi K3,以2.8万亿总参数刷新全球开源模型规模上限并支持百万token上下文,7月19日晚因算力紧缺暂停C端新用户订阅。材料另提及智谱GLM5.3预计8月发布、MiniMax M系列预计9月发布,属预期口径,暂记为待核实。
+
+这里需要把卖方逻辑与已发生事实分开。卖方给出的AI上游四大看空逻辑——资本开支逻辑反转、科技巨头发债的信用风险、Token Budgeting压制高端需求、卖铲人逻辑松动——是一套完整的叙事,但目前只有第三条有直接证据(OpenAI走量档降价、旗舰档不降价),其余三条更接近推演。个人倾向于认为,市场风格确实在从“买宏大叙事”转向“买兑现证据”,但把这一转向直接等同于“上游见顶、下游启动”,是把叙事替换当成了产业转折。
+
+第五层 · 非AI主线:机器人与核电
+
+机器人:据7月30日晚公告,宇树科技科创板IPO拟发行4044.6434万股占发行后总股本10%,初步询价日8月5日,网上、网下申购日均为8月10日,缴款截止日8月12日,中信证券保荐,拟募资42.02亿元且85%投向研发。材料按三大阵营梳理产业链:国产链方向长盛轴承、晋拓股份等,特斯拉链方向斯菱智驱、新泉股份、福赛科技、北特科技、浙江荣泰、鸣志电器等,波士顿动力链方向福赛科技、凯迪股份、泛亚微透等;当日盘面上永茂泰涨停,奥比中光触及20cm涨停,绿的谐波、昊志机电等涨超10%。
+
+个人判断,这轮机器人修复带有明显的事件驱动属性。机器人板块自2026年2月以来已跌破年线、回调幅度约四到五成,超跌属性叠加低仓位确实是加分项,但三大阵营的划分中多数标的属于预期映射而非订单映射。更值得跟踪的变量是数据采集规模——大规模数采是具身模型走向成熟的必经之路,而这一环节的订单最终会体现在真实的零部件出货上。
+
+核电:据7月31日国务院常务会议,决定核准浙江金七门核电二期(3、4号机组)、广东太平岭核电三期(5、6号机组)、辽宁庄河核电一期(1、2号机组)、山东莱阳核电一期(1、2号机组),共8台新机组,据澎湃新闻估算总投资将超1700亿元;其中金七门二期与太平岭三期被确定为“华龙一号”2.0版示范项目。中国核电7月31日晚公告确认庄河1、2号与金七门3、4号机组获核准。设备端材料提示融发核电(核岛主管道设备、核后处理装备、核一级容器类锻件、核一级泵阀支承类铸件)与保变电气(30余年核电变压器供货历史)。
+
+据公开统计,2022年至2025年我国连续四年每年核准10台及以上机组,本次为8台。也就是说,这更接近“常态化审批节奏的确认”,而非增量超预期。个人判断,核电设备与工程建设环节的订单释放具备确定性,但这是一条适合按年度节奏跟踪的中长期线索,把它当作短线主线来做,与其催化强度并不匹配。
+
+非科技与防御:当日银行板块多数调整,农业银行、交通银行跌超2%,保险板块领跌,中国平安跌幅居前,食品饮料、石油石化同步走弱。材料提到早盘逆势走强的一鸣食品与华天酒店,个人未独立核实其当日走势细节。个人判断,非科技方向低开走强、科技与非科技呈跷跷板,本质是总量能不足下的存量腾挪;只要跷跷板存在,就说明尚未出现增量资金入场,这一点比任何单一板块的涨幅都更能说明市场当前的资金状态。
+
+三、个股定时点:叙事强度与可验证性的落差
+
+普联软件(300996):据7月29日晚公告,实际控制人蔺国强、王虎及其一致行动人共16名股东,以11.38元/股向北京明略昭辉科技有限公司转让7547.39万股,占总股本19.07%,交易总对价8.59亿元;交易完成后明略昭辉将成为控股股东,实际控制人变更为吴明辉;董事会9名席位中明略昭辉有权提名7名。7月30日、31日该股连续两个20cm涨停,月内涨幅70.98%。
+
+有两处被市场忽略的事实。其一,据明略科技公告,交易完成后普联软件将成为集团重要战略参股公司,并不纳入集团合并报表范围;其二,明略科技2025年归母亏损64.15亿元,且同日将全球发售募资中4.5亿元未动用款项,从技术研发用途重新分配至战略性投资与并购。同一笔交易在港股与A股呈现完全相反的定价方向。个人判断,“腾讯企业级AI首家A股控股平台”这一说法在股权穿透层面成立、在并表与协同层面尚待验证,属于典型的预期先行型标的,右侧确认应等待央国企ERP与司库场景中具体产品口径与订单的落地。
+
+富瀚微(300613):据7月31日晚业绩预告,预计上半年营业收入14亿至15亿元同比增长103.48%至118.01%,归母净利润2.7亿至3.5亿元同比增长1072.72%至1420.19%,扣非净利润2.66亿至3.46亿元同比增长1640.93%至2164.52%,二季度营收与归母净利润双双创单季历史新高。当日该股收报56.23元,涨3.75%。
+
+公司自身给出的归因是:全球电子业原材料价格上涨,公司对产品价格进行了上调,智能视频、智能物联、智能出行三大业务板块量价齐升,同时新产品AI-ISP芯片市场反馈积极、已向机器人等新领域客户拓展。流传材料中写作“AHSP芯片”,公司口径为AI-ISP,转述时需更正。个人判断,把这份预告完全归因于端侧AI的结构性需求爆发,会系统性高估其可持续性——瑞芯微、星宸科技同期预告同样亮眼,本身就说明这是行业性景气而非个体突破。至于“2025年以超过20%全球份额位居出货量第一”与“力争3至5年内年度营收突破40亿元”的表述,来自材料转述,暂记为待核实。
+
+宇树科技(688836):业绩方面,据招股材料,2023至2025年营收分别为1.59亿、3.92亿、17.08亿元,2024年扭亏、2025年归母净利润2.78亿元,增长主要来自2025年初春晚演出带来的品牌关注度提升。但2026年一季度营收4.23亿元,同比增速由上年同期的332.64%回落至68.49%,归母净利润5001.38万元,同比下滑47.69%。
+
+增速回落与利润下滑这两项,是市场在事件驱动行情中最容易略过的数据。个人判断,宇树上市对机器人板块的意义主要在情绪锚与估值锚,产业层面的兑现仍需看下半年国产机器人IPO节奏与实际量产数据。
+
+传智教育(003032):材料给出的逻辑链是:AI行业新发职位需求增长带来的人才结构性错配催生培训需求,叠加职业教育专业目录增补人形机器人工程技术、具身智能计算技术等方向;公司推出覆盖AI开发与AI应用两大赛道的课程体系并发布子品牌,正式发布AI具身智能机器人开发新学科、首期班计划10月开班,对接智元、宇树等厂商的本体二次开发方案,硬件端2024年自研低成本具身智能机器人GenkiArm。个人判断,教育类标的的特点是叙事完整但兑现周期长——首期班10月开班意味着收入确认最早也在四季度,这类标的更适合作为机器人主题的弹性配角而非主攻方向。上述公司层面数据均来自材料转述,暂记为待核实。
+
+四、下一交易日:两种情形与共同的观察变量
+
+8月首周的关键,不在于判断AI是不是主线,而在于确认AI的哪一层是主线。上游算力叙事正在被“买兑现证据”的定价方式取代,中下游应用则需要用token消耗、订单与订阅收入来自证。分歧本身不是坏事——当日AI应用的分支太多太杂,从短剧到Agent到MCP到营销,反而看不出核心逻辑;一次分化恰好可以把有催化的和被映射的区分开。在这个前提下给出两种情形。
+
+情形A:量能维持在2.3万亿元以上,AI应用方向的高标个股不炸板或顺利晋级,封板率回升至60%以上,且分化的结果是围绕企业级Agent这一条主线收敛,则分歧转一致成立,可按右侧确认的方式跟踪。此时若硬件端同步走强而非跷跷板,为加分项。
+
+情形B:量能回落至2万亿元下方,高位股延续大面积炸板,AI应用内部继续无序轮动而不收敛,非科技与防御方向重新走强,则本轮仍应定性为反弹而非反转,市场回到震荡磨底,此时更合理的动作是等待而非追高。
+
+无论走向哪一种情形,有四个共同变量值得逐日记录:一是封板率与炸板率的变化,这是情绪温度最直接的读数;二是德明利一类情绪龙头能否止跌企稳,龙头不修复则空间难打开;三是科技与非科技是否仍呈跷跷板,只要跷跷板存在,就说明是存量博弈而非增量入场;四是AI应用的分化是否收敛到单一主线,散而无核的题材扩散通常是行情末段而非起点。在这四项之前,先不要急着给这轮修复定性。
+
+五、风险提示
+
+模型迭代与降价节奏不及预期;AI应用端的token消耗与商业化兑现慢于叙事;存储及上游元器件涨价周期见顶导致业绩弹性回落;企业级Agent生态的收入分成与结算机制落地慢于预期;机器人量产与产业链国产化进度不及预期;核电及新股发行节奏变化;外盘波动通过风险偏好渠道传导。
+
+免责声明:本文所涉资讯、数据均来自公开信息,仅代表个人研究与观点记录,不构成任何投资建议。市场有风险,据此操作风险自担。
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-085700_text_8.3\346\227\251\347\233\230_1232141287338016792.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-085700_text_8.3\346\227\251\347\233\230_1232141287338016792.txt"
new file mode 100644
index 0000000..df7bf97
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-085700_text_8.3\346\227\251\347\233\230_1232141287338016792.txt"
@@ -0,0 +1,8 @@
+标题:8.3(早盘)
+博主:青枫浦上Q
+发布时间:2026-08-03 08:57:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1232141287338016792
+内容类型:纯文字动态
+
+上周五市场高开后震荡回落,创业板涨幅收窄至3%,这个形态本身并不意外。如果理解“科技的反弹是让科技止跌,而不是反弹”,那么八月最合理也最稳妥的路径,仍是震荡筑底。 一、隔夜与开盘:分子端强、存储弱,高开不必强求 据美股上周五收盘(北京时间8月1日),道指涨0.53%报52485.03点,纳指涨1.00%报25373.85点,标普500涨0.70%报7489.72点;三大指数本周分别累涨1.04%、1.59%、1.05%,但7月全月纳指累跌3.20%、标普500微跌0.13%、道指涨0.32%。个股层面,亚马逊涨超15%,创2012年以来最大单日涨幅;谷歌涨超6%,微软涨超3%,英伟达涨近3%、以4.86万亿美元市值重回全球市值第一;苹果跌超7%,创2025年4月以来最大跌幅,市场归因于内存短缺对其业绩展望的拖累。 据同源行情,上周五美股存储链是分化而非普跌:铠侠ADR跌超10%,闪迪、美光科技跌超5%,SK海力士跌逾3%;而西部数据、康宁、迈威尔科技涨超2%,Coherent涨超5%。7月全月,铠侠ADR累跌53%,闪迪累跌超46%,美光累跌超28%,Roundhill存储ETF月内累跌近32%。 铠侠控股:据公司7月31日披露的2027财年第一财季(2026年4–6月)财报,营收1.77万亿日元、同比增长415%,营业利润1.27万亿日元、低于市场预期的1.37万亿日元,归母净利润8421.7亿日元、低于预期的9738.1亿日元,毛利率78%亦略低于预期;营收增长主要来自生成式AI带动的数据中心需求、产品均价抬升与汇率贡献。日股当日收涨17.72%(财报于收盘后公布),美股ADR当晚收跌10.1%。 个人判断:外盘的作用只反映在开盘定价,开盘后市场回归自身节奏。上周五夜盘其实已经回答了“利空还是利空出尽”的一半——业绩显著低于预期后ADR收跌10%,说明资金第一时间并没有按“利空出尽”定价,因此今日对存储方向不宜有高预期。沿用一贯口径:低开不可怕,接不住才可怕。 二、指数定环境:三要素拆解 量能 2.5万亿以上是这轮修复的成本线。放量2000亿而指数冲高回落,说明高位承接的成本并不低;后续若缩量至2.2万亿以下且伴随冲高回落,性质需要重新定义——那不再是换手,而是接力断档。 大金融 据上周五盘面,银行、煤炭、保险、白酒、石油石化等逆势下跌,这是资金从防御端切向进攻端的直接证据,属于修复行情中的正常结构。反过来讲,若今日出现大金融重新走强而科技回落、一天之内整体切回防御的情形,应读作负面信号而非“护盘”。 情绪 零家跌停是修复的必要条件,但43.72%的封板率并不算高,说明次日溢价预期一般,属于“普涨型情绪修复”而非“强主线型情绪启动”。据行情统计,爱丽家居以9连板居连续涨停之首,蓝色光标、利通电子、利欧股份封单金额居前,分别约3.89亿元、3.06亿元、2.62亿元。按首板88家、约15%晋级率的经验值推算,今日二板家数最好在12、13家以上,同时首板家数不能锐减,两个条件同时满足,行情才谈得上持续性。 结构与仓位 除上证指数与上证50外,多数指数已出现90或120分钟级别底部结构,个人判断短期止跌概率较大,6–7成仓位参与博弈是当前较为合适的暴露水平。但要把“止跌”与“反转”分开:底部结构解决的是下跌动能的衰竭,不解决上行斜率。政策端可作为底层支撑参考——据新华社,7月30日中央政治局会议提出深化资本市场投融资综合改革,提升资本市场韧性和信心,措辞由此前的“稳信心”向“提韧性”侧重。 三、板块定方向 其一 · AI应用:从走出到走强,接下来是向细分具体化 据上周五盘面,AI应用端全线爆发,中文在线、荣信文化、蓝色光标、易点天下等20cm涨停,昆仑万维、天娱数科、利欧股份等涨停;算力租赁概念持续拉升,美利云、云赛智联、宏景科技、利通电子等多股涨停;Kimi概念、ChatGPT概念、腾讯云等板块涨幅居前。 催化 · DeepSeek:据官方7月31日消息,DeepSeek-V4-Flash正式版API上线公测,模型结构与尺寸同预览版一致(MoE架构、总参数2840亿、激活130亿),仅重新进行后训练,Agent能力大幅增强,多项基准测试远超V4-Pro-Preview;官方对比口径下整体性能超过GLM-5.2、逼近Claude Opus 4.8。第三方Artificial Analysis给出的智能指数为50分,较预览版提升10分。 催化 · 海外映射:据行情数据,微软7月30日单日暴涨15.5%,创2008年10月以来最大单日涨幅,市值单日增加约4500亿美元,为美股史上最大单日市值增长纪录,驱动来自Azure收入的高增长。材料引用的OpenRouter口径(截至7月26日近28天,中国模型份额约63.5%)按原始来源记录,未独立核实。 个人判断:AI应用从走出到走强用了小半个月,7月17日提出把超跌属性视作必要条件、7月30与31日强调“要么找消息催化,要么找科技里的超跌方向”,这条线索是连贯的。但周末讨论度显著抬升,短期反而要防一个情绪高点,而非在高热度处加仓。后续更可能向细分具体化——正如硬件端分化出光模块、存储、电子布,应用端也会分出词元、Agent框架、垂类落地与云侧的差异。方向跟踪:中文在线、昆仑万维、蓝色光标、易点天下、值得买。提及的传智教育、普联软件、久其软件、南天信息,属公司公告口径的产业链记录,非推荐。 其二 · AI硬件与半导体:本周由财报决定能否企稳 上周五盘面上,存储芯片概念股高开低走,德明利在开盘涨停背景下尾盘收绿,长鑫科技、兆易创新等大幅回落。这种“开盘涨停、尾盘收绿”的形态,本质仍与此前反复提到的科技股出清有关:现阶段大量同质化策略下,个股走势高度相似,其止跌并非抛压消失,而是出现了承接,所以跌停股龙虎榜上常见大资金对轰——正是在买卖双方攻守易势的反复中,这些个股才真正完成筑底。 本周日程密集。据公开报道,北京时间8月6日凌晨闪迪发布财报,机构预期2026财年第四财季营收83.93亿美元、同比增加341.49%,每股收益33.01美元、同比扭亏为盈;本周还有AMD与西部数据的业绩;北京时间8月5日凌晨SpaceX将公布上市后首份季报,8月6日SpaceX将迎来1160亿美元限售股解禁,为美股史上最大单股解禁。 个人判断:今日首要观察点是周五那根上影线能否反包,它直接决定接力强度。若闪迪、西部数据在业绩公布后能形成正反馈,A股硬件方向大概率企稳;反之,德明利式的高开低走会继续在整条链上复制。总之仍可参考那句话:科技的反弹是让科技止跌,而不是反弹。 其三 · 非科技超跌:性价比仍在 上周五一鸣食品的连板与华天酒店的反包是两个较为积极的信号。短期人气比不上沾边科技的AI应用,但非科技方向凭借超跌属性依然具备较高的安全边际。个人判断:只要指数不是立刻大幅反弹,这条线的性价比就还在,且它与科技主线之间存在天然的对冲关系。 其四 · 核电:订单催化明确,属可跟踪的政策方向 据财联社与澎湃新闻7月31日消息,国务院常务会议核准四个核电项目共8台机组,包括浙江金七门核电二期(3、4号机组)、广东太平岭核电三期(5、6号机组)、辽宁庄河核电一期(1、2号机组)、山东莱阳核电一期(1、2号机组),估算总投资超过1700亿元;此前2022至2025年,我国连续四年每年核准10台及以上机组。口径提示:财新报道的同一批次投资规模约为1600亿元,并指出核准以三代“华龙一号”技术为主,两个数字并存,对外发布时建议标注来源方。据方正证券机械团队观点,此次批复覆盖中核、中广核、国家电投三大集团,庄河与莱阳属全新厂址,金七门与太平岭采用升级版华龙一号2.0,对产业链构成明确的订单催化。 产业链记录:据公司公开信息,利柏特为工业模块方向,代表产品含核电气体分离装置,此前承担宁德二期柴发大模块制造;兰石重装核能产品覆盖上游核燃料装备、中游核电站装备与下游乏燃料循环及核环保装备,涵盖三代、四代堆型。以上为产业链跟踪记录,不构成推荐。 其五 · 机器人与电网设备:事件驱动为主 机器人端本周有明确事件——据公开报道,宇树科技将于8月10日启动申购,作为A股人形机器人第一股,对板块情绪存在牵引作用;上周五中大力德、锋龙股份、卧龙电驱、奥比中光等已有涨停表现。电网设备方向,材料提及伊顿因数据中心相关业务驱动、二季度业绩超预期并上调全年利润指引,股价涨7.32%,逻辑指向供电架构升级与SST产业化,A股相关公司包括伊戈尔、白云电器;该涨幅数据待核实,对外发布建议以行情为准。 四、今日重点:两种情形与操作预案 情形A(筑底延续):不高开或小幅低开,存储链能够承接、德明利与兆易创新等不破上周五低点,二板家数在12–13家以上、首板家数不锐减,成交维持2.4万亿以上,跌停家数继续为零。此情形下按原预案执行——已关注的波段方向以做T为主,短线方向保留先手。 情形B(情绪高点):高开冲高回落、周五上影线无法反包,上周五已经消灭的跌停死灰复燃,封板率跌破40%,成交缩至2.2万亿附近。此情形下短线方向若无主动性即取消关注,仓位回落至观察档,等待下一次量价确认。 两种情形的共同前提是不追高。位置决定意义,右侧确认,不见量价确认不动手。另需注意,上周五全市场近4700只个股上涨的普涨结构,天然伴随次日的分化压力,分化本身不是坏事,分化之后谁能被资金重新选中才是要观察的东西。 五、本周外部变量 据公开报道,8月7日美国将发布7月非农就业报告;中东局势仍是最大不确定性来源,本周油价反弹引发的通胀担忧已将美国长期国债收益率推高至次贷危机以来新高。此外,摩根大通已将美联储首次加息的预期时点从2027年下半年提前至今年12月——这是7月以来海外定价的一处方向性变化,对全球科技股估值构成压制,值得纳入跟踪变量。 跟踪清单:铠侠ADR后续走势、闪迪与西部数据业绩反应、二板家数与首板家数、成交额是否守住2.4万亿、大金融是否重新走强、宇树科技申购前后的机器人情绪。 风险提示:本文所涉指数、板块与个股信息,均为公开信息的整理与盘面事实记录,仅用于研究交流与投资者教育,不构成任何投资建议或买卖依据。市场有风险,投资需谨慎,投资者应独立决策并自担风险。
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-095700_text_\345\244\232\347\202\271\350\200\220\345\277\203_1232156727764320257.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-095700_text_\345\244\232\347\202\271\350\200\220\345\277\203_1232156727764320257.txt"
new file mode 100644
index 0000000..ded82fc
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-095700_text_\345\244\232\347\202\271\350\200\220\345\277\203_1232156727764320257.txt"
@@ -0,0 +1,8 @@
+标题:多点耐心
+博主:青枫浦上Q
+发布时间:2026-08-03 09:57:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1232156727764320257
+内容类型:纯文字动态
+
+开太低可以做T, 尾盘把加仓的那部分T出去就好了,不用过于悲观,今天做T还行吧。
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-130400_text_8.3\345\215\210\347\233\230_1232204861433446409.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-130400_text_8.3\345\215\210\347\233\230_1232204861433446409.txt"
new file mode 100644
index 0000000..5c0b158
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-130400_text_8.3\345\215\210\347\233\230_1232204861433446409.txt"
@@ -0,0 +1,8 @@
+标题:8.3(午盘)
+博主:青枫浦上Q
+发布时间:2026-08-03 13:04:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1232204861433446409
+内容类型:纯文字动态
+
+今日亚洲市场风险偏好再次降温。尽管国际油价开盘大跌约6%,能源压力有所缓解,但并未提振韩国股市。KOSPI盘中一度下跌5.5%,三星电子和SK海力士均下跌约9%,韩国芯片股的剧烈波动,再次成为全球市场最突出的风险变量。 从价格结构看,目前仍不能简单定义为“二次崩盘”。以上周四收盘价计算,KOSPI仍累计上涨约11.5%;三星电子和SK海力士在经历周五大涨、周一回落后,也仍分别高出约15%和18%。当前价格整体仍处于周五巨阳线内部,周一的下跌更接近强反弹后的第一次集中回踩。 真正值得警惕的是资金性质。周一开盘后,外资迅速净卖出超过1万亿韩元。上周暴跌期间,外资大规模抛售三星电子和SK海力士;周五又成为指数创纪录反弹的主要买方;到了本周一,再次转为主要卖方。连续几个交易日的资金行为更接近空头回补、指数交易和快速降低风险,尚未形成稳定的中期配置方向。 因此,韩国市场当前已经进入寻底阶段,但底部确认仍未完成。周五是第一次强反弹,周一是第一次回踩。真正有效的底部,需要市场在反弹后再次遭遇抛售时,仍能守住前低区域。未来两天需要重点观察外资卖盘是否继续扩大,以及三星电子、SK海力士能否守住上周低点。 A股上午同样呈现较为混沌的轮动格局。早盘资金先后尝试风电、光伏、可控核聚变和机器人等低位方向,其中可控核聚变相对较强,但持续时间主要集中在开盘前半小时,整体强度仍弱于上周五的AI应用。AI应用前排个股尚有冲高,但随着轮动逐级扩散,后续分支的强度明显递减。 科技方向的修复力度同样有限。AI应用本质上仍属于超跌反弹,市场中期核心仍在AI硬件。上午存储、半导体等分支持续承压并创出阶段新低,临近午间,中际旭创、天孚通信等CPO核心,以及次新方向的长鑫科技出现回升,才对科技板块形成一定止血作用。不过,AI硬科技整体修复强度一般,资金更多是在不同低位分支之间轮动,尚未出现明确的主线聚焦和板块共振。 从指数环境看,上周五利好落地后,大盘高开低走;外围市场随后连续反弹两日,今日A股依然没有出现有力度的进攻修复。当前市场仍处于下跌趋势阶段,普通轮动的参与价值较低,只有情绪或价格达到极值时,博弈的性价比才会明显提高。 后续重点关注两类机会。第一,核心板块开盘即表现出强势姿态,并带动指数共振修复,形成阶段企稳预期,此时领涨分支和核心超跌品种具备博弈价值。第二,指数和板块再次出现集中恐慌杀跌,但跌停数量保持可控、核心品种没有过早封死跌停,说明情绪释放可能较为前置,可以观察恐慌买阴的机会。 类似今日这种跌幅不足、进攻不强、方向模糊的状态,上下皆有可能,交易难度较高。此时更合适的选择仍是等待,等待市场再次出现恐慌极值,或者等待多头板块以更明确的联动和持续性证明修复诚意。 等待本身就是交易的一部分。下跌趋势中,最难控制的往往不是买卖动作,而是盘中焦躁、局部拉升诱惑,以及大幅调整后急于抄底和挽回回撤的冲动。在缺乏明确胜率的阶段,保持克制,不亏就是赢。
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-141800_text_\350\277\231\351\207\214\345\260\261\346\230\257\345\272\225\351\203\250\345\235\232\346\214\201\344\275\217_1232223909666881541.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-141800_text_\350\277\231\351\207\214\345\260\261\346\230\257\345\272\225\351\203\250\345\235\232\346\214\201\344\275\217_1232223909666881541.txt"
new file mode 100644
index 0000000..627f605
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-141800_text_\350\277\231\351\207\214\345\260\261\346\230\257\345\272\225\351\203\250\345\235\232\346\214\201\344\275\217_1232223909666881541.txt"
@@ -0,0 +1,8 @@
+标题:这里就是底部,坚持住。
+博主:青枫浦上Q
+发布时间:2026-08-03 14:18:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1232223909666881541
+内容类型:纯文字动态
+
+半导体ETF今天跌幅较大是因为晶圆代工,这一部分在今天开始大幅补跌,算是最后一个跌的板块了。 这里可以明确就是底部,抗住不要卖!
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-193903_video-cover_A\350\202\241\347\274\251\351\207\217\347\243\250\345\272\225\351\230\266\346\256\265_BV1DVMX6XEPq.webp" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-193903_video-cover_A\350\202\241\347\274\251\351\207\217\347\243\250\345\272\225\351\230\266\346\256\265_BV1DVMX6XEPq.webp"
new file mode 100644
index 0000000..8a65f3c
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-193903_video-cover_A\350\202\241\347\274\251\351\207\217\347\243\250\345\272\225\351\230\266\346\256\265_BV1DVMX6XEPq.webp"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3316590cc2ca332b1188c99be48cf2e8ded7dc66f3972a0b65a528d74362cc9e
+size 14646
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-221300_text_8.3\345\244\215\347\233\230_1232346363423883282.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-221300_text_8.3\345\244\215\347\233\230_1232346363423883282.txt"
new file mode 100644
index 0000000..1e20702
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260803-221300_text_8.3\345\244\215\347\233\230_1232346363423883282.txt"
@@ -0,0 +1,8 @@
+标题:8.3(复盘)
+博主:青枫浦上Q
+发布时间:2026-08-03 22:13:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1232346363423883282
+内容类型:纯文字动态
+
+今天是一根缩量的弱势 K 线,但盘面内部并不安静。指数弱、个股修复、量能萎缩三者同时出现,本质上是磨底期的风格再平衡,而不是新一轮下跌的起点。 一、指数定环境:低开弱势,量能萎缩,承接力才是主要矛盾 指数低开后全天弱势震荡,早盘科技权重持续承压带动指数下探,午后低位题材逐步活跃、个股层面出现修复,但指数端始终缺乏权重承接,收盘仍维持弱势。量能相较上一交易日大幅萎缩,这一点比点位本身更重要——存量水平的成交额无法支撑多板块同步走强,场内资金只能在板块之间腾挪,场外资金观望情绪浓厚,近期龙虎榜上活跃资金的身影也明显减少。 情绪端相较指数略微偏强,尾盘一鸣食品的涨停对投机氛围有小幅激活作用。 个人判断,当前位置的分歧不在方向而在节奏:缩量下的普跌不可怕,可怕的是缩量之后连低位承接都消失。今天低位题材尚能接得住,说明流动性并未进一步恶化,磨底的结构还在。 从调整性质上区分,科技硬件这一轮更接近内生性回调叠加轮动踩踏,而非基本面证伪:连续快速下跌之后空头动能已有消耗,但出清尚未完全结束。按此前的三信号检验——强势股补跌、多杀多、流动性见底——目前主要卡在第一条上:市场担心强势股补跌,所以追涨动能持续偏弱。 二、板块定方向:热点持续轮动,上周五的强势方向几乎全部承压 (一)科技主攻:AI 应用相对活跃,硬件仍在等待「强者恒强」 AI 应用与智能体:盘中一度有所回流,是今天科技内部相对活跃的方向。据公开消息,8 月 3 日阿里巴巴发布新一代基座大模型 Qwen3.8,并同步推出企业级 Agent 产品「千问办公」开启公测,个人与企业用户均可通过官网体验,目前已初步打通钉钉;受此提振,阿里巴巴港股当日大涨。存量博弈环境下 A股映射走得较为坎坷,但市场认可度依然较高。 AI 硬件与 CPO:上周五反弹不及预期,今天继续承压;CPO 受量产出货消息刺激冲高回落。个人判断,科技当前最大的问题不在产业逻辑而在筹码心理——市场普遍担心强势股补跌,因此不敢追涨。什么时候看到强者恒强的现象重新出现,科技股整体氛围才会真正改善。最值得玩味的是,连英伟达自身都在主动推进以太网架构,这意味着国内自主算力集群坚定走全栈国产 RoCE 路线的确定性反而更高,长期国产替代空间进一步打开——在这种产业方向明确的背景下,交换机及芯片依然出现补跌,正说明当前是情绪与筹码在定价,而非基本面在定价。 (二)政策与消息驱动:核电全天强势,光伏午后异动 核电:全天强势,是今天盘面最突出的亮点。据新闻联播报道,7 月 31 日国务院常务会议决定核准浙江金七门二期、广东太平岭三期、辽宁庄河一期、山东莱阳一期四个项目共 8 台机组,为 2026 年度首次核电核准,也是「十五五」期间首批;据媒体估算总投资超过 1700 亿元。消息面催化较多,量化资金蜂拥而上,多股封板。个人判断,在轮动为主的大环境下,明天需要回避前排晋级、后排普遍回落的局面——这类由消息驱动、量化放大的板块,第二天的接力质量往往决定它是主线还是一日游。 光伏:午后同样受消息刺激集体异动,多晶硅相关标的领涨,通威股份涨停。据财联社报道,市场监管总局在江苏盐城对光伏行业开展价格合规指导,推动企业从「拼价格」向「拼质量」转变。核心还是长期超跌属性提供了空间。 (三)防御与低位补充:埋伏未反弹方向,但难成主流 从光伏的走法可以推演出一条阶段性有效的思路:在轮动主导、量能不足的阶段,埋伏低位尚未反弹的方向是当前少数有效手段之一。比如环保、轻工机械等方向。但需要清醒的是,这类方向基本上都很难成为主流——它们提供的是轮动收益,而不是趋势收益,持续性普遍偏弱。 (四)盘后新增的两条政策与业绩线索电网:据新华社 8 月 3 日报道,国家发展改革委、国家能源局印发《新型电力系统建设「十五五」规划》,提出到 2030 年初步建成新型电力系统,非化石能源发电量占比达到 50%,实现 28 亿千瓦以上新能源高水平消纳,建成可支撑超过 1.1 亿辆电动汽车出行的充电基础设施网络,并明确常规水电约 4.1 亿千瓦、核电约 1.1 亿千瓦等目标。据东莞证券研报观点,「十五五」期间两大电网合计规划投资约 5 万亿元,较「十四五」大幅增长,特高压骨干网架、配网智能化与虚拟电厂全链条受益。相关标的记录:汉缆股份(高压及超高压电缆,110kV—500kV 交联电缆与 110kV—220kV 电缆附件)、太阳电缆(500kV 及以下交联聚乙烯绝缘电缆、海底电缆及海底光电复合电缆等)。 创新药:据公司公告,药明康德 8 月 3 日晚发布半年报并全面上调 2026 年全年业绩指引:整体收入由 513 亿—530 亿元上调至 585 亿—605 亿元,持续经营业务收入同比增速由 18%—22%上调至 35%—39%;资本开支由 65 亿—75 亿元上调至 75 亿—85 亿元,并提前启动常州新基地建设;经调整自由现金流由 105 亿—115 亿元上调至 135 亿—145 亿元。据其半年报,上半年营收 288.97 亿元、同比增长 38.93%。相关标的记录:普洛药业(小分子 CDMO 战略)、药易购(医药+人工智能方向)。个人判断,这类超预期指引对板块的意义在于给出了基本面锚,但在缩量环境下,能否转化为板块级行情仍取决于明天的承接。 三、个股定时点:三条产业链跟踪记录 天孚通信:据市场流传的方案拆解,英伟达推出精简版 Lite 算力集群方案,成本结构出现显著变化——内存成本占比下降约 12 个百分点,互联成本占比提升约 10 个百分点(Scale-up 由 4%提升至12%,Scale-out 由 14%提升至 16%),并将机柜内 GPU 互联从私有 NVLink 架构切换为标准 RoCE以太网路线(该拆解数据源自公开流传材料,待官方口径进一步确认)。若成立,则直接推高单 GPU 对应的高速光模块及上游核心光器件配套价值量。公司作为全球高端光器件龙头,已实现 800G/1.6T 高速光引擎、光纤阵列单元(FAU)等全品类覆盖,通过 Fabrinet 等核心渠道嵌入供应链体系;原本主要覆盖机柜间互联的产品矩阵,将新增机柜内 Scale-up 互联的增量需求,电信与传统数通业务提供业绩基本盘。 泛微网络:OA 是 B 端 AI Agent 最天然的落地入口——承载着企业最核心的流程、知识与协作数据。据公司口径,公司积累约 8.5 万家企业客户,依托 Xiaoe.AI 的「大模型+小模型+智能体」架构与WorkBot 智能体生成平台,2026 年 5 月发布 300 多个可落地 AI 应用场景,覆盖市场、销售、合同、采购、人事、财务等十六大核心业务,开箱即用,并与华为、腾讯生态合作。个人判断,公司正从协同管理软件龙头向企业智能体经济的核心枢纽演化,AI Agent 重构 B 端软件是其第二成长曲线的观察主线。 福莱新材:人形机器人无论是训练还是最终的全 AI 控制,触觉都是不可或缺的一环;更广泛的适用性与算法壁垒决定了触觉板块的利润空间,有望成为下一个丝杠级别的方向。据公司信息,2024 年公司成功开发高性能柔性传感器材料,实现从传统产业向科技领域跨越;2025 年 2 月、5 月、11 月相继推出三代柔性触觉传感器产品,其中第三代搭载触觉智能 TPU(Tactile Processing Unit),可在指尖完成边缘计算而非集中回传「大脑」处理。目前柔性传感器中试产线搭建完成,具备批量供货能力,已与国内外数十家客户合作,北美市场实现小批量订单。 四、明日观察:两种情形与对应的应对 短期来看,科技板块明天应该会有回流——最近强一天弱一天的特征比较明显,创业板指也呈现一阳一阴交替。但回流不等于反转,需要用量价确认,而非用情绪预判。 情形 A:量能温和回升,科技出现强者恒强,前排龙头不补跌、二板晋级家数不锐减。那么这轮硬件调整基本可以确认进入尾声,右侧确认的窗口开始打开。此时低位补涨方向的性价比反而下降,重心应回到科技主线。 情形 B:量能继续萎缩,核电等前排在高位放量分歧、后排普遍回落,同时科技回流一日游。那么当前仍属轮动博弈阶段,低位防御与题材接力仍是短期主线,但持续性普遍偏弱,此时更应控制参与频率,等待强势股补跌与多杀多这两个信号真正出现,而不是在缩量中反复试错。 整体来看,当前市场处于磨底期的风格再平衡阶段:科技硬件的调整尚未完全结束,但连续快速下跌后空头动能已有消耗;低位防御与题材轮动是短期主线,但持续性普遍偏弱。不见量价确认不追,是这个阶段最朴素也最有效的纪律。 免责声明:本文为个人研究记录与盘面复盘,所涉公司、板块均为产业链跟踪与事实陈述,不构成任何投资建议或买卖指令。市场有风险,投资需谨慎。
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-091300_article_8.4\346\227\251\347\233\230_1232516577286422548.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-091300_article_8.4\346\227\251\347\233\230_1232516577286422548.txt"
new file mode 100644
index 0000000..2099c2e
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-091300_article_8.4\346\227\251\347\233\230_1232516577286422548.txt"
@@ -0,0 +1,79 @@
+标题:8.4(早盘)
+博主:青枫浦上Q
+发布时间:2026-08-04 09:13:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1232516577286422548
+内容类型:文章
+
+隔夜涨的是七巨头,不是芯片;存储的正反馈只兑现了一半。费城半导体指数是盘中跌了 3% 才拉回来的,铠侠系与 NAND 修复、DRAM 与 HDD 掉队,这个差别会直接决定今天开盘 A 股科技板块的预期定价,也决定了「等德明利止跌」这个命题今天能推进到哪一步。
+
+一、隔夜外盘:涨的是七巨头,不是芯片
+
+据公开行情,8 月 3 日美股高开高收。道琼斯指数涨 1.32% 报 53178.41 点,纳斯达克综合指数涨 2.13% 报 25913.90 点,标普 500 涨 1.48% 报 7600.50 点,距 6 月初创下的历史最高点仅差约 0.3%。美股七巨头指数上涨 3.6%,创 3 月 31 日以来最佳单日表现:Meta 涨 6%,微软涨近 5%(近三个交易日累计涨约 25%),Alphabet 涨超 4% 且总市值超过苹果升至全球第二,亚马逊涨超 4.5%、市值首次突破 3 万亿美元,英伟达涨逾 3%、市值重回 5 万亿美元上方;唯一的例外是苹果下跌 1.78%。
+
+但真正要看的那一笔在费半。据公开行情,费城半导体指数盘中一度跌约 3%,尾盘低开高走、全天收涨 1.05%。所以「昨晚美股大涨」这个描述用在科技板块上是失真的——涨的是应用与云,芯片是被救回来的。
+
+存储内部的分化则更清楚:铠侠 ADR 涨约 14%,闪迪反弹超 6%,美光仅涨 0.79%;另一侧,SK 海力士跌 0.70%,西部数据与希捷科技均跌逾 3%。昨天盘前押的那条链路——铠侠开盘跌超 4% 最终收涨超 5%,这份正反馈能否传导至隔夜美股存储与韩国市场——答案是传导了,但只沿着一条支线走。
+
+个人判断:这一轮修复是「铠侠—闪迪—NAND / 模组」这一条线,而非整个存储链条同步。DRAM 与 HBM 的代表(SK 海力士、美光)、HDD 的代表(西数、希捷)都没跟上。映射到 A 股,同样挂着「存储」两个字,今天开盘的含金量并不一样:NAND 模组方向的止跌确定性,要高于 HBM、先进封装与 DRAM 相关方向。
+
+另一条昨日盘前材料里没有覆盖、但隔夜信号更干净的线是光通信。据公开行情,Lumentum 涨 9.24%,Coherent 涨逾 9%,康宁涨 6.07%,Credo 涨逾 5%。这一组的涨幅结构比存储整齐得多,且没有「先跌 3% 再拉回」的过程。倾向于认为,今天 A 股开盘后最值得优先验证的不是存储,而是光模块方向能否接得住这份外溢。
+
+其余外围变量:据公开报道,美国总统特朗普宣布取消原定针对伊朗的打击行动,国际油价大跌,NYMEX WTI 原油跌逾 5%、ICE 布伦特原油跌逾 4%;叠加 OPEC+ 决定自 2026 年 9 月起每日石油产量配额上调 18.8 万桶。个人判断,这对石油石化是压制项,对航空、交运及化工中下游是成本端的边际利好,但油价单日大跌本身不足以支撑这些方向走出独立行情。
+
+本周日历上有三个硬时点。其一,据新浪财经援引 FactSet 一致预期,美国 7 月非农预计新增 8.75 万人(前值 5.7 万),失业率或从 4.2% 升至 4.3%,周五公布。其二,据公开报道,SpaceX 将于本周二首次以上市公司身份发布财报,商业航天方向今日盘中可能提前反应。其三,据雪球等公开日程信息,闪迪与西部数据财报预计在北京时间 8 月 6 日凌晨前后发布——此项待核实,但若属实,则周三收盘前存储方向的仓位管理需要预留这个变量,不宜在财报前把仓位打满。
+
+二、指数三要素:量能 · 大金融 · 情绪
+
+量能:一步到位缩到 2 万亿,是起点不是终点
+
+据交易所数据,昨日沪深两市成交 2 万亿元,较上一交易日缩量 5446 亿元;截至收盘,上证指数跌 0.59%,深证成指跌 0.96%,创业板指跌 1.24%。半导体方向是主要失血口,多只芯片与半导体设备 ETF 单日跌幅超过 4%,部分超过 7%。
+
+按此前归纳的量能路径:峰值近 4 万亿先降至 2.5 至 2.6 万亿区间,再回升至 3 万亿以上,跌破 2.5 万亿后进一步缩至 2 万亿,在此反复一段时间,最后才落到 1.5 至 1.6 万亿的地量——分别对应去年 11 月底和今年 4 月初。昨天是一步到位缩到 2 万亿,位置上对应去年 10 月中旬与今年 2 月中旬那两次「量能首次跌至 2 万亿」。
+
+个人判断,理论上距离一波量价齐升的反弹不远了;但要把话说完整——两轮历史样本里,「首次到 2 万亿」之后都还有一段在这一带的反复,直接从这里起爆的概率并不高于先去 1.8 至 1.9 万亿再磨一次的概率。把「快到了」当成「就是今天」,是这个位置最容易犯的错。
+
+大金融与结构:拖累在权重,承接在中小盘
+
+昨日黄白线分化明显,指数收跌而全市场超 4000 只个股上涨。这个组合说明拖累项在权重端(大金融与大市值科技为主),承接项在中小盘。个人判断,这是典型的缩量磨底结构,而不是恐慌出清结构——恐慌盘的特征是放量、跌停家数扩张、黄白线同向下杀,昨天三条都不成立。
+
+但结构健康不等于指数能自己走出来。缩量磨底期,指数的方向由权重决定,赚钱效应由中小盘决定,两者可以长期背离。上证从上周二至今在 3750 至 3850 之间小幅震荡,创业板指看着波动大,昨天与上周二的收盘价其实相差有限。震荡筑底的判断到目前为止没有被证伪,性价比区域的定位也没有变化。
+
+情绪:这轮调整以来短线生态最健康的一天
+
+据昨日盘面数据,二板家数 13 个,对应一进二晋级率 14.77%;首板家数由 88 家减少至 58 家,降幅远好于 7 月下旬;跌停数量控制在个位数;高标传智教育缩量一字晋级至 6 板,次高标一鸣食品尾盘上板。按「首板家数 × 约 15% 晋级率」的推演口径,13 个二板与 88 个首板基本吻合,承接是在线的。
+
+唯一的瑕疵在首板家数下滑。个人判断,这意味着新增资金仍在观望,昨天的接力靠的是存量高标的稳定性而不是增量入场;一旦高标出现分歧,缺乏首板梯队会让情绪的下滑速度快于上行速度。这一项需要今天继续跟踪。
+
+三、今日重点:存储止跌的条件化推演
+
+核心命题没变——最能跌的板块不跌了,科技和指数自然也就不跌了。变化的是,隔夜给了这个命题一个部分成立的答案,于是今天的推演要按两种情形走。
+
+情形 A · 正反馈落地:存储方向高开后能守住分时均价(重点看德明利、江波龙这类 NAND 与模组标的),光模块跟随隔夜光通信走强并且不是一小时行情,量能从 2 万亿回升。则可以确认昨天的缩量就是这一轮的量能低点,科技止跌即指数止跌。此种情形下的操作原则仍是「多找低点」而非追高——按「科技的反弹是让科技止跌,而不是反弹」的框架,止跌来自有承接而不是无抛压,跌停股龙虎榜上的大资金对轰就是攻守易势的过程,要在这个过程中建仓,而不是等它走完。
+
+情形 B · 高开低走:存储冲高回落,光模块只反弹半小时到一小时,量能不升反降至 1.9 万亿以下。则说明外盘的正反馈没有转化成场内承接,2 万亿不是终点;参考去年 11 月底与今年 4 月初的 1.5 至 1.6 万亿地量,磨底时间要往后拉。此种情形下不必急于抄科技,做 T 为主或持股等待,等右侧确认。
+
+开盘观察顺序建议是:光模块 → NAND 模组 → 指数量能。把最干净的信号放在最前面看,而不是先盯着最纠结的存储。另外提示一个反向信号:如果今天电力、医药、大消费这类传统超跌板块反而强于科技,那不是好消息,而是情形 B 的早期征兆——它们本就靠「指数回调」才支愣起来,在隔夜科技全线走强的背景下还能跑赢,只能说明资金不敢碰科技。
+
+四、板块与个股跟踪
+
+核电 · 事件催化+超跌:据财联社 7 月 31 日电,当天召开的国务院常务会议决定核准辽宁庄河一期工程等四个核电项目,共计 8 台新机组,据估算总投资将超过 1700 亿元。方正证券机械团队指出,此次批复涵盖中核、中广核、国家电投三大集团,庄河与莱阳属全新核电厂址,金七门和太平岭项目采用升级版华龙一号 2.0,标志核电审批节奏延续高位。板块内相关标的包括利柏特、瑞迪智驱、中国核建。个人判断,这与上周的食品饮料(可口可乐)、半导体(光刻机)、脑机接口同属「事件催化+超跌板块」的组合,资金拉升顺畅是结构使然;中国核建这种体量的标的上板后几无分歧,恰恰是这类题材第一天的典型特征,持续性不宜高估。
+
+AI 办公 / A 应用:据公开信息,8 月 3 日阿里巴巴千问办公开启公测,Qoder、Work、悟空、MuleRun 三款 Agent 产品合并为统一产品体系;7 月 30 日字节跳动将飞书产品团队并入豆包、销售线划归火山引擎;同日腾讯 WorkBuddy 上线「人机双写」协同编辑能力。三家整合手法各异但指向一致:让 AI 从独立功能变成办公 App 的基础能力。华源证券文化传媒团队认为,AI 应用产品的落地和执行仍是产业发展与市场交易的核心,AI 叙事正从投入切换到兑现;申万宏源林起贤看好国内大众化、横向通用办公 Agent。相关标的:金山办公(WPS AI 锚定 AIGC 内容创作、Copilot 智慧助手、Insight 知识洞察三大方向)、金桥信息(金桥 e 享 VaaS 远程通讯及云协作平台)。
+
+个人判断,A 应用上周五已经迎来一个情绪高点,昨天的分歧属预期内;预期外的是传智教育缩量一字把高度拓展到 6 板,场内与场外资金像有共识一般。接下来的看点不是高标还能走多远,而是下一次分歧转一致时,哪个细分方向脱颖而出。
+
+光模块 · 隔夜新增线索:隔夜 Lumentum、Coherent、康宁、Credo 一组的强度明显高于存储,且形态干净。A 股对应方向可跟踪中际旭创、新易盛、天孚通信。个人判断,这条线目前只是外盘映射,尚无国内催化,属于「先验证再参与」而非「提前埋伏」的性质——不见量价确认不追。
+
+存储 · 最敏感的牵引变量:德明利、江波龙一线仍是判断科技方向能否企稳的观察窗口。据公开信息,行云科技在机构调研中表示,在手算力、存储类 3 至 5 年期长期框架订单规模超 154 亿元——这是需求侧的硬证据,可作为存储景气度未被证伪的旁证,但它解决不了当下的筹码问题。说到底还是要看资金什么时候出清完。
+
+量子科技:据公开报道,为期三天的第五届 CCF 量子计算大会暨大湾区量子科学论坛于 8 月 3 日在深圳开幕,由姚期智与薛其坤共同担任大会主席,汇聚近千家机构约 2400 人。相关标的:铜牛信息(与国科量子签订战略合作协议,推进量子通信技术市场化)、天融信(投资安徽问天量子,已将量子密码通信、认证、签名、密钥生成等成果应用于 VPN、堡垒机等产品)。个人判断,会议型催化的爆发力弱于政策批复型,更适合列入观察名单而非首选方向。
+
+海外映射:亚马逊涨 4.58%、市值首破 3 万亿美元,云厂商财报验证 AI 回报兑现,AI 中下游(云、AI 办公、多模态应用)成为反弹主力,A 股相关公司包括卓易信息、云赛智联;Snap 盘后涨 8%,二季度业绩全面超预期且广告业务复苏迹象增强,A 股相关公司包括蓝色光标、三人行。另据公开报道,Palantir 于周一收盘后公布财报,SpaceX 于周二首次以上市公司身份发布财报,商业航天方向可同步跟踪。
+
+五、跟踪变量与风险提示
+
+需要在今日盘中逐项验证的变量有五个:其一,光模块方向能否把隔夜的外盘强度接过来,且不是一小时行情;其二,NAND 模组方向(德明利、江波龙)能否守住分时均价;其三,量能能否从 2 万亿回升,还是继续下探至 1.9 万亿以下;其四,首板家数能否止住下滑,二板与晋级率能否维持在昨天的水平;其五,传统超跌板块(电力、医药、大消费)与科技的相对强弱——若前者反强,警惕情形 B。
+
+风险方面,除周五非农与本周存储财报的时点风险外,最需要提防的是把外盘的部分修复读成全面修复。隔夜费半是从盘中跌 3% 拉回来的,这个「拉回来」的性质是抢反弹还是趋势反转,一天的行情给不出答案。指数仍在磨底期,位置上已进入性价比较高的区域,但位置便宜与时点成熟是两回事——多点耐心,等结构性的分化自己长出来。
+
+免责声明:本文为个人盘面记录与研究笔记,所涉个股仅作产业链跟踪与盘面事实陈述,不构成任何投资建议、要约或承诺。市场有风险,投资需谨慎。
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-104800_text_\350\275\256\345\212\250\350\241\214\346\203\205_1232541024240271379.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-104800_text_\350\275\256\345\212\250\350\241\214\346\203\205_1232541024240271379.txt"
new file mode 100644
index 0000000..e712fb7
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-104800_text_\350\275\256\345\212\250\350\241\214\346\203\205_1232541024240271379.txt"
@@ -0,0 +1,8 @@
+标题:轮动行情。
+博主:青枫浦上Q
+发布时间:2026-08-04 10:48:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1232541024240271379
+内容类型:纯文字动态
+
+跟昨天预想的一样,轮动补跌应该是差不多结束了,后面就是轮动修复。 轮动行情不要去追涨杀跌,可以提前埋伏,但不一定会对。所以稳健的就做ETF尽量避免选择个股。至于Ai应用,最近几天几乎每天也都在提,从各个方面来看,都有点志存高远的意思,应用目前也是在局部分化的,所以我目前也是做了两个方向,一边是DS和算力租赁相关的,一遍是Workbuddy相关的。
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-135100_text_\345\201\232\344\272\244\346\230\223\350\246\201\345\220\203\347\232\204\344\270\213\351\245\255\347\235\241\345\276\227\347\235\200\350\247\211_1232588032685637632.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-135100_text_\345\201\232\344\272\244\346\230\223\350\246\201\345\220\203\347\232\204\344\270\213\351\245\255\347\235\241\345\276\227\347\235\200\350\247\211_1232588032685637632.txt"
new file mode 100644
index 0000000..83b2ce3
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-135100_text_\345\201\232\344\272\244\346\230\223\350\246\201\345\220\203\347\232\204\344\270\213\351\245\255\347\235\241\345\276\227\347\235\200\350\247\211_1232588032685637632.txt"
@@ -0,0 +1,8 @@
+标题:做交易要吃的下饭,睡得着觉。
+博主:青枫浦上Q
+发布时间:2026-08-04 13:51:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1232588032685637632
+内容类型:纯文字动态
+
+现在的行情就是很容易超预期,跌了超预期,涨也超预期。所以做T你们可以小仓位练习,不用非得大仓位把自己T飞。 点位昨天视频也说过了,至少可以先往那个方向看,找一个自己舒服的仓位,进退有度就好。
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-224300_article_8.4\345\244\215\347\233\230_1232725342775410720.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-224300_article_8.4\345\244\215\347\233\230_1232725342775410720.txt"
new file mode 100644
index 0000000..931c1fd
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260804-224300_article_8.4\345\244\215\347\233\230_1232725342775410720.txt"
@@ -0,0 +1,140 @@
+标题:8.4(复盘)
+博主:青枫浦上Q
+发布时间:2026-08-04 22:43:00 Asia/Shanghai
+来源:https://www.bilibili.com/opus/1232725342775410720
+内容类型:文章
+
+今天是科技连续调整之后的一次系统性修复,量能与情绪同步回暖,光互连确立为反弹的核心方向。但收盘之后落下了两枚方向相反的变量——一枚指向光模块的政策风险,一枚指向油价与利率的松动。明日大概率是一场强分化,本质上是这两股力量与支线承接力的三方定价。
+
+一、指数定环境:缩量反弹升级,明日才是那根验证线
+
+全天震荡走高,指数小幅分化,双创板块单边走强,沪指受防御权重拖累涨幅相对有限。随着行情回暖,量能较上一交易日有所放大,性质上仍属缩量反弹,但强度明显高于上周四、周五,其持续性也有望适当延长。
+
+位置决定意义。创业板指自6月25日的高点4380点开始下跌,7月双创板块单月最大跌幅超过25%,已越过此前15%—20%的预期回撤区间;7月17日人工智能大会当日的大跌,把市场认知从“阶段性高切低”推向了“牛转熊”的恐慌。今天的修复,是从这个位置上启动的。
+
+从7月21日开始,市场强弱交替极为明显——涨一天歇一天的节奏本身就是分歧未决的证据。所以明天能否维持强势,比今天涨了多少更重要,那才是一次真正意义上的突破。在此之前,我倾向于把今天定义为修复而非反转:右侧确认要求的是连续性,不是单日的量价配合。
+
+情绪端
+
+短线情绪随指数同步回暖,高标传智教育再度涨停拿下7连板,全市场涨停家数明显扩容。个人判断,涨停家数扩容与高标继续晋级同时出现,说明赚钱效应的修复是有厚度的,不是单一题材的独角戏;但缩量环境下的高标,容错率天然更低,位置决定其意义。明日重点看首板家数是否锐减、以及二板晋级率能否达标。
+
+二、板块定方向:光互连确立核心,防御端全面失血
+
+科技主攻
+
+科技成长集体走高,光互连方向表现最为强势,确立为本轮反弹的核心方向,CPO与磷化铟两大细分领涨两市,同时带动PCB、液冷等算力配套赛道同步走强。算力租赁板块掀起涨停潮,随着海外头部CSP厂商财报陆续披露,北美云业务的商业闭环进一步得到验证。
+
+半导体芯片以跟随修复为主,国产链表现相对偏弱。个人判断,这一点需要重点跟踪:此前国产链的下跌以补跌为主,若后续仍不能补涨,说明资金修复的是海外链的叙事,而不是整个科技板块的风险偏好。
+
+AI软件、应用方向如期迎来回流。据公开信息,Palantir二季度业绩大超预期并上调全年营收与利润指引,大涨超20%;同期安森美(ON Semiconductor)业绩与指引双超,并表示预计2026年AI数据中心收入将翻倍以上。市场对AI应用迎来商业化拐点的预期持续升温。
+
+非科技方向
+
+非科技方向分化明显。药明康德大幅上调全年业绩指引后开盘涨停,带动医药板块集体回暖——个人判断,这是今天非科技端唯一有清晰基本面锚的上涨,性质与纯轮动不同。
+
+银行、煤炭等高股息红利板块在失去防守价值后全天持续回落,食品饮料等消费板块同步走弱。资金从防御端大幅流出、转向成长赛道,缩量环境下的跷跷板效应十分清晰——这轮反弹的燃料,目前主要来自存量调仓而非增量入场。
+
+三、当日消息面:三条产业线索
+
+线索一 · 算力租赁:又一笔近八十亿的大单
+
+据蜂助手(301382)8月4日公告,全资子公司雅安云智算力技术有限公司与A公司签署《算力服务器采购协议》,由A公司提供算力服务器及组网设备,协议总金额30.62亿元(含税);与B公司签署《算力服务合同》,提供服务器硬件、配套机柜、存储资源、网络服务等算力资源服务及配套技术支持,合同总金额46.08亿元(含税),合作期限自2026年8月5日至2031年8月4日,每批次服务期限60个月,合计约76.7亿元。公司预计项目年均净利润6000万元至7200万元,合同尚需股东会审议通过后生效。
+
+据同一公告,采购资金来源为自有资金、融资租赁及配套流动资金贷款的组合模式,其中自有资金预计占比5%至20%,融资租赁占比80%至95%;公司同时提示,融资租赁将形成长期有息负债,资产负债率预计提升较大,受融资成本波动影响,个别年度甚至可能削弱公司净利润。
+
+这是一笔典型的重资产、高杠杆算力租赁订单。近七十七亿元的合同总额,对应的是六千万到七千二百万元的年均净利润,这个量级的对比需要放在心里——决定这类标的能否走远的从来不是签约金额,而是融资成本与上架率。公告里自己写明的资产负债率提升与净利润可能被削弱,比订单本身更值得记进跟踪表。
+
+引述的中国信通院数据,2026年一季度国内算力租赁市场规模达680亿元、同比增长62%,全年预计突破2600亿元;据浙商证券,行业供需两旺、量价齐升,业绩已逐步进入兑现期,板块整体呈现高景气。
+
+线索二 · 智能驾驶:强制性国标正式发布
+
+据工业和信息化部消息,2026年7月30日,《智能网联汽车 自动驾驶系统安全要求》(GB 44721—2026)强制性国家标准由国家市场监督管理总局、国家标准化管理委员会批准发布,拟于2027年7月1日起正式实施,适用于搭载L3级、L4级系统的M类和N类车辆,不适用于自动泊车系统。据同一消息,标准要求车辆制造商从安全方针、风险管理、安全保证、安全提升四个维度,建立覆盖产品设计开发、生产制造及部署后全过程的保障能力,并要求自动驾驶系统的安全水平至少达到正在承担动态驾驶任务的合格且专注驾驶人的水平;2025年已有两款L3级车型获得附条件准入许可。
+
+2026年是中国智能驾驶从“功能竞赛”转向“制度化商业落地”的关键分水岭,产业坐标已切换至三条主线并行:L2进入强监管周期,行业由拼体验转向拼合规、拼安全、拼体系;L3从试点走向有限商业化,责任边界、准入机制与标准体系开始形成;L4与Robotaxi在中美共振下迈入规模化验证阶段,商业化判断标准从能否跑起来转向能否低成本复制、能否跑通单车经济模型。Robotaxi、智驾芯片、激光雷达三条细分赛道有望受益。
+
+国标的实施日在2027年7月1日,这是典型的预期先行、兑现滞后——标准发布往往是情绪高点而非基本面拐点,不宜按业绩兑现的节奏去理解。但位置决定意义:智驾板块此前并不处在拥挤位置,在明日海外链大概率承压的背景下,它同时具备低位与催化两个条件,是支线能否撑住情绪的头号候选。
+
+线索三 · 存储:三大原厂2027年产能已提前售罄
+
+据DIGITIMES 8月4日报道援引业界人士,三星电子、美光及SK海力士的DRAM与HBM 2027年产能已全数分配完毕,涵盖长约大客户及中小型买家;三星、美光、闪迪的NAND Flash全年产能亦已预售一空,铠侠与SK海力士预计最晚于2026年8月底前完成分配。据同一报道,原厂已陆续与大客户签订3至5年期长期供货协议;威刚董事长陈立白称,HBM与AI服务器相关应用将占据DRAM产能约七成,原厂实际可提供的产能通常仅达买方原定目标的六至七成。据SK集团会长崔泰源,2027年AI半导体需求可望较2026年增长60%至100%,整体存储需求增幅估达50%至60%。业界同时预期2027年价格涨幅将较2026年趋缓。
+
+这条消息对存储是双向的,它同时验证了多头的供需缺口与空头的涨幅趋缓。但更值得注意的是第三层:三到五年期长约把存储从标准商品的周期循环里往外拉了一步,被削弱的不是涨价空间,而是“纯周期股”这个估值前提。我此前对存储的态度是观望,难点在于股价有提前量,这条消息不改变那个难点;但若明日存储板块对如此明确的供给端信息仍反应平淡,反过来说明资金确实已经把判顶定价做完了——这本身就是有价值的读数。
+
+四、盘后变量之一:FCC拟禁进口中国新型光收发器
+
+据路透社8月4日华盛顿电,四位知情人士透露,特朗普政府正在起草一项禁令,禁止进口中国制造的新型号数据中心组件;负责监管美国电信行业的联邦通讯委员会(FCC)正推进这项措施,拟禁止进口中国制造的新型号光收发器,官员希望今年公布并于今年生效。消息人士同时强调,FCC仍有可能修改或搁置这项限制。据同一报道,该机构将先禁止所有新型收发器进口,再豁免许多非中国供应商。
+
+据Counterpoint Research数据,中际旭创在全球数据中心收发器市场占据约27%的份额;据美国创新基金会的报告,Coherent与Lumentum虽拥有具竞争力的技术,但规模不足以取代中国厂商,同时中际旭创约90%的收入来自中国以外地区。据报道,消息公布后美股光通信标的普涨,Applied Optoelectronics涨约16%,Coherent涨约13%,Lumentum涨约11%,康宁涨约8%。中国驻华盛顿大使馆表示,对于任何对中国利益造成实质性损害的行为,中国将采取一切必要措施予以回应。
+
+个人判断
+
+第一,逻辑本身值得商榷。光模块的核心功能是光电信号转换,本身不承担业务数据存储,相比主动设备,其风险边界和攻击能力明显不同。因此消息人士援引的安全逻辑能否成立、拟议限制是否符合必要性原则,本身就是有争议的。
+
+第二,这件事目前仍属传言、处在博弈阶段,所以对明日的影响是情绪性打击而非基本面打击,短期冲击明确,中长期大概率还是雷声大、雨点小。从2020年开始,类似的故事已经讲过很多轮,市场一直担心中国厂商的份额被北美竞争对手抢走,但这两年讨论反而少了——因为几年验证下来,即便产业政策不断向美国本土供应链倾斜,中国厂商的份额仍然不降反增。
+
+第三,可落地程度不高。高速光模块不是有厂房、有设备就能马上交货,客户认证、量产良率、交付稳定性都需要时间积累;美国自身的相关产能与原材料都是很大的限制。现在海外大客户的高速光模块订单中,中国供应商份额平均已达到七八成甚至更高,真要一刀切,北美厂商可能几年都填不上缺口——中国供应商还没被替代,美国的数据中心建设节奏先被拖慢、自身AI产业链的成本压力先上来了,因小失大。
+
+第四,中国厂商也不是站着挨打。2022年前后主流厂商开始布局泰国产能,四年后的今天,泰国工厂已经变成海外供应主力;2025年前后,源杰、旭创等又开始推进美国本土产能与运营布局。政策的补丁还没打完,供应链已经迭代了好几个版本。所以这件事短期交易的是情绪,中期观察的是规则口径,长期比拼的还是产品、良率和交付——政策可以改变发货地址,但未必能改变谁最会做光模块。
+
+说实话,看到这条消息确实很恶心。反反复复,只要出现一次像样的大反弹,就马上冒出幺蛾子,科技硬件这段时间的命确实苦。好在一点光我们很久没看了,主要科技方向也是国产提到的半导体和Ai应用,所以受到的影响是可控的,如果因为这些外部事件扰乱导致低开或者低走,也是我们可以继续加仓的位置。
+
+五、盘后变量之二:霍尔木兹、油价与利率
+
+据CNBC报道,美国财政部长贝森特8月4日表示,美国与伊朗有可能在今明两天达成开放霍尔木兹海峡的协议,届时商船可自由通行;他同时预计能源价格将回落,滞留波斯湾的数百艘船只放行后会出现一波宽松交易。消息公布后WTI原油期货跌幅一度扩大至4%以上、报77美元附近,布伦特跌至80—81美元区间,两者均为约三周低点;美股道指与标普500盘中创出新高,为连续第四个交易日上涨。
+
+据公开报价,10年期美债收益率近日在4.7%附近波动,仍处于约十八个月高位区域;据市场定价,9月加息25个基点的概率仍在六成以上。
+
+这一变量其实在今晚这种时候非常重要。油价下行→通胀预期缓解→加息路径松动,这条链条作用在科技股的估值分母上,比FCC禁令作用在光模块的分子上更根本。前者影响的是整个成长板块的贴现率,后者影响的是一个细分环节的远期份额假设。两者方向相反,明日开盘的定价结果,取决于市场当下更在意哪一端——而通常,直观的消息先被定价,根本的变量后被定价。
+
+六、个股定时点
+
+云南锗业:据公司信息,为国内磷化铟衬底龙头企业,磷化铟是制作光芯片的关键衬底材料;公司于2026年4月启动高品质磷化铟单晶片建设项目,计划建设期18个月,在现有15万片/年(2—4英寸)产能基础上扩建,目标年产45万片(折合4英寸),其中包括6000片6英寸。另据引述,Lumentum首席执行官在巴黎RAISE峰会上表示,磷化铟的供应紧张程度已超越DRAM和NAND,公司拥有五座磷化铟晶圆厂但出货量仍比客户需求低30%以上,缺口较上一季度还在扩大(该表态按原引来源记录,待核实)。个人判断,磷化铟是光互连链条里少数同时具备上游、国产替代与供需缺口三重属性的位置,与光模块整机的贝塔并不完全同源——若明日海外链整体承压而磷化铟相对抗跌,说明市场愿意给上游材料一个独立定价。
+
+行云科技:据媒体报道,为本轮科技大跌以来首个创出阶段新高的AI相关个股,以超154亿元长期算力订单锁定未来3至5年收入;7月连续与三家核心客户(涵盖头部央企与一线大模型厂商)签署补充协议,客户在首批设备交付验收后主动加价锁量,其中一家头部大模型客户订单金额涨幅超201%、算力规模翻倍,一家央企客户租金整体上调79%(加价幅度按原引来源记录,待核实)。个人判断,算力租赁若真能从签约潮走向加价潮,并被两家以上公司独立验证,这是这条支线区别于纯题材的关键证据——值得跟踪,但不构成当日追高的理由。
+
+恒银科技:据媒体报道,公司服务覆盖六大国有银行、十二家股份制银行及超百家城商行,拥有国内较大的银行智能终端存量(超30万台),通过模型蒸馏、量化等技术实现大模型轻量化部署,并联合华为、海光、沐曦等国产算力厂商推出训推一体机与AI端侧服务器;今年上半年银行业智能体采购已进入招标密集落地阶段,苏州银行自3月以来连续发布6个智能体项目招标(按原引来源记录)。作为国产链在金融终端侧的落地样本跟踪。
+
+药明康德:据公司公告,大幅上调全年业绩指引,当日开盘涨停并带动医药板块回暖。作为产业链跟踪记录:这是今日盘面上少数由业绩指引直接驱动的上涨。
+
+传智教育:当日再度涨停拿下7连板,为本轮短线情绪的标志性高标。此处仅作盘面事实记录。
+
+方向跟踪:智能驾驶方向,浙江世宝主营汽车转向系统及配件、自研智能转向可用于各类智能驾驶及无人驾驶汽车,精进电动的电驱动系统为无人驾驶汽车执行层面的核心部件;算力方向,安诺其构建以GPU弹性算力服务、GPU硬件供应、GPU算力调度平台部署为核心的业务生态,常山北明投资建设的常山云数据中心提供机柜租赁、服务器托管、定制化算力解决方案等服务。以上均为产业链跟踪记录,不构成交易依据。
+
+七、配置框架与硬件板块看法
+
+据科技产业四阶段规律,本轮AI行情已进入供需缺口阶段:上游对应存储板块(类比新能源行情中的锂矿),下游对应AI应用、多模态大模型与国产替代相关链条(类比汽车零部件),以量增逻辑为主。对应的操作原则是守着龙头品种的同时重点布局二三线——2026年二季度部分二三线标的涨幅已显著超过同期龙头。
+
+科技M顶:一顶是交易顶、二顶是基本面顶,两者间隔至少一个季度;把强贝塔属性的龙头切换为二三线强阿尔法品种;逃顶参考规则是股价对业绩透支超过3年开始逐步减仓、超过5年全部清仓。每一轮大跌与高切低之后,即使主线不变,核心领涨品种也会换人——本轮反弹前的核心是涨价链,后续持续性更强的应是量增链。
+
+国产链基本面好于海外链。海外资本开支基数已经很高,未来增速不及国内;且资本开支的增长依靠云厂经营现金流已经不够,自由现金流转负,越来越依赖债务融资,而当前美债利率处在多年高位,一些模型公司与算力租赁公司对债务融资和循环融资的依赖更深,风险更集中。国内模型的性价比远超海外、技术水平越来越接近,这对海外链是另一重压力。国产链自身的问题则是估值——科创板整体估值仍然偏贵,大跌之后依旧不便宜。
+
+存储:供需紧张依旧,PE估值是所有硬件里最低的,价格坚挺到2027年问题不大,但周期性是其最大问题。随着最大供给方韩国企业因暴利面临来自美国的风险,加上季度涨幅环比放缓、股价此前涨幅巨大,资金已提前判顶卖出,这是本轮AI硬件调整的起点。基本面不难判断,难的是股价有提前量——目前我选择观望。
+
+光:本身属于海外链,承担前述海外链的全部风险。高市占率、高毛利率的龙头再去博大级别的投资机会并不现实;相比其他半导体环节,光模块偏制造业,技术壁垒没那么高,未来参与者越来越多、竞争越来越激烈,市占率与毛利率都难保证。远期估值看似不高,但个人不认为具备大的投资价值。反而是那些从无到有、目前资本市场尚未给估值的转型类公司值得关注——规避市占率从高到低的企业,参与市占率从无到有的机会。光模块以外的CPO,有核心技术的企业估值太高,技术路线变化与收入利润兑现时点都有不确定性,科技大势好时叙事投资没问题,但在当下的行情结构里,短期业绩不落地或估值过贵的,要谨慎参与。
+
+晶圆制造:与存储相比,后者靠涨价获得了十倍以上涨幅,前者因为慢慢涨价,产业周期的持续性反而更久、股价也不高。硅基时代的硅基制造依然是核心之核心,壁垒之深导致几乎没有新玩家、没有竞争,未来数年业绩增长有保证。它没有存储的周期性,也没有光的竞争风险与技术路线风险,唯一要判断的就是估值。其确定性与成长久期是前两者无法比较的,只要估值合理就值得投资。
+
+半导体设备:作为存储原厂与晶圆制造厂的上游,未来数年订单高增是确定的,唯一要计算的同样是估值。国产链更好,但估值也更贵,若估值下来则值得投资。
+
+以上都是板块层面的贝塔观点,个别公司若有自身阿尔法业绩,另当别论。
+
+八、明日预判与跟踪线
+
+今天科技硬件(海外链)是反弹主流,而这块又深度绑架各个指数的权重,所以FCC消息落在这个位置上,杀伤力会被放大。个人判断,明日应该是一场强分化:海外链承压,其他支线需要出来撑情绪。到底反应到什么程度,只能开盘看情况再说。
+
+情形A:明日直接大低开。此时科技内部的非海外链支线——算力租赁、国产链、智驾、AI应用——若能走出主动性,在对冲海外链低开的同时缓和恐慌,进而带动海外链盘中修复,总体应该不会太糟糕。低开不可怕,接不住才可怕。
+
+情形B:平开或高开后海外链快速走弱,且支线跟随下杀。这说明今日缩量反弹的资金结构是脆弱的,跷跷板的另一头没有人接。此种情况下应回到轮动思路,不追反弹,等待下一次量价共振。
+
+四条跟踪线
+
+第一,盘面还会不会出现强势股随机补跌。若补跌不再频繁出现,科技反弹的节奏会顺畅很多;反之大概率仍以轮动为主。这是“强势股补跌、多杀多、流动性见底”三信号清单里,目前唯一还没给出明确答案的一条。
+
+第二,国产链与前期抗跌标的能不能跟上节奏、走出强者恒强。这直接决定本轮反弹的广度与高度——只有海外链在涨的反弹,天花板是有限的;而在海外链被政策消息压制的当口,这一条的答案会来得更快。
+
+第三,光互连方向明日的分时形态。低开拉起说明市场已把它当作雷声大雨点小的老故事,高开回落则说明政策风险被真实定价。这一天的分时,比消息本身更有信息量。
+
+第四,防御端是否回补。若红利与消费重新走强,说明资金又切回防御,这对反弹持续性是减分项;若红利继续回落、资金留在成长内部轮动,则说明市场并未进入系统性避险。
+
+风险提示与免责声明:本文为个人研究笔记与盘面记录,所涉数据均来自公开信息或所引来源,不保证其准确性与完整性;市场有风险,据此操作,风险自担。
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-091521_article_8.5\346\227\251\347\233\230_1232888061922115591.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-091521_article_8.5\346\227\251\347\233\230_1232888061922115591.txt"
new file mode 100644
index 0000000..cd5b59c
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-091521_article_8.5\346\227\251\347\233\230_1232888061922115591.txt"
@@ -0,0 +1,263 @@
+标题:8.5(早盘)
+
+博主:青枫浦上Q
+
+发布时间:2026-08-05 09:15:21 +08:00
+
+来源:https://www.bilibili.com/opus/1232888061922115591
+
+内容类型:文章
+
+
+
+一、隔夜外盘与开盘预判:满分里夹着一根刺
+
+(一)外盘:指数创新高,半导体与光通信全线爆发
+
+据 8 月 4 日美股收盘,道琼斯工业指数涨 1.71%,首次突破 54000 点;标普 500 指数涨 1.79%,首次突破 7700 点,二者均创历史新高;纳斯达克指数涨 2.59%,纳斯达克 100 指数涨超 2.5%。费城半导体指数大涨超 6%,成份股全线上涨。
+
+结构上,半导体、存储、光通信三条线同时爆发。据媒体统计,应用光电涨超 19%,ARM 涨超 17%,Coherent 与迈威尔科技涨超 12%,英特尔、闪迪涨超 10%,Lumentum 涨近 9%,SK 海力士 ADR 涨超 8%,美光科技涨超 7%,康宁涨逾 3%。AI 应用侧,Palantir 二季度业绩全面超预期并大幅上调全年收入指引,盘中一度暴涨超 27%;美股基建龙头卡特彼勒盘中一度大涨超 12%,创 2022 年 10 月以来最大单日盘中涨幅,领涨道指成份股。
+
+商品与宏观侧同样偏正面。据媒体报道,美国财政部长贝森特表示霍尔木兹海峡相关问题最快可能于周二或周三达成协议,国际油价应声下跌,布伦特原油主力合约跌破 80 美元/桶,WTI 主力合约日内跌超 5%、盘后口径跌超 6%;伦敦现货黄金一度站上 4100 美元/盎司上方。此前用来区分「宏观风险」与「AI 商业模式风险」的证伪条件里,油价站稳 80 美元下方是其中一条,昨夜这条被满足了。在美联储按兵、油价回落、长端利率未失控的组合下,科技股若还跌,问题才在商业模式;而昨夜科技股大涨,说明当下的矛盾并不在宏观层面。
+
+(二)刺:FCC 光模块草案
+
+据路透社 8 月 4 日引述四名知情人士报道,负责监管美国电信行业的联邦通信委员会(FCC)正在制定相关措施,计划禁止进口中国新型号的光模块,美方官员希望在 2026 年内公布并实施;报道同时强调,知情人士称 FCC 仍可能修改或搁置有关限制,方案尚未最终确定。
+
+据报道细节,FCC 将原则上禁止所有新型收发器(transceiver)型号的进口,但计划对大量非中国供应商给予豁免。另据公开信息,FCC 已于 2026 年 7 月 22 日投票通过一项扩大禁令范围的提案,禁止含有「覆盖清单实体」生产的逻辑硬件组件的设备进口。中国驻美使馆当日回应,敦促美方倾听两国工商界客观理性的声音,停止抹黑与以制裁威胁中国企业,并强调将采取一切必要措施回应任何严重损害中方利益的行动。
+
+昨夜北美光通信的暴涨与今日 A 股光模块的处境,是同一条消息的两面。外盘传导只反映在开盘定价,开盘后回归自身节奏——今天大概率是「指数不难看、结构很撕裂」的一天。低开不可怕,接不住才可怕。
+
+(三)开盘的两种情形
+
+情形 A:光模块低开后有承接,分时不再创新低,指数牵引由存储、半导体设备、算力租赁分担。此时应按一次估值冲击处理,反弹结构未破,重点转向观察资金在 AI 链内部的再分配路径。
+
+情形 B:光模块低开低走,并带崩通信、PCB、CPO 整条链条,同时存储也跟随走弱。此时说明昨日 2.21 万亿的放量是情绪高点而非新一轮起点,需要把整体预期下调一档,不再以「反弹中继」的框架应对。
+
+二、全球市场推演:周二的单边上涨不宜线性外推
+
+把视野从 A 股拉开一层:亚洲股市大概率高开,但未必能把涨幅完整保留下来;到了美股时段,市场更可能出现明显分化,而不是复制周二那种几乎所有资产同时给出利好的单边上涨。
+
+(一)周二美股涨得太快,但情绪并没有完全跟上
+
+标普 500 和道指创下历史新高,纳斯达克大涨,半导体指数上涨约 6.6%,但美国股市的风险偏好只是略微升温。这说明推动上涨的力量里,固然有油价下跌、收益率回落和企业盈利改善,但也包含了大量空头回补和追涨盘。指数创新高与风险偏好只是略升,这两件事同时出现,本身就是提示。
+
+周二盘后已经出现第一个值得注意的变化。据公开财报,AMD 二季度营收 115.4 亿美元、调整后每股收益 1.66 美元,均高于市场预期(营收预期约 112.5 至 112.8 亿美元、EPS 预期 1.60 至 1.62 美元),数据中心业务收入 67 亿美元、同比增长 107%,三季度营收指引约 130 亿美元上下浮动 3 亿,亦高于分析师预期的 125.1 亿;SpaceX 上市后首份财报营收 78.14 亿美元、同比增长 92%(预期 69.3 亿),调整后 EBITDA 35.38 亿美元、同比增长 191%,净亏损由 10 亿美元收窄至 5.41 亿美元,三大业务收入均超预期。两家公司业绩都不差,甚至超过预期,但盘后股价双双下跌超过 7%
+
+下跌的理由指向同一处:资本开支。据财报,SpaceX 二季度资本支出达 184 亿美元,远高于市场预期的 132 亿美元,其中 158.3 亿美元投入 AI;今年前六个月投入 AI 的资本开支已达 235.5 亿美元,而上年同期仅 33.2 亿美元,华尔街预计全年资本开支将超过 450 亿美元。AMD 则被市场重新审视其 AI 芯片与英伟达竞争的长期胜率及未来增速预期。
+
+这两笔盘后交易的含义,比涨跌本身重要:市场开始从「只要和 AI 有关就买」,重新回到「业绩够不够支撑估值」。这与此前提出的证伪框架完全接得上——宏观条件(美联储按兵、油价低于 80 美元、长端利率未失控)已经改善,若科技股仍要下跌,问题就落在 AI 的商业模式与投入产出上,而不是宏观。
+
+因此,周三美股最可能出现的不是全面大跌,而是指数震荡、AI 内部开始分化。英伟达、存储、光模块等周二涨幅较大的方向容易出现获利回吐;真正有业绩支撑的公司可能相对稳定;道指和传统工业股的表现未必会弱于纳斯达克。
+
+(二)油价:反弹概率不低,但 80 美元才是分水岭
+
+据 8 月 4 日行情,NYMEX 近月原油期货下跌 5.69%、收于每桶 75.77 美元,布伦特原油跌破每桶 80 美元,催化为美伊协议接近达成的迹象浮现,以及美国财长关于霍尔木兹海峡最快周二或周三达成协议的表态。
+
+油价出现反弹的概率不低,但只要没有重新站上 80 美元,这种反弹就不会破坏昨天的市场逻辑。真正需要警惕的是油价突然重回 80 美元上方,同时 10 年期美债收益率重新向 4.70% 靠近——那会说明市场昨天把协议交易得太早。
+
+(三)亚洲:会高开,但不同市场明显分化
+
+日本和韩国会受益于周二美股半导体大涨,但韩国芯片股前几天已经经历了极端波动,周三即使高开,也容易出现资金兑现。中国资产的状态可能相对好一些,因为人民币的风险偏好已经突破中性区域,对 A 股、港股形成支撑。不过恒生指数自身仍在降温,所以港股能否跟上人民币,是周三亚洲市场最值得观察的细节。
+
+两种情形要分开读:如果人民币继续走强,而恒生指数仍然冲高回落,说明外部环境虽然改善,但资金还没有真正回到港股;反过来,如果人民币、恒生指数和恒生科技同步上涨,这轮全球风险偏好才算开始向中国资产扩散。
+
+这一条与第五章的映射逻辑要合起来看:港股阿里等互联网云厂与 A 股算力租赁本就不是一种东西,周一晚美股云厂大涨而周二白天港股阿里表现平平,已经验证过一次;今日若港股与人民币同步走强,才是外部风险偏好真正外溢的信号,而非单纯的映射交易。
+
+(四)黄金:4100 美元的反复与 4110—4120 的确认位
+
+周二黄金最高触及 4100 美元附近,但没有完成突破。油价下跌、美债收益率回落和美元走弱对黄金有利;美股创新高、地缘风险降温又会压制黄金的避险需求。现货黄金周二上涨约 1%,收盘仍在 4100 美元下方。周三更可能先回踩 4050 美元,再看欧美时段是否重新试探 4100 美元。
+
+只有站上 4110 至 4120 美元,才能确认这次不是又一次冲高失败。
+
+(五)今晚数据:ADP 与 ISM 服务业 PMI
+
+今晚还将公布 ADP 就业报告和 ISM 服务业 PMI。若数据偏弱,美债收益率可能跌破 4.60%,黄金会再次冲击 4100 美元,美股也能获得支撑;若数据偏强,市场会重新交易加息,美债收益率可能反弹,黄金和高估值科技股都会承压。另需注意,周五公布的美国非农就业数据仍是本周研判美联储利率路径的核心参照,ADP 只是前哨而非定论。
+
+(六)三个位置,胜过一切叙事
+
+最重要的还是看三个位置:美国原油能否继续留在 80 美元以下;10 年期美债收益率能否守住 4.60% 附近;标普 500 在没有新利好的情况下,能否守住历史新高。这三个位置同时成立,昨夜的定价才算被确认;有任何一个失守,都应把今日 A 股的高开视为需要检验的开盘定价,而非趋势的延续。
+
+三、指数三要素:量能 · 大金融 · 情绪
+
+(一)量能:2 万亿的前置观察点已经兑现
+
+据 8 月 4 日收盘数据,上证指数涨 0.33% 报 3822.28 点,深证成指涨 3.25% 报 13885.71 点,创业板指涨 5.64% 报 3488.97 点、盘中一度涨超 6%,科创 50 涨 4.09%;沪深两市全天成交 2.21 万亿元,较上一交易日放量约 2000 至 2200 亿元(不同统计口径在 2036 亿与 2162 亿之间),全市场超 3600 只个股上涨,内资主力资金净流入约 538 至 585 亿元,两市涨跌中位数 0.97%。
+
+这一组数据正好落在此前反复讲的量能路径模型上:峰值近 4 万亿之后先降至 2.5 至 2.6 万亿,回升至 3 万亿以上,跌破 2.5 万亿后进一步缩至 2 万亿,反复一段时间才有量价齐升。以「首次跌至 2 万亿」作为前置观察点,昨日得到了验证。所以今日的判据不是能不能继续放量,而是缩量缩在什么位置。
+
+若成交维持在 2.1 万亿上方,且分时呈现「开盘缩量、盘中递增」的日内模型,属健康换手,可以继续按修复节奏对待;若冲高后放大至 2.4 万亿以上并回落收阴,则应按高位换手处理,警惕昨日进场资金的短线兑现压力。
+
+(二)大金融:跷跷板供血,而非趋势走坏
+
+据 8 月 4 日盘面,大金融板块震荡回落,银行、保险方向跌幅居前,「工农中建」四大行均跌超 3%。个人判断,这是典型的跷跷板供血形态,是科技主攻时的常规筹码腾挪,而非大金融趋势性走坏。真正需要警惕的是另一种形态:科技与大金融同时走弱,那才是资金整体离场;只要两者中有一头在承接,指数的下限就还在。
+
+(三)情绪:接力链条的量化判据
+
+据短线数据,二板家数 11 个,对应一进二晋级率 18.97%;首板家数由 58 家增加至 118 家,跌停数量归零;高标传智教育晋级 7 连板。周一的短线承接已属理想,昨日可以算是强上加强。
+
+今日的量化标准因此很清楚:按 118 个首板乘以约 15% 的晋级率推演,二板家数需达到 17 至 18 家以上,且首板家数不出现锐减,情绪才算稳住。若晋级率跌破 15% 的同时首板腰斩,即便指数收红,也应视作接力链条断裂,短线难度显著抬升。
+
+另有一点值得记录:昨日又是很经典的开盘缩量、盘中慢慢放量的日内模型,分时明显是被一点一点推上去的,而不是像 7 月 21 日那样大起大落。某种程度上说明市场开始恢复理性——这类分时结构的持续性,通常好于情绪化的一次性冲高。
+
+四、今日核心矛盾:光模块的位置、性质与三个未定口径
+
+(一)位置决定意义
+
+昨日光模块是主升方向。据 8 月 4 日盘后统计,通信指数收盘暴涨超 9%,电子指数大涨超 5%,光芯片、光模块、光电路交换机等概念指数暴涨超 8%,CPO 龙头当日获主力抢筹超 50 亿元。财新亦提到,A 股光通信板块当日受 CPO 量产消息影响大幅上涨,主力资金流入创 2 月以来新高;催化来自英伟达出货新一代 Spectrum-X CPO 交换机、宣告 CPO 进入量产。
+
+昨天是「高位加主力抢筹」,今天是「高位加政策利空」。这两者叠加的杀伤力,远大于同一条消息落在低位时——今天砍的是估值和筹码,不是业绩。
+
+存储是「点」,光模块是「面」。只要点稳得住,面上的调整不会像想象中那么致命;反过来,如果存储也塌,那才是需要整体降预期的信号。参考铠侠业绩低于预期后美股的低开高走,今天同样是对「易中天」与 A 股科技股的一次考验,若走势能强于 7 月 22 日,后续预期可以再抬一档。
+
+(二)龙头的现实风险与现实筹码
+
+风险侧是清楚的。光模块龙头对美国市场依赖度高,美国收入占比超过六成,业务又高度集中在高速光模块,一旦政策落地影响是致命的。更麻烦的是,「只限制新型号」对光模块未必算温和——行业正从 800G 快速升级到 1.6T、3.2T,真正支撑估值的不是旧产品还能卖多久,而是下一代产品还能不能继续进入美国市场。同时龙头的客户与上游都较集中,一边依赖少数美国云厂的大订单,另一边依赖部分海外高速芯片,极端情况下可能同时面临客户减单与上游限制。
+
+但现实筹码同样是实的。高端光模块不是普通标准件,美国厂商虽有技术,短期未必具备足够的产能、良率与大规模交付能力;云厂更换供应商需要重新验证,订单不可能一夜切走。更重要的是,禁得太快,最先被拖慢的可能是美国自己的 AI 数据中心建设——成本上升与交付延迟,都会让微软、谷歌、AWS 争取过渡期或豁免。
+
+据中金观点,现在海外大客户的高速光模块订单中,中国供应商份额平均已达七八成甚至更高,真要一刀切,北美厂商可能几年都填不上缺口;2022 年前后主流厂商开始布局泰国产能,四年后泰国工厂已成海外供应主力,2025 年前后源杰科技、中际旭创等又开始推进美国本土产能与运营布局;政策的补丁还没打完,供应链已经迭代了好几个版本。因此认为,这件事短期交易的是情绪,中期观察的是规则口径,长期比拼的还是产品、良率和交付。
+
+(三)三个尚未明确的口径,决定影响是冲击还是改写
+
+其一,泰国产能能否被豁免。据报道细节,草案并未明确定义何为「新型」,存量型号、已入关库存、光组件零部件是否纳入全部未知;而「豁免非中国供应商」的设计意味着打击面精准指向中国品牌产品,而非所有在中国境内生产的收发器。这一区别直接决定东南亚工厂生产的中国品牌产品是否同样受限。若最终按企业身份而非生产地认定,换到泰国生产也未必能绕开。
+
+其二,已在售的 1.6T 是否被视为存量型号。若被认定为新型号,1.6T 及下一代新品基本丧失美国市场入场资格;若认定为存量,则影响主要落在再下一代产品上,时间换空间的余地大得多。
+
+其三,美国云厂能否拿到过渡期。这决定利空是一次性释放还是分阶段兑现。
+
+综合判断:若这三个条件偏正面,本次更像一次估值冲击;若泰国产能与现有 1.6T 均无法豁免,那才会真正改写光模块龙头未来两三年的增长曲线。个人倾向于认为中长期雷声大雨点小——依据是 2020 年以来同类叙事下中国厂商份额不降反增,以及客户认证、良率、交付构成的时间壁垒;但这不意味着今天可以逆势去接,右侧确认的纪律在利空日尤其要守,不见分时企稳与量价确认不追。
+
+(四)不在射程内的结构
+
+茂莱光学:据公司近期接受机构调研的交流信息,光互联业务布局三类产品——第一类为无线空间激光通讯设备,目前为谷歌供应算力机房互联用的整机产品,未来可应用于空地空间传输场景;第二类为微透镜、棱镜、光学耦合阵列等传统光互联器件,公司自认该类产品技术壁垒较低,仅为短期需求驱动的品类;第三类为基于光波导检测技术的 CPO 光芯片及封装过程检测设备,公司目前已具备光波导光学性能检测能力和个位数纳米级的封装对准精度技术,未来将结合半导体与 AR/VR 领域的技术积累开发该类设备,支撑下一代基于光波导的 CPO 封装量产,目前已与康宁开展合作,第一代产品基于微 V 型槽检测界面开发,第二代基于光波导技术开发。个人判断,其客户结构与产品定位使其不在本次国产光模块潜在利空的射程内,反而在下一代光互联技术推进后受益。
+
+液冷与 Cage 配套:光芯片一侧有海外美国公司扩产与美国自主可控的影响;光模块液冷国内主要做代工,利润大头在上游安费诺,且属人力密集型产业,对美国而言成本过高、看不上也做不来。1.6T 以上服务器升级中,光模块液冷与 Cage 属标配。个人倾向于认为这一条是配套放量逻辑而非管制逻辑,若跟随错杀,可逢低分批左侧关注,明年是业绩放量大年。
+
+五、主线推演:云计算与算力租赁——A 股的「云」到底在哪
+
+(一)美股云厂的基本面:亚马逊二季报
+
+据财联社 7 月 31 日报道,亚马逊二季度业绩、盈利双双跑赢预期,AWS 云业务营收增速创 18 个季度新高,公司上调 2026 全年资本开支指引,并对 AI 长期万亿营收空间给出乐观判断。CEO 贾西表示,即便全年资本开支上调至 2200 亿美元,2026 年算力供给依旧无法完全满足客户全部需求,2027 年同样存在算力缺口,2028 年已有大量算力订单提前锁定。
+
+具体数据上,据公司财报,AWS 单季营收 422.32 亿美元、同比增长 36.7%,为近 18 个季度最快同比增速,高于彭博汇总的 406 亿美元市场预期;公司总营收同比增长 20% 至 2006 亿美元,营业利润增长 43% 至约 275 亿美元;二季度购置物业和设备支出 542.08 亿美元、同比增长约 68%,全年资本开支预期由 2000 亿美元上调至 2200 亿美元,贾西将本次上调的部分原因归于存储芯片价格上涨。合同储备口径,媒体报道在 4690 亿至 4960 亿美元之间(环比新增约千亿美元),新签订单额显著超出微软且远超收入确认额。据原稿,华泰证券王珂认为北美云厂商 AI 资本开支扩张趋势并未改变,本轮板块大幅调整更多源于交易与情绪层面,坚定看好 AI 算力链投资机会。另据花旗观点,云厂商资本开支高增,需求确定性延伸至 2028 年。
+
+需注意的另一面:亚马逊当前自由现金流持续深度转负,市场对 AI 投入的产出确定性、后续折旧对利润的反噬以及资本开支是否见顶仍有分歧。这是多头逻辑中最需要持续跟踪的变量。
+
+(二)压在云上的那座大山,正在被搬开
+
+之前压在云上面的一座大山是:云厂商是 AI 时代出钱修路的人,最后却可能只变成闭源模型的基础设施供应商。云厂投入巨额资本购买 GPU、建设数据中心,但如果绝大部分任务都必须调用少数几家闭源模型,那么模型厂商掌握需求入口和定价权,云厂商承担重资产投入,增量利润却可能被模型层拿走。
+
+现在这个格局开始变化。开源模型越来越强、越来越便宜,虽然最强闭源模型在复杂推理和高价值任务上仍有溢价,但它已经没有必要成为所有任务的默认选择。未来的 AI 调用会从「一个模型解决所有问题」转向分层路由:最难的任务调用最强模型,普通任务调用便宜的小模型或开放模型,稳定的大规模工作负载由云厂商自行部署,并通过自研 ASIC 芯片进一步降低推理成本。
+
+这对云厂商的意义很大。模型之间越可替代,云厂商越能掌握路由权、客户入口和定价权。过去闭源模型是云上游的收费站,未来模型更像云平台可以自由调度的一种计算资源。云厂商既可以出租 GPU,也可以在自有基础设施上托管开放模型、销售 Token,从而减少向外部模型厂商让渡的利润。
+
+(三)云的逻辑变好,对上游硬件却是道难题
+
+一方面,开源模型竞争会刺激更多玩家增加训练投入,Token 降价也可能带来更大的调用量;与此同时,模型压缩、推理优化、路由和自研 ASIC 又会降低单位 Token 所需的通用 GPU。所以硬件需求最终取决于需求弹性:如果 Token 数量和每项任务的计算量增长快于算法及芯片效率提升,总算力需求就会继续扩张;反之,即使 Token 快速增长,资本开支增速也可能见顶。
+
+因此,最完整的多头逻辑不是简单地说「模型便宜利好云,云赚钱利好硬件」,而是:开放模型削弱模型层的垄断租金,云厂商通过路由和垂直整合提高算力变现效率;更低的推理价格进一步创造新需求,需求增长又跑赢效率提升,使数据中心保持高利用率和较高的投入资本回报率,最终推动云厂商继续投资算力。只有这个循环能够持续,云与上游硬件才会形成真正的正反馈。这也是本周美股云厂与半导体轮番上涨的原因。
+
+(四)A 股映射的关键:算力租赁就是 A 股的云计算
+
+至于 A 股对美股的映射,主要体现在大模型的超额收益向云计算转移的逻辑。开放权重模型崛起,云计算受让更多产业议价权,当前国内外云计算产业的数据与叙事强势,是 AI 板块阻力最小的方向。
+
+但必须讲清楚一点:美股的云厂商、港股的阿里巴巴、A 股的「云计算」,根本不是一种东西。周一晚上美股云厂大涨,隔日周二白天港股阿里表现平平没怎么涨,反而是周一早盘圈内提示的算力租赁板块接近集体涨停。
+
+原因很直接:国内互联网大厂没有海外高端算力卡,出于管制原因与合规风控,国内头部厂商基本不直接持有受美国管控的硬件;再加上当前国产卡的新建先进制程产能暂未有效释放,以及三张国产卡顶海外卡一张的性价比,国内互联网大厂资本开支很大一部分都投向了算力租赁。
+
+国内互联网大厂配套的 AIDC、云服务,其实反而受制于上游高端海外卡的合规获取与高昂成本,这和谷歌、亚马逊、微软最直接且最低成本获取海外高端卡的模式是有区别的。国内云厂普遍需要通过算力租赁这层「白手套」来获取海外高端算力,算力租赁是国内高端云资源的唯一来源,所以当前在产业链占据有利位置——算力租赁就是 A 股的云计算,尤其是正在不断落地的 Token 分成模式。
+
+(五)再推一层:算租的地位升级与估值修复空间
+
+云业务地位升级、模型开源与 Token 价格降低,将促进下游应用对云的需求,进一步拉动云业务的增长和盈利能力。同时模型开源、云厂自己部署大模型,云业务在 AI 产业链里的地位也将提升,分得更多利润。算力租赁将同步受益:一方面,算力租赁作为云业务的支撑和补充,需求将进一步提升;另一方面,随着 Token 分成商业模式落地以及模型开源,部分算力租赁公司也将晋升为「新云厂」,行业地位提升。
+
+在中美地缘政治与技术出口管制下,掌握海外高端算力资源的算力租赁厂商地位将进一步提升,成为各家模型厂商想要提升模型能力的必备战略资源。真正能拿到卡的算租厂商或 Token 分成厂商,其稀缺性、话语权与普通模组厂、组装厂完全不是一回事,独立大模型公司已逐步成为云厂之外算租的主要需求方,盈利和估值均存在超预期以及修复空间。
+
+(六)四月给出的行业格局判断,维持
+
+强者恒强,弱者消失。以下为产业链跟踪记录,非推荐:
+
+杭州 DS 模型:对应利通电子。
+
+北京 Kimi 模型:对应行云科技。
+
+北京 ZP 模型:对应宏景科技。
+
+北京 ZJ 云:对应润建股份、赛意信息。
+
+杭州 AL 云:对应协创数据、宏景科技。
+
+盘面验证上,据 8 月 4 日收盘,算力租赁概念延续强势,美利云 3 连板,利通电子、宏景科技走出 3 天 2 板,高乐股份、行云科技涨停;科创板方面青云科技 20% 涨停。原稿提及的板块内公司还包括美利云、青云科技等。
+
+今日的关键在于:算力租赁能否在光模块承压时接过牵引棒。若成立,说明资金是在 AI 链内部做结构再分配而非撤退,反弹结构完好;若算租也跟随退潮、龙头炸板,那就要按整条 AI 链的情绪高点处理。
+
+六、存储:本轮海内外共振的那个「点」
+
+据财联社 8 月 5 日电,三星电子在美国加州圣克拉拉举行的未来存储与内存大会(FMS)上推出其 V10 Bonding V-NAND(BV-NAND)原型产品,并展示了其称为业界首款的 zHBM 和 zNAND-O 架构概念模型。该技术通过垂直堆叠存储单元,在更小空间内存储更多数据,其性能约为下一代 HBM5 的八倍。三星表示,其晶圆键合技术有望实现超过传统 HBM5 内存 10 倍的存储密度,同时将能源效率提高 3 倍,并将热阻降低一半以上。不同于传统 HBM 与 AI 处理器并列封装的方式,三星的 zHBM 会将存储垂直堆叠于 AI 加速器之上,以缩短数据传输距离、提高带宽并提升能源效率。
+
+同日另有两条产业信息:据媒体报道,SK 海力士正式官宣联合闪迪共同发布下一代高带宽闪存 HBF 的行业首套标准规范,明确 8 层、16 层两种 NAND 堆叠架构,最高支持 512GB 容量,带宽划分三个等级、覆盖 0.4TB/s 至 3.0TB/s;业内消息显示三大原厂已完成 2027 全年产能分配谈判,DRAM 与 HBM 产能被提前预订一空,NAND Flash 产能也将在 2026 年 8 月底前完成全部锁单。
+
+据金元证券电子团队观点,从制造工艺而言,3D 结构的 NAND 存储工艺难点在于高深宽比的刻蚀工艺、薄膜沉积及未来可能成为主流的混合键合工艺;对于半导体产业链而言,AI-NAND 的发展意味着低温刻蚀、混合键合、薄膜沉积、CMP 技术的设备商或将迎来 NAND 推动下的景气周期,由于工艺复杂度提升,设备在晶圆成本中的占比将持续上升。
+
+拓荆科技:据公司信息,作为薄膜沉积设备行业的领头羊,其 PECVD 产品占据核心收入来源,ALD 和三维集成设备也在迅速发展;此前计划通过发行股份及支付现金方式收购无锡尚积 100% 股份,从而补充其 PVD 产品线并增强三维集成设备能力。
+
+微导纳米:据公司信息,高温 PECVD、TiN ALD 等多款设备已实现规模量产,其核心工艺技术对推动存储芯片技术迭代、尤其是 3D DRAM 与 3D NAND 技术的研发与产业化具有关键支撑作用。
+
+结合此前的框架:半导体设备的订单高增是确定的,剩下的只需要算估值;而存储虽 PE 最低,周期性也最大、股价有提前量,个人仍选择观望,只把它作为科技板块与指数的牵引变量来跟踪,而非直接参与对象。
+
+七、AI 应用与其余跟踪线
+
+(一)AI 应用:收费落地是比榜单更硬的证据
+
+据阿里云公告,容器服务 Agent 将增加新的技能中心和 IM 频道等功能,并于北京时间 9 月 3 日 10 时开启商业化收费。本次调整后,相关对话、自主智能运维及定时任务等功能将开始收费,调整过程不会影响存量用户已有设置和历史任务记录。容器服务 Agent 以积分(Credit)作为计量单位,免费开通,支持按量付费及预付费资源包抵扣,使用相关功能时产生的模型资源消耗会折算为积分计费。
+
+据中银国际证券苏凌瑶观点,Token 调用量持续增长,AI 应用需求仍在释放;当前 AI 产业正由模型参数、榜单排名等技术能力竞争,逐步转向真实调用量、工程化交付、算力适配和商业场景落地的综合竞争阶段。随着世界模型、长上下文、Agent 应用及国产算力生态持续推进,AI 产业链正在进入应用验证和商业化加速阶段,建议关注大模型企业和相关 Agent 企业。
+
+深信服:据公司信息,重磅发布系列生产级 AI 新品,覆盖企业 Agent「构建—承载—保护」全生命周期,在 Agent 构建层面针对不同岗位角色量身打造生产级 Agent 构建工具,降低 AI 应用开发门槛。
+
+普联软件:据公司信息,研发的智能体开发平台支持知识问答、数据问答、文档解析等通用智能能力,以及基于该平台研发的智能财务共享、智能司库、智能设备运维、智能投资资产管理等 AI Agent 企业级应用,支持接入 DeepSeek、通义千问等大模型。(原稿作「普联软性」,应为普联软件,已更正。)
+
+昨日 AI 应用的表现值得单独留意:按软硬件跷跷板的常规节奏,硬件高开时应用本该走弱,但资金呈现两头下注——早盘云办公方向不及预期后,午后转而发动 AI 视频等细分方向。这种「哪边都不敢空」的形态通常持续性不强,后续关键仍在于有没有针对某一细分方向的新催化。
+
+(二)美股映射线
+
+卡特彼勒链:据公司二季报,营收首次突破 200 亿美元,利润大幅超预期并上调全年业绩指引;北美地区由于电力短缺、数据中心建设增加,推动燃气轮机等发电设备需求上升。A 股相关公司包括潍柴动力、天润工业等。
+
+Palantir 链:据公司二季报,业绩全面超越市场预期并上调全年业绩指引,AI 商业化落地速度超出投资者预期,推动股价创近年来最大单日涨幅之一;AI 赋能军工、引领现代战争的战略重心转移。A 股相关公司包括能科科技、观想科技等。
+
+算力金属链:云南锗业、光智科技为代表的磷化铟方向跟风美股 AXT。
+
+关于映射本身,需要一句冷静话:昨日云计算这个新题材的驱动来自美股亚马逊的大涨,磷化铟跟的是 AXT——如果说指数可以跟美股,为何个股与题材不可以?但这本身就是位置信息。这类「找锚」行为通常出现在市场刚回暖、资金缺乏信心的阶段;日均 3 万亿成交额的市场里,没多少人会在意隔夜美股的涨跌,现在会,这说明信心还没修复完。
+
+(三)地缘背景:为什么是现在
+
+据公开报道,中美双方高层官员近期刚进行视频通话,美方重点追问中国对稀土、农产品等承诺的落实情况,中方则对前一段时间美国实施的经贸限制措施提出严正交涉;中美主管外交的高级官员分别在马尼拉和华盛顿会面,双方同时应在为九月可能举行的领导人会面做准备。在视频通话一天后,FCC 发布对中国产机器人和逆变器的禁令;四天后,媒体又传出光模块禁令的消息。另据公开信息,4 月敲定的贸易理事会和投资理事会进展目前尚无消息。
+
+从技术角度看,光模块包含 DSP、控制芯片、EEPROM、固件和管理接口,理论上确实可以制造中断、误码或供应链植入风险,但要实现消息人士所说的「大规模窃取数据、安装恶意软件」,技术链条远比主动网络设备复杂;目前也没有看到 FCC 拿出中国光模块实际参与攻击的公开证据。所以从国家安全角度,实际出台这个限制的必要性要比联网汽车、机器人等小。
+
+既然必要性没有这么紧迫,美方为什么这么着急放出这个消息?个人判断,光模块禁令有可能是美方打出来给中方施加压力的一个筹码——既然是筹码,就还有谈的可能,关键在于双方做什么交换、能不能达成交换。毕竟 9 月份还有元首会晤,特朗普政府在中期选举前需要稳定中美关系。目前负责打出这些牌的主要是 FCC,后面还有哪些牌,暂不可知。
+
+从长周期看,另一种更审慎的解读同样需要摆在桌面上:数据传输器件作为敏感组件,美方将中国产品最大程度剔除供应链,可能并非「是或否」的选择题,而是时间问题。光模块承担 AI 算力网络的数据流转,已被纳入美国供应链安全的审查框架,管制的大方向或已确立,只是执行节奏与约束范围仍在博弈调整。短期出于供给缺口的现实压力会保留过渡期、逐步收紧门槛;拉长周期看,对华高速光模块的限制具备一定必然性。行业需要认清:短期的供需博弈可以争取缓冲窗口,但不宜把海外产能当作安全避风港——未来管制规则完全可以穿透原产地标签,追溯企业主体与上游供应链。
+
+八、今日操作框架与观察清单
+
+一句话概括:指数看存储能不能守住那个「点」,结构看算力租赁能不能接住棒,情绪看一进二能不能过 17 家。
+
+情形一,三者皆成立。则光模块的杀跌属于内生性估值消化叠加外力扰动,反弹节奏不变,可按「科技内部结构再分配」处理,重点跟踪算租、半导体设备、存储三条线的资金承接质量。
+
+情形二,存储稳、算租弱、情绪弱。则说明只是单纯的题材切换失败,而非趋势破坏,宜降低仓位周转频率,等待针对某一细分方向的新催化出现。
+
+情形三,存储也破位。则昨日 2.21 万亿的放量需要重新定性为情绪释放而非趋势起点,此时不应再用「反弹中继」的框架应对,应回到观望与右侧确认。
+
+贯穿始终的两条纪律不变:位置决定意义——同一条消息落在高位和低位,性质完全不同;右侧确认——不见量价确认不追,利空日尤其如此。
+
+本周待跟踪的外部变量
+
+其一,今晚 ADP 就业报告与 ISM 服务业 PMI,以及周五的美国非农就业数据,构成本周研判美联储利率路径的核心参照;其二,霍尔木兹海峡相关协议能否落地,直接影响油价与通胀预期这条证伪链;其三,SpaceX 首份财报已披露,后续跟踪转向其解禁节奏——据公开信息,首份财报公布后投资人可出售符合禁售条件持股的 20%、最多约 9.115 亿股,市场估算对应规模可达千亿美元级别,为商业航天与美股情绪的共同扰动源;其四,FCC 光模块措施的正式口径与公布时点。
+
+并把第二章的三个位置抄到今日的看盘表最上方:美国原油留在 80 美元以下、10 年期美债收益率守住 4.60% 附近、标普 500 在无新利好下守住历史新高。这三个位置决定了外部环境是继续供血,还是从今晚开始回收。
+
+风险提示与免责声明:本文所载内容系个人研究记录与公开信息整理,仅供交流参考,不构成任何证券买卖要约、投资建议或收益承诺。市场有风险,投资需谨慎。
+
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-095345_text_\346\216\247\345\210\266\345\245\275\344\273\223\344\275\215_1232897957496356868.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-095345_text_\346\216\247\345\210\266\345\245\275\344\273\223\344\275\215_1232897957496356868.txt"
new file mode 100644
index 0000000..a9db0ae
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-095345_text_\346\216\247\345\210\266\345\245\275\344\273\223\344\275\215_1232897957496356868.txt"
@@ -0,0 +1,15 @@
+标题:控制好仓位
+
+博主:青枫浦上Q
+
+发布时间:2026-08-05 09:53:45 +08:00
+
+来源:https://www.bilibili.com/opus/1232897957496356868
+
+内容类型:纯文字动态
+
+
+
+虽然今天表现不错,但是不能追高,做了加仓的动作的今天要找机会T出去,我认为明天不会太乐观,再叠加变盘前夕仓位不应太重。
+
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-114706_text_\350\247\201\345\276\256\347\237\245\350\221\227_1232927167618220050.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-114706_text_\350\247\201\345\276\256\347\237\245\350\221\227_1232927167618220050.txt"
new file mode 100644
index 0000000..7adec4d
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-114706_text_\350\247\201\345\276\256\347\237\245\350\221\227_1232927167618220050.txt"
@@ -0,0 +1,15 @@
+标题:见微知著
+
+博主:青枫浦上Q
+
+发布时间:2026-08-05 11:47:06 +08:00
+
+来源:https://www.bilibili.com/opus/1232927167618220050
+
+内容类型:纯文字动态
+
+
+
+早盘半设涨6个点,同时形成5分钟顶部结构。但是承接很好很快就拉起来了,说明市场整体很有韧性,也慢慢有右侧的感觉。但是今晚要公布ADP就业报告和ISM服务业PMI,如果数据很强,再叠加AMD这些股票的大跌,科技股会出现明显的分化,7号还要出非农数据,上证也没彻底站上右侧趋势,所以今天没有任何开仓的理由,最多做一个正T,盘中可以满仓吃涨,但是加仓的部分是要T出去的。整体科技仓位保留在5成左右。还是昨天那句话,做交易要吃的下饭,睡得着觉,7月份的打不能白挨,B浪反弹之后还有C浪下跌。
+
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-230034_article_8.5\345\244\215\347\233\230_1233100718643085351.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-230034_article_8.5\345\244\215\347\233\230_1233100718643085351.txt"
new file mode 100644
index 0000000..35c4a04
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260805-230034_article_8.5\345\244\215\347\233\230_1233100718643085351.txt"
@@ -0,0 +1,49 @@
+标题:8.5(复盘)
+
+博主:青枫浦上Q
+
+发布时间:2026-08-05 23:00:34 +08:00
+
+来源:https://www.bilibili.com/opus/1233100718643085351
+
+内容类型:文章
+
+
+
+今日指数低开后全天震荡走高,反弹趋势进一步确认,两市成交额突破 2.5 万亿元,较上一交易日继续放大。本轮反弹从缩量启动走向温和放量,节奏是健康的;真正需要提防的不是回踩,而是量能一旦短期冲到 3 万亿以上后的情绪过热与分歧。
+
+一、指数定环境:利空之下的逆势走强,做多共识基本凝聚
+据当日盘面,指数低开后全天震荡走高,短线情绪与赛道行情形成共振,连板高度上传智教育突破了此前的连板空间瓶颈。个人判断,今日这根低开高走的阳线,意义不在涨幅本身,而在于它是在利空环境下走出来的——位置决定意义,能在压力中逆势收强,说明承接盘的力量已经从被动接货切换为主动出价。
+从右侧确认的角度看,缩量启动、温和放量、利空验证这三步已经依次走完,本轮反弹的强度与持续性都值得高看一线。但也要留一分冷静:尾盘已有部分资金按捺不住率先兑现,泛科技赛道连续两日普涨且高潮明显,明日出现技术性小幅回踩是大概率的。倾向于认为这属于内生性回调(过热与轮动),而非外力扰动,因此回踩幅度相对有限,不改趋势。
+
+二、板块定方向:AI 应用是因,AI 硬件是果
+科技主攻 · AI Agent 与 AI 软件应用
+个人判断,这次反弹最核心的一条主线,是以 AI Agent 为牵引的 AI 软件及应用。催化来自 Palantir——由多智能体组成、能安全可控地完成实际工作的“数字员工团队”已经全面落地,把 Agent 从演示叙事推进到交付叙事。顺着这条线,今日自动驾驶的爆发并带动物理 AI 方向走强,逻辑上是同一条产业传导链:感知—决策—执行的闭环能力外溢到实体场景,所以中午的复盘会才会认为午后机器人存在异动可能。这一段行情的持续性是有产业逻辑支撑的,而不只是资金轮动。
+据工业和信息化部及新华社 8 月 4 日消息,《智能网联汽车 自动驾驶系统安全要求》(GB 44721—2026)强制性国家标准已于 2026 年 7 月 30 日获批发布,适用于搭载 L3、L4 级系统的 M 类、N 类车辆,拟自 2027 年 7 月 1 日起实施;另据 8 月 6 日《上海证券报》,中国汽车工业协会在第 16 届中国汽车论坛上正式启动成立“自动驾驶汽车产业发展联席会”。个人判断,强制性国标落地的意义在于把准入基线明确下来,这正是 Robotaxi 从试点走向规模化必须先解决的前置条件。
+
+硬件与半导体:是果,不是因
+AI 硬件与半导体芯片今日的走强,个人判断是结果而非原因——只有 AI 产业侧的瓶颈被突破,Token 消耗才会进一步增长,硬件需求才有扩大的理由。据 8 月 6 日《证券时报》,前一交易日电子化学品、MLCC 概念、贵金属、小金属、存储芯片板块涨幅居前;另据同日《中证快报》,主力资金净流入居前的板块为半导体、消费电子与元件。这提示资金在硬件端的选择仍是“跟随应用叙事”,而非独立起爆。
+
+上游涨价:存储与显卡的供给端故事仍在延续
+据 8 月 6 日《证券时报》援引媒体报道,三星电子、SK 海力士与美光已提前完成 2027 年一般 DRAM 与 HBM 的分配谈判,产能售罄,业内估算三大原厂仅能满足下游 60%~70% 的需求;同期日本分销商 CFD Sales 公告技嘉显卡自 8 月 1 日起在日本市场涨价 20%~40%。此外,据同日《上海证券报》,SpaceX 在其上市后首份财报中宣布与英伟达合作开发 Starmind 卫星计算载荷,太空算力成为新增变量。个人判断,涨价链的位置比涨价本身重要:显存与存储的紧张已被市场交易多轮,后续更值得跟踪的是紧张向消费级终端的传导,以及它对硬件板块估值的支撑能否维持。
+
+防御补充:医药作为过渡
+据 8 月 6 日三大报,百济神州 2026 年半年度营业总收入 222.20 亿元、同比增长 26.8%,归母净利润 32.71 亿元、同比增长 627.1%,并将全年营收预测上调至 449 亿至 462 亿元;渤海证券认为医药生物板块基金仓位仍低,具备修复空间。个人判断,明日若泛科技出现技术性回踩,医药与防御性板块是合适的短暂过渡方向,且医药这一段的持续性可能会比单纯的避险轮动更好一些。
+
+三、个股定时点
+以下为盘面与产业链跟踪记录,只做事实陈述与判断,不构成任何操作建议。
+大晟文化:据公司公开信息,其自 2024 年起全面发力短剧业务,已建立覆盖国内外市场的独立制作、发行与运营体系;2025 年短剧业务收入 1.31 亿元、同比增长 2057.75%,毛利率 75.38%,带动整体营收同比增长 99.86%。据 DataEye 研究院预估(转引自 8 月 6 日《上海证券报》),国内 AI 漫剧全年市场规模将达 400 亿元;原始材料中“2026 年微短剧、AI 短剧、AI 漫剧合计突破 1210 亿元”一项未见公开出处,暂标注待核实。个人判断,短剧业务从高增长迈向精品化的过程中,平台绑定与出海是主要变量,而近期摘帽本身是明显的加分项。
+星环科技:个人判断,Agent 的智能程度高度依赖调用多模态实时数据并形成持续进化闭环的能力,这一架构需求使传统 CPU 驱动的数据库难以胜任,转而需要能在同一平台统一处理向量、图、时序等多种数据模型、并原生部署于 GPU 之上的新一代认知数据库。据公司公开材料,其认知数据库基于英伟达 CUDA 深度重构后,向量检索与分析性能分别实现约 20 至 200 倍及约 20 倍的提升(性能倍数为公司口径,待核实),公司亦称其为全球首个完成 TPC-DS 测试并通过官方审计的数据库企业、首家进入 Gartner 数据仓库魔力象限的中国公司。战略层面正从“以数据为中心”向“以 Agent 为中心”转型,是 A 股较为稀缺的 AI 原生基础设施标的。
+大众交通:据公司公告与公开报道,公司持有上海核心出租车运营牌照与车队管理经验,旗下上海数讯为上海领先的数据中心服务商;2025 年 7 月获上海首批智能网联汽车示范运营牌照,已与百度萝卜快跑在浦东开展 5 辆主驾无人 Robotaxi 示范运行、在嘉定运营 38 辆主驾有人车辆;8 月 3 日与曹操出行签署战略合作协议,分阶段推进上海及长三角区域 Robotaxi 规模化运营。个人判断,其价值在于技术平台绕不开的线下运力与牌照壁垒,属于从“传统出行运营商”向“自动驾驶时代运力服务商”的身份切换,估值重估的节奏取决于示范运营向商业运营的推进速度。
+交换机方向:据媒体报道,Arista Networks 二季度业绩全面超出预期,单季收入首次突破 30 亿美元,全年收入指引上调至 126 亿美元,盘前一度大涨 11.7%;Dell'Oro Group 预计 2026 年以太网交换机市场规模在 435 亿至 450 亿美元(原稿写作 DelrOro,已更正)。开源证券认为 Token 正从 AI 底层技术单元上升为数字经济的核心价值载体,看好国产 AI 链从网络端、计算端到 AIDC 的全产业链机会。公司方面,锐捷网络已是字节跳动、阿里、腾讯等头部互联网企业的数据中心交换机核心供应商,以太网交换机国内份额居前三;盛科通信的以太网交换芯片产品线涵盖 12.8Tbps 与 25.6Tbps 高端芯片及 2.4Tbps、1.2Tbps 系列。
+创新药方向:据 8 月 6 日三大报,百济神州上调 2026 年营收预测,主因百悦泽在美国市场的领先地位及欧洲与全球市场的持续扩张。公司方面,药康生物核心业务为商品化小鼠模型销售与模型定制服务,下游覆盖高校、科研机构、医院及创新药企与临床前 CRO;泰诺麦博依托全人源单克隆抗体研发平台,形成覆盖感染性疾病、疼痛等领域的管线布局。个人判断,半年报窗口内业绩催化与临床数据读出是这条线的主要跟踪点。
+
+四、明日跟踪与条件化情景
+对于明日,不给单一结论,按两种情形处理:
+情形 A:指数小幅低开或高开后回踩,成交额维持在 2.3 万亿至 2.6 万亿区间,AI 应用与自动驾驶方向回踩不破今日实体,涨停家数不明显萎缩——则视为强势整理,反弹趋势延续,回踩即是节奏问题而非方向问题。
+情形 B:成交额快速冲上 3 万亿,同时高标炸板、赛道股冲高回落、连板梯队断层——则按情绪过热后的分歧对待,先降低参与节奏,等待第二轮量能收敛后的确认。
+跟踪变量:其一,接力强度,用今日首板家数乘以约 15% 的晋级率推演明日二板家数,并观察首板家数是否锐减;其二,传智教育的连板空间能否继续打开,这是短线情绪的直接温度计;其三,存储链的海外映射能否稳住,存储仍是牵一发而动全身的那个“点”;其四,医药能否走出独立于避险的持续性,这决定回踩期间的资金去处是过渡还是撤退。
+
+
+
+风险提示与免责声明:本文为个人研究记录与盘面复盘,所涉行情数据、公司信息均来自公开渠道或当日盘面观察,不保证其准确性与完整性。市场有风险,投资须谨慎。
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-090105_article_8.6\346\227\251\347\233\230_1233255470622310421.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-090105_article_8.6\346\227\251\347\233\230_1233255470622310421.txt"
new file mode 100644
index 0000000..6bf0352
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-090105_article_8.6\346\227\251\347\233\230_1233255470622310421.txt"
@@ -0,0 +1,90 @@
+标题:8.6(早盘)
+
+博主:青枫浦上Q
+
+发布时间:2026-08-06 09:01:05 +08:00
+
+来源:https://www.bilibili.com/opus/1233255470622310421
+
+内容类型:文章
+
+
+
+周三市场震荡反弹,三大指数低开高走,科创50指数上涨4.78%;盘面上智能驾驶概念开盘爆发,贵金属活跃,AI应用反复走强,半导体产业链迎来反弹,油气走势偏弱。盘前提示的只要存储稳住,即使光模块调整,影响也不会有想象中那么大,收盘走势基本印证。但真正的考验,大概率不在昨天,而在今天。
+
+一、指数定环境:量能、承接与情绪的三重错位
+量能:2万亿究竟是不是地量
+据交易所数据,周三两市成交额2.66万亿元,较上一交易日放量4460亿元。放在周二2万亿的对比之下,市场很自然会把2万亿认定为本轮的地量位置。
+个人判断,整数位看似牢不可破,恰恰也最具迷惑性。A股最具代表性的便是曾经的3000点——之前有过多少次保卫战,就意味着被击穿过多少次。参考去年10月中旬与今年2月中旬的两次缩量过程,若2万亿最终仍会被击穿,那么这两天涌入的增量资金,后续很可能反过来成为砸盘主力。
+承接:增量资金来自哪里
+据盘面成交数据,中际旭创单日成交额超675亿元,不仅刷新此前597.7亿元的纪录,较周二的497.9亿元亦放量177.1亿元,增幅接近35%。可见增量的大头仍然来自科技股,而非全市场的均匀扩散。
+个人判断,这意味着量能扩张与筹码集中是同一件事的两面:增量越集中于少数高位品种,一旦这些品种滞涨,全市场的承接力就越脆弱。因此无论从量能位置还是资金结构出发,这两天的市场承接都还将面临一次正面考验。
+情绪:高涨之下的一丝隐忧
+据情绪数据,周三二板家数27个,为3月3日以来新高,对应一进二晋级率由18.97%提升至22.88%;首板家数则由118家减少至70家,跌停仅1家。
+按以往首板家数×约15%晋级率的推演口径,70家首板对应今日二板的健康区间约在10家上下,较昨日27家的水位有明显收缩。这组数据的含义是情绪高涨与接力面变窄同时发生:高度在抬升,宽度在收敛。
+与二板数量新高相对应的,是高标传智教育出现筹码松动。这固然可以用八连板的高度来解释,但该股一路以来的风格都是快速一致,尤其考虑到昨天还是指数上涨的背景,午后的分歧仍显反常,这也为之后资金的高低切埋下了伏笔。
+
+二、板块定方向:反制成为当日最强叙事
+(一)新题材:数量偏多,但方向指向一致
+周三的新题材比往常要多:有新消息催化的无人驾驶,有借三星存储消息老树开新花的存储与半导体产业链,还包括磷化铟这种先跟风美股、随后逐步发酵成反制方向的品种。
+除无人驾驶外,其余两个新题材都符合昨天提到的跟风美股逻辑,可见这条线索的有效性依然还在。而由于叠加了新的叙事,磷化铟脱颖而出的可能性较大;顺着这个思路,也可以留意其他的反制方向。
+
+(二)反制方向:一次性落地的五项措施
+据商务部及市场监管总局8月5日公告,针对美国联邦通信委员会近期密集出台的涉华措施,以及美国土安全部7月31日将四十余家中国实体列入相关清单的做法,中方当日公布五项反制措施:
+其一,无人机出口管制:据商务部公告2026年第34号,对列入《两用物项出口管制清单》的无人机及其关键零部件、相关技术对美出口逐案从严审核,不适用许可便利措施,自公布之日起实施。
+其二,打印复印设备安全调查:据商务部公告2026年第33号,自8月5日起对装有外国系统软件、具有打印复印功能的进口办公设备发起对外贸易国家安全调查;据商务部发言人答记者问,此为中国首例对外贸易国家安全调查。
+其三至其五:暂停中国强制性产品认证(CCC)指定机构委托在美认证机构实施的获证后工厂跟踪检查;将美国合规性测试公司(Compliance Testing LLC)列入反制清单;将应用DNA科学公司等6家美国实体列入反制清单。
+个人判断,这一组措施对市场的意义不在单项力度,而在覆盖面——无人机、认证检测、办公设备、生物技术同时被点到,等于给「反制」这条叙事提供了可以持续外扩的题材边界。在没有新增产业催化的窗口里,资金选择叙事最完整的方向,这就是昨天高低切的落点所在。
+(三)AI硬件与半导体:见底不等于反弹
+积极的一面是新易盛、中际旭创、天孚通信这一组核心品种扛住了抛压,因此8月科技股见底的可能性进一步增强。
+但科技股不仅要面对来自内部的出清,还要面对外部产业政策上的风险,某种程度上二者相辅相成——外部风险会让内部资金出清得更为坚决。所以见底也不意味着能立刻反弹,止跌来自有承接,而不是来自无抛压。
+(四)AI应用:跷跷板没有兑现,但资金介入较深
+此前预期AI应用能与硬件互为跷跷板,实际资金似乎更倾向于反制方向,这也能解释为何传智教育下午会开板。不过像普联软件、米奥会展等核心股的走势并不弱,说明资金介入比较深。
+个人判断,后续这一方向还是会不断反复,关键仍要看有没有针对某个细分方向的新催化出现,而不是整体性的beta修复。
+
+三、磷化铟:从跟风美股到反制叙事的合流
+据公开信息,磷化铟衬底公司AXT于周二晚间上涨超13%,上周五上涨28.74%,三天累计涨幅超85%。其基本面锚点在于:据公司2026年二季度业绩,营收4760万美元,同比增长约164%、环比增长约77%,调整后每股收益0.19美元,磷化铟单季收入创历史新高;同时公司与Lumentum签署延续至2031年底的产能预留协议,对方分两笔各支付4350万美元、合计8700万美元保证金以锁定优先供货权。
+据财通证券基础化工团队观点,磷化铟作为芯片衬底材料,因AI算力需求爆发,市场规模将持续扩大;但扩产受限于技术、设备和资源等多重壁垒,已实现产能布局的企业具有明显先发优势。铟资源稀缺且受政策限制,产品价格呈上升趋势;高纯红磷提纯技术壁垒高,全球供应高度集中。
+补充一条关键的政策背景:据2026年2月4日商务部、海关总署联合公告,中方已对钨、碲、铋、钼、铟相关物项实施出口管制。这意味着磷化铟同时挂在跟风美股与资源反制两条叙事上,这是它比其他新题材更容易走出高度的原因。
+
+四、钨:
+据公开报道,美国将于本月下旬起禁止出口钨废料和回收电池材料(俗称黑粉),相关物项被强制要求在美国本土销售,企业仍可申请豁免,禁令执行期为一年;此前一周美方已正式授权政府部门限制含关键矿物与材料的工业废料出口。据同一报道,美国自2015年以来未再进行商业钨矿开采,全球钨供应仍由中国主导,钨废料因此成为其国内为数不多的钨来源之一。
+
+
+五、今日重点:两种情形与观察窗口
+据昨日盘面,量能、结构与情绪同时处在需要验证的位置,因此今日不宜给单一方向的确定性结论,而应以条件化情景应对:
+情形A(承接成立):开盘后成交额斜率不低于昨日同期,全天维持在2.4万亿以上,且高位品种没有出现集体炸板。此时高低切在板块内部完成,反制方向承接昨日的接力,AI硬件以震荡消化为主,则2万亿的地量假设可以暂时搁置,节奏仍属健康的温和放量。
+情形B(承接失败):成交额快速回落至2.2万亿下方,同时中际旭创等放量高位股冲高回落,传智教育低开或再度开板。此时增量资金转为砸盘力量,路径大概率是先杀高标、再杀补涨,2万亿是否为地量这个命题,将被重新计价。
+三个具体的观察窗口:一是早盘半小时的量能斜率,这决定后续是补量还是缩量;二是传智教育的开盘表现,它是昨日分歧能否修复的直接读数;三是大金融是否出手护盘,这决定指数在题材退潮时的缓冲厚度。
+
+六、产业链跟踪
+以下为盘面事实与公开信息的跟踪记录,仅作产业链梳理,不构成推荐。
+钨与硬质合金
+江钨装备(600397):据公司信息,收购江西江钨硬质合金、赣州华茂钨材料、九江有色金属冶炼各100%股权,注入钨制品及钨深加工领域资产。
+中钨高新(000657):据公司业绩预告,预计2026年上半年归母净利润19.70亿元至21.70亿元,同比增长261%—298%,主因钨精矿均价同比上涨、PCB微钻及高端刀具订单增长。
+章源钨业(002378):国内具备完整钨产业链生产能力的企业之一,钨粉为国家级制造业单项冠军。
+厦门钨业(600549):拥有前端钨矿采选、中端冶炼及后端硬质合金、钨丝材与切削工具的完整产业链。
+翔鹭钨业(002842):据2026年7月29日募集说明书,公司拥有完备的光伏用超细钨丝制备技术。
+新金路(000510):据公司信息,栗木矿涉及锡、钨、铌钽,一期采矿、选矿、冶炼项目2026年开工。
+中船特气(688146):据公司信息,当前六氟化钨年产能2000吨,产能利用率处于较高水平。
+无人机与镁合金
+中无人机(688297):中航工业旗下唯一无人机上市平台,大型固定翼长航时无人机国家级制造业单项冠军,主要产品为翼龙系列及无人机技术服务。
+海格通信:据公司信息,九天无人机由陕西无人装备科技委托、公司控股子公司西安驰达飞机承制,为可通过模块化任务载荷换装适配军民两用多元场景的重型无人机。
+宝武镁业(002182):国内规模领先的镁合金生产商,据公司信息生产无人机前舱框架,相关年订单额约6000多万元。
+兴民智通(002355):据公司信息,通过控股子公司九五智驾收购无人机制造企业珠海市双捷科技。
+星源卓镁(301398):从事镁合金、铝合金精密压铸件及配套压铸模具的研发、生产和销售。
+产业背景方面,据「十五五」规划纲要,推进新域新质作战力量规模化、实战化、体系化发展,加快无人智能作战力量及反制能力建设;据华泰证券航空航天团队观点,美国2027财年国防预算中「无人机优势」相关投入预计超740亿美元,较上一年度大幅增加,叠加农业无人机市场发展迅速,看好无人智能成为装备发展的重要方向。
+磷化铟与铟资源
+云南锗业(002428):据公司公告,控股子公司签订磷化铟晶片重大合同,总金额为5.7008亿元至8.5512亿元(含税)。
+金圆股份(000546):据公司信息,参股子公司林西县富强金属具备高品位铟提取能力,主要通过从锌冶炼烟灰、工业废料中萃取再生铟。
+豫光金铅(600531):据公司信息,已成功制备7N级高纯铟等高纯金属产品。
+宿迁联盛(603065):据公司公告,拟与相关方共同设立江苏光铟科技,投资建设磷化铟衬底项目。
+光智科技(300489):据公司信息,增资先锐科技布局磷化铟资产,依托先导集团在全产业链的积累。
+海川智能(300720):据公司公告,拟收购集溢半导体部分股权,后者全资子公司具备磷化铟晶片相关产能。
+其他跟踪
+打印复印方向 · 南天信息:据公司信息,开发PR9plus系列及PR9C双色打印机,采用自主设计、国内生产的主控SoC芯片并拥有知识产权,该系列2019年已批量生产销售。
+贵金属:据盘面,贵金属板块普涨,受益于国际金价大幅反弹;地缘风险缓解与油价回落降低通胀压力,进一步提升市场对货币宽松的押注,中长期驱动仍在AI相关需求增长、供应受限与关税政策扰动三方面。
+电商AI映射 · Shopify:据公司二季度业绩,整体超预期,公司表示AI工具正在帮助商家提升效率与获取客户,AI助手Sidekick用户活跃度同比明显增长。A股相关映射包括返利科技、吉宏股份等。
+
+
+风险提示与免责声明:本文所载信息与数据均来源于公开资料及本人盘面观察,不保证其准确性与完整性,亦不构成任何投资建议或买卖要约。市场有风险,投资需谨慎,据此操作风险自担。
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-105340_image_\345\270\202\345\234\272\347\233\256\345\211\215\346\216\245\345\212\233\346\203\205\347\273\252\345\276\210\345\245\275_1233284483106471972.jpg" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-105340_image_\345\270\202\345\234\272\347\233\256\345\211\215\346\216\245\345\212\233\346\203\205\347\273\252\345\276\210\345\245\275_1233284483106471972.jpg"
new file mode 100644
index 0000000..b9cd0ec
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-105340_image_\345\270\202\345\234\272\347\233\256\345\211\215\346\216\245\345\212\233\346\203\205\347\273\252\345\276\210\345\245\275_1233284483106471972.jpg"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:be6c82f18a49f3f7e9bf6129bda5bf41836336514f617c9ef47a7d61ddd226f9
+size 136317
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-105340_text_\345\270\202\345\234\272\347\233\256\345\211\215\346\216\245\345\212\233\346\203\205\347\273\252\345\276\210\345\245\275_1233284483106471972.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-105340_text_\345\270\202\345\234\272\347\233\256\345\211\215\346\216\245\345\212\233\346\203\205\347\273\252\345\276\210\345\245\275_1233284483106471972.txt"
new file mode 100644
index 0000000..218709c
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-105340_text_\345\270\202\345\234\272\347\233\256\345\211\215\346\216\245\345\212\233\346\203\205\347\273\252\345\276\210\345\245\275_1233284483106471972.txt"
@@ -0,0 +1,14 @@
+标题:市场目前接力情绪很好
+
+博主:青枫浦上Q
+
+发布时间:2026-08-06 10:53:40 +08:00
+
+来源:https://www.bilibili.com/opus/1233284483106471972
+
+内容类型:图文动态
+
+
+
+上证指数突破3916则视为右侧,pcb涨的最好因为有涨价催化,今天科技很强,早盘没有加的的就不要追高,依旧是看反弹,买阴不买阳,目前科技还是保持5成仓就好。
+
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-223010_video-cover_\350\241\214\346\203\205\351\234\207\350\215\241\345\220\221\344\270\212_BV1AkuH64EZh.jpg" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-223010_video-cover_\350\241\214\346\203\205\351\234\207\350\215\241\345\220\221\344\270\212_BV1AkuH64EZh.jpg"
new file mode 100644
index 0000000..4352535
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-223010_video-cover_\350\241\214\346\203\205\351\234\207\350\215\241\345\220\221\344\270\212_BV1AkuH64EZh.jpg"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:41c0f9d47204ecd22a9463d7163398a6c3894f2ad8d70c4d65e3bc8766cf3e4d
+size 360053
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-233453_image_8.6\345\244\215\347\233\230_1233480647120519170.png" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-233453_image_8.6\345\244\215\347\233\230_1233480647120519170.png"
new file mode 100644
index 0000000..b337a99
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260806-233453_image_8.6\345\244\215\347\233\230_1233480647120519170.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:939f5b0a8f8f15e212680b5e3a4ff6f0ad1de66de76900be03b558a097927745
+size 233531
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260809-140809_video-cover_\345\235\207\347\272\277_BV189u16KEPe.jpg" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260809-140809_video-cover_\345\235\207\347\272\277_BV189u16KEPe.jpg"
new file mode 100644
index 0000000..db0ec1e
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/20260809-140809_video-cover_\345\235\207\347\272\277_BV189u16KEPe.jpg"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cd1a2643a089e033ac9b9eade054e46941dc6bc24679a72a9ab5539ab862c7e7
+size 153034
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.audio.flac" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.audio.flac"
new file mode 100644
index 0000000..4f7e14f
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.audio.flac"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9515885539cd9e63a424f6ff305f69d323bbc0052bc77d5e5c1f2560384f840e
+size 39881813
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.json" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.json"
new file mode 100644
index 0000000..049c6a3
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.json"
@@ -0,0 +1,3132 @@
+{
+ "source": "BV1DVMX6XEPq.mp4",
+ "audio": "BV1DVMX6XEPq.audio.flac",
+ "language": "zh",
+ "language_probability": 1.0,
+ "model": "large-v3",
+ "device": "cuda",
+ "compute_type": "float16",
+ "vad_filter": true,
+ "segments": [
+ {
+ "start": 0.44,
+ "end": 1.38,
+ "text": "哈喽大家好"
+ },
+ {
+ "start": 1.38,
+ "end": 2.92,
+ "text": "开始我们今天一个视频"
+ },
+ {
+ "start": 2.92,
+ "end": 5.3,
+ "text": "韩后出来一个消息"
+ },
+ {
+ "start": 5.3,
+ "end": 7.1,
+ "text": "这个消息其实挺有意思的"
+ },
+ {
+ "start": 7.1,
+ "end": 8.7,
+ "text": "就韩国那边开始抓人了"
+ },
+ {
+ "start": 8.7,
+ "end": 12.14,
+ "text": "一般来讲开始抓人就离底不远了"
+ },
+ {
+ "start": 12.14,
+ "end": 12.98,
+ "text": "是吧"
+ },
+ {
+ "start": 12.98,
+ "end": 14.16,
+ "text": "但开个玩笑"
+ },
+ {
+ "start": 14.16,
+ "end": 17.96,
+ "text": "但这个确实值得说一下的"
+ },
+ {
+ "start": 17.96,
+ "end": 21.36,
+ "text": "那么我们这个底"
+ },
+ {
+ "start": 21.36,
+ "end": 24.04,
+ "text": "因为今天整体来讲"
+ },
+ {
+ "start": 24.04,
+ "end": 26.2,
+ "text": "让大家就是不要太过于焦虑"
+ },
+ {
+ "start": 26.2,
+ "end": 26.84,
+ "text": "对吧"
+ },
+ {
+ "start": 26.84,
+ "end": 28.24,
+ "text": "因为我们其实已经"
+ },
+ {
+ "start": 28.24,
+ "end": 30.42,
+ "text": "属于一个慢慢摸底的过程当中"
+ },
+ {
+ "start": 30.42,
+ "end": 32.4,
+ "text": "其实看整个60分钟的话"
+ },
+ {
+ "start": 32.4,
+ "end": 33.88,
+ "text": "会更为清晰一点"
+ },
+ {
+ "start": 33.88,
+ "end": 35.44,
+ "text": "整体的结构"
+ },
+ {
+ "start": 35.44,
+ "end": 37.68,
+ "text": "其实是慢慢走一个收敛三角形"
+ },
+ {
+ "start": 37.68,
+ "end": 40.64,
+ "text": "那么差不多再推演下去"
+ },
+ {
+ "start": 40.64,
+ "end": 43.16,
+ "text": "到8月7号"
+ },
+ {
+ "start": 43.16,
+ "end": 44.36,
+ "text": "差不多这周五"
+ },
+ {
+ "start": 44.36,
+ "end": 46.06,
+ "text": "这周五和下周一"
+ },
+ {
+ "start": 46.06,
+ "end": 49.0,
+ "text": "我感觉这个位置就差不多了"
+ },
+ {
+ "start": 49.0,
+ "end": 51.5,
+ "text": "时间节点上来讲的话也差不多"
+ },
+ {
+ "start": 51.5,
+ "end": 53.6,
+ "text": "因为我们今天整体的量"
+ },
+ {
+ "start": 53.6,
+ "end": 55.02,
+ "text": "缩量对吧"
+ },
+ {
+ "start": 55.02,
+ "end": 56.12,
+ "text": "缩量是对着的"
+ },
+ {
+ "start": 56.12,
+ "end": 57.8,
+ "text": "如果今天反而放量不好"
+ },
+ {
+ "start": 57.8,
+ "end": 57.92,
+ "text": "不好"
+ },
+ {
+ "start": 57.92,
+ "end": 59.38,
+ "text": "所以缩量就对了"
+ },
+ {
+ "start": 59.38,
+ "end": 60.44,
+ "text": "缩量的话"
+ },
+ {
+ "start": 60.44,
+ "end": 63.3,
+ "text": "它就会慢慢的形成一个收敛三角形"
+ },
+ {
+ "start": 63.3,
+ "end": 66.38,
+ "text": "那么一旦形成我们这一波反弹"
+ },
+ {
+ "start": 66.38,
+ "end": 69.06,
+ "text": "因为现在走出一个背离结构"
+ },
+ {
+ "start": 69.06,
+ "end": 69.5,
+ "text": "对吧"
+ },
+ {
+ "start": 69.5,
+ "end": 70.64,
+ "text": "走出一个背离结构"
+ },
+ {
+ "start": 70.64,
+ "end": 72.62,
+ "text": "那么反弹的日期我们看不到"
+ },
+ {
+ "start": 72.62,
+ "end": 73.84,
+ "text": "但是能够反弹"
+ },
+ {
+ "start": 73.84,
+ "end": 75.52,
+ "text": "差不多高度能够看到"
+ },
+ {
+ "start": 75.52,
+ "end": 77.4,
+ "text": "差不多就1900吧"
+ },
+ {
+ "start": 77.4,
+ "end": 79.62,
+ "text": "科广501900好吧"
+ },
+ {
+ "start": 79.62,
+ "end": 81.98,
+ "text": "这个位置我是能看得到的"
+ },
+ {
+ "start": 81.98,
+ "end": 84.18,
+ "text": "所以最近就慢慢加就好了"
+ },
+ {
+ "start": 84.18,
+ "end": 85.56,
+ "text": "不用太过于着急"
+ },
+ {
+ "start": 85.56,
+ "end": 87.38,
+ "text": "让你加快一点"
+ },
+ {
+ "start": 87.38,
+ "end": 87.9,
+ "text": "加百分之百"
+ },
+ {
+ "start": 87.9,
+ "end": 87.92,
+ "text": "加百分之百"
+ },
+ {
+ "start": 87.92,
+ "end": 93.58,
+ "text": "10上周二加百分之十周三加百分之十周四加百分之百分之百分之三十"
+ },
+ {
+ "start": 93.58,
+ "end": 95.8,
+ "text": "然后周五高开肯定这天不能加"
+ },
+ {
+ "start": 95.8,
+ "end": 99.26,
+ "text": "那你就只能今天加那百分之四十"
+ },
+ {
+ "start": 99.26,
+ "end": 100.38,
+ "text": "你再慢慢加下去"
+ },
+ {
+ "start": 100.38,
+ "end": 101.48,
+ "text": "我觉得差不多吧"
+ },
+ {
+ "start": 101.48,
+ "end": 106.16,
+ "text": "明天正常来讲是要收一根小阳线的好吧"
+ },
+ {
+ "start": 106.16,
+ "end": 108.76,
+ "text": "那么他收一根小阳线"
+ },
+ {
+ "start": 108.76,
+ "end": 111.98,
+ "text": "那明天肯定你买买阴不买阳嘛"
+ },
+ {
+ "start": 111.98,
+ "end": 114.24,
+ "text": "但整体来讲就OK了"
+ },
+ {
+ "start": 114.24,
+ "end": 115.78,
+ "text": "就差不多了"
+ },
+ {
+ "start": 115.78,
+ "end": 116.04,
+ "text": "好吧"
+ },
+ {
+ "start": 116.04,
+ "end": 117.9,
+ "text": "我觉得时间来讲也是一样的"
+ },
+ {
+ "start": 117.92,
+ "end": 121.58,
+ "text": "差不多空间也也刚好再往下跌"
+ },
+ {
+ "start": 121.58,
+ "end": 122.2,
+ "text": "说实话"
+ },
+ {
+ "start": 122.2,
+ "end": 124.64,
+ "text": "整体整体不会看那么大了"
+ },
+ {
+ "start": 124.64,
+ "end": 127.78,
+ "text": "因为今天整个行情大家其实有点看不明白"
+ },
+ {
+ "start": 127.78,
+ "end": 128.74,
+ "text": "对吧"
+ },
+ {
+ "start": 128.74,
+ "end": 131.06,
+ "text": "看不明白给各位看一张图"
+ },
+ {
+ "start": 131.06,
+ "end": 137.32,
+ "text": "这个是情绪周期的这个东西前几个月还跟大家说过"
+ },
+ {
+ "start": 137.32,
+ "end": 143.06,
+ "text": "因为还没有到整体整体开始讲情绪周期的时候对吧"
+ },
+ {
+ "start": 143.06,
+ "end": 146.16,
+ "text": "但你看了之后就差不多就能明白情绪周期"
+ },
+ {
+ "start": 146.16,
+ "end": 147.5,
+ "text": "我们一共分四个阶段"
+ },
+ {
+ "start": 147.5,
+ "end": 147.9,
+ "text": "第一阶段是情绪周期"
+ },
+ {
+ "start": 147.9,
+ "end": 149.24,
+ "text": "第二阶段是情绪反抽期"
+ },
+ {
+ "start": 149.24,
+ "end": 151.6,
+ "text": "第二阶段是资金建仓期"
+ },
+ {
+ "start": 151.6,
+ "end": 153.52,
+ "text": "第三阶段是祖身期"
+ },
+ {
+ "start": 153.52,
+ "end": 156.02,
+ "text": "祖身期里面我们又分四个小阶段"
+ },
+ {
+ "start": 156.02,
+ "end": 159.16,
+ "text": "第一个小阶段是人气的形成的一个初期"
+ },
+ {
+ "start": 159.16,
+ "end": 161.2,
+ "text": "第二小阶段是人气亢奋期"
+ },
+ {
+ "start": 161.2,
+ "end": 162.54,
+ "text": "第三小阶段成熟期"
+ },
+ {
+ "start": 162.54,
+ "end": 164.86,
+ "text": "然后第四小阶段是衰退阶段"
+ },
+ {
+ "start": 164.86,
+ "end": 166.86,
+ "text": "整个第四阶段呢"
+ },
+ {
+ "start": 166.86,
+ "end": 169.6,
+ "text": "就情绪周期的第四个阶段是退烧期"
+ },
+ {
+ "start": 169.6,
+ "end": 172.18,
+ "text": "所以一共有四个周期四大周期"
+ },
+ {
+ "start": 172.18,
+ "end": 176.54,
+ "text": "那我们如果从情绪周期的角度来看的话"
+ },
+ {
+ "start": 176.54,
+ "end": 176.62,
+ "text": "其实对于今天整个很重要的一阶段是什么呢"
+ },
+ {
+ "start": 176.62,
+ "end": 176.64,
+ "text": "其实对于今天整个很重要的一阶段是"
+ },
+ {
+ "start": 176.64,
+ "end": 180.22,
+ "text": "其实对于今天整个行情就会比较明了了"
+ },
+ {
+ "start": 180.22,
+ "end": 182.54,
+ "text": "因为我们目前是处于情绪反抽期"
+ },
+ {
+ "start": 182.54,
+ "end": 183.56,
+ "text": "也就是第一阶段"
+ },
+ {
+ "start": 183.56,
+ "end": 185.82,
+ "text": "第一阶段的特征呢"
+ },
+ {
+ "start": 185.82,
+ "end": 188.3,
+ "text": "就是前期的老腰股指跌反抽"
+ },
+ {
+ "start": 188.3,
+ "end": 190.92,
+ "text": "所以我们可以看到"
+ },
+ {
+ "start": 190.92,
+ "end": 195.7,
+ "text": "前段时间的MLCC的那个票涨得比较好"
+ },
+ {
+ "start": 195.7,
+ "end": 195.96,
+ "text": "对吧"
+ },
+ {
+ "start": 195.96,
+ "end": 196.72,
+ "text": "包括今天"
+ },
+ {
+ "start": 196.72,
+ "end": 200.86,
+ "text": "我们也看到有一些商业航天出来了"
+ },
+ {
+ "start": 200.86,
+ "end": 203.62,
+ "text": "包括说AI应用那边的票"
+ },
+ {
+ "start": 203.62,
+ "end": 205.3,
+ "text": "整体走的都还不错"
+ },
+ {
+ "start": 205.3,
+ "end": 205.54,
+ "text": "对吧"
+ },
+ {
+ "start": 205.54,
+ "end": 205.84,
+ "text": "今天"
+ },
+ {
+ "start": 205.84,
+ "end": 206.62,
+ "text": "因为今天是上个月"
+ },
+ {
+ "start": 206.62,
+ "end": 207.92,
+ "text": "今天有个票是炸了嘛"
+ },
+ {
+ "start": 207.92,
+ "end": 208.96,
+ "text": "炸了之后"
+ },
+ {
+ "start": 208.96,
+ "end": 209.8,
+ "text": "NEO这边"
+ },
+ {
+ "start": 209.8,
+ "end": 211.18,
+ "text": "它是板上了"
+ },
+ {
+ "start": 211.18,
+ "end": 213.8,
+ "text": "这个也是属于之前的一个老腰股的"
+ },
+ {
+ "start": 213.8,
+ "end": 215.0,
+ "text": "之前的一个老腰股"
+ },
+ {
+ "start": 215.0,
+ "end": 217.68,
+ "text": "所以从本质上来讲呢"
+ },
+ {
+ "start": 217.68,
+ "end": 219.3,
+ "text": "是处于一个情绪反抽期"
+ },
+ {
+ "start": 219.3,
+ "end": 221.7,
+ "text": "然后这些老腰反抽"
+ },
+ {
+ "start": 221.7,
+ "end": 223.56,
+ "text": "它的整体高度不会很高的"
+ },
+ {
+ "start": 223.56,
+ "end": 225.08,
+ "text": "所以你们一直在问M"
+ },
+ {
+ "start": 225.08,
+ "end": 225.48,
+ "text": "对吧"
+ },
+ {
+ "start": 225.48,
+ "end": 226.84,
+ "text": "MLCC走这么强"
+ },
+ {
+ "start": 226.84,
+ "end": 228.98,
+ "text": "它是不是可以"
+ },
+ {
+ "start": 228.98,
+ "end": 231.3,
+ "text": "后面有行情的话"
+ },
+ {
+ "start": 231.3,
+ "end": 232.1,
+ "text": "它会继续更强"
+ },
+ {
+ "start": 232.1,
+ "end": 233.02,
+ "text": "不一定的"
+ },
+ {
+ "start": 233.02,
+ "end": 235.18,
+ "text": "它后面哪怕是会走"
+ },
+ {
+ "start": 235.18,
+ "end": 236.6,
+ "text": "也不一定会真的有走得比较好"
+ },
+ {
+ "start": 236.62,
+ "end": 238.52,
+ "text": "它会在主线行情走出来之后强"
+ },
+ {
+ "start": 238.52,
+ "end": 240.64,
+ "text": "我们后面肯定还是要去看主线行情"
+ },
+ {
+ "start": 240.64,
+ "end": 243.08,
+ "text": "但是离情绪反抽期到主线的一个行程"
+ },
+ {
+ "start": 243.08,
+ "end": 244.96,
+ "text": "它会有一段的时间"
+ },
+ {
+ "start": 244.96,
+ "end": 245.32,
+ "text": "好吧"
+ },
+ {
+ "start": 245.32,
+ "end": 245.98,
+ "text": "快的话"
+ },
+ {
+ "start": 245.98,
+ "end": 246.94,
+ "text": "可能一两周"
+ },
+ {
+ "start": 246.94,
+ "end": 247.78,
+ "text": "慢的话"
+ },
+ {
+ "start": 247.78,
+ "end": 251.2,
+ "text": "可能这边稍微等的时间比较久了"
+ },
+ {
+ "start": 251.2,
+ "end": 254.2,
+ "text": "但我今天想想跟各位聊的呢"
+ },
+ {
+ "start": 254.2,
+ "end": 255.94,
+ "text": "就是CPU这边"
+ },
+ {
+ "start": 255.94,
+ "end": 259.48,
+ "text": "像今天的部分的光走的还不错"
+ },
+ {
+ "start": 259.48,
+ "end": 260.14,
+ "text": "对吧"
+ },
+ {
+ "start": 260.14,
+ "end": 261.12,
+ "text": "因为有一些"
+ },
+ {
+ "start": 261.12,
+ "end": 263.62,
+ "text": "我们如果看这个池子的话"
+ },
+ {
+ "start": 263.62,
+ "end": 266.6,
+ "text": "能够看到有前面的这些光"
+ },
+ {
+ "start": 266.62,
+ "end": 269.38,
+ "text": "因为有一些走的还不错"
+ },
+ {
+ "start": 269.38,
+ "end": 270.36,
+ "text": "包括这边"
+ },
+ {
+ "start": 270.36,
+ "end": 273.24,
+ "text": "这边少记了一个彭东光"
+ },
+ {
+ "start": 273.24,
+ "end": 274.88,
+ "text": "少记一个北交所的"
+ },
+ {
+ "start": 274.88,
+ "end": 276.72,
+ "text": "这个今天也不错"
+ },
+ {
+ "start": 276.72,
+ "end": 278.42,
+ "text": "没有让各位买的意思"
+ },
+ {
+ "start": 278.42,
+ "end": 279.48,
+ "text": "就是我说"
+ },
+ {
+ "start": 279.48,
+ "end": 281.02,
+ "text": "因为为什么呢"
+ },
+ {
+ "start": 281.02,
+ "end": 282.22,
+ "text": "因为光通信这边"
+ },
+ {
+ "start": 282.22,
+ "end": 286.36,
+ "text": "它出了一个还行的利好"
+ },
+ {
+ "start": 286.36,
+ "end": 288.16,
+ "text": "因为Rubin Ultra"
+ },
+ {
+ "start": 288.16,
+ "end": 290.3,
+ "text": "就是因为它下一代的"
+ },
+ {
+ "start": 290.3,
+ "end": 291.26,
+ "text": "就Rubin嘛"
+ },
+ {
+ "start": 291.26,
+ "end": 292.22,
+ "text": "它原先是"
+ },
+ {
+ "start": 292.22,
+ "end": 295.14,
+ "text": "Rubin Ultra是384级别"
+ },
+ {
+ "start": 295.14,
+ "end": 296.6,
+ "text": "然后现在是"
+ },
+ {
+ "start": 296.62,
+ "end": 299.24,
+ "text": "缩减到了192级别"
+ },
+ {
+ "start": 299.24,
+ "end": 301.14,
+ "text": "为什么要这个样子缩呢"
+ },
+ {
+ "start": 301.14,
+ "end": 302.74,
+ "text": "有两个原因"
+ },
+ {
+ "start": 302.74,
+ "end": 306.22,
+ "text": "一个是因为HBM供应紧张"
+ },
+ {
+ "start": 306.22,
+ "end": 306.94,
+ "text": "价格上涨"
+ },
+ {
+ "start": 306.94,
+ "end": 308.52,
+ "text": "另一个原因呢"
+ },
+ {
+ "start": 308.52,
+ "end": 310.06,
+ "text": "是因为电力瓶颈"
+ },
+ {
+ "start": 310.06,
+ "end": 313.26,
+ "text": "所以它们整个规格是变小的"
+ },
+ {
+ "start": 313.26,
+ "end": 316.06,
+ "text": "规格变小之后"
+ },
+ {
+ "start": 316.06,
+ "end": 319.34,
+ "text": "那么就会影响到HBM的放量"
+ },
+ {
+ "start": 319.34,
+ "end": 320.38,
+ "text": "对吧"
+ },
+ {
+ "start": 320.38,
+ "end": 322.8,
+ "text": "它的整个增长是放缓了的"
+ },
+ {
+ "start": 322.8,
+ "end": 326.08,
+ "text": "但是对于网络的投入"
+ },
+ {
+ "start": 326.08,
+ "end": 327.06,
+ "text": "它会上升"
+ },
+ {
+ "start": 327.06,
+ "end": 329.94,
+ "text": "也就是光互联NVSwitch"
+ },
+ {
+ "start": 329.94,
+ "end": 332.72,
+ "text": "这些网络的用量会提高"
+ },
+ {
+ "start": 332.72,
+ "end": 334.0,
+ "text": "因为之前的话"
+ },
+ {
+ "start": 334.0,
+ "end": 338.1,
+ "text": "整体HBM占比占整个结构"
+ },
+ {
+ "start": 338.1,
+ "end": 339.66,
+ "text": "大概是40%"
+ },
+ {
+ "start": 339.66,
+ "end": 344.66,
+ "text": "然后从328G降到198G之后"
+ },
+ {
+ "start": 344.66,
+ "end": 346.0,
+ "text": "384G"
+ },
+ {
+ "start": 346.0,
+ "end": 348.62,
+ "text": "然后降到192G之后"
+ },
+ {
+ "start": 348.62,
+ "end": 350.92,
+ "text": "它的HBM的占的总成本"
+ },
+ {
+ "start": 350.92,
+ "end": 352.86,
+ "text": "大概要缩到28%"
+ },
+ {
+ "start": 352.86,
+ "end": 354.24,
+ "text": "就缩12%左右"
+ },
+ {
+ "start": 354.24,
+ "end": 356.04,
+ "text": "然后网络"
+ },
+ {
+ "start": 356.08,
+ "end": 357.26,
+ "text": "这一块的配比"
+ },
+ {
+ "start": 357.26,
+ "end": 358.3,
+ "text": "就要提高"
+ },
+ {
+ "start": 358.3,
+ "end": 360.28,
+ "text": "从4%提高到12%"
+ },
+ {
+ "start": 360.28,
+ "end": 363.62,
+ "text": "所以今天光整体走得还不错"
+ },
+ {
+ "start": 363.62,
+ "end": 364.82,
+ "text": "它是有利好的"
+ },
+ {
+ "start": 364.82,
+ "end": 366.76,
+ "text": "也就是说"
+ },
+ {
+ "start": 366.76,
+ "end": 370.2,
+ "text": "目前的市场没有乱潮"
+ },
+ {
+ "start": 370.2,
+ "end": 372.28,
+ "text": "就是没有出现问题"
+ },
+ {
+ "start": 372.28,
+ "end": 372.92,
+ "text": "就是说"
+ },
+ {
+ "start": 372.92,
+ "end": 375.68,
+ "text": "哪怕是它有利好"
+ },
+ {
+ "start": 375.68,
+ "end": 376.9,
+ "text": "是正常你要去涨"
+ },
+ {
+ "start": 376.9,
+ "end": 379.04,
+ "text": "包括之前的MSCC"
+ },
+ {
+ "start": 379.04,
+ "end": 380.46,
+ "text": "也是因为涨价"
+ },
+ {
+ "start": 380.46,
+ "end": 382.2,
+ "text": "所以才往上去涨"
+ },
+ {
+ "start": 382.2,
+ "end": 383.68,
+ "text": "也就是说"
+ },
+ {
+ "start": 383.68,
+ "end": 384.7,
+ "text": "现在的东西"
+ },
+ {
+ "start": 384.7,
+ "end": 385.88,
+ "text": "它并不是说"
+ },
+ {
+ "start": 385.88,
+ "end": 387.72,
+ "text": "没有根源的市场"
+ },
+ {
+ "start": 387.72,
+ "end": 389.78,
+ "text": "不讲道理的胡乱的砸"
+ },
+ {
+ "start": 389.78,
+ "end": 391.34,
+ "text": "它都是有原因的"
+ },
+ {
+ "start": 391.34,
+ "end": 394.18,
+ "text": "为什么韩国那边会砸"
+ },
+ {
+ "start": 394.18,
+ "end": 398.08,
+ "text": "因为HBM的用量变少了"
+ },
+ {
+ "start": 398.08,
+ "end": 399.76,
+ "text": "所以存储整体沉压"
+ },
+ {
+ "start": 399.76,
+ "end": 402.2,
+ "text": "这是整体的原因"
+ },
+ {
+ "start": 402.2,
+ "end": 404.74,
+ "text": "那么HBM这边一旦沉压之后"
+ },
+ {
+ "start": 404.74,
+ "end": 407.04,
+ "text": "对于半导体设备"
+ },
+ {
+ "start": 407.04,
+ "end": 408.36,
+ "text": "然后我们这边的"
+ },
+ {
+ "start": 408.36,
+ "end": 410.08,
+ "text": "这种存储芯片"
+ },
+ {
+ "start": 410.08,
+ "end": 410.32,
+ "text": "对吧"
+ },
+ {
+ "start": 410.32,
+ "end": 411.32,
+ "text": "就更不用说了"
+ },
+ {
+ "start": 411.32,
+ "end": 413.36,
+ "text": "那么他们基本上也都是"
+ },
+ {
+ "start": 413.36,
+ "end": 414.96,
+ "text": "本来就不是很正宗的"
+ },
+ {
+ "start": 414.96,
+ "end": 415.58,
+ "text": "然后"
+ },
+ {
+ "start": 415.88,
+ "end": 416.84,
+ "text": "那边一跌"
+ },
+ {
+ "start": 416.84,
+ "end": 419.26,
+ "text": "那么我们这边自然也跟着跌"
+ },
+ {
+ "start": 419.26,
+ "end": 422.26,
+ "text": "然后之前因为半导体涨了"
+ },
+ {
+ "start": 422.26,
+ "end": 425.86,
+ "text": "因为半导体是最后一轮去涨的"
+ },
+ {
+ "start": 425.86,
+ "end": 427.1,
+ "text": "所以它跌的时候"
+ },
+ {
+ "start": 427.1,
+ "end": 429.0,
+ "text": "也是最后一轮跌的"
+ },
+ {
+ "start": 429.0,
+ "end": 430.8,
+ "text": "然后在上一周"
+ },
+ {
+ "start": 430.8,
+ "end": 433.04,
+ "text": "光这边是完成了补跌"
+ },
+ {
+ "start": 433.04,
+ "end": 436.04,
+ "text": "然后今天半导体那边是进行补跌"
+ },
+ {
+ "start": 436.04,
+ "end": 438.48,
+ "text": "像一些市值比较大的"
+ },
+ {
+ "start": 438.48,
+ "end": 438.74,
+ "text": "对吧"
+ },
+ {
+ "start": 438.74,
+ "end": 439.82,
+ "text": "它跌的还是比较狠"
+ },
+ {
+ "start": 439.82,
+ "end": 440.6,
+ "text": "像中威公司"
+ },
+ {
+ "start": 440.6,
+ "end": 442.8,
+ "text": "包括华清科这种东西"
+ },
+ {
+ "start": 442.8,
+ "end": 443.66,
+ "text": "整体来讲"
+ },
+ {
+ "start": 443.66,
+ "end": 445.86,
+ "text": "跌的还是比较厉害的"
+ },
+ {
+ "start": 445.88,
+ "end": 446.98,
+ "text": "然后这些呢"
+ },
+ {
+ "start": 446.98,
+ "end": 449.98,
+ "text": "占科创50的权重又比较高"
+ },
+ {
+ "start": 449.98,
+ "end": 453.48,
+ "text": "所以以至于整个科创版"
+ },
+ {
+ "start": 453.48,
+ "end": 454.68,
+ "text": "科创50"
+ },
+ {
+ "start": 454.68,
+ "end": 456.8,
+ "text": "科创半导体设备ETF"
+ },
+ {
+ "start": 456.8,
+ "end": 458.94,
+ "text": "今天表现很"
+ },
+ {
+ "start": 458.94,
+ "end": 459.92,
+ "text": "很惨"
+ },
+ {
+ "start": 459.92,
+ "end": 462.86,
+ "text": "但是这最后一个板块也跌"
+ },
+ {
+ "start": 462.86,
+ "end": 463.12,
+ "text": "对吧"
+ },
+ {
+ "start": 463.12,
+ "end": 464.26,
+ "text": "我们之前超节点"
+ },
+ {
+ "start": 464.26,
+ "end": 465.14,
+ "text": "什么算力"
+ },
+ {
+ "start": 465.14,
+ "end": 466.04,
+ "text": "国产算力"
+ },
+ {
+ "start": 466.04,
+ "end": 467.28,
+ "text": "这些东西也都跌过了"
+ },
+ {
+ "start": 467.28,
+ "end": 468.92,
+ "text": "那么这些东西"
+ },
+ {
+ "start": 468.92,
+ "end": 470.42,
+ "text": "这两天的一个下跌"
+ },
+ {
+ "start": 470.42,
+ "end": 471.66,
+ "text": "我认为差不多了"
+ },
+ {
+ "start": 471.66,
+ "end": 472.28,
+ "text": "好吧"
+ },
+ {
+ "start": 472.28,
+ "end": 473.44,
+ "text": "时候也差不多了"
+ },
+ {
+ "start": 473.44,
+ "end": 474.8,
+ "text": "从时间上来讲"
+ },
+ {
+ "start": 474.8,
+ "end": 475.84,
+ "text": "从空间上来讲"
+ },
+ {
+ "start": 475.88,
+ "end": 478.18,
+ "text": "基本上也都也都快到位了"
+ },
+ {
+ "start": 478.18,
+ "end": 479.98,
+ "text": "基本上再跌"
+ },
+ {
+ "start": 479.98,
+ "end": 481.3,
+ "text": "再调整个"
+ },
+ {
+ "start": 481.3,
+ "end": 483.76,
+ "text": "三三四天左右"
+ },
+ {
+ "start": 483.76,
+ "end": 485.1,
+ "text": "我觉得就差不多了"
+ },
+ {
+ "start": 485.1,
+ "end": 485.7,
+ "text": "就到位了"
+ },
+ {
+ "start": 485.7,
+ "end": 487.06,
+ "text": "如果能够那样调的话"
+ },
+ {
+ "start": 487.06,
+ "end": 488.16,
+ "text": "我觉得是比较好的"
+ },
+ {
+ "start": 488.16,
+ "end": 490.1,
+ "text": "因为它整个完整的形态"
+ },
+ {
+ "start": 490.1,
+ "end": 490.98,
+ "text": "也能走出来"
+ },
+ {
+ "start": 490.98,
+ "end": 492.96,
+ "text": "所以科创这边"
+ },
+ {
+ "start": 492.96,
+ "end": 494.76,
+ "text": "虽然说它创新低了"
+ },
+ {
+ "start": 494.76,
+ "end": 496.52,
+ "text": "但是整体走了一个背离"
+ },
+ {
+ "start": 496.52,
+ "end": 497.06,
+ "text": "好吧"
+ },
+ {
+ "start": 497.06,
+ "end": 498.16,
+ "text": "这种背离走出来"
+ },
+ {
+ "start": 498.16,
+ "end": 501.16,
+ "text": "反而是对于后面的行情会更好的"
+ },
+ {
+ "start": 501.16,
+ "end": 502.64,
+ "text": "那么美中不足的"
+ },
+ {
+ "start": 502.64,
+ "end": 504.52,
+ "text": "就是上证这边没有踩地"
+ },
+ {
+ "start": 504.52,
+ "end": 505.56,
+ "text": "而这条没有砸下来"
+ },
+ {
+ "start": 505.88,
+ "end": 508.96,
+ "text": "那我估计国家队也不太想"
+ },
+ {
+ "start": 508.96,
+ "end": 510.48,
+ "text": "就是让上证这边下来"
+ },
+ {
+ "start": 510.48,
+ "end": 512.28,
+ "text": "如果不想让上证这边下来"
+ },
+ {
+ "start": 512.28,
+ "end": 513.56,
+ "text": "也无所谓好吧"
+ },
+ {
+ "start": 513.56,
+ "end": 516.12,
+ "text": "本身它能够横住也都ok了"
+ },
+ {
+ "start": 516.12,
+ "end": 517.92,
+ "text": "这个没有什么关系"
+ },
+ {
+ "start": 517.92,
+ "end": 520.48,
+ "text": "因此我们再回过头来看"
+ },
+ {
+ "start": 520.48,
+ "end": 522.0,
+ "text": "对吧"
+ },
+ {
+ "start": 522.0,
+ "end": 525.08,
+ "text": "我们重点就是后面"
+ },
+ {
+ "start": 525.08,
+ "end": 526.1,
+ "text": "包括说"
+ },
+ {
+ "start": 526.1,
+ "end": 530.46,
+ "text": "因为光这边光通信这边是有利好的"
+ },
+ {
+ "start": 530.46,
+ "end": 533.78,
+ "text": "所以它这两天可能会有表现"
+ },
+ {
+ "start": 533.78,
+ "end": 535.84,
+ "text": "如果说明天收一根阳线"
+ },
+ {
+ "start": 535.88,
+ "end": 540.24,
+ "text": "那么这些光是有可能会表现的"
+ },
+ {
+ "start": 540.24,
+ "end": 544.32,
+ "text": "但是我认为它整体一个幅度不会很大"
+ },
+ {
+ "start": 544.32,
+ "end": 547.4,
+ "text": "基本上也就1000就差不多了好吧"
+ },
+ {
+ "start": 547.4,
+ "end": 551.24,
+ "text": "我觉得这一波它整体反弹也就差不多了"
+ },
+ {
+ "start": 551.24,
+ "end": 552.78,
+ "text": "然后后面它再慢慢上"
+ },
+ {
+ "start": 552.78,
+ "end": 553.8,
+ "text": "后面再慢慢上"
+ },
+ {
+ "start": 553.8,
+ "end": 556.36,
+ "text": "因为等整体的一个结构起稳"
+ },
+ {
+ "start": 556.36,
+ "end": 556.88,
+ "text": "好吧"
+ },
+ {
+ "start": 556.88,
+ "end": 559.68,
+ "text": "在这边不能说就把它看作是"
+ },
+ {
+ "start": 559.68,
+ "end": 561.22,
+ "text": "多么牛的一个行情了"
+ },
+ {
+ "start": 561.22,
+ "end": 563.02,
+ "text": "因为主线行情目前还没有出来"
+ },
+ {
+ "start": 563.02,
+ "end": 564.8,
+ "text": "AI应用机器人"
+ },
+ {
+ "start": 564.8,
+ "end": 565.84,
+ "text": "包括商业航天人"
+ },
+ {
+ "start": 565.84,
+ "end": 567.38,
+ "text": "对吧"
+ },
+ {
+ "start": 567.38,
+ "end": 569.94,
+ "text": "硬科技这些都是想要去表现"
+ },
+ {
+ "start": 569.94,
+ "end": 574.8,
+ "text": "都想要去做下一个阶段的一个主线"
+ },
+ {
+ "start": 574.8,
+ "end": 576.84,
+ "text": "但是没有那么容易"
+ },
+ {
+ "start": 576.84,
+ "end": 578.12,
+ "text": "谁能走出来了"
+ },
+ {
+ "start": 578.12,
+ "end": 579.16,
+ "text": "我们进一步观察"
+ },
+ {
+ "start": 579.16,
+ "end": 582.48,
+ "text": "目前来讲的话最有可能是AI应用和"
+ },
+ {
+ "start": 582.48,
+ "end": 585.04,
+ "text": "还是和半导体相关的好吧"
+ },
+ {
+ "start": 585.04,
+ "end": 586.32,
+ "text": "但大家也知道"
+ },
+ {
+ "start": 586.32,
+ "end": 589.14,
+ "text": "因为AI应用我们这边确实不太行"
+ },
+ {
+ "start": 589.14,
+ "end": 590.68,
+ "text": "正宗的在港股"
+ },
+ {
+ "start": 590.68,
+ "end": 594.0,
+ "text": "昨天跟各位说了一个港股的票"
+ },
+ {
+ "start": 594.0,
+ "end": 595.8,
+ "text": "那个票涨的有点开了"
+ },
+ {
+ "start": 595.84,
+ "end": 597.38,
+ "text": "开盘都开的特别猛"
+ },
+ {
+ "start": 597.38,
+ "end": 598.92,
+ "text": "我也"
+ },
+ {
+ "start": 598.92,
+ "end": 600.7,
+ "text": "我也没办法说了好吧"
+ },
+ {
+ "start": 600.7,
+ "end": 603.0,
+ "text": "这种东西我有还是不提了"
+ },
+ {
+ "start": 603.0,
+ "end": 604.54,
+ "text": "提出去就"
+ },
+ {
+ "start": 604.54,
+ "end": 605.56,
+ "text": "就长成那个样子"
+ },
+ {
+ "start": 605.56,
+ "end": 606.6,
+ "text": "很恶心"
+ },
+ {
+ "start": 606.6,
+ "end": 608.12,
+ "text": "所以我们"
+ },
+ {
+ "start": 608.12,
+ "end": 609.92,
+ "text": "就继续去"
+ },
+ {
+ "start": 609.92,
+ "end": 610.94,
+ "text": "观察就好了"
+ },
+ {
+ "start": 610.94,
+ "end": 612.48,
+ "text": "谁能走出来"
+ },
+ {
+ "start": 612.48,
+ "end": 613.5,
+ "text": "不知道"
+ },
+ {
+ "start": 613.5,
+ "end": 614.78,
+ "text": "等走出来之后"
+ },
+ {
+ "start": 614.78,
+ "end": 616.84,
+ "text": "因为主线行情它一定是走出来"
+ },
+ {
+ "start": 616.84,
+ "end": 617.86,
+ "text": "走出来我们知道"
+ },
+ {
+ "start": 617.86,
+ "end": 618.88,
+ "text": "看到它成"
+ },
+ {
+ "start": 618.88,
+ "end": 619.9,
+ "text": "成为主线行情的时候"
+ },
+ {
+ "start": 619.9,
+ "end": 621.18,
+ "text": "它一定已经是"
+ },
+ {
+ "start": 621.18,
+ "end": 622.2,
+ "text": "涨了一"
+ },
+ {
+ "start": 622.2,
+ "end": 623.48,
+ "text": "一段了"
+ },
+ {
+ "start": 623.48,
+ "end": 624.76,
+ "text": "就是这些"
+ },
+ {
+ "start": 624.76,
+ "end": 625.8,
+ "text": "我们目前"
+ },
+ {
+ "start": 625.8,
+ "end": 627.08,
+ "text": "所有的"
+ },
+ {
+ "start": 627.08,
+ "end": 629.38,
+ "text": "包括说AI硬科技"
+ },
+ {
+ "start": 629.38,
+ "end": 631.44,
+ "text": "AI应用"
+ },
+ {
+ "start": 631.44,
+ "end": 634.5,
+ "text": "商业航天机器人这些东西全都属于"
+ },
+ {
+ "start": 634.5,
+ "end": 635.52,
+ "text": "超跌反弹"
+ },
+ {
+ "start": 635.52,
+ "end": 638.08,
+ "text": "只不过AI应用它跌的是"
+ },
+ {
+ "start": 638.08,
+ "end": 639.88,
+ "text": "时间是比较久的对吧"
+ },
+ {
+ "start": 639.88,
+ "end": 640.9,
+ "text": "它整体"
+ },
+ {
+ "start": 640.9,
+ "end": 643.46,
+ "text": "筹码结构出新的也比较完整"
+ },
+ {
+ "start": 643.46,
+ "end": 645.26,
+ "text": "那么周五"
+ },
+ {
+ "start": 645.26,
+ "end": 647.82,
+ "text": "资金流向了最没有"
+ },
+ {
+ "start": 647.82,
+ "end": 649.6,
+ "text": "阻力的一个位置"
+ },
+ {
+ "start": 649.6,
+ "end": 652.42,
+ "text": "包括说这边应该是有一些人"
+ },
+ {
+ "start": 652.42,
+ "end": 653.44,
+ "text": "想要去做出"
+ },
+ {
+ "start": 653.44,
+ "end": 654.48,
+ "text": "东西来的"
+ },
+ {
+ "start": 654.48,
+ "end": 655.76,
+ "text": "所以他们整个"
+ },
+ {
+ "start": 655.76,
+ "end": 658.32,
+ "text": "最近吹得非常厉害对吧"
+ },
+ {
+ "start": 658.32,
+ "end": 660.36,
+ "text": "一些卖方这边的"
+ },
+ {
+ "start": 660.36,
+ "end": 661.64,
+ "text": "资料发的也特别"
+ },
+ {
+ "start": 661.64,
+ "end": 662.42,
+ "text": "特别多"
+ },
+ {
+ "start": 662.42,
+ "end": 665.24,
+ "text": "那么说明他们是想要做出点动作"
+ },
+ {
+ "start": 665.24,
+ "end": 667.28,
+ "text": "那么再配合到"
+ },
+ {
+ "start": 667.28,
+ "end": 668.82,
+ "text": "一些实际的"
+ },
+ {
+ "start": 668.82,
+ "end": 671.12,
+ "text": "因素例如DeepSeek那边"
+ },
+ {
+ "start": 671.12,
+ "end": 673.16,
+ "text": "降价包括OpenAI降价"
+ },
+ {
+ "start": 673.16,
+ "end": 677.26,
+ "text": "然后最大的呢其实就是腾讯那边的"
+ },
+ {
+ "start": 677.26,
+ "end": 680.34,
+ "text": "那个AI智能体WorkBuddy"
+ },
+ {
+ "start": 680.34,
+ "end": 682.38,
+ "text": "那么这个东西"
+ },
+ {
+ "start": 682.38,
+ "end": 685.72,
+ "text": "甚至后面是有可能会"
+ },
+ {
+ "start": 685.76,
+ "end": 688.32,
+ "text": "被统一部署到"
+ },
+ {
+ "start": 688.32,
+ "end": 689.34,
+ "text": "央国企的"
+ },
+ {
+ "start": 689.34,
+ "end": 690.88,
+ "text": "以及政府单位当中"
+ },
+ {
+ "start": 690.88,
+ "end": 692.92,
+ "text": "所以后面的催化"
+ },
+ {
+ "start": 692.92,
+ "end": 693.7,
+ "text": "有"
+ },
+ {
+ "start": 693.7,
+ "end": 694.98,
+ "text": "故事也有"
+ },
+ {
+ "start": 694.98,
+ "end": 698.3,
+ "text": "AI的业绩也都能够给你挖掘到"
+ },
+ {
+ "start": 698.3,
+ "end": 699.58,
+ "text": "也都能够实现"
+ },
+ {
+ "start": 699.58,
+ "end": 700.6,
+ "text": "但是"
+ },
+ {
+ "start": 700.6,
+ "end": 702.66,
+ "text": "它能不能走出来"
+ },
+ {
+ "start": 702.66,
+ "end": 704.96,
+ "text": "需要整体资金的一个共识"
+ },
+ {
+ "start": 704.96,
+ "end": 706.76,
+ "text": "好吧因此我们在这段"
+ },
+ {
+ "start": 706.76,
+ "end": 708.04,
+ "text": "这段时间内呢"
+ },
+ {
+ "start": 708.04,
+ "end": 710.08,
+ "text": "还是不要追高"
+ },
+ {
+ "start": 710.08,
+ "end": 711.36,
+ "text": "对啊不要追高"
+ },
+ {
+ "start": 711.36,
+ "end": 714.18,
+ "text": "慢慢的去把科技的仓位打上去"
+ },
+ {
+ "start": 714.18,
+ "end": 715.72,
+ "text": "因为这些东西都超级反弹"
+ },
+ {
+ "start": 715.76,
+ "end": 717.04,
+ "text": "只要谈"
+ },
+ {
+ "start": 717.04,
+ "end": 719.34,
+ "text": "科创50谈"
+ },
+ {
+ "start": 719.34,
+ "end": 720.88,
+ "text": "不管那些半导体也好"
+ },
+ {
+ "start": 720.88,
+ "end": 722.16,
+ "text": "AI硬件也好"
+ },
+ {
+ "start": 722.16,
+ "end": 723.44,
+ "text": "光PCB这些东西"
+ },
+ {
+ "start": 723.44,
+ "end": 725.74,
+ "text": "他们都会有表现"
+ },
+ {
+ "start": 725.74,
+ "end": 727.54,
+ "text": "整个创业板"
+ },
+ {
+ "start": 727.54,
+ "end": 729.84,
+ "text": "上涨这些科技股也都会有表现"
+ },
+ {
+ "start": 729.84,
+ "end": 731.38,
+ "text": "甚至那些AI应用对吧"
+ },
+ {
+ "start": 731.38,
+ "end": 733.16,
+ "text": "他们也都会有表现"
+ },
+ {
+ "start": 733.16,
+ "end": 735.22,
+ "text": "我们整体把仓位推上去"
+ },
+ {
+ "start": 735.22,
+ "end": 737.52,
+ "text": "配置选手呢就把仓位推上去"
+ },
+ {
+ "start": 737.52,
+ "end": 741.1,
+ "text": "去做ETF这些东西没有什么问题"
+ },
+ {
+ "start": 741.1,
+ "end": 743.4,
+ "text": "该吃到都能吃到"
+ },
+ {
+ "start": 743.4,
+ "end": 744.68,
+ "text": "至于说"
+ },
+ {
+ "start": 744.68,
+ "end": 745.72,
+ "text": "我们去做"
+ },
+ {
+ "start": 745.72,
+ "end": 747.76,
+ "text": "组生期的时候"
+ },
+ {
+ "start": 747.76,
+ "end": 748.28,
+ "text": "对吧"
+ },
+ {
+ "start": 748.28,
+ "end": 750.58,
+ "text": "那得等他们资金杀出来之后"
+ },
+ {
+ "start": 750.58,
+ "end": 751.6,
+ "text": "我们再去做选择"
+ },
+ {
+ "start": 751.6,
+ "end": 753.92,
+ "text": "这是那些主力资金要做的事情"
+ },
+ {
+ "start": 753.92,
+ "end": 755.44,
+ "text": "对吧他们去"
+ },
+ {
+ "start": 755.44,
+ "end": 757.24,
+ "text": "杀出一条血路来"
+ },
+ {
+ "start": 757.24,
+ "end": 758.78,
+ "text": "然后我们跟着后"
+ },
+ {
+ "start": 758.78,
+ "end": 760.56,
+ "text": "跟在后面吃肉就行了"
+ },
+ {
+ "start": 760.56,
+ "end": 761.6,
+ "text": "就这么简单"
+ },
+ {
+ "start": 761.6,
+ "end": 763.64,
+ "text": "现阶段不要去赌方向"
+ },
+ {
+ "start": 763.64,
+ "end": 765.18,
+ "text": "有能力做港股的人"
+ },
+ {
+ "start": 765.18,
+ "end": 766.72,
+ "text": "去做港股的相关的配置"
+ },
+ {
+ "start": 766.72,
+ "end": 768.24,
+ "text": "好吧"
+ },
+ {
+ "start": 768.24,
+ "end": 770.8,
+ "text": "这个都没有什么问题"
+ },
+ {
+ "start": 770.8,
+ "end": 771.84,
+ "text": "嗯"
+ },
+ {
+ "start": 771.84,
+ "end": 774.64,
+ "text": "昨天跟各位讲了8月份的一个宏观"
+ },
+ {
+ "start": 774.64,
+ "end": 775.68,
+ "text": "感觉是不是有点"
+ },
+ {
+ "start": 775.68,
+ "end": 777.72,
+ "text": "把大家吓到了对吧"
+ },
+ {
+ "start": 777.72,
+ "end": 780.54,
+ "text": "其实不用太过于悲观"
+ },
+ {
+ "start": 780.54,
+ "end": 781.06,
+ "text": "好吧"
+ },
+ {
+ "start": 781.06,
+ "end": 784.12,
+ "text": "这些数据并不一定非得要往"
+ },
+ {
+ "start": 784.12,
+ "end": 785.4,
+ "text": "不好的方向走"
+ },
+ {
+ "start": 785.4,
+ "end": 788.48,
+ "text": "它是有可能会往好的方向走的"
+ },
+ {
+ "start": 788.48,
+ "end": 791.8,
+ "text": "所以不用太过于非得就说这种时候"
+ },
+ {
+ "start": 791.8,
+ "end": 793.34,
+ "text": "就要出现利空了"
+ },
+ {
+ "start": 793.34,
+ "end": 796.68,
+ "text": "我们其实上周五的上涨也是因为"
+ },
+ {
+ "start": 796.68,
+ "end": 799.24,
+ "text": "出现的是利好的消息对不对"
+ },
+ {
+ "start": 799.24,
+ "end": 800.52,
+ "text": "不是出现利空的嘛"
+ },
+ {
+ "start": 800.52,
+ "end": 802.04,
+ "text": "所以是出现利好的"
+ },
+ {
+ "start": 802.04,
+ "end": 803.84,
+ "text": "所以我们也不用太过于悲观"
+ },
+ {
+ "start": 803.84,
+ "end": 805.38,
+ "text": "照说整个8月份"
+ },
+ {
+ "start": 805.38,
+ "end": 807.18,
+ "text": "就让他们慢慢磨底嘛"
+ },
+ {
+ "start": 807.18,
+ "end": 808.96,
+ "text": "这个底磨的时间越长"
+ },
+ {
+ "start": 808.96,
+ "end": 811.02,
+ "text": "也就代表着后面"
+ },
+ {
+ "start": 811.02,
+ "end": 814.08,
+ "text": "金九银十的整个行情会长得越顺畅"
+ },
+ {
+ "start": 814.08,
+ "end": 814.6,
+ "text": "好吧"
+ },
+ {
+ "start": 814.6,
+ "end": 815.62,
+ "text": "我们整体就"
+ },
+ {
+ "start": 815.62,
+ "end": 817.42,
+ "text": "我觉得在这种位置"
+ },
+ {
+ "start": 817.42,
+ "end": 820.22,
+ "text": "没有必要去悲观了"
+ },
+ {
+ "start": 820.22,
+ "end": 821.26,
+ "text": "好吧"
+ },
+ {
+ "start": 821.26,
+ "end": 823.56,
+ "text": "这种位置真的没有必要去悲观"
+ },
+ {
+ "start": 823.56,
+ "end": 824.84,
+ "text": "该怎么做怎么做"
+ },
+ {
+ "start": 824.84,
+ "end": 826.12,
+ "text": "等出现了"
+ },
+ {
+ "start": 826.12,
+ "end": 828.16,
+ "text": "我们想象当中的主线行情的时候"
+ },
+ {
+ "start": 828.16,
+ "end": 830.46,
+ "text": "到时候去积极参与就好了"
+ },
+ {
+ "start": 830.46,
+ "end": 832.26,
+ "text": "现阶段呢就是"
+ },
+ {
+ "start": 832.26,
+ "end": 835.34,
+ "text": "尽量不要去追涨杀跌好吧"
+ },
+ {
+ "start": 835.34,
+ "end": 836.62,
+ "text": "不要去追涨杀跌"
+ },
+ {
+ "start": 836.62,
+ "end": 838.92,
+ "text": "不要被大资金牵着鼻子走"
+ },
+ {
+ "start": 838.92,
+ "end": 841.48,
+ "text": "因为现阶段从情绪周期上来讲"
+ },
+ {
+ "start": 841.48,
+ "end": 843.28,
+ "text": "是很难的"
+ },
+ {
+ "start": 843.28,
+ "end": 844.56,
+ "text": "我承认这"
+ },
+ {
+ "start": 844.56,
+ "end": 845.32,
+ "text": "我承认"
+ },
+ {
+ "start": 845.32,
+ "end": 846.86,
+ "text": "除了主升期"
+ },
+ {
+ "start": 846.86,
+ "end": 848.4,
+ "text": "主升期的"
+ },
+ {
+ "start": 848.4,
+ "end": 852.24,
+ "text": "人气亢奋期和成熟阶段相对来讲好做之外"
+ },
+ {
+ "start": 852.24,
+ "end": 854.8,
+ "text": "其他的这些所有的节点都不好做"
+ },
+ {
+ "start": 854.8,
+ "end": 856.58,
+ "text": "都很难做"
+ },
+ {
+ "start": 856.58,
+ "end": 858.12,
+ "text": "所以对于"
+ },
+ {
+ "start": 858.12,
+ "end": 860.18,
+ "text": "散户来讲其实"
+ },
+ {
+ "start": 860.18,
+ "end": 863.76,
+ "text": "你在做票的很大程度上其实都是在等"
+ },
+ {
+ "start": 863.76,
+ "end": 864.78,
+ "text": "在慢慢的等"
+ },
+ {
+ "start": 864.78,
+ "end": 865.8,
+ "text": "在熬时间"
+ },
+ {
+ "start": 865.8,
+ "end": 870.16,
+ "text": "正儿八经可以做行情的时候其实很少"
+ },
+ {
+ "start": 870.16,
+ "end": 872.2,
+ "text": "对吧所以我们整体还是要等"
+ },
+ {
+ "start": 872.2,
+ "end": 874.26,
+ "text": "包括到了资金建仓期"
+ },
+ {
+ "start": 874.26,
+ "end": 878.34,
+ "text": "这边整体的仓位甚至是要比情绪反抽期要更少的"
+ },
+ {
+ "start": 878.34,
+ "end": 881.42,
+ "text": "因为情绪反抽我们还能去做一个"
+ },
+ {
+ "start": 881.42,
+ "end": 882.7,
+ "text": "超跌反弹"
+ },
+ {
+ "start": 882.7,
+ "end": 884.24,
+ "text": "包括说我们"
+ },
+ {
+ "start": 884.24,
+ "end": 887.56,
+ "text": "这整个7月份哪怕是白酒医药"
+ },
+ {
+ "start": 887.56,
+ "end": 891.4,
+ "text": "消费对吧那些东西他们其实本质上也都是超跌反弹"
+ },
+ {
+ "start": 891.4,
+ "end": 894.74,
+ "text": "就是大量的整个超跌股呢同步上的"
+ },
+ {
+ "start": 894.74,
+ "end": 898.84,
+ "text": "只不过他会在不同的板块之间去进行一个表现"
+ },
+ {
+ "start": 898.84,
+ "end": 901.66,
+ "text": "但是在整体上涨三五天之后"
+ },
+ {
+ "start": 901.66,
+ "end": 903.18,
+ "text": "就会陆续的分化"
+ },
+ {
+ "start": 903.18,
+ "end": 904.98,
+ "text": "纯粹依赖那种"
+ },
+ {
+ "start": 904.98,
+ "end": 909.08,
+ "text": "超跌的品种呢他会回落的比较快一点"
+ },
+ {
+ "start": 909.08,
+ "end": 911.12,
+ "text": "具备产业逻辑"
+ },
+ {
+ "start": 911.12,
+ "end": 912.4,
+ "text": "业绩支撑"
+ },
+ {
+ "start": 912.4,
+ "end": 912.92,
+ "text": "与"
+ },
+ {
+ "start": 912.92,
+ "end": 915.48,
+ "text": "资金承接方向的他会走出第二波"
+ },
+ {
+ "start": 915.48,
+ "end": 917.78,
+ "text": "那么这种第二波"
+ },
+ {
+ "start": 917.78,
+ "end": 919.32,
+ "text": "他就是主线了"
+ },
+ {
+ "start": 919.32,
+ "end": 921.88,
+ "text": "所以我们在反弹初期"
+ },
+ {
+ "start": 921.88,
+ "end": 923.92,
+ "text": "没有必要去"
+ },
+ {
+ "start": 923.92,
+ "end": 924.7,
+ "text": "找对吧"
+ },
+ {
+ "start": 924.7,
+ "end": 927.0,
+ "text": "谁会是主线"
+ },
+ {
+ "start": 927.0,
+ "end": 928.02,
+ "text": "因为第"
+ },
+ {
+ "start": 928.02,
+ "end": 929.3,
+ "text": "因为"
+ },
+ {
+ "start": 929.3,
+ "end": 933.14,
+ "text": "我们在第1波反弹的时候他解决的就是"
+ },
+ {
+ "start": 933.14,
+ "end": 934.42,
+ "text": "情绪修复"
+ },
+ {
+ "start": 934.42,
+ "end": 938.78,
+ "text": "你到了第2波才会去定产业方向和龙头归属"
+ },
+ {
+ "start": 938.78,
+ "end": 941.6,
+ "text": "所以我们今天也能够看到一些"
+ },
+ {
+ "start": 941.6,
+ "end": 944.92,
+ "text": "AI应用的方向对吧他们是出现了分歧的"
+ },
+ {
+ "start": 944.92,
+ "end": 947.22,
+ "text": "因为周五整体已经普涨了嘛"
+ },
+ {
+ "start": 947.22,
+ "end": 948.76,
+ "text": "整体板块已经高潮了"
+ },
+ {
+ "start": 948.76,
+ "end": 950.3,
+ "text": "那么他们出现分歧"
+ },
+ {
+ "start": 950.3,
+ "end": 952.6,
+ "text": "分歧之后的再加强"
+ },
+ {
+ "start": 952.6,
+ "end": 954.66,
+ "text": "他才能够确认"
+ },
+ {
+ "start": 954.7,
+ "end": 956.74,
+ "text": "这个方向是有的"
+ },
+ {
+ "start": 956.74,
+ "end": 957.52,
+ "text": "东西做"
+ },
+ {
+ "start": 957.52,
+ "end": 959.3,
+ "text": "你在第1天"
+ },
+ {
+ "start": 959.3,
+ "end": 962.38,
+ "text": "普涨的时候那一天你反而是没有什么可以做的"
+ },
+ {
+ "start": 962.38,
+ "end": 965.2,
+ "text": "你整体仓位你是推不上去的"
+ },
+ {
+ "start": 965.2,
+ "end": 968.52,
+ "text": "你只有他们在分歧结束之后再次"
+ },
+ {
+ "start": 968.52,
+ "end": 970.06,
+ "text": "转强的时候"
+ },
+ {
+ "start": 970.06,
+ "end": 971.86,
+ "text": "我们才能够对他"
+ },
+ {
+ "start": 971.86,
+ "end": 972.88,
+ "text": "高看一"
+ },
+ {
+ "start": 972.88,
+ "end": 975.18,
+ "text": "所以行情是走出来不是拆出来的"
+ },
+ {
+ "start": 975.18,
+ "end": 975.94,
+ "text": "好吧"
+ },
+ {
+ "start": 975.94,
+ "end": 977.22,
+ "text": "大家"
+ },
+ {
+ "start": 977.22,
+ "end": 980.56,
+ "text": "很多散户陷入了一个误区就是非得要找确定性"
+ },
+ {
+ "start": 980.56,
+ "end": 981.84,
+ "text": "没有确定性的"
+ },
+ {
+ "start": 981.84,
+ "end": 984.4,
+ "text": "等真出确定性的时候你又不敢去买了"
+ },
+ {
+ "start": 984.4,
+ "end": 985.94,
+ "text": "还是我说了一句话好吧"
+ },
+ {
+ "start": 985.94,
+ "end": 988.5,
+ "text": "做交易不要又贪又怂"
+ },
+ {
+ "start": 988.5,
+ "end": 989.78,
+ "text": "贪呢是因为"
+ },
+ {
+ "start": 989.78,
+ "end": 991.06,
+ "text": "主线涨出来了"
+ },
+ {
+ "start": 991.06,
+ "end": 993.88,
+ "text": "主线没涨出来你想买在最底部对吧"
+ },
+ {
+ "start": 993.88,
+ "end": 996.18,
+ "text": "那么怂呢是主线涨起来了"
+ },
+ {
+ "start": 996.18,
+ "end": 998.22,
+ "text": "然后你又不敢买了"
+ },
+ {
+ "start": 998.22,
+ "end": 1000.52,
+ "text": "往往就错过了主线行情"
+ },
+ {
+ "start": 1000.52,
+ "end": 1002.32,
+ "text": "这是散户最大的一个问题"
+ },
+ {
+ "start": 1002.32,
+ "end": 1005.14,
+ "text": "但也是交易当中最反人性的事情"
+ },
+ {
+ "start": 1005.14,
+ "end": 1007.7,
+ "text": "这也是所有散户必须要卖过的坎"
+ },
+ {
+ "start": 1007.7,
+ "end": 1010.52,
+ "text": "好吧你不卖过这个坎呢"
+ },
+ {
+ "start": 1010.52,
+ "end": 1013.32,
+ "text": "自然你就享受不到主身了"
+ },
+ {
+ "start": 1013.32,
+ "end": 1014.36,
+ "text": "这是我今天想讲的"
+ },
+ {
+ "start": 1014.36,
+ "end": 1015.38,
+ "text": "想跟各位说的"
+ },
+ {
+ "start": 1015.38,
+ "end": 1016.66,
+ "text": "所以最近"
+ },
+ {
+ "start": 1016.66,
+ "end": 1017.68,
+ "text": "依旧"
+ },
+ {
+ "start": 1017.68,
+ "end": 1018.96,
+ "text": "大跌大买小跌小买"
+ },
+ {
+ "start": 1018.96,
+ "end": 1020.5,
+ "text": "但我认为不会有"
+ },
+ {
+ "start": 1020.5,
+ "end": 1021.52,
+ "text": "大跌出现了好吧"
+ },
+ {
+ "start": 1021.52,
+ "end": 1023.58,
+ "text": "除非出现特别的利空"
+ },
+ {
+ "start": 1023.58,
+ "end": 1026.64,
+ "text": "那么如果出现特别的利空"
+ },
+ {
+ "start": 1026.64,
+ "end": 1030.24,
+ "text": "那这种点位也是比较好加仓的位置"
+ },
+ {
+ "start": 1030.24,
+ "end": 1033.3,
+ "text": "因为如果是因为外部的干扰的话"
+ },
+ {
+ "start": 1033.3,
+ "end": 1035.6,
+ "text": "反而它反弹的力度会整体会大一点"
+ },
+ {
+ "start": 1035.6,
+ "end": 1037.14,
+ "text": "但我不太希望这种事情出现"
+ },
+ {
+ "start": 1037.14,
+ "end": 1038.16,
+ "text": "因为"
+ },
+ {
+ "start": 1038.16,
+ "end": 1039.96,
+ "text": "暴涨和暴跌"
+ },
+ {
+ "start": 1039.96,
+ "end": 1044.06,
+ "text": "都不利于整体行情的一个修复"
+ },
+ {
+ "start": 1044.06,
+ "end": 1045.86,
+ "text": "和它的发展"
+ },
+ {
+ "start": 1045.86,
+ "end": 1047.14,
+ "text": "所以我还是"
+ },
+ {
+ "start": 1047.14,
+ "end": 1048.16,
+ "text": "希望"
+ },
+ {
+ "start": 1048.16,
+ "end": 1049.44,
+ "text": "好好走向今天对吧"
+ },
+ {
+ "start": 1049.44,
+ "end": 1051.22,
+ "text": "这个科技走成这个样子"
+ },
+ {
+ "start": 1051.22,
+ "end": 1053.54,
+ "text": "涨跌加速这边4000多家红牌"
+ },
+ {
+ "start": 1053.54,
+ "end": 1054.82,
+ "text": "只有1000多家"
+ },
+ {
+ "start": 1054.82,
+ "end": 1055.84,
+ "text": "绿牌"
+ },
+ {
+ "start": 1055.84,
+ "end": 1057.62,
+ "text": "但你如果在科技里面对吧"
+ },
+ {
+ "start": 1057.62,
+ "end": 1060.44,
+ "text": "就相对来讲会难受点"
+ },
+ {
+ "start": 1060.44,
+ "end": 1062.24,
+ "text": "包括上周五的时候"
+ },
+ {
+ "start": 1062.24,
+ "end": 1064.8,
+ "text": "涨跌加速也非常夸张"
+ },
+ {
+ "start": 1064.8,
+ "end": 1067.1,
+ "text": "但你的体感就会很弱"
+ },
+ {
+ "start": 1067.1,
+ "end": 1068.12,
+ "text": "这就是"
+ },
+ {
+ "start": 1068.12,
+ "end": 1070.18,
+ "text": "情绪反抽期的"
+ },
+ {
+ "start": 1070.18,
+ "end": 1070.94,
+ "text": "时候"
+ },
+ {
+ "start": 1070.94,
+ "end": 1071.96,
+ "text": "就这个样子"
+ },
+ {
+ "start": 1071.96,
+ "end": 1074.02,
+ "text": "好吧他就是要去恶心你的"
+ },
+ {
+ "start": 1074.06,
+ "end": 1077.14,
+ "text": "这个阶段就是散户最难受的时候"
+ },
+ {
+ "start": 1077.14,
+ "end": 1078.66,
+ "text": "最难受的时候"
+ },
+ {
+ "start": 1078.66,
+ "end": 1080.72,
+ "text": "他甚至比退烧期要更难受"
+ },
+ {
+ "start": 1080.72,
+ "end": 1081.48,
+ "text": "因为退烧期的吧"
+ },
+ {
+ "start": 1081.48,
+ "end": 1082.26,
+ "text": "见顶了"
+ },
+ {
+ "start": 1082.26,
+ "end": 1083.78,
+ "text": "大家都知道见顶"
+ },
+ {
+ "start": 1083.78,
+ "end": 1085.58,
+ "text": "那你就直接"
+ },
+ {
+ "start": 1085.58,
+ "end": 1087.12,
+ "text": "干脆不做不看对吧"
+ },
+ {
+ "start": 1087.12,
+ "end": 1088.14,
+ "text": "或者去"
+ },
+ {
+ "start": 1088.14,
+ "end": 1088.66,
+ "text": "搞别的"
+ },
+ {
+ "start": 1088.66,
+ "end": 1090.7,
+ "text": "但现在就是给你来回"
+ },
+ {
+ "start": 1090.7,
+ "end": 1091.46,
+ "text": "电风扇"
+ },
+ {
+ "start": 1091.46,
+ "end": 1094.02,
+ "text": "所以在这个阶段一定不要去追高好吧"
+ },
+ {
+ "start": 1094.02,
+ "end": 1095.06,
+ "text": "你追高"
+ },
+ {
+ "start": 1095.06,
+ "end": 1095.56,
+ "text": "会"
+ },
+ {
+ "start": 1095.56,
+ "end": 1097.1,
+ "text": "死得非常惨"
+ },
+ {
+ "start": 1097.1,
+ "end": 1098.9,
+ "text": "这是我想跟各位说的"
+ },
+ {
+ "start": 1098.9,
+ "end": 1103.5,
+ "text": "不悲观哪怕后面再跌好吧不悲观不悲观"
+ },
+ {
+ "start": 1103.5,
+ "end": 1105.54,
+ "text": "哪怕后面再跌也是正常的"
+ },
+ {
+ "start": 1105.54,
+ "end": 1109.14,
+ "text": "他再跌下去整个背离就会更大了"
+ },
+ {
+ "start": 1109.14,
+ "end": 1109.64,
+ "text": "那么"
+ },
+ {
+ "start": 1109.64,
+ "end": 1112.72,
+ "text": "我们对于后面的行情整体来讲就会"
+ },
+ {
+ "start": 1112.72,
+ "end": 1113.74,
+ "text": "更为舒服一点"
+ },
+ {
+ "start": 1113.74,
+ "end": 1117.58,
+ "text": "那如果你本身已经在这种位置打满仓了"
+ },
+ {
+ "start": 1117.58,
+ "end": 1118.6,
+ "text": "对吧那么"
+ },
+ {
+ "start": 1118.6,
+ "end": 1120.14,
+ "text": "后面出现上涨"
+ },
+ {
+ "start": 1120.14,
+ "end": 1121.16,
+ "text": "是不是你就"
+ },
+ {
+ "start": 1121.16,
+ "end": 1122.96,
+ "text": "比较好做替的时候了"
+ },
+ {
+ "start": 1122.96,
+ "end": 1124.5,
+ "text": "你就要把自己的仓位"
+ },
+ {
+ "start": 1124.5,
+ "end": 1125.26,
+ "text": "进行"
+ },
+ {
+ "start": 1125.26,
+ "end": 1126.54,
+ "text": "动一下把你的"
+ },
+ {
+ "start": 1126.54,
+ "end": 1128.34,
+ "text": "因为在这种时候"
+ },
+ {
+ "start": 1128.34,
+ "end": 1129.36,
+ "text": "其实你"
+ },
+ {
+ "start": 1129.36,
+ "end": 1133.46,
+ "text": "如果动一下做替的话是能把你成本"
+ },
+ {
+ "start": 1133.46,
+ "end": 1135.76,
+ "text": "降下来的是能够降"
+ },
+ {
+ "start": 1135.76,
+ "end": 1137.56,
+ "text": "降挺多的说实话"
+ },
+ {
+ "start": 1137.56,
+ "end": 1138.58,
+ "text": "对吧你"
+ },
+ {
+ "start": 1138.58,
+ "end": 1139.6,
+ "text": "周五的时候"
+ },
+ {
+ "start": 1139.6,
+ "end": 1141.66,
+ "text": "早盘你如果卖的话今天"
+ },
+ {
+ "start": 1141.66,
+ "end": 1142.42,
+ "text": "今天你"
+ },
+ {
+ "start": 1142.42,
+ "end": 1146.52,
+ "text": "你在一接你整体成本能降多少能降不少的"
+ },
+ {
+ "start": 1146.52,
+ "end": 1148.56,
+ "text": "抓住这种时候好好去把你"
+ },
+ {
+ "start": 1148.56,
+ "end": 1149.84,
+ "text": "你如果能"
+ },
+ {
+ "start": 1149.84,
+ "end": 1151.64,
+ "text": "做替去把你成本降下来"
+ },
+ {
+ "start": 1151.64,
+ "end": 1153.42,
+ "text": "如果做不了替"
+ },
+ {
+ "start": 1153.42,
+ "end": 1155.48,
+ "text": "那就继续等着呗好吧等"
+ },
+ {
+ "start": 1155.48,
+ "end": 1156.76,
+ "text": "反正这一轮"
+ },
+ {
+ "start": 1156.76,
+ "end": 1158.3,
+ "text": "科创我是"
+ },
+ {
+ "start": 1158.3,
+ "end": 1161.62,
+ "text": "就底部跌出来之后我认为我能看到19001900的话整体代价局部选择"
+ },
+ {
+ "start": 1161.62,
+ "end": 1163.42,
+ "text": "我认为我能看到190019的话整体代价局部选择"
+ },
+ {
+ "start": 1163.42,
+ "end": 1165.22,
+ "text": "会修复会修复的还不错"
+ },
+ {
+ "start": 1165.22,
+ "end": 1168.02,
+ "text": "一些被腰斩的股票甚至能"
+ },
+ {
+ "start": 1168.02,
+ "end": 1169.06,
+ "text": "对吧涨25%"
+ },
+ {
+ "start": 1169.06,
+ "end": 1172.38,
+ "text": "涨25%我认为"
+ },
+ {
+ "start": 1172.38,
+ "end": 1174.42,
+ "text": "还行吧好吧对于你们"
+ },
+ {
+ "start": 1174.42,
+ "end": 1175.96,
+ "text": "那这样已经很好了"
+ },
+ {
+ "start": 1175.96,
+ "end": 1179.54,
+ "text": "那么到后面再找一轮主线行情直接就做回来了好吧"
+ },
+ {
+ "start": 1179.54,
+ "end": 1180.32,
+ "text": "所以我认为"
+ },
+ {
+ "start": 1180.32,
+ "end": 1182.1,
+ "text": "整体难度没有那么大"
+ },
+ {
+ "start": 1182.1,
+ "end": 1184.16,
+ "text": "现阶段没有那么悲观了"
+ },
+ {
+ "start": 1184.16,
+ "end": 1185.94,
+ "text": "没有那么悲观"
+ },
+ {
+ "start": 1185.94,
+ "end": 1188.26,
+ "text": "我对于下半年来讲还是看好了"
+ },
+ {
+ "start": 1188.26,
+ "end": 1189.28,
+ "text": "好吧"
+ },
+ {
+ "start": 1189.28,
+ "end": 1191.58,
+ "text": "所以今天的视频能讲的不多"
+ },
+ {
+ "start": 1191.58,
+ "end": 1193.12,
+ "text": "差不多就就到这里吧"
+ },
+ {
+ "start": 1193.12,
+ "end": 1193.38,
+ "text": "嗯"
+ },
+ {
+ "start": 1193.42,
+ "end": 1196.24,
+ "text": "嗯这周也要开始看"
+ },
+ {
+ "start": 1196.24,
+ "end": 1199.06,
+ "text": "强趋势我还有一节没讲"
+ },
+ {
+ "start": 1199.32,
+ "end": 1200.92,
+ "text": "我先讲一节看图说话"
+ },
+ {
+ "start": 1200.92,
+ "end": 1202.8,
+ "text": "强趋势后面再讲"
+ },
+ {
+ "start": 1202.8,
+ "end": 1203.6,
+ "text": "好吧"
+ },
+ {
+ "start": 1203.6,
+ "end": 1204.7,
+ "text": "今天视频就到这里"
+ },
+ {
+ "start": 1204.7,
+ "end": 1205.26,
+ "text": "谢谢大家"
+ }
+ ]
+}
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.srt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.srt"
new file mode 100644
index 0000000..4759c40
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.srt"
@@ -0,0 +1,2495 @@
+1
+00:00:00,440 --> 00:00:01,380
+哈喽大家好
+
+2
+00:00:01,380 --> 00:00:02,920
+开始我们今天一个视频
+
+3
+00:00:02,920 --> 00:00:05,300
+韩后出来一个消息
+
+4
+00:00:05,300 --> 00:00:07,100
+这个消息其实挺有意思的
+
+5
+00:00:07,100 --> 00:00:08,700
+就韩国那边开始抓人了
+
+6
+00:00:08,700 --> 00:00:12,140
+一般来讲开始抓人就离底不远了
+
+7
+00:00:12,140 --> 00:00:12,980
+是吧
+
+8
+00:00:12,980 --> 00:00:14,160
+但开个玩笑
+
+9
+00:00:14,160 --> 00:00:17,960
+但这个确实值得说一下的
+
+10
+00:00:17,960 --> 00:00:21,360
+那么我们这个底
+
+11
+00:00:21,360 --> 00:00:24,040
+因为今天整体来讲
+
+12
+00:00:24,040 --> 00:00:26,200
+让大家就是不要太过于焦虑
+
+13
+00:00:26,200 --> 00:00:26,840
+对吧
+
+14
+00:00:26,840 --> 00:00:28,240
+因为我们其实已经
+
+15
+00:00:28,240 --> 00:00:30,420
+属于一个慢慢摸底的过程当中
+
+16
+00:00:30,420 --> 00:00:32,400
+其实看整个60分钟的话
+
+17
+00:00:32,400 --> 00:00:33,880
+会更为清晰一点
+
+18
+00:00:33,880 --> 00:00:35,440
+整体的结构
+
+19
+00:00:35,440 --> 00:00:37,680
+其实是慢慢走一个收敛三角形
+
+20
+00:00:37,680 --> 00:00:40,640
+那么差不多再推演下去
+
+21
+00:00:40,640 --> 00:00:43,160
+到8月7号
+
+22
+00:00:43,160 --> 00:00:44,360
+差不多这周五
+
+23
+00:00:44,360 --> 00:00:46,060
+这周五和下周一
+
+24
+00:00:46,060 --> 00:00:49,000
+我感觉这个位置就差不多了
+
+25
+00:00:49,000 --> 00:00:51,500
+时间节点上来讲的话也差不多
+
+26
+00:00:51,500 --> 00:00:53,600
+因为我们今天整体的量
+
+27
+00:00:53,600 --> 00:00:55,020
+缩量对吧
+
+28
+00:00:55,020 --> 00:00:56,120
+缩量是对着的
+
+29
+00:00:56,120 --> 00:00:57,800
+如果今天反而放量不好
+
+30
+00:00:57,800 --> 00:00:57,920
+不好
+
+31
+00:00:57,920 --> 00:00:59,380
+所以缩量就对了
+
+32
+00:00:59,380 --> 00:01:00,440
+缩量的话
+
+33
+00:01:00,440 --> 00:01:03,300
+它就会慢慢的形成一个收敛三角形
+
+34
+00:01:03,300 --> 00:01:06,380
+那么一旦形成我们这一波反弹
+
+35
+00:01:06,380 --> 00:01:09,060
+因为现在走出一个背离结构
+
+36
+00:01:09,060 --> 00:01:09,500
+对吧
+
+37
+00:01:09,500 --> 00:01:10,640
+走出一个背离结构
+
+38
+00:01:10,640 --> 00:01:12,620
+那么反弹的日期我们看不到
+
+39
+00:01:12,620 --> 00:01:13,840
+但是能够反弹
+
+40
+00:01:13,840 --> 00:01:15,520
+差不多高度能够看到
+
+41
+00:01:15,520 --> 00:01:17,400
+差不多就1900吧
+
+42
+00:01:17,400 --> 00:01:19,620
+科广501900好吧
+
+43
+00:01:19,620 --> 00:01:21,980
+这个位置我是能看得到的
+
+44
+00:01:21,980 --> 00:01:24,180
+所以最近就慢慢加就好了
+
+45
+00:01:24,180 --> 00:01:25,560
+不用太过于着急
+
+46
+00:01:25,560 --> 00:01:27,380
+让你加快一点
+
+47
+00:01:27,380 --> 00:01:27,900
+加百分之百
+
+48
+00:01:27,900 --> 00:01:27,920
+加百分之百
+
+49
+00:01:27,920 --> 00:01:33,580
+10上周二加百分之十周三加百分之十周四加百分之百分之百分之三十
+
+50
+00:01:33,580 --> 00:01:35,800
+然后周五高开肯定这天不能加
+
+51
+00:01:35,800 --> 00:01:39,260
+那你就只能今天加那百分之四十
+
+52
+00:01:39,260 --> 00:01:40,380
+你再慢慢加下去
+
+53
+00:01:40,380 --> 00:01:41,480
+我觉得差不多吧
+
+54
+00:01:41,480 --> 00:01:46,160
+明天正常来讲是要收一根小阳线的好吧
+
+55
+00:01:46,160 --> 00:01:48,760
+那么他收一根小阳线
+
+56
+00:01:48,760 --> 00:01:51,980
+那明天肯定你买买阴不买阳嘛
+
+57
+00:01:51,980 --> 00:01:54,240
+但整体来讲就OK了
+
+58
+00:01:54,240 --> 00:01:55,780
+就差不多了
+
+59
+00:01:55,780 --> 00:01:56,040
+好吧
+
+60
+00:01:56,040 --> 00:01:57,900
+我觉得时间来讲也是一样的
+
+61
+00:01:57,920 --> 00:02:01,580
+差不多空间也也刚好再往下跌
+
+62
+00:02:01,580 --> 00:02:02,200
+说实话
+
+63
+00:02:02,200 --> 00:02:04,640
+整体整体不会看那么大了
+
+64
+00:02:04,640 --> 00:02:07,780
+因为今天整个行情大家其实有点看不明白
+
+65
+00:02:07,780 --> 00:02:08,740
+对吧
+
+66
+00:02:08,740 --> 00:02:11,060
+看不明白给各位看一张图
+
+67
+00:02:11,060 --> 00:02:17,320
+这个是情绪周期的这个东西前几个月还跟大家说过
+
+68
+00:02:17,320 --> 00:02:23,060
+因为还没有到整体整体开始讲情绪周期的时候对吧
+
+69
+00:02:23,060 --> 00:02:26,160
+但你看了之后就差不多就能明白情绪周期
+
+70
+00:02:26,160 --> 00:02:27,500
+我们一共分四个阶段
+
+71
+00:02:27,500 --> 00:02:27,900
+第一阶段是情绪周期
+
+72
+00:02:27,900 --> 00:02:29,240
+第二阶段是情绪反抽期
+
+73
+00:02:29,240 --> 00:02:31,600
+第二阶段是资金建仓期
+
+74
+00:02:31,600 --> 00:02:33,520
+第三阶段是祖身期
+
+75
+00:02:33,520 --> 00:02:36,020
+祖身期里面我们又分四个小阶段
+
+76
+00:02:36,020 --> 00:02:39,160
+第一个小阶段是人气的形成的一个初期
+
+77
+00:02:39,160 --> 00:02:41,200
+第二小阶段是人气亢奋期
+
+78
+00:02:41,200 --> 00:02:42,540
+第三小阶段成熟期
+
+79
+00:02:42,540 --> 00:02:44,860
+然后第四小阶段是衰退阶段
+
+80
+00:02:44,860 --> 00:02:46,860
+整个第四阶段呢
+
+81
+00:02:46,860 --> 00:02:49,600
+就情绪周期的第四个阶段是退烧期
+
+82
+00:02:49,600 --> 00:02:52,180
+所以一共有四个周期四大周期
+
+83
+00:02:52,180 --> 00:02:56,540
+那我们如果从情绪周期的角度来看的话
+
+84
+00:02:56,540 --> 00:02:56,620
+其实对于今天整个很重要的一阶段是什么呢
+
+85
+00:02:56,620 --> 00:02:56,640
+其实对于今天整个很重要的一阶段是
+
+86
+00:02:56,640 --> 00:03:00,220
+其实对于今天整个行情就会比较明了了
+
+87
+00:03:00,220 --> 00:03:02,540
+因为我们目前是处于情绪反抽期
+
+88
+00:03:02,540 --> 00:03:03,560
+也就是第一阶段
+
+89
+00:03:03,560 --> 00:03:05,820
+第一阶段的特征呢
+
+90
+00:03:05,820 --> 00:03:08,300
+就是前期的老腰股指跌反抽
+
+91
+00:03:08,300 --> 00:03:10,920
+所以我们可以看到
+
+92
+00:03:10,920 --> 00:03:15,700
+前段时间的MLCC的那个票涨得比较好
+
+93
+00:03:15,700 --> 00:03:15,960
+对吧
+
+94
+00:03:15,960 --> 00:03:16,720
+包括今天
+
+95
+00:03:16,720 --> 00:03:20,860
+我们也看到有一些商业航天出来了
+
+96
+00:03:20,860 --> 00:03:23,620
+包括说AI应用那边的票
+
+97
+00:03:23,620 --> 00:03:25,300
+整体走的都还不错
+
+98
+00:03:25,300 --> 00:03:25,540
+对吧
+
+99
+00:03:25,540 --> 00:03:25,840
+今天
+
+100
+00:03:25,840 --> 00:03:26,620
+因为今天是上个月
+
+101
+00:03:26,620 --> 00:03:27,920
+今天有个票是炸了嘛
+
+102
+00:03:27,920 --> 00:03:28,960
+炸了之后
+
+103
+00:03:28,960 --> 00:03:29,800
+NEO这边
+
+104
+00:03:29,800 --> 00:03:31,180
+它是板上了
+
+105
+00:03:31,180 --> 00:03:33,800
+这个也是属于之前的一个老腰股的
+
+106
+00:03:33,800 --> 00:03:35,000
+之前的一个老腰股
+
+107
+00:03:35,000 --> 00:03:37,680
+所以从本质上来讲呢
+
+108
+00:03:37,680 --> 00:03:39,300
+是处于一个情绪反抽期
+
+109
+00:03:39,300 --> 00:03:41,700
+然后这些老腰反抽
+
+110
+00:03:41,700 --> 00:03:43,560
+它的整体高度不会很高的
+
+111
+00:03:43,560 --> 00:03:45,080
+所以你们一直在问M
+
+112
+00:03:45,080 --> 00:03:45,480
+对吧
+
+113
+00:03:45,480 --> 00:03:46,840
+MLCC走这么强
+
+114
+00:03:46,840 --> 00:03:48,980
+它是不是可以
+
+115
+00:03:48,980 --> 00:03:51,300
+后面有行情的话
+
+116
+00:03:51,300 --> 00:03:52,100
+它会继续更强
+
+117
+00:03:52,100 --> 00:03:53,020
+不一定的
+
+118
+00:03:53,020 --> 00:03:55,180
+它后面哪怕是会走
+
+119
+00:03:55,180 --> 00:03:56,600
+也不一定会真的有走得比较好
+
+120
+00:03:56,620 --> 00:03:58,520
+它会在主线行情走出来之后强
+
+121
+00:03:58,520 --> 00:04:00,640
+我们后面肯定还是要去看主线行情
+
+122
+00:04:00,640 --> 00:04:03,080
+但是离情绪反抽期到主线的一个行程
+
+123
+00:04:03,080 --> 00:04:04,960
+它会有一段的时间
+
+124
+00:04:04,960 --> 00:04:05,320
+好吧
+
+125
+00:04:05,320 --> 00:04:05,980
+快的话
+
+126
+00:04:05,980 --> 00:04:06,940
+可能一两周
+
+127
+00:04:06,940 --> 00:04:07,780
+慢的话
+
+128
+00:04:07,780 --> 00:04:11,200
+可能这边稍微等的时间比较久了
+
+129
+00:04:11,200 --> 00:04:14,200
+但我今天想想跟各位聊的呢
+
+130
+00:04:14,200 --> 00:04:15,940
+就是CPU这边
+
+131
+00:04:15,940 --> 00:04:19,480
+像今天的部分的光走的还不错
+
+132
+00:04:19,480 --> 00:04:20,140
+对吧
+
+133
+00:04:20,140 --> 00:04:21,120
+因为有一些
+
+134
+00:04:21,120 --> 00:04:23,620
+我们如果看这个池子的话
+
+135
+00:04:23,620 --> 00:04:26,600
+能够看到有前面的这些光
+
+136
+00:04:26,620 --> 00:04:29,380
+因为有一些走的还不错
+
+137
+00:04:29,380 --> 00:04:30,360
+包括这边
+
+138
+00:04:30,360 --> 00:04:33,240
+这边少记了一个彭东光
+
+139
+00:04:33,240 --> 00:04:34,880
+少记一个北交所的
+
+140
+00:04:34,880 --> 00:04:36,720
+这个今天也不错
+
+141
+00:04:36,720 --> 00:04:38,420
+没有让各位买的意思
+
+142
+00:04:38,420 --> 00:04:39,480
+就是我说
+
+143
+00:04:39,480 --> 00:04:41,020
+因为为什么呢
+
+144
+00:04:41,020 --> 00:04:42,220
+因为光通信这边
+
+145
+00:04:42,220 --> 00:04:46,360
+它出了一个还行的利好
+
+146
+00:04:46,360 --> 00:04:48,160
+因为Rubin Ultra
+
+147
+00:04:48,160 --> 00:04:50,300
+就是因为它下一代的
+
+148
+00:04:50,300 --> 00:04:51,260
+就Rubin嘛
+
+149
+00:04:51,260 --> 00:04:52,220
+它原先是
+
+150
+00:04:52,220 --> 00:04:55,140
+Rubin Ultra是384级别
+
+151
+00:04:55,140 --> 00:04:56,600
+然后现在是
+
+152
+00:04:56,620 --> 00:04:59,240
+缩减到了192级别
+
+153
+00:04:59,240 --> 00:05:01,140
+为什么要这个样子缩呢
+
+154
+00:05:01,140 --> 00:05:02,740
+有两个原因
+
+155
+00:05:02,740 --> 00:05:06,220
+一个是因为HBM供应紧张
+
+156
+00:05:06,220 --> 00:05:06,940
+价格上涨
+
+157
+00:05:06,940 --> 00:05:08,520
+另一个原因呢
+
+158
+00:05:08,520 --> 00:05:10,060
+是因为电力瓶颈
+
+159
+00:05:10,060 --> 00:05:13,260
+所以它们整个规格是变小的
+
+160
+00:05:13,260 --> 00:05:16,060
+规格变小之后
+
+161
+00:05:16,060 --> 00:05:19,340
+那么就会影响到HBM的放量
+
+162
+00:05:19,340 --> 00:05:20,380
+对吧
+
+163
+00:05:20,380 --> 00:05:22,800
+它的整个增长是放缓了的
+
+164
+00:05:22,800 --> 00:05:26,080
+但是对于网络的投入
+
+165
+00:05:26,080 --> 00:05:27,060
+它会上升
+
+166
+00:05:27,060 --> 00:05:29,940
+也就是光互联NVSwitch
+
+167
+00:05:29,940 --> 00:05:32,720
+这些网络的用量会提高
+
+168
+00:05:32,720 --> 00:05:34,000
+因为之前的话
+
+169
+00:05:34,000 --> 00:05:38,100
+整体HBM占比占整个结构
+
+170
+00:05:38,100 --> 00:05:39,660
+大概是40%
+
+171
+00:05:39,660 --> 00:05:44,660
+然后从328G降到198G之后
+
+172
+00:05:44,660 --> 00:05:46,000
+384G
+
+173
+00:05:46,000 --> 00:05:48,620
+然后降到192G之后
+
+174
+00:05:48,620 --> 00:05:50,920
+它的HBM的占的总成本
+
+175
+00:05:50,920 --> 00:05:52,860
+大概要缩到28%
+
+176
+00:05:52,860 --> 00:05:54,240
+就缩12%左右
+
+177
+00:05:54,240 --> 00:05:56,040
+然后网络
+
+178
+00:05:56,080 --> 00:05:57,260
+这一块的配比
+
+179
+00:05:57,260 --> 00:05:58,300
+就要提高
+
+180
+00:05:58,300 --> 00:06:00,280
+从4%提高到12%
+
+181
+00:06:00,280 --> 00:06:03,620
+所以今天光整体走得还不错
+
+182
+00:06:03,620 --> 00:06:04,820
+它是有利好的
+
+183
+00:06:04,820 --> 00:06:06,760
+也就是说
+
+184
+00:06:06,760 --> 00:06:10,200
+目前的市场没有乱潮
+
+185
+00:06:10,200 --> 00:06:12,280
+就是没有出现问题
+
+186
+00:06:12,280 --> 00:06:12,920
+就是说
+
+187
+00:06:12,920 --> 00:06:15,680
+哪怕是它有利好
+
+188
+00:06:15,680 --> 00:06:16,900
+是正常你要去涨
+
+189
+00:06:16,900 --> 00:06:19,040
+包括之前的MSCC
+
+190
+00:06:19,040 --> 00:06:20,460
+也是因为涨价
+
+191
+00:06:20,460 --> 00:06:22,200
+所以才往上去涨
+
+192
+00:06:22,200 --> 00:06:23,680
+也就是说
+
+193
+00:06:23,680 --> 00:06:24,700
+现在的东西
+
+194
+00:06:24,700 --> 00:06:25,880
+它并不是说
+
+195
+00:06:25,880 --> 00:06:27,720
+没有根源的市场
+
+196
+00:06:27,720 --> 00:06:29,780
+不讲道理的胡乱的砸
+
+197
+00:06:29,780 --> 00:06:31,340
+它都是有原因的
+
+198
+00:06:31,340 --> 00:06:34,180
+为什么韩国那边会砸
+
+199
+00:06:34,180 --> 00:06:38,080
+因为HBM的用量变少了
+
+200
+00:06:38,080 --> 00:06:39,760
+所以存储整体沉压
+
+201
+00:06:39,760 --> 00:06:42,200
+这是整体的原因
+
+202
+00:06:42,200 --> 00:06:44,740
+那么HBM这边一旦沉压之后
+
+203
+00:06:44,740 --> 00:06:47,040
+对于半导体设备
+
+204
+00:06:47,040 --> 00:06:48,360
+然后我们这边的
+
+205
+00:06:48,360 --> 00:06:50,080
+这种存储芯片
+
+206
+00:06:50,080 --> 00:06:50,320
+对吧
+
+207
+00:06:50,320 --> 00:06:51,320
+就更不用说了
+
+208
+00:06:51,320 --> 00:06:53,360
+那么他们基本上也都是
+
+209
+00:06:53,360 --> 00:06:54,960
+本来就不是很正宗的
+
+210
+00:06:54,960 --> 00:06:55,580
+然后
+
+211
+00:06:55,880 --> 00:06:56,840
+那边一跌
+
+212
+00:06:56,840 --> 00:06:59,260
+那么我们这边自然也跟着跌
+
+213
+00:06:59,260 --> 00:07:02,260
+然后之前因为半导体涨了
+
+214
+00:07:02,260 --> 00:07:05,860
+因为半导体是最后一轮去涨的
+
+215
+00:07:05,860 --> 00:07:07,100
+所以它跌的时候
+
+216
+00:07:07,100 --> 00:07:09,000
+也是最后一轮跌的
+
+217
+00:07:09,000 --> 00:07:10,800
+然后在上一周
+
+218
+00:07:10,800 --> 00:07:13,040
+光这边是完成了补跌
+
+219
+00:07:13,040 --> 00:07:16,040
+然后今天半导体那边是进行补跌
+
+220
+00:07:16,040 --> 00:07:18,480
+像一些市值比较大的
+
+221
+00:07:18,480 --> 00:07:18,740
+对吧
+
+222
+00:07:18,740 --> 00:07:19,820
+它跌的还是比较狠
+
+223
+00:07:19,820 --> 00:07:20,600
+像中威公司
+
+224
+00:07:20,600 --> 00:07:22,800
+包括华清科这种东西
+
+225
+00:07:22,800 --> 00:07:23,660
+整体来讲
+
+226
+00:07:23,660 --> 00:07:25,860
+跌的还是比较厉害的
+
+227
+00:07:25,880 --> 00:07:26,980
+然后这些呢
+
+228
+00:07:26,980 --> 00:07:29,980
+占科创50的权重又比较高
+
+229
+00:07:29,980 --> 00:07:33,480
+所以以至于整个科创版
+
+230
+00:07:33,480 --> 00:07:34,680
+科创50
+
+231
+00:07:34,680 --> 00:07:36,800
+科创半导体设备ETF
+
+232
+00:07:36,800 --> 00:07:38,940
+今天表现很
+
+233
+00:07:38,940 --> 00:07:39,920
+很惨
+
+234
+00:07:39,920 --> 00:07:42,860
+但是这最后一个板块也跌
+
+235
+00:07:42,860 --> 00:07:43,120
+对吧
+
+236
+00:07:43,120 --> 00:07:44,260
+我们之前超节点
+
+237
+00:07:44,260 --> 00:07:45,140
+什么算力
+
+238
+00:07:45,140 --> 00:07:46,040
+国产算力
+
+239
+00:07:46,040 --> 00:07:47,280
+这些东西也都跌过了
+
+240
+00:07:47,280 --> 00:07:48,920
+那么这些东西
+
+241
+00:07:48,920 --> 00:07:50,420
+这两天的一个下跌
+
+242
+00:07:50,420 --> 00:07:51,660
+我认为差不多了
+
+243
+00:07:51,660 --> 00:07:52,280
+好吧
+
+244
+00:07:52,280 --> 00:07:53,440
+时候也差不多了
+
+245
+00:07:53,440 --> 00:07:54,800
+从时间上来讲
+
+246
+00:07:54,800 --> 00:07:55,840
+从空间上来讲
+
+247
+00:07:55,880 --> 00:07:58,180
+基本上也都也都快到位了
+
+248
+00:07:58,180 --> 00:07:59,980
+基本上再跌
+
+249
+00:07:59,980 --> 00:08:01,300
+再调整个
+
+250
+00:08:01,300 --> 00:08:03,760
+三三四天左右
+
+251
+00:08:03,760 --> 00:08:05,100
+我觉得就差不多了
+
+252
+00:08:05,100 --> 00:08:05,700
+就到位了
+
+253
+00:08:05,700 --> 00:08:07,060
+如果能够那样调的话
+
+254
+00:08:07,060 --> 00:08:08,160
+我觉得是比较好的
+
+255
+00:08:08,160 --> 00:08:10,100
+因为它整个完整的形态
+
+256
+00:08:10,100 --> 00:08:10,980
+也能走出来
+
+257
+00:08:10,980 --> 00:08:12,960
+所以科创这边
+
+258
+00:08:12,960 --> 00:08:14,760
+虽然说它创新低了
+
+259
+00:08:14,760 --> 00:08:16,520
+但是整体走了一个背离
+
+260
+00:08:16,520 --> 00:08:17,060
+好吧
+
+261
+00:08:17,060 --> 00:08:18,160
+这种背离走出来
+
+262
+00:08:18,160 --> 00:08:21,160
+反而是对于后面的行情会更好的
+
+263
+00:08:21,160 --> 00:08:22,640
+那么美中不足的
+
+264
+00:08:22,640 --> 00:08:24,520
+就是上证这边没有踩地
+
+265
+00:08:24,520 --> 00:08:25,560
+而这条没有砸下来
+
+266
+00:08:25,880 --> 00:08:28,960
+那我估计国家队也不太想
+
+267
+00:08:28,960 --> 00:08:30,480
+就是让上证这边下来
+
+268
+00:08:30,480 --> 00:08:32,280
+如果不想让上证这边下来
+
+269
+00:08:32,280 --> 00:08:33,560
+也无所谓好吧
+
+270
+00:08:33,560 --> 00:08:36,120
+本身它能够横住也都ok了
+
+271
+00:08:36,120 --> 00:08:37,920
+这个没有什么关系
+
+272
+00:08:37,920 --> 00:08:40,480
+因此我们再回过头来看
+
+273
+00:08:40,480 --> 00:08:42,000
+对吧
+
+274
+00:08:42,000 --> 00:08:45,080
+我们重点就是后面
+
+275
+00:08:45,080 --> 00:08:46,100
+包括说
+
+276
+00:08:46,100 --> 00:08:50,460
+因为光这边光通信这边是有利好的
+
+277
+00:08:50,460 --> 00:08:53,780
+所以它这两天可能会有表现
+
+278
+00:08:53,780 --> 00:08:55,840
+如果说明天收一根阳线
+
+279
+00:08:55,880 --> 00:09:00,240
+那么这些光是有可能会表现的
+
+280
+00:09:00,240 --> 00:09:04,320
+但是我认为它整体一个幅度不会很大
+
+281
+00:09:04,320 --> 00:09:07,400
+基本上也就1000就差不多了好吧
+
+282
+00:09:07,400 --> 00:09:11,240
+我觉得这一波它整体反弹也就差不多了
+
+283
+00:09:11,240 --> 00:09:12,780
+然后后面它再慢慢上
+
+284
+00:09:12,780 --> 00:09:13,800
+后面再慢慢上
+
+285
+00:09:13,800 --> 00:09:16,360
+因为等整体的一个结构起稳
+
+286
+00:09:16,360 --> 00:09:16,880
+好吧
+
+287
+00:09:16,880 --> 00:09:19,680
+在这边不能说就把它看作是
+
+288
+00:09:19,680 --> 00:09:21,220
+多么牛的一个行情了
+
+289
+00:09:21,220 --> 00:09:23,020
+因为主线行情目前还没有出来
+
+290
+00:09:23,020 --> 00:09:24,800
+AI应用机器人
+
+291
+00:09:24,800 --> 00:09:25,840
+包括商业航天人
+
+292
+00:09:25,840 --> 00:09:27,380
+对吧
+
+293
+00:09:27,380 --> 00:09:29,940
+硬科技这些都是想要去表现
+
+294
+00:09:29,940 --> 00:09:34,800
+都想要去做下一个阶段的一个主线
+
+295
+00:09:34,800 --> 00:09:36,840
+但是没有那么容易
+
+296
+00:09:36,840 --> 00:09:38,120
+谁能走出来了
+
+297
+00:09:38,120 --> 00:09:39,160
+我们进一步观察
+
+298
+00:09:39,160 --> 00:09:42,480
+目前来讲的话最有可能是AI应用和
+
+299
+00:09:42,480 --> 00:09:45,040
+还是和半导体相关的好吧
+
+300
+00:09:45,040 --> 00:09:46,320
+但大家也知道
+
+301
+00:09:46,320 --> 00:09:49,140
+因为AI应用我们这边确实不太行
+
+302
+00:09:49,140 --> 00:09:50,680
+正宗的在港股
+
+303
+00:09:50,680 --> 00:09:54,000
+昨天跟各位说了一个港股的票
+
+304
+00:09:54,000 --> 00:09:55,800
+那个票涨的有点开了
+
+305
+00:09:55,840 --> 00:09:57,380
+开盘都开的特别猛
+
+306
+00:09:57,380 --> 00:09:58,920
+我也
+
+307
+00:09:58,920 --> 00:10:00,700
+我也没办法说了好吧
+
+308
+00:10:00,700 --> 00:10:03,000
+这种东西我有还是不提了
+
+309
+00:10:03,000 --> 00:10:04,540
+提出去就
+
+310
+00:10:04,540 --> 00:10:05,560
+就长成那个样子
+
+311
+00:10:05,560 --> 00:10:06,600
+很恶心
+
+312
+00:10:06,600 --> 00:10:08,120
+所以我们
+
+313
+00:10:08,120 --> 00:10:09,920
+就继续去
+
+314
+00:10:09,920 --> 00:10:10,940
+观察就好了
+
+315
+00:10:10,940 --> 00:10:12,480
+谁能走出来
+
+316
+00:10:12,480 --> 00:10:13,500
+不知道
+
+317
+00:10:13,500 --> 00:10:14,780
+等走出来之后
+
+318
+00:10:14,780 --> 00:10:16,840
+因为主线行情它一定是走出来
+
+319
+00:10:16,840 --> 00:10:17,860
+走出来我们知道
+
+320
+00:10:17,860 --> 00:10:18,880
+看到它成
+
+321
+00:10:18,880 --> 00:10:19,900
+成为主线行情的时候
+
+322
+00:10:19,900 --> 00:10:21,180
+它一定已经是
+
+323
+00:10:21,180 --> 00:10:22,200
+涨了一
+
+324
+00:10:22,200 --> 00:10:23,480
+一段了
+
+325
+00:10:23,480 --> 00:10:24,760
+就是这些
+
+326
+00:10:24,760 --> 00:10:25,800
+我们目前
+
+327
+00:10:25,800 --> 00:10:27,080
+所有的
+
+328
+00:10:27,080 --> 00:10:29,380
+包括说AI硬科技
+
+329
+00:10:29,380 --> 00:10:31,440
+AI应用
+
+330
+00:10:31,440 --> 00:10:34,500
+商业航天机器人这些东西全都属于
+
+331
+00:10:34,500 --> 00:10:35,520
+超跌反弹
+
+332
+00:10:35,520 --> 00:10:38,080
+只不过AI应用它跌的是
+
+333
+00:10:38,080 --> 00:10:39,880
+时间是比较久的对吧
+
+334
+00:10:39,880 --> 00:10:40,900
+它整体
+
+335
+00:10:40,900 --> 00:10:43,460
+筹码结构出新的也比较完整
+
+336
+00:10:43,460 --> 00:10:45,260
+那么周五
+
+337
+00:10:45,260 --> 00:10:47,820
+资金流向了最没有
+
+338
+00:10:47,820 --> 00:10:49,600
+阻力的一个位置
+
+339
+00:10:49,600 --> 00:10:52,420
+包括说这边应该是有一些人
+
+340
+00:10:52,420 --> 00:10:53,440
+想要去做出
+
+341
+00:10:53,440 --> 00:10:54,480
+东西来的
+
+342
+00:10:54,480 --> 00:10:55,760
+所以他们整个
+
+343
+00:10:55,760 --> 00:10:58,320
+最近吹得非常厉害对吧
+
+344
+00:10:58,320 --> 00:11:00,360
+一些卖方这边的
+
+345
+00:11:00,360 --> 00:11:01,640
+资料发的也特别
+
+346
+00:11:01,640 --> 00:11:02,420
+特别多
+
+347
+00:11:02,420 --> 00:11:05,240
+那么说明他们是想要做出点动作
+
+348
+00:11:05,240 --> 00:11:07,280
+那么再配合到
+
+349
+00:11:07,280 --> 00:11:08,820
+一些实际的
+
+350
+00:11:08,820 --> 00:11:11,120
+因素例如DeepSeek那边
+
+351
+00:11:11,120 --> 00:11:13,160
+降价包括OpenAI降价
+
+352
+00:11:13,160 --> 00:11:17,260
+然后最大的呢其实就是腾讯那边的
+
+353
+00:11:17,260 --> 00:11:20,340
+那个AI智能体WorkBuddy
+
+354
+00:11:20,340 --> 00:11:22,380
+那么这个东西
+
+355
+00:11:22,380 --> 00:11:25,720
+甚至后面是有可能会
+
+356
+00:11:25,760 --> 00:11:28,320
+被统一部署到
+
+357
+00:11:28,320 --> 00:11:29,340
+央国企的
+
+358
+00:11:29,340 --> 00:11:30,880
+以及政府单位当中
+
+359
+00:11:30,880 --> 00:11:32,920
+所以后面的催化
+
+360
+00:11:32,920 --> 00:11:33,700
+有
+
+361
+00:11:33,700 --> 00:11:34,980
+故事也有
+
+362
+00:11:34,980 --> 00:11:38,300
+AI的业绩也都能够给你挖掘到
+
+363
+00:11:38,300 --> 00:11:39,580
+也都能够实现
+
+364
+00:11:39,580 --> 00:11:40,600
+但是
+
+365
+00:11:40,600 --> 00:11:42,660
+它能不能走出来
+
+366
+00:11:42,660 --> 00:11:44,960
+需要整体资金的一个共识
+
+367
+00:11:44,960 --> 00:11:46,760
+好吧因此我们在这段
+
+368
+00:11:46,760 --> 00:11:48,040
+这段时间内呢
+
+369
+00:11:48,040 --> 00:11:50,080
+还是不要追高
+
+370
+00:11:50,080 --> 00:11:51,360
+对啊不要追高
+
+371
+00:11:51,360 --> 00:11:54,180
+慢慢的去把科技的仓位打上去
+
+372
+00:11:54,180 --> 00:11:55,720
+因为这些东西都超级反弹
+
+373
+00:11:55,760 --> 00:11:57,040
+只要谈
+
+374
+00:11:57,040 --> 00:11:59,340
+科创50谈
+
+375
+00:11:59,340 --> 00:12:00,880
+不管那些半导体也好
+
+376
+00:12:00,880 --> 00:12:02,160
+AI硬件也好
+
+377
+00:12:02,160 --> 00:12:03,440
+光PCB这些东西
+
+378
+00:12:03,440 --> 00:12:05,740
+他们都会有表现
+
+379
+00:12:05,740 --> 00:12:07,540
+整个创业板
+
+380
+00:12:07,540 --> 00:12:09,840
+上涨这些科技股也都会有表现
+
+381
+00:12:09,840 --> 00:12:11,380
+甚至那些AI应用对吧
+
+382
+00:12:11,380 --> 00:12:13,160
+他们也都会有表现
+
+383
+00:12:13,160 --> 00:12:15,220
+我们整体把仓位推上去
+
+384
+00:12:15,220 --> 00:12:17,520
+配置选手呢就把仓位推上去
+
+385
+00:12:17,520 --> 00:12:21,100
+去做ETF这些东西没有什么问题
+
+386
+00:12:21,100 --> 00:12:23,400
+该吃到都能吃到
+
+387
+00:12:23,400 --> 00:12:24,680
+至于说
+
+388
+00:12:24,680 --> 00:12:25,720
+我们去做
+
+389
+00:12:25,720 --> 00:12:27,760
+组生期的时候
+
+390
+00:12:27,760 --> 00:12:28,280
+对吧
+
+391
+00:12:28,280 --> 00:12:30,580
+那得等他们资金杀出来之后
+
+392
+00:12:30,580 --> 00:12:31,600
+我们再去做选择
+
+393
+00:12:31,600 --> 00:12:33,920
+这是那些主力资金要做的事情
+
+394
+00:12:33,920 --> 00:12:35,440
+对吧他们去
+
+395
+00:12:35,440 --> 00:12:37,240
+杀出一条血路来
+
+396
+00:12:37,240 --> 00:12:38,780
+然后我们跟着后
+
+397
+00:12:38,780 --> 00:12:40,560
+跟在后面吃肉就行了
+
+398
+00:12:40,560 --> 00:12:41,600
+就这么简单
+
+399
+00:12:41,600 --> 00:12:43,640
+现阶段不要去赌方向
+
+400
+00:12:43,640 --> 00:12:45,180
+有能力做港股的人
+
+401
+00:12:45,180 --> 00:12:46,720
+去做港股的相关的配置
+
+402
+00:12:46,720 --> 00:12:48,240
+好吧
+
+403
+00:12:48,240 --> 00:12:50,800
+这个都没有什么问题
+
+404
+00:12:50,800 --> 00:12:51,840
+嗯
+
+405
+00:12:51,840 --> 00:12:54,640
+昨天跟各位讲了8月份的一个宏观
+
+406
+00:12:54,640 --> 00:12:55,680
+感觉是不是有点
+
+407
+00:12:55,680 --> 00:12:57,720
+把大家吓到了对吧
+
+408
+00:12:57,720 --> 00:13:00,540
+其实不用太过于悲观
+
+409
+00:13:00,540 --> 00:13:01,060
+好吧
+
+410
+00:13:01,060 --> 00:13:04,120
+这些数据并不一定非得要往
+
+411
+00:13:04,120 --> 00:13:05,400
+不好的方向走
+
+412
+00:13:05,400 --> 00:13:08,480
+它是有可能会往好的方向走的
+
+413
+00:13:08,480 --> 00:13:11,800
+所以不用太过于非得就说这种时候
+
+414
+00:13:11,800 --> 00:13:13,340
+就要出现利空了
+
+415
+00:13:13,340 --> 00:13:16,680
+我们其实上周五的上涨也是因为
+
+416
+00:13:16,680 --> 00:13:19,240
+出现的是利好的消息对不对
+
+417
+00:13:19,240 --> 00:13:20,520
+不是出现利空的嘛
+
+418
+00:13:20,520 --> 00:13:22,040
+所以是出现利好的
+
+419
+00:13:22,040 --> 00:13:23,840
+所以我们也不用太过于悲观
+
+420
+00:13:23,840 --> 00:13:25,380
+照说整个8月份
+
+421
+00:13:25,380 --> 00:13:27,180
+就让他们慢慢磨底嘛
+
+422
+00:13:27,180 --> 00:13:28,960
+这个底磨的时间越长
+
+423
+00:13:28,960 --> 00:13:31,020
+也就代表着后面
+
+424
+00:13:31,020 --> 00:13:34,080
+金九银十的整个行情会长得越顺畅
+
+425
+00:13:34,080 --> 00:13:34,600
+好吧
+
+426
+00:13:34,600 --> 00:13:35,620
+我们整体就
+
+427
+00:13:35,620 --> 00:13:37,420
+我觉得在这种位置
+
+428
+00:13:37,420 --> 00:13:40,220
+没有必要去悲观了
+
+429
+00:13:40,220 --> 00:13:41,260
+好吧
+
+430
+00:13:41,260 --> 00:13:43,560
+这种位置真的没有必要去悲观
+
+431
+00:13:43,560 --> 00:13:44,840
+该怎么做怎么做
+
+432
+00:13:44,840 --> 00:13:46,120
+等出现了
+
+433
+00:13:46,120 --> 00:13:48,160
+我们想象当中的主线行情的时候
+
+434
+00:13:48,160 --> 00:13:50,460
+到时候去积极参与就好了
+
+435
+00:13:50,460 --> 00:13:52,260
+现阶段呢就是
+
+436
+00:13:52,260 --> 00:13:55,340
+尽量不要去追涨杀跌好吧
+
+437
+00:13:55,340 --> 00:13:56,620
+不要去追涨杀跌
+
+438
+00:13:56,620 --> 00:13:58,920
+不要被大资金牵着鼻子走
+
+439
+00:13:58,920 --> 00:14:01,480
+因为现阶段从情绪周期上来讲
+
+440
+00:14:01,480 --> 00:14:03,280
+是很难的
+
+441
+00:14:03,280 --> 00:14:04,560
+我承认这
+
+442
+00:14:04,560 --> 00:14:05,320
+我承认
+
+443
+00:14:05,320 --> 00:14:06,860
+除了主升期
+
+444
+00:14:06,860 --> 00:14:08,400
+主升期的
+
+445
+00:14:08,400 --> 00:14:12,240
+人气亢奋期和成熟阶段相对来讲好做之外
+
+446
+00:14:12,240 --> 00:14:14,800
+其他的这些所有的节点都不好做
+
+447
+00:14:14,800 --> 00:14:16,580
+都很难做
+
+448
+00:14:16,580 --> 00:14:18,120
+所以对于
+
+449
+00:14:18,120 --> 00:14:20,180
+散户来讲其实
+
+450
+00:14:20,180 --> 00:14:23,760
+你在做票的很大程度上其实都是在等
+
+451
+00:14:23,760 --> 00:14:24,780
+在慢慢的等
+
+452
+00:14:24,780 --> 00:14:25,800
+在熬时间
+
+453
+00:14:25,800 --> 00:14:30,160
+正儿八经可以做行情的时候其实很少
+
+454
+00:14:30,160 --> 00:14:32,200
+对吧所以我们整体还是要等
+
+455
+00:14:32,200 --> 00:14:34,260
+包括到了资金建仓期
+
+456
+00:14:34,260 --> 00:14:38,340
+这边整体的仓位甚至是要比情绪反抽期要更少的
+
+457
+00:14:38,340 --> 00:14:41,420
+因为情绪反抽我们还能去做一个
+
+458
+00:14:41,420 --> 00:14:42,700
+超跌反弹
+
+459
+00:14:42,700 --> 00:14:44,240
+包括说我们
+
+460
+00:14:44,240 --> 00:14:47,560
+这整个7月份哪怕是白酒医药
+
+461
+00:14:47,560 --> 00:14:51,400
+消费对吧那些东西他们其实本质上也都是超跌反弹
+
+462
+00:14:51,400 --> 00:14:54,740
+就是大量的整个超跌股呢同步上的
+
+463
+00:14:54,740 --> 00:14:58,840
+只不过他会在不同的板块之间去进行一个表现
+
+464
+00:14:58,840 --> 00:15:01,660
+但是在整体上涨三五天之后
+
+465
+00:15:01,660 --> 00:15:03,180
+就会陆续的分化
+
+466
+00:15:03,180 --> 00:15:04,980
+纯粹依赖那种
+
+467
+00:15:04,980 --> 00:15:09,080
+超跌的品种呢他会回落的比较快一点
+
+468
+00:15:09,080 --> 00:15:11,120
+具备产业逻辑
+
+469
+00:15:11,120 --> 00:15:12,400
+业绩支撑
+
+470
+00:15:12,400 --> 00:15:12,920
+与
+
+471
+00:15:12,920 --> 00:15:15,480
+资金承接方向的他会走出第二波
+
+472
+00:15:15,480 --> 00:15:17,780
+那么这种第二波
+
+473
+00:15:17,780 --> 00:15:19,320
+他就是主线了
+
+474
+00:15:19,320 --> 00:15:21,880
+所以我们在反弹初期
+
+475
+00:15:21,880 --> 00:15:23,920
+没有必要去
+
+476
+00:15:23,920 --> 00:15:24,700
+找对吧
+
+477
+00:15:24,700 --> 00:15:27,000
+谁会是主线
+
+478
+00:15:27,000 --> 00:15:28,020
+因为第
+
+479
+00:15:28,020 --> 00:15:29,300
+因为
+
+480
+00:15:29,300 --> 00:15:33,140
+我们在第1波反弹的时候他解决的就是
+
+481
+00:15:33,140 --> 00:15:34,420
+情绪修复
+
+482
+00:15:34,420 --> 00:15:38,780
+你到了第2波才会去定产业方向和龙头归属
+
+483
+00:15:38,780 --> 00:15:41,600
+所以我们今天也能够看到一些
+
+484
+00:15:41,600 --> 00:15:44,920
+AI应用的方向对吧他们是出现了分歧的
+
+485
+00:15:44,920 --> 00:15:47,220
+因为周五整体已经普涨了嘛
+
+486
+00:15:47,220 --> 00:15:48,760
+整体板块已经高潮了
+
+487
+00:15:48,760 --> 00:15:50,300
+那么他们出现分歧
+
+488
+00:15:50,300 --> 00:15:52,600
+分歧之后的再加强
+
+489
+00:15:52,600 --> 00:15:54,660
+他才能够确认
+
+490
+00:15:54,700 --> 00:15:56,740
+这个方向是有的
+
+491
+00:15:56,740 --> 00:15:57,520
+东西做
+
+492
+00:15:57,520 --> 00:15:59,300
+你在第1天
+
+493
+00:15:59,300 --> 00:16:02,380
+普涨的时候那一天你反而是没有什么可以做的
+
+494
+00:16:02,380 --> 00:16:05,200
+你整体仓位你是推不上去的
+
+495
+00:16:05,200 --> 00:16:08,520
+你只有他们在分歧结束之后再次
+
+496
+00:16:08,520 --> 00:16:10,060
+转强的时候
+
+497
+00:16:10,060 --> 00:16:11,860
+我们才能够对他
+
+498
+00:16:11,860 --> 00:16:12,880
+高看一
+
+499
+00:16:12,880 --> 00:16:15,180
+所以行情是走出来不是拆出来的
+
+500
+00:16:15,180 --> 00:16:15,940
+好吧
+
+501
+00:16:15,940 --> 00:16:17,220
+大家
+
+502
+00:16:17,220 --> 00:16:20,560
+很多散户陷入了一个误区就是非得要找确定性
+
+503
+00:16:20,560 --> 00:16:21,840
+没有确定性的
+
+504
+00:16:21,840 --> 00:16:24,400
+等真出确定性的时候你又不敢去买了
+
+505
+00:16:24,400 --> 00:16:25,940
+还是我说了一句话好吧
+
+506
+00:16:25,940 --> 00:16:28,500
+做交易不要又贪又怂
+
+507
+00:16:28,500 --> 00:16:29,780
+贪呢是因为
+
+508
+00:16:29,780 --> 00:16:31,060
+主线涨出来了
+
+509
+00:16:31,060 --> 00:16:33,880
+主线没涨出来你想买在最底部对吧
+
+510
+00:16:33,880 --> 00:16:36,180
+那么怂呢是主线涨起来了
+
+511
+00:16:36,180 --> 00:16:38,220
+然后你又不敢买了
+
+512
+00:16:38,220 --> 00:16:40,520
+往往就错过了主线行情
+
+513
+00:16:40,520 --> 00:16:42,320
+这是散户最大的一个问题
+
+514
+00:16:42,320 --> 00:16:45,140
+但也是交易当中最反人性的事情
+
+515
+00:16:45,140 --> 00:16:47,700
+这也是所有散户必须要卖过的坎
+
+516
+00:16:47,700 --> 00:16:50,520
+好吧你不卖过这个坎呢
+
+517
+00:16:50,520 --> 00:16:53,320
+自然你就享受不到主身了
+
+518
+00:16:53,320 --> 00:16:54,360
+这是我今天想讲的
+
+519
+00:16:54,360 --> 00:16:55,380
+想跟各位说的
+
+520
+00:16:55,380 --> 00:16:56,660
+所以最近
+
+521
+00:16:56,660 --> 00:16:57,680
+依旧
+
+522
+00:16:57,680 --> 00:16:58,960
+大跌大买小跌小买
+
+523
+00:16:58,960 --> 00:17:00,500
+但我认为不会有
+
+524
+00:17:00,500 --> 00:17:01,520
+大跌出现了好吧
+
+525
+00:17:01,520 --> 00:17:03,580
+除非出现特别的利空
+
+526
+00:17:03,580 --> 00:17:06,640
+那么如果出现特别的利空
+
+527
+00:17:06,640 --> 00:17:10,240
+那这种点位也是比较好加仓的位置
+
+528
+00:17:10,240 --> 00:17:13,300
+因为如果是因为外部的干扰的话
+
+529
+00:17:13,300 --> 00:17:15,600
+反而它反弹的力度会整体会大一点
+
+530
+00:17:15,600 --> 00:17:17,140
+但我不太希望这种事情出现
+
+531
+00:17:17,140 --> 00:17:18,160
+因为
+
+532
+00:17:18,160 --> 00:17:19,960
+暴涨和暴跌
+
+533
+00:17:19,960 --> 00:17:24,060
+都不利于整体行情的一个修复
+
+534
+00:17:24,060 --> 00:17:25,860
+和它的发展
+
+535
+00:17:25,860 --> 00:17:27,140
+所以我还是
+
+536
+00:17:27,140 --> 00:17:28,160
+希望
+
+537
+00:17:28,160 --> 00:17:29,440
+好好走向今天对吧
+
+538
+00:17:29,440 --> 00:17:31,220
+这个科技走成这个样子
+
+539
+00:17:31,220 --> 00:17:33,540
+涨跌加速这边4000多家红牌
+
+540
+00:17:33,540 --> 00:17:34,820
+只有1000多家
+
+541
+00:17:34,820 --> 00:17:35,840
+绿牌
+
+542
+00:17:35,840 --> 00:17:37,620
+但你如果在科技里面对吧
+
+543
+00:17:37,620 --> 00:17:40,440
+就相对来讲会难受点
+
+544
+00:17:40,440 --> 00:17:42,240
+包括上周五的时候
+
+545
+00:17:42,240 --> 00:17:44,800
+涨跌加速也非常夸张
+
+546
+00:17:44,800 --> 00:17:47,100
+但你的体感就会很弱
+
+547
+00:17:47,100 --> 00:17:48,120
+这就是
+
+548
+00:17:48,120 --> 00:17:50,180
+情绪反抽期的
+
+549
+00:17:50,180 --> 00:17:50,940
+时候
+
+550
+00:17:50,940 --> 00:17:51,960
+就这个样子
+
+551
+00:17:51,960 --> 00:17:54,020
+好吧他就是要去恶心你的
+
+552
+00:17:54,060 --> 00:17:57,140
+这个阶段就是散户最难受的时候
+
+553
+00:17:57,140 --> 00:17:58,660
+最难受的时候
+
+554
+00:17:58,660 --> 00:18:00,720
+他甚至比退烧期要更难受
+
+555
+00:18:00,720 --> 00:18:01,480
+因为退烧期的吧
+
+556
+00:18:01,480 --> 00:18:02,260
+见顶了
+
+557
+00:18:02,260 --> 00:18:03,780
+大家都知道见顶
+
+558
+00:18:03,780 --> 00:18:05,580
+那你就直接
+
+559
+00:18:05,580 --> 00:18:07,120
+干脆不做不看对吧
+
+560
+00:18:07,120 --> 00:18:08,140
+或者去
+
+561
+00:18:08,140 --> 00:18:08,660
+搞别的
+
+562
+00:18:08,660 --> 00:18:10,700
+但现在就是给你来回
+
+563
+00:18:10,700 --> 00:18:11,460
+电风扇
+
+564
+00:18:11,460 --> 00:18:14,020
+所以在这个阶段一定不要去追高好吧
+
+565
+00:18:14,020 --> 00:18:15,060
+你追高
+
+566
+00:18:15,060 --> 00:18:15,560
+会
+
+567
+00:18:15,560 --> 00:18:17,100
+死得非常惨
+
+568
+00:18:17,100 --> 00:18:18,900
+这是我想跟各位说的
+
+569
+00:18:18,900 --> 00:18:23,500
+不悲观哪怕后面再跌好吧不悲观不悲观
+
+570
+00:18:23,500 --> 00:18:25,540
+哪怕后面再跌也是正常的
+
+571
+00:18:25,540 --> 00:18:29,140
+他再跌下去整个背离就会更大了
+
+572
+00:18:29,140 --> 00:18:29,640
+那么
+
+573
+00:18:29,640 --> 00:18:32,720
+我们对于后面的行情整体来讲就会
+
+574
+00:18:32,720 --> 00:18:33,740
+更为舒服一点
+
+575
+00:18:33,740 --> 00:18:37,580
+那如果你本身已经在这种位置打满仓了
+
+576
+00:18:37,580 --> 00:18:38,600
+对吧那么
+
+577
+00:18:38,600 --> 00:18:40,140
+后面出现上涨
+
+578
+00:18:40,140 --> 00:18:41,160
+是不是你就
+
+579
+00:18:41,160 --> 00:18:42,960
+比较好做替的时候了
+
+580
+00:18:42,960 --> 00:18:44,500
+你就要把自己的仓位
+
+581
+00:18:44,500 --> 00:18:45,260
+进行
+
+582
+00:18:45,260 --> 00:18:46,540
+动一下把你的
+
+583
+00:18:46,540 --> 00:18:48,340
+因为在这种时候
+
+584
+00:18:48,340 --> 00:18:49,360
+其实你
+
+585
+00:18:49,360 --> 00:18:53,460
+如果动一下做替的话是能把你成本
+
+586
+00:18:53,460 --> 00:18:55,760
+降下来的是能够降
+
+587
+00:18:55,760 --> 00:18:57,560
+降挺多的说实话
+
+588
+00:18:57,560 --> 00:18:58,580
+对吧你
+
+589
+00:18:58,580 --> 00:18:59,600
+周五的时候
+
+590
+00:18:59,600 --> 00:19:01,660
+早盘你如果卖的话今天
+
+591
+00:19:01,660 --> 00:19:02,420
+今天你
+
+592
+00:19:02,420 --> 00:19:06,520
+你在一接你整体成本能降多少能降不少的
+
+593
+00:19:06,520 --> 00:19:08,560
+抓住这种时候好好去把你
+
+594
+00:19:08,560 --> 00:19:09,840
+你如果能
+
+595
+00:19:09,840 --> 00:19:11,640
+做替去把你成本降下来
+
+596
+00:19:11,640 --> 00:19:13,420
+如果做不了替
+
+597
+00:19:13,420 --> 00:19:15,480
+那就继续等着呗好吧等
+
+598
+00:19:15,480 --> 00:19:16,760
+反正这一轮
+
+599
+00:19:16,760 --> 00:19:18,300
+科创我是
+
+600
+00:19:18,300 --> 00:19:21,620
+就底部跌出来之后我认为我能看到19001900的话整体代价局部选择
+
+601
+00:19:21,620 --> 00:19:23,420
+我认为我能看到190019的话整体代价局部选择
+
+602
+00:19:23,420 --> 00:19:25,220
+会修复会修复的还不错
+
+603
+00:19:25,220 --> 00:19:28,020
+一些被腰斩的股票甚至能
+
+604
+00:19:28,020 --> 00:19:29,060
+对吧涨25%
+
+605
+00:19:29,060 --> 00:19:32,380
+涨25%我认为
+
+606
+00:19:32,380 --> 00:19:34,420
+还行吧好吧对于你们
+
+607
+00:19:34,420 --> 00:19:35,960
+那这样已经很好了
+
+608
+00:19:35,960 --> 00:19:39,540
+那么到后面再找一轮主线行情直接就做回来了好吧
+
+609
+00:19:39,540 --> 00:19:40,320
+所以我认为
+
+610
+00:19:40,320 --> 00:19:42,100
+整体难度没有那么大
+
+611
+00:19:42,100 --> 00:19:44,160
+现阶段没有那么悲观了
+
+612
+00:19:44,160 --> 00:19:45,940
+没有那么悲观
+
+613
+00:19:45,940 --> 00:19:48,260
+我对于下半年来讲还是看好了
+
+614
+00:19:48,260 --> 00:19:49,280
+好吧
+
+615
+00:19:49,280 --> 00:19:51,580
+所以今天的视频能讲的不多
+
+616
+00:19:51,580 --> 00:19:53,120
+差不多就就到这里吧
+
+617
+00:19:53,120 --> 00:19:53,380
+嗯
+
+618
+00:19:53,420 --> 00:19:56,240
+嗯这周也要开始看
+
+619
+00:19:56,240 --> 00:19:59,060
+强趋势我还有一节没讲
+
+620
+00:19:59,320 --> 00:20:00,920
+我先讲一节看图说话
+
+621
+00:20:00,920 --> 00:20:02,800
+强趋势后面再讲
+
+622
+00:20:02,800 --> 00:20:03,600
+好吧
+
+623
+00:20:03,600 --> 00:20:04,700
+今天视频就到这里
+
+624
+00:20:04,700 --> 00:20:05,260
+谢谢大家
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.txt" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.txt"
new file mode 100644
index 0000000..8a29c28
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.txt"
@@ -0,0 +1,1247 @@
+[00:00:00] 哈喽大家好
+
+[00:00:01] 开始我们今天一个视频
+
+[00:00:02] 韩后出来一个消息
+
+[00:00:05] 这个消息其实挺有意思的
+
+[00:00:07] 就韩国那边开始抓人了
+
+[00:00:08] 一般来讲开始抓人就离底不远了
+
+[00:00:12] 是吧
+
+[00:00:12] 但开个玩笑
+
+[00:00:14] 但这个确实值得说一下的
+
+[00:00:17] 那么我们这个底
+
+[00:00:21] 因为今天整体来讲
+
+[00:00:24] 让大家就是不要太过于焦虑
+
+[00:00:26] 对吧
+
+[00:00:26] 因为我们其实已经
+
+[00:00:28] 属于一个慢慢摸底的过程当中
+
+[00:00:30] 其实看整个60分钟的话
+
+[00:00:32] 会更为清晰一点
+
+[00:00:33] 整体的结构
+
+[00:00:35] 其实是慢慢走一个收敛三角形
+
+[00:00:37] 那么差不多再推演下去
+
+[00:00:40] 到8月7号
+
+[00:00:43] 差不多这周五
+
+[00:00:44] 这周五和下周一
+
+[00:00:46] 我感觉这个位置就差不多了
+
+[00:00:49] 时间节点上来讲的话也差不多
+
+[00:00:51] 因为我们今天整体的量
+
+[00:00:53] 缩量对吧
+
+[00:00:55] 缩量是对着的
+
+[00:00:56] 如果今天反而放量不好
+
+[00:00:57] 不好
+
+[00:00:57] 所以缩量就对了
+
+[00:00:59] 缩量的话
+
+[00:01:00] 它就会慢慢的形成一个收敛三角形
+
+[00:01:03] 那么一旦形成我们这一波反弹
+
+[00:01:06] 因为现在走出一个背离结构
+
+[00:01:09] 对吧
+
+[00:01:09] 走出一个背离结构
+
+[00:01:10] 那么反弹的日期我们看不到
+
+[00:01:12] 但是能够反弹
+
+[00:01:13] 差不多高度能够看到
+
+[00:01:15] 差不多就1900吧
+
+[00:01:17] 科广501900好吧
+
+[00:01:19] 这个位置我是能看得到的
+
+[00:01:21] 所以最近就慢慢加就好了
+
+[00:01:24] 不用太过于着急
+
+[00:01:25] 让你加快一点
+
+[00:01:27] 加百分之百
+
+[00:01:27] 加百分之百
+
+[00:01:27] 10上周二加百分之十周三加百分之十周四加百分之百分之百分之三十
+
+[00:01:33] 然后周五高开肯定这天不能加
+
+[00:01:35] 那你就只能今天加那百分之四十
+
+[00:01:39] 你再慢慢加下去
+
+[00:01:40] 我觉得差不多吧
+
+[00:01:41] 明天正常来讲是要收一根小阳线的好吧
+
+[00:01:46] 那么他收一根小阳线
+
+[00:01:48] 那明天肯定你买买阴不买阳嘛
+
+[00:01:51] 但整体来讲就OK了
+
+[00:01:54] 就差不多了
+
+[00:01:55] 好吧
+
+[00:01:56] 我觉得时间来讲也是一样的
+
+[00:01:57] 差不多空间也也刚好再往下跌
+
+[00:02:01] 说实话
+
+[00:02:02] 整体整体不会看那么大了
+
+[00:02:04] 因为今天整个行情大家其实有点看不明白
+
+[00:02:07] 对吧
+
+[00:02:08] 看不明白给各位看一张图
+
+[00:02:11] 这个是情绪周期的这个东西前几个月还跟大家说过
+
+[00:02:17] 因为还没有到整体整体开始讲情绪周期的时候对吧
+
+[00:02:23] 但你看了之后就差不多就能明白情绪周期
+
+[00:02:26] 我们一共分四个阶段
+
+[00:02:27] 第一阶段是情绪周期
+
+[00:02:27] 第二阶段是情绪反抽期
+
+[00:02:29] 第二阶段是资金建仓期
+
+[00:02:31] 第三阶段是祖身期
+
+[00:02:33] 祖身期里面我们又分四个小阶段
+
+[00:02:36] 第一个小阶段是人气的形成的一个初期
+
+[00:02:39] 第二小阶段是人气亢奋期
+
+[00:02:41] 第三小阶段成熟期
+
+[00:02:42] 然后第四小阶段是衰退阶段
+
+[00:02:44] 整个第四阶段呢
+
+[00:02:46] 就情绪周期的第四个阶段是退烧期
+
+[00:02:49] 所以一共有四个周期四大周期
+
+[00:02:52] 那我们如果从情绪周期的角度来看的话
+
+[00:02:56] 其实对于今天整个很重要的一阶段是什么呢
+
+[00:02:56] 其实对于今天整个很重要的一阶段是
+
+[00:02:56] 其实对于今天整个行情就会比较明了了
+
+[00:03:00] 因为我们目前是处于情绪反抽期
+
+[00:03:02] 也就是第一阶段
+
+[00:03:03] 第一阶段的特征呢
+
+[00:03:05] 就是前期的老腰股指跌反抽
+
+[00:03:08] 所以我们可以看到
+
+[00:03:10] 前段时间的MLCC的那个票涨得比较好
+
+[00:03:15] 对吧
+
+[00:03:15] 包括今天
+
+[00:03:16] 我们也看到有一些商业航天出来了
+
+[00:03:20] 包括说AI应用那边的票
+
+[00:03:23] 整体走的都还不错
+
+[00:03:25] 对吧
+
+[00:03:25] 今天
+
+[00:03:25] 因为今天是上个月
+
+[00:03:26] 今天有个票是炸了嘛
+
+[00:03:27] 炸了之后
+
+[00:03:28] NEO这边
+
+[00:03:29] 它是板上了
+
+[00:03:31] 这个也是属于之前的一个老腰股的
+
+[00:03:33] 之前的一个老腰股
+
+[00:03:35] 所以从本质上来讲呢
+
+[00:03:37] 是处于一个情绪反抽期
+
+[00:03:39] 然后这些老腰反抽
+
+[00:03:41] 它的整体高度不会很高的
+
+[00:03:43] 所以你们一直在问M
+
+[00:03:45] 对吧
+
+[00:03:45] MLCC走这么强
+
+[00:03:46] 它是不是可以
+
+[00:03:48] 后面有行情的话
+
+[00:03:51] 它会继续更强
+
+[00:03:52] 不一定的
+
+[00:03:53] 它后面哪怕是会走
+
+[00:03:55] 也不一定会真的有走得比较好
+
+[00:03:56] 它会在主线行情走出来之后强
+
+[00:03:58] 我们后面肯定还是要去看主线行情
+
+[00:04:00] 但是离情绪反抽期到主线的一个行程
+
+[00:04:03] 它会有一段的时间
+
+[00:04:04] 好吧
+
+[00:04:05] 快的话
+
+[00:04:05] 可能一两周
+
+[00:04:06] 慢的话
+
+[00:04:07] 可能这边稍微等的时间比较久了
+
+[00:04:11] 但我今天想想跟各位聊的呢
+
+[00:04:14] 就是CPU这边
+
+[00:04:15] 像今天的部分的光走的还不错
+
+[00:04:19] 对吧
+
+[00:04:20] 因为有一些
+
+[00:04:21] 我们如果看这个池子的话
+
+[00:04:23] 能够看到有前面的这些光
+
+[00:04:26] 因为有一些走的还不错
+
+[00:04:29] 包括这边
+
+[00:04:30] 这边少记了一个彭东光
+
+[00:04:33] 少记一个北交所的
+
+[00:04:34] 这个今天也不错
+
+[00:04:36] 没有让各位买的意思
+
+[00:04:38] 就是我说
+
+[00:04:39] 因为为什么呢
+
+[00:04:41] 因为光通信这边
+
+[00:04:42] 它出了一个还行的利好
+
+[00:04:46] 因为Rubin Ultra
+
+[00:04:48] 就是因为它下一代的
+
+[00:04:50] 就Rubin嘛
+
+[00:04:51] 它原先是
+
+[00:04:52] Rubin Ultra是384级别
+
+[00:04:55] 然后现在是
+
+[00:04:56] 缩减到了192级别
+
+[00:04:59] 为什么要这个样子缩呢
+
+[00:05:01] 有两个原因
+
+[00:05:02] 一个是因为HBM供应紧张
+
+[00:05:06] 价格上涨
+
+[00:05:06] 另一个原因呢
+
+[00:05:08] 是因为电力瓶颈
+
+[00:05:10] 所以它们整个规格是变小的
+
+[00:05:13] 规格变小之后
+
+[00:05:16] 那么就会影响到HBM的放量
+
+[00:05:19] 对吧
+
+[00:05:20] 它的整个增长是放缓了的
+
+[00:05:22] 但是对于网络的投入
+
+[00:05:26] 它会上升
+
+[00:05:27] 也就是光互联NVSwitch
+
+[00:05:29] 这些网络的用量会提高
+
+[00:05:32] 因为之前的话
+
+[00:05:34] 整体HBM占比占整个结构
+
+[00:05:38] 大概是40%
+
+[00:05:39] 然后从328G降到198G之后
+
+[00:05:44] 384G
+
+[00:05:46] 然后降到192G之后
+
+[00:05:48] 它的HBM的占的总成本
+
+[00:05:50] 大概要缩到28%
+
+[00:05:52] 就缩12%左右
+
+[00:05:54] 然后网络
+
+[00:05:56] 这一块的配比
+
+[00:05:57] 就要提高
+
+[00:05:58] 从4%提高到12%
+
+[00:06:00] 所以今天光整体走得还不错
+
+[00:06:03] 它是有利好的
+
+[00:06:04] 也就是说
+
+[00:06:06] 目前的市场没有乱潮
+
+[00:06:10] 就是没有出现问题
+
+[00:06:12] 就是说
+
+[00:06:12] 哪怕是它有利好
+
+[00:06:15] 是正常你要去涨
+
+[00:06:16] 包括之前的MSCC
+
+[00:06:19] 也是因为涨价
+
+[00:06:20] 所以才往上去涨
+
+[00:06:22] 也就是说
+
+[00:06:23] 现在的东西
+
+[00:06:24] 它并不是说
+
+[00:06:25] 没有根源的市场
+
+[00:06:27] 不讲道理的胡乱的砸
+
+[00:06:29] 它都是有原因的
+
+[00:06:31] 为什么韩国那边会砸
+
+[00:06:34] 因为HBM的用量变少了
+
+[00:06:38] 所以存储整体沉压
+
+[00:06:39] 这是整体的原因
+
+[00:06:42] 那么HBM这边一旦沉压之后
+
+[00:06:44] 对于半导体设备
+
+[00:06:47] 然后我们这边的
+
+[00:06:48] 这种存储芯片
+
+[00:06:50] 对吧
+
+[00:06:50] 就更不用说了
+
+[00:06:51] 那么他们基本上也都是
+
+[00:06:53] 本来就不是很正宗的
+
+[00:06:54] 然后
+
+[00:06:55] 那边一跌
+
+[00:06:56] 那么我们这边自然也跟着跌
+
+[00:06:59] 然后之前因为半导体涨了
+
+[00:07:02] 因为半导体是最后一轮去涨的
+
+[00:07:05] 所以它跌的时候
+
+[00:07:07] 也是最后一轮跌的
+
+[00:07:09] 然后在上一周
+
+[00:07:10] 光这边是完成了补跌
+
+[00:07:13] 然后今天半导体那边是进行补跌
+
+[00:07:16] 像一些市值比较大的
+
+[00:07:18] 对吧
+
+[00:07:18] 它跌的还是比较狠
+
+[00:07:19] 像中威公司
+
+[00:07:20] 包括华清科这种东西
+
+[00:07:22] 整体来讲
+
+[00:07:23] 跌的还是比较厉害的
+
+[00:07:25] 然后这些呢
+
+[00:07:26] 占科创50的权重又比较高
+
+[00:07:29] 所以以至于整个科创版
+
+[00:07:33] 科创50
+
+[00:07:34] 科创半导体设备ETF
+
+[00:07:36] 今天表现很
+
+[00:07:38] 很惨
+
+[00:07:39] 但是这最后一个板块也跌
+
+[00:07:42] 对吧
+
+[00:07:43] 我们之前超节点
+
+[00:07:44] 什么算力
+
+[00:07:45] 国产算力
+
+[00:07:46] 这些东西也都跌过了
+
+[00:07:47] 那么这些东西
+
+[00:07:48] 这两天的一个下跌
+
+[00:07:50] 我认为差不多了
+
+[00:07:51] 好吧
+
+[00:07:52] 时候也差不多了
+
+[00:07:53] 从时间上来讲
+
+[00:07:54] 从空间上来讲
+
+[00:07:55] 基本上也都也都快到位了
+
+[00:07:58] 基本上再跌
+
+[00:07:59] 再调整个
+
+[00:08:01] 三三四天左右
+
+[00:08:03] 我觉得就差不多了
+
+[00:08:05] 就到位了
+
+[00:08:05] 如果能够那样调的话
+
+[00:08:07] 我觉得是比较好的
+
+[00:08:08] 因为它整个完整的形态
+
+[00:08:10] 也能走出来
+
+[00:08:10] 所以科创这边
+
+[00:08:12] 虽然说它创新低了
+
+[00:08:14] 但是整体走了一个背离
+
+[00:08:16] 好吧
+
+[00:08:17] 这种背离走出来
+
+[00:08:18] 反而是对于后面的行情会更好的
+
+[00:08:21] 那么美中不足的
+
+[00:08:22] 就是上证这边没有踩地
+
+[00:08:24] 而这条没有砸下来
+
+[00:08:25] 那我估计国家队也不太想
+
+[00:08:28] 就是让上证这边下来
+
+[00:08:30] 如果不想让上证这边下来
+
+[00:08:32] 也无所谓好吧
+
+[00:08:33] 本身它能够横住也都ok了
+
+[00:08:36] 这个没有什么关系
+
+[00:08:37] 因此我们再回过头来看
+
+[00:08:40] 对吧
+
+[00:08:42] 我们重点就是后面
+
+[00:08:45] 包括说
+
+[00:08:46] 因为光这边光通信这边是有利好的
+
+[00:08:50] 所以它这两天可能会有表现
+
+[00:08:53] 如果说明天收一根阳线
+
+[00:08:55] 那么这些光是有可能会表现的
+
+[00:09:00] 但是我认为它整体一个幅度不会很大
+
+[00:09:04] 基本上也就1000就差不多了好吧
+
+[00:09:07] 我觉得这一波它整体反弹也就差不多了
+
+[00:09:11] 然后后面它再慢慢上
+
+[00:09:12] 后面再慢慢上
+
+[00:09:13] 因为等整体的一个结构起稳
+
+[00:09:16] 好吧
+
+[00:09:16] 在这边不能说就把它看作是
+
+[00:09:19] 多么牛的一个行情了
+
+[00:09:21] 因为主线行情目前还没有出来
+
+[00:09:23] AI应用机器人
+
+[00:09:24] 包括商业航天人
+
+[00:09:25] 对吧
+
+[00:09:27] 硬科技这些都是想要去表现
+
+[00:09:29] 都想要去做下一个阶段的一个主线
+
+[00:09:34] 但是没有那么容易
+
+[00:09:36] 谁能走出来了
+
+[00:09:38] 我们进一步观察
+
+[00:09:39] 目前来讲的话最有可能是AI应用和
+
+[00:09:42] 还是和半导体相关的好吧
+
+[00:09:45] 但大家也知道
+
+[00:09:46] 因为AI应用我们这边确实不太行
+
+[00:09:49] 正宗的在港股
+
+[00:09:50] 昨天跟各位说了一个港股的票
+
+[00:09:54] 那个票涨的有点开了
+
+[00:09:55] 开盘都开的特别猛
+
+[00:09:57] 我也
+
+[00:09:58] 我也没办法说了好吧
+
+[00:10:00] 这种东西我有还是不提了
+
+[00:10:03] 提出去就
+
+[00:10:04] 就长成那个样子
+
+[00:10:05] 很恶心
+
+[00:10:06] 所以我们
+
+[00:10:08] 就继续去
+
+[00:10:09] 观察就好了
+
+[00:10:10] 谁能走出来
+
+[00:10:12] 不知道
+
+[00:10:13] 等走出来之后
+
+[00:10:14] 因为主线行情它一定是走出来
+
+[00:10:16] 走出来我们知道
+
+[00:10:17] 看到它成
+
+[00:10:18] 成为主线行情的时候
+
+[00:10:19] 它一定已经是
+
+[00:10:21] 涨了一
+
+[00:10:22] 一段了
+
+[00:10:23] 就是这些
+
+[00:10:24] 我们目前
+
+[00:10:25] 所有的
+
+[00:10:27] 包括说AI硬科技
+
+[00:10:29] AI应用
+
+[00:10:31] 商业航天机器人这些东西全都属于
+
+[00:10:34] 超跌反弹
+
+[00:10:35] 只不过AI应用它跌的是
+
+[00:10:38] 时间是比较久的对吧
+
+[00:10:39] 它整体
+
+[00:10:40] 筹码结构出新的也比较完整
+
+[00:10:43] 那么周五
+
+[00:10:45] 资金流向了最没有
+
+[00:10:47] 阻力的一个位置
+
+[00:10:49] 包括说这边应该是有一些人
+
+[00:10:52] 想要去做出
+
+[00:10:53] 东西来的
+
+[00:10:54] 所以他们整个
+
+[00:10:55] 最近吹得非常厉害对吧
+
+[00:10:58] 一些卖方这边的
+
+[00:11:00] 资料发的也特别
+
+[00:11:01] 特别多
+
+[00:11:02] 那么说明他们是想要做出点动作
+
+[00:11:05] 那么再配合到
+
+[00:11:07] 一些实际的
+
+[00:11:08] 因素例如DeepSeek那边
+
+[00:11:11] 降价包括OpenAI降价
+
+[00:11:13] 然后最大的呢其实就是腾讯那边的
+
+[00:11:17] 那个AI智能体WorkBuddy
+
+[00:11:20] 那么这个东西
+
+[00:11:22] 甚至后面是有可能会
+
+[00:11:25] 被统一部署到
+
+[00:11:28] 央国企的
+
+[00:11:29] 以及政府单位当中
+
+[00:11:30] 所以后面的催化
+
+[00:11:32] 有
+
+[00:11:33] 故事也有
+
+[00:11:34] AI的业绩也都能够给你挖掘到
+
+[00:11:38] 也都能够实现
+
+[00:11:39] 但是
+
+[00:11:40] 它能不能走出来
+
+[00:11:42] 需要整体资金的一个共识
+
+[00:11:44] 好吧因此我们在这段
+
+[00:11:46] 这段时间内呢
+
+[00:11:48] 还是不要追高
+
+[00:11:50] 对啊不要追高
+
+[00:11:51] 慢慢的去把科技的仓位打上去
+
+[00:11:54] 因为这些东西都超级反弹
+
+[00:11:55] 只要谈
+
+[00:11:57] 科创50谈
+
+[00:11:59] 不管那些半导体也好
+
+[00:12:00] AI硬件也好
+
+[00:12:02] 光PCB这些东西
+
+[00:12:03] 他们都会有表现
+
+[00:12:05] 整个创业板
+
+[00:12:07] 上涨这些科技股也都会有表现
+
+[00:12:09] 甚至那些AI应用对吧
+
+[00:12:11] 他们也都会有表现
+
+[00:12:13] 我们整体把仓位推上去
+
+[00:12:15] 配置选手呢就把仓位推上去
+
+[00:12:17] 去做ETF这些东西没有什么问题
+
+[00:12:21] 该吃到都能吃到
+
+[00:12:23] 至于说
+
+[00:12:24] 我们去做
+
+[00:12:25] 组生期的时候
+
+[00:12:27] 对吧
+
+[00:12:28] 那得等他们资金杀出来之后
+
+[00:12:30] 我们再去做选择
+
+[00:12:31] 这是那些主力资金要做的事情
+
+[00:12:33] 对吧他们去
+
+[00:12:35] 杀出一条血路来
+
+[00:12:37] 然后我们跟着后
+
+[00:12:38] 跟在后面吃肉就行了
+
+[00:12:40] 就这么简单
+
+[00:12:41] 现阶段不要去赌方向
+
+[00:12:43] 有能力做港股的人
+
+[00:12:45] 去做港股的相关的配置
+
+[00:12:46] 好吧
+
+[00:12:48] 这个都没有什么问题
+
+[00:12:50] 嗯
+
+[00:12:51] 昨天跟各位讲了8月份的一个宏观
+
+[00:12:54] 感觉是不是有点
+
+[00:12:55] 把大家吓到了对吧
+
+[00:12:57] 其实不用太过于悲观
+
+[00:13:00] 好吧
+
+[00:13:01] 这些数据并不一定非得要往
+
+[00:13:04] 不好的方向走
+
+[00:13:05] 它是有可能会往好的方向走的
+
+[00:13:08] 所以不用太过于非得就说这种时候
+
+[00:13:11] 就要出现利空了
+
+[00:13:13] 我们其实上周五的上涨也是因为
+
+[00:13:16] 出现的是利好的消息对不对
+
+[00:13:19] 不是出现利空的嘛
+
+[00:13:20] 所以是出现利好的
+
+[00:13:22] 所以我们也不用太过于悲观
+
+[00:13:23] 照说整个8月份
+
+[00:13:25] 就让他们慢慢磨底嘛
+
+[00:13:27] 这个底磨的时间越长
+
+[00:13:28] 也就代表着后面
+
+[00:13:31] 金九银十的整个行情会长得越顺畅
+
+[00:13:34] 好吧
+
+[00:13:34] 我们整体就
+
+[00:13:35] 我觉得在这种位置
+
+[00:13:37] 没有必要去悲观了
+
+[00:13:40] 好吧
+
+[00:13:41] 这种位置真的没有必要去悲观
+
+[00:13:43] 该怎么做怎么做
+
+[00:13:44] 等出现了
+
+[00:13:46] 我们想象当中的主线行情的时候
+
+[00:13:48] 到时候去积极参与就好了
+
+[00:13:50] 现阶段呢就是
+
+[00:13:52] 尽量不要去追涨杀跌好吧
+
+[00:13:55] 不要去追涨杀跌
+
+[00:13:56] 不要被大资金牵着鼻子走
+
+[00:13:58] 因为现阶段从情绪周期上来讲
+
+[00:14:01] 是很难的
+
+[00:14:03] 我承认这
+
+[00:14:04] 我承认
+
+[00:14:05] 除了主升期
+
+[00:14:06] 主升期的
+
+[00:14:08] 人气亢奋期和成熟阶段相对来讲好做之外
+
+[00:14:12] 其他的这些所有的节点都不好做
+
+[00:14:14] 都很难做
+
+[00:14:16] 所以对于
+
+[00:14:18] 散户来讲其实
+
+[00:14:20] 你在做票的很大程度上其实都是在等
+
+[00:14:23] 在慢慢的等
+
+[00:14:24] 在熬时间
+
+[00:14:25] 正儿八经可以做行情的时候其实很少
+
+[00:14:30] 对吧所以我们整体还是要等
+
+[00:14:32] 包括到了资金建仓期
+
+[00:14:34] 这边整体的仓位甚至是要比情绪反抽期要更少的
+
+[00:14:38] 因为情绪反抽我们还能去做一个
+
+[00:14:41] 超跌反弹
+
+[00:14:42] 包括说我们
+
+[00:14:44] 这整个7月份哪怕是白酒医药
+
+[00:14:47] 消费对吧那些东西他们其实本质上也都是超跌反弹
+
+[00:14:51] 就是大量的整个超跌股呢同步上的
+
+[00:14:54] 只不过他会在不同的板块之间去进行一个表现
+
+[00:14:58] 但是在整体上涨三五天之后
+
+[00:15:01] 就会陆续的分化
+
+[00:15:03] 纯粹依赖那种
+
+[00:15:04] 超跌的品种呢他会回落的比较快一点
+
+[00:15:09] 具备产业逻辑
+
+[00:15:11] 业绩支撑
+
+[00:15:12] 与
+
+[00:15:12] 资金承接方向的他会走出第二波
+
+[00:15:15] 那么这种第二波
+
+[00:15:17] 他就是主线了
+
+[00:15:19] 所以我们在反弹初期
+
+[00:15:21] 没有必要去
+
+[00:15:23] 找对吧
+
+[00:15:24] 谁会是主线
+
+[00:15:27] 因为第
+
+[00:15:28] 因为
+
+[00:15:29] 我们在第1波反弹的时候他解决的就是
+
+[00:15:33] 情绪修复
+
+[00:15:34] 你到了第2波才会去定产业方向和龙头归属
+
+[00:15:38] 所以我们今天也能够看到一些
+
+[00:15:41] AI应用的方向对吧他们是出现了分歧的
+
+[00:15:44] 因为周五整体已经普涨了嘛
+
+[00:15:47] 整体板块已经高潮了
+
+[00:15:48] 那么他们出现分歧
+
+[00:15:50] 分歧之后的再加强
+
+[00:15:52] 他才能够确认
+
+[00:15:54] 这个方向是有的
+
+[00:15:56] 东西做
+
+[00:15:57] 你在第1天
+
+[00:15:59] 普涨的时候那一天你反而是没有什么可以做的
+
+[00:16:02] 你整体仓位你是推不上去的
+
+[00:16:05] 你只有他们在分歧结束之后再次
+
+[00:16:08] 转强的时候
+
+[00:16:10] 我们才能够对他
+
+[00:16:11] 高看一
+
+[00:16:12] 所以行情是走出来不是拆出来的
+
+[00:16:15] 好吧
+
+[00:16:15] 大家
+
+[00:16:17] 很多散户陷入了一个误区就是非得要找确定性
+
+[00:16:20] 没有确定性的
+
+[00:16:21] 等真出确定性的时候你又不敢去买了
+
+[00:16:24] 还是我说了一句话好吧
+
+[00:16:25] 做交易不要又贪又怂
+
+[00:16:28] 贪呢是因为
+
+[00:16:29] 主线涨出来了
+
+[00:16:31] 主线没涨出来你想买在最底部对吧
+
+[00:16:33] 那么怂呢是主线涨起来了
+
+[00:16:36] 然后你又不敢买了
+
+[00:16:38] 往往就错过了主线行情
+
+[00:16:40] 这是散户最大的一个问题
+
+[00:16:42] 但也是交易当中最反人性的事情
+
+[00:16:45] 这也是所有散户必须要卖过的坎
+
+[00:16:47] 好吧你不卖过这个坎呢
+
+[00:16:50] 自然你就享受不到主身了
+
+[00:16:53] 这是我今天想讲的
+
+[00:16:54] 想跟各位说的
+
+[00:16:55] 所以最近
+
+[00:16:56] 依旧
+
+[00:16:57] 大跌大买小跌小买
+
+[00:16:58] 但我认为不会有
+
+[00:17:00] 大跌出现了好吧
+
+[00:17:01] 除非出现特别的利空
+
+[00:17:03] 那么如果出现特别的利空
+
+[00:17:06] 那这种点位也是比较好加仓的位置
+
+[00:17:10] 因为如果是因为外部的干扰的话
+
+[00:17:13] 反而它反弹的力度会整体会大一点
+
+[00:17:15] 但我不太希望这种事情出现
+
+[00:17:17] 因为
+
+[00:17:18] 暴涨和暴跌
+
+[00:17:19] 都不利于整体行情的一个修复
+
+[00:17:24] 和它的发展
+
+[00:17:25] 所以我还是
+
+[00:17:27] 希望
+
+[00:17:28] 好好走向今天对吧
+
+[00:17:29] 这个科技走成这个样子
+
+[00:17:31] 涨跌加速这边4000多家红牌
+
+[00:17:33] 只有1000多家
+
+[00:17:34] 绿牌
+
+[00:17:35] 但你如果在科技里面对吧
+
+[00:17:37] 就相对来讲会难受点
+
+[00:17:40] 包括上周五的时候
+
+[00:17:42] 涨跌加速也非常夸张
+
+[00:17:44] 但你的体感就会很弱
+
+[00:17:47] 这就是
+
+[00:17:48] 情绪反抽期的
+
+[00:17:50] 时候
+
+[00:17:50] 就这个样子
+
+[00:17:51] 好吧他就是要去恶心你的
+
+[00:17:54] 这个阶段就是散户最难受的时候
+
+[00:17:57] 最难受的时候
+
+[00:17:58] 他甚至比退烧期要更难受
+
+[00:18:00] 因为退烧期的吧
+
+[00:18:01] 见顶了
+
+[00:18:02] 大家都知道见顶
+
+[00:18:03] 那你就直接
+
+[00:18:05] 干脆不做不看对吧
+
+[00:18:07] 或者去
+
+[00:18:08] 搞别的
+
+[00:18:08] 但现在就是给你来回
+
+[00:18:10] 电风扇
+
+[00:18:11] 所以在这个阶段一定不要去追高好吧
+
+[00:18:14] 你追高
+
+[00:18:15] 会
+
+[00:18:15] 死得非常惨
+
+[00:18:17] 这是我想跟各位说的
+
+[00:18:18] 不悲观哪怕后面再跌好吧不悲观不悲观
+
+[00:18:23] 哪怕后面再跌也是正常的
+
+[00:18:25] 他再跌下去整个背离就会更大了
+
+[00:18:29] 那么
+
+[00:18:29] 我们对于后面的行情整体来讲就会
+
+[00:18:32] 更为舒服一点
+
+[00:18:33] 那如果你本身已经在这种位置打满仓了
+
+[00:18:37] 对吧那么
+
+[00:18:38] 后面出现上涨
+
+[00:18:40] 是不是你就
+
+[00:18:41] 比较好做替的时候了
+
+[00:18:42] 你就要把自己的仓位
+
+[00:18:44] 进行
+
+[00:18:45] 动一下把你的
+
+[00:18:46] 因为在这种时候
+
+[00:18:48] 其实你
+
+[00:18:49] 如果动一下做替的话是能把你成本
+
+[00:18:53] 降下来的是能够降
+
+[00:18:55] 降挺多的说实话
+
+[00:18:57] 对吧你
+
+[00:18:58] 周五的时候
+
+[00:18:59] 早盘你如果卖的话今天
+
+[00:19:01] 今天你
+
+[00:19:02] 你在一接你整体成本能降多少能降不少的
+
+[00:19:06] 抓住这种时候好好去把你
+
+[00:19:08] 你如果能
+
+[00:19:09] 做替去把你成本降下来
+
+[00:19:11] 如果做不了替
+
+[00:19:13] 那就继续等着呗好吧等
+
+[00:19:15] 反正这一轮
+
+[00:19:16] 科创我是
+
+[00:19:18] 就底部跌出来之后我认为我能看到19001900的话整体代价局部选择
+
+[00:19:21] 我认为我能看到190019的话整体代价局部选择
+
+[00:19:23] 会修复会修复的还不错
+
+[00:19:25] 一些被腰斩的股票甚至能
+
+[00:19:28] 对吧涨25%
+
+[00:19:29] 涨25%我认为
+
+[00:19:32] 还行吧好吧对于你们
+
+[00:19:34] 那这样已经很好了
+
+[00:19:35] 那么到后面再找一轮主线行情直接就做回来了好吧
+
+[00:19:39] 所以我认为
+
+[00:19:40] 整体难度没有那么大
+
+[00:19:42] 现阶段没有那么悲观了
+
+[00:19:44] 没有那么悲观
+
+[00:19:45] 我对于下半年来讲还是看好了
+
+[00:19:48] 好吧
+
+[00:19:49] 所以今天的视频能讲的不多
+
+[00:19:51] 差不多就就到这里吧
+
+[00:19:53] 嗯
+
+[00:19:53] 嗯这周也要开始看
+
+[00:19:56] 强趋势我还有一节没讲
+
+[00:19:59] 我先讲一节看图说话
+
+[00:20:00] 强趋势后面再讲
+
+[00:20:02] 好吧
+
+[00:20:03] 今天视频就到这里
+
+[00:20:04] 谢谢大家
diff --git "a/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/manifest.jsonl" "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/manifest.jsonl"
new file mode 100644
index 0000000..2083700
--- /dev/null
+++ "b/ana-data/news-\351\235\222\346\236\253\346\265\246\344\270\212Q/manifest.jsonl"
@@ -0,0 +1,47 @@
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"article","stable_id":"1231979117971243025","source_url":"https://www.bilibili.com/opus/1231979117971243025","published_at":"2026-08-02T22:28:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260802-222800_article_8.2复盘_1231979117971243025.txt","bytes":23512,"sha256":"2E8EF1EDC177B323E6852C8318D0D36456C8D67B894EB22A2E2A3794421AC997"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1HA3o6oEJJ","source_url":"https://www.bilibili.com/video/BV1HA3o6oEJJ","published_at":"2026-08-02T21:11:11+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"VIDEO_DOWNLOAD_PENDING_EXTENSION","video_path":null,"cover_path":"20260802-211111_video-cover_流水淘沙周复盘_BV1HA3o6oEJJ.webp","cover_bytes":13450,"cover_sha256":"F71BAA83F76E6B3577C544C61FE8E70EF02504247D939E81F2703C4C5189970C"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232141287338016792","source_url":"https://www.bilibili.com/opus/1232141287338016792","published_at":"2026-08-03T08:57:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260803-085700_text_8.3早盘_1232141287338016792.txt","bytes":11595,"sha256":"193D0B87B552474D605E8B7BEC0A57F07486AAACA8D5587ADD07B62E2685679A"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232156727764320257","source_url":"https://www.bilibili.com/opus/1232156727764320257","published_at":"2026-08-03T09:57:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260803-095700_text_多点耐心_1232156727764320257.txt","bytes":299,"sha256":"FE30BAFF68994F7524AE0E2E3CC34C8DA25F63065E6EB8CEAD1AAD8626473CD3"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232204861433446409","source_url":"https://www.bilibili.com/opus/1232204861433446409","published_at":"2026-08-03T13:04:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260803-130400_text_8.3午盘_1232204861433446409.txt","bytes":3728,"sha256":"6C96034CC6C2F63D75247ECF4F353EC52863B08D8C4CC45EB7314E8726EB073B"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232223909666881541","source_url":"https://www.bilibili.com/opus/1232223909666881541","published_at":"2026-08-03T14:18:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260803-141800_text_这里就是底部坚持住_1232223909666881541.txt","bytes":392,"sha256":"352EFAE0A0FC4961C741E87A5A7130244EDBC7E1D25B06742E111A5FF7494E99"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1DVMX6XEPq","source_url":"https://www.bilibili.com/video/BV1DVMX6XEPq","published_at":"2026-08-03T19:39:03+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"VIDEO_DOWNLOAD_PENDING_EXTENSION","video_path":null,"cover_path":"20260803-193903_video-cover_A股缩量磨底阶段_BV1DVMX6XEPq.webp","cover_bytes":14646,"cover_sha256":"3316590CC2CA332B1188C99BE48CF2E8DED7DC66F3972A0B65A528D74362CC9E"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232346363423883282","source_url":"https://www.bilibili.com/opus/1232346363423883282","published_at":"2026-08-03T22:13:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260803-221300_text_8.3复盘_1232346363423883282.txt","bytes":10013,"sha256":"C4D1F07DA4F7DBD6EF7FFD191D59B4C28F14A05ACA006B14B581F236E68E669E"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"article","stable_id":"1232516577286422548","source_url":"https://www.bilibili.com/opus/1232516577286422548","published_at":"2026-08-04T09:13:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260804-091300_article_8.4早盘_1232516577286422548.txt","bytes":13515,"sha256":"D8DC6EC2278C05E7A2251D481C1B702AAEFE42426D9D464DFCA3F77EA06E506D"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232541024240271379","source_url":"https://www.bilibili.com/opus/1232541024240271379","published_at":"2026-08-04T10:48:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260804-104800_text_轮动行情_1232541024240271379.txt","bytes":678,"sha256":"A1C29D8FE9FD879FB83CD45C46B9B502493FDAC18C0158B6A16A571DC2BE25BB"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232588032685637632","source_url":"https://www.bilibili.com/opus/1232588032685637632","published_at":"2026-08-04T13:51:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260804-135100_text_做交易要吃的下饭睡得着觉_1232588032685637632.txt","bytes":496,"sha256":"100E73DB3347085EA2CA15F1E57E12A3E9AACD7B61F7C1C0EF67854CD6DD6F73"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"article","stable_id":"1232725342775410720","source_url":"https://www.bilibili.com/opus/1232725342775410720","published_at":"2026-08-04T22:43:00+08:00","collected_at":"2026-08-04T23:56:06+08:00","status":"SAVED","path":"20260804-224300_article_8.4复盘_1232725342775410720.txt","bytes":22205,"sha256":"2D69FAD10730CD93E4E0C121C12163DA877888BBFE5FF71BA83014D562AF43E8"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"article","stable_id":"1232888061922115591","source_url":"https://www.bilibili.com/opus/1232888061922115591","published_at":"2026-08-05T09:15:21+08:00","collected_at":"2026-08-05T10:34:55+08:00","status":"CONTENT_ACCESS_PENDING_CHROME","path":null,"failure_reason":"Logged-in Chrome metadata was available, but all controlled tab read/navigation operations timed out; no credential or access-control bypass attempted."}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232897957496356868","source_url":"https://www.bilibili.com/opus/1232897957496356868","published_at":"2026-08-05T09:53:45+08:00","collected_at":"2026-08-05T10:34:55+08:00","status":"CONTENT_ACCESS_PENDING_CHROME","path":null,"failure_reason":"Logged-in Chrome metadata was available, but all controlled tab read/navigation operations timed out; no credential or access-control bypass attempted."}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"article","stable_id":"1232888061922115591","title":"8.5(早盘)","source_url":"https://www.bilibili.com/opus/1232888061922115591","published_at":"2026-08-05T09:15:21+08:00","collected_at":"2026-08-05T15:17:12+08:00","status":"SAVED","path":"20260805-091521_article_8.5早盘_1232888061922115591.txt","bytes":36197,"sha256":"0DEDDD5A24CF59CEFB7CC3ED32E21D17A0B16FC5F589E77681B7A90D84D01AE0","supersedes_status":"CONTENT_ACCESS_PENDING_CHROME"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232897957496356868","title":"控制好仓位","source_url":"https://www.bilibili.com/opus/1232897957496356868","published_at":"2026-08-05T09:53:45+08:00","collected_at":"2026-08-05T15:17:12+08:00","status":"SAVED","path":"20260805-095345_text_控制好仓位_1232897957496356868.txt","bytes":365,"sha256":"697983AD520403F55DE030D59BA3E07D9E44F9D46C07E9F99F07EE4E7EBC7AA6","supersedes_status":"CONTENT_ACCESS_PENDING_CHROME"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1232927167618220050","title":"见微知著","source_url":"https://www.bilibili.com/opus/1232927167618220050","published_at":"2026-08-05T11:47:06+08:00","collected_at":"2026-08-05T15:17:12+08:00","status":"SAVED","path":"20260805-114706_text_见微知著_1232927167618220050.txt","bytes":868,"sha256":"980386FB11F13E77F6FE3C4B10EE25DB87397B53A70E3A91BDACD4D1BF05AD05"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1DVMX6XEPq","title":"A股缩量磨底阶段。","source_url":"https://www.bilibili.com/video/BV1DVMX6XEPq","published_at":"2026-08-03T19:39:03+08:00","collected_at":"2026-08-05T17:04:58+08:00","status":"VIDEO_DOWNLOADED_COMPLETE_HANDOFF_SENT","video_path":"F:\\video\\青枫浦上Q\\BV1DVMX6XEPq.mp4","mapping_path":"F:\\video\\青枫浦上Q\\BV1DVMX6XEPq.download.json","bytes":94746500,"sha256":"3C6591AB65F1A821790D5C815C5D8A1B647C15CD0A980683A442D28F39105737","duration_seconds":1206.067664,"video_codec":"av1","audio_codec":"aac","processing_handoff_id":"HANDOFF-VIDEO-DOWNLOADER-MEDIA-BV1DVMX6XEPq-20260805-001","supersedes_status":"VIDEO_DOWNLOAD_PENDING_EXTENSION"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1HA3o6oEJJ","title":"流水淘沙不暂停,前波未灭后波生。(周复盘)","source_url":"https://www.bilibili.com/video/BV1HA3o6oEJJ","published_at":"2026-08-02T21:11:11+08:00","collected_at":"2026-08-05T17:04:58+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_AUTH_REQUIRED","video_path":null,"expected_duration_seconds":3133.95,"observed_preview_duration_seconds":600.133313,"quarantine_path":"F:\\video\\青枫浦上Q\\.quarantine\\BV1HA3o6oEJJ.preview-600s.mp4","quarantine_bytes":20531388,"quarantine_sha256":"6B4867B4E20E9D32304F2E83F34A55362823A97C99D366422C6976B87693FE72","failure_reason":"Credential-free download exposed only a 600-second preview while remote metadata reports 3133.95 seconds. Full source requires the existing authorized member session; credentials and access controls were not read or bypassed.","supersedes_status":"VIDEO_DOWNLOAD_PENDING_EXTENSION"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video_transcript","stable_id":"BV1DVMX6XEPq","source_url":"https://www.bilibili.com/video/BV1DVMX6XEPq","published_at":"2026-08-03T19:39:03+08:00","collected_at":"2026-08-05T17:34:00+08:00","status":"TRANSCRIPTION_PASS_COMPLETE","video_path":"F:\\video\\青枫浦上Q\\BV1DVMX6XEPq.mp4","transcript_path":"BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.txt","transcript_bytes":24739,"transcript_sha256":"272E9564708394338CCF293990223A0DAA334A1494F3903CEBE0DA50C8C447CE","srt_path":"BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.srt","srt_sha256":"071C9CA274D6844F6E01281771DA2A7225FAC2F08A2B4C058B9D91D5C3F13601","json_path":"BV1DVMX6XEPq.transcript/BV1DVMX6XEPq.json","json_sha256":"2D15A75E9E59D961D2936DA20946252140E72B906668EAB001FC40B102D26711","language":"zh","segments":624,"model":"large-v3","device":"cuda","compute_type":"float16","result_handoff_id":"HANDOFF-MEDIA-VIDEO-DOWNLOADER-BV1DVMX6XEPq-TRANSCRIPTION-RESULT-20260805-001","supersedes_status":"VIDEO_DOWNLOADED_COMPLETE_HANDOFF_SENT"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"article","stable_id":"1233100718643085351","title":"8.5(复盘)","source_url":"https://www.bilibili.com/opus/1233100718643085351","published_at":"2026-08-05T23:00:34+08:00","collected_at":"2026-08-06T02:00:18+08:00","status":"SAVED","path":"20260805-230034_article_8.5复盘_1233100718643085351.txt","bytes":9768,"sha256":"071872E70883A193643011C268E0C317C4827D0BF7BFEFAB97674D2878F3818E"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"article","stable_id":"1233255470622310421","title":"8.6(早盘)","source_url":"https://www.bilibili.com/opus/1233255470622310421","published_at":"2026-08-06T09:01:05+08:00","collected_at":"2026-08-06T10:35:09+08:00","status":"SAVED","path":"20260806-090105_article_8.6早盘_1233255470622310421.txt","bytes":12910,"sha256":"AD022C6D7AA17F5107762B541E4BD8CC38BB285363DD1043EA91562B0ECA8751"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"text","stable_id":"1233284483106471972","title":"市场目前接力情绪很好","source_url":"https://www.bilibili.com/opus/1233284483106471972","published_at":"2026-08-06T10:53:40+08:00","collected_at":"2026-08-06T21:47:25+08:00","status":"SAVED","path":"20260806-105340_text_市场目前接力情绪很好_1233284483106471972.txt","bytes":419,"sha256":"C8B034BBE7EA7C55D23E880B66329CCB7503B579D6C0062DD5289949C5466B2D","image_path":"20260806-105340_image_市场目前接力情绪很好_1233284483106471972.jpg","image_bytes":136317,"image_sha256":"BE6C82F18A49F3F7E9BF6129BDA5BF41836336514F617C9EF47A7D61DDD226F9","image_source_url":"https://i1.hdslb.com/bfs/new_dyn/c0599fff80642da9c521e8abb3c9eaad1420210197.jpg"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-06T22:44:07+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_RUNTIME_STABILITY_GATE","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The installed VideoTextHost 1.0.5 has a confirmed foreground write-EOF popup defect. Project administration marked the EOF stability repair as a hard gate before real video-download runtime release; the existing host was not invoked, no authentication secret was read, and no preview was accepted as a complete original."}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-06T23:51:03+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_AUTH_SESSION_SOURCE_REVIEW_HOLD2","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The prior Host-specific EOF causal claim was retracted after read-only provenance showed the active Python VideoTextHost does not match the Electron/Node popup stack. Real download remains frozen solely because authenticated-session source review is HOLD/2: exact source-tree enumeration and clean builder-environment gates are not yet closed.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_RUNTIME_STABILITY_GATE","decision_handoff_id":"HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-VIDEO-TEXT-HOST-WRITE-EOF-GATE-REASSESSMENT-20260806-001"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"article","stable_id":"1233480647120519170","cvid":"cv52135841","title":"8.6(复盘)","source_url":"https://www.bilibili.com/opus/1233480647120519170","published_at":"2026-08-06T23:34:53+08:00","collected_at":"2026-08-07T01:15:23+08:00","status":"ARTICLE_TEXT_BLOCKED_LOGGED_IN_CHROME_BRIDGE_TIMEOUT","path":null,"image_path":"20260806-233453_image_8.6复盘_1233480647120519170.png","image_bytes":233531,"image_sha256":"939F5B0A8F8F15E212680B5E3A4FF6F0AD1DE66DE76900BE03B558A097927745","image_source_url":"https://i0.hdslb.com/bfs/new_dyn/banner/501eccb80239f5ad88ac195578477c861420210197.png","failure_reason":"The exact opus ID, cvid, title, author, publication timestamp and original cover were verified. Logged-in Chrome loaded the article tab, but every read-only DOM/export/same-session request through the Chrome control bridge timed out and reset the bridge. The unauthenticated public page exposes only the charging paywall, so no partial search snippet was substituted for the full article and no credential or signed request data was read."}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-07T01:30:38+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_REPLACEMENT_BUILD_REPAIR_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The prior HOLD/2 source rereview passed, but the single authorized replacement build failed cleanly before producing an EXE or receipt because PyInstaller copy_metadata could not discover the verified temporary yt-dlp .deps metadata. Project administration authorized only a minimal same-task source repair and existing-chain rereview; install, authenticated runtime and real media download remain frozen until a new source PASS, tree-external approval, successful clean build and later exact-build gates.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_AUTH_SESSION_SOURCE_REVIEW_HOLD2","build_blocker_handoff_id":"HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-BLOCKED-20260807-001","decision_handoff_id":"HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-REBUILD-DECISION-20260807-001","build_blocker_evidence":"dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-blocked-20260807.json","build_blocker_evidence_sha256":"34EEC108F0F27384F605C67AB4529682463196B8C0A5F03CC96E595DD61D19FA"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-07T02:27:42+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_METADATA_REPAIR_SOURCE_REREVIEW_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The minimal verified .deps metadata-path repair is complete and has been submitted to the existing reviewer chain. The replacement build count after repair remains zero. A new clean build is forbidden until source rereview PASS and a new tree-external source approval; installation, authenticated Chrome runtime and real media download remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_REPLACEMENT_BUILD_REPAIR_PENDING","repair_submission_handoff_id":"HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-SOURCE-REVIEW-SUBMITTED-20260807-001","repair_evidence":"dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/metadata-repair-source-rereview-20260807.json","repair_evidence_sha256":"217CD836934DD4C31B2C6A67DDCBE84B85B11AD7E584B0EDDCA0AF311E46B108"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-07T03:38:49+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_REPLACEMENT_BUILD_002_ARCHIVE_METADATA_MISSING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The F1-only source rereview passed and the single authorized clean build-002 assembled a PyInstaller executable, but the mandatory pre-receipt static archive gate could not find the pinned yt-dlp package metadata. The build exited nonzero and rolled back the entire candidate output; accepted EXE and schema 2 receipt counts are zero. No installable artifact exists, and authenticated runtime and real media download remain frozen pending a same-chain successor decision.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_METADATA_REPAIR_SOURCE_REREVIEW_PENDING","build_blocker_handoff_id":"HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-002-BLOCKED-20260807-001","source_review_result_handoff_id":"HANDOFF-INFOREV-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-PREVALIDATION-F1-SOURCE-REREVIEW-RESULT-20260807-001","build_blocker_evidence":"dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-002-blocked-20260807.json","build_blocker_evidence_sha256":"3E184118BEE03C8AA6D536B10C0B41F2B795FF8D4D56D72911B065CA9D50A257"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-07T03:40:12+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_ARCHIVE_METADATA_CONTRACT_REPAIR_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"Project administration authorized the existing owner and reviewer chain to distinguish true package-metadata absence from a validator miss, repair only the exact archive-metadata contract, and obtain a new source PASS and reviewer-owned approval. Build-003 is not yet authorized to run; install, authenticated browser runtime and real media collection remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_REPLACEMENT_BUILD_002_ARCHIVE_METADATA_MISSING","decision_handoff_id":"HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-BLOCKER-DECISION-20260807-001"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-07T10:09:48+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_ARCHIVE_METADATA_SOURCE_REVIEW_CAPACITY","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The archive-metadata contract repair is complete and the original reviewer independently reproduced source 17/17 plus the focused archive test 1/1, but the review turn ended with a usage-limit system error before a formal verdict, audit entry or reviewer-owned source approval was created. Candidate build-003 is absent. The capacity blocker has been escalated to project administration on the same task and review chain; authenticated runtime and real media download remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_ARCHIVE_METADATA_CONTRACT_REPAIR_PENDING","escalation_handoff_id":"HANDOFF-VIDEO-DOWNLOADER-INFOADMIN-BILI-ARCHIVE-METADATA-SOURCE-REVIEW-CAPACITY-BLOCKER-20260807-001","capacity_evidence":"dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-source-review-capacity-blocked-20260807.json"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-07T10:20:56+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_ARCHIVE_METADATA_SOURCE_REVIEW_RESUME_SCHEDULED","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"Project administration confirmed that no authorized alternate reviewer exists and retained the original inforev task. One same-chain recovery check is scheduled for 2026-08-08T13:35:00+08:00, after the reported usage reset, and will resume the original reviewer only if verdict, audit and reviewer-owned source approval are still missing. Candidate build-003 remains absent and forbidden; authenticated runtime and real media download remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_ARCHIVE_METADATA_SOURCE_REVIEW_CAPACITY","recovery_decision_handoff_id":"HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-ARCHIVE-METADATA-SOURCE-REVIEW-CAPACITY-RECOVERY-DECISION-20260807-001","scheduled_recovery_at":"2026-08-08T13:35:00+08:00"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T14:05:52+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_CONTROLLED_BUILD003_IN_PROGRESS","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The original reviewer recovered the same review chain and issued PASS/0 plus a reviewer-owned exact source approval. The sole authorized owner has started the single clean build-003 lifecycle, but no accepted EXE/schema 2 receipt or install-exact-build approval exists yet. Installation, authenticated Chrome runtime and real media download remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_ARCHIVE_METADATA_SOURCE_REVIEW_RESUME_SCHEDULED","source_review_audit_id":"DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-CONTRACT-SOURCE-REREVIEW-20260808-001","source_manifest_sha256":"508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910","source_approval_path":"ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-source-approval.json","source_approval_sha256":"A46A8D29A42B85E0DB83AA4EC5153B298BE9C24684637C6B7864AD23EF5B41E8","build003_path":"dev/tmp/bili-auth-controlled-build-ytdlp-20260807-003"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T14:48:45+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_BUILD003_ARCHIVE_METADATA_TYPE_FALSE_REJECTION","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The only authorized build-003 assembled the offline PyInstaller onefile host but failed closed before receipt creation because the static archive gate expected metadata type x while PyInstaller 6.15.0 on Windows emitted decoded CArchive type b for the exact metadata bytes. Output rollback completed; accepted EXE and schema 2 receipt counts are zero. No retry is authorized; installation and authenticated media collection remain frozen pending same-task minimal repair, source rereview, new approval and a new single-build authorization.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_CONTROLLED_BUILD003_IN_PROGRESS","build_started_at":"2026-08-08T14:04:44.3957204+08:00","build_ended_at":"2026-08-08T14:06:06.3223346+08:00","build_wall_seconds":81.921,"build_attempt_count":1,"retry_count":0,"build_error_code":"E_BUILD_ARCHIVE_METADATA_TYPE_FALSE_REJECTION","build_blocker_evidence":"dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-003-blocked-20260808.json","build_blocker_evidence_sha256":"0FA8D0D9719D19C819F607E383D12B93021CE718489A742F740FBFEE13BBCC73"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T14:52:27+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_TYPE_CONTRACT_REPAIR_SOURCE_REVIEW_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"Project administration authorized the existing sole owner to repair only the PyInstaller archive metadata type contract so exact pinned metadata DATA entries may be x or Windows-promoted b while all exact content and identity gates remain unchanged. Build-003 authorization is consumed. Build-004 may run once only after same-chain source PASS/0 and a new reviewer-owned source approval; installation and authenticated media collection remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_BUILD003_ARCHIVE_METADATA_TYPE_FALSE_REJECTION","decision_handoff_id":"HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-YTDLP-TYPE-CONTRACT-REPAIR-DECISION-20260808-001","correlation_handoff_id":"HANDOFF-INFODEV2-INFOADMIN-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-003-BLOCKED-20260808-001","next_build":"build-004","next_gate":"same-chain source PASS/0 plus new reviewer-owned source approval"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T15:49:42+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_TYPE_CONTRACT_REPAIR_COMPLETE_SOURCE_REREVIEW_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The sole owner completed the exact archive metadata type-contract repair and offline verification. Only the unique exact metadata root and seven exact files may use PyInstaller DATA type x or Windows-promoted b; all other types, path drift, payload drift and receipt tree-hash drift fail closed. Build-004 count remains zero and its output is absent. Same-chain source rereview and a new reviewer-owned source approval are still required before one clean build-004 attempt; installation and authenticated media collection remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_TYPE_CONTRACT_REPAIR_SOURCE_REVIEW_PENDING","build_sha256":"3739436920FCEA8BA68DC04BBDAB0F346C08216F49D89A097C50075912A258A0","installer_sha256":"53A1D1AFD9E4571A2491AE66C5E721E77A2201DBFAA29C9094E42CE0CB684C2A","source_manifest_sha256":"21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3","test_sha256":"2E7D6BE3B9607A34A90B283F2CDDBC65A31CD2AF02026EB97E8CED6C153075AB","repair_evidence":"dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-type-contract-repair-20260808.json","repair_evidence_sha256":"5880E90521F1B382C8B546A5814CBEC5DDEC826897F1EC498EEDE0C18C4263EB","next_build":"build-004","next_gate":"same-chain source PASS/0 plus new reviewer-owned source approval"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T16:51:45+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_TYPE_CONTRACT_SOURCE_PASS_BUILD004_AUTHORIZED_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The existing reviewer completed the focused metadata type-contract source rereview with PASS/0 and issued a new reviewer-owned source approval bound to the exact 3029-byte source manifest. The sole owner may now perform the single authorized clean build-004, but no build-004 output exists yet. Installation, authenticated Chrome runtime and real media collection remain frozen until exact-build hash-only PASS and a new install-exact-build approval.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_TYPE_CONTRACT_REPAIR_COMPLETE_SOURCE_REREVIEW_PENDING","source_review_audit_id":"DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-TYPE-CONTRACT-SOURCE-REREVIEW-20260808-001","source_manifest_sha256":"21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3","source_approval_path":"ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json","source_approval_sha256":"102B30405B81137BD3AC9B81CFA39A6A7BB21531115FB3F361F821B2A093335E","next_build":"build-004","next_gate":"exact-build hash-only PASS plus new install-exact-build approval"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T16:52:50+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_CONTROLLED_BUILD004_IN_PROGRESS","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The single authorized clean build-004 has started from the exact reviewer-approved source. The output root currently contains only build staging directories and active offline build processes; no accepted EXE/schema 2 receipt or install-exact-build approval exists yet. Installation, authenticated Chrome runtime and real media collection remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_TYPE_CONTRACT_SOURCE_PASS_BUILD004_AUTHORIZED_PENDING","build004_path":"dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004","next_gate":"build terminal result followed by exact-build hash-only review"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T16:56:13+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_BUILD004_STATIC_PASS_HASH_ONLY_REVIEW_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The single authorized build-004 completed its static build gates and produced exactly one PyInstaller onefile EXE plus a schema 2 build receipt. The executable has not been run or installed. The artifact is now pending the original reviewer's exact-build hash-only confirmation and a new install-exact-build approval; authenticated Chrome runtime and real media collection remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_CONTROLLED_BUILD004_IN_PROGRESS","build004_path":"dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004","build004_exe":"project-info-bili-auth-native-host.exe","build004_exe_bytes":21343775,"build004_exe_sha256":"DBF155BCC05B4DF55592D0A4B56612F8BE9C990B3B9AF7691F6A11648866B0B0","build004_receipt":"build-artifact-manifest.json","build004_receipt_bytes":2874,"build004_receipt_sha256":"0B36AA7DCA3AD80F8A370E96D50CAAFB386291EE2741305C7797ED663F5C2B27","archive_metadata_type_codes":["b"],"next_gate":"exact-build hash-only PASS plus new install-exact-build approval"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T17:26:06+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_BUILD004_NOT_INSTALLABLE_TREE_HASH_MISMATCH","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The original reviewer completed the exact-build hash-only review with HOLD/1. Build-004 EXE and receipt are internally consistent, but the receipt and static archive recompute metadata tree SHA-256 32F1DC23...E70 while the source-approved installer requires 7C9ABE23...105C, so preinstall validation must fail before any copy or HKCU action. No install-exact-build approval was created. Build-004 is frozen NOT_INSTALLABLE evidence pending same-chain minimal tree-hash contract repair and a new source/build lifecycle.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_BUILD004_STATIC_PASS_HASH_ONLY_REVIEW_PENDING","hash_review_message_id":"msg_20260808172457767_a0494086","hash_review_audit_id":"DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-HASH-ONLY-CONFIRMATION-20260808-001","hash_review_status":"HOLD/1","hash_review_conclusion":"BUILD004_NOT_INSTALLABLE_NO_INSTALL_APPROVAL","receipt_metadata_tree_sha256":"32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70","installer_required_metadata_tree_sha256":"7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C","install_approval_created":false,"next_gate":"project.admin same-chain minimal repair decision"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T17:33:34+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_TREE_HASH_CONTRACT_REPAIR_SOURCE_REVIEW_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"Project administration authorized the existing owner and reviewer chain to repair only the canonical seven-file metadata tree-hash contract and add production-equivalent installer preinstall positive and negative tests. Build-004 remains immutable NOT_INSTALLABLE evidence. Build-005 count is zero and no output exists; one clean build-005 may run only after same-chain source PASS and a new reviewer-owned source approval. Installation and authenticated media collection remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_BUILD004_NOT_INSTALLABLE_TREE_HASH_MISMATCH","decision_handoff_id":"HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-YTDLP-BUILD004-HOLD1-REPAIR-DECISION-20260808-001","hold1_audit_id":"DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-HASH-ONLY-CONFIRMATION-20260808-001","hold1_audit_sha256":"5EE85855516E114029ADFF537E23DEFFBB47DBF557E94AEE9AC4DCCB8C65E993","canonical_metadata_tree_sha256":"32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70","stale_installer_metadata_tree_sha256":"7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C","next_build":"build-005","next_gate":"same-chain source PASS plus new reviewer-owned source approval"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T20:00:46+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_CANONICAL_TREE_HASH_SOURCE_PASS_BUILD005_AUTHORIZED_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The existing reviewer completed the canonical seven-file tree-hash source rereview with PASS/0 and issued a new reviewer-owned source approval. The exact approved source now uses one golden canonical definition with tree SHA-256 32F1DC23...E70 across builder and installer, and the stale 7C9ABE23...105C constant is absent. The sole owner may perform one clean build-005, but no output exists yet. Installation and authenticated media collection remain frozen pending build-005 hash-only PASS and a new install-exact-build approval.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_TREE_HASH_CONTRACT_REPAIR_SOURCE_REVIEW_PENDING","source_review_audit_id":"DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-SOURCE-REREVIEW-20260808-001","source_review_conclusion":"CANONICAL_TREE_HASH_CONTRACT_CLOSED_SOURCE_APPROVED","source_manifest_sha256":"333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535","source_approval_path":"ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-canonical-tree-hash-source-approval.json","source_approval_sha256":"E0D621EB2C5E8149FE9B775CF7EA2F2ECB17C3FC3A6D4911D67CF2D24ACBD3EF","canonical_metadata_tree_sha256":"32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70","next_build":"build-005","next_gate":"exact-build hash-only PASS plus new install-exact-build approval"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T20:01:44+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_BUILD005_STATIC_PASS_HASH_ONLY_REVIEW_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The single authorized clean build-005 completed its static build gates and produced exactly one PyInstaller onefile EXE plus a schema 2 receipt. The canonical metadata tree SHA-256 in the receipt is 32F1DC23...E70 and the archive type code is b. The EXE has not been executed or installed. The artifact is pending the original reviewer's exact-build hash-only confirmation and a new install-exact-build approval; authenticated Chrome runtime and real media collection remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_CANONICAL_TREE_HASH_SOURCE_PASS_BUILD005_AUTHORIZED_PENDING","build005_path":"dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005","build005_exe":"project-info-bili-auth-native-host.exe","build005_exe_bytes":21343716,"build005_exe_sha256":"CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE","build005_receipt":"build-artifact-manifest.json","build005_receipt_bytes":2874,"build005_receipt_sha256":"CC6663A15CDEB375167900730A9F956C9B09839558586E5DFC3D7FC57728ACC0","archive_metadata_type_codes":["b"],"canonical_metadata_tree_sha256":"32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70","next_gate":"exact-build hash-only PASS plus new install-exact-build approval"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1AkuH64EZh","title":"行情震荡向上。","source_url":"https://www.bilibili.com/video/BV1AkuH64EZh","published_at":"2026-08-06T22:30:10+08:00","collected_at":"2026-08-08T20:45:52+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_BUILD005_EXACT_PASS_INSTALL_SCHEDULING_PENDING","video_path":null,"expected_duration_seconds":2478,"cover_path":"20260806-223010_video-cover_行情震荡向上_BV1AkuH64EZh.jpg","cover_bytes":360053,"cover_sha256":"41C0F9D47204ECD22A9463D7163398A6C3894F2AD8D70C4D65E3BC8766CF3E4D","failure_reason":"The original reviewer completed build-005 exact-build hash-only confirmation with PASS/0 and issued a reviewer-owned install-exact-build approval bound to the exact source manifest, schema 2 receipt and EXE. The EXE has not been executed or installed. A separate explicit controlled installation schedule is still required before any install or HKCU action; authenticated Chrome runtime and real media collection remain frozen.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_YTDLP_BUILD005_STATIC_PASS_HASH_ONLY_REVIEW_PENDING","hash_review_audit_id":"DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-005-HASH-ONLY-CONFIRMATION-20260808-001","hash_review_conclusion":"BUILD005_EXACT_BUILD_HASH_ONLY_CONFIRMED","install_approval_path":"ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json","install_approval_bytes":596,"install_approval_sha256":"4D18A32DE9DFEB6B90C1A398087BD36CEAE09418D54E1F0DC485D6B95C962A44","install_status":"NOT_INSTALLED","next_gate":"project.admin or requester explicit controlled installation schedule"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1HA3o6oEJJ","title":"流水淘沙不暂停,前波未灭后波生。(周复盘)","source_url":"https://www.bilibili.com/video/BV1HA3o6oEJJ","published_at":"2026-08-02T21:11:11+08:00","collected_at":"2026-08-08T21:45:15+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_EXTENSION_IDENTITY_VISIBILITY","video_path":null,"expected_duration_seconds":3133.95,"cover_path":"20260802-211111_video-cover_流水淘沙周复盘_BV1HA3o6oEJJ.webp","cover_bytes":13450,"cover_sha256":"F71BAA83F76E6B3577C544C61FE8E70EF02504247D939E81F2703C4C5189970C","failure_reason":"Controlled runtime preflight verified the exact installed EXE, config, Native Messaging manifest, HKCU default value, allowed origin, empty CreateNew target and zero partial files. Chrome browser control then refused access to the fixed chrome-extension origin under its URL security policy, so the downloader could not independently prove that extension oidmclckpdmpabbfedplkbdplmfcenbb is present, enabled and identical to the reviewed snapshot. Fail closed before EXE execution, Cookie/session access or media network activity; actual runtime attempts and downloads remain zero.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_AUTH_REQUIRED","runtime_authorization_handoff_id":"HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-BUILD005-FIRST-TARGET-RUNTIME-AUTH-20260808-001","installed_exe_bytes":21343716,"installed_exe_sha256":"CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE","installed_config_sha256":"2F09B69AC9B8B82F90EB16673F71D3418AB73A94092C1785E5F697F50E6F2B50","installed_native_manifest_sha256":"A050B24FBFC879BEB25044D775AC43DC95F7BCDBDCCF153BF4198EF6B8E68B51","native_host":"com.project_info.bili_auth_ingress","allowed_origin":"chrome-extension://oidmclckpdmpabbfedplkbdplmfcenbb/","runtime_attempt_count":0,"download_attempt_count":0,"next_gate":"user-visible proof that the fixed reviewed extension is installed and enabled, or an admin-approved safer control surface"}
+{"schema_version":1,"creator":"青枫浦上Q","item_type":"video","stable_id":"BV1HA3o6oEJJ","title":"流水淘沙不暂停,前波未灭后波生。(周复盘)","source_url":"https://www.bilibili.com/video/BV1HA3o6oEJJ","published_at":"2026-08-02T21:11:11+08:00","collected_at":"2026-08-08T21:48:30+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_EXTENSION_NOT_LOADED","video_path":null,"expected_duration_seconds":3133.95,"cover_path":"20260802-211111_video-cover_流水淘沙周复盘_BV1HA3o6oEJJ.webp","cover_bytes":13450,"cover_sha256":"F71BAA83F76E6B3577C544C61FE8E70EF02504247D939E81F2703C4C5189970C","failure_reason":"Project administration performed a read-only Chrome profile readback and confirmed that exact extension ID oidmclckpdmpabbfedplkbdplmfcenbb is absent from Default/Extensions and absent from the Default Preferences entry, with no additional Profile-* profiles. The project Native Messaging host remains installed and healthy, but the reviewed Chrome extension is not loaded. The authorized first exact-target runtime is finalized blocked with zero EXE execution, authentication/session access, media network, download or formal file effects. Installation or loading of the extension is outside the current runtime authorization and no retry is allowed.","supersedes_status":"VIDEO_DOWNLOAD_BLOCKED_EXTENSION_IDENTITY_VISIBILITY","admin_readback_handoff_id":"HANDOFF-INFOADMIN-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-EXTENSION-PRESENCE-READBACK-20260808-001","extension_id":"oidmclckpdmpabbfedplkbdplmfcenbb","extension_loaded":false,"runtime_attempt_count":0,"download_attempt_count":0,"next_gate":"separate controlled installation/loading authorization for the exact reviewed Chrome extension"}
+{"schema_version":1,"creator":"青枫浦上Q","creator_uid":1420210197,"item_type":"video","stable_id":"BV189u16KEPe","title":"均线。","source_url":"https://www.bilibili.com/video/BV189u16KEPe","published_at":"2026-08-09T14:08:09+08:00","collected_at":"2026-08-09T16:37:48+08:00","status":"VIDEO_DOWNLOAD_BLOCKED_EXTENSION_NOT_LOADED","video_path":null,"expected_duration_seconds":3667,"cover_path":"20260809-140809_video-cover_均线_BV189u16KEPe.jpg","cover_bytes":153034,"cover_sha256":"CD1A2643A089E033AC9B9EADE054E46941DC6BC24679A72A9AB5539AB862C7E7","description":"感谢观看,一键三连。","source_verification":"Bilibili public view API code=0; owner.mid=1420210197; owner.name=青枫浦上Q; videos=1; AV duration=3667 seconds.","failure_reason":"A newly published exact-owner video was detected and its public cover was archived. Full-video acquisition did not start because the reviewed Chrome extension oidmclckpdmpabbfedplkbdplmfcenbb is still not loaded, and the existing unconsumed controlled runtime authorization covers only BV1HA3o6oEJJ, not this BVID. EXE execution, authentication/session access, media network, download and F:\\video publication remain zero for this item.","extension_id":"oidmclckpdmpabbfedplkbdplmfcenbb","extension_loaded":false,"runtime_attempt_count":0,"download_attempt_count":0,"next_gate":"user loads and confirms the exact reviewed extension; any later runtime for this BVID must remain separately exact-target scoped"}
diff --git "a/dev-doc/ana-doc/\350\202\241\347\245\250\344\274\260\345\200\274\346\265\201\346\260\264\347\272\277\350\257\264\346\230\216.md" "b/dev-doc/ana-doc/\350\202\241\347\245\250\344\274\260\345\200\274\346\265\201\346\260\264\347\272\277\350\257\264\346\230\216.md"
index 35200c2..f58514b 100644
--- "a/dev-doc/ana-doc/\350\202\241\347\245\250\344\274\260\345\200\274\346\265\201\346\260\264\347\272\277\350\257\264\346\230\216.md"
+++ "b/dev-doc/ana-doc/\350\202\241\347\245\250\344\274\260\345\200\274\346\265\201\346\260\264\347\272\277\350\257\264\346\230\216.md"
@@ -3,7 +3,7 @@
创建人员:`dev.developer.ana.cai`
文件职责:说明股票估值标准快照、统一计算、异常质检、缓存复用和 Markdown 底稿生成入口。
关联事项:`DEV-ANA-STOCK-VALUATION-PIPELINE-V1-20260801-001`。
-业务规范:`../../outputs/20260730_stock_valuation_guide/股票价格合理性评估操作手册_v1.0.md`。
+业务规范:`../../ana-doc/股票估值/股票价格合理性评估操作手册.md`。
代码入口:`../../dev/ana-dev/stock_valuation_pipeline/`。
测试入口:`../../dev/ana-dev/test/stock_valuation_pipeline/`。
审计入口:`../../ana-doc/案例审计报告.md`。
diff --git "a/dev-doc/project-doc/B\347\253\231\345\215\232\344\270\273\345\212\250\346\200\201\351\207\207\351\233\206\350\276\205\345\212\251\345\267\245\345\205\267.md" "b/dev-doc/project-doc/B\347\253\231\345\215\232\344\270\273\345\212\250\346\200\201\351\207\207\351\233\206\350\276\205\345\212\251\345\267\245\345\205\267.md"
new file mode 100644
index 0000000..1b88b74
--- /dev/null
+++ "b/dev-doc/project-doc/B\347\253\231\345\215\232\344\270\273\345\212\250\346\200\201\351\207\207\351\233\206\350\276\205\345\212\251\345\267\245\345\205\267.md"
@@ -0,0 +1,193 @@
+# B 站博主动态采集辅助工具
+
+创建人员:dev.developer.project.secondary / infodev-2
+文件职责:说明 B 站博主动态采集重型本地协调工具的入口、输入输出、安全边界、运行方法和验收方法。
+管理规范/模板:../../项目规范.md;../编码规范.md;../开发审计规范.md
+引用文件:../../ai-video-downloader/draft/2026-08-04-哔哩哔哩动态采集需求.md;../../dev/project-dev/bili_dynamic_collector.py;../../dev/project-dev/bili_dynamic_collector.example.json;../开发事项总纲.md;../开发事项计划.md;../开发执行日志.md
+记录方式:工具说明;公开 CLI、配置、状态、停止边界或验收结论变化时更新。
+
+## 1. 定位与边界
+
+本工具是本地文件协调器,不是爬虫或浏览器机器人。它只读取人工或浏览器扩展已导出的无认证动态元数据,并完成以下工作:
+
+1. 按博主和最近时间窗筛选动态。
+2. 以动态 ID、opus ID、BV 号和规范化来源 URL 去重。
+3. 生成待办 JSON,并维护逐事件 `manifest.jsonl`。
+4. 生成保留发布时间的 Windows 安全文件名。
+5. 按明确映射,把浏览器下载目录中已完成的视频安全移动到配置的视频目录。
+6. 生成给 `case_analysis.media_processor` 的完整、内容寻址 Codex 原生任务 handoff envelope;工具本身不发送。
+
+工具不联网,不调用 B 站接口,不读取浏览器配置,不控制浏览器或扩展,不添加、重试或下载扩展任务,也不发送 Codex 消息。登录、验证码、会员、DRM、访问控制和来源合法性不明均由人工停止处理。
+
+## 2. 文件入口
+
+| 入口 | 路径 |
+|---|---|
+| CLI | `dev/project-dev/bili_dynamic_collector.py` |
+| 开发示例配置 | `dev/project-dev/bili_dynamic_collector.example.json` |
+| 测试 | `dev/project-dev/test/test_bili_dynamic_collector.py` |
+| 脱敏 fixture | `dev/project-dev/test/fixtures/bili_dynamic_collector/dynamic_items.json` |
+
+示例配置故意只指向 `dev/project-dev/tmp/bili-dynamic-collector-mvp/`。项目管理员完成正式输出路径授权扩展前,不得把开发运行改到 `F:\video\<博主名>\` 或 `ana-data/news-<博主名>/`;本事项也不修改任何既有正式采集文件。
+
+## 3. 配置合同
+
+配置为 strict UTF-8 JSON。相对路径按配置文件所在目录解析。
+
+```json
+{
+ "schema_version": 1,
+ "creator": {
+ "name": "青枫浦上Q",
+ "dynamic_url": "https://space.bilibili.com/1420210197/dynamic"
+ },
+ "timezone": "Asia/Shanghai",
+ "window_hours": 72,
+ "minimum_complete_age_seconds": 30,
+ "title_max_length": 48,
+ "allowed_source_hosts": ["space.bilibili.com", "www.bilibili.com"],
+ "allowed_video_extensions": [".mp4", ".mkv", ".mov", ".webm"],
+ "native_handoff": {
+ "project_id": "project-info",
+ "source_ai_id": "video-downloader",
+ "source_thread_id": "019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5",
+ "source_role_instance_id": "case_analysis.video_downloader",
+ "target_ai_id": "media-processor",
+ "target_thread_id": "019fb7a4-bdfd-79f2-bd6b-e67e2b7d8efd",
+ "target_role_instance_id": "case_analysis.media_processor",
+ "reply_thread_id": "019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5"
+ },
+ "paths": {
+ "state_dir": "tmp/bili-dynamic-collector-mvp/state",
+ "download_dir": "tmp/bili-dynamic-collector-mvp/downloads",
+ "video_dir": "tmp/bili-dynamic-collector-mvp/videos"
+ }
+}
+```
+
+`native_handoff` 八个身份/路由字段全部必填,三个 thread ID 必须是小写 UUID。配置、导出、映射或历史 manifest 任意层级出现名称含 `password`、`cookie`、`token`、`secret`、`authorization`、`captcha`、`session`、密码、令牌、验证码或会话的字段时,三个命令都按 `E_SECRET_FIELD` 安全停止;只报告字段路径,不输出字段值,也不改写业务状态。
+
+## 4. 动态导出合同
+
+工具接收人工或扩展导出的 JSON 清单,不接收 HTML、浏览器 profile 或认证材料。
+
+```json
+{
+ "schema_version": 1,
+ "creator": "青枫浦上Q",
+ "items": [
+ {
+ "dynamic_id": "123456",
+ "opus_id": "987654",
+ "bvid": null,
+ "content_type": "text",
+ "published_at": "2026-08-04T10:00:00+08:00",
+ "title": "短标题",
+ "source_url": "https://www.bilibili.com/opus/987654"
+ }
+ ]
+}
+```
+
+合同要求:
+
+- `content_type` 只能是 `text`、`article`、`image`、`video`。
+- `published_at` 必须带 UTC offset 或 `Z`。
+- `dynamic_id`、`opus_id`、`bvid` 至少一项存在;`source_url` 必须存在且 host 已登记。
+- 时间窗包含下界和当前时刻;窗口外和未来时间项只计数、不生成待办。
+- 同一输入内或历史 manifest 中,只要任一稳定标识命中活动/成功实体,就不会重复生成待办。
+- 最新状态为 `QUEUE_FAILED`、`MOVE_FAILED` 或 `PROCESSING_FAILED` 时可重试,并追加新事件,不覆盖旧事件。
+
+## 5. 检查与定时入口
+
+```powershell
+python dev/project-dev/bili_dynamic_collector.py `
+ --config dev/project-dev/bili_dynamic_collector.example.json `
+ check `
+ --input <无认证动态导出.json>
+```
+
+`check` 是供 Codex 定时任务调用的入口。stdout 始终是一行 JSON;有新增时状态为 `TODO_GENERATED` 并给出待办路径,无新增时为 `NO_NEW_ITEMS` 且不新建空待办文件。`--now <带 offset 的 ISO-8601>` 只用于可重复测试或由调度器显式注入权威时钟。
+
+待办动作只描述人工下一步:文字/专栏导出完整 UTF-8 文本、图片下载原图、视频打开播放页并使用已安装扩展。工具不会替代这些交互。
+
+## 6. 命名与 manifest
+
+文件 stem 为 `YYYYMMDD-HHmmss_<类型>_<短标题>`。标题会替换 Windows 非法字符、去除尾部空格/点、保护保留名并按配置截断;不同实体占用同一 stem 时依次加 `_01`、`_02`。图片待办另给出同一动态多图的 `_{sequence:02d}` 命名模式。
+
+`state_dir/manifest.jsonl` 是 append-only 事件账。每行至少包含:博主、动态/opus/BV 标识、内容类型、发布时间、标题、来源 URL、稳定去重键、建议 stem、本地文件、SHA-256、采集时间、状态、失败原因、视频处理状态和文本路径。每行 JSON parse 后会先经过与配置/输入相同的递归秘密字段门禁,再读取 schema、身份或状态。写入采用同目录临时文件、flush/fsync 和原子替换;重跑不改写历史事件。
+
+视频终态还执行状态感知证据校验:`VIDEO_MOVED`/`COMPLETE` 必须有无控制字符的非空 `local_file` 和 64 位小写十六进制 SHA-256;`VIDEO_MOVED_SOURCE_RETAINED` 还必须有非空、无控制字符的 `failure_reason`。缺失、空白或格式损坏统一以 `E_MANIFEST`/exit 3 停止,不投影成功且不改写任何业务文件。
+
+MVP 状态主链为:
+
+```text
+TODO_QUEUED
+ -> MOVE_FAILED -> TODO_QUEUED(显式重试)
+ -> VIDEO_MOVED / VIDEO_MOVED_SOURCE_RETAINED
+ -> READY_FOR_HANDOFF(video_processing_status)
+```
+
+## 7. 安全移动已完成视频
+
+先由人工确认浏览器扩展任务已完成,再准备映射:
+
+```json
+{
+ "schema_version": 1,
+ "items": [
+ {
+ "source_file": "扩展下载结果.mp4",
+ "bvid": "BV1xxxxxxxxxx"
+ }
+ ]
+}
+```
+
+`source_file` 相对路径按 `download_dir` 解析;也可给绝对路径,但解析后仍必须位于该目录。每项可同时提供动态 ID、opus ID、BV 号和来源 URL;每个已提供选择器都必须独立、唯一命中,且全部命中同一实体,未知或不一致的选择器不会被忽略。
+
+```powershell
+python dev/project-dev/bili_dynamic_collector.py `
+ --config <已授权配置.json> `
+ move-completed `
+ --mapping <已完成视频映射.json>
+```
+
+移动采用两阶段全批预检。阶段 A 先解析全部选择器、拒绝重复实体并读取最新状态;只有最新状态精确为 `TODO_QUEUED` 或 `MOVE_FAILED` 的活动行进入阶段 B。处理阶段、缺失/空白或未知状态在复制前以 `E_STATUS` 停止。阶段 B 才检查临时下载后缀、完成宽限时间、视频后缀、下载根、链接/junction/reparse、物理同源和目标唯一性。任一预检失败时,整批不创建目标、不删除源、不追加 manifest。完整预检通过后的跨盘移动采用“目标目录隐藏 partial → flush/fsync → SHA-256 复验 → 同目录 hard-link no-overwrite 提交 → manifest → 删除源”的顺序;文件系统不支持安全 no-overwrite hard-link 时停止,不退化为可能覆盖的移动。
+
+源删除失败时,目标和 SHA-256 仍可信,manifest 追加 `VIDEO_MOVED_SOURCE_RETAINED`,stdout exit 为 `4`,由人工核对并清理源副本。相同 mapping 重跑继续复用已记录的目标、hash 和失败原因并返回 exit 4;即使保留源后来被人工删除也不降级、不追加事件。正常 `VIDEO_MOVED` 重跑在源已删除后返回 `ALREADY_MOVED`/0。正式目标绝不覆盖。
+
+## 8. 生成视频处理员原生 handoff
+
+```powershell
+python dev/project-dev/bili_dynamic_collector.py `
+ --config <已授权配置.json> `
+ handoff
+```
+
+工具会先逐个确认视频仍在 `video_dir` 内、不是链接且 SHA-256 与 manifest 一致,然后在 `state_dir/handoffs/` 生成内容寻址的完整 `<codex_native_handoff>` Markdown。正文含精确 source/target AI、thread、role、reply thread、`scope/evidence/expected_action`,状态为 `PROCESSING_REQUESTED`;handoff ID 由路由和排序后的证据派生。同一路由/证据即使 `--now` 不同也得到相同 bytes 并返回 `REUSED`。stdout 始终 `sent=false`;文件生成不等于发送,仍由视频下载员通过 Codex 原生任务通信确认并发送。
+
+生成前会验证全部 handoff 消费标量:内部 entity ID、可选 BV、canonical UTC 发布时间、标题、来源 URL、本地路径及 SHA-256。CR/LF、其他控制字符或格式异常均在文件创建前以 `E_MANIFEST` 停止;错误输出只含安全字段路径,不回显污染值。
+
+## 9. 退出码与停止处理
+
+| exit | 含义 |
+|---|---|
+| `0` | 成功或无新增 |
+| `1` | 未预期内部失败,默认关闭 |
+| `2` | 输入/配置合同错误 |
+| `3` | 安全停止:路径、认证字段、重名、未完成、身份冲突等 |
+| `4` | 目标已安全提交,但源文件删除失败并保留 |
+| `130` | 用户中断 |
+
+登录提示、验证码、付费墙、会员异常、DRM、访问控制、来源合法性不明和浏览器扩展任务异常不由本 CLI 处理;人工应停止并报告,不得向配置或输入补入认证秘密。
+
+## 10. 开发验证
+
+```powershell
+python -m py_compile dev/project-dev/bili_dynamic_collector.py
+python dev/project-dev/bili_dynamic_collector.py --help
+python -m unittest dev.project-dev.test.test_bili_dynamic_collector -v
+```
+
+目标测试包含 13 个离线用例,覆盖选择器一致性、同源/同实体全批预检、三类终态重跑、六类禁止状态、terminal 缺失/空白/格式损坏矩阵、handoff 全标量控制字符/格式注入矩阵、manifest secret gate、规范 handoff 稳定性及原有窗口/命名/移动边界。目标 13/13、新鲜闭环 1/1、project 76/76 均 PASS;审核员另独立复跑原阻断反例 2/2、目标 13/13、project 76/76,限定实现复审结论为 `PASS/0`。测试只使用脱敏 JSON 和临时小字节文件,不联网、不启动浏览器、不读写 `F:\video\`、不读写正式 `ana-data`,也不复制博主正文或真实媒体。请求方已按 `HANDOFF-VIDEO-DOWNLOADER-INFODEV2-BILI-DYNAMIC-COLLECTOR-PASS0-ACK-20260805-001` 接收并关闭本开发事项;真实网络、浏览器、下载、自动 native send、每小时调度、`F:\video\青枫浦上Q` 和正式 `ana-data` 的既有授权由视频下载员按项目配置消费,本工具和本开发事项本身不构成这些动作的授权来源。
diff --git "a/dev-doc/project-doc/B\347\253\231\345\215\232\344\270\273\345\212\250\346\200\201\351\207\207\351\233\206\350\276\205\345\212\251\345\267\245\345\205\267HOLD5\344\277\256\345\244\215\345\256\236\347\216\260\344\270\216\351\252\214\350\257\201\350\257\201\346\215\256.md" "b/dev-doc/project-doc/B\347\253\231\345\215\232\344\270\273\345\212\250\346\200\201\351\207\207\351\233\206\350\276\205\345\212\251\345\267\245\345\205\267HOLD5\344\277\256\345\244\215\345\256\236\347\216\260\344\270\216\351\252\214\350\257\201\350\257\201\346\215\256.md"
new file mode 100644
index 0000000..032db55
--- /dev/null
+++ "b/dev-doc/project-doc/B\347\253\231\345\215\232\344\270\273\345\212\250\346\200\201\351\207\207\351\233\206\350\276\205\345\212\251\345\267\245\345\205\267HOLD5\344\277\256\345\244\215\345\256\236\347\216\260\344\270\216\351\252\214\350\257\201\350\257\201\346\215\256.md"
@@ -0,0 +1,97 @@
+# B 站博主动态采集辅助工具 HOLD/5 修复实现与验证证据
+
+创建人员:dev.developer.project.secondary / infodev-2
+文件职责:记录 `DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001` 五项审核阻断的获批设计、实现映射、离线测试和外部动作边界。
+管理规范/模板:../../项目规范.md;../编码规范.md;../开发审计规范.md
+引用文件:开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md;开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V002.md;开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003.md;B站博主动态采集辅助工具.md;../../dev/project-dev/bili_dynamic_collector.py;../../dev/project-dev/test/test_bili_dynamic_collector.py
+记录方式:重型实现证据;审核结论或冻结实物变化时更新。
+
+## 1. 设计门禁
+
+- V001:`11917/16C14FB2751A0EAB63F76371E75D0569FF8EF5B47FDD8724BE861314B6062733`。
+- V002:`6342/DA3B3DDAA3C8B8EE33E2C0B6857B962754D740EB4BEE075E3BFB0F0FD4382736`。
+- V003:`3197/D5812302D994C9D1835E5EA5BEEF305FAEDEBA892CDCAC0E545EF82EC1BDEBDA`。
+- 最终设计回执:`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003-ALLOWLIST-DESIGN-REVIEW-RESULT-20260805-001`,`PASS/0`。
+- 实现在该 PASS 后开始;未修改 fixture,未扩大 provider、网络、浏览器、下载、调度、自动发送或正式数据范围。
+
+## 2. HOLD/5 实现映射
+
+| finding | 闭环实现 | 非真空证据 |
+|---|---|---|
+| F1 重型设计门禁 | V001+V002+V003 冻结状态/事务/恢复/handoff 合同 | 三轮独立限定设计审核,最终 PASS/0 |
+| F2 mapping/批量预检 | 每个 selector 单独命中且全一致;全批 entity 唯一后再分类;只有 `TODO_QUEUED`/`MOVE_FAILED` 进入物理源/目标预检;同物理源拒绝;预检不追加失败事件 | 未知 selector、跨实体 selector、重复实体、相对/绝对 alias 同源反例;manifest/source/target bytes 不变 |
+| F3 retained 重跑 | `VIDEO_MOVED_SOURCE_RETAINED` 从 manifest 投影原 local_file/hash/failure_reason,固定 exit 4,不 stat 源、不追加事件 | 源仍在重跑与人工删源后重跑;两次 manifest raw bytes 均不变 |
+| F4 manifest secret gate | 每行 parse 后、schema/identity/status 前调用递归 `reject_secret_keys` | 三个命令、根层 cookie 与嵌套 token;E_SECRET_FIELD 且值不出 stdout |
+| F5 canonical native handoff | 必填 route,thread/标识格式校验,内容寻址 ID,完整 source/target/reply 与三段正文,不含运行时生成时间 | 不同时钟同 route/evidence 得到同一文件/bytes、REUSED;路由缺失/格式/换行注入均 E_CONFIG |
+
+## 3. 冻结实物
+
+| 文件 | bytes | SHA-256 |
+|---|---:|---|
+| `dev/project-dev/bili_dynamic_collector.py` | 56152 | `9B5C6FCE647C8E18822AB53488A57B6048A09997B62024B8FD2B593F0196DABF` |
+| `dev/project-dev/bili_dynamic_collector.example.json` | 1117 | `42A9AA7EE29ED061DC408C6C9A907FA7B20FD8C2B41605C54B6716A0FE388951` |
+| `dev/project-dev/test/test_bili_dynamic_collector.py` | 24767 | `684F78F944F045C1352E4C09BFAFE598B7B5E9B5195EC944F5AF87D70EACC0D3` |
+| `dev/project-dev/test/fixtures/bili_dynamic_collector/dynamic_items.json` | 1216 | `0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F` |
+| `dev-doc/project-doc/B站博主动态采集辅助工具.md` | 10763 | `E976BDECE48D952B29AC65438C2E5891A67E29D1500DDD1D4B54889CDA224B48` |
+
+## 4. 离线验证
+
+```powershell
+python -B -m py_compile dev/project-dev/bili_dynamic_collector.py dev/project-dev/test/test_bili_dynamic_collector.py
+python -B -m unittest dev/project-dev/test/test_bili_dynamic_collector.py
+python -B -m unittest dev.project-dev.test.test_bili_dynamic_collector.BiliDynamicCollectorTests.test_safe_move_hash_manifest_and_handoff_are_idempotent -v
+python -B -m unittest discover -s dev/project-dev/test -p 'test_*.py'
+```
+
+- py_compile=PASS;CLI help=PASS。
+- 目标=`11/11 PASS / 1.393s`。
+- 新鲜 `TemporaryDirectory` 闭环=`1/1 PASS / 0.226s`。
+- project discover=`74/74 PASS / 48.657s`。
+- diff check=PASS;治理=`OK/projects=1/warnings=0`(在提交复审前复算)。
+
+开发自检:`dev/tmp/bili-dynamic-collector-mvp-hold5-repair-20260805/selfcheck.md`。
+
+## 5. 安全与交付状态
+
+验证期间网络、浏览器、扩展交互、下载、真实媒体、调度、自动 native send、`F:\video` 和正式 `ana-data` 动作为 0。
+
+限定实现复审回执 `HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-IMPLEMENTATION-REVIEW-RESULT-20260805-001` 为 `HOLD/2`。审核员独立复跑目标 `11/11`、新鲜闭环 `1/1`、project `74/74` 均 PASS,但另以纯临时反例确认:
+
+1. `VIDEO_MOVED`/retained 终态缺少必需 `local_file`、64 位小写 SHA-256 或 retained `failure_reason` 时仍可能投影成功;应以 `E_MANIFEST`/exit 3 零副作用停止。
+2. manifest 的 `entity_id`、`bvid`、`published_at` 等未校验标量可能以换行注入 canonical handoff;应在生成前验证或统一 newline-safe JSON 序列化,并补参数化入口反例。
+
+其余设计门禁、mapping 预检、完整字段 retained 粘性、secret gate、route/稳定 ID/sent=false 及无外部动作边界均已通过并冻结。当时状态=`HOLD_2_ISOLATED_DEVELOPMENT_SNAPSHOT_ONLY`;该状态已由第 7 节的限定复审 `PASS/0` 终态替代。
+
+## 6. HOLD/2 fail-closed 限定返修
+
+请求方明确持续授权后,仅实施审核列明的两项:
+
+1. 新增共享状态感知 terminal validator。视频 `VIDEO_MOVED`/`COMPLETE` 必须有非空无控制字符 `local_file` 与 64 位小写 SHA-256;retained 还必须有非空无控制字符 `failure_reason`。`load_manifest` 和 terminal projection 均 fail-closed。
+2. handoff 创建前验证所有消费标量:`entity_id` 为 24 位小写十六进制,BV 为合法可选标识,`published_at` 为 canonical UTC,title/source URL/local path 无控制字符,SHA-256 为 64 位小写十六进制。任何污染都返回 `E_MANIFEST`/exit 3,stdout 不回显值且不创建 handoff。
+3. 新增两个参数化 CLI/main 测试:terminal 缺失/空白/控制字符/错误 hash 共 9 组;handoff entity/BV/time/title/URL/path/hash 控制字符或格式错误共 10 组。每组比较调用前后完整业务树、manifest raw bytes、目标和锁/partial/event 状态。
+
+返修冻结实物:
+
+| 文件 | bytes | SHA-256 |
+|---|---:|---|
+| `dev/project-dev/bili_dynamic_collector.py` | 60090 | `67B2E6FB34F100C57BF50A4D3067D6FDD6E47C5D3FBEF58D2CF5D8796B07AB8B` |
+| `dev/project-dev/test/test_bili_dynamic_collector.py` | 31303 | `D82BFF8AD9B78FD749A7536FF9FED0486CF87D6B9FDDEE7947202C2414AAE322` |
+| 示例配置 | 1117 | `42A9AA7EE29ED061DC408C6C9A907FA7B20FD8C2B41605C54B6716A0FE388951` |
+| fixture(未修改) | 1216 | `0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F` |
+
+返修验证:py_compile/CLI help PASS;目标=`13/13 PASS / 2.294s`;新鲜闭环=`1/1 PASS / 0.227s`;project discover=`76/76 PASS / 61.424s`;diff check PASS。网络、浏览器、扩展、下载、真实媒体、调度、native send、`F:\video`、正式 `ana-data` 动作继续全部为 0。
+
+## 7. HOLD/2 fail-closed 限定复审终态
+
+- 回执:`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-REREVIEW-RESULT-20260805-001`。
+- 结论:`PASS/0`;F1 terminal persisted evidence fail-closed 与 F2 native handoff manifest 标量防注入均 `CLOSED`,前序冻结合同未回退。
+- 审核员独立复验:原始阻断反例=`2/2 PASS / 0.919s`;目标 unittest=`13/13 PASS / 2.452s`;project discover=`76/76 PASS / 58.021s`;py_compile、CLI help、diff、strict UTF-8/no BOM 和治理均 PASS。
+- 审计:`DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-LIMITED-IMPLEMENTATION-REREVIEW-20260805-001`;审计文件在该回执时为 `250254 bytes / 43FD12548388E3760DCB52644273E54318591D44CE3D83E42013E1699F8D64B2`。
+- 交付状态:`PASS_0_PROJECT_TOOL_DELIVERABLE`。冻结实现可作为项目级本地辅助工具交付;本结论不授权真实网络、浏览器、扩展、下载、自动 native send、调度、`F:\video` 或正式 `ana-data`。
+
+## 8. 请求方接收关闭
+
+- 回执:`HANDOFF-VIDEO-DOWNLOADER-INFODEV2-BILI-DYNAMIC-COLLECTOR-PASS0-ACK-20260805-001`;关联交付=`HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-DYNAMIC-COLLECTOR-MVP-PASS0-FINAL-20260805-001`。
+- 结论:`ACCEPTED`;请求方确认两项 fail-closed 阻断及独立复审证据完整,本开发事项终态关闭,后续新缺陷另行回传。
+- 外部边界:请求方确认当前博主真实浏览器、下载、`F:\video\青枫浦上Q`、正式 `ana-data`、原生转写交接及每小时调度已有用户与项目配置授权,无需由开发员重新申请;这些动作由视频下载员按现有授权执行,工具不被解释为浏览器或下载授权来源。
+- 最终状态:`ACCEPTED_CLOSED`;本轮未追加功能、未运行任何外部动作、未修改代码/测试/配置/fixture。
diff --git "a/dev-doc/project-doc/B\347\253\231\345\256\214\346\225\264\350\247\206\351\242\221\344\270\213\350\275\275\346\241\245\346\216\245\345\267\245\345\205\267.md" "b/dev-doc/project-doc/B\347\253\231\345\256\214\346\225\264\350\247\206\351\242\221\344\270\213\350\275\275\346\241\245\346\216\245\345\267\245\345\205\267.md"
new file mode 100644
index 0000000..e11c28c
--- /dev/null
+++ "b/dev-doc/project-doc/B\347\253\231\345\256\214\346\225\264\350\247\206\351\242\221\344\270\213\350\275\275\346\241\245\346\216\245\345\267\245\345\205\267.md"
@@ -0,0 +1,117 @@
+# B站完整视频下载桥接工具
+
+## 用途与边界
+
+`bili_video_download_bridge.py` 接收 1—10 条已确定的 Bilibili 规范视频地址。它既可顺序处理无需登录的公开视频,也可接管一个已经由用户授权界面完整落盘的本地媒体文件;两条路径最终复用同一 FFprobe、时长一致性、SHA-256、CreateNew 和映射门禁。成功产物必须同时包含视频流和音频流,并伴随 `bytes`、`SHA-256`、来源和发布时间映射。
+
+本工具不读取 Chrome Cookie、浏览器配置、扩展 token、密码、验证码或其他认证秘密;不接受带查询参数的地址,不使用外部签名 m4s 直链,也不绕过会员、DRM 或访问控制。公开视频无需登录即可继续;若站点要求登录或限制访问,工具明确失败。
+
+“视频转文字助手”1.0.5 的当前公开实现使用 `bestaudio/best` 下载音频、转为 MP3,再生成 TXT,没有完整视频输出接口,因此扩展任务已添加或 TXT 已生成都不算本工具的完成证据。参考其公开的 [mini_transcriber.py](https://github.com/kangchainx/video-text-chrome-extension/blob/main/mini_transcriber.py)。
+
+## 环境
+
+建议使用隔离环境;开发现场确认扩展服务自带的 2026.3.17 对两条目标均返回 HTTP 412,而当前稳定版 2026.7.4 的无 Cookie 探测均通过,因此固定使用已验证的新版本:
+
+```powershell
+conda create -n project-info-bili-download-bridge python=3.12 -y
+conda activate project-info-bili-download-bridge
+python -m pip install "yt-dlp==2026.7.4"
+```
+
+需要可执行的 `ffmpeg` 和 `ffprobe`。脚本本身只使用 Python 标准库。
+
+## 输入
+
+示例见 `dev/project-dev/bili_video_download_bridge.example.json`:
+
+```json
+{
+ "schema_version": "1.0",
+ "batch_id": "qingfengpushangq-20260805",
+ "items": [
+ {
+ "bvid": "BV1DVMX6XEPq",
+ "source_url": "https://www.bilibili.com/video/BV1DVMX6XEPq",
+ "published_at": "2026-08-03T19:39:03+08:00"
+ }
+ ]
+}
+```
+
+`source_url` 必须严格为 `https://www.bilibili.com/video/<BVID>`,不得带查询参数;`published_at` 必须带时区。正式文件名由 BVID 决定,远端标题不会参与路径拼接。
+
+## 使用
+
+先做无媒体下载探测:
+
+```powershell
+python dev/project-dev/bili_video_download_bridge.py `
+ --input dev/project-dev/bili_video_download_bridge.example.json `
+ --yt-dlp yt-dlp probe
+```
+
+探测两条均为 `METADATA_PASS_ONLY` 后,只能说明规范页面及远端总时长可读取,不能证明站点会提供完整媒体。随后由视频下载员执行真实下载:
+
+```powershell
+python dev/project-dev/bili_video_download_bridge.py `
+ --input dev/project-dev/bili_video_download_bridge.example.json `
+ --yt-dlp yt-dlp download `
+ --destination "F:\video\青枫浦上Q" `
+ --ffmpeg ffmpeg `
+ --ffprobe ffprobe
+```
+
+目标目录必须预先存在。工具在目标卷内的隐藏暂存目录下载,成功后以 CreateNew 语义发布 `<BVID>.<container>` 和 `<BVID>.download.json`;已有同 BVID 产物时拒绝覆盖。单项失败不阻断下一项,但整批返回非零。失败暂存会清理,错误中的远端 URL 会脱敏。
+
+发布前还会比较 probe 的远端总时长和 FFprobe 的本地时长:允许的封装偏差为 `max(3 秒, 远端时长 × 0.1%)`。超过该值视为会员预览、错误分 P 或不完整媒体,直接失败且不发布。映射同时保存远端时长、本地时长、差值和本次容差。多视频/播放列表结果以及 `needs_auth`、`premium_only`、`private`、`subscriber_only` 均显式停止。
+
+## 已登录 Chrome:精确人工步骤与自动接管
+
+### 扩展按钮链的实际能力
+
+“视频转文字助手”的现有按钮顺序是:
+
+1. 在目标视频页点击 Chrome 工具栏中的“视频转文字助手”图标,打开侧栏;
+2. 点击“添加任务”;
+3. 在“进行中”查看任务;若失败,悬停任务并点击右侧“重试”;
+4. 完成后切换到“已完成”,点击任务右侧“下载 TXT”。
+
+第 4 步固定下载 TXT,不是视频。公开后台同时证明它只保留 MP3 转写输入;所以这条按钮链只能用于转写任务,不能作为完整视频原件的人工步骤。Chrome browser-client 的 `locator.downloadMedia` 对当前 Bilibili 播放器没有产生下载事件,`cua_download_media` 不受 Chrome 支持,页面资产也没有完整媒体文件。当前控制面因此存在一个明确外部缺口:没有可在上述安全边界内把已授权播放直接导出为完整音视频文件的按钮。不要把“已完成”、TXT、MP3、封面或 600 秒预览交给本命令。
+
+### 文件已经合法落盘后的唯一接管命令
+
+只有当用户通过平台或其他已获授权、且不向本工具暴露认证秘密的界面,取得一个下载完成的完整媒体文件后,才执行:
+
+```powershell
+python dev/project-dev/bili_video_download_bridge.py `
+ --input dev/project-dev/bili_video_download_bridge.example.json `
+ --yt-dlp yt-dlp accept-browser-file `
+ --bvid BV1HA3o6oEJJ `
+ --media-file "<下载完成的本地完整媒体文件>" `
+ --destination "<正式目标目录>" `
+ --ffprobe ffprobe
+```
+
+运行前确认浏览器下载列表显示“完成”,输入文件不以 `.crdownload`、`.part`、`.m4s` 或 `.webp` 结尾。命令会等待 1 秒复核大小和修改时间稳定,读取公开 metadata 获取远端总时长,把源文件只读复制到目标卷隐藏暂存,核验源/副本 SHA-256、FFprobe 音视频与时长,再以 CreateNew 语义发布。源文件不会移动、重命名或删除;失败会清理暂存并保持正式输出为零。映射的 `acquisition_mode` 为 `authorized_browser_file_handoff`,不记录源绝对路径、Cookie、token、浏览器 profile 或媒体分片地址。
+
+此命令只自动化“文件落盘后的后半程”,不会假称解决当前 Chrome/扩展完整媒体出口缺口。
+
+## 完成判据
+
+以下条件缺一不可:
+
+- 目标目录中存在两个非空完整媒体文件,而不是 `.webp`、`.m4s` 或 `.part`;
+- FFprobe 对每个文件均确认时长大于零,且同时存在视频流和音频流;
+- 本地时长与远端总时长在上述封装容差内;600 秒预览不能冒充约 3134 秒完整视频;
+- 每个媒体均有对应 `.download.json`,其中 BVID、规范来源、`published_at`、字节数和 SHA-256 完整;
+- 映射中的字节数和 SHA-256 与落盘文件现场重算一致;
+- 两条均完成后,才可把这些完整原件和映射交给顺序转写链路。
+
+封面、扩展任务、仅音频、分离的 m4s、浏览器页面可见或单项下载成功均不代表整批完成。
+
+## 测试
+
+```powershell
+python -B -m unittest dev/project-dev/test/test_bili_video_download_bridge.py -v
+python -B -m unittest discover -s dev/project-dev/test -p "test_*.py" -v
+```
diff --git "a/dev-doc/project-doc/B\347\253\231\345\267\262\347\231\273\345\275\225\344\274\232\350\257\235\345\256\214\346\225\264\350\247\206\351\242\221\346\211\251\345\261\225\345\205\245\345\217\243.md" "b/dev-doc/project-doc/B\347\253\231\345\267\262\347\231\273\345\275\225\344\274\232\350\257\235\345\256\214\346\225\264\350\247\206\351\242\221\346\211\251\345\261\225\345\205\245\345\217\243.md"
new file mode 100644
index 0000000..5128fb0
--- /dev/null
+++ "b/dev-doc/project-doc/B\347\253\231\345\267\262\347\231\273\345\275\225\344\274\232\350\257\235\345\256\214\346\225\264\350\247\206\351\242\221\346\211\251\345\261\225\345\205\245\345\217\243.md"
@@ -0,0 +1,350 @@
+# B 站已登录会话完整视频扩展入口
+
+创建人员:`dev.developer.project`
+文件职责:说明精确目标 `BV1HA3o6oEJJ` 的 Chrome side panel、Native Messaging 安全入口、离线验证及后续获授权安装/真实验收步骤。
+管理规范/模板:`../../编码规范.md`;`../../开发审计规范.md`;`开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001.md`;`开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002.md`。
+引用文件:`目录导读.md`;`开发工作区说明.md`;`../../../dev/project-dev/bili_authenticated_extension/`;`B站完整视频下载桥接工具.md`;`../../开发执行日志.md`。
+记录方式:单目标高安全入口用法;身份、协议、构建、安装、按钮顺序或验收门禁变化时更新。
+
+## 1. 当前状态与边界
+
+- 事项:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;上游:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 唯一目标:`青枫浦上Q / BV1HA3o6oEJJ / https://www.bilibili.com/video/BV1HA3o6oEJJ`;完整时长基线 `3133.95 s`。
+- 扩展入口实现位于 `dev/project-dev/bili_authenticated_extension/`;冻结后半程仍是 `dev/project-dev/bili_video_download_bridge.py accept-browser-file`,未修改其字节。
+- 当前已完成离线实现、合成 AV、真实无网络 yt-dlp/CookieJar、Windows sleeping-child 验证,以及一次经批准但随后因缺少 `yt_dlp` 被判定不可安装的 exact-source PyInstaller onefile 历史构建。最小构建合同修复已完成并等待新的 source review;replacement build 尚未执行。扩展/Native Host 未安装,HKCU 未写入,真实 Cookie/会话未读取,媒体未联网下载,600 秒隔离预览未读取,`F:\video` 与正式 `ana-data` 未写入。
+- 必须先取得 `dev.reviewer.project` 实现审核 PASS,再由 `case_analysis.video_downloader` 在另行明确的真实动作授权下构建、安装和点击。开发线程不代替下载员执行真实认证下载。
+
+本入口不是通用下载器,不接受其他 BVID、账号、博主、URL、输出路径或命令;不做批量、续传、调度、转写或数据库。600.133313 秒会员预览继续隔离,绝不能发布或转写。
+
+## 2. 固定身份与组件
+
+| 项 | 固定值 |
+|---|---|
+| Extension ID | `oidmclckpdmpabbfedplkbdplmfcenbb` |
+| Origin | `chrome-extension://oidmclckpdmpabbfedplkbdplmfcenbb/` |
+| Native Host | `com.project_info.bili_auth_ingress` |
+| 协议 | `2` |
+| Extension build | `project-info-bili-auth-ingress/1.0.0+20260805.v002` |
+| Host build | `project-info-bili-auth-native-host/1.0.0+20260805.v002` |
+| Public DER SHA-256 | `E83C2B2AF3CF011543FBA13FBC524D1122EEA68548F9F27B9F7A82B5D594666C` |
+| yt-dlp | `2026.07.04`,版本与 22 个相关模块 SHA-256 固定 |
+| Host build tool | `PyInstaller 6.15.0 --onefile --noupx`;输出只能是唯一 EXE 与 schema 2 build receipt |
+
+主要文件:
+
+- `manifest.json`、`background.js`、`sidepanel.html/js/css`:用户可见双击入口;无 downloads/storage/debugger/webRequest/localhost/外部消息权限。
+- `native_host.py`:只用 stdlib/Win32 的 broker;启动时先核验 Chrome origin、非秘密配置和输出碰撞,不 import yt-dlp。
+- `job.py`:`CREATE_SUSPENDED → AssignProcessToJobObject → ResumeThread`;任务 Job 使用 `KILL_ON_JOB_CLOSE`。
+- `worker.py`:插件禁用、内存 CookieJar、一次 metadata 解析、实际 downloader allowlist、本地 FFmpeg MKV、冻结后半程。
+- `config.example.json`:非秘密固定配置范本;所有可执行程序、bridge、batch JSON 都必须给出绝对本地路径和 SHA-256。
+- `source-artifact-manifest.json`:实现审核使用的源文件 bytes/SHA-256 清单。
+- `build_host.ps1`:仅在审核后使用固定 PyInstaller 生成唯一 onefile EXE 和 `build-artifact-manifest.json`;调用 builder 前必须核对 source/build 树外的 reviewer/admin-approved source receipt。
+- `install_native_host.ps1`:默认只校验;必须先核对树外 approved source receipt 与 exact EXE/build receipt 的 approved build receipt。显式 `-Install` 才可能写当前用户 HKCU,且所有身份、host/config/path 门禁必须先通过。
+
+## 3. 安全闭环
+
+1. 用户先在精确页面可见地确认“充电中”、播放器 `52:14` 且完整播放可用。
+2. 第一次点击“校验当前页面”。扩展只投影 BVID、规范 URL、时长、尺寸、readyState、EME 布尔值和 60 秒 nonce;同时新建无秘密 Native Host 连接并完成初始 origin/config/hash/CreateNew 碰撞预检。
+3. 第二次点击在 side panel/background 的首个异步边界前取得不可重入 start lease 并立即禁用 start/retry,再发送含一次性 `prepare_id`、但不含 Cookie 的 fresh `prepare`。broker 重新核验 config/hash/正式碰撞,suspended worker 纳入独立 kill-on-close Job;worker 在清空环境前验证并保留固定 `%LOCALAPPDATA%`,清理陈旧 `run-*`、创建本次暂存 lease、禁用插件并报告 `READY_PLUGIN_DISABLED`。以上任一步失败时 Cookie 读取和 secret frame 均为 0。
+4. 只有与当前 start lease 精确关联的 `prepare_id` 返回 `READY`,扩展才按 active tab 唯一 Cookie store 读取当前 URL 可发送的 Cookie,逐字段投影后发送一次;重复 start 在读取 Cookie 前返回 `E_BUSY`,重复/错配的 native start 返回 `E_PREPARE` 且不得终止已启动任务。不使用 storage、Cookie 文件、profile 或 loopback。
+5. worker 在同一个冻结 `YoutubeDL` 实例中只执行一次 `extract_info(..., download=False, process=True)`,用同一 `download_info` 调用 `process_info()`;叶 downloader 只允许 HTTPS `HttpFD` 或 `DashSegmentsFD`。
+6. 双流只对本地文件 stream-copy 合并;单 AV fallback 也必须重新 stream-copy 为唯一 MKV。签名媒体 URL、headers 或 Cookie 一旦出现在子进程 argv/env 即在 CreateProcess 前拒绝。
+7. Cookie stream 在后半程前 `seek(0)/truncate(0)/close()`。随后由 worker(仍在同一 task Job)启动冻结 `accept-browser-file`,继续执行稳定、partial、AV、时长、SHA-256、CreateNew 媒体和映射门禁。
+8. 本地 remux、候选核对、FFprobe 和 bridge 前均检查 cancel。bridge 前设置共享 commit 事件并再次检查 cancel;commit 前取消只能形成无正式输出的 `CANCELED`,commit 已开始后取消被拒绝并由 bridge 的 `COMPLETE/FAILED` 唯一决定终态。EOF、Chrome 断连、`KeyboardInterrupt`、`SystemExit`、metadata 120 秒或下载合并 7200 秒超时继续由 Job Object 有界收口。
+
+## 4. 离线验证
+
+使用既有环境,不读取浏览器或网络:
+
+```powershell
+D:\Anaconda\envs\project-info-bili-download-bridge\python.exe -B -m unittest discover `
+ -s dev/project-dev/test/bili_authenticated_extension -p "test_*.py" -v
+```
+
+测试覆盖:
+
+- public key → DER hash → extension ID 复算、唯一 origin、exact wire/cookie schema、duplicate key/NaN/oversize/控制字符反例;
+- mock Chrome 的“校验 → 延迟 Cookie-store 双击 → retry”,双击只形成一个 Cookie read/secret start,另一个 `E_BUSY`,且 native 重复 start 不终止首任务;无效页面外部动作 0;
+- 冻结真实 yt-dlp 及相关模块哈希、默认 plugin 目录 sentinel 不加载、真实 `YoutubeDLCookieJar(io.StringIO)` 无网络;
+- extractor=1、验证对象与 `process_info` 对象 identity 相同、第二解析 guard、HttpFD/DashSegmentsFD 与 m3u8/FFmpegFD 反例;
+- 实际 Python audit hook 在 CreateProcess 前拒绝 URL/header/Cookie/越界路径;
+- 真实 FFmpeg lavfi 短合成单 AV remux 和双流 merge,结果均为有音视频 MKV;
+- 真实 Windows worker/child/grandchild 的 suspended assign、七类终态、宿主强杀 kill-on-close;
+- 有效配置的真实 worker 在 secret 前完成固定 stage 创建和陈旧目录清理;probe 后取消、commit 竞态、EOF/timeout 均验证终态与正式输出互斥;
+- PyInstaller `6.15.0 --onefile --noupx` 命令和完整 schema 2 build receipt;build/install 在解析 source/build manifest、调用 builder、复制或 HKCU 前先核对位于可变树外的 approved receipt,同目录产品+manifest/build receipt 一并重算仍失败关闭;安装 CreateNew/复读和注册状态机只用 validation-only 与临时文件 registry provider,真实 HKCU 动作为 0;冻结 bridge 目标测试和 project owner 全回归。
+
+## 5. 审核后构建与安装步骤
+
+第 9 节记录的旧构建已在 2026-08-06 执行一次,但随后因缺少 `yt_dlp` 被固定为 `NOT_INSTALLABLE / SUPERSEDED_FOR_INSTALL`,不得重跑或安装。下列命令只适用于第 11—12 节修复取得新 source approval 后、由管理员既有同事项授权执行的唯一一次 replacement build;安装命令仍只是操作说明。
+
+1. 实现复审 PASS 最多只允许后续受控 build。由 `dev.reviewer.project` 或 `project.admin` 在源码/构建树之外提供 source approval receipt,精确绑定已审 `source-artifact-manifest.json` 的 bytes/SHA-256;开发员不得在仓库或可变构建树中自签、自批或生成正式 trust receipt。
+2. 在隔离构建环境安装并核验固定 `PyInstaller 6.15.0`;确认 `project-info-bili-download-bridge` 中 yt-dlp 仍为 `2026.07.04` 且模块清单未漂移。构建到全新本地目录:
+
+```powershell
+powershell -NoProfile -File dev/project-dev/bili_authenticated_extension/build_host.ps1 `
+ -Python <固定构建环境python.exe> `
+ -PyInstallerExecutable <固定构建环境pyinstaller.exe> `
+ -ApprovedBuilderReceipt <构建树外\管理员批准的provision-receipt.md> `
+ -OutputRoot <全新绝对本地构建目录> `
+ -ApprovedSourceReceipt <源码和构建树外\reviewer或admin批准的source-receipt.json> `
+ -SourceArtifactManifest dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json
+```
+
+3. build 完成后先提交唯一 EXE 与 schema 2 build receipt 的 bytes/SHA-256 做 hash-only 限定确认。只有 `dev.reviewer.project` 或 `project.admin` 在 source/build 树外提供 exact build approval receipt 后,才具备安装前置条件;实现 PASS 或 source receipt 本身都不授权 install/HKCU。
+4. 从 `config.example.json` 创建不含秘密的 `config.json`。逐项填写绝对本地路径和 SHA-256;destination 只能是已授权 `F:\video\青枫浦上Q`,bridge hash 必须保持冻结值。
+5. 在 Chrome“加载已解压的扩展程序”中选择本目录,人工观察 ID 必须精确为 `oidmclckpdmpabbfedplkbdplmfcenbb`。其他 ID 立即停止。
+6. 先不带 `-Install` 执行安装脚本,要求返回 `VALIDATION_PASS_ONLY` 且不创建目录/注册表;安装器拒绝缺件、篡改、额外依赖、既有注册以及任何与两个外部 approval receipts 不一致的自洽重签树。再由获授权操作者显式增加 `-Install`,CreateNew 复制并复读全部工件后才创建注册;任何中途失败同时回滚本次安装目录与本次新建注册:
+
+```powershell
+powershell -NoProfile -File dev/project-dev/bili_authenticated_extension/install_native_host.ps1 `
+ -ObservedExtensionId oidmclckpdmpabbfedplkbdplmfcenbb `
+ -HostExecutable <构建目录\project-info-bili-auth-native-host.exe> `
+ -ConfigFile <绝对路径\config.json> `
+ -InstallRoot <全新当前用户本地安装目录> `
+ -SourceArtifactManifest dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json `
+ -BuildArtifactManifest <构建目录\build-artifact-manifest.json> `
+ -ApprovedSourceReceipt <源码和构建树外\approved-source-receipt.json> `
+ -ApprovedBuildReceipt <源码和构建树外\approved-build-receipt.json> `
+ -Install
+```
+
+不得把真实 Cookie/token/session、签名 URL、验证码或密码写入配置、命令、日志、截图、证据或交接文本。
+
+## 6. 审核后真实按钮与验收
+
+1. 在已登录 Chrome 打开精确 URL,确认“充电中”、`52:14`、可完整播放。
+2. 打开项目扩展 side panel,勾选可见确认。
+3. 点击“1. 校验当前页面”;只有状态 `READY` 时继续。
+4. 点击“2. 添加完整视频任务”;等待 `CHECKING → DOWNLOADING → MERGING → VALIDATING → COMPLETE`。失败只显示固定错误码;需要重试时只点击“失败重试”,它会重新校验并重新读取当前 Cookie。
+5. `COMPLETE` 不是仅“任务已添加”。必须由冻结后半程确认正式完整 MKV 与 `BV1HA3o6oEJJ.download.json` 均为 CreateNew;FFprobe 同时有音视频,时长约 `3133.95 s`,无 `.part/.crdownload/.tmp`,bytes/SHA-256/source/published_at/canonical mapping 完整。
+6. 600 秒预览、TXT、MP3、封面、单独 m4s、扩展任务状态或下载进度都不是完成原件,不得交转写。
+
+## 7. 失败停止条件
+
+- Extension ID、origin、build identity、源/构建清单、yt-dlp/module hash 或 pinned executable/config hash 不一致;
+- 页面不是精确 BVID、时长不符、EME/DRM、live/multi-P/preview/无 AV、Cookie store/partition/schema 不可证明;
+- downloader 不是 HttpFD/DashSegmentsFD,或任何 URL/header/Cookie 将进入 FFmpeg/bridge argv/env;
+- 无法在 secret 前建立独立 Job Object,或任一退出路径无法确认后代全灭;
+- 后半程时长/AV/SHA-256/CreateNew/mapping 任一失败。
+
+遇到以上任一项只报告固定错误和单一证据,不导出秘密、不放宽时长/DRM/格式门禁、不改用签名 m4s 直链,也不把 600 秒预览视为成功。
+
+## 8. HOLD/3 最小离线修复证据(2026-08-06)
+
+本节只记录同一事项 `DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001` 的 F2-R1、F4-R1、F5-R1 修复。项目管理员将唯一实施 ownership 临时续派给 `dev.developer.project.secondary / infodev-2`;未创建新任务、平行实现或新审核链,冻结 bridge 与 F1/F3 合同未修改。
+
+- F2-R1:side panel 在首次异步边界前取得不可重入 UI lease 并禁用 start/retry;background 同步取得唯一 start lease,`prepare_id` 把一次 prepare 与 start 绑定。延迟 Cookie-store 双击反例证明仅一次 Cookie-store 读取、一次 Cookie 读取和一次携密 start,第二次请求以 `E_BUSY` 失败,且不得终止首任务。
+- F4-R1:正式构建口径统一为固定 PyInstaller `6.15.0 --onefile --noupx`;输出只允许唯一 EXE 与 schema 2 build receipt。本文不再提供或暗示 onedir/one-directory 构建。
+- F5-R1:build 在解析 source manifest 或调用 builder 前,install 在解析 manifest、复制或 HKCU 前,必须验证位于可变 source/build 树之外、由 `dev.reviewer.project` 或 `project.admin` 批准的外部 receipt。开发员未创建或自签正式 approval receipt;离线测试只在 `TemporaryDirectory` 生成 reviewer-style fixture。即使篡改产品并同步重算同目录 manifest/receipt,旧外部锚仍会 fail closed。实现复审 PASS 最多允许后续受控 build;install/HKCU 仍需对唯一 EXE、schema 2 build receipt 进行 hash-only 限定确认并形成 exact build approval receipt。
+
+冻结快照与验证:
+
+- source artifact manifest:`2511 bytes / 039BE7791B517290F13458151A19B400C8DE516963FADB7354E22C680BE89709`;
+- 冻结 bridge:code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`,tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`,usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`,三者未修改;
+- 扩展目标测试=`31/31 PASS / 29.893s`;冻结 bridge=`21/21 PASS / 0.238s`;project discover=`130 tests / OK / 6 skipped / 84.069s`;
+- py_compile、Node 语法、PowerShell parser、JSON、严格 UTF-8、无 BOM、尾随空格、凭据字面量扫描与治理校验均 PASS,治理=`OK / projects=1 / warnings=0`;
+- 真实 build/install、HKCU、Chrome、Cookie/session、network/download、真实媒体、`F:\video`、正式 `ana-data`、转写均为 0。
+
+本节对应限定复审已于 2026-08-06 取得 `PASS/0`。该 PASS 只形成后续分阶段授权依据,不构成安装、HKCU 或真实认证下载授权。
+
+## 9. exact-source 受控 build 证据(2026-08-06)
+
+- 管理员恢复授权:`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-CONTROLLED-BUILD-RESUME-20260806-001`;前序本机依赖阻断由管理端预置离线环境后关闭。
+- 固定构建环境:Python=`262144/5912D0884B23C0343983A864C6064242391E2265536F50B88624857E353882C9`;PyInstaller executable=`108469/D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39`;`python -m PyInstaller` 与 executable 均为 `6.15.0`,`pip check` 无破损,离线 wheelhouse 7/7 hashes 匹配。
+- 构建前 reviewer source approval=`373/F6A56C14ED8DB1DFF7918F72CC5316D65866CED9B2D98368A88DF47D338FCBAA`、source manifest=`2511/039BE7791B517290F13458151A19B400C8DE516963FADB7354E22C680BE89709`、15/15 source files 和空输出目录全部复算通过。
+- `build_host.ps1` 仅执行一次:`build_count=1`,exit=`0`,墙钟=`20.839s`,实际参数为 `--clean --noupx --onefile`;未通过 PATH fallback,未联网安装或下载依赖。
+- 唯一 EXE:`dev/tmp/bili-auth-controlled-build-20260806-001/project-info-bili-auth-native-host.exe|8608281|F0D3CE2EF12D7D305B8F765169CBF5430DF9F0D36D03FF4447A85378AFF8A26F`。
+- 唯一 schema 2 receipt:`dev/tmp/bili-auth-controlled-build-20260806-001/build-artifact-manifest.json|995|9B989A32C92A4EB89911C9D5151F31B416BA8F265785CE575FDD1A23FBEDF63D`;其绑定 builder Python、build script、source manifest 与唯一 EXE 的 bytes/SHA-256,且与 reviewer source approval 形成一致的传递链。
+- 输出目录恰好包含上述两项普通文件;`.build/.spec` 均为 0;EXE `MZ` 头、receipt strict UTF-8/no BOM/no trailing whitespace 均 PASS;构建控制台对 Cookie header、Authorization bearer、Bilibili session 字段及签名 URL 模式扫描命中 0。
+- 本次未执行 EXE、install 或 HKCU,未接触 Chrome/Cookie/session/network/download/真实媒体/`F:\video`/正式 `ana-data`/转写。当前=`PENDING_EXACT_BUILD_HASH_ONLY_CONFIRMATION`;在 reviewer/admin 形成树外 `install-exact-build` approval 前不得安装。
+
+## 10. exact-build hash-only 确认终态
+
+- 原审核员回执=`HANDOFF-INFOREV-INFODEV2-BILI-AUTHENTICATED-SESSION-EXACT-BUILD-HASH-ONLY-CONFIRMATION-RESULT-20260806-001 / PASS/0`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXACT-BUILD-HASH-ONLY-CONFIRMATION-20260806-001`。
+- reviewer-owned 树外 build approval:`ai-inforev/worklog/2026-08-06-bili-authenticated-session-build-approval.json|594|244097BC466D969E046F0E64E9D3A1213F5AB64E12C9358DF0807B4BA0F8F816`;scope=`install-exact-build`,精确绑定 source manifest、schema 2 receipt 与 EXE bytes/SHA-256。
+- exact build identity gate 已满足,但审核未执行安装。任何 path/bytes/hash/output-set 漂移均必须失败关闭并重新审核。
+- 实际 install/HKCU 只能由管理员或请求方另行明确调度;当前仍为 `NOT_EXECUTED`,Chrome/Cookie/session/network/download/media/正式路径动作也均为 0。
+
+## 11. yt-dlp 构建合同最小修复(2026-08-06)
+
+安装预检随后证明第 9 节的 immutable exact build 虽通过当时的 bytes/SHA-256 绑定确认,但其 PyInstaller 归档中没有 `yt_dlp`,不能满足 worker 的启动合同。该 EXE、schema 2 receipt 和 reviewer approval 继续作为历史证据保留,状态固定为 `NOT_INSTALLABLE / SUPERSEDED_FOR_INSTALL`,不得安装、覆盖、删除或复用其 approval。
+
+项目管理员通过 `HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD-CONTRACT-REPAIR-ASSIGN-20260806-001` 授权同一事项、同一 owner 做一次最小构建合同修复。实施边界如下:
+
+- 精确依赖为 `yt-dlp==2026.7.4`(设计标签 `2026.07.04`)。一次有界 PyPI 获取只下载 `yt_dlp-2026.7.4-py3-none-any.whl`;wheel=`3184705/F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266`。wheel 的 `METADATA` 表明所有 `Requires-Dist` 都受 extra 条件约束,因此本 worker 默认路径的无条件运行时闭包为空,项目私有 wheelhouse 恰好只有该 wheel 与依赖清单。
+- dependency manifest=`dev/project-dev/bili_authenticated_extension/dependencies/dependency-artifact-manifest.json|723|2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`;source manifest=`dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json|3029|D1CE8C00A50CF17E45FB915577369BADA4AD9937335290852F393DC6BD053FCF`,精确绑定 17 个 source/dependency 文件。
+- `build_host.ps1` 在创建输出前校验 PyInstaller `6.15.0`、依赖清单、wheel exact set/bytes/SHA-256、wheel metadata、yt-dlp 版本和 source approval。真实 build 只允许从该 wheelhouse 使用 `--no-index --find-links --no-deps` 安装到临时 `.deps`,然后以 `--paths`、`--collect-all yt_dlp`、`--copy-metadata yt-dlp`、`--onefile --noupx` 构建;不使用 PATH fallback、浮动版本、插件发现或共享环境写入。
+- receipt 之前必须用 PyInstaller `CArchiveReader` 结构化递归检查归档,包含全部冻结 `yt_dlp`/worker 导入锚点,并且只接受根路径为 `yt_dlp-2026.7.4.dist-info` 的唯一 metadata 集合;该集合的 7 个相对路径、类型、bytes/SHA-256、`Name`、`Version` 与已验证 `.deps` 必须逐项一致。旧零条目产物以及缺失、重复、错版本、残缺、意外嵌套或替代分发均在 receipt 接受前失败关闭。
+- installer 同步要求新的 schema 2 receipt 明确绑定 yt-dlp 版本、dependency manifest、wheel SHA-256 和静态归档 PASS;旧 receipt 与旧 approval 无法通过新门禁。
+
+离线验证:目标测试=`31/31 PASS / 184.223s`;冻结 bridge=`21/21 PASS / 0.138s`;project discover=`133 tests / OK / 6 skipped / 172.091s`。PowerShell parser、JSON、Python 编译、source/dependency exact-set 校验均通过。第一次 project discover 使用缺少项目其他依赖的专用 bridge 环境而失败,随后按项目基础环境完整复跑通过;该环境误用不改变产品结论。
+
+本节冻结时旧构建目录与字节保持不变,新 replacement build 次数为 0;install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/正式 `ana-data`/转写均为 0。当前状态=`PENDING_YTDLP_SOURCE_REVIEW`。只有原审核员对上述 source/dependency manifest 返回 PASS 并形成新的树外 source approval 后,才能在全新空目录执行一次 replacement build;本节不授权安装。
+
+## 12. yt-dlp 构建合同 HOLD/2 限定闭环(2026-08-06)
+
+原审核员对第 11 节快照返回 `HOLD/2`:构建脚本只校验 manifest 自述集合,未证明实际源码树没有额外文件;同时继承的 `PYTHONPATH` 可注入伪造的 PyInstaller/pip。修复只覆盖这两个阻断,没有重开已通过的运行时合同、冻结 bridge 或产品范围。
+
+- F1:build 与 installer 均在 Python、pip、PyInstaller、输出复制或 HKCU 之前递归枚举实际源码树;源码根和 `dependencies` 路径必须为非 reparse,所有子路径都不得是 reparse。除 `source-artifact-manifest.json` 本身外,大小写敏感的普通文件集合必须精确等于清单冻结的 17 路径。根级 `yt_dlp.py`、嵌套额外文件和受支持的 reparse 反例均在业务副作用前失败关闭。
+- F2:production build 必须显式传入管理员批准环境中的 Python、PyInstaller executable 和位于源码/输出树外的 provision receipt,并按固定 bytes/SHA-256 复算其传递绑定。脚本在首次 Python 调用前移除全部继承的 `PYTHON*` 环境变量,所有版本探针、离线 pip、PyInstaller 与结构化 CArchive 检查均通过同一精确 Python 以 isolated 参数运行;不会拼回外部 `PYTHONPATH`。伪 PyInstaller/伪 pip 注入测试证明伪模块未执行、输出未生成。合成构建成功测试只修改 `TemporaryDirectory` 中的脚本副本作为测试 seam,production 参数不存在旁路开关。
+- 新 schema 2 receipt 继续绑定 source/dependency/wheel/archive,并新增精确 PyInstaller executable 与管理员 provision receipt 的 bytes/SHA-256;installer 同步要求这些字段,旧 receipt/approval 仍不能通过。
+
+当前冻结快照:
+
+- build script=`20430/3B0552ADA09D279D08FF89A84F59847C8F86BBC7606257DE8B26203CCBD42F92`;installer=`22247/F127BAE512BEE03AD78780BB1C98065398EF910EF1A6F9D59EE42463F4E2A62D`;
+- source manifest=`3029/E01C1320F8C7677B7435B4C8B87EA3688BA652585648D7CFD1F88D0DADB26958`,17/17 实物 bytes/SHA-256 与实际集合一致;dependency manifest 与 wheel 保持 `723/2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`、`3184705/F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266`;
+- 固定 builder:Python=`262144/5912D0884B23C0343983A864C6064242391E2265536F50B88624857E353882C9`;PyInstaller executable=`108469/D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39`;provision receipt=`2027/B65F4184E8782394F2CC27C47CB8656C942E366FD80E8FA7A548CE5A3367BACF`;
+- F1/F2 聚焦测试、installer 反例和 source manifest 检查均 PASS;完整扩展测试=`31/31 PASS / 113.250s`,冻结 bridge=`21/21 PASS / 0.195s`,project discover=`133 tests / OK / 6 skipped / 171.664s`。
+
+本轮只运行离线验证;replacement build/install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/正式 `ana-data`/转写均为 0。状态=`PENDING_HOLD2_SOURCE_REREVIEW`;原审核员 PASS 并形成新的树外 source approval 前不得 build。
+
+## 13. HOLD/2 PASS 后 replacement build 阻断(2026-08-07)
+
+原审核员已返回 `PASS/0`,并创建树外 source approval=`ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-build-source-approval.json|373|26AF51848717C47A2915D633B258269EA79213A94BAFEFF9CE7D30CDC2A77F96`,精确绑定 source manifest=`3029/E01C1320F8C7677B7435B4C8B87EA3688BA652585648D7CFD1F88D0DADB26958`。管理员既有授权下的 replacement build 前预检确认 17/17 source、builder Python/PyInstaller/provision receipt 与全新缺省输出目录均匹配。
+
+唯一一次构建调用在离线 wheel 成功安装到临时 `.deps` 后,于 PyInstaller spec 执行 `copy_metadata('yt-dlp')` 时失败:spec 进程的 `importlib.metadata` 搜索路径没有看到临时 `.deps` 中的 distribution metadata,返回 `PackageNotFoundError: yt-dlp`,随后脚本以非零退出。该失败发生在 accepted EXE/receipt 形成前,输出目录已回滚为不存在。
+
+- replacement build attempt count=`1`,exit=`1`,墙钟约 `25.9s`;未重试;
+- replacement output、EXE、schema 2 receipt、`.build/.spec/.deps` 均不存在;
+- 历史不可安装 EXE 与 receipt 仍为 `8608281/F0D3CE2EF12D7D305B8F765169CBF5430DF9F0D36D03FF4447A85378AFF8A26F`、`995/9B989A32C92A4EB89911C9D5151F31B416BA8F265785CE575FDD1A23FBEDF63D`;
+- source 仍为 17/17、reparse=0,未修改冻结 bridge;install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/正式 `ana-data`/转写仍为 0。
+
+当前状态=`BLOCKED_AFTER_SINGLE_BUILD_ATTEMPT/E_BUILD_METADATA_NOT_DISCOVERABLE`。单次构建授权已经消耗;不得自行改源码、重签 source approval 或重跑构建。需要管理员在原事项链决定最小 source 修订及新的 source review/build authorization。
+
+## 14. metadata discovery 最小修复(2026-08-07)
+
+项目管理员通过 `HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-REBUILD-AUTH-20260807-001` 授权同一事项最小修复;未重新获取依赖。修复保持 F1 actual source-tree、F2 exact builder/clean environment 及全部运行时合同不变:
+
+- 离线 pip 增加 `--no-compile`,`.deps` 只能是全新输出根直属的 canonical 非 reparse 目录;其递归内容按 pinned wheel ZIP、pip 生成的 `INSTALLER/REQUESTED` 与安装 `RECORD` 做精确文件集、bytes/SHA-256 和路径验证。只允许唯一 `yt-dlp 2026.7.4` distribution,额外 distribution、`PyInstaller` module/dist-info、缺件、篡改、错误 owner 或 reparse 均在 accepted output/receipt 前失败。
+- PyInstaller 仍由获准 exact Python 以 `-I` 启动;仅把验证后的 owned `.deps` 显式插入该进程内 `sys.path`,先用真实 `importlib.metadata.version('yt-dlp')` 确认 `2026.7.4`,再 `run_module('PyInstaller')`。继承 `PYTHON*`、user site、sitecustomize 与外部 module path 均不恢复。
+- production 不新增测试参数或旁路。正向 real metadata discovery 与缺失 metadata、package 篡改、错误 owner、额外 distribution、伪 PyInstaller/pip `PYTHONPATH` 五类负例都通过复制脚本的 `TemporaryDirectory` test seam 验证;任何失败都不会留下 output/receipt。
+
+冻结快照:build script=`25586/F5E9FC58313B2CB765772BF3A5C8605BCA9DD91A1FF74D0F03DBEB3174949845`;source manifest=`3029/AC852D40CF2B6FE891BE25D2E6E068A876EAC60E07EE6E9DB6826AC1064B29B8`;test native host=`58501/369C4E240585371967AAC128BBCB6AA5320C293282796C216283F1BACEF28F92`。验证:metadata 聚焦=`1/1 PASS / 51.241s`;旧/新构建门禁合并=`2/2 PASS / 128.251s`;扩展=`32 tests / OK / 5 skipped / 150.469s`;冻结 bridge=`21/21 PASS / 0.181s`;project=`134 tests / OK / 6 skipped / 203.739s`。
+
+本节没有执行 replacement build;累计状态为前次失败 attempt=1、本修复后 build=0。install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/正式 `ana-data`/转写均为 0。当前=`PENDING_METADATA_REPAIR_SOURCE_REREVIEW`;原审核员 PASS 并生成新的树外 source approval 前不得使用管理员条件构建授权。
+
+## 15. metadata 完整性校验前代码执行闭环(2026-08-07)
+
+原审核员对第 14 节快照返回 `HOLD/1`:离线 pip 安装后存在一个旧 identity probe,它在 `.deps` 精确文件集、哈希、RECORD、`INSTALLER/REQUESTED`、唯一 distribution 与 reparse 门禁通过前先把 `.deps` 加入 `sys.path` 并导入 `yt_dlp`。独立反例证明,篡改后的 `yt_dlp/__init__.py` 能在后续完整性门拒绝该目录之前执行写 marker 代码。
+
+本轮只关闭该阻断:删除旧 probe;pip 返回成功后先完成 owned canonical `.deps`、递归非 reparse、wheel-derived exact set/bytes/SHA-256、安装 RECORD、`INSTALLER/REQUESTED` 与唯一 `yt-dlp==2026.7.4` distribution 的全部非导入验证。验证通过后,PyInstaller launcher 才首次把 `.deps` 加入进程内 `sys.path` 并读取 metadata/运行 PyInstaller。新增入口反例在 pip 后篡改 `yt_dlp/__init__.py` 使其尝试写 marker,结果完整性门失败、output/receipt 均不存在且 marker 不存在,证明依赖代码未在验证前执行。
+
+冻结快照:build script=`25093/43816E7932A785321B545073FEAD9EB38198E5ED3B07F2469062895ADC41238F`;source manifest=`3029/463182C6980734B72831BDF9B387160F8AD21A03B52CBAB4AC74CA98CEB1B7AE`;test native host=`59374/55F7F4ECA14E370BAB1D5D1CA154BE8BFD1E602C5460B69670A376751C5E08EA`。验证:marker 聚焦=`1/1 PASS / 54.068s`;扩展=`32 tests / OK / 5 skipped / 149.629s`;冻结 bridge=`21/21 PASS / 0.204s`;project=`134 tests / OK / 6 skipped / 205.302s`;PowerShell parser、Python 编译与 source-manifest 绑定均 PASS。
+
+本轮 replacement build=0,候选输出 `dev/tmp/bili-auth-controlled-build-ytdlp-20260807-002` 仍不存在;install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/正式 `ana-data`/转写均为 0。状态=`PENDING_F1_PREVALIDATION_SOURCE_REREVIEW`;新 reviewer-owned 树外 source approval 形成前不得构建。
+
+机器证据:`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/prevalidation-code-execution-closure-20260807.json|2686|A3BA1EDB3C5C236C886B17821095481541DA1CAA7A349C3F4C25D279AF6BADF2`;治理=`OK/projects=1/warnings=0`。
+
+## 16. F1 PASS 后单次 replacement build 静态归档阻断(2026-08-07)
+
+原审核员已返回 `PASS/0` 并创建树外 source approval=`ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-prevalidation-source-approval.json|373|54514FCD70D2ED85F33E48433B13241F845A329A3DBD2C089AF416F1D67B2CEB`,精确绑定 source manifest=`3029/463182C6980734B72831BDF9B387160F8AD21A03B52CBAB4AC74CA98CEB1B7AE`。构建前 source/dependency/wheel、固定 Python/PyInstaller/provision receipt、历史不可安装产物与新输出缺省状态均复算匹配;`-ValidateOnly` 返回 `VALIDATION_PASS_ONLY`。
+
+管理员条件授权下只执行一次新洁净构建。离线 pip 安装 `yt-dlp==2026.7.4` 成功,PyInstaller 记录 EXE 组装完成,但 receipt 前递归静态归档门未找到固定 yt-dlp package metadata,脚本以 `Static archive is missing pinned yt-dlp package metadata.` / exit 1 失败。attempt_count=`1`、retry=`0`、墙钟约 `69.7s`;新输出根已回滚为不存在,没有 accepted EXE 或 schema 2 receipt,因而没有提交 hash-only 构建确认。
+
+机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-002-blocked-20260807.json|2322|3E184118BEE03C8AA6D536B10C0B41F2B795FF8D4D56D72911B065CA9D50A257`。历史不可安装 EXE/receipt 保持原字节;install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/正式 `ana-data`/转写均为 0。状态=`BLOCKED_AFTER_SINGLE_BUILD_ATTEMPT/E_BUILD_ARCHIVE_METADATA_MISSING`;本线程不自行修源码、重签 approval 或重试构建。
+
+## 17. onefile 归档 metadata 合同修复(2026-08-07)
+
+管理员通过 `HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-REPAIR-REBUILD-AUTH-20260807-001` 授权同一事项的最小离线修复。离线核查确认 `copy_metadata('yt-dlp')` 会从已验证 `.deps` 返回完整 `yt_dlp-2026.7.4.dist-info` 目录;第 16 节的失败不是 collection 缺失,而是旧 `archive_viewer -r` 以 Python `repr` 输出 Windows 反斜杠,文本中出现转义后的双反斜杠,单分隔符正则因此误判。
+
+修复未弱化归档门禁:`build_host.ps1` 改用 PyInstaller 6.15.0 `CArchiveReader` 读取 CArchive/PYZ 结构,不执行候选 EXE 或归档内代码。所有归档路径先把反斜杠规范为 `/`,并拒绝绝对路径、空段、`.`、`..`、控制字符与规范化重名;导入锚点必须齐全。metadata 只接受唯一根 `yt_dlp-2026.7.4.dist-info`,相对文件集固定为 7 项,所有条目必须是 data 类型且 bytes 与已验证 `.deps` 逐项相同;`METADATA` 必须精确为 `Name: yt-dlp`、`Version: 2026.7.4`。稳定树哈希为 `7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`。schema 2 receipt 新增 `metadata_files=7` 与该树哈希;installer 同步精确校验 method、文件数和树哈希,旧 receipt 不能满足新合同。
+
+离线反例覆盖模块缺失、metadata 缺失、规范化重复、错误 Name、错误 Version、残缺文件集、意外嵌套路径与无关分发替代;Windows 反斜杠的正常归档路径通过。测试使用 `TemporaryDirectory` 中的合成 CArchive reader,不执行 production PyInstaller EXE assembly。验证:聚焦=`1/1 PASS / 167.713s`;扩展=`32 tests / OK / 5 skipped / 239.755s`;冻结 bridge=`21/21 PASS / 0.147s`;project=`134 tests / OK / 6 skipped / 295.036s`;PowerShell parser=`PASS/0 errors`。
+
+冻结快照:build=`32216/4E25D57D8133670D25BACF0E2A38D1C673DB9576413B22EBDC28304CAB6B2A33`;installer=`22467/DBF1A828DC343DCA4B52283B8EE189962007581E42D03D213A55461CE05CFEF6`;source manifest=`3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`;test=`62074/66B70D38E983806B82A2D00A1F741A79445E9C9CF80EA2C0951EED2935C1CC2B`。机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-contract-repair-20260807.json`。
+
+本轮 production build/replacement build/install/HKCU/EXE execution/Chrome/Cookie/session/network/download/real media/`F:\video`/正式 `ana-data`/转写均为 0;候选输出 `dev/tmp/bili-auth-controlled-build-ytdlp-20260807-003` 不存在。状态=`PENDING_ARCHIVE_METADATA_SOURCE_REREVIEW`;只有原审核员 PASS/0 并创建绑定本轮 manifest 的新树外 source approval 后,才可按管理员条件授权执行一次新洁净构建。
+
+## 18. archive metadata source PASS 后 build-003 类型门阻断(2026-08-08)
+
+原审核员在容量恢复后返回 `PASS/0`,并创建树外 source approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-source-approval.json|373|A46A8D29A42B85E0DB83AA4EC5153B298BE9C24684637C6B7864AD23EF5B41E8`,绑定 source manifest=`3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`。构建前全部精确输入和输出缺省状态复算通过,`-ValidateOnly=PASS`。
+
+唯一一次授权 build-003 已执行:离线依赖安装与 PyInstaller onefile 组装完成,但 receipt 前结构化归档门以 `E_ARCHIVE_METADATA_TYPE` 拒绝,exit=`1`、墙钟=`81.921s`。脚本把新输出根回滚为不存在,accepted EXE/receipt=`0/0`,attempt=`1`、retry=`0`;不得以当前授权重试。
+
+离线只读诊断确认是门禁的 Windows 类型假阴性,而不是 metadata 缺失或内容不匹配。固定 PyInstaller 6.15.0 在 DATA 源路径满足 `os.access(..., X_OK)` 时把 CArchive type 改写为 `b`;本机临时 metadata 文件的 `X_OK=true`、mode=`0o100666`,wheel 原始 mode=`0o644`。当前门只接受 `x`,因而在逐文件 bytes/Name/Version/tree-hash 校验前误拒绝。此诊断未执行候选 EXE,也未修改 source。
+
+冻结证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-003-blocked-20260808.json`。历史不可安装 EXE/receipt 未变;install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/正式 `ana-data`/转写均为0。当前状态=`BLOCKED_AFTER_SINGLE_BUILD_ATTEMPT/E_BUILD_ARCHIVE_METADATA_TYPE_FALSE_REJECTION`;后续只允许原事项链上的最小类型合同修复、source 复审、新 reviewer-owned approval 与新单次构建授权。
+
+## 19. Windows DATA type `b/x` 精确合同修复(2026-08-08)
+
+管理员限定授权后,receipt 前 CArchive 门只做一个语义修订:当且仅当唯一根 `yt_dlp-2026.7.4.dist-info` 和固定 7 项相对文件集已匹配时,每个 metadata 成员可使用 PyInstaller 6.15.0 的标准 DATA type `x`,或 Windows 因 `os.access(source, X_OK)=true` 产生的提升 type `b`。任何其他 CArchive type 均失败;错误根、嵌套路径、额外/残缺文件或不受绑定的 `b` 不能进入 metadata 值。
+
+内容门未弱化:7 项 bytes 继续与已验证 `.deps` 精确相等,`METADATA` 继续要求唯一 `Name=yt-dlp`、`Version=2026.7.4`,树哈希继续固定为 `7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`。schema 2 receipt 额外记录实际 `metadata_type_codes`;installer 要求该集合非空且只能含 `b/x`,并继续校验原 method/path/file-count/tree-hash/module anchors。
+
+离线测试包括真实 PyInstaller `CArchiveWriter` 的 `x/b` 正例、`Z/a/d/l/m/n/o/s/z` 全拒绝、错误 metadata 路径下 `b` 拒绝、path/file-set/bytes/Name/Version/tree-hash 漂移。结果:聚焦 writer=`1/1 PASS / 2.086s`;installer+writer=`2/2 PASS / 17.198s`;完整构建门=`1/1 PASS / 370.155s`;冻结 bridge=`21/21 PASS / 0.225s`;project=`135 tests / OK / 6 skipped / 538.519s`。
+
+冻结:build=`33020/3739436920FCEA8BA68DC04BBDAB0F346C08216F49D89A097C50075912A258A0`;installer=`22741/53A1D1AFD9E4571A2491AE66C5E721E77A2201DBFAA29C9094E42CE0CB684C2A`;source manifest=`3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`;test=`69608/2E7D6BE3B9607A34A90B283F2CDDBC65A31CD2AF02026EB97E8CED6C153075AB`;机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-type-contract-repair-20260808.json`。
+
+本节 build-004/install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/正式 `ana-data`/转写均为0,候选 `dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004` 不存在。状态=`PENDING_ARCHIVE_METADATA_TYPE_SOURCE_REREVIEW`;只有原审核员 PASS/0 与新树外 source approval 都形成后,才允许消费管理员给出的单次 build-004 条件授权。
+
+## 20. build-004 精确产物(2026-08-08)
+
+原审核员已对第 19 节 source 返回 `PASS/0`,并创建树外 source approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json|373|102B30405B81137BD3AC9B81CFA39A6A7BB21531115FB3F361F821B2A093335E`,精确绑定 manifest=`3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`。预检全部通过后,唯一 owner 按管理员单次授权完成一次离线 onefile build;attempt=`1`、retry=`0`、exit=`0`、wall=`86.8s`。
+
+冻结输出根为 `dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004`,只含下列两个普通非 reparse 文件,没有目录或 `.build/.spec/.deps`:
+
+- `project-info-bili-auth-native-host.exe|21343775|DBF155BCC05B4DF55592D0A4B56612F8BE9C990B3B9AF7691F6A11648866B0B0`
+- `build-artifact-manifest.json|2874|0B36AA7DCA3AD80F8A370E96D50CAAFB386291EE2741305C7797ED663F5C2B27`
+
+EXE 只检查 `MZ` 文件头,未启动。receipt 为严格 UTF-8 schema 2,无 BOM、尾空白或重复键;其唯一文件项、source/dependency/wheel、Python/PyInstaller/provision receipt 绑定均匹配。归档静态门记录 6 个模块锚点、7 个精确 metadata 文件、实际 type codes=`[b]`,metadata tree SHA-256=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。
+
+机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-004-success-20260808.json`。当前仅可送原审核员做 hash-only 限定确认;install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/正式路径仍为0。任何路径、字节、哈希或输出集合漂移都必须失败关闭;新 reviewer-owned install-exact-build approval 与管理员安装调度形成前不得安装。
+
+## 21. build-004 hash-only 终态(2026-08-08)
+
+原审核员仅做静态 exact-build 限定确认,结论=`HOLD/1`。EXE、schema 2 receipt、输出集合以及 source/dependency/builder 绑定均通过;唯一阻断是 receipt 与 installer 对 metadata tree hash 的合同不一致:
+
+- receipt 与独立 CArchiveReader 复算:`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`;
+- 同一 source-approved installer 固定接受:`7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`。
+
+因此 build-004 虽是字节完整的历史产物,却无法通过自身 installer 的 copy/HKCU 前门禁,状态为 `NOT_INSTALLABLE`。审核员没有创建、复用或更新 install-exact-build approval;不得尝试安装或修改 receipt。正式审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-HASH-ONLY-CONFIRMATION-20260808-001|376110|5EE85855516E114029ADFF537E23DEFFBB47DBF557E94AEE9AC4DCCB8C65E993`,机器终态证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-004-hash-review-hold-20260808.json`。
+
+下一步不是重试 build-004。必须先经管理员同链授权,把 build 与 installer 对同一 7 文件的 canonical tree-hash 算法统一,并用 production-equivalent receipt 通过 installer preinstall 正例,同时保留旧值和任意漂移值的失败关闭反例;随后重新走 source 复审、新树外 source approval、新 absent-output build 和 hash-only 确认。此前 install/HKCU/EXE execution/真实动作继续为0。
+
+## 22. canonical metadata tree-hash 合同(2026-08-08)
+
+同链最小修复把唯一 golden definition 放入 `source-artifact-manifest.json#archive_metadata_contract`。该对象固定 root=`yt_dlp-2026.7.4.dist-info`、7 个 POSIX 相对路径、`Name=yt-dlp`、`Version=2026.7.4`、type codes=`b|x`,以及以下序列算法:按相对路径排序,对每个成员拼接 `path UTF-8 + NUL + decimal byte length ASCII + NUL + payload SHA-256 lowercase hex ASCII + LF`,最后取 uppercase SHA-256。当前精确值为 `32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。
+
+builder 和 installer 不再各自维护 tree-hash 常量:builder 从受批 manifest 读取合同,复算已验证 `.deps` 与 onefile 归档后才写 schema 2 receipt;installer 读取同一 manifest,只接受 receipt 的同一 canonical 值。产品脚本内旧 `7C9ABE...105C` 已移除,只在测试中作为必须失败的历史反例存在。
+
+pip 生成的 `bin/yt-dlp.exe` 内 ZIP 时间戳会改变 metadata `RECORD`。为使七文件真实可复现,合同固定标准 distlib `SOURCE_DATE_EPOCH=1786207924`;builder 只在离线 pip 子进程期间设置并在 finally 恢复。隔离实物验证得到 RECORD=`91484/433ED247B6625B8960AD19676BD04EC689640CCE050604A925A3EC013AF07D4F` 和 canonical tree=`32F1...E70`。
+
+production-equivalent schema 2 receipt 以 canonical 值通过 installer ValidateOnly,install root/测试 registry 均未创建;历史旧值、全零、任意漂移值在 copy/HKCU 前失败关闭。测试:聚焦=`2/2 PASS / 22.274s`;合成 builder=`1/1 PASS / 348.093s`;冻结 bridge=`21/21 PASS / 0.232s`;project=`135 tests / OK / 6 skipped / 489.917s`。
+
+冻结 source:build=`35414/6184EBA087470BB5DBD6FEC29EB22068F8119FBDE2ED640715E8317B5A928C04`;installer=`24079/AA9667A3CD765B21058F234FD82DFDB9621161246041EC595E4BCB17DDCFDE86`;source manifest=`3681/333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535`;test=`70133/8913DC78EBB1780C29A518D7AC4DE90E4C8B0E8B8FF82106A3461326A3389045`。机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/canonical-tree-hash-contract-repair-20260808.json`。
+
+当前 build-005 attempt=`0`,`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005` 不存在;install/HKCU/EXE execution/真实动作均为0。只有同链 source 复审 `PASS/0` 和新 reviewer-owned source approval 同时形成后,才可消费管理员给出的唯一一次 build-005 条件授权。
+
+## 23. build-005 精确产物(2026-08-08)
+
+canonical tree-hash source 复审已 `PASS/0`,新的 reviewer-owned source approval 精确绑定当前 manifest。构建前复算全部 source/dependency/wheel/Python/PyInstaller/provision receipt 与历史 build-004 均匹配,目标输出不存在;`ValidateOnly` 通过且未创建输出。
+
+管理员授权的唯一 build-005 已完成:attempt=`1`、retry=`0`、exit=`0`、wall=`66.8s`。输出根 `dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005` 精确包含两个普通非 reparse 文件,目录与 `.build/.spec/.deps` 均为0:
+- `project-info-bili-auth-native-host.exe|21343716|CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE`
+- `build-artifact-manifest.json|2874|CC6663A15CDEB375167900730A9F956C9B09839558586E5DFC3D7FC57728ACC0`
+
+EXE 只检查 `MZ` 文件头,未执行。receipt 为 strict UTF-8 schema 2,无 BOM、重复键或尾随空白;唯一 EXE 文件项及 source/dependency/wheel/Python/PyInstaller/provision receipt 均精确绑定。静态归档门记录 7 个精确 yt-dlp metadata 文件、type codes=`[b]`、canonical tree SHA-256=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。
+
+机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-005-success-20260808.json|4496|3FB4557AD81556068B27F6EBA10017883B511C52DDD66AA505150AAD4977CF57`。历史 build-004 保持原字节并继续为 NOT_INSTALLABLE 历史证据。当前只允许把 build-005 交原审核员做 hash-only 限定确认;在该复审 PASS、生成新的 install-exact-build approval 且管理员另行调度前,不得安装、写 HKCU、执行 EXE 或触发真实认证/网络/下载/媒体/正式路径动作。
+
+## 24. build-005 hash-only PASS 与安装边界(2026-08-08)
+
+原审核员已完成纯静态 hash-only 限定确认,结论=`PASS/0`。精确输出集合、EXE/MZ、schema 2 receipt、source/dependency/wheel/Python/PyInstaller/provision receipt/build script 绑定均匹配;独立 CArchiveReader 复算得到唯一 `yt_dlp-2026.7.4.dist-info` 七文件、type=`[b]`、Name/Version 与必需模块完整,canonical tree SHA-256=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。
+
+正式审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-005-HASH-ONLY-CONFIRMATION-20260808-001|386985|2F0DBEACE8584940D219CF89BAFD0F5AE6F5C4288B29C137CAC915776A3F7FEA`。新的 reviewer-owned install-exact-build approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json|596|4D18A32DE9DFEB6B90C1A398087BD36CEAE09418D54E1F0DC485D6B95C962A44`,只绑定本次不可替换的 source manifest、receipt 与 EXE。终态证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-005-hash-review-pass-20260808.json|2454|3E4BC53C8419C7D43992F10811A685C5E810F760EA2EA25B0BE087D904F148BE`。
+
+该 approval 仅满足 installer 的 exact-build 身份门禁,未执行安装,也不自行授权当前回合修改 HKCU 或启动 EXE。实际安装必须等待管理员/请求方另行明确调度;任一 bytes/hash/path/output-set 漂移都必须失败关闭并重新审核。
+
+## 25. build-005 受控安装状态(2026-08-08)
+
+管理员下发单次安装授权后,首次无副作用验证因源码树出现两个精确 Python 缓存文件而失败关闭,install attempt 保持0;证据=`dev/tmp/bili-auth-install-build005-20260808-001/install-preflight-blocked-20260808.json|2269|413AD21E4C836E90E86D7E66CD3C580C24AC8E7BCCD2708A21F108E6E5075A86`。续授权仅允许复核并清理这两个 `.pyc` 和空目录;清理后 source 恢复 `17/17 exact`,全部批准绑定保持不变。
+
+第二次且最后一次 `VALIDATION_PASS_ONLY` 通过并保持安装根/HKCU 空白,随后唯一实际安装 attempt=`1`、retry=`0` 成功。安装根为 `C:\Users\Cai\AppData\Local\project-info\bili-auth-native-host\1.0.0+20260805.v002`,精确包含:
+
+- `project-info-bili-auth-native-host.exe|21343716|CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE`
+- `config.json|1265|2F09B69AC9B8B82F90EB16673F71D3418AB73A94092C1785E5F697F50E6F2B50`
+- `native-host-manifest.json|433|A050B24FBFC879BEB25044D775AC43DC95F7BCDBDCCF153BF4198EF6B8E68B51`
+
+只读健康检查确认 host=`com.project_info.bili_auth_ingress`、allowed origin=`chrome-extension://oidmclckpdmpabbfedplkbdplmfcenbb/`、manifest path 与 HKCU 默认值一致,安装树无目录或 reparse,HKCU 无额外值或子键。机器证据=`dev/tmp/bili-auth-install-build005-20260808-001/install-success-20260808.json|3713|2349894EE33541E54A7452C054C248239E735403F3D4B4ED5649586C3C897AAB`。
+
+本次未执行 EXE、未启动 Chrome、未读取 Cookie/session、未联网/下载或写入媒体/正式数据。安装成功不等于 runtime 授权;真实认证与下载仍须后续明确调度。
diff --git "a/dev-doc/project-doc/\344\274\232\350\256\256\345\275\225\345\261\217PPT\351\241\265\351\235\242\346\217\220\345\217\226\345\267\245\345\205\267.md" "b/dev-doc/project-doc/\344\274\232\350\256\256\345\275\225\345\261\217PPT\351\241\265\351\235\242\346\217\220\345\217\226\345\267\245\345\205\267.md"
index 1df808e..ce09b96 100644
--- "a/dev-doc/project-doc/\344\274\232\350\256\256\345\275\225\345\261\217PPT\351\241\265\351\235\242\346\217\220\345\217\226\345\267\245\345\205\267.md"
+++ "b/dev-doc/project-doc/\344\274\232\350\256\256\345\275\225\345\261\217PPT\351\241\265\351\235\242\346\217\220\345\217\226\345\267\245\345\205\267.md"
@@ -3,14 +3,14 @@
创建人员:`dev.developer.project`
文件职责:说明单视频 PPT 页面提取 MVP 的运行方法、输出、安全边界和已知限制。
管理规范/模板:`../编码规范.md`;`../开发审计规范.md`。
-引用文件:`开发方案/CODE-DESIGN-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V002.md`;`../../ai-media-processor/draft/会议录屏PPT页面提取方案_v0.1.md`。
+引用文件:`开发方案/CODE-DESIGN-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V002.md`;`开发方案/CODE-DESIGN-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003.md`;`开发方案/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002.md`;`../../ai-media-processor/draft/会议录屏PPT页面提取方案_v0.1.md`。
记录方式:随 `dev/project-dev/extract_ppt_slides.py` 同步维护。
## 1. 运行环境
- Python `3.12`,并可导入 Pillow、NumPy。
- `ffmpeg`、`ffprobe` 已加入 `PATH`。
-- 不需要 GPU,不新增 OpenCV、OCR、PDF 第三方库或服务。
+- H.264/H.265 均按 codec 优先尝试 NVIDIA NVDEC/CUDA;像素格式只作证据记录,不提前跳过硬件尝试。硬件 surface/transfer/FFmpeg 路径不可用时只允许一次明确告警后的受限 CPU 回退。不新增 OpenCV、OCR、PDF 第三方库或服务。
## 2. 一条命令
@@ -32,22 +32,27 @@
- 稳定段同时约束相邻帧和不可变锚点,避免慢速过渡漂移成页面。
- 候选先尝试合并到最近逻辑页的动画状态,再对更早页面去重,最后才新增页面。
- 同页内容明显增加时采用后态,明显减少时保留较完整状态,完整度相同时采用较后的稳定时刻。
+- 稳定候选形成后,使用同一扫描帧的紧凑 RGB 颜色直方图选择占明显多数且内部连续的主课件序列;允许主题序列中存在单页颜色间隙,筛选后从 `slide_001` 重新连续编号。
+- 只有主题标记数、全局占比和闭区间密度同时达到保守门槛时才裁去前后候选;没有合格强主题或全部候选属于同一主题时保持全部页面。
- 只在确定页面时间后用 FFmpeg 逐页提取原分辨率 PNG;PDF 逐页压缩并写出,不累计全部解码页面。
- 长视频扫描每 300 秒输出一次进度;原图提取逐页输出进度。
+- 扫描固定使用最多 4 个解码线程和 2 个滤镜线程;Windows 根 Python、FFprobe 和 FFmpeg 使用“低于正常”优先级。
+- 本工具与 `transcribe_media.py` 共用非阻塞 Windows named mutex;已有重型媒体任务运行时立即明确拒绝,不等待、不排队,也不启动外部进程。
+- H.264 使用 `h264_cuvid`,H.265/HEVC 使用 `hevc_cuvid`。成功日志会打印实际 `NVDEC/CUDA (<decoder>)`;只有非 H.264/H.265 codec 才直接明确使用受限 CPU。
## 4. 安全与错误
- 原视频只读,不移动、不改名、不删除、不覆盖。
- 输入必须是普通文件,输入符号链接会被拒绝;输出目录已存在(文件或目录)以及存活/断链符号链接时,在启动 FFmpeg 前直接拒绝,不提供覆盖开关。
- 所有文件先写入输出目录同级的唯一暂存目录,PNG 和 PDF 全部完成后只做一次目录重命名。
-- FFprobe/FFmpeg 非零退出、rawvideo 半帧、无视频流、无稳定页面、PNG 尺寸异常均明确失败。
+- FFprobe/FFmpeg 非零退出、rawvideo 半帧、无视频流、无稳定页面、PNG 尺寸异常均明确失败。只有 NVDEC 启动、非零、零帧或半帧属于硬件回退边界;页面算法、无稳定页、文件/清理和中断错误不会被误报为硬件失败,也不会触发第二次扫描。
- 普通失败、`KeyboardInterrupt`、`SystemExit` 均先回收子进程并删除本次暂存;不会留下可误认为完整的正式目录。
## 5. 已知限制
-- 本工具按全画面比较,不裁剪 PPT 区域,也不移除人像、会议界面、鼠标或遮挡;这些内容会原样保留。
+- 本工具按全画面比较,不裁剪 PPT 区域,也不移除人像、会议界面、鼠标或遮挡;保留页面中的这些内容会原样输出,因此 PPT 主体上的小型人像窗口会保留。
- 稳定帧和动画合并采用无 OCR 的图像启发式。快速翻页、持续全屏动画或整段从未稳定的画面可能不产出对应页。
-- 长时间桌面演示、行情软件和全屏人像如果形成持续稳定画面,第一版可能把它们作为额外候选页;工具不通过 OCR、人像识别或 PPT 区域识别做语义排除。真实验收应结合联系表抽查这类候选,是否继续收紧只能依据具体失败证据做最小调整。
+- 主课件序列过滤不是人物或 PPT 语义识别:它只能在课件共享明显颜色主题、且非课件画面位于主体序列前后时保守排除额外候选。主题不明显、课件风格高度异质或非课件画面夹在主体内部时会保持候选,仍需结合联系表抽查。
- 仅处理第一条视频流和单个输入;不生成 PPTX、OCR、JSON,不提供批处理、GUI、API、数据库、转写联动或 Skill。
## 6. 自测
@@ -56,9 +61,9 @@
python -B -m unittest dev/project-dev/test/test_extract_ppt_slides.py -v
```
-测试覆盖锚点漂移、动画合并、全局去重、短读/半帧、FFmpeg 失败和中断回收、PDF RGB 顺序、20 页单页存活、中文路径合成视频闭环、源文件指纹与失败提交安全。
+测试覆盖颜色直方图、主课件序列选择及保守回退、锚点漂移、动画合并、全局去重、短读/半帧、硬件失败类型边界和单次 CPU 回退、H.264/H.265 的 NVDEC/CPU 同链等价、named mutex、BelowNormal、FFmpeg 线程上限、资源采样边界、PDF RGB 顺序、20 页单页存活、中文路径合成视频闭环、源文件指纹与失败提交安全。
-## 7. 首次真实视频开发验收记录
+## 7. 首次真实视频开发验收记录(V002 基线)
- 源视频:`G:\熊猫财经\20260729192357-2026小郑掘金产业链之算力行业付费直播-视频-1-共享屏幕.mp4`,H.264、`2560x1440`、`12866.880 s`、`1268392019 bytes`。
- 30:00—35:00 独立短片:exit=`0`,墙钟=`21.727 s`,得到 `5` 张 `2560x1440` PNG 和 `5` 页 PDF;逐页 RGB/顺序一致,目视为连续、清晰且互异的课件页面。
@@ -67,3 +72,25 @@
- 一致性:53 个 PNG 编号连续且文件哈希均互异;PDF 53 页逐页解压 RGB 与对应 PNG 完全一致,Poppler 首/中/末页渲染清晰、页序正确。
- 源保护:运行前后大小、创建时间、修改时间和 SHA-256 均相等;SHA-256=`3A6F62815602C10B6D31A3A53DDBF6D8D5176DCF614EF30E03E32C12F113C9D6`。
- 视觉抽查:`slide_002`—`slide_038` 构成连续课件;`slide_001` 以及课件结束后的行情演示/人像稳定画面属于上一节所述 V1 额外候选边界。开发结果与完整输出已保留,等待 `case_analysis.media_processor` 只读复核并决定是否接受该边界。
+
+## 8. V003 离线回放与完整复验记录
+
+- V003 只增加确定性的主课件序列过滤,公开 CLI、稳定段/动画/去重、原图提取、PDF、原子提交和失败清理均保持不变。
+- 使用产品同一颜色直方图 helper 只读回放上述 53 张既有候选 PNG,自动选择原 `slide_002..038`,共 37 页;没有读取源视频,也没有调用 FFprobe/FFmpeg。
+- 独立离线结果位于任务临时根的 `sequence-filter-offline-v003.slides/`:PNG 重新连续编号为 `slide_001..slide_037`,PDF 为 37 页、每页 `2560x1440`,逐页解压 RGB 与 PNG 的内容和顺序完全一致。
+- 联系表 `sequence-filter-offline-v003-contact-sheet.png` 的首、中、末抽查均为课件主体;课件右上角的小型人像窗口按需求保留。
+- 既有 53 页输出与请求方人工归档 PDF 均只读且指纹未变。限定实现审核 `DEV-AUDIT-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-IMPLEMENTATION-20260803-001=PASS/0` 后,按授权执行了唯一一次完整复验。
+- 完整复验输出位于任务临时根的 `real-full-v2.slides/`:稳定候选 `53`、主课件 `37`,PNG 为连续 `slide_001..slide_037`,与旧合格页 `slide_002..slide_038` 逐一字节相同;PDF 为 `37` 页、`58570040 bytes`,逐页 RGB 和顺序 PASS。
+- 工具内耗时 `1108.593 s`,外层墙钟 `1109.297 s`;288 个全程进程树样本的 Python+FFmpeg 峰值工作集为 `351191040 bytes / 334.922 MiB`,首末各 20 个样本峰值只相差 `7614464 bytes`,没有随时长线性增长。
+- 联系表和 Poppler 首/中/末页渲染均只包含连续课件主体;小型人像窗口保留,开场及课后全屏人物、行情和软件画面均未进入结果。源文件四项指纹、旧 53 页证据和人工归档均未变化,暂存残留为 0。
+- PowerShell 原始 `Start-Process.ExitCode` 取值为 `null`,但外层 runner 返回 0、程序输出 37 页完成标记、stderr 为空且正式目录完成原子提交;原始记录未改写,等价退出证据单独保留。未为该取证差异重跑媒体。
+- 当前状态为 `PENDING_REQUESTER_FINAL_REAL_MEDIA_ACCEPTANCE`;结果交由 `case_analysis.media_processor` 做最终页面效果验收,开发员不再运行媒体或修改冻结实现。
+
+## 9. 主机响应保护开发门禁
+
+- 事项:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`;组合方案基线为 V001+V002,限定方案复审=`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-DESIGN-REREVIEW-20260804-001=PASS/0`。
+- 离线合成门禁分别实际运行 H.264/H.265 的 NVDEC 与 forced CPU:两种编码均得到 `19` 个采样、`5` 个稳定候选、`1` 次动画更新、`1` 次历史重复、`3` 个逻辑页,选中时间均为 `6/10/18 s`;PNG/PDF 页数、顺序、RGB 和尺寸一致。
+- 测试侧 `dev/project-dev/test/monitor_media_host_responsiveness.ps1` 以 500 ms 目标周期记录整机 CPU、任务 PID 集/命令、任务树 CPU/RAM/优先级、GPU/decoder 利用率、全局显存、任务 PID 专用显存和各查询的原始行/状态;任一冻结必填列缺失即 `sampling_valid=false`。采样阶段只锁定首个符合 `fps=1 + rawvideo` 产品扫描命令的唯一 FFmpeg PID,到观察该 PID 退出的同一周期为止;后续逐页 FFmpeg 不进入扫描门禁。5 秒尾窗按实际时间加权、每窗至少 10 个成员且窗口数必须非零;CPU 必须严格 `<85%`,连续 `>=85%` 的实际时长达到 `10.000 s` 即失败。日志与该 PID 的完整 argv 必须同时证明 `-hwaccel cuda`、`-hwaccel_output_format cuda`、cuvid、输入前 4/2 线程和 `hwdownload`;资源合同失败时采样器返回非零,即使被测程序本身 exit=0。采样器会在启动被测进程前初始化并预热 Processor 计数器、初始化并枚举 GPU Process Memory 类别,再通过继承环境标记建立 sampler-ready 握手;离线 Run 桩不再依赖 3 秒睡眠覆盖冷启动。该桩已连续三次证明伪 NVDEC stdout/CPU 回退不会绕过 argv 门禁,且逐页进程不会延长扫描阶段。
+- 最终限定实现复审 `DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-F3-R1-R1-REREVIEW-20260804-001=PASS/0` 后,仅按授权生成一次 593.120 秒独立片段并运行一次资源采样。目标工具 exit=`0`、7 张 RGB `2560x1440` PNG 与 7 页 PDF 均生成,NVDEC argv、`h264_cuvid`、GPU decoder 峰值 `100%`、有效扫描整机 CPU 峰值 `18.206%` 和 5 秒加权最高 `13.064%` 均有原始证据;但采样器因首两个 5 秒窗口各只有 9 个成员、扫描结束后的逐页 FFmpeg CIM 消失竞态产生 6 个采样错误及终态日志证据标志不一致,正式返回 exit=`3`、`sampling_valid=false`、`cpu_gate_pass=false`。该唯一真实运行不得重试,当前只能作为 `RESOURCE_SAMPLER_EVIDENCE_INVALID` 失败证据,不能宣称真实资源门禁 PASS。
+- 请求方确认上述 FAIL 后另行授权 sampler-only 修复:目标启动前预热 CIM/nvidia-smi;调度落后时只做一次 1 ms 受控追赶而不降低 500 ms、10 样本、85%/10 秒门限;短命 FFmpeg 只有在二次确认进程已退出时才按消失竞态计数,存活查询失败仍为硬错误;previous timestamp 只在样本完整加入 CSV 后推进;stdout/stderr 使用异步完整读取,并以 ASCII `NVDEC/CUDA (<decoder>)` 对抗 Windows PowerShell 5 无 BOM 脚本中文字面量误解码。无媒体桩连续三次 PASS,目标 `56/56`、project `63/63`;独立复审 PASS 前不得运行 r2。
+- sampler-only 独立复审 `DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-SAMPLER-ONLY-REPAIR-REREVIEW-20260804-001=PASS/0` 后,仅复用既有 593.120 秒片段执行一次独立 r2:target/sampler exit=`0/0`,`sampling_valid/cpu_gate_pass/resource_acceptance_pass=true/true/true`。扫描有效样本=`73`,完整 5 秒窗=`63`,最大间隔=`0.515724 s`,最差 CPU 窗口=`11.546972%`,高 CPU 段=`0 s`;单样本整机 CPU 峰值=`16.135574%`。NVDEC 日志和 argv 均 PASS,GPU decoder 峰值=`100%`,任务树/FFmpeg 均 BelowNormal。输出与 r1 成功的产品内容逐文件 SHA-256 相同,r1 失败证据哈希保持不变。
diff --git "a/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001.md" "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001.md"
new file mode 100644
index 0000000..486b821
--- /dev/null
+++ "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001.md"
@@ -0,0 +1,286 @@
+# Bilibili 已登录会话完整视频扩展入口安全设计 V001
+
+## 1. 事项与审核门禁
+
+- 项目:`project-info`
+- 上游需求:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 开发事项:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`
+- 管理授权:`HANDOFF-INFOADMIN-INFODEV-BILI-PLUGIN-FULL-VIDEO-NARROW-SCOPE-ASSIGN-20260805-001`
+- 唯一 owner:`dev.developer.project / infodev`
+- 独立审核员:`dev.reviewer.project / inforev`
+- 精确目标:`青枫浦上Q / BV1HA3o6oEJJ / https://www.bilibili.com/video/BV1HA3o6oEJJ`
+- 远端完整时长基线:`3133.95 s`;已隔离错误预览:`600.133313 s`
+- 分类:已有需求支撑的高安全失败成本实现。扩展、Native Messaging、认证会话和本地发布形成跨进程安全边界,因此先做一次合并安全设计审核,PASS 前禁止实现。
+
+本 V001 仅设计“完整文件入口”增量。已经独立审核 PASS/0 的
+`dev/project-dev/bili_video_download_bridge.py accept-browser-file` 后半程保持原字节、原 CLI 和原测试合同,不在本设计中重写或放宽。当前已安装 1.0.5 TXT 扩展、其 localhost 服务、SQLite、固定 Cookie 临时文件和 token 查询接口也保持不变且不被完整视频模式调用。
+
+设计审核阶段外部动作固定为:真实 Cookie 读取 `0`、真实下载 `0`、Chrome/扩展安装或修改 `0`、Native Host 注册 `0`、`F:\video` 写入 `0`、正式 `ana-data` 写入 `0`、600 秒隔离预览读取 `0`。
+
+## 2. 最小目标与明确排除
+
+### 2.1 最小目标
+
+在项目自有、用户可见的相邻 side-panel 模式中复用“添加任务 → 进行中/失败重试 → 已完成”的交互,完成以下单条闭环:
+
+1. 用户在已登录 Chrome 打开精确目标页并主动点击“校验当前页面”。
+2. 扩展仅在用户再次点击“添加完整视频任务”时读取该目标站点当前请求所需 Cookie,并通过一条专用 Native Messaging 连接传入可信本地主机内存。
+3. 本地主机使用内存 CookieJar 和 yt-dlp Python API 获取 `bestvideo+bestaudio/best`,由 FFmpeg 仅做流复制合并,生成一个完整 MKV。
+4. 本地主机立即调用冻结的 `accept-browser-file` 后半程,执行稳定性、partial、音视频流、远端/本地时长、SHA-256、CreateNew 媒体和映射门禁。
+5. 只有后半程 exit 0 时 side panel 显示“已完成”和非秘密的正式文件名;其他情况显示固定错误码并允许用户显式重试。
+
+### 2.2 明确排除
+
+- 不做通用账号、博主、BVID 或批量下载框架;协议、界面和主机均硬绑定一个 BVID。
+- 不读取 Chrome profile 文件,不导出 Cookie,不提供 Cookie/token 查看、复制、日志或调试入口。
+- 不输出、记录或持久化签名媒体 URL,不使用人工签名 `.m4s` 直链。
+- 不绕过会员、充电、DRM、验证码、登录或访问控制;无法证明当前会话可完整播放时 fail-closed。
+- 不修改现有 TXT 扩展 1.0.5,不复用其 loopback/token/SQLite/Cookie 文件链路。
+- 不修改冻结后半程,不新增转写、调度、数据库、HTTP API、远程服务、GUI 应用或多任务队列。
+- 不续传、不跨启动恢复下载;失败或中断后清理内部暂存,重试必须重新校验页面并重新取得当前会话 Cookie。
+
+## 3. 威胁模型与不可变安全合同
+
+### 3.1 受保护数据
+
+受保护数据包括 Cookie 名称和值、会话 token、密码、验证码、内部解析得到的签名清单/媒体 URL,以及包含这些值的异常、请求、调试对象和下载器 `info_dict`。真实值只允许短暂存在于:
+
+- 用户点击后运行的项目自有 Chrome 扩展内存;
+- 该扩展专属 Native Messaging 管道;
+- 可信本地主机进程、yt-dlp、内存 CookieJar 和下载所需网络库内存。
+
+这些值不得进入 Codex、任务文本、argv、环境变量、配置、项目文件、浏览器 storage、Native Host manifest、日志、stdout/stderr、映射、栈追踪、测试、证据或 handoff。
+
+### 3.2 信任边界
+
+- 网页内容不受信任。网页只能被扩展注入的只读校验函数观察,不能直接连接 Native Host,也不能传 Cookie、路径、命令或 BVID。
+- side panel 是唯一用户入口;background service worker 是唯一 Native Messaging 调用方。background 校验消息发送者属于本扩展 side-panel 页面。
+- Native Host manifest 的 `allowed_origins` 必须是安装后实际扩展 ID 对应的唯一精确 `chrome-extension://<id>/`,禁止占位符遗留、通配符或第二来源。
+- Native Host 不监听 TCP,不提供 localhost、CORS、HTTP、WebSocket 或远程绑定。因此本阶段不使用 loopback 的一次性 capability 例外。
+- 输出根、bridge 路径、batch JSON、目标目录、yt-dlp/FFmpeg/FFprobe 位置都来自安装时生成的非秘密、当前用户只读配置;扩展消息不能提供或覆盖任何路径、程序名、参数或命令。
+
+### 3.3 fail-closed 总原则
+
+输入 schema、来源、页面证明、Cookie 边界、认证元数据、DRM、格式、下载、合并、候选唯一性、后半程或清理任一环节不能满足合同,任务均不得显示 COMPLETE、不得创建正式媒体/映射、不得把 600 秒预览交给转写。未知异常只映射为固定安全错误码,原异常文本与对象不出进程。
+
+## 4. 组件和数据流
+
+```text
+用户可见 side panel
+ └─ 用户点击校验/添加
+ └─ background service worker
+ ├─ 注入只读页面证明(无秘密持久化)
+ ├─ chrome.cookies.getAll(精确 Bilibili URL)
+ └─ chrome.runtime.connectNative(精确 host)
+ └─ 专用 Native Host
+ ├─ 严格协议校验
+ ├─ Cookie 数组 → io.StringIO CookieJar
+ ├─ yt-dlp API → 内部暂存 → FFmpeg stream-copy MKV
+ ├─ 清空并关闭内存 CookieJar
+ └─ 冻结 accept-browser-file 子进程
+ └─ F:\video\青枫浦上Q\BV1HA3o6oEJJ.mkv
+ + BV1HA3o6oEJJ.download.json
+```
+
+实现使用相邻的项目自有 side-panel 构建和专用 Native Host,不向当前 TXT 扩展二进制注入代码。界面状态和按钮语义复用现有可见工作流;安全边界与 localhost TXT 后端完全分离。background 持有一个长连接,Native Host 同一时刻仅允许一个任务;side panel 关闭再打开时只从 Native Host 查询非秘密的阶段、百分比、错误码和最终文件名,不使用 `chrome.storage` 保存任务或秘密。
+
+## 5. 扩展入口合同
+
+### 5.1 最小权限
+
+扩展 manifest 只申请:
+
+- `sidePanel`:用户可见控制面;
+- `activeTab` 与 `scripting`:仅在用户点击时读取当前页的非秘密播放证明;
+- `cookies` 加 `https://www.bilibili.com/*` host permission:仅在用户点击添加/重试时读取该 URL 可发送的 Cookie;
+- `nativeMessaging`:连接唯一专用 Native Host。
+
+不申请 `downloads`、`storage`、`debugger`、`webRequest`、`declarativeNetRequest`、`tabs` 通配访问或 `127.0.0.1` 权限;不注册 content script、外部消息入口或快捷键自动触发。
+
+### 5.2 页面证明
+
+用户点击“校验当前页面”后,background 通过 `chrome.scripting.executeScript` 取得并立刻验证:
+
+- hostname=`www.bilibili.com` 且 path 精确为 `/video/BV1HA3o6oEJJ`;query/hash 不作为下游输入,host 只接收冻结 canonical URL;
+- 页面主 video 元素唯一可用,`readyState >= 1`、`videoWidth > 0`、`videoHeight > 0`;
+- `duration` 与 `3133.95 s` 差值不超过 `max(3.0 s, 3133.95*0.1%)`;
+- `mediaKeys === null`;出现 EME/DRM、登录/验证码/付费阻断或无法获得完整时长均 fail-closed;
+- 界面明确提示用户只在页面显示“充电中”且播放器为 `52:14` 时继续。该人工可见确认不是唯一门禁,后续认证元数据与最终时长仍必须独立通过。
+
+页面校验结果只包含 BVID、规范 URL、时长、尺寸、readyState、DRM 布尔值和时间戳,不包含 DOM 文本、HTML、媒体 URL 或播放器内部对象;校验结果过期 60 秒即失效。
+
+### 5.3 Cookie 最小化
+
+只有在有效页面证明尚未过期且用户点击“添加完整视频任务”或“重试”时,background 才调用 `chrome.cookies.getAll({url: "https://www.bilibili.com/"})`。扩展不序列化到 storage,不显示值,不计算证据哈希,不在错误对象中附带请求。发送完成后立即清空数组引用。
+
+Native Host 只接受 domain 为 `bilibili.com` 或以 `.bilibili.com` 结尾、path 以 `/` 开头的 Cookie;单个字段、Cookie 数、总 UTF-8 字节均有上限。外域、控制字符、重复关键字段、未知字段、空值、超限消息一律拒绝,且固定返回 `E_SECRET_INPUT`,不回显原值。
+
+## 6. Native Messaging 协议
+
+### 6.1 固定消息
+
+扩展到主机只允许:
+
+- `{"type":"status","schema":1}`:无秘密状态查询;
+- `{"type":"start","schema":1,"target":"BV1HA3o6oEJJ","page_proof":{...},"cookies":[...]}`:一次任务;
+- `{"type":"cancel","schema":1,"target":"BV1HA3o6oEJJ"}`:用户显式取消。
+
+主机到扩展只返回固定字段:`schema/type/target/phase/progress/error_code/formal_filename/mapping_filename`。禁止返回 URL、命令、Cookie、下载器对象、stderr、异常文本或内部绝对暂存路径。正式文件仅返回 basename;项目用法另行记录固定授权目标目录。
+
+消息采用 Chrome Native Messaging 长度前缀;主机在分配大对象前拒绝超长帧。JSON 必须是 UTF-8、对象、无重复键、精确字段集合和正确类型。target 不允许由请求选择其他 BVID。主机全局单任务;第二个 `start` 返回 `E_BUSY`,不产生网络或文件动作。
+
+### 6.2 无日志实现
+
+主机 stdout 仅写协议帧;stderr 默认空。yt-dlp 使用自定义 logger 和 progress hook:logger 将所有第三方字符串丢弃,仅在内存更新固定错误码;progress hook只抽取 `status`、已下载字节、总字节和数值百分比,不保存或转发 `filename/info_dict/url/fragment/headers`。顶层捕获 `BaseException`,执行清理后发送静态码,不打印 traceback。测试模式也只使用合成哨兵,真实秘密永不进入证据。
+
+## 7. 可信主机下载合同
+
+### 7.1 安装与固定配置
+
+实现阶段拟新增一个项目目录和一个测试目录;不会改动冻结 bridge:
+
+- `dev/project-dev/bili_authenticated_extension/`:manifest、side panel、background、Native Host、构建/当前用户安装脚本和非秘密配置范本;
+- `dev/project-dev/test/bili_authenticated_extension/`:协议、秘密不落盘、失败清理和 UI 桩测试;
+- `dev-doc/project-doc/B站已登录会话完整视频扩展入口.md`:安装、按钮顺序、卸载和真实验收说明。
+
+Native Host 用 Python 实现并构建为 Windows 当前用户可执行程序;构建产物不包含真实配置或秘密。安装脚本只在后续实现审核 PASS 且由获授权操作者显式执行时:
+
+1. 校验实际扩展 ID 为 32 位 Chrome ID;
+2. 写入只含该 ID 的 Native Host manifest;
+3. 在 HKCU 注册专用 host;
+4. 生成当前用户可读的非秘密配置,固定 exact BVID、canonical URL、预期时长、bridge Python/脚本、batch JSON、FFmpeg/FFprobe 和已授权目标目录;
+5. 拒绝现有输出碰撞、通配目录、相对路径、网络路径和重解析点。
+
+开发/复审不执行安装脚本,不写注册表,不启动 Chrome。打包采用固定依赖版本的 one-directory 可执行构建;不把 Cookie、会话、路径样本或运行日志打入产物。
+
+### 7.2 内存 CookieJar
+
+主机把通过 schema 的 Chrome Cookie 转换为 Netscape 格式文本并写入 `io.StringIO`,将这个 text stream 直接传给 yt-dlp `cookiefile` 参数。已核对本机 yt-dlp 2026.07.04 的 `YoutubeDLCookieJar` 支持文件名或 text stream;设计不使用 Cookie 临时文件、固定路径、浏览器 profile 或 `--cookies-from-browser`。
+
+所有 yt-dlp 操作在同一 `with YoutubeDL(opts)` 生命周期完成;结束或失败时依次 `seek(0)`、`truncate(0)`、`close()` 并释放 Cookie/请求引用。Python 不承诺物理内存安全擦除,但不跨进程生命周期、不持久化,符合“仅可信扩展/服务内存短暂存在”的授权边界。
+
+### 7.3 认证元数据和格式门禁
+
+下载前先在同一个认证上下文中 `extract_info(download=False)`,只在内存检查:
+
+- extractor 返回单条视频,`id == BV1HA3o6oEJJ`,无 `entries`、playlist、live 或 multi-P 展开;
+- duration 满足同一 `max(3.0 s, remote*0.1%)` 门禁;
+- 没有 `has_drm`,选中格式及其组成格式均没有 DRM 标志;
+- 存在可选择的视频流和音频流;不得把单音频、封面、TXT 或 600 秒预览视为完整文件;
+- 无登录、验证码、权限或格式失败。任何无法证明项均在下载前失败。
+
+`info_dict`、format URL、HTTP headers 和下载器异常只存在内存,不写 JSON/description/thumbnail/subtitle/日志/cache。禁止 verbose、dump、print、write-info、write-comments、write-playlist-metafiles、浏览器 Cookie 导出和插件加载。
+
+### 7.4 下载与合并
+
+yt-dlp 固定选项:
+
+- `format="bestvideo+bestaudio/best"`;
+- `merge_output_format="mkv"`;
+- `noplaylist=True`、`continuedl=False`、`overwrites=False`、`cachedir=False`;
+- 使用内置 HTTP 下载器,不配置外部下载器,保证签名 URL 不进入子进程 argv;
+- FFmpeg 只接收本地主机暂存的音视频文件并做 stream-copy mux,不转码、不降画质;
+- 不写 metadata、thumbnail、subtitle、description、info JSON 或 cookies。
+
+主机从固定 `%LOCALAPPDATA%\project-info\bili-auth-ingress\BV1HA3o6oEJJ\` 下独占创建随机运行目录,固定输出模板,不接受消息路径。创建、遍历和清理前确认所有路径位于固定根且不是重解析点。下载中间件只能留在该目录;任务结束只允许存在一个受支持的非 partial 媒体候选,出现多个候选、未知 sidecar、`.crdownload/.part/.tmp` 残留或文本型 URL 状态文件即 fail-closed 并清理。启动时只清理固定根内未提交的陈旧运行目录,不触碰正式目录或其他用户文件。
+
+## 8. 冻结后半程接管
+
+完整 MKV 形成后,主机先关闭 CookieJar 并释放认证元数据,再以固定非秘密 argv 启动冻结 bridge:
+
+```text
+<bridge-python> <bridge-script>
+ --input <exact-batch-json>
+ --yt-dlp <credential-free-yt-dlp>
+ accept-browser-file
+ --bvid BV1HA3o6oEJJ
+ --media-file <fixed-internal-stage-candidate>
+ --destination <authorized-destination>
+ --ffprobe <ffprobe>
+```
+
+argv 不含 Cookie、token、signed URL 或网页对象。bridge 继续负责:输入文件稳定且无 companion partial、公共无凭据远端元数据、目标卷只读复制、FFprobe 同时有音视频、`3133.95 s` 时长一致性、源/正式双 SHA-256、CreateNew 媒体与 mapping、BaseException 回滚和暂存清理。600.133313 秒预览必须在正式发布前失败。
+
+主机只解析 bridge 的固定 JSON 成功字段;失败 stdout/stderr 不原样进入扩展或日志。只有 exit 0、映射 BVID/bytes/hash/duration/acquisition_mode 全部匹配,且正式媒体和 mapping 均存在时返回 COMPLETE。成功后可清理主机自有的内部候选;若成功后清理失败,正式交付仍保留并返回静态 `W_STAGING_RETAINED`,下次启动仅在确认正式 mapping 已存在且哈希匹配时删除本主机运行目录。主机绝不删除或覆盖正式文件。
+
+## 9. 失败、取消与重试
+
+- 页面证明、Cookie schema、认证元数据、DRM 或格式门禁失败:网络媒体写入为 0。
+- yt-dlp/FFmpeg 失败:终止本任务后代进程,清理内部运行目录,不调用 bridge。
+- bridge 失败:保留 bridge 的零/回滚正式输出合同,清理主机内部运行目录;扩展只收到固定 `E_BACKHALF`。
+- `KeyboardInterrupt`、`SystemExit`、Native Host 断连、Chrome 关闭和未知 `BaseException`:先结束本任务后代、关闭内存 CookieJar、清理内部暂存,再传播为固定失败状态;不打印原对象。
+- 用户取消:与失败同样清理,不保留可恢复下载;扩展显示 CANCELED。
+- 重试:仅由用户点击触发;重新校验当前页面、重新读取当前 Cookie、创建全新随机运行目录。禁止读取旧 Cookie、旧 URL、旧 partial 或旧任务正文。
+- 已存在正式媒体或 mapping:在 Cookie 读取、网络和暂存创建前返回 `E_EXISTS`,无覆盖、无重复下载。
+
+## 10. 实现文件边界
+
+设计 PASS 后仅允许新增/修改下列范围:
+
+- 新增 `dev/project-dev/bili_authenticated_extension/` 内最小静态扩展、Native Host、构建和当前用户安装脚本;
+- 新增对应 `dev/project-dev/test/bili_authenticated_extension/`;
+- 新增一页用法;
+- 更新本事项开发账本和私有 worklog。
+
+禁止修改:
+
+- `dev/project-dev/bili_video_download_bridge.py`
+- `dev/project-dev/test/test_bili_video_download_bridge.py`
+- 当前安装的扩展目录和 public reference snapshot
+- `F:\video\青枫浦上Q`、正式 `ana-data`、600 秒隔离预览
+- 其他开发员文件或相邻动态采集工具
+
+## 11. 离线与合成测试门禁
+
+实现审核前全部测试使用合成 Cookie/URL 哨兵、fixture、mock Chrome API、fake yt-dlp/FFmpeg/FFprobe/bridge 和 `TemporaryDirectory`;网络、Chrome、扩展安装、注册表、真实 Cookie、真实媒体及正式路径动作均为 0。
+
+### 11.1 协议和权限
+
+- manifest 只含批准权限;无 downloads/storage/localhost/外部消息/content script。
+- Native Host manifest 只有一个精确 origin;占位符、通配符、第二 origin 失败。
+- page BVID/host/path、时长、readyState、尺寸、过期、DRM 各反例均在 `cookies.getAll` 和 native connect 前失败。
+- start 的未知字段、其他 BVID、非 canonical、外域 Cookie、超长值、重复 JSON key、过大 frame、任意路径/命令注入全部失败且外部动作 0。
+- status/cancel 不携带秘密;busy 第二任务外部动作 0。
+
+### 11.2 秘密不泄露
+
+使用唯一 synthetic sentinel 作为 Cookie、token、signed URL、header 和第三方异常文本;覆盖成功、metadata 失败、下载失败、FFmpeg 失败、bridge 失败、取消、BaseException 和断连。逐字节扫描 stdout/stderr、协议响应、配置、项目临时树、argv/env、日志、mapping fixture、异常和证据,sentinel 出现次数必须为 0。
+
+把文件 API、`NamedTemporaryFile`、`mkstemp`、yt-dlp CookieJar 和 subprocess 设为观测桩,证明 Cookie 只进入 `io.StringIO`,不存在 Netscape Cookie 文件、固定 Cookie 路径、token 文件、URL sidecar 或浏览器 profile 访问。关闭后 stream 长度为 0 且已关闭。
+
+### 11.3 下载和业务门禁
+
+- fake yt-dlp 断言 exact URL、`bestvideo+bestaudio/best`、MKV、noplaylist、无 cache/metadata/thumbnail/subtitle/plugin、内部 HTTP 下载器和本地 FFmpeg 输入;签名 URL 不进入 argv。
+- authenticated metadata 的其他 id、entries/multi-P、live、DRM、缺视频、缺音频、600 秒 preview、无 duration 均失败且 bridge=0。
+- 唯一完整合成 AV 候选调用冻结后半程一次;bridge argv 只含固定非秘密参数;成功响应 basename、bytes/hash/duration 与 mapping 绑定。
+- companion partial、文件增长、多个候选、输出碰撞和预览时长在 bridge/发布前失败;正式输出 0。
+- success/failure/cancel/BaseException/host disconnect 后内部暂存与后代进程均为 0;成功后清理警告不删除正式文件。
+- 安装/卸载脚本仅在临时 HKCU provider 桩和临时目录运行,验证 exact origin、当前用户范围、CreateNew、路径/重解析点拒绝和重复运行 fail-closed。
+
+### 11.4 UI 和全项目回归
+
+- mock `chrome.cookies` 返回 synthetic 值,断言只在有效用户点击后调用一次;side panel 关闭/重开只查询安全状态;重试重新取 Cookie。
+- 按钮序列固定为校验 → 添加 → 进行中 → 已完成,失败时唯一重试;自动启动、页面脚本直接启动和第二并发任务均禁止。
+- 冻结 bridge 原目标测试和 project owner 范围全回归必须 PASS;冻结文件 bytes/SHA-256 必须与基线一致。
+
+## 12. 审核与真实验收顺序
+
+1. 本 V001 先提交 `dev.reviewer.project` 合并安全设计审核;HOLD 则只修设计,禁止实现。
+2. 设计 PASS 后按第 10 节实施,并提交同一审核链一次必要实现审核;审核只用离线/合成证据。
+3. 实现审核 PASS 后,由 `case_analysis.video_downloader` 按其真实动作授权安装项目自有扩展/Native Host,在目标页可见地点击一次完整任务。开发线程不代替下载员读取真实会话或写正式目录。
+4. 真实验收必须得到约 `3133.95 s`、同时含音视频、无 partial、SHA-256 与 exact canonical mapping 的完整原件;再交媒体处理员顺序转写。
+5. 若平台返回 DRM/CAPTCHA、无法在不泄露秘密下证明完整授权、Native Messaging 安全边界不可满足,或只得到 600 秒预览,则报告单一具体 blocker;不得放宽门禁或改用签名直链。
+
+## 13. 设计通过标准
+
+独立审核需要逐项确认:
+
+- 精确单 BVID/单账号范围没有泛化;
+- 当前 TXT 扩展和冻结 backhalf 均未被修改;
+- Cookie 与 signed URL 的内存生命周期、协议、日志、异常、yt-dlp、FFmpeg 和清理合同无持久化缺口;
+- Native Messaging 唯一 origin、无 loopback、无外部消息和固定路径配置构成闭合边界;
+- entitlement、DRM、multi-P、preview、partial、AV、时长、SHA-256、CreateNew 和 BaseException 均 fail-closed;
+- 测试能够用 synthetic sentinel 证明秘密不进入任何证据面;
+- 设计审核前所有真实外部动作为 0。
+
+设计 PASS 仅授权离线最小实现,不授权真实 Cookie、Chrome 安装、真实下载、`F:\video`、正式 `ana-data` 或转写。
diff --git "a/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002.md" "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002.md"
new file mode 100644
index 0000000..c7919a8
--- /dev/null
+++ "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002.md"
@@ -0,0 +1,335 @@
+# Bilibili 已登录会话完整视频扩展入口安全设计 V002
+
+## 1. 修订范围与组合基线
+
+- 事项:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`
+- 上游需求:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 唯一目标:`青枫浦上Q / BV1HA3o6oEJJ`
+- 前序设计:`CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001.md=22418/665DDDFE4247DEFEE69B6DD1E0CBBD128EDD75B8A32BA609C90C718AC7C97C71`
+- 前序审核:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001-DESIGN-20260805-001 / HOLD/4`
+- 本修订只闭环前序 `F1—F4`。V001 已通过的 exact BVID、二次用户点击、无通用下载、无外部消息/loopback/token 文件、内存 Cookie、页面/DRM/multi-P/live/preview 门禁、冻结 `accept-browser-file` 和 offline-only 边界全部保留。
+- V001 与 V002 组成唯一设计合同;冲突处以 V002 为准。V002 独立复审 PASS 前,禁止创建产品实现、测试、扩展、Native Host、构建/安装脚本,禁止任何真实认证、Chrome、下载、注册表、媒体或正式路径动作。
+
+## 2. F1:固定扩展身份与 exact wire schema
+
+### 2.1 固定 public identity
+
+项目扩展身份不再由安装器接收任意合法格式 ID。V002 固定以下值:
+
+- Native Host 名:`com.project_info.bili_auth_ingress`
+- 协议版本:`2`
+- extension build identity:`project-info-bili-auth-ingress/1.0.0+20260805.v002`
+- host build identity:`project-info-bili-auth-native-host/1.0.0+20260805.v002`
+- manifest public key DER SHA-256:`E83C2B2AF3CF011543FBA13FBC524D1122EEA68548F9F27B9F7A82B5D594666C`
+- expected extension ID:`oidmclckpdmpabbfedplkbdplmfcenbb`
+- exact origin:`chrome-extension://oidmclckpdmpabbfedplkbdplmfcenbb/`
+
+manifest 固定 `key`:
+
+```text
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt2dT1HGYaI0DXM7zZwOTNBWXTKlMBJMpyDVjRUc+v6bUotmLyoraC+ay2scy9UQluSZVYq0tS8qvQNNvuZOlc5w2bOExm4TH2IIKvaVO8nVthHBnNz2kXdiM8ItN0vPZEmS+8gpTCI1+6wPTuUglMoXpqYBYhii8fJ5RkENRF3PRJBBigGt8soqdBFRY1QZUmpQv9dYw4dRq4L2C4QtBgClUg4bQpuCppiVZ9LHbePi9IAjc9r9R93KLzpaBuXdJpfVRE5w/6YHnxP8ovXxBdl7XktmrdH3xj7mWT7Q7ZBxkDNwn2RkruD45XgDD3yuNxOYSkLFMkseN+Ua69gSS4wIDAQAB
+```
+
+该 public key 由一次内存 RSA 生成,仅保留 public SPKI;private material 未写文件、未记录且已释放。Chrome ID 按 public DER SHA-256 前 128 bit 的 nibble `a..p` 映射复算,必须得到上述 expected ID。
+
+安装器只允许操作者提供 Chrome UI 观察到的 `ObservedExtensionId`,并执行比较,不用它生成身份:
+
+1. 读取项目 manifest 的 `key`,复算 DER hash 与 extension ID;两者必须等于冻结值。
+2. `ObservedExtensionId` 必须逐字等于 pinned ID;不接受其他 32 位 ID、通配符或第二 origin。
+3. Native Host manifest 的 `allowed_origins` 必须是只含 exact origin 的单元素数组。
+4. 安装器核对 extension/host build identity 和实现审核冻结 artifact manifest hashes 后才写 HKCU;任何差异在注册表动作前失败。
+
+Native Host 启动时把 Chrome 传入的首个 origin argv 与 exact origin 常量逐字比较;只允许额外的 Chrome `--parent-window=<decimal>` 参数,其他参数失败。origin 失败时在读取 native stdin、创建 worker、读取秘密或触发文件/网络前退出。扩展和 host 先交换 build identity;任一方不一致不得发送 Cookie。
+
+### 2.2 Native frame 与 JSON 共用规则
+
+- Chrome 到 host 的帧上限=`131072 bytes`;host 到 Chrome 的帧上限=`16384 bytes`。
+- UTF-8 必须严格解码;JSON 必须是 object;使用 duplicate-key rejecting decoder;禁止 NaN/Infinity。
+- 所有 object 使用逐层 exact key set,未知、缺失或重复字段均失败。
+- 字符串禁止 NUL、TAB、CR、LF 和 Unicode 控制字符;所有计数和时间使用 JSON integer,禁止 bool 冒充 integer。
+- `schema` 必须为 integer `2`;`target` 必须为 `BV1HA3o6oEJJ`;`canonical_url` 必须为 `https://www.bilibili.com/video/BV1HA3o6oEJJ`。
+- 除 `start` 的 `cookies[].value` 外,任何协议响应、状态或错误均不得含秘密或第三方文本。
+
+### 2.3 handshake/status/cancel schema
+
+扩展连接后必须先发送:
+
+```json
+{
+ "schema": 2,
+ "type": "hello",
+ "extension_build": "project-info-bili-auth-ingress/1.0.0+20260805.v002",
+ "target": "BV1HA3o6oEJJ"
+}
+```
+
+host 只返回固定 `host_build`、`phase` 和 `target`。随后只允许:
+
+```json
+{"schema":2,"type":"status","target":"BV1HA3o6oEJJ"}
+{"schema":2,"type":"cancel","target":"BV1HA3o6oEJJ","task_nonce":"<32 lowercase hex>"}
+```
+
+`task_nonce` 由 background 在页面校验前用 CSPRNG 生成 16 bytes,仅用于绑定本次用户点击和取消,不是认证秘密;长度和字符集固定。过期、非当前 nonce 或任务已终态的 cancel 不产生外部动作。
+
+### 2.4 page proof schema
+
+`page_proof` exact keys、类型与范围:
+
+| 字段 | 类型与范围 | 规则 |
+| --- | --- | --- |
+| `target` | string | exact BVID |
+| `canonical_url` | string | exact canonical URL |
+| `task_nonce` | string | 32 lowercase hex,与 start/cancel 一致 |
+| `observed_at_unix_ms` | integer | `0 < value < 2^53`;host 接收时不早于 60 s、不晚于 host 时钟 5 s |
+| `observed_duration_ms` | integer | 与 `3133950` 差值不超过 `3134 ms` |
+| `video_width` | integer | `1..7680` |
+| `video_height` | integer | `1..4320` |
+| `ready_state` | integer | `1..4` |
+| `eme_present` | boolean | 必须 `false` |
+
+background 只投影上述原始标量,不传 DOM、文本、HTML、媒体对象、URL 列表或页面异常。页面 hostname/path 校验、可见 `52:14/充电中` 提示及 V001 门禁不变。
+
+### 2.5 start schema
+
+`start` exact keys:
+
+```json
+{
+ "schema": 2,
+ "type": "start",
+ "extension_build": "project-info-bili-auth-ingress/1.0.0+20260805.v002",
+ "target": "BV1HA3o6oEJJ",
+ "canonical_url": "https://www.bilibili.com/video/BV1HA3o6oEJJ",
+ "cookie_store_id": "0",
+ "page_proof": {},
+ "cookies": []
+}
+```
+
+- `cookie_store_id` 为 ASCII `[0-9]{1,8}`。background 调用 `chrome.cookies.getAllCookieStores()`,要求 active tab ID 恰好属于一个 store;使用该 store ID 读取/投影 Cookie,所有 record 必须同值。
+- `cookies` 数量=`1..128`;整个 start frame 不超过 `131072 bytes`。
+- 原始 Chrome Cookie object 不得直接 stringify;background 逐字段创建下节 record。
+- host 在 worker 发出 `READY_PLUGIN_DISABLED` 前不把 start 的 secret 部分写入 worker。
+
+### 2.6 cookie record schema 与 Netscape 转换
+
+每个 record exact keys:
+
+```json
+{
+ "name": "synthetic-name",
+ "value": "synthetic-value",
+ "domain": ".bilibili.com",
+ "host_only": false,
+ "path": "/",
+ "secure": true,
+ "http_only": true,
+ "same_site": "unspecified",
+ "session": true,
+ "expiration_unix": null,
+ "store_id": "0",
+ "partition_key": null
+}
+```
+
+字段规则:
+
+- `name`=`1..256 UTF-8 bytes`,`value`=`1..4096 UTF-8 bytes`;均禁止 NUL/TAB/CR/LF/control。禁止空值 record。
+- `domain` 只允许:`host_only=true` 时 exact `www.bilibili.com`;`host_only=false` 时 exact `.bilibili.com`。大小写、尾点、IDN、其他子域和其他站点均拒绝。
+- `path`=`1..1024 UTF-8 bytes`,必须以 `/` 开始、无控制字符,且必须是 canonical target path `/video/BV1HA3o6oEJJ` 的 RFC cookie-path prefix;不做文件路径解析。
+- `secure/http_only/session` 必须是 boolean。
+- `same_site` 仅允许 `no_restriction|lax|strict|unspecified`。
+- `store_id` 必须逐字等于 top-level `cookie_store_id`。
+- `partition_key` 必须显式为 `null`。Chrome 返回任何 partitioned cookie 时 fail-closed;不把分区 Cookie 降级为非分区 Cookie。
+- `session=true` 时 `expiration_unix` 必须为 null;转换为 Netscape expiry `0`。
+- `session=false` 时 `expiration_unix` 必须是 `1..2^53-1` integer,且大于 page proof 秒级时间;转换为相同整数。过期、float、bool、二者不一致均拒绝。
+
+Netscape text 只在 worker 内的 `io.StringIO` 形成:header 固定;domain column 使用 canonical domain,`http_only=true` 时只在内存中加 `#HttpOnly_` 前缀;include-subdomains=`FALSE` iff host_only,否则 `TRUE`;path/secure/expiry/name/value 按上述已验证值写 TAB 分隔行。转换后禁止任何文件名、Cookie 临时文件或 browser profile 入口。
+
+## 3. F2:真实禁用插件与隔离第三方输出
+
+### 3.1 broker/worker 进程隔离
+
+V001 的单进程 host 改为最小 broker/worker 两进程:
+
+- broker 只使用 Python stdlib/Win32 ctypes,负责 Native Messaging、origin/build/schema、任务状态、Job Object 和固定 bridge 调度;绝不 import yt-dlp。
+- worker 是同一冻结 host executable 的 `--worker` 模式。argv 只含非秘密 mode 与继承 handle 数字;start JSON/Cookie 经专用匿名 stdin pipe 传入,不进入 argv/env。
+- broker 在启动早期复制 Chrome protocol stdout 的二进制 OS handle并设为 non-inheritable;随后把 broker 的 fd 1/fd 2 和 worker 的 stdout/stderr 全部指向 Windows `NUL`。只有 broker 的 protocol writer 能写复制 handle。
+- worker 的安全状态通过另一条只承载固定 schema/数值的匿名 control pipe返回;该 pipe 不发送第三方字符串、路径、URL、headers 或 Cookie。
+
+因此 yt-dlp CookieJar 的 direct `write_string`、Python warning、真实包 stdout/stderr、FFmpeg stdout/stderr 和 traceback 都只能进入不持久化 `NUL` sink,不能污染 Chrome 协议 stdout,也不形成日志文件。
+
+### 3.2 import 前禁用插件
+
+worker 的启动顺序固定:
+
+1. 只 import stdlib;把 fd 1/fd 2 指向 `NUL`。
+2. 在进程环境设置 `YTDLP_NO_PLUGINS=1`,该环境不含任何 Cookie/token/URL。
+3. 才允许 `import yt_dlp`。
+4. 立即设置 `yt_dlp.globals.plugin_dirs.value=[]`;调用冻结插件初始化后断言:`plugin_dirs.value==[]`、`plugins.directories()==[]`、`plugin_ies.value=={}`、`plugin_pps.value=={}`、`plugin_ies_overrides.value` 为空。
+5. 任一断言失败则从 control pipe 发送 `E_PLUGIN_BOUNDARY` 并退出;broker 不发送 start secret。
+6. 只有全部通过后发送 `READY_PLUGIN_DISABLED`,broker 才把 start payload 写入 worker stdin。
+
+这同时阻断默认 `plugin_dirs=['default']`、旧兼容插件目录和用户插件在秘密进入进程后执行。实现固定 yt-dlp `2026.07.04` 包 hash 清单;版本或相关模块 hash 漂移须重新实现审核。
+
+### 3.3 真实包无网络 sentinel 门禁
+
+实现审核前用冻结真实包而非仅 fake logger 执行:
+
+- 在临时默认插件目录放一个若加载即写 sentinel/marker 的 synthetic plugin;worker 设置禁用机制并真实创建 `YoutubeDL`,断言 marker 不出现且 plugin maps/directories 为空。
+- 用真实 `YoutubeDLCookieJar` 从 `io.StringIO` 分别加载合法和畸形 synthetic Cookie,触发正常、解析 warning、close/save 路径;sentinel 不得进入 broker protocol、父 stdout/stderr、临时树、配置、异常或证据。
+- 网络 request director 被测试桩设为“调用即失败”,证明这些测试网络=0;真实 yt-dlp 初始化、CookieJar 和输出路径仍被执行。
+- 启动真实 sleeping `ffmpeg` helper,把 sentinel 写 stdout/stderr/异常后退出;broker protocol 只可收到固定错误码,磁盘日志为 0。
+- 逐字节扫描 protocol frames、所有父子 stdout/stderr 捕获、argv/env、临时树和 evidence;Cookie/token/signed URL/header/exception sentinel 出现次数必须为 0。
+
+## 4. F3:一次解析、同一下载对象和 downloader/FFmpeg 门禁
+
+### 4.1 唯一解析状态机
+
+同一个冻结 `YoutubeDL` 实例只执行一次:
+
+```text
+processed_info = ydl.extract_info(CANONICAL_URL, download=False, process=True)
+```
+
+随后:
+
+1. 对 `processed_info` 执行 exact id、single video、duration、live、multi-P、DRM、AV、availability 和 format 基线门禁。
+2. 使用冻结 2026.07.04 实例的 `_get_formats`、`build_format_selector("bestvideo+bestaudio/best")` 和 `_select_formats` 取得唯一 selected format;数量不为 1 即失败。
+3. 按冻结库自身路径生成一次 `download_info = ydl._copy_infodict(processed_info); download_info.update(selected_format)`。
+4. 对这个 exact `download_info` 及其 `requested_formats` 逐叶验证下节合同。
+5. 验证后把 `ydl.extract_info` 替换为“调用即 `E_SECOND_EXTRACT`” guard,再把同一个 `download_info` object 直接交给 `ydl.process_info(download_info)`。
+
+禁止调用 `ydl.download(url)`、第二次 `extract_info`、按 URL 的 `process_ie_result` 或重新取得 metadata/formats。测试记录 extractor invocation count 必须恒等于 1,并证明被验证 object identity 与交给 `process_info` 的 object identity 相同。
+
+### 4.2 requested format 与实际 downloader allowlist
+
+逐个实际叶 format 校验:
+
+- 若 `requested_formats` 存在,必须恰好 2 个:一个 `vcodec!=none/acodec=none`,一个 `vcodec=none/acodec!=none`;顶层必须同时有 AV 合并语义。
+- 若不存在,单叶必须同时 `vcodec!=none` 且 `acodec!=none`,进入 4.4 单文件 remux。
+- 每个叶 `has_drm` 必须 false/absent;URL scheme 必须 `https`;protocol 只允许 `https` 或 `http_dash_segments`。
+- 用冻结真实 `get_suitable_downloader(leaf, ydl.params)` 计算实际 class;只允许 exact `HttpFD` 或 `DashSegmentsFD`。
+- `FFmpegFD`、`HlsFD`、任何 ExternalFD、`m3u8/m3u8_native/rtmp/rtsp/mms/ism`、未知协议、多个 manifest、缺失 URL 或额外 subtitle/data format 全部在首个媒体字节前失败。
+- `fragment_retries`、HTTP headers 和签名 fragment URL只由 worker 内置 downloader 消费;不导出、不转成任何子进程 argv。
+
+### 4.3 子进程 argv 审计门
+
+worker 在 yt-dlp import 后、任何子进程前注册 Python audit hook,拦截所有 `subprocess.Popen`:
+
+- executable 必须逐字等于安装配置中 pinned FFmpeg 或固定 host/bridge executable;其他 child 失败。
+- argv 中禁止含 `://`、`-headers`、`-cookies`、`authorization`、`cookie:`、`referer:`、`user-agent:`,禁止含本任务任一 Cookie value/name、signed URL/header synthetic/real value。
+- FFmpeg 的所有 `-i` 值和输出必须是固定 run directory 下通过重解析点检查的本地普通文件;禁止 pipe/network/protocol input。
+- child environment 使用固定 allowlist,禁止 Cookie/token/URL/header;stdout/stderr=`NUL`;child 必须属于第 5 节 Job Object。
+
+audit hook 在 CreateProcess 前抛出安全异常,因此禁止值不得短暂出现在 Windows process command line。真实 dispatcher 测试要求 `https -> HttpFD`、`http_dash_segments -> DashSegmentsFD` PASS,`m3u8 -> FFmpegFD` 被产品门禁拒绝且实际 FFmpeg spawn=0。
+
+### 4.4 最终唯一 MKV
+
+- 双叶 DASH 路径:`merge_output_format="mkv"`,yt-dlp 只在两份本地文件完成后调用 FFmpeg stream-copy merger;argv 必须通过 4.3。
+- 单文件 `best` fallback:下载器只产生本地完整 AV 文件;host 不把原容器视为最终结果,使用 pinned FFmpeg 固定本地命令 `-nostdin -i <local> -map 0:v:0 -map 0:a:0 -c copy -map_metadata -1 -f matroska <exclusive-stage>.mkv`。remux、AV 或 MKV 验证失败即清理并 fail-closed。
+- 两种路径最终都必须只剩一个 `.mkv`;原单文件或双流中间件在 MKV 验证成功后删除。任何其他媒体候选、未知 sidecar 或 partial 残留失败。
+
+离线合成门禁使用真实 FFmpeg 生成短 AV fixture,分别覆盖双叶合并和单叶 remux;验证 MKV、音视频、无转码语义、唯一候选、argv 全为本地路径。无需网络或业务媒体。
+
+## 5. F4:有界并发、超时与 Windows Job Object
+
+### 5.1 broker reader/coordinator/worker
+
+- broker 的 reader thread 持续读取 Chrome stdin;它不等待下载,可立即消费 `status/cancel`。EOF、坏帧、origin/session disconnect 设置同一个 cancel event。
+- coordinator 是唯一状态机 owner,负责 worker、control pipe、时钟、Job Object、协议响应和清理;同一时刻最多一个 worker。
+- worker 通过继承的 Windows event handle检查 cancel;在 metadata 前后、每个 progress/fragment hook、FFmpeg 前后及 bridge 前后检查。阻塞 socket 由固定 socket timeout打断。
+- reader 只入队已验证的非 secret control;start secret 只从 broker 写入 worker input pipe一次,随后 broker 清空 start/Cookie引用。
+
+### 5.2 固定重试和时间上限
+
+yt-dlp/host 值固定为:
+
+| 项目 | 上限 |
+| --- | --- |
+| socket/read timeout | `20 s` |
+| extractor retries | `1` |
+| HTTP retries | `2` |
+| fragment retries | `2` |
+| file access retries | `1` |
+| retry sleep | 每次 `1 s`,最大 `3 s` |
+| metadata phase | `120 s` |
+| download+merge 总墙钟 | `7200 s` |
+| frozen bridge 子进程 | `900 s` |
+| graceful cancel | `3 s` |
+| TerminateJobObject 后 wait | `5 s` |
+| reader/control join | 各 `5 s` |
+
+达到任一阶段或总墙钟,coordinator 设置 cancel;3 秒后 worker 仍存活则调用 `TerminateJobObject`,5 秒后仍不能确认 PID 全灭则返回 `E_PROCESS_OWNERSHIP` 并退出,绝不继续后半程或发布。没有无限重试、无限 join 或断点续传。
+
+### 5.3 OS 级后代所有权
+
+每个 task 在接收 secret 前创建独立 Windows Job Object,设置 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`。broker 使用 Win32 `CreateProcessW` 的 `CREATE_SUSPENDED|CREATE_NO_WINDOW` 启动同一 host executable 的 worker 模式,继承句柄 allowlist 只含 worker input、sanitized control 和 cancel event:
+
+1. worker 尚未执行用户代码时,broker 用 `AssignProcessToJobObject` 加入 task job。
+2. 成功后才 `ResumeThread`;失败则 terminate suspended worker、关闭 handles,并在 secret 写入前停止。
+3. worker 后续 yt-dlp/FFmpeg/bridge children 自动继承同一 job;禁止 breakaway。
+4. broker 持有 job 的唯一有效 handle。cancel/EOF/disconnect/BaseException/timeout 走 grace→TerminateJobObject→wait;broker 被 Chrome 强杀或崩溃时 handle 由 OS 关闭,kill-on-close 自动终止 worker 及全部后代。
+
+若当前 Windows/Chrome job 策略不允许该所有权链,任务在 Cookie 读取/worker secret/网络前以 `E_JOB_OBJECT` fail-closed,不回退到普通 Popen 或仅 PID 递归清理。
+
+### 5.4 Cookie 和 stage 的终态
+
+- graceful success/cancel/EOF/BaseException/timeout 路径要求 worker 在退出前 `seek(0)/truncate(0)/close()` Cookie stream,control pipe 只返回 `cookie_stream_closed=true`;broker 不记录内容。
+- 强制 kill/host crash 不依赖 Python finally;Job Object 确保持有 Cookie 的进程及后代全部终止,秘密内存由 OS 回收。此路径不虚报 Python close marker。
+- stage 始终只含媒体字节,不含 Cookie/URL/headers/log。强杀可能留下内部 stage;下次 broker 启动在读取任何 secret 前按 V001 固定根/重解析点边界清理,正式目录不受影响。
+
+### 5.5 真实 sleeping-child 无网络门禁
+
+实现审核使用真实 Win32 process,不只 fake child:
+
+- 测试 broker 通过同一 suspended→assign job→resume 路径启动 worker;worker 再启动一个 300 秒 sleeping child 和一个 grandchild,记录非秘密 PID。
+- 分别触发:用户 cancel、native stdin EOF、模拟 Chrome disconnect、worker `KeyboardInterrupt`、`SystemExit`、下载墙钟超时、broker `TerminateProcess` 模拟 host kill。
+- 每个 case 用 `OpenProcess/WaitForSingleObject` 验证 worker/child/grandchild handle 全部 signaled、无 PID 存活;grace 与 kill/join 时间均不超冻结值。
+- graceful case 断言 Cookie stream truncate+close marker;host kill case 断言 job kill 后秘密进程为 0、重启清理 stage成功且无协议/sentinel/日志。
+- child stdout/stderr 写 synthetic secret sentinel;逐字节扫描 Native protocol、父捕获、临时树、argv/env/evidence,出现次数必须为 0。
+
+## 6. V002 离线复审矩阵
+
+### 6.1 F1
+
+- 复算 public DER hash 和 expected ID;manifest key/ID/build 任一字符漂移失败。
+- installer 对正确 observed ID PASS;任意其他合法 32 位 ID、第二 origin、wildcard、hash/build 漂移均在 HKCU 前失败。
+- host argv exact origin PASS;错 origin、缺 origin、额外非 parent-window arg 在读 stdin/secret 前失败。
+- page/start/cookie 每个字段分别覆盖 missing/unknown/wrong type/bool-as-int/null/range/control/duplicate-key/oversize。
+- active tab store 不唯一、record store不一致、partitioned cookie、domain/path/hostOnly/session/expiry 所有反例外部动作 0。
+
+### 6.2 F2
+
+- 冻结真实 yt-dlp 首次 import/实例化,默认插件目录存在 sentinel plugin 仍不加载;plugin dirs/maps exact empty。
+- 真实 CookieJar 合法/畸形/close 路径和 sleeping FFmpeg stdout/stderr sentinel 均进入 NUL;protocol frame保持可解析且无 sentinel。
+- worker 未发 READY 时 broker 不写 start secret;plugin gate失败时 secret write count=0。
+
+### 6.3 F3
+
+- extractor invocation=1;validated/downloaded `download_info` object identity相同;第二解析 guard非真空。
+- real downloader class:HttpFD/DashSegmentsFD PASS;FFmpegFD/HlsFD/ExternalFD/unknown/m3u8 全拒绝且 media spawn=0。
+- audit hook 对 URL/header/Cookie/env/非 pinned executable/非本地 `-i` 在 CreateProcess 前拒绝。
+- 真实短合成双流与单文件均产一个 MKV,FFprobe AV PASS;single fallback 不得直接发布原容器。
+
+### 6.4 F4
+
+- reader 在 worker 阻塞时仍能于冻结时限消费 cancel/EOF/status;第二 start E_BUSY。
+- retry/metadata/socket/fragment/total/bridge 每一门禁有计时反例,无无限等待。
+- 七类真实 sleeping child case 的 worker/child/grandchild PID 全灭;graceful Cookie close、host-kill OS memory终止、restart stage cleanup和无 sentinel全部非真空。
+
+## 7. 文件与动作边界
+
+V002 复审前仅新增本设计并追加既有开发账本/私有 worklog。以下保持冻结:
+
+- `dev/project-dev/bili_video_download_bridge.py=28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`
+- `dev/project-dev/test/test_bili_video_download_bridge.py=22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`
+- `dev-doc/project-doc/B站完整视频下载桥接工具.md=6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`
+- 当前安装 TXT 扩展、public snapshot、localhost/token/SQLite/Cookie 文件链、600 秒隔离 preview、`F:\video` 和正式 `ana-data`。
+
+真实 Cookie/token/signed URL、Chrome、扩展安装、Native Host 注册、network、download、real media、`F:\video`、formal `ana-data`、implementation/test/config actions 全部保持 `0`。
+
+## 8. 复审请求与后续授权
+
+请 `dev.reviewer.project` 只复审 F1—F4 是否闭环以及 V001 已通过边界是否回退,返回一次完整 PASS/0 或精确 HOLD。V002 PASS 只授权在冻结文件边界内创建最小离线实现、合成媒体测试和真实 sleeping-child 测试;不授权读取真实 Cookie、安装 Chrome/扩展/host、注册 HKCU、联网下载、读取隔离预览、写 `F:\video`/正式 `ana-data` 或启动转写。
diff --git "a/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md" "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md"
new file mode 100644
index 0000000..22bf13d
--- /dev/null
+++ "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md"
@@ -0,0 +1,191 @@
+# B 站博主动态采集辅助工具 HOLD/5 最小修复编码方案 V001
+
+创建人员:dev.developer.project.secondary / infodev-2
+文件职责:冻结 `DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001` 首次实现审核 HOLD/5 的最小重型修复合同、失败语义和验收矩阵。
+管理规范/模板:../../../../common/dev-doc/编码规范.md;../../编码规范.md;../../开发审计规范.md
+引用文件:../../../ai-video-downloader/draft/2026-08-04-哔哩哔哩动态采集需求.md;../B站博主动态采集辅助工具.md;../../开发审计报告.md;../../../dev/project-dev/bili_dynamic_collector.py;../../../dev/project-dev/test/test_bili_dynamic_collector.py
+记录方式:重型编码方案;方案独立审核 PASS 后才允许修改实现与测试。
+
+## 1. 事项与修复边界
+
+- 事项:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`。
+- 新授权:请求方在首次终态交付后明确“继续推进”,只允许关闭 `HOLD/5`,不增加新功能。
+- 前序审核:`DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-20260804-001=HOLD/5`。
+- owner:`dev.developer.project.secondary / infodev-2`。
+- reviewer:`dev.reviewer.project / inforev`。
+- 代码/测试:继续只修改现有 `bili_dynamic_collector.py`、示例配置和 `test_bili_dynamic_collector.py`;不拆包、不增加运行模块。
+- 文档:只同步现有用法、目录入口、根级账本、worklog 和开发 tmp 证据。
+- 外部动作:网络、浏览器、扩展、下载、真实媒体、`F:\video`、正式 `ana-data` 均为 0。
+
+本修复从现在起按重型事项处理,原因是现有工具包含三个持久阶段、跨调用 manifest 状态、互斥、重跑和复制/提交/删除事务。当前代码继续视为 `TERMINAL_HOLD_DEVELOPMENT_SNAPSHOT_ONLY`;本方案 PASS 前不得修改代码或测试。
+
+## 2. 不变合同
+
+以下已通过合同不重开:
+
+1. `check / move-completed / handoff` 三个公开 CLI 名称、全局 `--config` 和可选 `--now`。
+2. offset-aware 时间、最近时间窗、动态/opus/BV/URL 基础去重、Windows 安全 stem 和重复输入不生成空待办。
+3. 不联网、不控制浏览器/扩展、不下载内容、不读取浏览器 profile、不自动发送 Codex 消息。
+4. 目标存在时不覆盖;happy path 继续为 partial → flush/fsync → SHA-256 → 同目录 hard-link no-overwrite → manifest → 删除源。
+5. handoff 生成前继续复核视频位于 `video_dir` 且实物 SHA-256 与 manifest 一致。
+6. 只使用脱敏 JSON、TemporaryDirectory/dev tmp 和数十字节非媒体 fixture;不运行真实采集。
+
+## 3. 模块、接口与状态
+
+仍为单一 stdlib 模块,内部职责按函数分层:
+
+| 内部层 | 职责 | 本轮变化 |
+|---|---|---|
+| JSON/manifest 输入层 | strict UTF-8、JSON、秘密字段、schema | manifest 事件补统一秘密 gate |
+| identity/preflight 层 | selector→entity、source→target、批次唯一性 | mapping 改为所有已提供 selector 必须命中同一实体;同源批次拒绝 |
+| move 状态/事务层 | 预检、copy/commit、manifest、源删除、重跑 | retained-source 重跑保持 exit 4;预检错误零副作用 |
+| native handoff 层 | 路由、证据、完整 envelope、内容寻址输出 | 配置化精确 source/target 身份并输出 canonical envelope |
+
+状态只修一个既有分支:
+
+```text
+VIDEO_MOVED_SOURCE_RETAINED
+ -- 相同 mapping 重跑 --> VIDEO_MOVED_SOURCE_RETAINED / exit 4
+```
+
+不新增“自动清理源”或“自动关闭告警”功能。源副本即使被人工删除,也不由本工具猜测为已解决;未来如需显式确认,另立需求,本轮保持告警粘性。
+
+## 4. F2:mapping 全选择器一致与批次预检零副作用
+
+### 4.1 selector 合同
+
+mapping 每项允许提供 `dynamic_id`、`opus_id`、`bvid`、`source_url` 中的一项或多项。与 `check` 的“新实体发现”不同,move mapping 是既有实体的精确引用:
+
+1. 每一个已提供 selector 都必须在 manifest token map 中存在。
+2. 每一个 selector 都必须只解析到一个实体。
+3. 所有 selector 的唯一实体必须完全相同。
+4. 任一未知、冲突或多实体 selector 立即 `SAFETY_STOP`,不忽略错误 selector。
+5. mapping 的 `source_url` 必须走与动态导出相同的 HTTPS、无凭据、registered-host 规范化。
+
+现有宽松 `resolve_entity()` 保留给 `check`;新增/改造的 exact resolver 只供 move mapping 调用,避免改变已通过的新增实体去重语义。
+
+### 4.2 source identity 与批次合同
+
+每项预检解析源文件后形成 source identity:优先使用 `(st_dev, st_ino)`;若平台 inode 为 0,则使用 `normcase(resolve(strict=True))`。在任何复制、目标创建或 manifest 追加前,对整个 mapping 批次检查:
+
+1. 一个 source identity 只能出现一次;无论映射到相同或不同实体,重复源都拒绝。
+2. 一个实体在同一批次只能出现一次。
+3. 目标路径继续保持大小写不敏感唯一。
+4. 任一 selector、源、实体或目标预检失败时,正式目标文件新增数为 0,manifest bytes 不变,所有源文件不变。
+
+因此删除当前“预检异常也追加 MOVE_FAILED”的行为。`MOVE_FAILED` 只记录批次已完整通过预检后,某项在 copy/hash/commit/manifest 的运行期失败;批次运行期仍按顺序记录逐项终态,不承诺跨多个大文件的全批次回滚。
+
+## 5. F3:retained-source 告警重跑保真
+
+`preflight_move()` 对最新状态的处理冻结为:
+
+| 最新状态 | 本次结果 | exit |
+|---|---|---:|
+| `VIDEO_MOVED` / `COMPLETE` | `ALREADY_MOVED` | 0 |
+| `VIDEO_MOVED_SOURCE_RETAINED` | `VIDEO_MOVED_SOURCE_RETAINED`,保留原 local_file/hash/failure_reason | 4 |
+| `TODO_QUEUED` / `MOVE_FAILED` | 执行正常预检与移动 | 0/3/4 |
+| 其他 | 安全停止 | 3 |
+
+汇总只要包含一个 retained-source 结果,状态必须是 `COMPLETE_WITH_RETAINED_SOURCE`、exit 4。相同 mapping 重跑不得写新目标、不得删除/改写现有目标、不得把状态降成 `ALREADY_MOVED` 或 exit 0,也不追加重复 manifest 事件。
+
+## 6. F4:manifest 统一秘密字段门禁
+
+`load_manifest()` 每行完成 JSON parse 后、读取 schema/identity/状态前,必须对完整事件递归调用现有 `reject_secret_keys(event, "$manifest[line]")`。任何层级字段名命中 password/passwd/cookie/token/secret/authorization/captcha/session 或中文对应词,三个命令都通过共同入口得到 `SAFETY_STOP/E_SECRET_FIELD`。
+
+门禁不打印字段值,只返回字段路径。manifest bytes 不改写,state/queue/target/handoff 均无新增。
+
+## 7. F5:规范且稳定的 Codex 原生 handoff envelope
+
+### 7.1 配置化路由
+
+示例配置新增必需对象 `native_handoff`:
+
+```json
+{
+ "project_id": "project-info",
+ "source_ai_id": "video-downloader",
+ "source_thread_id": "019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5",
+ "source_role_instance_id": "case_analysis.video_downloader",
+ "target_ai_id": "media-processor",
+ "target_thread_id": "019fb7a4-bdfd-79f2-bd6b-e67e2b7d8efd",
+ "target_role_instance_id": "case_analysis.media_processor",
+ "reply_thread_id": "019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5"
+}
+```
+
+所有字段为非空字符串;thread ID 必须为小写十六进制 UUID 形式;source/target/reply 三个精确线程与 AI 身份来自当前项目配置快照。该对象不包含认证秘密。工具仍不发送消息。
+
+### 7.2 envelope 合同
+
+输出必须是可直接作为一次精确目标消息发送的完整 `<codex_native_handoff>`:
+
+1. 顶层必有 `project_id`、`message_type=video_processing_request`、`handoff_id`。
+2. 必有 source/target AI、thread、role 以及 `reply_thread_id`。
+3. `status=PROCESSING_REQUESTED`。
+4. 必有显式 `scope:`、`evidence:`、`expected_action:` 区段。
+5. evidence 每个视频包含 entity ID、BV、发布时间、JSON 转义标题/URL/本地路径和 SHA-256。
+6. `handoff_id` 由 route + 排序后视频身份/路径/hash 的 canonical JSON SHA-256 派生,格式 `HANDOFF-BILI-DYNAMIC-VIDEO-PROCESSING-<24位大写HEX>`。
+7. envelope 不含运行时 `generated_at`,因此 ready 集合与 route 不变时,不同 `--now` 重跑仍得到同一 handoff ID、同一 bytes 和 `REUSED`。
+8. stdout 继续明确 `sent=false`;生成文件不是自动发送证据。
+
+## 8. 错误码与失败副作用
+
+继续复用 exit:0 成功、2 输入合同、3 安全停止、4 retained source、130 中断。新增或细化错误码:
+
+| 错误码 | 条件 | 文件/manifest 副作用 |
+|---|---|---|
+| `E_MAPPING_SELECTOR_UNKNOWN` | 任一 selector 未命中 | 0 |
+| `E_MAPPING_SELECTOR_CONFLICT` | selectors 指向不同/多个实体 | 0 |
+| `E_SOURCE_DUPLICATE` | 批次 source identity 重复 | 0 |
+| `E_ENTITY_DUPLICATE` | 批次实体重复 | 0 |
+| `E_SECRET_FIELD` | manifest 任意层级秘密字段 | 0 |
+| `E_CONFIG` | native_handoff 缺字段/格式错误 | 0 |
+
+原有 `E_TARGET_CONFLICT`、下载完成/范围/重名、hash、路径和 atomic create 错误继续有效。
+
+## 9. 测试与验收矩阵
+
+### L0
+
+- py_compile、CLI help、strict UTF-8、diff check。
+
+### L1/L3 聚焦反例
+
+1. 正确 BV + 未知 dynamic ID:`E_MAPPING_SELECTOR_UNKNOWN`;源、目标、manifest bytes 不变。
+2. 两个已知 selector 指向不同实体:`E_MAPPING_SELECTOR_CONFLICT`;零副作用。
+3. 同一物理源用同路径/相对与绝对 alias 映射两个实体:`E_SOURCE_DUPLICATE`;零副作用。
+4. 同一实体在批次重复:`E_ENTITY_DUPLICATE`;零副作用。
+5. 注入源删除失败:首次 exit 4;相同 mapping 重跑仍 exit 4,源与可信目标均保留,目标/hash/manifest bytes 不变。
+6. manifest 根层或嵌套层加入 `cookie`/`token`:check、move、handoff 共同入口安全停止,字段值不出现在 stdout,state/目标不变。
+7. handoff 全部必填身份字段与 `scope/evidence/expected_action` 存在;route/ready 相同但 `--now` 不同仍同一 ID、同一 bytes、`REUSED`。
+8. native_handoff 缺字段或 thread 格式错误:配置输入错误且不写 state。
+
+### L2/L3 回归
+
+- 原有 6 项测试全部保留并按新配置补 route。
+- 新鲜 TemporaryDirectory 完成 check → safe move → canonical handoff → repeat check;不使用前序 dev/tmp 持久状态冒充回归。
+- 运行 `dev/project-dev/test` project flat discover;不运行网络、浏览器、下载、真实媒体或正式路径。
+
+## 10. 文档、证据与恢复
+
+1. 更新现有工具说明中的重型分类、exact mapping、retained-source 粘性、manifest secret gate 和完整 handoff route/envelope。
+2. 新建独立 repair dev/tmp 证据目录,不覆盖前序 `dev/tmp/bili-dynamic-collector-mvp/` HOLD 快照。
+3. worklog、总纲、计划和执行日志 append-only 记录新授权、设计 PASS、实现、测试与复审。
+4. 实现复审 PASS 前不得宣称工具可用于真实移动、调度或正式交接;即使 PASS,本事项也不自动触发真实运行。
+
+## 11. 明确排除
+
+不新增网络请求、B 站 API、浏览器/扩展控制、下载器、登录态、cookie/token、数据库、服务、GUI、自动调度、自动 native send、自动源清理、`F:\video` 写入或正式 `ana-data` 写入。不处理前序 HOLD/5 之外的新需求或非阻断建议。
+
+## 12. 实施顺序与门禁
+
+```text
+V001 限定方案审核 PASS
+-> 修改现有代码/配置/测试/说明
+-> L0 + 聚焦反例 + 新鲜 tmp smoke + project 回归
+-> 冻结证据
+-> 同一 dev.reviewer.project 一次限定实现复审
+-> PASS/0 或明确剩余阻断回传请求方
+```
+
+V001 未取得独立 PASS 前,代码、配置、测试和 fixture 继续冻结;只允许本设计、目录入口、账本和审核通信变化。
diff --git "a/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V002.md" "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V002.md"
new file mode 100644
index 0000000..c6a96f4
--- /dev/null
+++ "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V002.md"
@@ -0,0 +1,70 @@
+# B 站博主动态采集辅助工具 HOLD/5 最小修复设计 V002
+
+## 1. 修订身份与适用范围
+
+- 事项:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`
+- 需求:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`
+- 基线:`CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md`
+- 基线字节数:`11917`
+- 基线 SHA-256:`16C14FB2751A0EAB63F76371E75D0569FF8EF5B47FDD8724BE861314B6062733`
+- 前序结论:`HOLD/1`,唯一未闭环项为 `F1_DESIGN_RERUN_PREFLIGHT_ORDER_CONFLICT`
+- 本修订性质:仅补充 V001 第 4.2、5、8 节之间的执行顺序;V001 已通过的重型分类、精确选择器、manifest 秘密字段检查、规范原生 handoff 及所有范围边界继续冻结。
+
+本修订 PASS 前,代码、示例配置、测试和 fixture 继续保持冻结;不运行网络、浏览器、下载,不接触 `F:\video` 或正式 `ana-data`。
+
+## 2. 冻结的 move-completed 两阶段预检顺序
+
+`move-completed` 对整个 mapping 批次采用下列固定顺序。任何阶段失败都在复制、目标创建、源删除和 manifest 追加之前结束。
+
+### 2.1 阶段 A:选择器解析、实体唯一性与终态投影
+
+1. 读取并验证完整 mapping 结构和秘密字段。
+2. 对每一行解析所有已提供的 `dynamic_id`、`opus_id`、`bvid`、`source_url`;每个选择器都必须存在、唯一命中,且同一行的全部选择器必须命中同一实体。`source_url` 继续执行 V001 的 registered-host 规范化。
+3. 在全批次检查 entity 唯一性;同一实体出现两次即 `E_ENTITY_DUPLICATE`。该检查不依赖 `source_file` 是否仍存在。
+4. 读取该实体最新有效 manifest 状态,并将该行分类为:
+ - `TERMINAL_MOVED`:最新状态是 `VIDEO_MOVED` 或 `COMPLETE`;
+ - `TERMINAL_RETAINED`:最新状态是 `VIDEO_MOVED_SOURCE_RETAINED`;
+ - `ACTIVE_MOVE`:最新状态是 `TODO_QUEUED`、`MOVE_FAILED`,或没有移动终态。
+5. 终态投影直接使用 manifest 中已经持久化的可信字段,不重新要求源文件存在:
+ - `TERMINAL_MOVED` 返回 `ALREADY_MOVED`、exit 0,并复用已记录的 `local_file` 与 `sha256`;
+ - `TERMINAL_RETAINED` 返回 `VIDEO_MOVED_SOURCE_RETAINED`、exit 4,并复用已记录的 `local_file`、`sha256` 与 `failure_reason`;即使保留源后来被人工删除,告警也保持粘性;
+ - 两类终态均不 stat/resolve `source_file`,不参与物理源重复检查,不创建、删除或改写文件,不追加 manifest 事件。
+
+### 2.2 阶段 B:仅对 ACTIVE_MOVE 做源与目标预检
+
+只有 `ACTIVE_MOVE` 行进入以下预检:
+
+1. `source_file` 必须存在且为允许下载根目录中的普通文件;逐级拒绝链接、junction/reparse,检查稳定年龄与扩展名。
+2. 形成物理 source identity:优先 `(st_dev, st_ino)`;inode 为 0 时使用 `normcase(resolve(strict=True))`。
+3. 对全部 `ACTIVE_MOVE` 行检查 source identity 唯一性;重复即 `E_SOURCE_DUPLICATE`。
+4. 计算目标路径并检查全批目标唯一、既有目标不覆盖以及根目录安全。
+5. 阶段 A 或阶段 B 任一错误均使本次调用正式目标新增数为 0、manifest bytes 不变、所有源文件不变。预检错误不追加 `MOVE_FAILED`;只有完整两阶段预检通过后发生的真实运行时事务失败才可按 V001 记录失败事件。
+
+终态行可以与活动行共存于同一批次。终态行仍参与 entity 唯一性检查,但不参与 source identity/目标写入集合;活动行全部通过阶段 B 后才允许逐项进入 V001 已冻结的 copy/fsync/hash/atomic link/manifest/delete 事务。
+
+## 3. 精确终态矩阵
+
+| manifest 最新状态 | 当前源文件 | 分类 | 输出状态 | exit | manifest 变化 |
+| --- | --- | --- | --- | ---: | --- |
+| `VIDEO_MOVED` / `COMPLETE` | 不存在或存在 | `TERMINAL_MOVED` | `ALREADY_MOVED` | 0 | 无 |
+| `VIDEO_MOVED_SOURCE_RETAINED` | 存在 | `TERMINAL_RETAINED` | `VIDEO_MOVED_SOURCE_RETAINED` | 4 | 无 |
+| `VIDEO_MOVED_SOURCE_RETAINED` | 后来被人工删除 | `TERMINAL_RETAINED` | `VIDEO_MOVED_SOURCE_RETAINED` | 4 | 无 |
+| `TODO_QUEUED` / `MOVE_FAILED` / 无终态 | 存在且通过预检 | `ACTIVE_MOVE` | 按事务结果 | 0/4/3 | 仅运行时事务可追加 |
+| `TODO_QUEUED` / `MOVE_FAILED` / 无终态 | 不存在或不安全 | `ACTIVE_MOVE` | 输入/安全错误 | 2/3 | 无 |
+
+批次汇总规则保持不变:只要投影或新事务结果中包含一个 retained-source,整体为 `COMPLETE_WITH_RETAINED_SOURCE`、exit 4;否则按既有成功/失败规则汇总。
+
+## 4. 新增非真空入口测试合同
+
+在 V001 第 8 节基础上必须新增以下实际 CLI 入口回归,全部使用 `TemporaryDirectory`、小型非媒体字节文件和秘密无关的净化 JSON:
+
+1. **正常成功后源已删除重跑**:首次移动成功并确认源不存在;保存目标/hash/manifest bytes;相同 mapping 重跑,断言 exit 0、item=`ALREADY_MOVED`,目标/hash/manifest bytes 完全不变,目录无新增临时文件。
+2. **retained 源仍存在重跑**:注入源删除失败得到 exit 4;相同 mapping 重跑,断言 exit 4、item=`VIDEO_MOVED_SOURCE_RETAINED`,复用原 `local_file`/`sha256`/`failure_reason`,源、目标和 manifest bytes 不变。
+3. **retained 源被人工删除后重跑**:在第 2 项之后于临时目录人工删除保留源;相同 mapping 重跑,断言仍 exit 4 且复用原可信字段,目标/hash/manifest bytes 不变,无新事件。
+4. **活动同源/同实体混合批次**:保持 V001 的 selector 不一致、同一物理源映射两实体、同实体两行反例;断言全批次零业务副作用。终态行不掩盖活动行的选择器错误,也不进入物理同源检查。
+
+测试必须从 CLI/`main()` 入口进入,不得只调用内部分类函数。三种重跑均须比较调用前后 manifest 原始 bytes,而非只比较事件数量。
+
+## 5. 实施与复审门禁
+
+V001 与 V002 组合为唯一实施合同。V002 限定设计 PASS 后,开发员才可一次性实现五项 HOLD 修复,并运行目标测试与 project 回归;全程不得联网、打开浏览器、触发下载、写 `F:\video` 或正式 `ana-data`。实现完成后只向原 `dev.reviewer.project` 提交一次限定实现复审。
diff --git "a/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003.md" "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003.md"
new file mode 100644
index 0000000..a4c84e7
--- /dev/null
+++ "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003.md"
@@ -0,0 +1,48 @@
+# B 站博主动态采集辅助工具 HOLD/5 最小修复设计 V003
+
+## 1. 唯一修订点
+
+- 事项:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`
+- 基线组合:V001 `11917/16C14FB2751A0EAB63F76371E75D0569FF8EF5B47FDD8724BE861314B6062733` + V002 `6342/DA3B3DDAA3C8B8EE33E2C0B6857B962754D740EB4BEE075E3BFB0F0FD4382736`
+- 前序唯一阻断:`F1_R1_ACTIVE_MOVE_ALLOWLIST_REGRESSION`
+- 本修订只覆盖 V002 第 2.1 节第 4 项、2.2 节和第 3 节的活动状态允许列表;其他文字与全部已通过合同不变。
+
+## 2. ACTIVE_MOVE 精确允许列表
+
+V002 中“或没有移动终态”“无终态”两处表述废止,禁止作为实施依据。
+
+读取每个实体最新有效 manifest 事件后,只允许按以下顺序分类:
+
+1. 最新状态为 `VIDEO_MOVED` 或 `COMPLETE`:`TERMINAL_MOVED`,按 V002 投影为 `ALREADY_MOVED`、exit 0。
+2. 最新状态为 `VIDEO_MOVED_SOURCE_RETAINED`:`TERMINAL_RETAINED`,按 V002 投影为同名状态、exit 4。
+3. 最新状态精确等于 `TODO_QUEUED` 或 `MOVE_FAILED`:`ACTIVE_MOVE`,允许进入阶段 B 的源/目标安全预检。
+4. 最新状态为 `PROCESSING_HANDOFF_CONFIRMED`、`PROCESSING`、`PROCESSING_FAILED`,事件缺少/空白 `status`,或任何未知状态:在阶段 A 返回 `E_STATUS`、`SAFETY_STOP`、exit 3。
+
+第 4 类禁止进入阶段 B,禁止 stat/resolve 源文件,禁止复制、创建目标、删除源文件或追加 manifest;正式业务文件和 manifest 原始 bytes 必须不变。该安全停止不改变 V001 的任何状态、exit code 或产品边界。
+
+## 3. 修正后的精确矩阵
+
+| 最新状态 | 分类 | 是否进入阶段 B | 输出/exit | manifest 变化 |
+| --- | --- | --- | --- | --- |
+| `VIDEO_MOVED` / `COMPLETE` | `TERMINAL_MOVED` | 否 | `ALREADY_MOVED` / 0 | 无 |
+| `VIDEO_MOVED_SOURCE_RETAINED` | `TERMINAL_RETAINED` | 否 | `VIDEO_MOVED_SOURCE_RETAINED` / 4 | 无 |
+| `TODO_QUEUED` / `MOVE_FAILED` | `ACTIVE_MOVE` | 是 | 按冻结事务合同 | 仅运行时事务可追加 |
+| `PROCESSING_HANDOFF_CONFIRMED` / `PROCESSING` / `PROCESSING_FAILED` | 禁止状态 | 否 | `E_STATUS` + `SAFETY_STOP` / 3 | 无 |
+| 缺失、空白或未知 `status` | 禁止状态 | 否 | `E_STATUS` + `SAFETY_STOP` / 3 | 无 |
+
+## 4. 参数化非真空入口反例
+
+测试通过 CLI/`main()` 入口参数化覆盖:
+
+- `PROCESSING_HANDOFF_CONFIRMED`
+- `PROCESSING`
+- `PROCESSING_FAILED`
+- 缺少 `status`
+- `status=""`
+- `status="UNKNOWN_STATE"`
+
+每个 case 均在 `TemporaryDirectory` 中准备净化 manifest、合法 mapping 和一个哨兵源文件;调用前记录 manifest 原始 bytes、源文件 bytes、业务目录树和所有文件哈希。调用后统一断言:exit 3、stdout 中 `status=SAFETY_STOP` 且 `error.code=E_STATUS`;manifest 原始 bytes、源文件、正式目标集合和所有业务文件均完全不变;无 `.partial`、锁残留或新增事件。
+
+## 5. 门禁
+
+V001+V002+V003 为唯一组合实施合同。V003 限定复审 PASS 前,代码、配置、测试和 fixture 继续冻结。PASS 后只实施前序 HOLD/5 五项,不增加网络、浏览器、下载、调度、自动发送或正式数据能力。
diff --git "a/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001.md" "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001.md"
new file mode 100644
index 0000000..7b3d98a
--- /dev/null
+++ "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001.md"
@@ -0,0 +1,163 @@
+# 本地媒体主机响应保护编码方案 V001
+
+创建人员:`dev.developer.project`
+文件职责:冻结 PPT 页面提取与本地视频转写两个既有 CLI 的最小主机响应保护改动、离线测试和受控真实资源验收。
+管理规范/模板:`../../../编码规范.md`;`../../../开发审计规范.md`;`../../../../common/dev-doc/编码规范.md`。
+引用文件:`../../../../ai-media-processor/draft/会议录屏PPT页面提取方案_v0.1.md`;`../会议录屏PPT页面提取工具.md`;`../本地视频语音转写工具.md`;`../../../开发执行日志.md`。
+记录方式:事项 `DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001` 的 V001 完整方案;方案独立审核 PASS 前不得修改产品代码或运行真实媒体。
+
+## 1. 上游依据与问题归因
+
+- 授权:`HANDOFF-MEDIA-INFODEV-MEDIA-HOST-RESPONSIVENESS-20260804-001 / AUTHORIZED_MINIMAL_IMPLEMENTATION`。
+- 事故窗口:`2026-08-03 20:58:07+08:00..21:16:36+08:00`;对应 PPT 完整提取根 Python PID=`45268`。
+- 已有证据表明 `extract_ppt_slides.py` 的稳定帧扫描命令没有 `-hwaccel`、解码/滤镜线程上限或 Windows 优先级;本机为 `22C/44T` Xeon,长时间软件解码可挤占桌面响应。
+- 本机只读能力核对:FFmpeg 提供 `cuda`、`h264_cuvid`、`hevc_cuvid`、`hwdownload`、`scale_cuda`;GPU 为 RTX 3090 24GB。2 秒合成 H.264 以本方案拟定的 `h264_cuvid + hwdownload` 命令执行 `exit=0`,证明命令链在当前主机可用。
+- 问题类型:性能与进程调度问题,不是页面算法、转写模型、源媒体或既有产物问题。
+- 修改基线:`extract_ppt_slides.py=32133/9C4F57ED56B01C9EE2BD68CDC532BC865F73EB3662E01B6600410C5660F1F605`;`transcribe_media.py=20996/309166EEFE11A74472EA355CA4AF87F9432A15393B9ABE6D69F7F1054055DE35`。两组目标测试基线为 `41/41 PASS`。
+
+## 2. 冻结范围
+
+### 2.1 允许修改
+
+1. `dev/project-dev/transcribe_media.py`。
+2. `dev/project-dev/extract_ppt_slides.py`。
+3. 对应现有测试;允许新增一个仅供验收的只读 PowerShell 采样测试脚本。
+4. 两份既有用法说明、目标目录导读和根级开发账本。
+
+不新增产品 package、常驻进程、服务、数据库、API、GUI、调度器或用户参数。为避免引入第三个产品模块,跨脚本公共保护的 stdlib-only 内部 owner 固定放在现有 `transcribe_media.py`,`extract_ppt_slides.py` 只导入该内部入口;两个公开 CLI 和业务函数签名保持不变。
+
+### 2.2 不得回退
+
+- PPT:稳定段、动画合并、去重、主课件序列过滤、逐页原分辨率 PNG、合并 PDF、第一视频流、进度、源保护、拒绝覆盖、原子目录提交及中断/失败清理均不变。
+- 转写:第一音轨、16kHz 单声道 FLAC、large-v3、CUDA/float16/VAD、20 分钟长视频分块、全局时间轴、FLAC/TXT/SRT/JSON、拒绝 CPU/模型降级、四文件提交回滚及源保护均不变。
+- 不通过降低质量、跳过页面、降低扫描频率、减少输出或改变识别参数换取资源指标。
+- 不增加 OCR、人像/人脸识别、裁剪、摘要、翻译、Skill 或其他媒体能力。
+
+## 3. 公共主机保护
+
+### 3.1 跨进程互斥
+
+`transcribe_media.py` 增加一个 stdlib-only 内部上下文入口,由两个 CLI 的 `main` 在解析参数后、任何 FFprobe/FFmpeg、模型加载、暂存或正式输出动作前进入:
+
+1. Windows 使用固定命名互斥体 `Local\\MBXMediaHeavyTaskV1`;`CreateMutexW` 后以 `WaitForSingleObject(..., 0)` 非阻塞获取。
+2. `WAIT_OBJECT_0` 或 `WAIT_ABANDONED` 表示本任务取得唯一占用;退出时 `ReleaseMutex` 并 `CloseHandle`。进程异常退出时由 Windows 释放 owner,不使用易残留的“文件存在”判断。
+3. `WAIT_TIMEOUT` 明确返回“已有重型媒体任务运行,拒绝并行启动”,不得等待、排队或启动任何外部进程;其他 Win32 错误带错误码失败。
+4. 非 Windows 保持既有可运行性,不设置 Windows 优先级;本事项只对已登记 Windows 主机声明互斥与资源验收合同。
+5. `extract_ppt_slides.py` 与 `transcribe_media.py` 必须调用同一个导入对象和同一个 mutex name,不复制第二套锁实现。
+
+互斥只覆盖一次 CLI 重型任务生命周期,不建立常驻服务,不改变输出目录锁和 CreateNew/拒绝覆盖语义。
+
+### 3.2 Windows 低优先级
+
+取得互斥后,Windows CLI 先用 `GetPriorityClass` 保存进入前等级,再以 `SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS)` 把根 Python 设为“低于正常”。读取或设置失败必须在外部进程启动前显式终止并报告 Win32 错误,不静默继续。
+
+所有产品代码启动的 FFprobe/FFmpeg 都显式使用 Python `subprocess.BELOW_NORMAL_PRIORITY_CLASS` creation flag;`extract_ppt_slides.py` 的两个 `Popen` 入口和 `transcribe_media.py` 的默认 `subprocess.run` runner 共用同一 creation-flags helper。测试桩 runner 不启动系统进程,不伪造优先级证据。
+
+上下文退出时先尽力恢复进入前优先级,再释放 mutex;恢复失败写明确 stderr 告警但不得覆盖正在传播的业务异常或把已原子提交的正式结果伪装成未完成。`KeyboardInterrupt`、`SystemExit` 和普通异常均不得遗留互斥占用;CLI 的整个重型工作区间仍始终为 BelowNormal。
+
+## 4. FFmpeg 线程上限与 PPT NVDEC
+
+### 4.1 固定资源常量
+
+- `MEDIA_DECODE_THREADS = 4`:所有 FFmpeg 输入解码显式使用 `-threads 4`,不使用默认 `auto`。
+- `MEDIA_FILTER_THREADS = 2`:PPT 扫描显式使用 `-filter_threads 2 -filter_complex_threads 2`。
+
+这些值固定在内部代码,不新增 CLI 参数。本事项主机为 44 逻辑处理器,4 个解码线程和 2 个滤镜线程属于保守上限。逐页 PNG 只解码一个定位帧,也使用 `-threads 4`;转写的完整音轨和音频块 FFmpeg 命令同样使用 `-threads 4`。FFprobe 不包含可配置的 FFmpeg 解码/滤镜线程,但子进程优先级仍为 BelowNormal。
+
+### 4.2 视频流 codec 探测
+
+现有 FFprobe JSON 的第一视频流增加读取 `codec_name`;`VideoInfo` 增加带默认值的 `codec_name`,不改变既有三参数测试构造。映射固定为:
+
+| `codec_name` | NVDEC decoder |
+|---|---|
+| `h264` | `h264_cuvid` |
+| `hevc` | `hevc_cuvid` |
+
+其他 codec 没有猜测性 decoder 映射,直接进入有线程上限的 CPU 路径并明确告警。
+
+### 4.3 扫描命令与回退
+
+支持 codec 的首次扫描命令固定包含:
+
+```text
+-hwaccel cuda -hwaccel_output_format cuda -c:v <h264_cuvid|hevc_cuvid>
+-threads 4
+-filter_threads 2 -filter_complex_threads 2
+-vf hwdownload,format=nv12,setpts=PTS-STARTPTS,fps=fps=1:start_time=0,scale=<w>:<h>:flags=bilinear
+```
+
+NVDEC 只替代输入视频解码;硬件帧下载后继续沿用原来的 `setpts -> fps=1 -> scale` 和 Python Pillow/NumPy 特征算法,不用 CUDA 重写页面算法。rawvideo 仍是 `rgb24`,采样时间映射和页面结果合同不变。
+
+单次扫描逻辑拆成内部 `_scan_once`:每次创建全新的稳定段 detector 与页面 classifier。硬件路径非零退出、无完整帧或其他普通扫描失败时,丢弃该次内存候选,向 stderr 打印包含 decoder 与原错误的明确“NVDEC 不可用,改用受限 CPU”告警,然后只重启一次 CPU 扫描;不得在部分硬件候选上继续,也不得多次重试。`KeyboardInterrupt`/`SystemExit` 原样传播,不触发 CPU 回退。
+
+CPU 回退命令不带任何 `-hwaccel`/`cuvid`,但必须含 `-threads 4`、`-filter_threads 2` 和 `-filter_complex_threads 2`。成功路径 stdout 固定打印实际解码路径、decoder 和线程上限;日志可作为 NVDEC 路径证据,显存变化不能替代该证据。
+
+## 5. 失败与兼容合同
+
+1. 互斥忙、主进程优先级设置失败属于外部动作前错误;不得创建暂存、正式输出或加载模型。
+2. NVDEC 普通失败只允许一次明确告警后的受限 CPU 回退;CPU 路径失败继续使用既有 `SlideExtractionError`、stderr 尾部、子进程回收和暂存清理。
+3. 所有新保护都在现有最外层失败包络内;中断类型、CLI 退出码以及提交回滚合同不变。
+4. 外部调用向两个业务函数注入测试 runner 时继续沿用原签名;只有默认 runner 负责真实 Windows creation flag。
+5. 原视频和既有证据只读;设计、离线测试和审核阶段不得读取 3.57 小时源视频或 4 小时媒体。
+
+## 6. 离线与合成测试门禁
+
+### 6.1 线程和命令
+
+- H.264/H.265 分别唯一映射到 `h264_cuvid/hevc_cuvid`;硬件命令包含 CUDA、hwdownload、固定滤镜顺序、4/2 线程上限。
+- CPU 命令不含硬件参数,仍含相同 4/2 上限;逐页 PNG、完整音轨、音频块命令包含 `-threads 4`。
+- 默认 `run/Popen` 在 Windows 都收到 `BELOW_NORMAL_PRIORITY_CLASS`;优先级设置失败时没有外部进程调用。
+
+### 6.2 路径与互斥
+
+- 用两个真实 Python 进程做无媒体互斥:进程 A 取得命名 mutex 并等待测试事件;进程 B 分别调用两个 CLI,均在路径/FFmpeg/模型动作前得到明确 busy;A 退出后锁可重新取得。
+- `KeyboardInterrupt`、普通异常及 Win32 获取错误均验证 mutex handle 关闭;不创建队列、守护进程或锁文件。
+
+### 6.3 硬件回退与业务回归
+
+- 桩硬件失败后只出现一次显式告警和一次 CPU 命令;硬件已产生的候选不进入 CPU classifier;CPU 再失败时不做第三次尝试。
+- 2 秒合成 H.264 实际 NVDEC 扫描成功并记录路径;合成/桩测试不得使用业务源。
+- 既有 PPT 颜色主题、稳定/动画/去重、PDF、安全测试与转写分块/时间轴/提交回滚测试全部通过;py_compile、两个 CLI help、project 测试、UTF-8 和治理校验无回退。
+
+基线为 `41/41 PASS`。实现阶段测试允许 FFmpeg 合成媒体,不允许读取完整业务源。
+
+## 7. 实现审核后的唯一真实资源验收
+
+### 7.1 执行边界
+
+只有独立实现审核 PASS 后才允许:
+
+1. 只读核对原源大小、创建时间、修改时间与 SHA-256。
+2. 在 `dev/project-dev/tmp/DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001/` 用 FFmpeg 从既有 H.264 源生成一个从 `00:30:00` 开始、目标 `590 s` 的独立短片;FFprobe 实测必须 `<=600 s`。生成命令本身使用线程上限和 BelowNormal 启动,不覆盖任何文件。
+3. 只运行一次 `extract_ppt_slides.py`,输出写新目录;不得并行启动转写,不得重试或调参,不得运行 3.57 小时/4 小时媒体。
+4. 运行后再次核对原源四项指纹;检查 PNG/PDF、页面顺序、源保护、暂存清理和冻结代码哈希。
+
+转写侧只做离线命令/优先级/互斥回归;本轮真实资源验收由 PPT NVDEC 路径代表重型 FFmpeg 场景。
+
+### 7.2 只读采样器
+
+新增测试辅助 `dev/project-dev/test/monitor_media_host_responsiveness.ps1`,不进入产品 CLI。它以 `500 ms` 目标周期启动唯一被测 Python 并记录:
+
+- 整机:`Processor(_Total)\\% Processor Time`。
+- 任务树:根 Python 与递归后代 PID、进程名、命令行、PriorityClass、CPU 累计时间、同采样点 CPU 百分比和 WorkingSet 合计。
+- GPU:`nvidia-smi` 原始行、GPU utilization、decoder utilization、全局显存;同任务 PID 的 Windows `GPU Process Memory\\Dedicated Usage` 专用显存及原始实例名。
+- 媒体:stdout/stderr、首次观察到的完整 FFmpeg 命令、采样实际间隔、退出码和墙钟。
+
+逐样本写 `resource_samples.csv`,汇总写 `resource_summary.json`,原始命令和日志分别保存。stdout 中的 `NVDEC/CUDA (h264_cuvid)` 与 FFmpeg 命令参数是硬件路径主证据;decoder utilization 仍逐样本记录,不能只用显存声明硬件解码。
+
+### 7.3 CPU 门禁与采样有效性
+
+- 对时间戳位于每个样本尾端前 `5 s` 的至少 10 个连续有效样本取整机 CPU 算术平均;所有完整窗口必须 `<85%`。
+- 不得出现连续 20 个 `500 ms` 有效样本的整机 CPU 均 `>=85%`,即不得连续约 10 秒达到或超过 85%。
+- 任务树 CPU 按同间隔内 CPU 累计秒增量除以 `间隔秒 × 逻辑处理器数` 归一到整机 `0..100%`;RAM 为同采样点根与全部后代 WorkingSet 之和。
+- 采样周期、实际间隔、逻辑处理器数、滚动窗口成员和最长高 CPU 连续段必须落盘;任一实际间隔 `>1500 ms`、根 PID 丢失、未观察到扫描 FFmpeg、优先级不是 BelowNormal、GPU/decoder 查询失败或必填字段缺失时 `sampling_valid=false`,不得宣告资源 PASS。
+
+真实验收通过条件:被测 exit=0;整机两项 CPU 门禁通过;根 Python 和全部观察到的 FFmpeg 均 BelowNormal;NVDEC 命令/日志证据存在;CPU/RAM/GPU/decoder/显存字段完整;业务输出和源保护回归通过。未给 RAM/显存数值上限,只记录峰值,不自行扩展门禁。
+
+## 8. 审核与交付顺序
+
+1. V001 独立方案审核 PASS。
+2. 仅按本方案实现并运行离线/合成测试。
+3. 提交 `dev.reviewer.project` 限定实现审核;PASS 前禁止真实片段生成和运行。
+4. PASS 后执行第 7 节唯一一次短片验收;失败只报告单一具体证据,不放宽 85% 门限、不改跑长视频。
+5. 更新两份用法说明和开发账本,向 `case_analysis.media_processor` 回传代码、测试、互斥、回退、CPU/GPU 采样、审核和已知限制。
diff --git "a/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002.md" "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002.md"
new file mode 100644
index 0000000..6e956f2
--- /dev/null
+++ "b/dev-doc/project-doc/\345\274\200\345\217\221\346\226\271\346\241\210/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002.md"
@@ -0,0 +1,158 @@
+# 本地媒体主机响应保护编码方案 V002
+
+创建人员:`dev.developer.project`
+文件职责:在 V001 已通过部分不回退的前提下,闭环限定方案审核的 F1—F3;作为后续实现、离线测试、实现审核和一次受控短片验收的唯一方案基线。
+管理规范/模板:`../../../dev-doc/编码规范.md`;`../../../dev-doc/开发审计规范.md`;`../../../../common/dev-doc/编码规范.md`。
+引用文件:`CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001.md`;`../../../dev-doc/开发审计报告.md`;`../会议录屏PPT页面提取工具.md`;`../本地视频语音转写工具.md`。
+记录方式:事项 `DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001` 的 V002 限定修订;V002 复审 PASS 前继续冻结产品代码与真实媒体。
+
+## 1. 修订依据与不回退合同
+
+- 前序方案:`CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001.md`,`13606 bytes / 7F0F5505EF1D92C73FBE6EDFF382F6E25D95A0ABE6D28A7A366353B88CA80273`。
+- 前序审核:`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001-DESIGN-20260804-001=HOLD/3`,审计报告当时为 `171737 bytes / C9857D954230147A316DD72E2EAC0798C13865859125C566FCC14196AABB6D28`。
+- 本版只修订 F1“回退归因边界”、F2“H.264/H.265 业务等价合成门禁”和 F3“资源采样非空与时间口径”。V001 第 2、3、4.1、4.2、5、7.1 和 8 节中与三项修订不冲突的内容继续有效。
+- 产品文件仍仅允许修改 `dev/project-dev/transcribe_media.py`、`dev/project-dev/extract_ppt_slides.py` 及其既有测试;另允许一个测试侧只读 PowerShell 采样器、两份既有用法说明、目录导读和开发账本。不新增产品模块、依赖、参数、服务、数据库、API、GUI、批处理或调度器。
+- 两 CLI 继续共用非阻塞 Windows named mutex;根 Python 和产品默认 FFprobe/FFmpeg 全生命周期使用 BelowNormal;固定解码线程 `4`、滤镜线程 `2`。公开 CLI、页面/转写算法、PNG/PDF、FLAC/TXT/SRT/JSON、源保护、原子提交和失败清理合同不变。
+- 实现审核 PASS 前仍不得读取或运行真实源、不得生成真实片段。实现审核 PASS 后最多只读生成一个 `<=600 s` 独立片段并运行一次 PPT 提取;不得重跑、调参、并发转写或改跑长媒体。
+
+## 2. F1:硬件可归因失败与单次回退边界
+
+### 2.1 类型边界
+
+扫描内部只新增一个私有、不可跨 CLI 暴露的类型 `HardwareDecodePathError(SlideExtractionError)`。它只表示“本次硬件 decoder/硬件帧下载管道没有形成可供现有页面算法完整消费的 rawvideo 帧流”,不得用来包装任何页面业务错误。
+
+硬件 `_scan_once` 分为两个严格区域:
+
+1. **外部解码/传输区域**:组装和启动 FFmpeg、读取固定大小 rawvideo 帧、等待 FFmpeg、解释返回码和 stderr。只有本区域下表列出的失败可转换为 `HardwareDecodePathError`。
+2. **页面业务区域**:把已经读满的单帧交给既有稳定段 detector、页面 classifier、动画/去重和主课件选择。这里的异常保留原类型、原消息和原 traceback;不得被硬件错误捕获器包围,也不得触发 CPU。
+
+| 事件 | 对外类型/语义 | CPU 回退次数 |
+|---|---|---:|
+| 硬件 FFmpeg `Popen` 启动失败,且命令确为 NVDEC | `HardwareDecodePathError`,保留 decoder、命令与原异常链 | 恰好 1 次 |
+| 读取到 `0 < len(frame) < frame_size` 的半帧 | `HardwareDecodePathError`,记录期望/实际字节数 | 恰好 1 次 |
+| FFmpeg 退出后完整帧数为 0 | `HardwareDecodePathError`,保留返回码和 stderr 尾部 | 恰好 1 次 |
+| 硬件 FFmpeg 非零退出 | `HardwareDecodePathError`,保留返回码和 stderr 尾部 | 恰好 1 次 |
+| detector/classifier/特征计算/动画/去重/主序列选择异常 | 原异常原样传播 | 0 次 |
+| FFmpeg 完整成功、页面算法也结束,但合法结果是“无稳定页面” | 既有无稳定页面错误原样传播 | 0 次 |
+| 输入/输出文件、正式提交、暂存或清理异常 | 既有错误原样传播 | 0 次 |
+| `KeyboardInterrupt`、`SystemExit` | 原对象原样传播 | 0 次 |
+| 受限 CPU 第二次扫描失败 | CPU 路径的原失败作为最终失败;告警中仍保留首个硬件失败摘要 | 不再尝试 |
+
+硬件帧读取循环不得以“其他普通扫描失败”作为兜底转换条件。关闭 pipe、`wait`、收集 stderr 或回收子进程时出现的清理异常,不得冒充 decoder 不可用;若已有业务异常正在传播,清理异常只作为诊断附注,不覆盖业务根因。`BaseException` 只用于 `finally` 清理,不用于转换或回退判断。
+
+### 2.2 唯一回退控制流
+
+外层扫描协调器只捕获 `HardwareDecodePathError`:打印一次带 `NVDEC`、实际 decoder 和根因的 stderr 告警,然后创建全新的 detector/classifier,执行一次固定 `4/2` 线程的 CPU `_scan_once`。CPU `_scan_once` 不会抛 `HardwareDecodePathError`,其任何失败直接结束任务;代码中不存在第三个 scan 调用点或循环重试。
+
+不支持的 codec 不是运行时 NVDEC 失败:它在探测后直接打印“不支持硬件映射,使用受限 CPU”的明确告警并只运行一次 CPU。H.264/H.265 才进入上述硬件尝试。
+
+### 2.3 F1 离线桩门禁
+
+必须记录每次 scan 的路径和调用序号,并覆盖:
+
+1. 硬件启动失败、非零退出、零帧、半帧各自只触发一次 CPU;CPU 成功时总尝试数严格为 2。
+2. detector 异常、classifier 异常、无稳定页面、文件/清理异常的 CPU 调用数严格为 0,异常类型、消息和因果链不被改写为 NVDEC 错误。
+3. 硬件阶段 `KeyboardInterrupt`、`SystemExit` 使用对象身份或退出码断言原样传播,CPU 调用数为 0。
+4. CPU 第二次失败时总尝试数严格为 2,没有第三次;最终错误是 CPU 根因,stderr 告警仍含首个硬件根因,二者不互相覆盖。
+
+## 3. F2:H.264/H.265 双路径业务等价合成门禁
+
+### 3.1 固定合成序列
+
+测试只使用测试临时目录中的程序生成 RGB PNG,不读取业务视频。生成 `320x180`、`1 fps` 的确定性短序列,颜色、几何块和帧号完全固定:
+
+- `A-stable`:3 帧,稳定页 A;
+- `A-animation`:3 帧,在 A 的固定区域加入动画块;
+- `B-stable`:3 帧,切换到稳定页 B;
+- `A-animation-repeat`:3 帧,重复已经出现的 A 动画终态,用于验证合理去重;
+- `C-stable`:3 帧,切换到稳定页 C。
+
+每段首尾画面保持足够稳定,使既有稳定段、动画合并、重复去重和主题选择链都被真实调用。测试生成器不得修改产品阈值,也不得通过跳过 detector/classifier 来制造等价。
+
+同一 PNG 序列分别用当前 FFmpeg 的 `libx264` 和 `libx265` 编成短小 H.264/H.265 媒体。编码命令自身固定线程上限,仅用于合成测试。缺少任一 encoder、`h264_cuvid` 或 `hevc_cuvid` 时,对应集成测试必须明确 `SKIP` 并保存能力缺口;不得伪造 PASS。当前目标验收主机具备这些入口,提交实现审核时两种 codec 都必须实际 PASS。
+
+### 3.2 四组实际扫描
+
+对 H.264、H.265 各运行两条真实扫描路径:
+
+- NVDEC:分别固定 `h264_cuvid`、`hevc_cuvid`,CUDA 硬件帧经 `hwdownload` 转为产品现有 RGB rawvideo;
+- forced CPU:同一媒体、同一采样/缩放/业务函数,但禁用 `-hwaccel` 和 cuvid,使用固定 `4/2` 线程。
+
+每次运行保存实际 codec、decoder、完整 argv、FFmpeg 返回码、stderr 尾部和产品扫描/选择摘要。硬件证据以 argv 中的 `-hwaccel cuda`、实际 cuvid decoder 和成功返回为主;显存变化不能代替该证据。
+
+### 3.3 等价断言
+
+对每种 codec 的 NVDEC 与 forced CPU 结果逐项断言:
+
+1. 输入采样数相同;稳定候选数和动画更新数相同。
+2. 去重后的逻辑页数相同;主序列选中页的逻辑标识及顺序相同。
+3. 每个选中页的相对时间戳相同;允许的唯一浮点误差为 `<=0.001 s`,不得用整页误差掩盖漂移。
+4. 完整产品提取生成的 PNG 数、连续编号、顺序、RGB 模式和尺寸相同;PDF 页数、顺序、每页 RGB 图像尺寸相同。
+5. 不要求 NVDEC/CPU PNG 或 PDF 压缩字节逐字节相同;但是任一业务页、时间、数量、顺序、模式或尺寸漂移即阻断。
+
+命令结构测试必须对 H.264/H.265 各自断言 `-hwaccel`、`-hwaccel_output_format`、`-c:v <decoder>` 和输入 `-threads 4` 全部位于目标输入 `-i` 之前;`-filter_threads 2`、`-filter_complex_threads 2` 明确存在。CPU 命令不得含硬件参数,但同样含 `4/2` 上限。
+
+## 4. F3:非空、连续且按真实时间判定的资源采样
+
+### 4.1 采样阶段与有效行
+
+测试侧 `monitor_media_host_responsiveness.ps1` 目标周期仍为 `500 ms`。采样器以首次发现本次任务树中的扫描 FFmpeg 为 `scan_start`,持续到该 FFmpeg 退出后的同一采样周期为 `scan_end`;该区间必须连续覆盖,不能用根 Python 的非扫描阶段替代。
+
+性能计数器第一次读数标为 `counter_warmup=true`、`cpu_valid=false`,只保留原始值,不进入任何窗口或高 CPU 段。其后每一行保存:
+
+- `timestamp_utc`、`previous_valid_timestamp_utc`、`interval_seconds`、`cpu_valid`;
+- 整机 CPU、任务 PID 集、任务树 CPU/RAM、根/FFmpeg PriorityClass;
+- GPU utilization、decoder utilization、全局显存、PID 专用显存及原始查询状态;
+- `scan_ffmpeg_present`、实际命令和 decoder 字段。
+
+任一有效行代表区间 `(previous_valid_timestamp, timestamp]`。实际相邻有效间隔必须 `0 < interval_seconds <=1.5`;超过 `1.5 s`、时间戳非递增、计数器读取失败、扫描起止无法归属或必填字段缺失,均令 `sampling_valid=false`。
+
+### 4.2 五秒尾窗公式与非空性
+
+只评估完整落在 `[scan_start, scan_end]` 的 5 秒尾窗。每个尾窗以有效样本区间与窗口的时间重叠作为权重:
+
+`window_cpu = sum(cpu_i * overlap_seconds_i) / sum(overlap_seconds_i)`。
+
+每个窗口必须同时满足:
+
+- 时间覆盖 `sum(overlap_seconds_i) >= 5.000 s`(仅允许浮点计算容差 `1 ms`);
+- 至少 `10` 个不同有效样本成员;
+- 窗口内最大样本间隔 `<=1.5 s`;
+- `window_cpu <85.0`。
+
+汇总的 `window_count` 必须 `>0`。若扫描阶段不足以形成一个完整 5 秒窗口、任一窗口覆盖或密度不足,均为 `sampling_valid=false`,不得用空集合宣布 CPU PASS。`resource_windows.csv` 固定逐窗保存窗口起止、成员行号列表、成员数、覆盖秒数、最大间隔、加权 CPU 和判定;`resource_summary.json` 保存 `window_count`、最大窗口 CPU、最差窗口及成员。
+
+### 4.3 连续高 CPU 的真实时间公式
+
+高 CPU 段不用固定 20 行近似。对连续有效且整机 CPU `>=85.0` 的样本区间,按真实 `interval_seconds` 累计;遇到 `<85.0`、无效行、缺口或扫描阶段边界即结束一段。逐段保存 `start_timestamp_utc`、`end_timestamp_utc`、`duration_seconds`、成员行号和终止原因。
+
+任一高 CPU 段 `duration_seconds >=10.000` 即 CPU FAIL;等于 10 秒必须失败。密度/覆盖无效和阈值超标是不同字段:前者使 `sampling_valid=false`,后者使 `cpu_gate_pass=false`,不得混为成功。
+
+### 4.4 F3 合成 CSV 门禁
+
+不启动媒体,使用固定时间戳的合成 CSV 直接测试 parser/汇总器:
+
+1. 密集 `84.9%`:窗口非空、全部 `<85%`、无 10 秒高段,PASS。
+2. 密集 `85.0%`:所有相关 5 秒窗口以严格 `<85%` 门禁 FAIL。
+3. 连续高 CPU 恰好 `10.000 s`:高段门禁 FAIL;`9.999 s` 反例不因该项失败。
+4. 稀疏 `0.8—1.5 s`:即使单行看似正常,只要任一完整尾窗成员少于 10 或覆盖不足,`sampling_valid=false`。
+5. 扫描不足 5 秒或没有合格窗口:`window_count=0` 且 `sampling_valid=false`,证明不能空通过。
+6. warm-up 行不进入窗口;首/末覆盖、时间戳倒退和 `>1.5 s` 缺口分别触发无效。
+
+真实短片验收的 summary 还必须固定扫描开始/结束、有效样本数、最大间隔、窗口数/最差成员、高 CPU 最长段起止/时长、整机 CPU 两项门禁,并同时保留 V001 规定的任务树 CPU/RAM、GPU/decoder/显存和命令/日志证据。
+
+## 5. 实现与审核门禁
+
+1. V002 限定方案复审只检查本文件第 2—4 节及第 1 节不回退合同;PASS 前产品代码、产品测试和真实媒体继续冻结。
+2. PASS 后仅按 V001+V002 实现;先运行 F1 桩、F2 H.264/H.265 合成媒体、F3 合成 CSV,再运行两个目标测试和 project 全回归。合成媒体只在测试临时目录生成和清理。
+3. 实现审核材料必须包含代码/测试/用法/采样器 bytes 与 SHA-256、完整测试结果、两种 codec 的四组 decoder argv 与等价摘要、F1 调用序列、F3 反例汇总、产品边界未回退证明,以及真实业务媒体动作仍为 0。
+4. 只有独立实现审核 PASS 后,才执行 V001 第 7.1 节的一次 `<=600 s` 片段和一次 PPT 运行。采样无效、CPU 门禁失败、NVDEC 主证据缺失或业务回归失败时只报告单一具体阻断,不重试、不调参、不改跑长视频。
+5. 最终向 `case_analysis.media_processor` 回传代码变更、测试、互斥/优先级/回退行为、CPU/GPU/decoder 采样、独立审核结论和源保护;不扩展产品范围。
+
+## 6. V001 阻断逐项映射
+
+| 前序 finding | V002 闭环 | 实现后必备证据 |
+|---|---|---|
+| F1 flow-failure-contract | 第 2 节:只允许 `HardwareDecodePathError` 回退;算法、空结果、文件/清理和 `BaseException` 均零回退;CPU 失败无第三次 | 启动/非零/零帧/半帧、算法、空结果、中断、CPU 二次失败桩的调用序列和根因断言 |
+| F2 business-contract-test | 第 3 节:固定稳定页+动画+重复+切换序列;H.264/H.265 分别实际比较 NVDEC 与 forced CPU | 两种 codec 的 decoder argv;采样、页数、顺序、时间戳、PNG/PDF/RGB/尺寸等价摘要 |
+| F3 evidence-sampling | 第 4 节:warm-up 丢弃;扫描 FFmpeg 连续覆盖;窗口非空且每窗至少 10 样本;按实际时间累计高 CPU 段 | 窗口成员、最大间隔、最差窗口、高段起止/时长;`<85`、`==85`、10 秒、稀疏、零窗口合成反例 |
diff --git "a/dev-doc/project-doc/\346\205\247\345\215\232\347\262\276\347\241\256\347\240\224\346\212\245\346\211\271\351\207\217\346\211\247\350\241\214\345\231\250.md" "b/dev-doc/project-doc/\346\205\247\345\215\232\347\262\276\347\241\256\347\240\224\346\212\245\346\211\271\351\207\217\346\211\247\350\241\214\345\231\250.md"
index 72a58df..a91a210 100644
--- "a/dev-doc/project-doc/\346\205\247\345\215\232\347\262\276\347\241\256\347\240\224\346\212\245\346\211\271\351\207\217\346\211\247\350\241\214\345\231\250.md"
+++ "b/dev-doc/project-doc/\346\205\247\345\215\232\347\262\276\347\241\256\347\240\224\346\212\245\346\211\271\351\207\217\346\211\247\350\241\214\345\231\250.md"
@@ -10,7 +10,7 @@
采集员先人工选定 1–10 份精确研报,再用一个 JSON 和一条命令顺序调用现有 `hibor_fast_collection` CLI。执行器不做候选发现、评分或查询改写,也不重新实现 PDF、SHA-256、页数、额度、原件不变和 CreateNew 校验。
-代码:`dev/project-dev/hibor_batch_executor.py`
+代码:`dev/project-dev/hibor_batch_executor.py`
示例:`dev/project-dev/hibor_batch_example.json`
## 2. 单命令
@@ -42,6 +42,13 @@
| `runtime` | 既有 CLI 所需的额度账本、ADB、pdfinfo、设备和 UI 配置 |
| `items` | 1–10 个精确研报项 |
+`runtime.device_serial` 有两种安全用法:
+
+- 推荐显式填写当前设备,例如 `emulator-5554`。执行器会先运行 `adb devices -l`,只有该串号在线且状态为 `device` 才继续。
+- 可填写 `auto`。仅当 ADB 列表恰好包含一个记录且该记录状态为 `device` 时解析成功;0 个、多个、`offline` 或 `unauthorized` 均在创建批次目录和调用采集内核前失败。
+
+因此旧任务 JSON 中的 `127.0.0.1:21503` 不会再被默认复用;继续使用旧文件前必须只把 `runtime.device_serial` 改成 `emulator-5554`,或在确认只连接一个设备时改成 `auto`。`runtime.adb_executable` 可继续写 `adb`,也可由操作者在 JSON 中填写其当前安装位置;程序和说明不固定个人机器路径。
+
每个 `items[]`:
| 字段 | 必填 | 说明 |
@@ -64,6 +71,8 @@
- `normal_max_screens=1`
- `quantity=1`
- `expected_reports` 绑定标题、机构、日期和页数
+- `cache_root=/sdcard/Android/data/cn.com.hibor/files/myfile/`
+- `device_serial` 为本轮预检得到的唯一显式串号,不把 `auto` 透传给内核
## 4. 输出
@@ -100,6 +109,20 @@
测试使用本地桩 CLI,覆盖全部成功、单项失败后继续、部分成功、输入反例、CreateNew、批次 terminal 和 220 字符短路径;不连接 APP/ADB,不下载、不读写额度账本、不写正式归档。
-## 6. 边界
+设备预检测试另覆盖:显式串号、多设备中的显式精确匹配、`auto` 单设备成功、0/多设备失败、`offline`、`unauthorized`、串号缺失和 ADB 命令失败。所有失败都发生在批次输出目录创建之前。
+
+## 6. 新模拟器与可选共享通道
+
+当前已验证运行环境为:
+
+- ADB 设备:`emulator-5554`
+- APP:`cn.com.hibor`
+- 缓存:`/sdcard/Android/data/cn.com.hibor/files/myfile/`
+- 可选设备共享目录:`/sdcard/Pictures`
+- 当前工作站对应共享目录:`F:\hb`
+
+现有内核的 ADB 直接复制仍是默认和正式路径,本适配不增加共享复制实现或新 schema 字段。`/sdcard/Pictures` 与 `F:\hb` 仅作为已验证的可选人工传输通道;如采集员临时使用,仍须采用唯一暂存名、CreateNew,并逐段复核 bytes 与 SHA-256,不能覆盖或修改缓存原件。批量执行器不会把共享目录存在、任务已添加或文件名相同当作采集完成。
+
+## 7. 边界
执行器不包含自动发现、自动评分、查询改写、并发、重试状态机、数据库、服务、GUI、插件、调度、OCR/Markdown 或研报分析。访问控制、验证码、登录、订阅、额度、PDF 和正式原件安全继续以既有内核终态为准。
diff --git "a/dev-doc/project-doc/\346\234\254\345\234\260\350\247\206\351\242\221\350\257\255\351\237\263\350\275\254\345\206\231\345\267\245\345\205\267.md" "b/dev-doc/project-doc/\346\234\254\345\234\260\350\247\206\351\242\221\350\257\255\351\237\263\350\275\254\345\206\231\345\267\245\345\205\267.md"
index af9b875..92a8806 100644
--- "a/dev-doc/project-doc/\346\234\254\345\234\260\350\247\206\351\242\221\350\257\255\351\237\263\350\275\254\345\206\231\345\267\245\345\205\267.md"
+++ "b/dev-doc/project-doc/\346\234\254\345\234\260\350\247\206\351\242\221\350\257\255\351\237\263\350\275\254\345\206\231\345\267\245\345\205\267.md"
@@ -8,7 +8,7 @@
## 1. 事项与范围
-- 基线事项:`DEV-PROJECT-INFO-LOCAL-MEDIA-TRANSCRIBE-MVP-20260731-001`;长视频优化:`DEV-PROJECT-INFO-LOCAL-MEDIA-TRANSCRIBE-LONG-VIDEO-OPT-20260801-001`。
+- 基线事项:`DEV-PROJECT-INFO-LOCAL-MEDIA-TRANSCRIBE-MVP-20260731-001`;长视频优化:`DEV-PROJECT-INFO-LOCAL-MEDIA-TRANSCRIBE-LONG-VIDEO-OPT-20260801-001`;主机响应保护:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`。
- 入口:`dev/project-dev/transcribe_media.py`。
- 只处理一个本地视频的第一条音轨,输出 FLAC、TXT、SRT、JSON。
- 固定使用 faster-whisper `large-v3`、CUDA、`float16`、VAD;不提供 CPU 降级。
@@ -22,6 +22,13 @@
- 单任务只加载一次模型;未指定语言时首块检测,后续块固定复用;同时最多存在一个临时音频块,完成即删。
- 控制台逐块输出 `正在转写分块 i/n`;失败或用户中断仍清理当前块和相邻暂存目录。
+### 1.2 主机响应保护
+
+- Windows CLI 与 PPT 页面提取工具共用非阻塞 named mutex;同一时间只允许一个重型媒体任务。锁忙时立即明确拒绝,不加载模型、不创建暂存、不启动 FFprobe/FFmpeg。
+- 重型任务期间根 Python 和默认 FFprobe/FFmpeg 使用“低于正常”优先级,结束后恢复根进程原优先级并释放 mutex。
+- 音轨提取和长视频音频块命令固定最多 4 个 FFmpeg 解码线程,禁止采用默认自动线程数占满主机。
+- 这些保护不改变 large-v3、CUDA/float16/VAD、长视频分块或输出合同;GPU 转写仍不允许降级到 CPU。
+
## 2. 环境
当前已安装并自检的隔离环境:
diff --git "a/dev-doc/project-doc/\347\233\256\345\275\225\345\257\274\350\257\273.md" "b/dev-doc/project-doc/\347\233\256\345\275\225\345\257\274\350\257\273.md"
index e7423da..b533cbb 100644
--- "a/dev-doc/project-doc/\347\233\256\345\275\225\345\257\274\350\257\273.md"
+++ "b/dev-doc/project-doc/\347\233\256\345\275\225\345\257\274\350\257\273.md"
@@ -24,15 +24,29 @@
- 会议录屏 PPT 页面提取工具:`dev-doc/project-doc/会议录屏PPT页面提取工具.md`
- 股票估值端到端协调层 V2:`dev-doc/project-doc/股票估值端到端协调层V2.md`
- 股票估值 V2 实现与验收证据:`dev-doc/project-doc/股票估值端到端协调层V2实现与验收证据.md`
+- B 站博主动态采集辅助工具:`dev-doc/project-doc/B站博主动态采集辅助工具.md`
+- B 站已登录会话完整视频扩展入口:`dev-doc/project-doc/B站已登录会话完整视频扩展入口.md`
+- 股票估值每日台账:`dev-doc/project-doc/股票估值每日台账.md`
+- 股票估值每日台账实现与验收证据:`dev-doc/project-doc/股票估值每日台账实现与验收证据.md`
+- B 站动态采集 HOLD/5 最小修复设计:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md`
+- B 站动态采集 HOLD/5 终态重跑顺序限定修订:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V002.md`
+- B 站动态采集 HOLD/5 活动状态白名单最终限定:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003.md`
+- B 站动态采集 HOLD/5 修复实现与验证证据:`dev-doc/project-doc/B站博主动态采集辅助工具HOLD5修复实现与验证证据.md`
- 代码入口:`dev/project-dev/transcribe_media.py`
- 批量执行代码入口:`dev/project-dev/hibor_batch_executor.py`
- PPT 页面提取代码入口:`dev/project-dev/extract_ppt_slides.py`
- 股票估值 V2 代码入口:`dev/project-dev/stock_valuation_pipeline_v2/`
+- B 站动态采集代码入口:`dev/project-dev/bili_dynamic_collector.py`
+- B 站已登录会话扩展入口代码:`dev/project-dev/bili_authenticated_extension/`
+- 股票估值每日台账代码入口:`dev/project-dev/stock_valuation_ledger/`
- 环境入口:`dev/project-dev/environment.yml`
- 测试入口:`dev/project-dev/test/test_transcribe_media.py`
- 批量执行测试入口:`dev/project-dev/test/test_hibor_batch_executor.py`
- PPT 页面提取测试入口:`dev/project-dev/test/test_extract_ppt_slides.py`
- 股票估值 V2 测试入口:`dev/project-dev/test/stock_valuation_pipeline_v2/test_pipeline_v2.py`
+- B 站动态采集测试入口:`dev/project-dev/test/test_bili_dynamic_collector.py`
+- B 站已登录会话扩展入口测试:`dev/project-dev/test/bili_authenticated_extension/`
+- 股票估值每日台账测试入口:`dev/project-dev/test/stock_valuation_ledger/test_ledger.py`
- 批量执行示例:`dev/project-dev/hibor_batch_example.json`
## 3. 约束
@@ -48,5 +62,11 @@
| `DEV-PROJECT-INFO-HIBOR-BATCH-COORDINATOR-V1-20260801-001` | 慧博多主题批量协调器 V1 | 已由后继精确清单 MVP 取代;原 discovery blocker 不再阻塞后继范围 | `../开发问题记录.md` |
| `DEV-PROJECT-INFO-LOCAL-MEDIA-TRANSCRIBE-LONG-VIDEO-OPT-20260801-001` | 单视频 4 小时转写资源优化 | 请求方最终验收 `ACCEPTED_CLOSED`;代码与验收证据保留 | `本地视频语音转写工具.md`;`开发方案/CODE-DESIGN-PROJECT-INFO-LOCAL-MEDIA-TRANSCRIBE-LONG-VIDEO-OPT-V002.md` |
| `DEV-PROJECT-INFO-HIBOR-BATCH-EXECUTOR-MVP-20260801-001` | 慧博精确研报清单批量执行器 MVP | 请求方离线功能验收 PASS;待下一次已授权真实批次 SLA 验收 | `慧博精确研报批量执行器.md` |
-| `DEV-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-20260801-001` | 会议录屏 PPT 页面提取 MVP | 真实验收 HOLD/1;V003 主序列过滤方案待限定审核 | `会议录屏PPT页面提取工具.md`;`开发方案/CODE-DESIGN-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003.md` |
+| `DEV-PROJECT-INFO-HIBOR-NEW-EMULATOR-ADAPTER-20260806-001` | 慧博新模拟器最小运行时适配 | 请求方普通功能验收 `ACCEPTED_CLOSED`;后续正式批次可使用新设备预检 | `慧博精确研报批量执行器.md` |
+| `DEV-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-20260801-001` | 会议录屏 PPT 页面提取 MVP | 请求方最终验收 `ACCEPTED_CLOSED`;37 页自动输出与人工归档字节级一致 | `会议录屏PPT页面提取工具.md`;`开发方案/CODE-DESIGN-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003.md` |
+| `DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001` | 两个本地媒体 CLI 的主机响应保护最小修复 | sampler-only 复审 `PASS/0`;唯一 r2 target/sampler exit=0,采样/CPU/资源三门禁 PASS,NVDEC decoder 100%,待请求方最终只读验收;r1 FAIL 证据保留 | `开发方案/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002.md` |
| `DEV-ANA-STOCK-VALUATION-PIPELINE-V2-20260801-001` | project 级股票估值端到端协调层 V2;只读复用 ana V1 内核 | 请求方最终验收 `ACCEPTED_CLOSED`;V003—V007 方案、实现和操作手册确认均 PASS/0,V2 59/59、V1 10/10,保留 real-smoke-006 历史股本诚实 BLOCKED 限制 | `股票估值端到端协调层V2.md`;`股票估值端到端协调层V2实现与验收证据.md`;`开发方案/CODE-DESIGN-PROJECT-INFO-STOCK-VALUATION-PIPELINE-V2-V007.md` |
+| `DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001` | 无认证动态导出、本地去重/manifest、安全视频移动和 canonical handoff | `ACCEPTED_CLOSED`;限定实现复审 PASS/0,请求方已接收,后续新缺陷另行回传 | `B站博主动态采集辅助工具.md`;`B站博主动态采集辅助工具HOLD5修复实现与验证证据.md` |
+| `DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001` | 精确 BVID 的已登录 Chrome side panel、Native Messaging、内存 Cookie 和完整 MKV 安全入口 | exact build hash-only 确认 `PASS/0`,树外 install-exact-build approval 已形成;实际 install/HKCU 与真实认证/下载须另行明确调度,当前均未执行 | `B站已登录会话完整视频扩展入口.md`;`开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002.md` |
+| `DEV-PROJECT-INFO-VIDEO-TEXT-HOST-WRITE-EOF-STABILITY-20260806-001` | VideoTextHost `write EOF` 弹窗来源只读核对 | `EVIDENCE_AWAITING_NO_PRODUCT_MUTATION`;active host 因果声明已撤回,无 patch/build/install;仅复发且上游提供最小进程身份证据时做一次只读核对,不再阻断认证下载发布 | `视频转文字助手EOF稳定性修复.md` |
+| `DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001` | MySQL 股票估值每日台账:正式估值版本、只读 trading_xuntou 前复权专表完整日收盘价、V1 四档判定与 latest 成对发布 | `ACCEPTED_CLOSED`;最终审核 PASS/0。审核基线 125/136/124/124;审后 live 估值版本 183/194,daily/latest 保持 124 行并等待权威日历水位更新 | `股票估值每日台账.md`;`股票估值每日台账实现与验收证据.md` |
diff --git "a/dev-doc/project-doc/\350\247\206\351\242\221\350\275\254\346\226\207\345\255\227\345\212\251\346\211\213EOF\347\250\263\345\256\232\346\200\247\344\277\256\345\244\215.md" "b/dev-doc/project-doc/\350\247\206\351\242\221\350\275\254\346\226\207\345\255\227\345\212\251\346\211\213EOF\347\250\263\345\256\232\346\200\247\344\277\256\345\244\215.md"
new file mode 100644
index 0000000..29042e4
--- /dev/null
+++ "b/dev-doc/project-doc/\350\247\206\351\242\221\350\275\254\346\226\207\345\255\227\345\212\251\346\211\213EOF\347\250\263\345\256\232\346\200\247\344\277\256\345\244\215.md"
@@ -0,0 +1,63 @@
+# 视频转文字助手 EOF 稳定性修复
+
+## 一、当前结论
+
+`DEV-PROJECT-INFO-VIDEO-TEXT-HOST-WRITE-EOF-STABILITY-20260806-001` 已在只读来源映射阶段停止代码替换。已安装 Windows Native Messaging host 与上游 v1.0.5 `host_win.py` 的归一化字节码指纹完全一致,不是弹窗堆栈指向的 Node `host.cjs`。项目管理员已接受该反证并撤回“已安装 Python host 导致弹窗”的因果声明。因此,不修改 active Python host,不宣称已消除现场弹窗。
+
+事项当前仅保留为 `EVIDENCE_AWAITING_NO_PRODUCT_MUTATION`:如弹窗再次出现且现场提供 PID、父 PID、可执行文件路径或等价 WER 记录,才进行一次有界只读身份核对。不构建、不安装、不替换,不建立常驻监视器;本事项不再阻断认证下载运行时发布。
+
+## 二、只读来源证据
+
+- Registry: `HKCU\Software\Google\Chrome\NativeMessagingHosts\com.video_text.transcriber` 指向 `C:\Users\Cai\AppData\Roaming\VideoTextHost\manifest.json`。
+- Native host manifest: `275 / 4CC85582F0BFD45114E081F7FAB8E8AC2B255A868F3412806943095D8D50F705`;`path` 为 `host-win.bat`,唯一 `allowed_origins` 为 `chrome-extension://ikdcnnpejkfnpakmmddnfemenokhnmoc/`。
+- `host-win.bat`: `38 / 18F1C7B231E853756CC1492EE4C0920FDE6B2A3196C76866AE94171A0B8A58EF`,唯一动作是启动同目录 `native-host.exe`。
+- Active `native-host.exe`: `1943105 / BEAE2752888EFB468A5C38FC0685F6FE74F42D8BC9CE1C54432DF227BA61818F`。
+- Active `video-text-transcriber.exe`: `15996098 / EA54C72B29406299A0FE12E2DDA9F5E63EBDF340330E6BB96F4664BC34FB6C08`。
+- Chrome 扩展 `1.0.5_0/manifest.json`: `1683 / B8C03B5CA688E5DEFAD632A13831465B2202116C4AF0CBE4B54945B913508873`。
+- 上游 annotated tag `v1.0.5` 的 tag object 为 `6c386da41865726f7b731553b820aeff2a5fd2ef`,解引用后的 full commit 为 `537f424866fd3736f5845224208e3dcc67352b70`。
+- 该 commit 的源码归档: `2702422 / 411FEE86FC4A78CD864FB8966003FCAA60271C962A3E859786D8454C3A4DDC2A`。
+- 上游 `native-host/host_win.py`: `7094 / FB1D24F7BE5C299D73972B1B68EE9FCDD4F77D5F5DC7BEA6B2BDB97F2B10ECB8`。
+- 上游 `native-host/host.cjs`: `7814 / 8A88D3A95D52D2144CD58C7F0241E598793F4D3960AD1FE12FFFE98E6506F7DC`。
+
+## 三、active binary 到源码的精确映射
+
+1. PyInstaller CArchive 只读目录显示 active `native-host.exe` 的业务入口为 `host_win`,不包含 `host.cjs`。
+2. 从 active EXE 只读提取的 `host_win` marshal code object 为 `11476 / 054750D6C70986748124895D6AC257DFAB01AFE16C96FF61F6BB1DF27A9EFC54`。
+3. 使用 Python 3.11 分别对 active code object 和固定 commit 源码编译结果递归指纹;忽略绝对文件名与行号,保留 bytecode、constants、names、variables、flags 和 exception table。
+4. 两者指纹均为 `DB5F1CE9DE4695B9D72F1060D16BA70C0979C85A724D83BC9FC4B052EF54D753`,结果 `match=True`。
+
+这一映射比版本号或文件时间更强:active EXE 实际执行的就是固定 commit 中的 `host_win.py`。
+
+## 四、不能忽略的矛盾
+
+- 现场截图显示 `A JavaScript error occurred in the main process / Error: write EOF / WriteWrap.onWriteComplete / node:internal/stream_base_commons`。
+- Active Windows host 是 PyInstaller/Python `host_win.py`;其 `send_message()` 已把 header、payload 和 flush 包在一个 `try/except Exception` 内,不会产生 Node `WriteWrap` 堆栈。
+- Active transcriber 同样是 PyInstaller/Python `mini_transcriber`。已安装 `node.exe` 的产品元数据是标准 Node.js 20.11.1,安装树中没有 Electron/app.asar/active `host.cjs`。
+- 上游 `host.cjs` 确有两次未等待回调的 `process.stdout.write`,但上游 Windows build spec 和 active EXE 都不执行它。
+
+因此当前唯一阻断是:**弹窗时实际抛出 Node/Electron `write EOF` 的进程及可执行文件尚未定位,已验证的 active legacy host 不是该进程。**
+
+## 五、需要的单一补充证据
+
+下次弹窗时,由管理员或请求方只读记录弹窗所属 PID、父 PID 与可执行文件绝对路径(或提供包含 faulting application path 的 WER/Application Error 记录)。不需要、也不得收集 Cookie、token、签名 URL、命令行或媒体内容。
+
+只有当该路径与可审计源码/构建物建立不可变映射后,才恢复最小补丁、50 轮门禁、构建和审核。
+
+## 六、外部动作边界
+
+- installed root/HKCU/Chrome/extension 修改=`0`
+- 真实 Cookie/session/secret log 读取=`0`
+- 已安装二进制执行=`0`
+- 网络媒体/真实下载=`0`
+- `F:\video`/formal `ana-data`/转写=`0`
+- 补丁/构建/安装=`0`
+
+## 七、管理员范围纠正
+
+- 回执:`HANDOFF-INFOADMIN-INFODEV-VIDEO-TEXT-HOST-WRITE-EOF-SCOPE-CORRECTION-20260806-001`
+- 接受:原 exact provenance 证据、源码指纹和零产品变更记录全部保留。
+- 撤回:“active Python host 是弹窗来源”的因果声明。
+- 终止:active host 的 patch/build/install/replace;不进入开发审核或安装授权链。
+- 保留:仅弹窗真实复发且上游提供最小进程身份证据时,进行一次有界只读核对。
+- 禁止:常驻 watcher、命令行捕获、Cookie/token/session/签名 URL、媒体和下载动作。
+- 关系:本事项不再是 authenticated-download release blocker。
diff --git "a/dev-doc/\345\274\200\345\217\221\344\272\213\351\241\271\346\200\273\347\272\262.md" "b/dev-doc/\345\274\200\345\217\221\344\272\213\351\241\271\346\200\273\347\272\262.md"
index e3c59fd..f13f4bd 100644
--- "a/dev-doc/\345\274\200\345\217\221\344\272\213\351\241\271\346\200\273\347\272\262.md"
+++ "b/dev-doc/\345\274\200\345\217\221\344\272\213\351\241\271\346\200\273\347\272\262.md"
@@ -685,7 +685,15 @@
- 请求方真实验收:`msg_20260801214538194_7196a0fa=HOLD/1`;明确 `slide_001` 与 `slide_039..053` 不属于主课件,要求只保留原 `slide_002..038` 并重新连续编号。
- 同事项需求澄清:全屏人物/普通直播镜头且无 PPT 主体必须排除;PPT 上小型人像窗口保留。V003 仅做主课件序列选取,不增加人像识别、裁剪或语义模型;`ana-data/news-小郑/` 的旧 53 页 PDF 不覆盖。
- V003:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003.md`;只增加紧凑颜色主题的确定性主序列过滤,无合格强主题时不裁页,全部既有安全和范围合同冻结。
-- 当前状态:`PENDING_V003_LIMITED_DESIGN_REVIEW`;先复用 53 张候选离线证明,再做限定实现审核,PASS 后完整视频最多重跑一次。
+- V003 限定方案审核:`DEV-AUDIT-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-DESIGN-20260803-001=PASS/0`;审计入口=`156643 bytes / 5B33406A04CA3FC006A296979288C47E18F4F25F34F77CB9B9F3C75D05EB1CCB`,授权仅实施代码、离线测试和既有 53 张 PNG 回放。
+- V003 实现加入 512 项 RGB 颜色直方图和确定性 `select_main_sequence`;目标测试=`29 discovered / 26 PASS / 3 SKIP`,project 回归=`48 discovered / 45 PASS / 3 SKIP`;3 项均因离线门禁主动移除 PATH 后缺少 FFmpeg,py_compile 与 CLI help PASS。
+- 53 页只读回放自动选中原 `slide_002..038`,输出独立 37 张连续编号 PNG、37 页 PDF 和联系表;PDF 逐页 RGB/顺序、Poppler 页数/尺寸及首中末视觉抽查 PASS。源视频读取=`0`,FFprobe/FFmpeg=`0`,旧 53 页证据和人工归档指纹未变。
+- V003 限定实现审核:`DEV-AUDIT-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-IMPLEMENTATION-20260803-001=PASS/0`;审计入口=`163418 bytes / 2DB0AD6B7440CF0589ABF973B938A8CFA636616D879043A48F1C1B02523C1F50`,授权完整源视频最多运行一次。
+- 唯一完整复验:有效退出=`0`,工具/外层耗时=`1108.593/1109.297 s`,候选=`53`、主课件=`37`;37 张新 PNG 与旧 `002..038` 逐一字节相同,37 页 PDF 逐页 RGB/顺序和联系表视觉检查 PASS。
+- 资源与安全:288 个全程进程树样本,峰值工作集=`351191040 bytes / 334.922 MiB`,首末 20 样本峰值差=`7614464 bytes`;源四项指纹、旧 53 页证据、人工归档均未变,stderr=`0`、暂存残留=`0`。
+- 请求方最终验收:`HANDOFF-MEDIA-INFODEV-MEETING-SLIDE-V003-REQUESTER-FINAL-ACCEPTANCE-20260803-001=ACCEPTED_CLOSED`;37 页联系表、首/中/末原图、连续命名、逐页哈希、PDF 和源视频保护全部通过只读复核。
+- 归档结论:现有人工归档 PDF 与新自动输出均为 `58570040 bytes / 71C508E94852FBE36AA49DA1B17DDD3F634A82463A06E8AB029709517BB68535`,无需替换;最终验收未运行媒体、未修改代码、未写归档。
+- 当前状态:`ACCEPTED_CLOSED`;唯一完整运行次数已用尽,保留代码、文档、证据和现有归档,不再运行、重试、调参或扩展。
## DEV-ANA-STOCK-VALUATION-PIPELINE-V1-20260801-001
@@ -729,3 +737,325 @@
- 独立审核:最终实现复审与管理员授权后的操作手册单文件确认均为 `PASS/0`;审计入口 `dev-doc/开发审计报告.md`。
- 请求方只读核对 code/test/fixture/tool_doc/evidence/audit/manual、终态、receipt schema 2、QA、哈希与治理均通过;明确不要求网络、fixture、V2/V1 或 smoke 重跑。
- 当前状态:`ACCEPTED_CLOSED`;代码、测试、fixture、文档、失败限制和证据链保留。除非收到新的明确事项,不再追加功能、联网重跑或扩展范围。
+
+## DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001
+
+- 来源:`HANDOFF-MEDIA-INFODEV-MEDIA-HOST-RESPONSIVENESS-20260804-001`;请求方=`case_analysis.media_processor`;状态=`AUTHORIZED_MINIMAL_IMPLEMENTATION`。
+- 目标:为现有 PPT 页面提取和本地视频转写 CLI 增加 RTX 3090 NVDEC 优先、受限 CPU 回退、显式 FFmpeg 线程上限、Windows BelowNormal 优先级及跨进程单任务互斥。
+- 根因:2026-08-03 PPT 完整运行的扫描命令没有硬件解码、线程上限或子进程优先级,长时间软件解码会占用默认调度资源;页面和转写结果本身已验收,不在本事项重做。
+- 冻结:两套公开 CLI、PNG/PDF、FLAC/TXT/SRT/JSON、页面过滤、时间轴、源保护、原子提交和失败清理不变;不新增服务、数据库、API、GUI、批处理调度、OCR、人像处理、模型或产品模块。
+- 方案:V001 独立审核=`HOLD/3`;V002 入口为 `dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002.md`,只修订硬件失败归因边界、H.264/H.265 业务等价合成门禁及非空时间采样合同;产品仍只修改两个现有脚本,另有必要测试和只读资源采样 helper。
+- 验收:V002 方案复审=`PASS/0`;首次实现审核=`HOLD/5`,F1—F5 复审=`HOLD/2`,F3-R1/F4-R1 复审=`HOLD/1`,最终 F3-R1-R1-only 复审=`PASS/0`。按唯一授权生成 `593.120 s` 独立片段并运行一次:目标 exit=`0`、7 页输出完整,扫描 argv 为 CUDA/`h264_cuvid`,GPU decoder 峰值=`100%`,有效扫描整机 CPU 峰值=`18.206%`、5 秒加权最高=`13.064%`;但采样器因首两个 5 秒窗仅 9 成员、扫描后逐页 FFmpeg CIM 竞态产生 6 个错误和终态日志证据不一致,返回 exit=`3`、`sampling_valid=false`、`cpu_gate_pass=false`。
+- 后续授权:请求方以 `HANDOFF-MEDIA-INFODEV-MEDIA-HOST-RESPONSIVENESS-SAMPLER-REPAIR-AUTH-20260804-001` 确认 r1 FAIL,并只授权采样器/桩/必要说明修复。现已闭环首窗密度、短命 FFmpeg CIM 消失竞态、失败样本 previous/interval 提交一致性及 PowerShell 5 NVDEC 日志编码;无媒体桩连续 `3/3`、目标 `56/56`、project `63/63` PASS,两个产品脚本哈希未变。
+- r2:sampler-only 独立复审=`PASS/0` 后,仅复用既有 593.120 秒片段运行一次独立 r2。target/sampler exit=`0/0`,`sampling_valid/cpu_gate_pass/resource_acceptance_pass=true/true/true`;63 个完整 5 秒窗、最大间隔 `0.515724 s`、最差加权 CPU `11.546972%`、高 CPU 段 0;NVDEC argv/日志、decoder 100%、BelowNormal、源保护与 7 页输出全部 PASS。
+- 请求方最终验收:`HANDOFF-MEDIA-INFODEV-MEDIA-HOST-RESPONSIVENESS-R2-FINAL-ACCEPTANCE-CLOSURE-20260804-001 / ACCEPTED_CLOSED`;请求方独立复算 84 条样本、63 个完整窗口、最差 5 秒 CPU `11.546972%`、高 CPU 段 `0 s`,并核对 NVDEC/BelowNormal、7 页输出、源与片段保护、r1 历史 FAIL 保护全部通过。
+- 当前状态:`ACCEPTED_CLOSED`;r1 FAIL 与 r2 PASS 证据均冻结保留,r2 次数=`1/1`,不得再运行、重试、调参、重新截片、修改实现、并行转写或改跑长视频。
+
+## DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001
+
+- 来源交接:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;handoff=`development_request`;请求方=`case_analysis.video_downloader`;Codex task=`019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5`。
+- 需求入口:`ai-video-downloader/draft/2026-08-04-哔哩哔哩动态采集需求.md`;首个博主=`青枫浦上Q`。
+- 目标:在 `dev/project-dev/` 落地单一 stdlib 本地辅助 CLI,读取人工或浏览器扩展已导出的无认证元数据,完成最近时间窗筛选、稳定标识去重、append-only manifest、Windows 安全命名、已完成视频安全移动、视频处理员交接摘要和定时检查入口。
+- 分类:有明确需求文档的轻量 MVP;一个代码入口、一个示例配置、一个测试文件和一页用法,不联网、不并发、不控制浏览器、不下载内容,不创建重型编码方案。
+- 安全边界:不得读取或保存账号口令、cookie、token、验证码或浏览器会话;不绕过登录、验证码、会员、DRM 或访问控制;目标不唯一、下载未完成、文件越界、链接/reparse、重名或可能覆盖时必须停止。
+- 写入边界:开发与验收仅使用 `dev/tmp/` 或测试临时目录;正式 `F:\video\<博主名>\` 与 `ana-data/news-<博主名>/` 授权扩展完成前不得写入,且不得修改既有正式采集内容或无关文件。
+- owner=`dev.developer.project.secondary / infodev-2`;reviewer=`dev.reviewer.project / inforev`;审计入口=`dev-doc/开发审计报告.md`。
+- 当前状态:`IMPLEMENTING_LIGHTWEIGHT_MVP`;完成离线自测与需求对照后提交共享审核员轻量实现审核。
+
+### 2026-08-04T22:18:49+08:00 轻量 MVP 实现与自测完成
+
+- 代码、示例配置、脱敏 fixture、6 项合同测试、工具说明和开发 tmp 证据已完成;未引入网络、浏览器控制、认证材料或正式采集写入。
+- 目标测试=`6/6 PASS`;project flat discover=`69/69 PASS`;CLI 离线闭环验证 check → safe move → unsent handoff → repeat check,重复输入不再生成待办。
+- 证据入口:`dev/tmp/bili-dynamic-collector-mvp/selfcheck.md`;工作记录:`ai-infodev-2/worklog/2026-08-04-bili-dynamic-collector-mvp.md`。
+- 当前状态:`PENDING_SHARED_REVIEWER_LIGHT_IMPLEMENTATION_REVIEW`。
+
+### 2026-08-04T22:53:23+08:00 独立安全审查终态 HOLD/5
+
+- 独立审查回执:`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-REVIEW-RESULT-20260804-001`;审计入口=`dev-doc/开发审计报告.md`。
+- 结论:离线测试和基础 check/命名/去重/no-overwrite happy path 有效,但重型分类门禁、映射全一致/同源批量预检、retained-source 重跑告警、manifest 秘密 gate 和 canonical native handoff 共 5 项阻断,不能作为已验收可用采集器。
+- 安全边界:只保留源码、文档、fixture、自检和测试作为隔离开发快照;仅允许在 dev/tmp/TemporaryDirectory 对可信无秘密输入演示,不得真实移动、生产调度、发送当前交接草稿或写正式路径。
+- 按请求方即时收尾指令,不开启后继设计、返修复审或外部动作。当前状态:`TERMINAL_HOLD_DEVELOPMENT_SNAPSHOT_ONLY`。
+
+### 2026-08-04T23:19:20+08:00 请求方恢复 HOLD/5 最小修复
+
+- 新授权:请求方在终态交付后明确“继续推进”,只允许修复前序 F1—F5,并要求同一审核员一次限定复审。
+- 分类纠正:事项正式升级为有需求文档的重型实现;当前代码/测试继续冻结,先补 `CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md` 并取得方案 PASS。
+- 修复边界:selector/同源批次预检、retained-source 重跑、manifest 秘密 gate、canonical native handoff;不新增网络、浏览器、下载或正式路径能力。
+- 当前状态:`V001_DESIGN_PENDING_INDEPENDENT_REVIEW_IMPLEMENTATION_FROZEN`。
+
+### 2026-08-05T00:35:00+08:00 HOLD/5 修复实现完成待限定复审
+
+- 重型方案链:V001 `HOLD/1` → V002 `HOLD/1` → V003 最终限定复审 `PASS/0`;V001+V002+V003 为唯一实施合同。
+- 五项实现:全 selector 精确一致与全批 entity/source/target 预检;retained-source 告警跨重跑粘性;manifest 共同 secret gate;完整稳定 canonical native handoff;原重型状态/事务门禁闭环。
+- 验证:py_compile/CLI help PASS;目标=`11/11 PASS / 1.393s`;新鲜 TemporaryDirectory 闭环=`1/1 PASS / 0.226s`;project discover=`74/74 PASS / 48.657s`。
+- 证据:`dev-doc/project-doc/B站博主动态采集辅助工具HOLD5修复实现与验证证据.md`;`dev/tmp/bili-dynamic-collector-mvp-hold5-repair-20260805/selfcheck.md`。
+- 外部动作继续为 0;当前状态=`PENDING_LIMITED_IMPLEMENTATION_REREVIEW`,复审 PASS 前不作正式使用声明。
+
+### 2026-08-05T01:05:00+08:00 限定实现复审 HOLD/2
+
+- 回执=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-IMPLEMENTATION-REVIEW-RESULT-20260805-001`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-IMPLEMENTATION-20260805-001`。
+- 独立正向复验:目标 11/11、新鲜闭环 1/1、project 74/74、py_compile/CLI/diff/UTF-8/治理全部 PASS;前序五项主路径除下述 fail-closed 边界外均冻结通过。
+- 剩余阻断 2:终态缺少持久目标/hash/retained 失败原因仍可报成功;manifest 未校验标量可用换行注入 native handoff。
+- 按本轮“PASS/0 或明确剩余阻断返回”约束,不自动开启第二轮修复;当前=`HOLD_2_ISOLATED_DEVELOPMENT_SNAPSHOT_ONLY`,继续禁止真实移动、正式 handoff、调度和正式路径。
+
+### 2026-08-05T09:20:00+08:00 HOLD/2 持续授权返修完成
+
+- 请求方明确不接受停在 HOLD/2,授权只修 terminal 持久证据 fail-closed 与 handoff manifest 标量注入,并持续复审至 PASS/0 或外部阻断。
+- 实现:视频 terminal 必需 local_file/64-lower-hex SHA-256/retained failure_reason 共同校验;handoff entity/BV/canonical time/title/URL/path/hash 全标量控制字符与格式校验;异常统一 `E_MANIFEST`/exit 3 且不回显污染值。
+- 验证:目标=`13/13 PASS / 2.294s`;新鲜闭环=`1/1 PASS / 0.227s`;project=`76/76 PASS / 61.424s`;fixture 未修改,外部动作继续 0。
+- 当前=`PENDING_HOLD2_LIMITED_IMPLEMENTATION_REREVIEW`;PASS 前仍不进入真实移动、正式 handoff 或调度。
+
+### 2026-08-05T15:36:35+08:00 HOLD/2 fail-closed 限定复审 PASS/0
+
+- 回执=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-REREVIEW-RESULT-20260805-001`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-LIMITED-IMPLEMENTATION-REREVIEW-20260805-001`。
+- F1 terminal 持久证据 fail-closed 与 F2 native handoff manifest 标量防注入均 CLOSED;前序冻结合同未回退,阻断数=`0`。
+- 独立复验:原阻断反例 2/2、目标 13/13、project 76/76 PASS;py_compile、CLI help、diff、UTF-8/no BOM、治理均 PASS。
+- 当前状态=`PASS_0_PROJECT_TOOL_DELIVERABLE`;冻结实现可交付为项目级本地辅助工具。真实网络、浏览器、下载、自动 native send、调度、`F:\video` 和正式 `ana-data` 未获本结论授权。
+
+### 2026-08-05 请求方接收关闭
+
+- 请求方回执=`HANDOFF-VIDEO-DOWNLOADER-INFODEV2-BILI-DYNAMIC-COLLECTOR-PASS0-ACK-20260805-001 / ACCEPTED`;关联最终交付=`HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-DYNAMIC-COLLECTOR-MVP-PASS0-FINAL-20260805-001`。
+- 请求方确认 PASS/0 工具、两项 fail-closed 闭环和独立复审证据完整;本开发事项状态更新为 `ACCEPTED_CLOSED`,无需继续开发。
+- 请求方同时确认当前博主的真实浏览器、下载、`F:\video\青枫浦上Q`、正式 `ana-data`、原生转写交接和每小时调度已有用户与项目配置授权;这些外部动作由视频下载员消费,开发工具不作为授权来源。
+- 后续如发现新缺陷,由视频下载员另行回传并形成新开发回合。
+
+## DEV-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-20260805-001
+
+- 上游事项:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;请求方=`case_analysis.video_downloader`;开发员=`dev.developer.project`。
+- 目标:为 `BV1DVMX6XEPq`、`BV1HA3o6oEJJ` 提供一个不读取 Chrome/扩展认证秘密的完整视频下载闭环,供视频下载员把真实原件写入已授权的 `F:\video\青枫浦上Q`。
+- 最小范围:一个 stdlib CLI、一个双目标示例 JSON、目标单元/桩测试和一页用法;不修改 secondary 的动态采集器,不写正式视频或 `ana-data`,不增加浏览器插件、服务、数据库、GUI、调度或转写功能。
+- 安全合同:只接受无查询参数的规范 Bilibili URL;yt-dlp 忽略配置并显式禁用文件/浏览器 Cookie 和缓存;不使用人工签名 m4s 直链;暂存与正式目录同卷;ffprobe 必须确认正时长、视频流和音频流;媒体及来源映射按 CreateNew/no-overwrite 发布并记录 bytes/SHA-256;失败清理暂存,单项失败后继续并返回非零。
+- 现场结论:Chrome 普通页面可读而复杂视频页控制超时;“视频转文字助手”公开实现只有 `bestaudio/best → MP3 → TXT`,不能产生完整视频。yt-dlp 2026.3.17 对两条无 Cookie 探测均 HTTP 412,升级 2026.7.4 后两条无下载探测均 PASS。
+- 当前状态:`PENDING_SHARED_REVIEWER_LIGHT_IMPLEMENTATION_REVIEW`;开发线程正式视频/正式 `ana-data` 写入=`0`,真实下载由请求方在交付通过后执行。
+
+### 2026-08-05T17:04:33+08:00 轻量审核运行时阻断
+
+- 审核请求与补充证据已可靠写入 `dev.reviewer.project` inbox,但托管会话旧 rollout 缺少 session metadata,定向 `mbx agent step` 也在读取审核线程前失败;未形成审核结论,不是代码/测试阻断。
+- 管理恢复请求=`msg_20260805170128438_316f3ac7`;保留审核 inbox/queue,不越权清理或修复审核员会话。
+- 工具已完成,目标 11/11、project 87/87、无下载探测 2/2 PASS。按上游轻量交付约定转请求方普通功能验收;审核请求保留作附加代码质量门禁,不虚报 PASS。
+- 当前状态=`READY_REQUESTER_FUNCTIONAL_ACCEPTANCE_REVIEW_RUNTIME_BLOCKED`;开发线程仍不执行真实下载。
+
+### 2026-08-05T17:32:45+08:00 真实验收预览误发布阻断修复
+
+- 请求方发现 `BV1HA3o6oEJJ` 远端 `3133.95 s`、本地 `600.133313 s` 的会员预览被旧实现误标 COMPLETE;错误产物由下载员可恢复隔离且未转写。第一条 `1206.067664 vs 1206.067 s` 一致并已正式交接,本修复不触碰。
+- 旧交付立即撤回。新实现发布前强制远端/本地时长差不超过 `max(3.0 s, remote × 0.1%)`;否则按预览/错误分 P 失败且零正式发布。映射新增 remote/local/delta/tolerance 证据。
+- `probe` 成功状态改为 `METADATA_PASS_ONLY`;播放列表/多视频结果及明确认证/会员状态 fail-closed,不再把元数据可读误作完整下载能力。
+- 修复验证:目标=`15/15 PASS`;project=`91/91 PASS`;真实再下载=`0`、正式/隔离目录读写=`0`。当前状态=`PENDING_DURATION_FAIL_CLOSED_INDEPENDENT_REVIEW`,复审 PASS 前不重新交付。
+
+## DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001
+
+- 指派=`HANDOFF-INFOADMIN-INFODEV2-STOCK-VALUATION-LEDGER-MVP-ASSIGN-20260805-001`;请求方=`case_analysis.analyst.valuation`;单一 owner=`dev.developer.project.secondary / infodev-2`;共享审核员=`dev.reviewer.project / inforev`。
+- 权威需求=`ana-doc/股票估值/股票估值每日台账业务需求_v1.0.md`;目标是把正式估值版本、每日完整交易日收盘价和 V1 四档判定写入本地 SQLite,并生成 `latest.md/latest.csv`。
+- 固定落点:代码=`dev/project-dev/stock_valuation_ledger/`;测试=`dev/project-dev/test/stock_valuation_ledger/`;运行库=`ana-data/result/股票估值/估值台账/stock_valuation.sqlite3`;工程说明=`dev-doc/project-doc/股票估值每日台账.md`。
+- 复用边界:`dev/ana-dev/stock_valuation_pipeline` 全部只读;台账不复制估值公式,判定映射与 V1 conclusion position 做合同对照;真实行情通过 project 级 V2 `acquire_market` 适配器取值,不另写 provider 解析。
+- 实施口径:管理员与权威需求明确按单一 MVP 直接实施,不另开需求、架构或重型方案审核链;schema、输入、失败、幂等、性能和验收合同以权威需求及本事项计划唯一化,完成后只提交一次必要实现/输出审核。
+- 禁止:ORM、Web/API/服务、多用户权限、消息队列、付费源、盘中估值、重大事件监控、交易指令、修改 `ana-dev`、开发期间自动安装计划任务。
+- 当前状态=`IMPLEMENTING_SINGLE_OWNER_MVP`。
+
+### 2026-08-05T18:32:58+08:00 Bilibili 下载桥时长门禁限定复审 PASS/0 与交付
+
+- 事项=`DEV-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-20260805-001`;复审回执=`HANDOFF-INFOREV-INFODEV-BILI-BRIDGE-DURATION-GATE-REREVIEW-RESULT-20260805-001 / PASS`;阻断数=`0`。
+- 管理恢复前序=`HANDOFF-INFOADMIN-INFOREV-BILI-BRIDGE-DURATION-GATE-REREVIEW-RECOVERY-20260805-001`;原审核运行时阻断已由正式角色链恢复,不再影响本次限定复审结论。
+- 冻结实现=`22229/541CD9A57E23976614B80CF731C6D4D49361A29BFBC029E9053C8E7093EC18F1`;冻结测试=`15416/BDE24A5BE26BC10F534EA1BF7235B8066EDA6425BB432C06AC67BBD560EFA168`;目标测试 `15/15 PASS`,项目回归 `91/91 PASS`。
+- 结论:`3133.95 s` 远端对 `600.133313 s` 本地预览在发布前 fail-closed,正式输出为 0 且暂存清理;`1206.067 s` 对 `1206.067664 s` 通过。容差固定为 `max(3 s, remote*0.1%)`;多 P/播放列表及认证、会员、私有、订阅限制均 fail-closed;probe 只表示 `METADATA_PASS_ONLY`。
+- 已通过 MB-X 原角色链向 `case_analysis.video_downloader` 交付,消息=`msg_20260805183258435_048db111`;可靠 inbox 写入成功,可见调度因目标无可用可见会话而排队,不影响消息投递。
+- 路径勘误:首条交付文本误写了两个文件名;纠正消息=`msg_20260805183550141_a01a1463` 已可靠入箱。实际冻结路径为 `dev/project-dev/bili_video_download_bridge.py` 和 `dev/project-dev/test/test_bili_video_download_bridge.py`,本地只读 bytes/SHA-256 与复审证据完全匹配,其他结论不变。
+- 边界:600 秒预览继续隔离且禁止转写;第一条已验收视频未改变;本 PASS 与交付不新增联网、Chrome、扩展、下载、`F:\video`、正式 `ana-data`、调度或凭据访问授权。当前状态=`PASS_0_REPAIRED_SNAPSHOT_DELIVERED_PENDING_REQUESTER_ACK`。
+
+### 2026-08-05T18:47:00+08:00 股票估值每日台账 MVP 实现冻结
+
+- 代码=`dev/project-dev/stock_valuation_ledger/`;测试=`dev/project-dev/test/stock_valuation_ledger/`;工程说明与证据=`dev-doc/project-doc/股票估值每日台账.md`、`股票估值每日台账实现与验收证据.md`。
+- 正式 83 家首验收=`83/83`、重复导入 `0新增/83不变`、缺口 `0`;离线完整日更生成 `83 price + 83 judgement` 且四标签全覆盖。
+- 全量正式 `init`=`125 security / 136 valuation_version / 1 deterministic duplicate gap`;未联网,正式 `daily_price/daily_judgement=0`,不以测试价冒充最新价。
+- 验证:目标 `15/15 PASS`;project `106/106 PASS`;V1 只读 `10/10 PASS`;V2 只读 `59/59 PASS`;83 冷/热导入 `0.7603499s/0.7317935s`;200 家暖查询断言 `<1s`。
+- 当前状态=`PENDING_SINGLE_IMPLEMENTATION_OUTPUT_REVIEW`;共享审核员=`dev.reviewer.project / inforev`。未修改 `ana-dev`、未安装计划任务、未新增 provider 或交易能力。
+
+## DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001
+
+- 来源=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;请求方=`case_analysis.video_downloader`;关联只读调查=`DEV-PROJECT-INFO-BILI-EXTENSION-CONTROL-PATH-INVESTIGATION-20260805-001`;owner=`dev.developer.project`。secondary 已明确不接管、不操作 Chrome、不修改本事项文件。
+- 目标:在不读取、导出、回显、持久化或记录 Cookie/token/password/验证码、不使用或记录签名媒体 URL的边界下,为已登录 Chrome 授权会话产生的完整本地媒体提供最小自动接管后半程。
+- 已验证控制面:Chrome 中目标可完整播放 `52:14` 且显示“充电中”,但 `locator.downloadMedia` 无下载事件、`cua_download_media` 不受 Chrome 支持、页面资产无完整媒体;扩展 1.0.5 只有 side panel,按钮链为“添加任务 → 进行中/重试 → 已完成 → 下载 TXT”,后台固定 `bestaudio/best`、转 MP3、最终只导出 TXT,并会把 Cookie 写入本地临时文件,因此不能作为完整视频原件出口。
+- 唯一外部阻断:当前已安装 Chrome/扩展没有满足安全边界的完整音视频文件出口;该阻断不同于 600 秒预览,不能用 TXT、MP3、封面、partial、m4s 或任务完成状态替代。
+- 最小开发范围:仅在既有 `bili_video_download_bridge.py` 增加一个本地文件接管命令,按 batch+BVID 精确绑定,确认文件稳定且非 partial,使用公开无凭据 metadata probe 获取远端时长,复制到目标卷暂存,执行 FFprobe、时长一致性、SHA-256、CreateNew 发布和原子映射;原文件只读且失败清理暂存。
+- 明确不做:Cookie/profile读取、`--cookies-from-browser`、扩展安装/修改、localhost token读取、签名分片、浏览器录屏、DRM/访问控制绕过、通用下载框架、正式 `F:\video` 或 `ana-data` 写入。当前状态=`IMPLEMENTING_SAFE_LOCAL_HANDOFF_BACK_HALF_WITH_INGRESS_BLOCKER_RECORDED`。
+
+### 2026-08-05T20:55:01+08:00 安全后半程实现冻结
+
+- 已完成 `accept-browser-file`:完整文件一旦由合法界面落到请求方可读临时目录,工具自动完成稳定性/partial、精确 BVID、公开远端时长、FFprobe 音视频、时长一致性、SHA-256、CreateNew 发布、映射与失败清理;源文件不移动、不重命名、不删除。
+- code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`。
+- 目标 `21/21 PASS`;owner 范围 project `97/97 PASS`;治理 `OK/0 warnings`。当前状态=`PENDING_LIMITED_IMPLEMENTATION_REVIEW_WITH_SINGLE_INGRESS_BLOCKER`,不把受阻的完整文件入口虚报为完成。
+
+### 2026-08-05T21:13:57+08:00 限定审核 PASS/0
+
+- 回执=`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-BACKHALF-IMPLEMENTATION-REVIEW-RESULT-20260805-001`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BACKHALF-LIMITED-IMPLEMENTATION-REVIEW-20260805-001`;blocking findings=`0`。
+- `accept-browser-file` 后半程可交付;完整 AV 文件入口阻断继续保留。当前状态=`PASS_0_LOCAL_BACKHALF_READY_DELIVER_REQUESTER_WITH_INGRESS_BLOCKER`。
+
+### 2026-08-05T21:32:08+08:00 请求方验收终态
+
+- 回执=`HANDOFF-VIDEO-DOWNLOADER-INFODEV-BILI-AUTHENTICATED-SESSION-BACKHALF-RECEIPT-20260805-001 / RECEIVED_PASS_INGRESS_BLOCKED`;请求方接受安全本地文件后半程,并确认没有执行下载、认证秘密读取、正式路径写入或转写交接。
+- 完整 AV 文件入口阻断仍是外部事实,不属于已完成能力。开发事项终态=`ACCEPTED_CLOSED_LOCAL_BACKHALF_INGRESS_BLOCKED`;不新增审核链或后继开发。
+
+### 2026-08-05T22:39:30+08:00 扩展入口阶段按原事项续开
+
+- 管理授权=`HANDOFF-INFOADMIN-INFODEV-BILI-PLUGIN-FULL-VIDEO-NARROW-SCOPE-ASSIGN-20260805-001`;观察回执=`HANDOFF-INFOADMIN-INFODEV-BILI-PLUGIN-FULL-VIDEO-SCOPE-ACCEPT-OBSERVED-20260805-001`。本事项按原 task 续开,唯一 owner 仍为 `dev.developer.project / infodev`,不建平行任务或审核链。
+- 新增范围只解决 exact `青枫浦上Q/BV1HA3o6oEJJ` 的项目自有 side-panel/Native Messaging 完整视频入口,随后调用已验收的 `accept-browser-file`;现有 TXT 扩展和冻结后半程均不得改动。
+- 设计=`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001.md`;先做合并安全设计审核,PASS 前产品代码、测试、配置和安装动作冻结。
+- 真实 Cookie、signed URL、Chrome/扩展、下载、注册表、`F:\video`、正式 `ana-data` 和隔离 preview 动作均为 0。当前状态=`PENDING_CONSOLIDATED_SECURITY_DESIGN_REVIEW_IMPLEMENTATION_FROZEN`。
+
+### 2026-08-05T23:09:20+08:00 扩展入口 V001 HOLD/4 与 V002 返修
+
+- V001 审核=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001-DESIGN-20260805-001 / HOLD/4`;请求方回传要求同一 task、单 owner、同一审核链形成 V002。
+- V002=`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002.md`,只闭环:固定 manifest public key/expected ID/build 和 Cookie wire schema;import 前禁用 yt-dlp plugins/隔离第三方输出;一次解析同一下载对象/downloader 与 MKV 门禁;有界 reader/worker、超时和 Windows Job Object。
+- exact BVID、二次点击、无通用下载/loopback/token 文件、内存 Cookie方向、页面/DRM/multi-P/live/preview、冻结 backhalf 和 offline-only 边界未回退。
+- 产品实现、测试、扩展、host、安装脚本仍为 0;真实 Cookie、Chrome、注册表、network/download/media、`F:\video`、正式 `ana-data` 仍为 0。当前=`PENDING_V002_FOCUSED_DESIGN_REREVIEW_IMPLEMENTATION_FROZEN`。
+
+### 2026-08-05T21:20:11+08:00 股票估值每日台账 MySQL 单后端返修冻结
+
+- 事项=`DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001`;需求 owner 已把权威需求修订为 MySQL 单后端,管理员决定=`HANDOFF-INFOADMIN-INFODEV2-STOCK-VALUATION-LEDGER-MVP-MYSQL-F1-DECISION-20260805-001`。
+- 唯一可写库=`stock_valuation`;唯一生产行情源=`trading_xuntou` SELECT-only;`tianxia`、V2 网络 provider 和旧 SQLite 均不进入运行合同。
+- 正式结果包重建并热复验=`125 security / 136 valuation_version / 0 daily_price / 0 daily_judgement / 1 deterministic gap`;正式 daily 和 latest 发布均为 0。行情水位实际停在 `2026-02-24`,未重标为当前日期。
+- 前序 HOLD/4 已实施闭环:显式未复权/完整日证据门禁;后续复评次日生效;同身份全部不可变字段核对;全失败不发布且 latest 双文件 BaseException-safe 成对提交。
+- 验证:目标=`19/19 PASS / 82.174s`;project=`116/116 PASS / 145.514s`;compileall/help/PowerShell parser/UTF-8/diff/secret scan/治理全部 PASS。
+- MySQL 核验后删除旧试制 `stock_valuation.sqlite3` 及其专用 `.gitignore`;旧库未作为迁移源。当前状态=`MYSQL_HOLD4_REPAIR_FROZEN_PENDING_FOCUSED_REREVIEW`。
+
+### 2026-08-05 股票估值每日台账 MySQL 首次复审 HOLD/2 最小修复
+
+- 首次 MySQL 集中复审=`DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-MYSQL-HOLD4-REREVIEW-20260805-001 / HOLD/2`;F1、F2、F3 已关闭,唯一范围为 F4-R1 和 P1。
+- F4-R1:双文件 stage 的 create/write/flush/fsync 已全部进入同一 BaseException 回滚;24 组第一/第二文件、四故障点和三异常类型均保持正式 pair 原字节,stage/backup 为 0。
+- P1:所有区间与 V1 判定在 price INSERT 前完成;非正区间入口以 `E_RANGE` 单股失败,price/judgement 均为 0。
+- 验证:定向=`2/2 PASS / 10.714s`;目标=`21/21 PASS / 92.799s`;project=`118/118 PASS / 150.858s`。未运行正式 daily、latest、真实行情、provider 或计划任务。当前=`PENDING_F4_R1_P1_ONLY_REREVIEW`。
+
+### 2026-08-05 股票估值每日台账前复权专表需求变更
+
+- 需求 owner 回执=`requirement_change_front_adjusted_dedicated_table / USER_CONFIRMED_APPLY_IMMEDIATELY`;生产 daily 唯一行情表改为 `trading_xuntou.cn_stock_kline_1d_front`。
+- 禁止读取通用 `cn_stock_kline_1d`、禁止 `dividend_type` 推断、禁止 V2/network fallback;`source_id` 固定为专表全名。
+- 专表只读 schema 已核对具备 `id,symbol,trade_date,close,source,updated_at`;实现再绑定证券身份、正式日历、请求日以前最大开市日与 15:05 最终水位。专表缺行逐股失败,schema/连接失败整体阻断。
+- 正在进行的 F4-R1/P1-only 复审已收到 supersede 通知;两点修复保持,但其旧工件哈希不再代表最终交付。生产 daily、正式 latest、真实行情读取/写入仍为 0。当前=`IMPLEMENTING_FRONT_ADJUSTED_DEDICATED_TABLE_DELTA`。
+
+### 2026-08-05T22:46:51+08:00 股票估值每日台账前复权专表实现冻结
+
+- 最终行情合同:唯一表=`trading_xuntou.cn_stock_kline_1d_front`;`symbol == security.ticker` 且后缀为 `SH/SZ/BJ` 是身份硬门,`cn_stock_instrument_static` 仅诊断;固定 `source_id=trading_xuntou.cn_stock_kline_1d_front:xtquant:front`。
+- 实现及测试禁止通用表、`dividend_type`、V2/network fallback;F4-R1 stage 全点 BaseException 回滚和 P1 price INSERT 前区间/判定校验继续保持。
+- 离线验证:专表定向=`4/4 PASS / 11.473s`;目标=`22/22 PASS / 92.119s`;project=`119/119 PASS / 156.103s`。测试只写名称受限的隔离 MySQL 库。
+- 需求 owner 已单次完成 2026-08-05 生产 daily,正式计数=`125 security / 136 valuation_version / 124 daily_price / 124 daily_judgement`,港股 `09880.HK` 为唯一不支持缺口;本开发线程生产 daily 重跑=`0`,仅做只读行数、source、哈希和输出核验。当前=`PENDING_CONSOLIDATED_IMPLEMENTATION_REREVIEW`。
+
+### 2026-08-05T23:03:36+08:00 股票估值每日台账身份硬门最终收紧
+
+- 合并复审提交后自查发现 ticker/symbol 曾经先 `.upper()` 再比较,不满足用户“exact symbol == ticker”逐字合同;现已改为原值大小写敏感精确相等,`source` 也严格等于 `xtquant`。
+- 增加 lowercase symbol 与 uppercase source 反例;最终目标=`22/22 PASS / 91.232s`,project=`119/119 PASS / 144.696s`。生产 daily、正式历史行和正式输出改写仍为 0。
+- 已通知审核员旧提交哈希失效;只允许用完整替代快照继续合并复审。当前=`REPLACEMENT_SNAPSHOT_PENDING_NO_MORE_CHANGES`。
+
+### 2026-08-05 股票估值每日台账陈旧日历水位阻断
+
+- 审核反例证明 `status=current/is_current=1` 但 `window_end < as-of` 的资产仍可能把旧交易日误作请求视野最新日;按审核要求在 expected CTE、外层日历 join 和行消费边界全部强制 `window_end >= as-of`。
+- 新增 `as_of=2026-08-05 / window_end=2026-08-04` 非空反例;最终目标=`22/22 PASS / 86.679s`,project=`119/119 PASS / 140.964s`。生产增量仍为 0。当前=`FINAL_REPLACEMENT_SNAPSHOT_PENDING`。
+
+### 2026-08-05 股票估值每日台账 MVP PASS/0 接收关闭
+
+- 事项=`DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001`;单一 owner=`dev.developer.project.secondary / infodev-2`;共享审核员=`dev.reviewer.project / inforev`。
+- 最终交付=`HANDOFF-INFODEV2-INFOADMIN-STOCK-VALUATION-LEDGER-MVP-FINAL-20260805-001`;独立审核回执=`HANDOFF-INFOREV-INFODEV2-STOCK-VALUATION-LEDGER-MVP-FINAL-REVIEW-RESULT-20260805-001`。
+- 最终审计=`DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-STALE-CALENDAR-FINAL-REREVIEW-20260805-001 / PASS/0`;交付状态=`IMPLEMENTATION_AND_EXISTING_OUTPUT_ACCEPTED`;后续评审=`NOT_REQUIRED`。
+- 接受实现:MySQL 单后端只写 `stock_valuation`,生产 daily 唯一只读 `trading_xuntou.cn_stock_kline_1d_front`;ticker/source/后缀大小写敏感精确匹配,current 日历资产须先证明 `window_end>=as_of`,再派生最近开市日;禁止旧行情表、`dividend_type`、V2/network、SQLite 双后端、`tianxia`、ORM/API/service 回退。
+- 审核冻结基线:`125 security / 136 valuation_version / 124 daily_price / 124 daily_judgement`;A 股覆盖 `124/124`,唯一显式不支持项=`09880.HK`;四档标签=`偏低3 / 基本合理39 / 偏贵39 / 明显偏贵43`,source/close/version/label mismatch 均为 `0`。
+- 审后 live 数据增量:需求方独立导入 58 只新股票后,`stock_valuation` 当前报告为 `183 security / 194 valuation_version`;新 daily 因 authoritative calendar `window_end` 滞后被当前实现正确 fail-closed,未新增或覆盖 `daily_price`、`daily_judgement` 与原 124 行 `latest`。该数据增量不属于实现变更,不重开本次审核。
+- 正式输出:`latest.md=32890/98ECF8A0C7FE3141484A589A9533B8618BF1DEA5FDFE91614CE53F29ABE1F0F4`;`latest.csv=31060/C88986DF9CD008BF010623A3BF9BED49D675CF72975FF8E0B31DE3E54216E0A2`;`latest_gaps.csv=192/E5BBD897B4A3BBE5A256C9E747F08DD2BEB4C23A1ACFB860D3184B20FDD555F7`。
+- 验证:目标=`22/22 PASS`;project=`119/119 PASS`;compileall/help/PowerShell parser/diff/UTF-8/no BOM/no trailing/secret scan/governance 均 `PASS`;测试仅使用名称受限的隔离 MySQL 库;开发员与审核员均未重复执行 2026-08-05 production daily。
+- 旧试制 SQLite 已删除,且从未作为迁移源;`dual_backend=NO`。后续交易日只按工具文档由运维/需求方在权威日历水位完备后运行;新增港股来源、provider、网络路径、SQLite/V2 回退、计划任务安装或交易指令一律作为新需求。
+- 当前状态=`ACCEPTED_CLOSED`;保留实现、测试、文档、正式输出和完整 HOLD→PASS 审计历史,不再修改、重跑 production daily 或追加本事项评审链。
+
+### 2026-08-06 已登录会话下载入口 HOLD/3 同事项续派修复
+
+- 事项=`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;项目管理员因原 owner usage limit 将同一事项唯一实施 ownership 临时续派给 `dev.developer.project.secondary / infodev-2`,原 owner 在终态前不并发继续;共享审核员与既有审核链不变。
+- 本轮只修 F2-R1 并发 start lease/一次性 `prepare_id`、F4-R1 固定 PyInstaller `6.15.0 --onefile --noupx` 正式用法、F5-R1 source/build 树外 reviewer/admin approval receipt 信任锚;冻结 bridge、F1/F3 与既有产品边界不变。
+- 验证:扩展=`31/31 PASS / 29.893s`;冻结 bridge=`21/21 PASS / 0.238s`;project=`130 tests / OK / 6 skipped / 84.069s`;静态、编码、凭据和治理门禁均 PASS。
+- 真实 build/install、HKCU、Chrome、Cookie/session、network/download、真实媒体、`F:\video`、正式 `ana-data`、转写均为 0;开发员未创建或自签正式 trust receipt。当前=`PENDING_HOLD3_LIMITED_IMPLEMENTATION_REREVIEW`。
+
+### 2026-08-06 已登录会话下载入口 HOLD/3 限定复审 PASS/0
+
+- 原共享审核员回执=`HANDOFF-INFOREV-INFODEV2-BILI-AUTHENTICATED-SESSION-HOLD3-MINIMAL-OFFLINE-REPAIR-REREVIEW-RESULT-20260806-001`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-HOLD3-MINIMAL-OFFLINE-REPAIR-REREVIEW-20260806-001 / PASS/0`;F2-R1、F4-R1、F5-R1 全部关闭,冻结合同未回退。
+- reviewer-owned source approval=`ai-inforev/worklog/2026-08-06-bili-authenticated-session-source-approval.json|373|F6A56C14ED8DB1DFF7918F72CC5316D65866CED9B2D98368A88DF47D338FCBAA`,只批准 exact source manifest `2511/039BE779...9709` 的后续受控 build。
+- install/HKCU 继续未授权;受控 build 完成后必须提交唯一 EXE 与 schema2 build receipt 的 exact bytes/SHA-256,再由 reviewer/admin 形成树外 `install-exact-build` approval。真实 build/install/Chrome/Cookie/session/network/download/媒体/正式路径动作仍为 0。
+- 当前开发返修状态=`HOLD3_MINIMAL_OFFLINE_REPAIR_ACCEPTED`;后续构建/安装属于严格分阶段授权动作,不由本次 PASS 自动执行。
+
+### 2026-08-06 exact-source 受控 build 依赖预检阻断
+
+- 项目管理员已授权当前唯一 owner/operator 对 exact source 执行一次离线 build;开发员先回传 `ACCEPTED_CONTROLLED_BUILD_OPERATOR`。
+- reviewer approval 与 source manifest 再次复算完全匹配,预定输出目录 `dev/tmp/bili-auth-controlled-build-20260806-001` 不存在;但固定目标环境没有 `PyInstaller` 模块,本机已安装环境中也未找到精确 PyInstaller `6.15.0`。
+- 按授权合同在调用 build 脚本前 fail closed:`build_count=0`,输出目录/EXE/schema2 receipt/work/spec 均未创建;未联网安装依赖、未改写 approval 或 source manifest。
+- 当前阻断=`E_BUILD_DEPENDENCY_PYINSTALLER_6_15_0_UNAVAILABLE`;需 project.admin 提供或授权已固定、离线可用的 PyInstaller 6.15.0 构建环境后才能重新授权一次 build。install/HKCU 继续未授权。
+
+### 2026-08-06 exact-source 受控 build 成功冻结
+
+- 管理端预置离线 PyInstaller 6.15.0 环境并恢复原单次授权;预置 Python/PyInstaller、wheelhouse 7/7、provision receipt、reviewer source approval、source manifest 15/15 与空输出目录均在 build 前复算通过。
+- `build_host.ps1` 唯一执行一次,`build_count=1 / exit=0 / 20.839s`;EXE=`8608281/F0D3CE2EF12D7D305B8F765169CBF5430DF9F0D36D03FF4447A85378AFF8A26F`;schema2 receipt=`995/9B989A32C92A4EB89911C9D5151F31B416BA8F265785CE575FDD1A23FBEDF63D`。
+- 输出目录仅有 EXE 与 receipt;work/spec 清理完成,approval→source manifest→receipt→EXE 绑定复算通过,构建控制台 secret/signed-URL 模式命中 0。
+- 当前=`PENDING_EXACT_BUILD_HASH_ONLY_CONFIRMATION`;向原 `dev.reviewer.project / inforev` 提交同链限定确认。install/HKCU 与真实 Chrome/Cookie/network/download/media/正式路径动作继续为 0。
+
+### 2026-08-06 exact-build hash-only 确认 PASS/0
+
+- 审核回执=`HANDOFF-INFOREV-INFODEV2-BILI-AUTHENTICATED-SESSION-EXACT-BUILD-HASH-ONLY-CONFIRMATION-RESULT-20260806-001`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXACT-BUILD-HASH-ONLY-CONFIRMATION-20260806-001 / PASS/0`。
+- exact build identity gate=`SATISFIED`;EXE=`8608281/F0D3CE...A26F`、receipt=`995/9B989A...F63D`、两文件集合、source 15/15、builder/build-script/source approval 链均由审核员独立复算通过,EXE 未执行。
+- reviewer build approval=`ai-inforev/worklog/2026-08-06-bili-authenticated-session-build-approval.json|594|244097BC466D969E046F0E64E9D3A1213F5AB64E12C9358DF0807B4BA0F8F816`;范围只为 `install-exact-build` 身份门禁。
+- 实际 install/HKCU 仍为 `NOT_EXECUTED`,必须由管理员/请求方另行明确调度;任何工件漂移须重新审核。
+
+## DEV-PROJECT-INFO-HIBOR-NEW-EMULATOR-ADAPTER-20260806-001
+
+- 来源:`HANDOFF-YANBAO-INFODEV-HIBOR-NEW-EMULATOR-MINIMAL-ADAPTER-20260806-001`;请求方=`case_analysis.report_collector / yanbao-collector`;负责人=`dev.developer.project / infodev`。
+- 目标:在既有慧博精确研报批量执行器内增加最小 ADB 设备预检,支持显式 `emulator-5554` 和仅连接一个在线设备时的 `auto` 安全解析,消除对旧 `127.0.0.1:21503` 的依赖。
+- 范围:只改 `dev/project-dev/hibor_batch_executor.py`、对应测试、示例与项目工具说明;保留现有 JSON、单命令、顺序执行和 `dev/ana-dev` 公共合同。
+- 边界:0 个在线设备或 `auto` 下多个在线设备 fail closed;显式串号必须在线且精确匹配。继续使用现有 ADB 直拉;`/sdcard/Pictures` 与 `F:\hb` 仅登记为可选、已验证传输通道,不实现新服务或共享复制逻辑。
+- 禁止:不打开新研报,不触发 APP 下载、额度写入或 BATCH-031 正式归档;不修改缓存原件或 `dev/ana-dev`。
+- 验收:离线单元测试覆盖显式、单设备自动解析、0 设备、多设备、offline/unauthorized 和 preflight-before-output;可选只读 smoke 仅核对设备与既有缓存,不产生正式归档。
+- 当前状态:`AUTHORIZED_LIGHT_IMPLEMENTATION_IN_PROGRESS`。
+
+### 2026-08-06 新模拟器最小适配实现完成
+
+- 显式设备串号现在必须在 `adb devices -l` 中精确存在且状态为 `device`;`runtime.device_serial=auto` 只在列表恰好有一个 ready 记录时解析,0/多设备、offline、unauthorized、缺失串号和 ADB 错误全部 fail closed。
+- 设备预检发生在批次目录创建和内核调用之前;解析后的真实串号写入现有 TaskSpec,JSON schema、缓存路径、顺序执行与 `dev/ana-dev` 公共合同均未改。
+- 目标单测=`8/8 PASS`;project 回归=`133 tests / OK / 6 skipped`;只读环境核对=`emulator-5554 / cache root PASS / product auto resolution PASS`。
+- APP/研报打开、下载、额度写入、正式归档、BATCH-031、缓存原件修改均为 0。当前状态=`IMPLEMENTED_PENDING_REQUESTER_ACCEPTANCE`。
+
+### 2026-08-06 新模拟器最小适配请求方验收关闭
+
+- 请求方回执=`HANDOFF-YANBAO-INFODEV-HIBOR-NEW-EMULATOR-MINIMAL-ADAPTER-ACCEPTANCE-20260806-001 / ACCEPTED`;显式 `emulator-5554`、`auto` 唯一设备/fail-closed、preflight-before-batch-root、缓存根不变与零公共合同扩张全部通过。
+- 请求方接受目标 8/8 与 project 133 tests/6 skipped 证据,并确认实现符合快速落地口径;无需继续扩展或执行真实 APP/下载。
+- BATCH-031 后续输入兼容属于 `dev.developer.ana.cai` 持有的另一核心事项,不重开或扩大本适配。当前状态=`ACCEPTED_CLOSED`。
+
+## DEV-ANA-HIBOR-NEW-EMULATOR-INPUT-COMPAT-20260806-001
+
+- 来源:`HANDOFF-YANBAO-LAOYANCAI-HIBOR-NEW-EMULATOR-CORE-INPUT-FIX-20260806-001`;负责人=`dev.developer.ana.cai`;审核员=`dev.reviewer.ana.cai`。
+- 目标:修复新模拟器不支持 Memu 剪贴板服务、缺少 monkey 时的核心输入与启动兼容问题,并消除服务失败后误粘贴主机剪贴板内容的风险。
+- 范围:仅 `dev/ana-dev/hibor_fast_collection/adb.py`、对应回归测试和必要记录;不改公共采集、缓存、PDF/hash、quota、manifest 合同。
+- 安全边界:不执行真实 APP/ADB、下载、额度、归档;中文/特殊输入在未明确支持 MemuIME 的环境 fail closed;服务失败绝不 paste。
+- 当前状态:`IMPLEMENTED_PENDING_LIGHT_INDEPENDENT_REVIEW`。
+
+### 2026-08-06 新模拟器输入兼容修复审核通过
+
+- 限定实现复核=`AUDIT-DEV-ANA-HIBOR-NEW-EMULATOR-INPUT-COMPAT-IMPLEMENTATION-V001 / PASS/0/0`。
+- 安全 ASCII 原生输入、Memu fail-closed/no-paste、monkey 缺失 WelcomeActivity 回退及 UI 逐字回读均独立验证通过。
+- 当前状态:`IMPLEMENTATION_ACCEPTED_CLOSED`;后续可由采集流程按原执行边界使用,不新增设计版本。
+
+## DEV-PROJECT-INFO-VIDEO-TEXT-HOST-WRITE-EOF-STABILITY-20260806-001
+
+- 来源:`HANDOFF-INFOADMIN-INFODEV-VIDEO-TEXT-HOST-WRITE-EOF-STABILITY-ASSIGN-20260806-001`;父事项=`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;负责人=`dev.developer.project / infodev`;审核员=`dev.reviewer.project / inforev`。
+- 问题:已安装 VideoTextHost 在 Chrome Native Messaging 管道关闭时发生未捕获 `Error: write EOF`,弹出前台 JavaScript 主进程错误窗口,阻断真实下载运行时发布。
+- 目标:基于上游 v1.0.5 不可变源码和 active binary 映射,形成一个串行写状态机;预期 EOF/EPIPE/流销毁/父端断开只关闭一次并静默有界退出,同时显式收口 transcriber/ffmpeg/子进程且不丢失正常任务成功/失败状态。
+- 范围:仅 `dev/project-dev/video_text_host_eof_stability/`、对应测试、`dev-doc/project-doc/视频转文字助手EOF稳定性修复.md` 与必要账本。准备构建和回滚证据,但不安装、不改注册表或 Chrome。
+- 验收:冻结 registry/installed/source/release 身份;50+ 离线 fake-pipe/真实 sleeping-child 周期;foreground popup=0、orphan=0、正常 success/failure 状态不回退;形成 exact build inputs/output hashes 和 rollback steps 后提交 `inforev` 一次紧凑实现审核。
+- 禁止:真实 Cookie/session、secret-bearing logs、签名媒体 URL、真实 Chrome 会话执行、网络下载媒体、`F:\video`、formal `ana-data`、转写、installed root/HKCU 修改均为 0。
+- 当前状态:`AUTHORIZED_OFFLINE_IMPLEMENTATION_IN_PROGRESS`。
+
+### 2026-08-06 active host 来源映射阻断
+
+- Registry→manifest→`host-win.bat`→`native-host.exe` 调用链已只读冻结;active EXE 的 PyInstaller 业务入口为 `host_win`。
+- Active marshal code object 与上游 v1.0.5 full commit `537f424866fd3736f5845224208e3dcc67352b70` 的 `host_win.py` 归一化指纹完全一致;active Windows host 不执行 `host.cjs`。
+- 截图是 Electron/Node `WriteWrap` 未捕获异常,active host 则是 Python 且已捕获 header/payload/flush 写异常,两者来源不一致。
+- 唯一阻断:缺少弹窗时实际 faulting process 的 PID/父 PID/可执行文件路径或等价 WER 记录。在未建立该进程到源码/构建物的映射前,不构建、不安装、不替换。
+- installed root/HKCU/Chrome/真实会话/媒体/正式目录动作均为 0。当前状态=`BLOCKED_EXACT_FAULTING_PROCESS_PROVENANCE_REQUIRED`。
+
+### 2026-08-06 范围纠正与阻断解除
+
+- 管理员回执=`HANDOFF-INFOADMIN-INFODEV-VIDEO-TEXT-HOST-WRITE-EOF-SCOPE-CORRECTION-20260806-001`;已接受 provenance 反证,撤回 active Python host 的因果声明。
+- 保留精确来源映射和零变更证据;active host 的 patch/build/install/replace 全部停止,不进入审核链。
+- 事项仅在弹窗复发且上游提供 PID/父 PID/可执行路径或等价 WER 证据时,进行一次有界只读身份核对;不建常驻 watcher,不捕获命令行、认证数据或媒体。
+- 本事项不再是 authenticated-download release blocker。当前状态=`EVIDENCE_AWAITING_NO_PRODUCT_MUTATION`。
diff --git "a/dev-doc/\345\274\200\345\217\221\344\272\213\351\241\271\350\256\241\345\210\222.md" "b/dev-doc/\345\274\200\345\217\221\344\272\213\351\241\271\350\256\241\345\210\222.md"
index 72b682c..52e8b6e 100644
--- "a/dev-doc/\345\274\200\345\217\221\344\272\213\351\241\271\350\256\241\345\210\222.md"
+++ "b/dev-doc/\345\274\200\345\217\221\344\272\213\351\241\271\350\256\241\345\210\222.md"
@@ -765,7 +765,12 @@
- 请求方返回 `HOLD/1`:仅接受主课件原 `slide_002..038`,课件前后非 PPT 稳定画面必须过滤;其他实现、测试、资源、PDF 和源保护证据不再重审。
- 需求澄清冻结:无 PPT 主体的全屏人物必须过滤,PPT 页面中的小型人像窗口必须保留;实现采用序列过滤而非人像识别/裁剪,旧归档 53 页 PDF 只读保留。
- V003 计划:先审核紧凑颜色主题主序列方案;PASS 后只改单脚本/测试,复用 53 张候选离线验证严格选中 `2..38`;限定实现审核 PASS 后完整视频最多再运行一次并输出独立 `real-full-v2.slides`。
-- 当前状态:`PENDING_V003_LIMITED_DESIGN_REVIEW`;禁止提前改代码或重跑媒体。
+- V003 限定方案审核=`PASS/0`;已按授权完成单脚本/测试/用法最小实现。离线目标测试=`29 discovered / 26 PASS / 3 SKIP`,project 回归=`48 discovered / 45 PASS / 3 SKIP`;3 项均因主动禁用 FFmpeg,CLI help 和 py_compile PASS。
+- 既有 53 张 PNG 只读回放自动选中原 `2..38`,独立生成 37 张连续编号 PNG、37 页 PDF 和联系表;逐页 RGB/顺序、Poppler 页数/尺寸和视觉抽查 PASS,旧证据及人工归档未改。
+- V003 限定实现审核=`PASS/0`;已执行授权的唯一一次完整视频运行,输出独立 `real-full-v2.slides`,候选 `53`、选中 `37`、PNG/PDF/联系表/资源/源保护和旧产物保护全部完成开发方只读验证。
+- 请求方最终只读验收=`HANDOFF-MEDIA-INFODEV-MEETING-SLIDE-V003-REQUESTER-FINAL-ACCEPTANCE-20260803-001=ACCEPTED_CLOSED`;37 页主课件顺序、首/中/末清晰完整、小型人物窗口保留、非课件画面排除、逐页 SHA-256、PDF 和源保护均通过。
+- 现有人工归档 PDF 与新自动输出字节数和 SHA-256 完全一致,无需替换;最终验收媒体运行=`0`、代码修改=`0`、归档写入=`0`。
+- 当前状态:`ACCEPTED_CLOSED`;计划全部完成,唯一完整视频运行次数已用尽,不再运行、重试、调参或扩展。
## DEV-PLAN-ANA-MEETING-MINUTES-FAST-PIPELINE-20260801-001
@@ -819,3 +824,313 @@
- 最终实现独立复审=`PASS/0`;操作手册唯一授权文件同步及单文件限定确认=`PASS/0`;其他 outputs 写入=`0`。
- 请求方最终只读验收=`ACCEPTED_CLOSED`,回执关联 `HANDOFF-INFODEV2-SOURCE-STOCK-VALUATION-PIPELINE-V2-FINAL-DELIVERY-20260802-001`;未要求补测或变更。
- 当前状态:`ACCEPTED_CLOSED`;计划全部完成。保留 `real-smoke-006` 的 `E_HISTORICAL_SHARES_UNPROVEN` 限制,未来新增历史股本信源必须作为独立事项设计和审核。
+
+## DEV-PLAN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001-V001
+
+- 关联事项:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`;owner=`dev.developer.project`;reviewer=`dev.reviewer.project`;审计入口=`dev-doc/开发审计报告.md`。
+- 分类:有明确上游授权的重型最小性能修复;跨两个脚本、Windows 进程调度、硬件解码回退、跨进程互斥和真实资源门禁,必须方案先审。
+- 顺序:冻结基线/能力探测 → V001 方案审核 → 两脚本与必要测试实现 → 离线/合成回归 → 实现审核 → 至多一次 `<=10 min` 真实片段采样 → 请求方验收。
+- 实现:现有 `transcribe_media.py` 作为 stdlib-only 主机保护内部 owner;两个 CLI 共用 Windows named mutex 和 BelowNormal helper;PPT H.264/H.265 优先 NVDEC,失败只允许一次显式告警后的 4 解码线程/2 滤镜线程 CPU 回退。
+- 证据:500 ms 外部只读采样同时保存整机 CPU、任务树 CPU/RAM/优先级、GPU/decoder 利用率、全局与 PID 专用显存、FFmpeg 命令及 stdout/stderr。
+- 门禁:所有完整 5 秒滚动窗口 CPU `<85%`,无连续 20 个样本 `>=85%`;根 Python/FFmpeg BelowNormal;NVDEC 命令/日志存在;采样、输出及源保护完整。
+- 当前状态:`HISTORICAL_SUPERSEDED_BY_V002`;V001 审核为 `HOLD/3`,未冲突的互斥、优先级、线程和范围合同由 V002 继续冻结。
+
+## DEV-PLAN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001-V002
+
+- 前序:V001 方案审核=`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001-DESIGN-20260804-001=HOLD/3`;V001 中互斥/BelowNormal、4/2 线程、业务合同、真实运行边界继续有效。
+- F1:硬件扫描只把启动失败、非零退出、零帧和半帧转换为私有 `HardwareDecodePathError` 并允许一次 CPU;页面算法、无稳定页、文件/清理和中断均零回退;CPU 失败无第三次。
+- F2:用固定稳定页+动画+重复+切换合成序列分别编码 H.264/H.265;实际比较 NVDEC 和 forced CPU 的采样、逻辑页、顺序、相对时间、PNG/PDF 页数/RGB/尺寸,并保存两种实际 decoder 命令。
+- F3:500 ms 采样丢弃 warm-up,从扫描 FFmpeg 出现到退出连续覆盖;5 秒尾窗按实际时间加权、每窗至少 10 样本且窗口数必须非零;高 CPU 段按时间戳累计,`>=10.000 s` 失败。
+- 顺序:V002 限定方案复审 → 最小实现和离线/合成回归 → 限定实现审核 → 至多一次 `<=600 s` 独立短片/一次 PPT 运行 → 请求方验收。
+- 当前状态:请求方最终只读验收=`ACCEPTED_CLOSED`;唯一 r2 已复用既有 593.120 秒片段一次完成,target/sampler exit=`0/0`,sampling/CPU/resource 三门禁全 PASS,63 个完整窗口全部 `<85%` 且无 10 秒高段,NVDEC/BelowNormal/源保护/7 页输出 PASS。计划全部完成;r1 失败证据与 r2 通过证据保留,r2 次数已用尽,不得再运行、调参或修改实现。
+
+## DEV-PLAN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001
+
+- 关联事项:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`;上游需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;owner=`dev.developer.project.secondary`;reviewer=`dev.reviewer.project`。
+- 轻量判断:实现限定为一个本地 stdlib CLI,不含网络、浏览器控制、并发、服务、数据库、媒体下载或正式采集写入;因此按有需求文档的轻量实现直接编码,不创建编码方案。
+- 输入:无秘密的博主 JSON 配置、人工/扩展导出的动态元数据 JSON、可选已完成视频映射 JSON;所有相对路径按配置文件目录解析。
+- 输出:每博主 append-only `manifest.jsonl`、本次新增待办 JSON、检查摘要 JSON、视频处理员 Markdown 交接摘要,以及配置指定视频目录内经哈希校验且不覆盖的已完成视频。
+- 步骤:冻结输入/状态合同与停止边界 → 实现 72 小时筛选、标识去重、命名和 manifest → 实现安全视频移动与交接摘要 → 补 fixture/合同测试和用法 → 运行目标测试、project 回归、CLI help、治理校验 → 提交共享审核员轻量实现审核。
+- 核心验收:同输入重跑不重复;失败可重试并追加状态;命名保留发布时间且符合 Windows;动态 ID/opus/BV/URL 任一稳定标识可追溯;重名、越界、临时下载、链接和目标不唯一均 fail-fast;移动完成后 SHA-256 与 manifest 一致;工具不含认证字段或网络/浏览器实现。
+- 开发运行边界:只使用 `dev/tmp/bili-dynamic-collector-mvp/` 和测试临时目录;正式输出授权未完成也不阻塞代码与 fixture,自测不得触碰 `F:\video\` 或 `ana-data/news-青枫浦上Q/`。
+- 当前状态:`IN_PROGRESS_LIGHTWEIGHT_IMPLEMENTATION`。
+
+### 2026-08-04T22:18:49+08:00 实施完成待轻量审核
+
+- 输入/状态/停止合同、代码、fixture、测试、用法和开发 tmp 证据均已完成。
+- 计划验证:py_compile、CLI help、目标 `6/6`、project flat discover `69/69`、离线 CLI check/move/handoff/recheck、diff check 和治理校验全部 PASS。
+- 需求—代码自检未发现阻断;正式路径授权未完成不影响开发交付,运行配置继续限定开发 tmp。
+- 当前状态:`PENDING_SHARED_REVIEWER_LIGHT_IMPLEMENTATION_REVIEW`;下一步只做共享审核员一次轻量实现审核和必要真问题修复。
+
+### 2026-08-04T22:53:23+08:00 终态收口
+
+- 共享审核员一次性安全审查=`HOLD/5`;测试通过不消除五项设计/映射/重试/秘密/交接合同阻断。
+- 请求方明确要求当前审查后立即返回且不增加功能或评审链,因此不执行返修、重型设计、复审、真实网络、浏览器、下载或正式数据写入。
+- 计划终态:`TERMINAL_HOLD_DEVELOPMENT_SNAPSHOT_ONLY`;现有文件仅作隔离开发证据,不进入真实采集运行。
+
+### 2026-08-04T23:19:20+08:00 HOLD/5 最小修复恢复计划
+
+- 顺序:V001 重型设计 → 同一审核员限定方案审核 → 仅修 F2—F5 → 目标/project 回归与新鲜 tmp smoke → 一次限定实现复审。
+- 代码门禁:V001 PASS 前不得修改 `bili_dynamic_collector.py`、示例配置、测试或 fixture。
+- 验收:混合 selector、同物理源/重复实体、retained-source 重跑、manifest 嵌套秘密、完整稳定 handoff envelope 五组反例必须非真空;前序已通过合同不退化。
+- 外部动作继续为 0;不触碰 `F:\video` 或正式 `ana-data`。
+- 当前状态:`V001_DESIGN_REVIEW_PENDING`。
+
+### 2026-08-05T00:35:00+08:00 HOLD/5 修复执行完成
+
+- 设计:V001+V002+V003 已获原共享审核员最终 `PASS/0`,实施门禁解除。
+- 实施:仅关闭前序 F1—F5;没有新增网络、浏览器、下载、调度、自动发送或正式路径能力。
+- 回归:目标 11/11、新鲜闭环 1/1、project 74/74 全部 PASS;fixture 未修改。
+- 下一步:只向同一 `dev.reviewer.project` 提交一次限定实现复审;以 PASS/0 或明确剩余阻断回传请求方。
+
+### 2026-08-05T01:05:00+08:00 本轮终态
+
+- 限定实现复审=`HOLD/2`;正向测试和五项主要修复事实保留。
+- 明确剩余:状态感知的 terminal 必需证据验证;handoff 消费标量的控制字符/格式验证及参数化入口反例。
+- 本轮不再追加代码或评审;若请求方再次授权,只允许上述两项 fail-closed 修复与一次限定复审。
+
+### 2026-08-05T09:20:00+08:00 持续授权执行
+
+- 新授权已满足续开条件;只实施 terminal 状态证据 validator 与 handoff 标量注入 validator,不创建新设计、不扩大范围。
+- 两组参数化入口反例与完整回归已完成:目标 13/13、新鲜闭环 1/1、project 76/76 PASS。
+- 下一步:提交原 `dev.reviewer.project` 限定复审;若发现同一边界内问题继续最小闭环,不因授权理由停止。
+
+### 2026-08-05T15:36:35+08:00 计划终态
+
+- 原 `dev.reviewer.project` 限定复审=`PASS/0`,HOLD/2 两项 fail-closed 阻断全部关闭,无需新增设计、真实运行或继续评审链。
+- 开发计划状态=`COMPLETE_PROJECT_TOOL_DELIVERABLE`;保留代码、测试、说明和证据,停止增加功能。
+- 本计划不包含真实网络、浏览器、下载、自动 native send、调度、`F:\video` 或正式 `ana-data`;这些动作如需执行,仍须另行明确授权。
+
+### 2026-08-05 请求方验收终态
+
+- 请求方已按 `HANDOFF-VIDEO-DOWNLOADER-INFODEV2-BILI-DYNAMIC-COLLECTOR-PASS0-ACK-20260805-001` 接收 PASS/0 交付;计划状态=`ACCEPTED_CLOSED`。
+- 不再追加功能、测试、真实运行或评审链;后续新缺陷由请求方另行发起。
+- 请求方声明外部动作及正式路径已有既有授权;执行归视频下载员,本计划不新增、撤销或替代该授权。
+
+## DEV-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-20260805-001
+
+- 分类:已有明确上游目标与安全边界的轻量独立 CLI;不新建重型设计,不接管 secondary 的动态采集器。
+- 输入:1—10 条 BVID、严格规范来源 URL、带时区 `published_at` 和可选标题;当前示例固定两条已授权目标。
+- 输出:`<BVID>.<container>` 完整媒体与 `<BVID>.download.json` 来源映射;正式运行目录由下载员显式传入,开发测试只用 TemporaryDirectory。
+- 步骤:复现 Chrome/扩展阻断 → 核对扩展公开能力 → 冻结无认证安全合同 → 实现 probe/download/ffprobe/hash/CreateNew → 目标与 project 回归 → 无下载现场探测 → 共享审核员轻量审核 → 请求方执行真实下载。
+- 通过门禁:两条无 Cookie probe 均成功;真实下载命令无隐式配置/Cookie/netrc;产物不得为 WEBP/m4s/part;ffprobe 同时见视频流和音频流;映射 bytes/SHA-256 与现场文件一致;任何已有同 BVID 文件均拒绝覆盖。
+- 当前验证:py_compile/help/diff check PASS;目标=`11/11 PASS`;无下载探测=`2/2 PASS`(1206.067 s、3133.95 s)。project 首轮因既有媒体资源采样 5 秒窗口密度波动为 `86/87`,同一冻结代码再次完整回归=`87/87 PASS / 55.019 s`;与本新增文件无调用关系。
+- 当前状态:`PENDING_SHARED_REVIEWER_LIGHT_IMPLEMENTATION_REVIEW`。审核通过后只交付命令,由已获授权的 `case_analysis.video_downloader` 写 `F:\video\青枫浦上Q`;开发线程不代跑。
+
+### 2026-08-05T17:04:33+08:00 交付路由调整
+
+- 共享审核消息已可靠入箱,但审核员托管 rollout 损坏,普通和指定 message agent step 均在载入线程前失败;已向 `project.admin` 请求恢复,不由开发员修复或自审。
+- 本事项仍符合上游明确的轻量、实用、请求方执行真实下载口径;在不虚报独立审核 PASS 的前提下,先提交 `case_analysis.video_downloader` 做普通功能验收和命令消费。
+- 请求方若验收通过可按其既有外部路径授权运行;若要求独立审核先行,则等待管理恢复后再执行,不需要修改或重跑开发代码。
+
+### 2026-08-05T17:32:45+08:00 阻断性缺陷返修计划与执行
+
+- 唯一返修目标:以远端 probe 总时长为冻结期望值,FFprobe 本地时长只能有正常封装偏差;600 秒会员预览不得冒充 3134 秒完整视频。
+- 合同:容差=`max(3.0 s, remote × 0.1%)`,边界等于容差通过、超过即失败;验证发生在任何正式媒体或映射创建前。异常路径清理暂存并返回非零。
+- 补充边界:probe 仅 `METADATA_PASS_ONLY`;`_type=playlist`、entries、多 P/多视频聚合和明确认证状态停止;规范输入不接受 `?p=`,避免 BVID 与具体分 P 混淆。
+- 验证:目标 15/15;project 首轮被并行真实转写持有 mutex 明确拒绝 3 项,未干扰该任务;释放后完整回归 91/91 PASS / 54.225 s。
+- 当前=`PENDING_DURATION_FAIL_CLOSED_INDEPENDENT_REVIEW`;只提交新哈希、离线反例和无正式动作证据,复审前禁止真实下载。
+
+## DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001
+
+- 输入:权威需求、六张图片批次 `batch_results_83.json` 及其 83 份正式 snapshot/calculation/report、V1 只读内核和 V2 `acquire_market`。
+- 输出:四业务表 SQLite schema;`init/daily/list/show/export`;原子 `latest.md/latest.csv/import_gaps.csv`;`run_daily.ps1` 与手工计划任务示例;工程说明、测试和证据。
+- 步骤:冻结真实输入 schema/83 集合 → 实现 schema 与幂等导入 → 实现 V1 一致判定和事务 daily → 接入 V2 行情桥 → 实现查询/导出/PowerShell → 目标测试、83 实物验收、项目回归与性能 → 一次必要实现审核。
+- 核心门禁:只接受不晚于 as-of 的完整交易日收盘;单股失败不污染其他股;同日历史价格和判定不可静默改写;数据库事务失败全回滚;估值区间只由正式新版本导入改变;导出使用临时文件+fsync+replace。
+- 验收:空库四表;正式 83 只幂等导入;83 只 fixture daily;四个边界;新版本不回算历史;单股失败隔离;CLI/导出一致;V1/V2 测试不退化;200 只暖查询 <1 秒。
+- 审计入口=`dev-doc/开发审计报告.md`;只在实现和证据冻结后提交 `dev.reviewer.project` 一次必要审核。
+- 当前状态=`IN_PROGRESS`。
+
+### 2026-08-05T18:32:58+08:00 Bilibili 下载桥时长门禁返修计划终态
+
+- 限定复审=`HANDOFF-INFOREV-INFODEV-BILI-BRIDGE-DURATION-GATE-REREVIEW-RESULT-20260805-001 / PASS/0`,前序唯一真实缺陷已闭环,无需新建复审或重跑真实下载。
+- 冻结门禁:发布前比较远端/本地时长,容差=`max(3 s, remote*0.1%)`;预览、多 P/playlist、认证/会员/私有/订阅限制全部 fail-closed;probe 不升级为完成状态。
+- 冻结验证:目标 `15/15 PASS`、项目 `91/91 PASS`,py_compile、CLI help、diff、严格 UTF-8 无 BOM和治理均 PASS。
+- 交付消息=`msg_20260805183258435_048db111`,目标=`case_analysis.video_downloader`;开发计划状态=`COMPLETE_PASS_0_DELIVERED_PENDING_REQUESTER_ACK`。
+- 文件名纠正消息=`msg_20260805183550141_a01a1463`;正确路径=`dev/project-dev/bili_video_download_bridge.py`、`dev/project-dev/test/test_bili_video_download_bridge.py`,冻结字节数和哈希不变。
+- 不再修改代码、重跑下载或扩展能力;后续真实第二条视频执行只能由请求方依据其独立既有授权发起,并必须通过冻结时长门禁。
+
+### 2026-08-05T18:47:00+08:00 股票估值每日台账 MVP 实施计划收口
+
+- 已完成:四表 schema、正式 JSON 幂等导入、V1 四档判定、事务日更、V2 行情桥、list/show/export、原子最新总表、单次 PowerShell、手工计划任务示例。
+- 已完成验收:83 家正式导入与离线日更、四边界、重复运行、新版本历史不回写、单股隔离、数据库整批回滚、来源日期、无有效版本半状态、200 家暖查询和项目/V1/V2 回归。
+- 正式运行库已只执行 `init`;联网 `daily`、真实网络 SLA 和计划任务安装均未执行。
+- 当前唯一下一步:向 `dev.reviewer.project` 提交一次完整实现/输出审核;按回执修复阻断或在 PASS 后向项目管理员与请求方终态交付。
+- 当前状态=`IMPLEMENTATION_FROZEN_PENDING_REVIEW`。
+
+## DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001
+
+- 分类:有明确上游依据的轻量实现;单脚本、现有测试与一页用法局部扩展,不新增服务、扩展、依赖、调度或通用框架;完成后提交 `dev.reviewer.project` 一次必要实现审核。
+- 输入:既有 UTF-8 batch JSON、其中一个精确 BVID、由用户授权界面已完整落盘的单个媒体路径、请求方运行时目标目录;开发测试只使用 `TemporaryDirectory` 桩文件。
+- 输出:`<BVID>.<container>` 与 `<BVID>.download.json`;映射保留 canonical source/published_at/bytes/SHA-256、远端/本地时长和容差、音视频 codec,并标记 `authorized_browser_file_handoff`,不记录原绝对路径或认证信息。
+- 步骤:冻结现有代码/测试 → 实现稳定文件与 batch 精确绑定 → 同卷暂存复制及源/副本哈希一致 → 复用公开 probe、FFprobe、时长门禁、CreateNew/回滚 → 增加正常、partial、不稳定、预览时长不符、collision/BaseException 回归 → py_compile/CLI/目标/project 回归 → 文档与执行日志 → 必要独立实现审核。
+- 门禁:原无凭据 `probe/download` 参数和 fail-closed 行为不得回退;本地文件命令不得运行 yt-dlp 下载,不得读取 Chrome profile/Cookie/token,不得接受 `.crdownload/.part/.m4s/.webp`,不得修改源文件;`600.133313 vs 3133.95` 必须零正式输出且暂存清理。
+- 真实执行边界:开发线程的网络认证、Chrome下载、真实媒体、`F:\video\青枫浦上Q` 与正式 `ana-data` 动作为 0;视频下载员只有在合法授权界面先得到完整文件后,才运行自动接管命令。
+- 当前状态=`IN_PROGRESS_LIGHT_IMPLEMENTATION_INGRESS_CONTROL_SURFACE_BLOCKED`。
+
+### 2026-08-05T20:55:01+08:00 实现冻结与审核门禁
+
+- 已完成稳定本地文件接管、精确 BVID、公开无凭据远端时长、FFprobe AV、时长一致性、双 SHA-256、CreateNew/回滚与源文件不变测试;目标=`21/21 PASS`,owner 范围 project=`97/97 PASS`。
+- 唯一未闭环项是“合法授权界面如何产生完整 AV 文件”;扩展按钮链只产生 TXT,Chrome 媒体控制没有受支持下载事件。该外部入口不得通过 Cookie/profile、签名分片、600 秒预览或泛化下载框架绕过。
+- 下一步:提交 `dev.reviewer.project` 一次限定实现审核;PASS 后向请求方交付精确按钮调查结论、人机文件选择步骤和 `accept-browser-file` 自动接管命令。当前=`IMPLEMENTATION_FROZEN_PENDING_LIMITED_REVIEW`。
+
+### 2026-08-05T21:13:57+08:00 限定审核终态
+
+- 审核=`PASS/0`;独立目标=`21/21 PASS`,owner 范围 project=`97/97 PASS`;安全后半程不再追加评审。
+- 交付边界:精确说明扩展按钮链只导出 TXT;只有请求方通过合法界面取得完整且下载完成的 AV 文件后,才运行 `accept-browser-file`。当前=`READY_REQUESTER_HANDOFF_INGRESS_BLOCKER_PRESERVED`。
+- 原生交付=`HANDOFF-INFODEV-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-BACKHALF-PASS-DELIVERY-20260805-001` 已发送;开发计划终态=`COMPLETE_PASS_0_BACKHALF_DELIVERED_INGRESS_BLOCKER_PRESERVED`。
+
+### 2026-08-05T21:32:08+08:00 请求方验收收口
+
+- 请求方回执=`RECEIVED_PASS_INGRESS_BLOCKED`;安全本地文件后半程正式验收,入口阻断继续保留。
+- 当前=`ACCEPTED_CLOSED_LOCAL_BACKHALF_INGRESS_BLOCKED`;开发员停止本事项,不重跑测试、不改代码、不触发真实下载、不发送后继审核。合法完整文件以后出现时的真实执行归视频下载员。
+
+### 2026-08-05T22:39:30+08:00 扩展入口续开计划
+
+- 原事项按管理员窄范围决定续开;唯一目标=`BV1HA3o6oEJJ`,不泛化账号、博主、BVID 或批量能力。当前阶段只允许安全设计和账本,不修改冻结 bridge、现有 TXT 扩展或任何产品代码。
+- V001 设计采用项目自有相邻 side panel + 专用 Native Host + Native Messaging + `io.StringIO` CookieJar;不使用 localhost、CORS、持久 Cookie 文件、浏览器 profile、token 文件、签名 URL argv/日志或外部消息入口。
+- 实现顺序:V001 独立设计 PASS → 离线/合成最小实现 → 同一审核链实现 PASS → 由视频下载员执行一次真实安装/下载/后半程验收。开发与审核阶段真实秘密、网络媒体、Chrome、注册表及正式目录动作均为 0。
+- 设计审核唯一入口=`dev.reviewer.project / inforev`;当前状态=`PENDING_CONSOLIDATED_SECURITY_DESIGN_REVIEW_IMPLEMENTATION_FROZEN`。
+
+### 2026-08-05T23:09:20+08:00 V002 安全设计返修计划
+
+- V001 `HOLD/4` 已一次列全;不实施代码。V002 与 V001 组合,冲突处以 V002 为准,只补 F1—F4,不重开已通过范围。
+- V002 固定扩展 public identity=`oidmclckpdmpabbfedplkbdplmfcenbb`、协议/secret schema、broker/worker 输出隔离、`YTDLP_NO_PLUGINS`+空 plugin maps、单次 processed object、HttpFD/DashSegmentsFD allowlist、single fallback MKV remux、固定重试/墙钟和 Windows kill-on-close Job Object。
+- 当前唯一下一步:向原 `dev.reviewer.project / inforev` 提交 V002 限定复审;PASS 前 implementation/install/real auth/download 全部冻结。当前=`PENDING_V002_FOCUSED_DESIGN_REREVIEW_IMPLEMENTATION_FROZEN`。
+
+### 2026-08-05T21:20:11+08:00 股票估值每日台账 MySQL 变更与 HOLD/4 收口计划
+
+- 权威变更:SQLite 初版保留为历史,不再是当前合同;正式实现只保留 `mysql.connector` 单后端,写 `stock_valuation`,只读 `trading_xuntou`,不得调用 V2 行情、联网或访问 `tianxia`。
+- 已完成:MySQL schema/连接/CRUD、125/136 正式结果包重建、逐字段导入冲突门禁、后续版本次日生效、未复权完整日日 K schema/行级门禁、逐股隔离、事务价格+判定、latest 成对提交和单次 PowerShell。
+- 已完成验证:目标 `19/19`、project `116/116`、200 行暖查询 `<1s`、静态/编码/凭据/治理检查全部 PASS;生产 `daily_price/daily_judgement/latest` 保持 0。
+- 当前唯一动作:冻结文件 bytes/SHA-256 并向同一 `dev.reviewer.project` 提交一次 MySQL+HOLD/4 集中复审。状态=`IMPLEMENTATION_FROZEN_PENDING_MYSQL_HOLD4_REREVIEW`。
+
+### 2026-08-05 股票估值每日台账 MySQL HOLD/2 两点限定修复
+
+- 前序集中复审只保留 F4-R1/P1:stage 早期异常零残留、判定业务错误不得留下 price 半状态;F1—F3 与 MySQL 单后端合同不重开。
+- 实施:扩展 `_write_pair` BaseException 边界覆盖两份 stage 的 create/write/flush/fsync;将区间校验和 V1 标签计算前移到任何 daily_price INSERT 之前。
+- 反例:24 组 stage 故障矩阵与 `base_low=0` 入口均通过;目标 `21/21`、project `118/118`。
+- 下一步仅更新冻结哈希并提交同一审核员 F4-R1/P1-only 复审;不运行生产 daily、正式 latest、真实行情或计划任务。状态=`FROZEN_PENDING_TWO_FINDING_REREVIEW`。
+
+### 2026-08-05 前复权专表 delta
+
+- 用户把生产 daily 价格口径改为 `trading_xuntou.cn_stock_kline_1d_front` 前复权专表,权威需求已同步;本 delta 优先于此前未复权旧表合同。
+- 只改 `market.py`、行情合同测试、README/工程说明/证据与必要账本;不得读取旧表、使用 `dividend_type` 或回退网络。
+- 离线门禁:专表必需列正反例、固定 source ID、旧表/字段静态排除、请求日历最大开市日绑定、完整日/币种/身份/价格/时间反例;再跑完整目标和 project 回归。
+- 生产 daily 由需求 owner 完成只读预检并另行授权,本开发线程当前只实施和离线测试。状态=`FRONT_TABLE_DELTA_IN_PROGRESS_NO_PRODUCTION_DAILY`。
+
+### 2026-08-05T22:46:51+08:00 前复权专表 delta 完成计划
+
+- 已完成 `market.py` 专表 schema/SQL/身份/source/time 合同,`cn_stock_instrument_static` 降为非依赖诊断,并保留 MySQL、F2/F3、F4-R1/P1 全部既有修复。
+- 已完成专表聚焦 `4/4`、目标 `22/22`、project `119/119` 离线回归;不调用网络、不安装计划任务、不写 `trading_xuntou/tianxia`。
+- 需求 owner 已完成唯一一次 2026-08-05 正式日更;开发线程不得重复执行,只冻结其只读计数/哈希/输出证据。
+- 下一步只向既有 `dev.reviewer.project` 提交 MySQL+HOLD 修复+前复权专表的合并实现复审,目标 `PASS/0`。状态=`FROZEN_PENDING_CONSOLIDATED_IMPLEMENTATION_REREVIEW`。
+
+### 2026-08-05T23:03:36+08:00 身份硬门替代快照
+
+- 将 ticker/symbol 和 `source=xtquant` 从规范化后比较收紧为大小写敏感原值精确比较,补两个反例;不改变 schema、查询表、完整日、数据库或输出合同。
+- 最终回归 `22/22`、project `119/119`;下一步一次性发送完整替代哈希,之后冻结不改。状态=`REPLACEMENT_SNAPSHOT_READY`。
+
+### 2026-08-05 陈旧日历水位反例闭环
+
+- 全局水位门禁前移:current 日历资产必须覆盖请求 as-of 后才能派生 expected open day;行边界复验同一条件。
+- 反例与完整回归通过后发送最终替代快照;生产 daily/历史行/outputs 均不重跑。状态=`FINAL_REPLACEMENT_SNAPSHOT_READY`。
+
+### 2026-08-05 股票估值每日台账 MVP PASS/0 计划终态
+
+- 最终审计=`DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-STALE-CALENDAR-FINAL-REREVIEW-20260805-001 / PASS/0`;实现与现有正式输出均已接受,无需继续评审。
+- 计划目标已完成:MySQL 单后端、四业务表、正式估值版本导入、前复权专表完整日价格门禁、V1 四档判定、`init/daily/list/show/export`、成对原子 latest、PowerShell 手工入口、测试与工程证据全部交付。
+- 审核基线固定为 `125/136/124/124`;审后需求方独立导入后 live 估值数据报告为 `183 security / 194 valuation_version`,但 daily/latest 仍保持已审 124 行。陈旧 authoritative calendar 已正确阻断新 daily,待水位更新后由运维另行运行,不由开发线程重复执行。
+- 冻结边界继续有效:只读 `trading_xuntou.cn_stock_kline_1d_front`,只写 `stock_valuation`;禁止旧表、V2/network、SQLite、`tianxia`、provider fallback、自动计划任务和交易指令。
+- 当前计划状态=`ACCEPTED_CLOSED`;停止本事项实现、生产重跑和审核。未来新增港股、provider/backend/network/schedule 或交易能力时另立新需求。
+
+### 2026-08-06 已登录会话下载入口 HOLD/3 最小续修计划终态
+
+- 单一 owner 续派已生效;不创建新 task 或 review chain,只按既有 HOLD/3 修 F2-R1、F4-R1、F5-R1。
+- 已完成同步 start lease、一次性 prepare/start 关联、外部 source/build approval receipts、正式 onefile 用法与离线反例;冻结 bridge 与其他已通过合同不修改。
+- 已完成扩展 31/31、bridge 21/21、project 130 tests 及静态/治理核验。下一步只向原 `dev.reviewer.project / inforev` 提交一次限定复审,目标=`PASS/0`。
+- 实现复审 PASS 之前不构建、不安装、不写 HKCU、不接触 Chrome/Cookie/session/网络/下载/真实媒体/正式路径。状态=`FROZEN_PENDING_HOLD3_LIMITED_REREVIEW`。
+
+### 2026-08-06 已登录会话下载入口 HOLD/3 PASS 后分阶段计划
+
+- 限定复审=`PASS/0`,本轮最小离线返修结束;不追加功能、设计或审核链。
+- reviewer source approval 只允许获授权操作者对 exact source manifest 执行一次受控 build;本开发续修回合不自行构建。
+- build 后必须先冻结唯一 EXE 与 schema2 receipt 的 bytes/SHA-256,并取得 reviewer/admin 树外 exact-build approval;在此前 install/HKCU 均为 `NOT_AUTHORIZED`。
+- 当前=`REPAIR_PASS_CONTROLLED_BUILD_SEPARATELY_AUTHORIZED`;真实认证、下载、媒体和正式目录动作继续不属于本轮。
+
+### 2026-08-06 exact-source build 预检结果
+
+- 已完成 exact approval/source manifest、输出目录不存在和本机依赖预检;未进入 builder。
+- 固定目标环境返回 `No module named PyInstaller`,本机其他受查环境也没有 PyInstaller 6.15.0;按合同不得联网安装、降级版本或改写批准锚。
+- 当前=`BLOCKED_BEFORE_BUILD_COUNT_0`;等待管理员提供/授权固定离线构建环境。成功 build 之前不提交 exact-build hash-only 复审,install/HKCU 仍为 0。
+
+### 2026-08-06 exact-source build 成功后的限定计划
+
+- 管理端已预置并冻结离线 PyInstaller 6.15.0;一次受控 build 已成功,`build_count` 永久固定为 1,不得重跑。
+- 已完成唯一 EXE/schema2 receipt bytes/SHA-256、输出集合、receipt 链、work/spec 清理与秘密日志扫描。
+- 下一步只向原审核员提交 exact-build hash-only 限定确认;由 reviewer 创建树外 install-exact-build approval。确认前停止,install/HKCU=0。
+- 当前=`FROZEN_PENDING_EXACT_BUILD_HASH_ONLY_CONFIRMATION`。
+
+### 2026-08-06 exact-build 确认后计划边界
+
+- hash-only 限定确认已 `PASS/0`,reviewer-owned 树外 install-exact-build approval 已形成;本开发构建阶段完成。
+- 后续若管理员/请求方明确调度 install,必须使用完全相同的 EXE、schema2 receipt、source approval、build approval 与固定配置,先 validation-only 再决定是否安装。
+- 本开发线程不自行安装或写 HKCU;任何字节、哈希、路径或输出集合漂移均 fail closed。状态=`EXACT_BUILD_CONFIRMED_INSTALL_SEPARATELY_SCHEDULED`。
+
+## DEV-PLAN-PROJECT-INFO-HIBOR-NEW-EMULATOR-ADAPTER-20260806-001
+
+- 类型:项目工具轻量实现;不新增模块、服务、并发或公共内核合同,不需要重型编码方案。
+- 输入:现有 `HIBOR_EXACT_BATCH_V001` JSON、`runtime.adb_executable`、`runtime.device_serial` 与 `adb devices` 只读结果。
+- 输出:内核 TaskSpec 中唯一、已验证的在线设备串号;原批次 manifest/delivery/timing/terminal 合同不变。
+- 步骤:先在任何批次目录和内核命令前解析设备;显式串号做精确在线校验,`auto` 只接受恰好一个在线设备;补离线反例和现有 5 项回归;更新示例、用法、目录导读与执行日志。
+- 验收:语法与 CLI help;目标单测覆盖 1/0/多设备、显式不存在、offline/unauthorized、ADB 失败和 preflight 失败零输出;project 回归;治理校验;如环境允许,只读核对 `emulator-5554`,不触发研报采集。
+- 安全边界:不调用现有内核 `--execute` 做真实测试,不写额度/正式归档,不修改 `dev/ana-dev`;共享映射仅文档化,不增加 schema 字段。
+- 当前状态:`IMPLEMENTATION_IN_PROGRESS`。
+
+### 2026-08-06 新模拟器最小适配计划终态
+
+- 已完成显式串号校验、`auto` 唯一设备解析、preflight-before-output、离线正反例、现有执行器回归、用法/示例和只读设备 smoke。
+- 现有 ADB 直拉继续为唯一默认实现;共享目录仅登记环境事实,不增加复制服务、schema 或完成判定。
+- 当前状态=`IMPLEMENTATION_COMPLETE_PENDING_REQUESTER_ACCEPTANCE`;采集员使用旧任务 JSON 时只需更新 `runtime.device_serial`,不需要其他 schema 迁移。
+
+### 2026-08-06 新模拟器最小适配验收终态
+
+- 请求方普通功能验收=`ACCEPTED`;计划目标、失败关闭、迁移说明和范围边界全部完成。
+- 当前状态=`ACCEPTED_CLOSED`;停止本事项代码、测试、APP 与下载动作。正式内核的 MemuIME 输入兼容由既有 ana 核心修复事项独立处理。
+
+## DEV-PLAN-ANA-HIBOR-NEW-EMULATOR-INPUT-COMPAT-20260806-001
+
+- 类型:需求支持的轻量兼容与安全修复,不新建设计版本、不重构采集内核。
+- 输入:新模拟器环境事实、现有 `AdbClient.input_text/start_package` 和 UI 逐字回读链路。
+- 输出:安全 ASCII 原生输入;MemuIME 明确支持门禁;服务失败不 paste;monkey 缺失时 WelcomeActivity 回退。
+- 验收:4 类要求正反例及 UI 回读测试;完整离线单测、compileall、diff check;真实 APP/ADB/download/quota/archive=0。
+- 状态:`IMPLEMENTED_PENDING_LIGHT_INDEPENDENT_REVIEW`。
+
+### 2026-08-06 计划终态
+
+- 独立轻量复核=`PASS/0/0`,required_fixes=`NONE`;本最小修复已闭环。
+- 后续真实 APP/ADB、下载、quota 和归档继续沿用原执行边界;本修复不新增执行授权。
+
+## DEV-PLAN-PROJECT-INFO-VIDEO-TEXT-HOST-WRITE-EOF-STABILITY-20260806-001
+
+- 类型:有明确上游口径的相邻阻断 bug;管理员要求紧凑实现+证据审核,不新增重型架构链。
+- 输入:只读 active registry/installed hashes、扩展 1.0.5 身份、上游 v1.0.5 full commit/source archive/release asset;不得读取认证秘密或媒体临时内容。
+- 实现:冻结可复现映射;在上游 host 入口加入单一串行 writer、预期管道终止分类、stdin/stdout 生命周期、bounded shutdown、子进程 kill/join 和私有受限脱敏诊断;保持现有消息 schema 与任务可见状态。
+- 测试:fake stdin/stdout 与 child 正反例;Chrome close/cancel/complete/child exit/repeated disconnect;至少 50 cycles;预期管道错误不触发 uncaughtException/stderr/dialog,非管道错误仅写受限私有诊断;真实 sleeping child 无孤儿。
+- 证据:source/release/archive/installed hashes、构建清单、输出 hash、rollback snapshot/restore steps、零真实动作清单和完整测试结果。
+- 交付:只向既有 `dev.reviewer.project / inforev` 提交一次实现+证据审核;PASS 前不安装、不执行真实会话。PASS 后仍需树外 exact-build install approval,开发员不得自安装或自批。
+- 当前状态:`IMPLEMENTATION_IN_PROGRESS_OFFLINE_ONLY`。
+
+### 2026-08-06 计划停止点
+
+- 计划第一门“active binary 到不可变源码的映射”已完成,但结果证明 active Windows host 是 `host_win.py`,不是截图堆栈所示的 Node/Electron 进程。
+- 为保持“精确对象、不虚假修复”,代码、50 轮、构建与安装计划均暂停;不用 `host.cjs` 的明显缺陷替代未定位的现场故障。
+- 恢复条件只有一个:取得弹窗时 faulting process 的 PID/父 PID/可执行路径或等价 WER 记录,并建立到可审计源码/构建物的不可变映射。
+- 当前状态=`BLOCKED_BEFORE_IMPLEMENTATION`。
+
+### 2026-08-06 范围纠正后计划
+
+- 代码、测试、50 轮、构建、审核和安装步骤全部取消;不对 active host 进行任何产品变更。
+- 不安排主动监视或轮询。如上游在真实复发时主动提供最小 faulting-process 身份证据,才做一次只读核对;未复发时无待执行动作。
+- 认证下载发布不再依赖本事项。
+- 当前状态=`EVIDENCE_AWAITING_NO_PRODUCT_MUTATION`。
diff --git "a/dev-doc/\345\274\200\345\217\221\345\256\241\350\256\241\346\212\245\345\221\212.md" "b/dev-doc/\345\274\200\345\217\221\345\256\241\350\256\241\346\212\245\345\221\212.md"
index 414fe1d..d155918 100644
--- "a/dev-doc/\345\274\200\345\217\221\345\256\241\350\256\241\346\212\245\345\221\212.md"
+++ "b/dev-doc/\345\274\200\345\217\221\345\256\241\350\256\241\346\212\245\345\221\212.md"
@@ -1407,3 +1407,1600 @@
- 审计状态:`PASS`
- 阻断问题数:`0`
- 操作手册单文件同步确认完成;无需重跑网络、fixture、V2/V1 测试或 smoke。本事项的开发审核与授权后单文件确认链已闭环。
+
+## DEV-AUDIT-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-DESIGN-20260803-001
+
+- 记录时间:`2026-08-03T02:07:13+08:00`
+- 审核阶段:请求方真实验收 `HOLD/1` 后的 V003 限定方案审核。
+- 审核对象:`DEV-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-20260801-001`
+- 恢复请求:`meeting_slide_v003_design_review_resume_request`,来源任务=`019fb7a4-bdfd-79f2-bd6b-e67e2b7d8efd`,前序消息=`msg_20260801225701882_593be603`。
+- 请求方反馈:`msg_20260801214538194_7196a0fa=HOLD/1`;合格主体固定为旧输出 `slide_002..slide_038`,旧 `slide_001` 与 `slide_039..slide_053` 必须排除。
+- 执行方:`dev.developer.project / infodev`;独立审核方:`dev.reviewer.project / inforev`。
+- 需求:`ai-media-processor/draft/会议录屏PPT页面提取方案_v0.1.md`=`9764 bytes / A9FCB3AD03FEC637E287D53C16DB0D22A40156F94579377F2D83324AA630A740`。
+- V003:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003.md`=`9128 bytes / C9EB544A7F0E1A160237E3F9C9E426B093AD37D82F22808E8B6A792BE92D1051`。
+- 实现基线保持未改:`dev/project-dev/extract_ppt_slides.py`=`27482 bytes / 052F5B3BA40EA6C1BB87AF9A7BB4E9C50A84C5A45929540F23606582D30D7D1B`;测试基线=`29015 bytes / 35CB570ACC6C9EE3AB25EB9B1B15C11691F0902AE23314445B645A1BA3760484`。
+- 审核外部动作:源视频读取=`0`,既有 PNG/PDF 图像读取=`0`,FFprobe/FFmpeg=`0`,媒体重跑=`0`,测试运行=`0`;仅做方案、需求、前序审计、执行账本和代码接入点的静态只读核对。
+
+### Findings
+
+- 无阻断 finding。V003 在既有稳定段、动画合并和全局去重之后、原分辨率 PNG 提取之前增加单一确定性主序列选择,没有重开或改写已通过的 V002 主链。
+
+### 限定合同核对
+
+1. `PASS`:512 箱 RGB 紧凑直方图只随最终逻辑页保存,每页固定约 2 KiB;不保存扫描 RGB 帧,不引入 OCR、人像/人脸识别、语义模型、区域裁剪、遮挡、新依赖或新系统,符合 4 小时流式资源边界。
+2. `PASS`:主题标记覆盖、单页间隙、最少标记、全局占比和区间密度均有命名常量;分组、门禁、胜出排序及无合格组原样保留均为确定性规则,不按人物或文字语义猜测裁页。
+3. `PASS`:现有 53 页离线特征证据得到唯一主组:标记旧页 `2,4..8,10..38`,闭区间为旧页 `2..38`,`35/37=0.946` 密度、`35/53=0.660` 全局占比;由区间保留旧 `slide_003`、`slide_009` 两个单页主题偏离,同时排除旧 `slide_001` 与 `slide_039..053`。该证据只冻结规则和预期,不冒充修改后产品验收。
+4. `PASS`:保留页继续从原视频提取整幅原分辨率画面;算法不分析或修改页面区域,因此有效 PPT 上的小型人物窗口随整页保留,不裁剪、不遮挡。
+5. `PASS`:实现门禁覆盖直方图合法性、53 页结构桩、各阈值反例、全主题/无合格组保守行为、过滤只调用一次且位于原图提取前、连续重新编号,并要求既有目标/project 回归、中断、原子提交、合成 A完整/B 和短片 5 页合同不得回退。
+6. `PASS`:实施阶段先只读复用旧 53 张 PNG,通过产品同一 helper 和 `select_main_sequence` 证明选中旧 `2..38`,再生成独立 37 页离线 PNG/PDF 与联系表;用户手工生成的 37 页 PDF 不被用作代码实现或产品主链通过证据。
+7. `PASS`:V003 方案、离线测试和限定实现审核 PASS 前不得运行真实源;实现审核 PASS 后仅允许在独立 `real-full-v2.slides` 最多完整运行一次。旧 53 页输出、资源证据及归档 PDF保持只读,唯一复验失败即停止媒体重跑,不调参、不扩架构。
+
+### 后续人工 PDF 状态隔离
+
+- 本次恢复审核时已知:请求方后来按用户明确指令,人工用旧 `slide_002..038` 重新生成并替换了 `ana-data/news-小郑/` 下的归档 PDF。该人工 37 页文件只解决用户当下阅读需求,不是 V003 代码实现、离线回放或真实产品验收证据。
+- V003 实施仍必须以任务临时根保留的原始 `real-full.slides/pages/` 53 张候选 PNG 为离线回放输入;开发员不得读取人工 37 页 PDF 反推索引、不得再次改写该归档文件,也不得以其页数冒充自动过滤通过。
+
+### 已冻结且不重审的合同
+
+- V002 稳定段防漂移、动画更新、历史去重、FFmpeg 时间映射与 BaseException 回收、逐页 PDF、正式目录原子提交继续有效。
+- 前序实现审核 `PASS/0`、目标测试 `22/22`、project 回归 `41/41`、完整运行耗时/资源、PNG/PDF RGB 顺序和源四项指纹保护继续冻结;本轮 PASS 不替代 V003 实现审核或请求方最终真实验收。
+- 产品仍为单视频 CLI、一个脚本/一个测试文件/一页用法;不新增 PPTX、JSON、GUI、API、数据库、批处理、转写联动、Skill、GPU 或用户可调阈值。
+
+### Open questions
+
+- 无。V003 已把本轮唯一真实样本失败映射为可实现、可离线复验、可失败停止的最小自动规则;无需在方案阶段运行媒体。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 实现授权:允许 `dev.developer.project / infodev` 严格按 V003 修改既有单脚本、对应测试与用法,完成全部离线单元/回归及旧 53 页只读回放后提交限定实现审核。
+- 真实媒体门禁:实现审核 PASS 前不得读取或运行源视频;实现审核 PASS 后最多运行一次完整源视频,输出到全新独立目录,由 `case_analysis.media_processor` 最终验收。
+
+## DEV-AUDIT-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-IMPLEMENTATION-20260803-001
+
+- 记录时间:`2026-08-03T20:44:14+08:00`
+- 审核阶段:V003 `PASS/0` 后的限定实现、离线测试与旧 53 页只读回放审核。
+- 审核对象:`DEV-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-20260801-001`
+- 开发提交消息:`msg_20260803025656382_a037bf27`;handoff=`HANDOFF-INFODEV-INFOREV-MEETING-SLIDE-V003-IMPLEMENTATION-REVIEW-20260803-001`。
+- 恢复请求:`meeting_slide_v003_implementation_review_resume_request`,来源任务=`019fb7a4-bdfd-79f2-bd6b-e67e2b7d8efd`。
+- 前序方案审计:`DEV-AUDIT-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-DESIGN-20260803-001=PASS/0`。
+- 执行方:`dev.developer.project / infodev`;独立审核方:`dev.reviewer.project / inforev`。
+- 冻结设计:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003.md`=`9128 bytes / C9EB544A7F0E1A160237E3F9C9E426B093AD37D82F22808E8B6A792BE92D1051`,与方案 PASS 快照一致。
+- 实现:`dev/project-dev/extract_ppt_slides.py`=`32133 bytes / 9C4F57ED56B01C9EE2BD68CDC532BC865F73EB3662E01B6600410C5660F1F605`。
+- 测试:`dev/project-dev/test/test_extract_ppt_slides.py`=`35037 bytes / 63298A48F4F7944514301D0BC0AC619C58464651ABF8F53C23E15E85565106FA`。
+- 用法:`dev-doc/project-doc/会议录屏PPT页面提取工具.md`=`6729 bytes / C883E7A37846621E9889DDA584D5EC16066A85848A732C3C92CDE891A0485D6C`。
+- 开发执行记录:`DEV-LOG-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-OFFLINE-IMPLEMENTATION-20260803-001`。
+
+### Findings
+
+- 无阻断 finding。
+
+### 实现与冻结合同核对
+
+1. `PASS`:`_palette_histogram` 在既有最大 320 像素宽 RGB 扫描帧上生成归一化 `float32[512]` 颜色直方图;只随最终页候选保留约 2 KiB 特征,不保存 RGB 扫描帧,不改变长视频流式资源模型。
+2. `PASS`:颜色特征按 `FrameFeature -> PageCandidate -> LogicalPage.current_palette` 传递;稳定段采用其最终 committed best 的直方图,同页动画只有在页面状态更新时同步替换 palette,保留旧态或历史重复时不会污染最终页特征。
+3. `PASS`:`select_main_sequence` 严格实现覆盖率 `0.12`、最多一个非标记页间隙、至少 8 个标记、全局占比 `0.50`、区间密度 `0.80`,并按标记数、区间长度、区间覆盖和、早起点、小颜色箱确定性排序;无合格组或胜出区间覆盖全部候选时原样保留。
+4. `PASS`:所有逻辑页 palette 在选择前统一检查 512 维、有限、非负和归一化;非法特征显式 `SlideExtractionError`,空候选显式失败,不存在空列表或静默裁页 fallback。
+5. `PASS`:产品主链在 `scan_video` 形成稳定/动画/去重候选后只调用一次序列过滤,并在任何原分辨率提取前完成;下游仍按筛选列表从 `slide_001` 连续重编号并逐页写 PDF。保留页继续输出整幅原画面,小型人物窗口不裁剪、不遮挡。
+6. `PASS`:代码差异仅增加颜色特征、纯函数序列过滤和一次主链调用;V002 的稳定段、动画/历史去重、FFmpeg 时间与回收、PDF writer、路径预检、原子目录提交、BaseException 清理、两个公开 CLI 参数均未回退。
+7. `PASS`:未新增 OCR、人像/人脸识别、语义模型、区域识别、裁剪/遮挡、新模块、新依赖、新参数、JSON、GUI/API、批处理、数据库、Skill、GPU 或额外产品产物;改动文件和开发账本均在 V003 授权范围内。
+
+### 测试与独立离线复验
+
+- 审核侧使用捆绑 Python,先把子进程 `PATH` 置空,从物理上禁用 FFmpeg/FFprobe;`py_compile=PASS`,CLI help=`PASS`,公开参数仍只有 `video` 与可选 `--output`。
+- 目标测试:`29 discovered / 26 PASS / 3 SKIP / 0 FAIL`。三个 SKIP 精确对应既有 `large stderr`、VFR/非零 PTS 和中文路径合成视频三项 FFmpeg 集成测试;V003 新增颜色量化、旧 53 页结构、三个保守门禁、全主题保留、非法 palette、主链调用顺序和连续重编号全部实际执行并通过。
+- project 完整离线回归:`48 discovered / 45 PASS / 3 SKIP / 0 FAIL`;其余工具测试使用桩对象,没有外部媒体动作。
+- 审核侧独立读取旧 `real-full.slides/pages/` 的 53 张候选 PNG,以当前产品 `_gray_feature` 和 `select_main_sequence` 复算:`selected_count=37`,原索引严格为连续 `2..38`,无多页、少页或乱序。
+- 开发离线证据:`sequence-filter-offline-v003-evidence.txt`=`16268 bytes / A12AF6E0F1B3D4E7CBB2874924E07CA929CF22CE80BC3D7AE7D9E3527B7CDA60`;证据声明源视频读取、FFprobe、FFmpeg 均为 `0`。
+- 审核侧对新输出 37 张 PNG 做物理哈希复核:逐页与旧候选 `slide_002..038` 对应 SHA-256 完全一致,mismatch=`0`;新 PDF 实物=`58570040 bytes / 71C508E94852FBE36AA49DA1B17DDD3F634A82463A06E8AB029709517BB68535`,与提交证据一致。
+- 旧 53 页开发 PDF 实物仍为 `112814367 bytes / FD289013234317F5D93EA41475C96794581E33CE2C466311E1E3A0DC2F6B299E`;请求方人工 37 页归档仍为 `58570040 bytes / 71C508E94852FBE36AA49DA1B17DDD3F634A82463A06E8AB029709517BB68535`,创建/修改时间未变。新离线 PNG 的物理来源映射已独立证明,不以人工 PDF 页数冒充自动实现。
+- strict UTF-8=`PASS`,范围内 `git diff --check=PASS`;治理校验=`OK / projects=1 / warnings=0`。
+- 审核外部动作:源视频读取=`0`,FFprobe=`0`,FFmpeg=`0`,真实/合成视频运行=`0`;只读取代码、文本证据和旧候选 PNG 做离线特征复算及哈希核对,没有查看或更改任何源视频。
+
+### Open questions
+
+- 无。真实扫描帧与旧 PNG 离线缩放的细微编码差异按 V003 约定留给唯一一次完整源视频验收;这不是继续阻断已正确实现且离线闭环的理由。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- V003 限定实现、离线测试、53→37 自动选择、连续重编号和证据保护均通过;人工 37 页 PDF未替代代码实现。
+- 真实运行授权:允许 `dev.developer.project / infodev` 对授权的完整源视频最多运行一次,输出必须进入全新独立 `real-full-v2.slides` 与证据目录;不得覆盖旧 53 页开发输出或人工归档 PDF,不得先做额外短片/预跑,不得在失败后重试或调参。
+- 运行后必须记录 exit、耗时、进程树资源、候选数/最终页数、PNG/PDF RGB 与顺序、联系表、暂存残留及源大小/创建时间/修改时间/SHA-256 前后一致,并沿原任务链交 `case_analysis.media_processor` 最终验收。本 PASS 不替代最终页面效果验收。
+
+## DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001-DESIGN-20260804-001
+
+- 记录时间:`2026-08-04T11:35:07+08:00`
+- 审核阶段:本地媒体主机响应保护 V001 重型方案独立审核。
+- 审核对象:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`
+- 审核请求:`HANDOFF-INFODEV-INFOREV-MEDIA-HOST-RESPONSIVENESS-V001-DESIGN-REVIEW-20260804-001`;前序授权=`HANDOFF-MEDIA-INFODEV-MEDIA-HOST-RESPONSIVENESS-20260804-001 / AUTHORIZED_MINIMAL_IMPLEMENTATION`;来源任务=`019fb7c5-b721-7332-b5c3-7120176fe016`。
+- 执行方:`dev.developer.project / infodev`;独立审核方:`dev.reviewer.project / inforev`。
+- V001:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001.md`=`13606 bytes / 7F0F5505EF1D92C73FBE6EDFF382F6E25D95A0ABE6D28A7A366353B88CA80273`。
+- 产品基线保持冻结:`dev/project-dev/extract_ppt_slides.py`=`32133 bytes / 9C4F57ED56B01C9EE2BD68CDC532BC865F73EB3662E01B6600410C5660F1F605`;`dev/project-dev/transcribe_media.py`=`20996 bytes / 309166EEFE11A74472EA355CA4AF87F9432A15393B9ABE6D69F7F1054055DE35`。
+- 审核依据:上游原生授权、项目/common 编码与开发审计规范、项目配置、开发事项总纲/计划/执行日志、V001 及两个产品脚本真实接入点。
+- 审核外部动作:业务源读取=`0`,FFprobe/FFmpeg=`0`,合成或真实媒体运行=`0`,真实片段生成=`0`,产品代码/测试/用法修改=`0`;仅做静态只读核对与一次 `nvidia-smi` 只读字段可用性检查,确认本机 decoder utilization 查询可返回数值。
+
+### Findings
+
+#### F1(阻断,流程/失败合同):CPU 回退触发边界过宽,会把页面算法或业务判定错误伪装成“NVDEC 不可用”
+
+- V001 第 4.3 节第 91 行把“硬件路径非零退出、无完整帧或其他普通扫描失败”全部纳入一次 CPU 回退。当前扫描函数同时包含 rawvideo 读取、稳定段 detector、页面 classifier 和“未发现稳定页面”等业务判断;“其他普通扫描失败”因此可吞入特征/分类器缺陷、合法的空业务结果、内存或清理异常。
+- 这会改变既有错误语义并用 fallback 掩盖主路径错误,违反上游“硬件路径不可用才回退”及冻结页面算法/失败合同。即使 CPU 第二次扫描成功,首轮算法错误也会被错误宣告为硬件不可用;若第二次失败,原始根因还会被 CPU 错误覆盖。
+- `required_fix`:在修订方案中冻结硬件可归因的内部失败边界。只有硬件 decoder/transfer 的启动失败、FFmpeg 非零退出、零帧或半帧管道失败等外部 NVDEC 失败才允许触发一次 CPU 扫描;完整解码后的 detector/classifier/页面合法性、文件/日志、清理及其他业务异常必须原样传播,不得回退。“完整扫描但无稳定页面”继续走既有业务失败,不得改跑 CPU。补桩测试分别证明:硬件失败恰好一次 CPU;算法/分类器/空业务结果/`KeyboardInterrupt`/`SystemExit` 均为零次 CPU;CPU 再失败无第三次尝试且保留可定位根因。
+
+#### F2(阻断,业务合同/测试):现有门禁只证明 NVDEC 命令能启动,没有证明 H.264/H.265 下页面结果不漂移
+
+- V001 冻结稳定段、动画合并、去重、主课件序列、PNG/PDF 和时间映射不变,但第 6.3 节只要求一个 2 秒 H.264 NVDEC `exit=0` 探针;这只能证明 decoder/filter 管道可执行。NVDEC 与软件解码在色彩转换、像素舍入和帧输出上可能有差异,而当前页面算法使用 MAD、changed-ratio、颜色直方图等阈值,单纯复跑既有单元测试不能证明切换 decoder 后逻辑页、时间戳和最终顺序不变。H.265 实际路径也只有静态映射,没有实际合成链路证据。
+- 这不满足 common 性能规范“优化必须证明结果不漂移”和上游业务输出冻结合同,且唯一真实 590 秒运行不能同时再跑 CPU 基线来补足差分证据。
+- `required_fix`:不读取业务源,在实现阶段增加短小、确定性的 H.264 与 H.265 合成页序列(至少覆盖稳定页、动画更新、重复页和页面切换);分别用实际 NVDEC 与强制受限 CPU 路径执行同一 `_scan_once`/主序列链,断言采样数、逻辑页数、选中页顺序和相对时间戳一致,并验证最终 PNG/PDF 页数、顺序和 RGB 尺寸合同。命令测试同时断言 `-hwaccel/-hwaccel_output_format/-c:v/-threads` 位于输入 `-i` 之前,CPU 的解码线程参数同样属于输入侧;H.264/H.265 均须留下实际 decoder 路径证据。像素级允许的非业务差异无需逐字节冻结,但任何页面/时间/顺序漂移必须阻断。
+
+#### F3(阻断,证据/采样):500 ms 门禁可在采样密度不足时形成零滚动窗口并空通过,10 秒条件也未按实际时间计算
+
+- V001 第 7.3 节要求 5 秒窗口至少 10 个连续样本,却仅把单次间隔 `>1500 ms` 判无效;如果实际间隔持续为 0.8—1.5 秒,任一 5 秒区间都可能不足 10 个样本,从而没有“完整窗口”,而“所有完整窗口 `<85%`”会成为空集合真值。固定“连续 20 个样本≈10 秒”在允许的抖动范围内也可能覆盖 10—30 秒,不能严格证明上游的连续 10 秒门禁。
+- 当前合同没有冻结首个性能计数器样本的 warm-up、有效窗口非空和扫描阶段覆盖率;因此采样器可生成字段齐全但 CPU gate 不可判定的假 PASS。
+- `required_fix`:以实测时间戳冻结非空、非空过的采样合同。至少要求:丢弃并标记 counter warm-up;从扫描 FFmpeg 被观察到至其退出形成连续覆盖;每个进入判定的 5 秒尾窗必须有不少于 10 个有效样本,窗口数必须 `>0`,任何密度不足/缺口直接 `sampling_valid=false`;高 CPU 连续段按样本实际起止时间累计,持续时间达到 `10.000 s` 即失败,而不是按固定 20 行近似。`resource_samples.csv/resource_summary.json` 必须保存窗口数、窗口成员、最大间隔、高 CPU 段起止/持续时间,并用合成 CSV 单测覆盖临界 `<85/==85`、恰好 10 秒、稀疏采样和零窗口反例。
+
+### 已通过且冻结的合同
+
+1. `PASS`:只修改两个现有产品脚本、对应测试/说明与一个测试侧只读采样器,不增加服务、API、GUI、数据库、调度器、产品 package 或公开参数;共享保护 owner 放在既有 `transcribe_media.py` 的模块边界可实施。
+2. `PASS`:两个 CLI 在外部动作前共用非阻塞 Windows named mutex,根 Python 与默认 FFprobe/FFmpeg BelowNormal,异常退出释放 mutex、恢复失败不覆盖业务异常或已提交成功的生命周期方向正确;实现审核仍需核对精确错误映射和句柄回收。
+3. `PASS`:4 个输入解码线程、2 个扫描滤镜线程及所有 FFmpeg 入口覆盖范围明确;转写模型/参数、页面算法、公开 CLI、业务输出、源保护、原子提交和失败清理均声明冻结。
+4. `PASS`:实现审核前只允许离线/合成测试;实现审核 PASS 后才可只读生成一个 `<=600 s` 独立片段并运行一次 PPT 提取,禁止 3.57 小时/4 小时媒体、并行转写、重试、调参或覆盖旧证据,真实资源验收顺序合理。
+5. `PASS`:资源证据同时要求整机 CPU、任务树 CPU/RAM、根与后代优先级、GPU/decoder 利用率、全局及任务 PID 专用显存、原始命令和 stdout/stderr;硬件路径以 decoder 命令/日志为主证据,未把显存变化冒充 NVDEC。
+
+### Open questions
+
+- 无。三个问题均可在方案与离线/合成测试合同内最小闭环,不需要业务源、真实片段或产品代码预实施。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`3`
+- 根因分类:流程/失败合同 `1`;业务合同/测试 `1`;证据/采样 `1`。
+- 实现授权:`DENIED`。`dev.developer.project / infodev` 不得修改两个产品脚本、不得生成真实片段、不得运行业务媒体;只允许修订方案并补足 F1—F3 的离线/合成验收合同后提交一次限定复审。
+- 复审边界:只复审 F1—F3 闭环及本记录“已通过且冻结的合同”是否回退;不得新增产品范围或要求真实媒体。
+
+## DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-DESIGN-REREVIEW-20260804-001
+
+- 记录时间:`2026-08-04T12:09:02+08:00`
+- 审核阶段:V001 `HOLD/3` 后的 V002 限定方案复审。
+- 审核对象:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`
+- 复审请求:`HANDOFF-INFODEV-INFOREV-MEDIA-HOST-RESPONSIVENESS-V002-DESIGN-REREVIEW-20260804-001`;前序回执=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-V001-DESIGN-REVIEW-RESULT-20260804-001`;来源任务=`019fb7c5-b721-7332-b5c3-7120176fe016`。
+- 执行方:`dev.developer.project / infodev`;独立审核方:`dev.reviewer.project / inforev`。
+- 前序方案:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001.md`=`13606 bytes / 7F0F5505EF1D92C73FBE6EDFF382F6E25D95A0ABE6D28A7A366353B88CA80273`。
+- 前序审计:`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001-DESIGN-20260804-001=HOLD/3`;当时审计文件=`171737 bytes / C9857D954230147A316DD72E2EAC0798C13865859125C566FCC14196AABB6D28`。
+- V002:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002.md`=`14551 bytes / 5BCA39F8B397364E10682780515BCA7983EAC8F003DF54D6A268D852545BC332`;strict UTF-8=`PASS`,BOM=`NO`。
+- 产品与测试继续冻结:`extract_ppt_slides.py=32133/9C4F57ED56B01C9EE2BD68CDC532BC865F73EB3662E01B6600410C5660F1F605`;`transcribe_media.py=20996/309166EEFE11A74472EA355CA4AF87F9432A15393B9ABE6D69F7F1054055DE35`;对应测试=`35037/63298A48F4F7944514301D0BC0AC619C58464651ABF8F53C23E15E85565106FA` 与 `17227/87BD890436789BE2F813ADC57A37168A3C5A76C7815F2DA9B2FB04D4DE9D4512`。
+- 复审边界:仅检查前序 F1—F3 及 preserved contracts 是否回退;未重开 V001 已通过的模块边界、产品范围或真实验收次数。
+- 复审外部动作:业务源读取=`0`,FFprobe/FFmpeg=`0`,合成/真实媒体运行=`0`,真实片段生成=`0`,产品代码/测试修改=`0`;只读检查 V002、前序审计、账本和冻结实物哈希。
+
+### Findings
+
+- 无剩余阻断 finding。
+
+### F1—F3 闭环
+
+1. `PASS / F1`:V002 第 2 节新增的私有 `HardwareDecodePathError` 只覆盖 NVDEC `Popen` 启动、非零退出、零帧和半帧四类硬件可归因失败;页面特征、detector/classifier、动画/去重/主序列、完整扫描无稳定页、文件/提交/暂存/清理以及 `KeyboardInterrupt/SystemExit` 均原类型传播且 CPU 调用数为 0。外层只捕获该私有类型并恰好执行一次全新 4/2 CPU 扫描,CPU 失败后没有第三次尝试;首个硬件根因留在唯一告警、CPU 根因保持最终失败,关闭了“算法错误伪装成硬件不可用”的前序缺口。
+2. `PASS / F1 tests`:启动失败、非零、零帧、半帧、detector、classifier、空结果、文件/清理、中断和 CPU 二次失败均有明确调用次数、对象身份/退出码、异常类型/因果链及无第三次尝试断言;测试合同足以验证异常包络而不要求真实媒体。
+3. `PASS / F2`:V002 第 3 节固定同一 `320x180 / 1 fps` RGB 页序列,包含稳定页、动画更新、页面切换和历史重复;H.264/H.265 分别实际编码后,各自执行 NVDEC 与 forced 4/2 CPU 的同一扫描、稳定/动画/去重、主序列、PNG/PDF 链。两种 codec 都必须在当前验收主机实际 PASS,能力缺失只能诚实 SKIP,不能伪造通过。
+4. `PASS / F2 assertions`:每种 codec 均比较采样数、稳定候选/动画更新、逻辑页、选中标识/顺序、相对时间戳 `<=0.001 s`、PNG 连续编号/RGB/尺寸和 PDF 页序/RGB 尺寸;不把压缩字节差异升级为业务阻断,但任何页数、时间、顺序、模式或尺寸漂移都阻断。硬件 decoder、输出格式和输入解码线程均明确位于目标 `-i` 前,CPU 路径无硬件参数且保留 4/2 上限,关闭了“只证明命令能启动”的前序缺口。
+5. `PASS / F3`:V002 第 4 节把首次性能计数器行标为 warm-up 无效行,以扫描 FFmpeg 的实际出现和退出限定连续采样区间;每个有效样本代表真实时间区间,非递增、间隔 `>1.5 s`、读取/归属/字段缺失均令 `sampling_valid=false`。
+6. `PASS / F3 windows`:5 秒尾窗按样本区间重叠秒数加权,要求完整覆盖 `5.000 s`、至少 10 个不同有效成员、窗口最大间隔 `<=1.5 s`、严格 `<85.0` 且 `window_count>0`;密度不足或零窗口不能空通过。连续 `>=85.0` 按实际区间累计,`>=10.000 s` 即失败,不再用固定 20 行近似。
+7. `PASS / F3 evidence`:逐窗成员/覆盖/最大间隔/加权 CPU、最差窗口以及高 CPU 段起止/时长均固定落盘;合成 CSV 覆盖 `<85`、`==85`、`9.999/10.000 s`、0.8—1.5 秒稀疏、零窗口、warm-up、首末覆盖、倒序和大缺口,足以证明非空性、边界值与无假 PASS。
+
+### Preserved contracts
+
+1. `PASS`:V002 只修订 F1—F3;V001 中与之不冲突的公共 owner、两个 CLI 共用非阻塞 Windows named mutex、根 Python 与默认 FFprobe/FFmpeg BelowNormal、4 decode/2 filter 线程及其异常释放/优先级证据继续有效。实现审核仍必须验证 V001 已冻结的双进程 mutex、句柄清理、默认 `run/Popen` creation flags 和外部动作前失败门禁。
+2. `PASS`:公开 CLI/业务函数签名、页面稳定/动画/去重/主题算法、PNG/PDF、转写模型/分块/时间轴、FLAC/TXT/SRT/JSON、源保护、拒绝覆盖、原子提交和失败清理均未修改或放宽。
+3. `PASS`:不新增产品模块、依赖、参数、服务、API、GUI、数据库、批处理或调度器;唯一新文件仍限测试侧只读资源采样器。
+4. `PASS`:方案 PASS 后只开放产品最小实现和离线/合成测试;独立实现审核 PASS 前仍禁止读取业务源、生成真实片段或运行真实媒体。后续真实门禁继续是至多一个 `<=600 s` 独立片段和一次 PPT 提取,禁止长媒体、并行转写、重试、调参或覆盖旧证据。
+
+### Open questions
+
+- 无。V002 已在纯方案和离线/合成证据边界内完整闭环 F1—F3,不需要提前触发真实媒体。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 方案基线:后续实现必须同时遵守未冲突的 V001 与 V002;发生冲突时以 V002 第 2—4 节为准。
+- 实现授权:允许 `dev.developer.project / infodev` 严格按 V001+V002 修改两个现有产品脚本、对应测试/说明及测试侧只读采样器,完成 F1 桩、H.264/H.265 合成差分、F3 合成 CSV、目标测试和 project 全回归后提交限定实现审核。
+- 真实媒体门禁:本 PASS 不授权业务源读取、真实片段生成或真实媒体运行;只有后续独立实现审核 PASS 后,才可执行已冻结的一次 `<=600 s` 片段和一次 PPT 运行。
+
+## DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-IMPLEMENTATION-20260804-001
+
+- 记录时间:`2026-08-04T13:09:05+08:00`
+- 审核阶段:V001+V002 组合合同的限定实现与离线证据审核。
+- 审核对象:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`
+- 审核请求:`HANDOFF-INFODEV-INFOREV-MEDIA-HOST-RESPONSIVENESS-V002-IMPLEMENTATION-REVIEW-20260804-001`;前序方案回执=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-V002-DESIGN-REREVIEW-RESULT-20260804-001`;来源任务=`019fb7c5-b721-7332-b5c3-7120176fe016`。
+- 执行方:`dev.developer.project / infodev`;独立审核方:`dev.reviewer.project / inforev`。
+- 组合方案:V001=`13606 bytes / 7F0F5505EF1D92C73FBE6EDFF382F6E25D95A0ABE6D28A7A366353B88CA80273`;V002=`14551 bytes / 5BCA39F8B397364E10682780515BCA7983EAC8F003DF54D6A268D852545BC332`。
+- 实现实物:`extract_ppt_slides.py=38111/9AD12F95C0615034B1BB9C72F79DFC5F1150DE33E2DD8889CFF14534509BB41A`;`transcribe_media.py=25087/E889D78DEEEFBC061CD0F952157C83997704CD46AF833AAF452293B7434D96FD`。
+- 测试/采样器:`test_extract_ppt_slides.py=55492/C18A2ACB79A4C74E5D93184723E17E4C13683CA52B0CD86B2AD47AFB6DD75CEA`;`test_transcribe_media.py=19507/1C58C4C9ED45A1276A7CFF4D7E376F133446F506CB1A4890D231D00A229B82AC`;`monitor_media_host_responsiveness.ps1=28062/EAB0479662C29EA52CE56619EA9E27491DDE40AC6656E2391E82DE2356B27AB7`。
+- 用法说明:`会议录屏PPT页面提取工具.md=10145/BEA0B27D2B3CFA38B896963A9C1327A86EAECCA6110052D89EF489D6CFE61FA4`;`本地视频语音转写工具.md=6788/286F3395ADA8FCEC3DDA04185929D99A773E4A1B7250508254B2F645647804E5`。
+- 开发账本:`DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-OFFLINE-IMPLEMENTATION-20260804-001`;提交时 `dev-doc/开发执行日志.md=122695/274511B4FF5C3DA021DF52D1490B8097EEFEA761CE3321FC4E42C65D2E14A623`。
+- 独立复跑:py_compile=`PASS`;两个 CLI help=`PASS`;PowerShell parser=`PASS`;目标测试=`38+14=52/52 PASS`;project discover=`59/59 PASS`;H.264/H.265 的 NVDEC/forced CPU 四链均在当前主机实际执行且返回成功,业务摘要均为 `19 samples / 5 stable candidates / animation_updated 1 / historical_duplicate 1 / new_page 3 / selected 6,10,18 s`。
+- 文本/治理:范围内文件 strict UTF-8=`PASS`、BOM=`NO`;`mbx validate --project project-info=OK / projects=1 / warnings=0`。
+- 审核外部动作:业务源读取=`0`,业务 FFprobe/FFmpeg=`0`,真实片段生成=`0`,真实媒体运行=`0`,长媒体运行=`0`;独立复跑只执行获授权的离线桩与临时目录 H.264/H.265 合成媒体。
+
+### Findings
+
+#### F1(阻断,失败合同):业务异常与子进程清理异常同时发生时,业务根因仍会被替换
+
+- `extract_ppt_slides.py` 的 `_scan_video_once` 在捕获任意普通 `Exception` 后,只要 `_attempt_reap` 返回清理错误,就统一新建 `SlideExtractionError`。这会把 detector/classifier/特征计算等业务异常的对象、类型、消息和 traceback 改写,与 V002 第 2.1 节“清理异常只作诊断附注、不得覆盖业务根因”冲突。
+- 独立故障注入以 `_gray_feature -> ValueError('business-root')` 与 `_attempt_reap -> OSError('cleanup-failed')` 同时发生,实际得到 `caught_type=SlideExtractionError / same_object=False / cause_type=ValueError`,证明现有绿色用例没有覆盖组合失败。
+- `required_fix`:按活动异常域处理 reap/close 失败。业务/算法异常及 `KeyboardInterrupt/SystemExit` 必须传播原对象、原类型和原 traceback,清理错误只能使用 note/只读诊断属性附加;纯清理失败可以保持既有用户错误;硬件失败叠加清理失败仍须禁止 CPU 回退并保留双重诊断。补充“feature/detector/classifier + reap 失败”“CPU 根因 + reap 失败”“KeyboardInterrupt/SystemExit + reap 失败”和“硬件根因 + 清理失败”离线桩,逐项断言对象身份、CPU 调用数和无第三次尝试。
+
+#### F2(阻断,冻结合同回退):H.264/H.265 的 codec-only NVDEC 映射被未经方案批准的像素格式 allowlist 改写
+
+- V001 第 4.2 节冻结 `h264 -> h264_cuvid`、`hevc -> hevc_cuvid`,V002 第 2.2 节继续规定 H.264/H.265 先进入硬件尝试;只有其他 codec 才直接使用受限 CPU。当前 `_decoder_for_codec` 新增 `pix_fmt` allowlist,并由测试明确断言 `h264/gbrp -> None`,从而让仍属于 H.264/H.265 的输入在没有发生硬件失败时直接跳过 NVDEC。
+- 用法说明同步写成“像素格式受本机验证支持时”才优先 NVDEC,说明这不是单纯记录字段,而是已经改变了通过审核的产品选择合同;10-bit HEVC 映射又没有对应 10-bit 合成链证明固定 `hwdownload,format=nv12` 可用。
+- `required_fix`:最小修复是恢复 codec-only 决策:所有 `h264/hevc` 都先组装并尝试相应 cuvid 命令,实际不支持的 surface/filter 失败再严格通过 `HardwareDecodePathError` 做唯一一次 4/2 CPU 回退;`pix_fmt` 可以只作证据字段,不得改变选择。同步删除相反测试/说明,并增加非 `yuv420p` 的 H.264/HEVC 命令选择桩。若确需像素格式 allowlist,必须先提交限定方案修订及各 allowlist 分支的实际合成证据,不能在本实现复审中默认接受。
+
+#### F3(阻断,资源采样正确性):采样器不能唯一识别稳定页扫描 FFmpeg,CPU 窗口和 NVDEC 主证据均可能错归属
+
+- 采样循环把任务树中所有进程名为 `ffmpeg` 的 PID 都设为 `scan_ffmpeg_present`,没有利用命令区分长扫描 `rawvideo/fps=1` 与后续逐页 `-frames:v 1` 提取。真实 PPT 运行至少包含这两类 FFmpeg,故 `scan_end` 可能被后续逐页进程延长,或在多个短进程之间形成错误阶段;当前分析器也只取所有 active 行,未固定唯一扫描 PID及其退出后的同一采样周期。
+- `resource_acceptance_pass` 的 NVDEC 判定只匹配 stdout 的 `NVDEC/CUDA (...)` 文本,没有核验捕获到的扫描 argv 是否含 `-hwaccel cuda`、正确 cuvid decoder、输入侧 `-threads 4`、`-filter_threads 2/-filter_complex_threads 2` 与 `hwdownload`;因此日志文本存在但实际命令不满足合同时仍可假 PASS。运行模式在资源门禁失败但目标进程 exit=0 时仍以 0 退出,也没有把门禁失败提升为可靠终态。
+- `required_fix`:以首次匹配产品稳定扫描命令形状的唯一 FFmpeg PID 为 scan owner,冻结其完整 argv/decoder,连续记录至观测到该 PID 退出的同一采样周期,后续逐页 FFmpeg 不得重新打开或延长 scan 区间。`resource_acceptance_pass` 必须同时核验 stdout 和该唯一 argv 的 NVDEC/线程/滤镜结构;资源门禁失败时采样器返回明确非零,同时在 summary 保留原目标 exit。增加纯离线进程时间线/命令桩,覆盖“扫描进程 -> 间隔 -> 多个逐页进程”、CPU 回退告警、伪造 stdout 和缺少硬件参数,证明阶段及主证据不能误归属。
+
+#### F4(阻断,证据模式):逐样本与汇总缺少 V001+V002 固定的必填资源证据
+
+- `resource_samples.csv` 只保存解析后的三个 GPU 数值和 PID 专用显存总数,没有 V002 要求的 GPU 原始查询状态、显式 decoder 字段,也没有 V001 要求的 `nvidia-smi` 原始行、任务 PID 的 `GPU Process Memory` 原始实例名和逻辑处理器数。
+- `resource_summary.json/run_summary.json` 没有有效扫描样本数、最长高 CPU 段的起止对象、任务 CPU/RAM 峰值、GPU/decoder 利用率峰值、全局显存峰值和任务 PID 专用显存峰值;这不足以完成 V002 第 4.4 节和 V001 第 7.2—7.3 节规定的真实资源证据包。
+- `required_fix`:在逐样本中落盘原始 `nvidia-smi` 行/解析状态、任务 PID GPU 实例名、decoder、逻辑处理器数及完整必填字段;在汇总中落盘有效扫描样本数、扫描 PID/decoder/完整命令、最大间隔、最差窗口、最长高段起止/时长以及上述 CPU/RAM/GPU/decoder/显存峰值。任何查询/字段/解析缺失都必须 `sampling_valid=false`,并增加 schema 完整/缺字段反例。
+
+#### F5(阻断,离线门禁覆盖):已冻结的 F3 边界与双 CLI mutex 入口没有被提交测试完整执行
+
+- F3 合成 CSV 当前覆盖 `<85/==85`、`9.999/10.000 s`、稀疏、零窗口和 `1.6 s` 缺口,但没有 V002 第 4.4 节明确要求的时间戳倒退、首/末覆盖归属以及必填字段缺失反例;分析器还直接信任 CSV 的 `interval_seconds`,未验证它与相邻时间戳/`previous_valid_timestamp_utc` 一致。
+- Windows 双进程测试只在子进程直接调用共享 `media_host_guard()`,没有按 V001 第 6.2 节分别执行两个真实 CLI 入口,因而没有证明 PPT 与转写各自在路径解析、暂存、FFprobe/FFmpeg、模型动作之前由 busy 错误抢先终止;Win32 获取/优先级失败时的 Release/Close 也没有故障桩。
+- `required_fix`:补齐时间戳倒退、首/末覆盖、interval/previous 不一致、必填字段缺失的 Analyze 反例;真实两进程无媒体测试应由 A 持锁,B 分别调用两个 CLI 并断言各自固定 busy 退出/错误且外部动作和暂存均为 0,随后证明锁可重取、优先级恢复。再以 Win32 wait/get/set 失败桩验证句柄关闭和无外部动作。全部仍是离线测试,不需要业务源或真实媒体。
+
+### 已通过且冻结的实现
+
+1. `PASS`:两个 CLI 复用 `transcribe_media.py` 中同一个 `media_host_guard`、mutex 名和 creation-flags helper;guard 位于业务函数调用外层,静态顺序满足 busy/优先级错误先于路径、暂存、FFprobe/FFmpeg和模型动作。
+2. `PASS`:默认 `subprocess.run/Popen` 均使用 BelowNormal creation flag;完整音轨、音频块、PPT 扫描和逐页提取均含输入侧 `-threads 4`,扫描还含 `-filter_threads 2/-filter_complex_threads 2`。
+3. `PASS`:H.264/H.265 `yuv420p` 合成链在当前主机实际完成 NVDEC 与 forced CPU 差分,采样/候选/动画/去重/主序列/时间/PNG/PDF 合同一致;这项通过结果不抵消 F2 的选择合同回退。
+4. `PASS`:公开 CLI、页面/转写算法阈值、PNG/PDF、FLAC/TXT/SRT/JSON、large-v3 CUDA/float16/VAD、1200 秒核心块/5 秒重叠、源保护、拒绝覆盖、原子提交和失败清理的其余路径未发现回退;未新增产品模块、依赖、参数、服务、API、GUI、数据库或调度器。
+5. `PASS`:提交哈希、UTF-8、语法、CLI help、52 个目标测试、59 个 project 测试和治理校验均可独立复现;绿色测试只能证明现有覆盖通过,不能覆盖 F1—F5 指出的缺失组合与真实证据误归属。
+
+### Open questions
+
+- 无。五项均可通过最小代码/采样器/离线测试修复闭环,不需要读取业务源、生成真实片段或运行真实媒体。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`5`
+- 根因分类:失败合同 `1`;冻结产品合同 `1`;采样阶段/主证据 `1`;证据模式 `1`;离线覆盖 `1`。
+- 真实媒体授权:`DENIED`。不得读取业务源、生成 `<=600 s` 真实片段、运行 PPT 真实资源验收、重试、调参、并行转写或改跑长媒体。
+- 最小修复授权:只允许在既有两个产品脚本、对应测试、测试侧采样器和两份用法说明内闭环 F1—F5;不得扩展产品范围。修复后提交一次 F1—F5 限定实现复审,继续保持业务媒体动作=0。
+
+## DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-F1-F5-IMPLEMENTATION-REREVIEW-20260804-001
+
+- 记录时间:`2026-08-04T14:12:57+08:00`
+- 审核阶段:前序实现 `HOLD/5` 的 F1—F5 限定实现复审;只检查修复闭环与已通过合同是否回退。
+- 审核对象:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`
+- MB-X 请求消息:`msg_20260804135235824_b43d3af0`;请求交接=`HANDOFF-INFODEV-INFOREV-MEDIA-HOST-RESPONSIVENESS-V002-IMPLEMENTATION-REREVIEW-20260804-001`;前序回执=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-V002-IMPLEMENTATION-REVIEW-RESULT-20260804-001`;来源任务=`019fb7c5-b721-7332-b5c3-7120176fe016`。
+- 执行方:`dev.developer.project / infodev`;独立审核方:`dev.reviewer.project / inforev`。
+- 组合方案保持:V001=`13606 bytes / 7F0F5505EF1D92C73FBE6EDFF382F6E25D95A0ABE6D28A7A366353B88CA80273`;V002=`14551 bytes / 5BCA39F8B397364E10682780515BCA7983EAC8F003DF54D6A268D852545BC332`。
+- 复审实物:`extract_ppt_slides.py=38070/DCA04653CB349CB6DE449A6D291344E3E5788FB035F07A6BB2670EBE1F11D30A`;`transcribe_media.py=25245/0A485447578DB24DEA84CB887041F6D0CCB3E00F3F40745965228F2D06EEED8F`;`test_extract_ppt_slides.py=65196/50FCDCE8FF75711CF75599D9207C376DE8575C8EF193B9F81CC0F5B50CF02A4E`;`test_transcribe_media.py=23080/1BD0D8B1392BD69F72CBCE1A279BBEF2B36B813E03147E298D366FEBC4524E95`;`monitor_media_host_responsiveness.ps1=43771/6B038A4A44F874A93DAA5B10F6B59CD5BD9DD246A5ADFA0BD109F7CAFF8AFEAE`。
+- 用法说明:`会议录屏PPT页面提取工具.md=10630/5A0AC2B050E6E441D49E82AD55F4F1B12761EE9D26FC50C50F38784E5402C64E`;`本地视频语音转写工具.md=6788/286F3395ADA8FCEC3DDA04185929D99A773E4A1B7250508254B2F645647804E5`。
+- 开发账本:`DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-HOLD5-REPAIR-20260804-001`;提交实物=`126430 bytes / 5CEEF9820AC9BB7352FB1481110B26EF857494F16F45D485C7420FFCAA47DBC4`。
+- 独立复跑:py_compile=`PASS`;PowerShell parser=`PASS`;两个 CLI help=`PASS`;目标测试=`55/55 PASS / 21.715 s`;project discover=`62/62 PASS / 22.899 s`;项目测试使用 `PYTHONUTF8=1` 保证父测试进程与中文路径子 CLI 的一致 UTF-8 解码。
+- 合成证据:H.264/H.265 的 NVDEC/forced CPU 四条授权合成链均实际返回成功,继续得到 `19 samples / 5 stable candidates / animation_updated 1 / historical_duplicate 1 / new_page 3 / selected 6,10,18 s`,PNG/PDF 页数、顺序、RGB 和尺寸一致。
+- 治理:`mbx validate --project project-info=OK / projects=1 / warnings=0`。
+- 复审外部动作:业务源读取=`0`,业务 FFprobe/FFmpeg=`0`,真实片段生成=`0`,真实媒体运行=`0`,长媒体运行=`0`;只运行获授权的离线桩、临时合成 H.264/H.265 和合成 CSV。
+
+### Findings
+
+#### F3-R1(阻断,采样主证据与运行态离线门禁):NVDEC argv 校验仍可放行缺失冻结输入参数的命令,运行模式的阶段/日志/终态也未形成提交测试
+
+- V002 第 3.3 节冻结 `-hwaccel`、`-hwaccel_output_format`、`-c:v <decoder>` 和输入侧线程参数必须位于目标 `-i` 之前;V002 第 4.4/5 节又要求真实验收以该唯一扫描 argv 作为 NVDEC 主证据。当前 `Test-NvdecScanCommand` 只验证 `-hwaccel cuda`、cuvid、`threads=4`、两个滤镜线程参数和 `hwdownload`,没有验证 `-hwaccel_output_format cuda`,因此主证据判定不完整。
+- 提交的“有效低 CPU”合成 CSV 命令本身只有 `-hwaccel cuda -c:v h264_cuvid ...`,缺少 `-hwaccel_output_format cuda`,但 `low["sampling_valid"]` 仍断言为真并在独立 `55/55`、`62/62` 复跑中通过;这是可复现的假放行,不是仅有测试缺口。
+- 前序 F3 的 `required_fix` 还明确要求纯离线覆盖“稳定扫描 PID -> 间隔 -> 后续逐页 FFmpeg”、CPU 回退日志、伪造 NVDEC stdout、缺硬件参数以及“目标 exit=0、资源失败时采样器 exit=3 且保留 target exit”。当前唯一采样器测试只调用 `-AnalyzeSamplesPath`;没有运行模式桩或进程时间线桩证明 `$ScanPid/$ScanPhaseClosed/$NvdecLogEvidence/$ResourcePass` 的组合终态。静态实现方向合理,但未满足已冻结的非空执行证据门。
+- `required_fix`:在 `Test-NvdecScanCommand` 中把 `-hwaccel_output_format cuda` 纳入唯一输入 `-i` 前的强制校验,并增加删除/错值/置于 `-i` 后的合成 CSV 反例;再用无业务媒体的进程/命令桩执行采样器 Run 控制流,覆盖上述扫描后逐页进程、伪 stdout/CPU 回退和 exit=3/target-exit 保留。不得读取或运行真实媒体。
+
+#### F4-R1(阻断,schema 完整性门禁):采样器已写出的冻结必填列没有全部进入缺失校验
+
+- V002 第 4.1 节把任务 PID 集、任务树 CPU/RAM、优先级、GPU 原始状态和实际命令列为逐样本必填字段;前序 F4/F5 的 `required_fix` 要求任何必填列缺失都使 `sampling_valid=false`。当前采样行实际写出 `task_pids`、`ffmpeg_command_lines` 和 `nvidia_smi_compute_task_rows`,但 `Analyze-ResourceSamples` 的 `$RequiredColumns` 没有这三列;删除任一列仍不会产生 `required_column_missing_*`。
+- 现有缺列反例只删除 `gpu_query_raw_rows`,不能证明上述冻结列的 schema 门禁。实际 Run 生成器目前会写这些列,但验收分析器仍允许证据包被删列后继续判有效,故“缺列即无效”没有闭环。
+- `required_fix`:把 `task_pids`、`ffmpeg_command_lines`、`nvidia_smi_compute_task_rows` 纳入 `$RequiredColumns` 和扫描阶段非空校验,并逐列增加最小 Analyze 反例;不改变资源阈值,不需要真实媒体。
+
+### 已关闭且继续冻结
+
+1. `F1=PASS`:活动业务/CPU/`KeyboardInterrupt/SystemExit` 异常与 reap/close 失败并发时保留同一异常对象,清理诊断只作 note;硬件根因叠加清理失败时禁止 CPU,CPU 二次失败无第三次。
+2. `F2=PASS`:恢复 codec-only;所有 H.264/H.265 分别先选 `h264_cuvid/hevc_cuvid`,`pix_fmt` 仅作证据,非支持 surface 由实际硬件失败进入唯一一次 4/2 CPU。
+3. `F5=PASS`(除上列仍依赖 F3/F4 的采样器运行态/schema 证据外):时间戳/interval/previous 重算反例、首末覆盖、两个真实 CLI busy 抢先终止以及 Win32 wait/get/set 句柄释放桩均通过。
+4. 已通过合同继续冻结:共享非阻塞 named mutex、根 Python/默认 FFprobe/FFmpeg BelowNormal、4/2 线程;公开 CLI、页面/转写算法与业务输出、large-v3 CUDA/float16/VAD、1200 秒核心块/5 秒重叠、源保护、拒绝覆盖、原子提交和失败清理未发现回退;无新增产品模块、依赖、参数或服务。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`2`(F3-R1=`1`;F4-R1=`1`)。
+- 真实媒体授权:`DENIED`。当前不得读取业务源、生成 `<=600 s` 真实片段、运行 PPT 真实资源验收、重试、调参、并行转写或改跑长媒体。
+- 最小修复授权:只修复测试侧采样器及对应离线测试/说明中的上述两项;产品脚本和已关闭 F1/F2/F5 合同继续冻结。修复后只提交 F3-R1/F4-R1 限定复审,业务媒体动作继续保持 `0`。
+- Open questions:无;两项都可由最小采样器/离线测试修改闭环,不需要外部输入或真实媒体。
+
+## DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-F3-R1-F4-R1-REREVIEW-20260804-001
+
+- 记录时间:`2026-08-04T14:39:28+08:00`
+- 审核阶段:前序 `HOLD/2` 后的 F3-R1/F4-R1-only 限定实现复审。
+- 审核对象:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`
+- MB-X 请求消息:`msg_20260804143248490_0303f8fc`;请求交接=`HANDOFF-INFODEV-INFOREV-MEDIA-HOST-RESPONSIVENESS-F3-R1-F4-R1-REREVIEW-20260804-001`;前序回执=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-V002-F1-F5-IMPLEMENTATION-REREVIEW-RESULT-20260804-001`;来源任务=`019fb7c5-b721-7332-b5c3-7120176fe016`。
+- 执行方:`dev.developer.project / infodev`;独立审核方:`dev.reviewer.project / inforev`。
+- 审核边界:只复审 F3-R1/F4-R1;两个产品脚本、F1/F2/其余 F5 和全部 preserved contracts 均按前序结论冻结。本轮未读取业务源、未生成真实片段、未运行真实媒体或长媒体。
+- 冻结产品:`extract_ppt_slides.py=38070/DCA04653CB349CB6DE449A6D291344E3E5788FB035F07A6BB2670EBE1F11D30A`;`transcribe_media.py=25245/0A485447578DB24DEA84CB887041F6D0CCB3E00F3F40745965228F2D06EEED8F`;`test_transcribe_media.py=23080/1BD0D8B1392BD69F72CBCE1A279BBEF2B36B813E03147E298D366FEBC4524E95`;`本地视频语音转写工具.md=6788/286F3395ADA8FCEC3DDA04185929D99A773E4A1B7250508254B2F645647804E5`,均与前序冻结哈希一致。
+- 本轮实物:`test_extract_ppt_slides.py=72307/425F4C5D2C00F2E7260343CD961DCE0DC635184AE93F79446D12A8D4D489B06C`;`monitor_media_host_responsiveness.ps1=43995/8BAFFCE83980542F2FA7ED8BC939114CB09E77E7DB880E4DAD2CD66367B9D040`;`会议录屏PPT页面提取工具.md=10884/E9ABE70093728A92653CF8C8E14C59725E5CDF9BD5285648695F0B178E78BA3B`。
+- 开发账本:`DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-F3-R1-F4-R1-REPAIR-20260804-001`;提交实物=`129939 bytes / 21B3CB9383114082B3AD7F7CF849A0B4E8495F9AC550F4B0B1450165BD3EAE8F`。
+- 静态核验:`Test-NvdecScanCommand` 已要求 `-hwaccel_output_format cuda` 位于首个目标 `-i` 前;合成有效命令已补齐;删除/错值/后置和缺 `hwdownload` 反例齐备。`task_pids`、`ffmpeg_command_lines`、`nvidia_smi_compute_task_rows` 已进入 `$RequiredColumns` 和扫描阶段非空门禁,四类缺列反例齐备。
+- 独立基础检查:py_compile=`PASS`;PowerShell parser=`PASS`;`mbx validate --project project-info=OK / projects=1 / warnings=0`。
+- 独立聚焦复跑:首次两项套件=`1 PASS / 1 FAIL / 31.589 s`;其中 Analyze/F4 用例通过,Run/F3 用例在 `active_indexes=[]` 处失败。随后单独重跑同一 Run 用例=`1/1 PASS / 10.671 s`,证明现有用例随冷热启动时序变化而不稳定。
+
+### Findings
+
+#### F3-R1-R1(阻断,离线证据可复验性):Run 桩在冷启动时可于采样循环就绪前结束扫描,必要阶段证据非确定性
+
+- 失败首跑的 `resource_samples.csv` 共 6 行,第一行已经只观察到带 `-frames:v 1` 的逐页 `ffmpeg.exe`;`scan_ffmpeg_pid` 全部为空,`scan_phase_active` 全部为 false,`run_summary` 为 `scan_ffmpeg_seen=false / valid_scan_sample_count=0 / target_exit_code=0 / resource_acceptance_pass=false`。因此断言 `active_indexes >= 2` 实际失败。
+- 同一代码、同一命令随后的单项重跑通过。差异来自 Run 桩把扫描进程只保持 `3.0 s`,而采样器在启动目标进程之后才构造整机 CPU 与 `GPU Process Memory` 计数器;冷启动初始化耗时可超过“3 秒扫描 + 1.5 秒间隔”,使首个进程树快照只看到后续逐页进程。该竞态使前序 F3-R1 要求的扫描 PID/退出周期/逐页排除/exit=3 证据不能稳定复验。
+- 这不是要求真实媒体或放宽资源门禁;聚焦用例本身是实现审核的必要离线证据,首跑失败即不能用一次暖态 PASS 关闭 F3-R1。
+- `required_fix`:消除目标启动至采样就绪之间的盲区,优先在启动被测进程前完成可能阻塞的性能计数器/GPU 类别初始化,或使用明确的 sampler-ready 握手后再让 stub 启动扫描;Run 桩不得依赖 3 秒睡眠恰好覆盖主机冷启动。修复后应稳定证明唯一扫描 PID、退出后同周期、后续逐页不延长、伪 stdout/CPU 回退不能假通过,以及 `target exit=0 / sampler exit=3`。仅需采样器与无媒体离线测试,不得修改产品脚本或运行真实媒体。
+
+### 已关闭且继续冻结
+
+1. `F4-R1=PASS`:三项遗漏列已纳入 schema 与非空门禁,`gpu_query_raw_rows/task_pids/ffmpeg_command_lines/nvidia_smi_compute_task_rows` 删除反例均由同一 Analyze 用例执行并通过。
+2. `F3-R1 argv 子项=PASS`:`-hwaccel_output_format cuda` 的位置/值校验及四个反例已闭环;仅 Run 证据的冷启动可复验性仍开放。
+3. F1、F2、其余 F5、共享 mutex、BelowNormal、4/2 线程、公开 CLI、页面/转写算法与业务输出、长转写、源保护、原子提交和失败清理继续冻结;产品脚本与冻结文件哈希未回退。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`1`(F3-R1-R1=`1`;F4-R1=`CLOSED`)。
+- 真实媒体授权:`DENIED`。不得读取业务源、生成 `<=600 s` 真实片段、运行 PPT 真实资源验收、重试、调参、并行转写或改跑长媒体。
+- 最小修复授权:只允许修改测试侧采样器、无媒体 Run 桩及必要说明以消除上述启动竞态;产品脚本、F4-R1 和其他冻结合同不得重开。修复后提交 F3-R1-R1-only 限定复审。
+- Open questions:无;无需开发员或外部输入,按上述最小同步/初始化修复即可闭环。
+
+## DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-F3-R1-R1-REREVIEW-20260804-001
+
+- 记录时间:`2026-08-04T15:18:25+08:00`
+- 审核阶段:前序 `HOLD/1` 后的 F3-R1-R1-only 限定实现复审。
+- 审核对象:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`
+- MB-X 请求消息:`msg_20260804150032663_852b38d9`;请求交接:`HANDOFF-INFODEV-INFOREV-MEDIA-HOST-RESPONSIVENESS-F3-R1-R1-REREVIEW-20260804-001`;前序回执:`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-F3-R1-F4-R1-REREVIEW-RESULT-20260804-001`;来源任务:`019fb7c5-b721-7332-b5c3-7120176fe016`。
+- 执行方:`dev.developer.project / infodev`;独立审核方:`dev.reviewer.project / inforev`。
+- 审核边界:只复审采样器冷启动时性能计数器/GPU 类别同步初始化及 sampler-ready 握手;F4-R1、F3 argv 子项、F1/F2/其余 F5、两个产品脚本和全部 preserved contracts 均冻结,不重开。
+- 本轮实物:`monitor_media_host_responsiveness.ps1=44142/E5909D0E3A3DEA875755E054EB10F78A530A54D08946C6DFE18AFFA52E429DF2`;`test_extract_ppt_slides.py=73191/E9654F1B5D8832CE0CA925EC391BB3E733EA325DEE1B8D193CCD3612A9B63981`;`会议录屏PPT页面提取工具.md=11125/53D824CB5B371F7C2A913842DB2C19C4AE0374FF848E2DDC0B6439D790926705`。
+- 开发账本:`DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-F3-R1-R1-REPAIR-20260804-001`;提交实物 `dev-doc/开发执行日志.md=132152/C7D8DF3D61E0006691CD59D22841B30C7EBA3C4E73E110DF666DB58EF5FE9EDD`。
+- 冻结实物复核:`extract_ppt_slides.py=38070/DCA04653CB349CB6DE449A6D291344E3E5788FB035F07A6BB2670EBE1F11D30A`;`transcribe_media.py=25245/0A485447578DB24DEA84CB887041F6D0CCB3E00F3F40745965228F2D06EEED8F`;`test_transcribe_media.py=23080/1BD0D8B1392BD69F72CBCE1A279BBEF2B36B813E03147E298D366FEBC4524E95`;`本地视频语音转写工具.md=6788/286F3395ADA8FCEC3DDA04185929D99A773E4A1B7250508254B2F645647804E5`,均与提交及冻结值一致。
+- 静态闭环:采样器在 `Process.Start()` 前依次创建并预热 `Processor(_Total)\\% Processor Time`、创建并枚举 `GPU Process Memory`,随后写入子进程继承环境变量 `MBX_MEDIA_SAMPLER_READY=1`,最后才启动目标;无媒体 stub 在 ready 缺失时直接失败。测试同时静态断言上述初始化、ready 标志均位于 `Process.Start()` 前,消除了前序“目标先启动、采样器后冷初始化”的盲区。
+- 独立冷启动复验:使用 3 个相互独立的 Python 进程分别启动全新 PowerShell 进程执行同一 Run 用例,结果连续 `3/3 PASS`;各次总耗时约 `18.557 s / 14.508 s / 14.260 s`,不依赖失败后的进程内重试或暖态复用。用例稳定证明唯一扫描 PID、扫描退出周期、后续逐页进程不延长扫描阶段、伪 NVDEC stdout/CPU 回退不能假通过,以及 `target exit=0 / sampler exit=3`。
+- 独立回归:PowerShell parser=`PASS`;py_compile=`PASS`;两个 CLI help=`PASS`;project discover=`63/63 PASS / Ran 63 tests in 46.365 s`;H.264/H.265 NVDEC/forced CPU 合成链继续得到冻结的 `19 samples / 5 stable candidates / 3 selected pages / 6,10,18 s` 一致结果;`mbx validate --project project-info=OK / projects=1 / warnings=0`。
+- 审核外部动作:业务源读取=`0`,业务 FFprobe/FFmpeg=`0`,真实片段生成=`0`,真实媒体运行=`0`,长媒体运行=`0`;仅执行获授权的无媒体桩、离线回归及临时合成媒体测试。
+
+### Findings
+
+- 无阻断 finding。
+
+### F3-R1-R1 闭环与冻结合同
+
+1. `PASS / F3-R1-R1`:可能阻塞的性能计数器与 GPU 类别初始化已前移至目标启动前,显式 sampler-ready 握手又把测试 stub 的扫描启动约束在采样器就绪之后;全新进程三连跑稳定通过,前序冷/暖启动竞态已消除。
+2. `PASS / preserved contracts`:F4-R1、F3 argv 子项、F1/F2/其余 F5、共享 named mutex、BelowNormal、4/2 线程、公开 CLI、页面/转写算法与业务输出、源保护、原子提交和失败清理均未发现回退;产品脚本及冻结文件哈希保持不变。
+3. 本次修复仅涉及测试侧只读采样器、无媒体离线用例和对应说明/账本,没有新增产品模块、参数、服务或依赖,也没有触发任何业务媒体动作。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- F3-R1-R1:`CLOSED`;本任务实现审核的既有阻断至此全部关闭。
+- 真实资源验收授权:`AUTHORIZED_EXACTLY_ONCE_UNDER_V001+V002`。允许开发员按冻结方案对既有源只读校验指纹后,仅生成一个独立 `<=600 s` 片段(既定目标为从 `00:30:00` 起约 `590 s`,必须复核实际时长不超过 600 秒),并仅运行一次 PPT 提取及资源采样,写入新的独立输出/证据目录。
+- 授权限制:不得运行 3.57 小时源或 4 小时媒体,不得重试、调参、覆盖旧证据、并行转写或扩大范围;若生成、运行或资源门禁失败,应如实返回唯一失败证据,不得自行再次运行。完成后沿原任务链提交真实资源验收结果并交 `case_analysis.media_processor` 最终验收。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-20260804-001
+## DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-20260804-001
+
+- 记录时间:`2026-08-04T22:48:20+08:00`
+- 审核阶段:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001` 对应 project 工具的独立实现审核;按上游即时收尾约束,本记录为当前冻结快照的一次性终态,不创建后继设计、返修复审或外部动作。
+- 审核对象:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`
+- 原生请求:`HANDOFF-INFODEV2-INFOREV-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-20260804-001`;来源任务/回传任务:`019fbcbb-bed7-7c90-83ab-f50610f80d3a`;执行方=`dev.developer.project.secondary / infodev-2`;审核方=`dev.reviewer.project / inforev`。
+- 需求:`ai-video-downloader/draft/2026-08-04-哔哩哔哩动态采集需求.md=4114/DC5807D6325AA13B1E0C72C92B9534003CC4F8046A1789AA70FCC98DECC9AB87`。
+- 实现:`bili_dynamic_collector.py=49357/FFEA819A53DF15CFD2D92305F6926A4CCF3E261F436C315D9F0332EA8501918F`;示例配置=`658/0BF9F65C6AD08145EB933152CA44A4BA91742620540C7838B5E6A486760D9D6A`;测试=`10904/52E8269D82C676F4AB499F0A116B06E56F0FE4234D3EC36FA9BBA3137171E24A`;fixture=`1216/0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F`;用法=`8763/1692918402CACF7A220F17E82C81825A2DFE500EBA86AE8F46BAFC6158509598`。
+- 开发证据:worklog=`3714/A35C1F370ACF24561688609DE74D5B9C4741ACC4AE3BF9284963842DE7B60709`;selfcheck=`3624/A403750607C6BB91A67FDCB27B96C5855D32A8C0B57F9038A990DE69B6CB4D92`;执行账本=`DEV-LOG-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-20260804-001`。
+- 独立正向复验:py_compile=`PASS`;CLI help=`PASS`;目标 unittest=`6/6 PASS / 0.642 s`;project discover=`69/69 PASS / 51.946 s`;git diff check=`PASS`;交付文件 strict UTF-8=`PASS`、BOM=`NO`;`mbx validate --project project-info --governance=OK / projects=1 / warnings=0`。
+- 独立反例全部只使用 `TemporaryDirectory`、脱敏 JSON 和数十字节非媒体文件;网络=`0`,浏览器=`0`,扩展=`0`,真实媒体=`0`,`F:\\video` 读写=`0`,正式 `ana-data` 读写=`0`。
+
+### Findings
+
+#### F1(阻断,流程/分类):当前事项具备重型合同,却以“无并发的轻量实现”绕过了编码方案门
+
+- common 编码规范第 5.3 节把“多个阶段”“正式数据链路”“并发”“失败后代价较高”列为重型实现;本地规范也明确涉及重跑、并发或断点/恢复时应先有编码方案。
+- 当前实现不是单一无状态小脚本:它有 `check / move-completed / handoff` 三阶段、append-only manifest 状态机、跨调用重跑/失败恢复、`StateLock` 并发互斥、跨目录复制/哈希/hard-link/manifest/源删除事务和正式原生交接草稿。开发计划第 850 行及总纲第 759 行声明“不含并发”,与 `StateLock` 第 130—164 行及所有命令统一持锁的第 1153 行直接矛盾。
+- 因此“一个 Python 文件”不能替代重型判断;当前没有经独立审核的状态、事务、恢复和交接合同,已实现代码不能取得正式实现 PASS。
+
+#### F2(阻断,代码/测试):`move-completed` 没有形成“每个已提供选择器都精确一致 + 一个源文件只对应一个实体”的批次预检
+
+- `resolve_entity()` 只汇总能在历史 token map 中命中的选择器,未知选择器被静默忽略。独立反例给同一映射同时提供正确 BV 和错误 `dynamic_id=definitely-wrong`,实际仍返回 `exit=0 / COMPLETE / VIDEO_MOVED`;这违反需求中的“下载文件无法对应动态或目标不唯一立即停止”和交接声明的 exact queued-video mapping。
+- 批次只检查目标路径重复,不检查规范化/解析后的源文件重复。独立反例把同一个 `one.mp4` 映射给两个不同视频实体,预检未停止;工具先把源提交给第一个实体并删除源,随后第二项以 `FileNotFoundError` 失败,最终 manifest 为“第一个 `VIDEO_MOVED`、第二个 `MOVE_FAILED`”,且已经产生一个目标文件。这是发生文件副作用后的迟发失败,不是批次 fail-fast。
+- 现有 6 项测试没有覆盖混合正确/错误选择器、未知 selector、同一源对应多实体及“失败前零文件/零 manifest 副作用”。
+
+#### F3(阻断,状态/重跑):`VIDEO_MOVED_SOURCE_RETAINED` 在原映射重跑时被降级成普通成功
+
+- 首次源删除失败会正确返回 `exit=4 / COMPLETE_WITH_RETAINED_SOURCE / VIDEO_MOVED_SOURCE_RETAINED`,但 `preflight_move()` 把该状态直接折叠为 `ALREADY_MOVED`;汇总又只检查本次结果是否含 `VIDEO_MOVED_SOURCE_RETAINED`。
+- 独立故障注入证明:首次为 `4 / COMPLETE_WITH_RETAINED_SOURCE` 且源仍存在;相同映射第二次变成 `0 / COMPLETE / ALREADY_MOVED`,源仍然存在。定时调用方会把未关闭的源保留告警误判为已完成,违反失败/重跑状态不得被掩盖的合同。
+- 现有测试没有覆盖源删除失败后的同映射重跑。
+
+#### F4(阻断,安全边界):秘密字段门禁没有覆盖工具自身持续读取的 manifest 状态
+
+- `load_json()` 会对配置、动态导出和 mapping 调用 `reject_secret_keys()`,但 `load_manifest()` 解析每个事件后只检查 schema,不检查 secret/session 字段。
+- 独立反例在 manifest 事件加入 `cookie=forbidden-value` 后再次运行 `check`,实际返回 `exit=0 / NO_NEW_ITEMS`,没有 `E_SECRET_FIELD` 安全停止。manifest 是三个命令共同读取的输入状态;这与事项总纲“不得读取或保存账号口令、cookie、token、验证码或浏览器会话”及提交所称“any secret/session field name is safety-stopped”冲突。
+- 现有秘密测试只覆盖动态导出根字段,未覆盖 manifest、mapping/config 嵌套状态的统一门禁。
+
+#### F5(阻断,正式交接合同):生成物使用 `<codex_native_handoff>` 标记却不是本项目可发送的原生交接
+
+- 当前 917 字节草稿只有 project、message type、source/target role、status 和视频列表;缺少本项目工作说明规定的 `handoff_id`、source/target AI 与精确 thread、`reply_thread_id`,也缺少显式 `scope:`、`evidence:`、`expected_action:`。
+- 文件虽标记 `DRAFT_REQUIRES_VIDEO_DOWNLOADER_CONFIRMATION / sent=false`,但用法明确让视频下载员确认后把正文作为 Codex 原生任务发送;直接发送会违反 canonical envelope 和 one-exact-target 合同。草稿应当生成可验证的完整 envelope,或改成不冒充 `<codex_native_handoff>` 的中性证据摘要并由发送方另行构造完整交接。
+- 现有测试只断言草稿包含 DRAFT/local path 且可内容复用,没有校验原生必填字段和三个必填区段。
+
+### 已通过且可保留的部分
+
+1. 时间窗上下界、offset-aware 时间、动态/opus/BV/URL 基础去重、Windows 安全 stem、相同输入不生成空待办,以及目标已存在不覆盖的正向合同有非真空测试并通过。
+2. happy-path 的 partial → flush/fsync → SHA-256 → 同目录 hard-link no-overwrite → manifest → 源删除顺序合理;handoff 前重新核对本地文件范围和 SHA-256 的方向正确。
+3. 代码没有网络、浏览器、扩展控制或原生发送实现;示例配置仅指向开发 tmp;本轮及开发证据均未触碰真实媒体、`F:\\video` 或正式 `ana-data`。
+
+### 当前冻结快照的安全使用边界
+
+- `可安全保留`:源代码、说明、fixture、自检和现有测试可作为隔离开发样例留在当前工作区;可在一次性 `TemporaryDirectory` 或 `dev/tmp` 中,用完全可信、无秘密、人工核对且单实体单源的脱敏输入运行现有单元测试和演示性 `check`。
+- `不可安全使用`:不得把当前快照交付为可用采集工具;不得对真实下载目录或正式视频执行 `move-completed`;不得用于定时/重叠生产调用;不得把生成的 Markdown 直接作为 Codex 原生 handoff 发送;不得依赖它对既有 manifest 的秘密字段或 retained-source 重跑给出可靠终态;不得写 `F:\\video` 或正式 `ana-data`。
+- 正式输出路径未获授权是独立外部边界,不计入上述五项 finding;即使未来获得路径授权,也不能抵消当前代码/流程阻断。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`5`
+- 当前可向请求方交付的内容:仅限“冻结实现快照 + 离线测试结果 + 本 HOLD 风险清单”的终态开发摘要,不得宣称工具已通过或可正式运行。
+- 后继动作:按上游即时收尾约束,本审核不创建设计请求、返修复审、外部授权、网络/浏览器/下载或正式数据动作;事项如在未来另行恢复,应由责任方重新建立明确授权和审计入口,本轮不自动续接。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-SAMPLER-ONLY-REPAIR-REREVIEW-20260804-001
+
+- 记录时间:`2026-08-04T17:13:13+08:00`
+- 审核阶段:唯一真实 r1 资源验收失败后的 sampler-only 限定独立复审。
+- 审核对象:`DEV-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-20260804-001`
+- MB-X 请求消息:`msg_20260804170453363_a3bf1918`;关联消息:`msg_20260804160056055_13e286dd`;请求交接:`HANDOFF-INFODEV-INFOREV-MEDIA-HOST-RESPONSIVENESS-SAMPLER-ONLY-REPAIR-REREVIEW-20260804-001`;前序授权:`HANDOFF-MEDIA-INFODEV-MEDIA-HOST-RESPONSIVENESS-SAMPLER-REPAIR-AUTH-20260804-001`;来源任务:`019fb7c5-b721-7332-b5c3-7120176fe016`。
+- 执行方:`dev.developer.project / infodev`;独立审核方:`dev.reviewer.project / inforev`。
+- 审核边界:只复审测试侧 `monitor_media_host_responsiveness.ps1`、对应无媒体/桩测试和必要说明中的首窗密度、FFmpeg 消失竞态、CSV previous/interval 一致性与 NVDEC 完整日志证据;两个产品脚本、公开 CLI、媒体算法/输出、r1 媒体/输出/证据和其他冻结合同均不重开。
+- 本轮实物:`monitor_media_host_responsiveness.ps1=46515/A53BA7CD9806028460CF60AE6025624EB9BFA42E209A991CF1DDE7E899785177`;`test_extract_ppt_slides.py=76255/9FF695DAC3B819E7F3DCDF2EB48EE0EDB37D6E625BAC7F248BFAB9C4A537BC19`;`会议录屏PPT页面提取工具.md=12430/DE3B684093A6773CA4C39FF014B041CCD88A2A8ABA81EF94F372126AD956EC74`。
+- 开发账本:`DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-SAMPLER-ONLY-REPAIR-20260804-001`;提交实物 `dev-doc/开发执行日志.md=138539/A9466E9F9241ADA8BE6303E10CCE5D6174A5D7C7A46E15F841B8AC4E348A24B1`。
+- 冻结产品:`extract_ppt_slides.py=38070/DCA04653CB349CB6DE449A6D291344E3E5788FB035F07A6BB2670EBE1F11D30A`;`transcribe_media.py=25245/0A485447578DB24DEA84CB887041F6D0CCB3E00F3F40745965228F2D06EEED8F`,与 sampler 修复前冻结值一致。
+- r1 保护核验只计算文件实物哈希、未读取媒体内容:`resource_samples.csv=162404/F85B2B90EF2F57245AC5BFD625AB7C3C09F09B3B06F11855D3E460561B58A839`;`resource_windows.csv=9264/E33DA3CF90AD23360183661E263E528185A13295BB5B7442D7E8D2FFC5E532C1`;`resource_summary.json=2650/4479CCB89A152693DCC39EF74569489E457440A896782BE7D0B02896545AAE29`;`run_summary.json=3257/A088EA2F244283CF996D4AE7B3D8B1AC3BD1E28C1B4DA112E22D8C37498C4045`,均与 r1 冻结值一致。
+- 独立聚焦复验:以 3 个相互独立的 Python 进程分别启动全新 Windows PowerShell 进程执行同一 Run 桩,结果连续 `3/3 PASS`;总耗时约 `24.625 s / 19.369 s / 19.350 s`,用例自身耗时约 `22.700 s / 18.759 s / 18.746 s`,没有失败后重试或进程内暖态替代。
+- 独立回归:PowerShell parser=`PASS`;py_compile=`PASS`;两个 CLI help=`PASS`;project discover=`63/63 PASS / Ran 63 tests in 46.941 s`;H.264/H.265 NVDEC/forced CPU 四条离线合成链继续为 `19 samples / 5 candidates / 3 pages / 6,10,18 s`,PNG/PDF/RGB/顺序/尺寸一致;范围内文件 strict UTF-8=`PASS`、BOM=`NO`;`mbx validate --project project-info=OK / projects=1 / warnings=0`。
+- 审核外部动作:业务源读取=`0`,真实片段生成=`0`,真实媒体运行=`0`,重试=`0`,调参=`0`,长媒体运行=`0`;只执行无媒体桩、静态检查和获授权的离线临时合成测试。
+
+### Findings
+
+- 无阻断 finding。
+
+### 四项 sampler-only 闭环
+
+1. `PASS / 首窗密度`:目标启动前已完成 CIM `Win32_Process`、nvidia-smi GPU/decode 与 compute-process 预热;500 ms 调度落后时仅将下一次安排为 `elapsed+1 ms` 做单次受控追赶,未修改 500 ms 周期、每个完整窗至少 10 个成员、最大间隔 1.5 秒、CPU 严格 `<85%` 或连续高段 `10.000 s` 门禁。三个全新 Run 桩均只报告预期的 `nvdec_scan_argv_invalid`,没有首窗密度问题或 9 成员窗。
+2. `PASS / FFmpeg 消失竞态`:只有 Toolhelp 快照已识别 FFmpeg,且命令行/进程属性读取出错后二次确认 PID 已退出时才计入 `command_line_disappearance_race_count` 并容忍;仍存活但 CIM 无行、命令行空或属性不可读时继续硬失败。24 个短命逐页 FFmpeg 加 100 ms CIM 延迟稳定产生 `race_count>0`,但没有额外 sampling error,也没有延长已关闭的扫描阶段。
+3. `PASS / CSV previous/interval`:`PreviousCounterTimestamp` 与 `FirstCounter` 只在 `$Samples.Add(...)` 成功后推进;扫描结束后注入的唯一一次提交前异常不会污染下一条已提交行。聚焦用例逐行复算并证明 `previous_valid_timestamp_utc` 等于上一条已提交 `timestamp_utc`,`interval_seconds` 与两者差值一致,Analyze 没有 previous/interval 问题。
+4. `PASS / NVDEC 日志证据`:目标 stdout/stderr 自启动后即以 `ReadToEndAsync()` 消费,并在目标退出后完整 `GetResult()`、一次写入 UTF-8 无 BOM 文件;NVDEC 正式日志门禁使用 ASCII `NVDEC/CUDA (<decoder>)`。桩证明完整日志证据为 true,但缺失 `-hwaccel_output_format cuda` 的 argv 证据仍为 false,最终 `target exit=0 / sampler exit=3 / resource_acceptance_pass=false`,日志不能绕过命令合同。
+
+### 冻结合同
+
+1. `PASS`:两个产品脚本及产品合同未修改;F1/F2/F3-R1-R1/F4-R1/其余 F5、共享 named mutex、BelowNormal、4/2 线程、公开 CLI、页面/转写算法与业务输出、源保护、原子提交和失败清理均未发现回退。
+2. `PASS`:r1 的媒体、输出及四份核心资源证据保持原 bytes/SHA-256,r1 的 `BLOCKED_RESOURCE_SAMPLER_EVIDENCE_INVALID_NO_RERUN` 历史事实没有被覆盖或改写。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- sampler-only 修复:`CLOSED`。
+- r2 授权:`AUTHORIZED_EXACTLY_ONCE_REUSE_EXISTING_CLIP`。允许按请求方既有授权只复用已经生成并冻结的 `593.120 s` 独立片段执行唯一一次 PPT 提取与资源采样,输出到全新的 r2 目录;不得重新截片、读取或运行长业务源、覆盖 r1、重试、调参、并行转写或扩大范围。
+- r2 失败处理:若目标、采样或任一资源/证据门禁失败,必须保留并回传该唯一 r2 的真实失败证据,不得再次运行。完成后沿原任务链提交给 `case_analysis.media_processor` 最终验收。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001-DESIGN-20260804-001
+
+- 记录时间:`2026-08-04T23:38:23+08:00`
+- 审核阶段:前序实现 `HOLD/5` 的最小重型修复方案独立审核。
+- 审核对象:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 请求交接:`HANDOFF-INFODEV2-INFOREV-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001-DESIGN-20260804-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`;执行方=`dev.developer.project.secondary / infodev-2`;审核方=`dev.reviewer.project / inforev`。
+- 设计:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001.md=11917/16C14FB2751A0EAB63F76371E75D0569FF8EF5B47FDD8724BE861314B6062733`;strict UTF-8=`PASS`;BOM=`NO`;引用目标=`8/8 EXISTS`;目录导读、总纲、计划和开发员 worklog 均已有设计待审入口。
+- 前序审核:`DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-20260804-001=HOLD/5`。
+- 角色/路由核验:`dev.reviewer.project` 有权写本审计入口;`case_analysis.video_downloader / video-downloader / 019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5` 与 `case_analysis.media_processor / media-processor / 019fb7a4-bdfd-79f2-bd6b-e67e2b7d8efd` 均与当前 `mbx.project.yaml`、项目配置清单一致。
+- 冻结基线独立复核:实现仍为 `49357/FFEA819A53DF15CFD2D92305F6926A4CCF3E261F436C315D9F0332EA8501918F`;测试仍为 `10904/52E8269D82C676F4AB499F0A116B06E56F0FE4234D3EC36FA9BBA3137171E24A`;实际 fixture 为 `dev/project-dev/test/fixtures/bili_dynamic_collector/dynamic_items.json=1216/0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F`。代码、测试和 fixture 与前序审计冻结实物一致,本轮实现变化=`0`。
+- 提交证据校正(非阻断):本次原生请求中的 frozen code/test SHA-256 被错误抄写,且把 fixture 写成不存在的 `dev/project-dev/test/fixtures/bili_dynamic_export.json`;因审核员已用实物和前序审计逐项恢复唯一正确基线,本次不把可校正的交接录入错误升级为方案阻断,但后续复审交接必须使用上述真实路径与哈希。
+- 外部动作:网络=`0`;浏览器=`0`;下载=`0`;真实媒体=`0`;`F:\\video`=`0`;正式 `ana-data`=`0`;实现/测试/fixture 修改=`0`。
+
+### Findings
+
+#### F1(阻断,设计/重跑):F2 批次源身份预检与 F3 终态重跑表的执行顺序互相冲突
+
+- V001 第 4.2 节要求“每项预检解析源文件后形成 source identity”,并以 `resolve(strict=True)`/`stat` 为整个 mapping 批次执行重复源检查;第 5 节同时冻结 `VIDEO_MOVED / COMPLETE -> ALREADY_MOVED / exit 0`,以及 `VIDEO_MOVED_SOURCE_RETAINED -> 原告警 / exit 4`。第 3 节还明确:即使 retained source 被人工删除,工具也不能猜测告警已解决。
+- 正常 `VIDEO_MOVED` 成功后原源文件按合同已经删除;retained source 也允许后来被人工删除但告警仍必须粘性。若照第 4.2 节的“每项严格解析源”先执行,这两类重跑都会因源不存在提前失败,无法到达第 5 节的 `ALREADY_MOVED` 或粘性 exit 4;若先按状态短路,第 4.2 节“一切 mapping 均参与 source identity”的文字又不成立。
+- 测试矩阵只冻结 retained source 仍存在时的同 mapping 重跑,没有冻结普通成功移动后源已删除的 mapping 重跑,也没有覆盖 retained source 已被人工删除后的粘性重跑,因此不能靠验收矩阵消除该实现分叉。
+
+### 最小 required fix
+
+1. 明确冻结预检顺序:所有 mapping 先做完整 selector exact resolution 和批次 entity 唯一性;随后按最新状态分类。
+2. `VIDEO_MOVED / COMPLETE` 必须在不要求 `source_file` 仍存在、不做物理 source identity 的情况下返回 `ALREADY_MOVED / exit 0`;`VIDEO_MOVED_SOURCE_RETAINED` 必须同样不依赖源仍存在,直接复用 manifest 的 local file/hash/failure reason 返回粘性 `exit 4`,且不追加事件。
+3. 只有需要实际移动的 `TODO_QUEUED / MOVE_FAILED` 项进入源存在性、下载目录、reparse、age、扩展名、物理 source identity、重复源、目标唯一性和零副作用批次预检;明确终态 no-op 项不参加物理源重复判定。
+4. 增加三组非真空入口回归:普通成功移动后源已删除的同 mapping 重跑;retained source 仍存在的同 mapping 重跑;retained source 已被人工删除的同 mapping 重跑。三组分别断言 exit/status、目标/hash、manifest bytes 和无额外副作用;并保留 active move 的同源/同实体混合批次零副作用反例。
+
+### 已闭环且冻结的部分
+
+1. `PASS / F1`:事项已纠正为重型,代码/测试门禁、模块职责、状态/事务/失败范围和分层验收入口明确;计划、总纲和目录入口已同步,V001 PASS 前实现保持冻结。
+2. `PASS / F2 其余部分`:每个已提供 selector 必须存在、唯一且指向同一实体;mapping URL 走登记 host 规范化;active move 的同源、同实体和目标批次唯一性以及预检错误零正式文件/零 manifest 副作用方向正确。
+3. `PASS / F4`:manifest 每行 JSON parse 后、schema/identity/state 消费前递归走共同 secret-key gate;三个命令共享,错误只泄露字段路径、不泄露值且不改写业务状态。
+4. `PASS / F5`:精确 source/target/reply 路由与当前项目配置一致;完整 envelope 的必填身份、`scope/evidence/expected_action`、稳定内容寻址 handoff ID、JSON 转义视频证据、不同 `--now` 字节稳定复用及 `sent=false` 边界足以指导实现。
+5. `PASS / preserved boundaries`:公开 CLI、时间窗/基础去重/Windows 命名、no-overwrite、copy/fsync/hash/link/manifest/delete happy path、handoff 文件范围与哈希复核,以及无网络/浏览器/下载/自动发送/正式路径动作均未被方案回退。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`1`
+- 实现授权:`DENIED_PENDING_F1_CLOSURE`;代码、示例配置、测试和 fixture 继续冻结。
+- 复审边界:只需提交上述“终态分类先于 active source identity”的单点闭环及对应测试矩阵;已通过部分不重开,不增加网络、浏览器、下载、调度、正式数据或产品能力。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V002-F1-DESIGN-REREVIEW-20260805-001
+
+- 记录时间:`2026-08-05T00:08:59+08:00`
+- 审核阶段:V001 `HOLD/1` 的 `F1_DESIGN_RERUN_PREFLIGHT_ORDER_CONFLICT` 限定方案复审。
+- 审核对象:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 请求交接:`HANDOFF-INFODEV2-INFOREV-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V002-F1-DESIGN-20260804-001`;前序=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V001-DESIGN-REVIEW-RESULT-20260804-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 组合设计:V001=`11917/16C14FB2751A0EAB63F76371E75D0569FF8EF5B47FDD8724BE861314B6062733`;V002=`6342/DA3B3DDAA3C8B8EE33E2C0B6857B962754D740EB4BEE075E3BFB0F0FD4382736`;V002 strict UTF-8=`PASS`、BOM=`NO`、trailing whitespace=`0`;目录导读已登记 V002。
+- 冻结基线:代码=`49357/FFEA819A53DF15CFD2D92305F6926A4CCF3E261F436C315D9F0332EA8501918F`;测试=`10904/52E8269D82C676F4AB499F0A116B06E56F0FE4234D3EC36FA9BBA3137171E24A`;fixture=`1216/0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F`,均与前序冻结值一致;实现/配置/测试/fixture 变化=`0`。
+- 治理校验:`OK / projects=1 / warnings=0`。
+- 外部动作:网络=`0`;浏览器=`0`;下载=`0`;真实媒体=`0`;`F:\\video`=`0`;正式 `ana-data`=`0`。
+
+### Findings
+
+#### F1-R1(阻断,设计/状态安全):V002 用“或没有移动终态”重新扩大了 `ACTIVE_MOVE` 状态集合
+
+- V001 第 5 节已冻结的精确状态表只允许 `TODO_QUEUED / MOVE_FAILED` 进入实际移动,其他状态必须安全停止;该合同在上一轮已通过且本轮声明不重开。
+- V002 第 2.1 节第 4 项却把 `ACTIVE_MOVE` 定义为 `TODO_QUEUED`、`MOVE_FAILED`“或没有移动终态”,第 3 节矩阵再次写成 `TODO_QUEUED / MOVE_FAILED / 无终态`。这会允许实现者把 `PROCESSING_HANDOFF_CONFIRMED`、`PROCESSING`、`PROCESSING_FAILED`、缺失或未知状态等“尚无移动终态”的行误送入源/目标预检和移动事务,而不是沿 V001 的 `E_STATUS` 安全停止。
+- 该问题不是重开已通过状态合同,而是 V002 新增分类文字造成的真实回退;V001 原文没有该扩大项,因此上一轮无法报告。若不消除,终态分类先于源预检虽已闭环,状态准入仍存在两种相反实现。
+
+### 最小 required fix
+
+1. 删除 `ACTIVE_MOVE` 定义和矩阵中的“或没有移动终态 / 无终态”;明确只有最新状态严格等于 `TODO_QUEUED` 或 `MOVE_FAILED` 才可进入阶段 B。
+2. 明确 `PROCESSING_HANDOFF_CONFIRMED`、`PROCESSING`、`PROCESSING_FAILED`、缺失状态和任意未知状态均为 `E_STATUS / SAFETY_STOP`,在阶段 B、复制、目标创建、源删除和 manifest 追加前终止,所有业务文件与 manifest raw bytes 不变。
+3. 增加一个参数化 CLI/main 入口反例覆盖上述非允许状态,断言 exit 3、`E_STATUS` 和零业务副作用;不得借此改变 V001 其他状态、退出码或范围。
+
+### 已闭环且继续冻结
+
+1. `PASS / 原 F1 顺序冲突`:V002 已明确 all-selectors + entity uniqueness → latest-state classification → terminal projection;只有 active rows 进入物理源/目标预检。普通 moved 源已删除、retained 源存在、retained 源人工删除三组入口测试均明确比较原始 manifest bytes 和无额外副作用。
+2. `PASS / terminal projection`:`TERMINAL_MOVED` 与 `TERMINAL_RETAINED` 均不依赖当前源存在,不参加 source identity/目标写入集合;retained 告警粘性、整体 exit 4 与不追加事件明确。
+3. V001 已通过的重型分类、F2 精确选择器与 active 批次唯一性、F4、F5 以及全部 preserved boundaries 不重开。
+
+### 第三轮 HOLD 收敛说明
+
+- 本事项已达到本地审计规范的连续第三轮 `HOLD` 收敛节点。本记录已一次列全当前组合设计可发现的剩余阻断,并说明该项源于 V002 新增文字。
+- 后续只允许一次性删除上述状态扩大并补对应参数化反例;不得再以微观字段逐轮追加设计。若相同事实仍未闭环或出现无新关键事实的继续分叉,应按规范升级管理/人工裁决或缩小范围,不继续串行加码。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`1`
+- 实现授权:`DENIED_PENDING_F1_R1_CLOSURE`;实现、配置、测试和 fixture 继续冻结。
+- 复审边界:仅核对 `ACTIVE_MOVE` 严格 allowlist、所有其他状态 fail-fast 和参数化零副作用反例;原 F1 顺序闭环及所有已通过合同不重开。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003-ALLOWLIST-FINAL-DESIGN-REREVIEW-20260805-001
+
+- 记录时间:`2026-08-05T00:31:40+08:00`
+- 审核阶段:连续第三轮 HOLD 收敛后的 `F1_R1_ACTIVE_MOVE_ALLOWLIST_REGRESSION` 最终单点方案复审。
+- 审核对象:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 请求交接:`HANDOFF-INFODEV2-INFOREV-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003-ALLOWLIST-DESIGN-20260805-001`;前序=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V002-F1-DESIGN-REREVIEW-RESULT-20260805-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 组合设计:V001=`11917/16C14FB2751A0EAB63F76371E75D0569FF8EF5B47FDD8724BE861314B6062733`;V002=`6342/DA3B3DDAA3C8B8EE33E2C0B6857B962754D740EB4BEE075E3BFB0F0FD4382736`;V003=`3197/D5812302D994C9D1835E5EA5BEEF305FAEDEBA892CDCAC0E545EF82EC1BDEBDA`。
+- V003 质量:strict UTF-8=`PASS`;BOM=`NO`;trailing whitespace=`0`;目录导读已登记 V003。
+- 冻结基线:代码=`49357/FFEA819A53DF15CFD2D92305F6926A4CCF3E261F436C315D9F0332EA8501918F`;测试=`10904/52E8269D82C676F4AB499F0A116B06E56F0FE4234D3EC36FA9BBA3137171E24A`;fixture=`1216/0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F`;实现/配置/测试/fixture 变化=`0`。
+- 治理校验:`OK / projects=1 / warnings=0`。
+- 外部动作:网络=`0`;浏览器=`0`;下载=`0`;真实媒体=`0`;`F:\\video`=`0`;正式 `ana-data`=`0`。
+
+### Findings
+
+- 无阻断 finding。
+
+### F1-R1 闭环
+
+1. `PASS / 精确 allowlist`:V002 的“或没有移动终态 / 无终态”已显式废止;只有最新状态严格等于 `TODO_QUEUED` 或 `MOVE_FAILED` 才分类为 `ACTIVE_MOVE` 并进入阶段 B。
+2. `PASS / fail-fast`:`PROCESSING_HANDOFF_CONFIRMED`、`PROCESSING`、`PROCESSING_FAILED`、缺失/空白 status 和任意未知状态均在阶段 A 返回 `E_STATUS / SAFETY_STOP / exit 3`,不得 stat/resolve 源、进入阶段 B、复制、创建目标、删除源或追加 manifest。
+3. `PASS / 非真空验收`:参数化 CLI/main 入口逐项覆盖六类禁止状态;每例冻结 stdout、exit、manifest raw bytes、哨兵源、业务目录树、文件哈希、partial、lock 和新增事件断言,足以证明零业务副作用。
+4. `PASS / 组合一致性`:V003 只修正 V002 的活动状态分类,不改变 V001/V002 已通过的终态投影、selector、批次唯一性、secret gate、canonical handoff 或范围边界。
+
+### 冻结实施合同
+
+- 唯一实施依据:V001 + V002 + V003 组合;发生文字冲突时,V003 的 ACTIVE_MOVE 精确 allowlist 优先于 V002 被废止的两处宽泛表述。
+- 允许开发员一次性实现前序 HOLD/5 五项并执行获授权的纯离线目标测试、参数化反例、新鲜 TemporaryDirectory smoke 和 project 回归。
+- 不授权网络、浏览器、扩展、下载、真实媒体、调度、自动原生发送、`F:\\video` 或正式 `ana-data`;实现完成后仍须提交一次限定独立实现审核,PASS 前不得宣称工具可正式使用。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 方案状态:`V001+V002+V003_APPROVED_FOR_LIMITED_IMPLEMENTATION`。
+- 管理升级:`NOT_REQUIRED`;第三轮收敛节点的唯一剩余阻断已完整关闭,没有继续串行设计分叉。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-IMPLEMENTATION-20260805-001
+
+- 记录时间:`2026-08-05T01:28:48+08:00`
+- 审核阶段:V001+V002+V003 `PASS/0` 合同下的 HOLD/5 限定独立实现复审。
+- 审核对象:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 请求交接:`HANDOFF-INFODEV2-INFOREV-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-IMPLEMENTATION-20260805-001`;前序=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003-ALLOWLIST-DESIGN-REVIEW-RESULT-20260805-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 冻结设计:V001=`11917/16C14FB2751A0EAB63F76371E75D0569FF8EF5B47FDD8724BE861314B6062733`;V002=`6342/DA3B3DDAA3C8B8EE33E2C0B6857B962754D740EB4BEE075E3BFB0F0FD4382736`;V003=`3197/D5812302D994C9D1835E5EA5BEEF305FAEDEBA892CDCAC0E545EF82EC1BDEBDA`。
+- 实现实物:code=`56152/9B5C6FCE647C8E18822AB53488A57B6048A09997B62024B8FD2B593F0196DABF`;config=`1117/42A9AA7EE29ED061DC408C6C9A907FA7B20FD8C2B41605C54B6716A0FE388951`;tests=`24767/684F78F944F045C1352E4C09BFAFE598B7B5E9B5195EC944F5AF87D70EACC0D3`;fixture=`1216/0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F`(未修改)。
+- 文档/证据:工具说明=`10763/E976BDECE48D952B29AC65438C2E5891A67E29D1500DDD1D4B54889CDA224B48`;实现证据=`4648/D484E2CCED3213F257C2CCC819EBD4643AF818BE878C3E0D15F42BCBA10FB784`;selfcheck=`2223/34FD25217F3E0AE094B08C56C731AFAE49F648B85F634E5A58C8FAC5E467AB7F`;worklog=`7622/FD0F0A5B7B6F2D5BF01DFEC23CEFB1C3C4B58E673703252F03F4DCD558B258E2`。
+- 独立正向复验:py_compile=`PASS`;CLI help=`PASS`;目标 unittest=`11/11 PASS / 1.426 s`;独立 fresh smoke=`1/1 PASS / 0.228 s`;project discover=`74/74 PASS / 48.897 s`;git diff check=`PASS`;交付文件 strict UTF-8=`PASS`、BOM=`NO`;治理=`OK / projects=1 / warnings=0`。
+- 独立反例只使用 `TemporaryDirectory`、净化 JSON 和数十字节非媒体文件;网络=`0`、浏览器=`0`、扩展=`0`、下载=`0`、真实媒体=`0`、调度=`0`、native send=`0`、`F:\\video`=`0`、正式 `ana-data`=`0`。
+
+### Findings
+
+#### F1(阻断,代码/状态证据):terminal projection 没有验证被称为“可信”的持久化字段
+
+- `classify_move_mapping()` 只按 status 把 `VIDEO_MOVED / COMPLETE` 和 `VIDEO_MOVED_SOURCE_RETAINED` 归为终态;`move_completed()` 第 1086、1097 行随后用 `event.get()` 直接投影 `local_file`、`sha256` 和 `failure_reason`,没有检查这些字段是否存在、非空或符合状态合同。
+- 独立入口反例构造 schema version 正确、selector 可解析但 `VIDEO_MOVED` 的 `local_file=null / sha256=null` 的 manifest;源文件按正常成功重跑场景不存在。实际结果为 `exit=0 / COMPLETE / ALREADY_MOVED / target=None / sha256=None`,把不完整持久状态错误宣称为成功,违反 V002“直接使用 manifest 中已经持久化的可信字段”和需求的 manifest 可追溯性。
+- 同一缺口会让 retained 状态在缺少目标、hash 或失败原因时仍返回 exit 4,而不是对坏 manifest fail closed。现有三组重跑测试只覆盖字段完整的工具自产事件,未覆盖缺失/畸形终态证据。
+
+#### F2(阻断,代码/正式交接安全):canonical native handoff 可被 manifest 证据字段注入额外 envelope 行
+
+- `load_manifest()` 只检查 schema version、entity_id 为字符串及 dedupe_keys 类型,没有重新验证 terminal/handoff 事件中的 `entity_id`、`bvid`、`published_at` 等消费者字段。`handoff_markdown()` 第 1218—1220 行把这三个值原样插入行文本;只有 title、source URL 和 local path 使用 JSON 转义。
+- 独立入口反例保留合法本地视频和真实匹配 SHA-256,只把 manifest `bvid` 改为 `BV1SAFE00000\nstatus=FORGED`。`handoff` 实际返回 `exit=0` 并生成文件,其中出现独立的 `status=FORGED` 行。内容寻址 ID 和 `sent=false` 不能消除正文注入;该文件不再是可直接发送的 canonical native envelope。
+- 路由字段换行注入测试只覆盖 config;现有 handoff 正向测试没有覆盖 persistent manifest evidence injection,也没有证明全部未转义标量均满足安全格式。
+
+### 最小 required fixes
+
+1. 在共同 manifest 消费边界增加状态感知验证,或在 terminal projection 前执行等价共同验证:`VIDEO_MOVED / COMPLETE` 必须有非空 `local_file` 和 64 位小写十六进制 SHA-256;`VIDEO_MOVED_SOURCE_RETAINED` 还必须有非空 `failure_reason`。缺失、空白或畸形字段统一 `E_MANIFEST / SAFETY_STOP / exit 3`,不得返回 ALREADY/retained 成功、不得写业务文件或 manifest。
+2. 在生成 handoff 前验证所有原样插值的 manifest 标量,至少冻结内部 entity_id、可选 BV、offset-aware canonical published_at 和 SHA-256 的安全格式;或把所有证据标量改为不会产生新行的规范 JSON 序列化。任何控制字符、换行或格式不合法必须在创建 handoff 文件前安全停止,错误输出不得回显注入值。
+3. 增加非真空 CLI/main 反例:参数化缺失/空白/畸形 terminal `local_file/sha256/failure_reason`;参数化 `entity_id/bvid/published_at` 换行或控制字符注入。每例断言 exit 3、明确错误码、manifest/source/target/handoff 原始 bytes 与目录树不变、无 partial/lock/event,stdout 不含注入值。
+4. 修复后复跑现有 11 项目标测试、fresh smoke 和 project discover,并同步工具说明、实现证据、自检、执行日志及冻结哈希;不需要新设计或任何外部/真实运行。
+
+### 已通过且冻结的部分
+
+1. `PASS / F1 重型门禁`:实现确在 V003 PASS 后开始,设计、实现、测试和证据入口齐全。
+2. `PASS / F2 mapping`:所有 selector 独立唯一且同行一致;全批 entity 唯一先于分类;只有 TODO/MOVE_FAILED 进入阶段 B;同物理源/同实体/目标冲突均在业务写入前停止,预检不追加 MOVE_FAILED。
+3. `PASS / F3 正常路径`:字段完整的 moved 与 retained 事件在源存在/删除三类重跑中保持 exit/status、目标/hash/失败原因和 manifest raw bytes;sticky 语义正确。
+4. `PASS / F4`:manifest 每行 parse 后即递归 secret gate,三个命令共享,字段值不出 stdout,状态不改写。
+5. `PASS / F5 其余部分`:route 必填/格式/配置注入门禁、完整 source/target/reply 与三段正文、稳定 24-hex handoff ID、跨 `--now` bytes 复用、文件范围/hash 复核和 `sent=false` 均符合合同。
+6. 公开 CLI、窗口/去重/命名、no-overwrite、copy/fsync/hash/link/manifest/delete happy path以及无网络/浏览器/下载/自动发送/正式路径范围未回退。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`2`
+- 正向测试:全部通过,但未覆盖上述两个 fail-closed 反例,不能据此放行。
+- 实现授权:`DENIED_PENDING_F1_F2_CLOSURE`;当前代码仍只可作为隔离开发快照,不得对真实下载目录执行移动,不得把生成 handoff 发送或宣称正式可用。
+- 复审边界:只复审状态感知 manifest 证据验证、handoff 标量防注入、对应反例及更新后的冻结证据;已通过部分不重开,无需新设计、网络、浏览器、下载、真实媒体或正式路径动作。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-LIMITED-IMPLEMENTATION-REREVIEW-20260805-001
+
+- 记录时间:`2026-08-05T15:36:35+08:00`
+- 审核阶段:前序实现 `HOLD/2` 的 F1/F2 fail-closed 限定独立复审。
+- 审核对象:`DEV-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-20260804-001`;需求:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 请求交接:`HANDOFF-INFODEV2-INFOREV-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-REREVIEW-20260805-001`;前序交接:`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-IMPLEMENTATION-REVIEW-RESULT-20260805-001`;来源/回传任务:`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 审核边界:只复审 F1 terminal persisted evidence fail-closed、F2 native handoff manifest scalar CR/LF/格式注入 fail-closed,以及已冻结合同是否回退;不重开此前通过项,不新增设计、产品或外部能力。
+- 实现实物:code=`60090/67B2E6FB34F100C57BF50A4D3067D6FDD6E47C5D3FBEF58D2CF5D8796B07AB8B`;tests=`31303/D82BFF8AD9B78FD749A7536FF9FED0486CF87D6B9FDDEE7947202C2414AAE322`;config=`1117/42A9AA7EE29ED061DC408C6C9A907FA7B20FD8C2B41605C54B6716A0FE388951`;fixture=`1216/0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F`,均与提交值一致。
+- 文档/证据:工具说明=`11553/63E5AE5E431B3D9647A2CA2BF3A39061D97B2935A7C43B55ECAA9958DF5BB0F5`;实现证据=`7323/00DE95503724AC47EA3D1383DCAA10A37567031F98C4710B1F52A651C7D9FA5C`;selfcheck=`3183/986B2F1706BE2253D8AA37CC3E4A69E4913C3465F456EFCC8FCD41DC8E825331`;worklog=`8717/0EB09F474F3A8BB2A0637D31A086257C9CF376D3D607FCDFB9F772881673DD40`,均与提交值一致。
+- 独立验证:前序两组原始阻断反例=`2/2 PASS / 0.919 s`;目标 unittest=`13/13 PASS / 2.452 s`;project discover=`76/76 PASS / 58.021 s`;`py_compile`、CLI help、`git diff --check`、范围文件 strict UTF-8/no BOM 均 `PASS`;治理=`OK / projects=1 / warnings=0`。
+- 外部动作:network=`0`;browser=`0`;extension=`0`;download=`0`;real media=`0`;schedule=`0`;native send=`0`;`F:\video`=`0`;正式 `ana-data`=`0`。
+
+### Findings
+
+- 无阻断 finding。
+
+### F1/F2 闭环
+
+1. `PASS / F1`:`validate_terminal_manifest_evidence()` 在 `load_manifest()` 共同消费边界验证全部视频 `VIDEO_MOVED`、`COMPLETE` 与 `VIDEO_MOVED_SOURCE_RETAINED` 事件,`classify_move_mapping()` 在终态投影前再次防御性验证。非空且无控制字符的 `local_file`、64 位小写十六进制 SHA-256,以及 retained 必需的非空无控制字符 `failure_reason` 均已成为 fail-closed 前置条件;缺失、空白、控制字符或错误格式统一返回 `E_MANIFEST / SAFETY_STOP / exit 3`,不再投影 ALREADY/retained 成功。
+2. `PASS / F1 反例`:9 组 CLI/main 参数化用例覆盖 terminal 字段缺失、空白、控制字符及非小写 hash;逐例证明完整业务树、manifest raw bytes、目标、事件与锁保持不变,污染值不进入 stdout。前序 `local_file=null / sha256=null` 成功误投影根因已关闭。
+3. `PASS / F2`:`validate_handoff_manifest_evidence()` 在 handoff 文件创建前验证 24 位小写内部 entity ID、可选 BV 格式、canonical UTC `published_at`、64 位小写 SHA-256,以及无控制字符的 title/source URL/local path;任何 CR/LF、其他控制字符或格式异常均安全停止,错误只报告字段路径,不回显污染值。
+4. `PASS / F2 反例`:10 组 CLI/main 参数化用例覆盖 entity ID/BV/time 格式错误及 entity/BV/time/title/source URL/local path/hash 的 CR/LF 注入;逐例均为 `E_MANIFEST / exit 3`,不创建 handoff/partial/event、不残留锁,manifest、目标与完整业务树不变。前序 `bvid` 注入独立 `status=FORGED` 行的根因已关闭。
+5. `PASS / preserved contracts`:本轮代码变化限定于上述共同验证及对应测试/说明;此前通过的 mapping、状态 allowlist、retained-source 重跑、secret gate、route/canonical envelope、公开 CLI、去重/命名、no-overwrite、原子移动/manifest 顺序及无外部能力边界未发现回退。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 前序 HOLD/2:`CLOSED`
+- 交付边界:该冻结实现可向请求方作项目工具交付;本 PASS 不等同于 `F:\video`、正式 `ana-data`、网络、浏览器、下载、自动 native send 或调度授权,正式输出路径及外部动作仍须由既有责任方另行明确授权。
+- 后继评审:`NOT_REQUIRED_FOR_THIS_CLOSURE`;无需新增设计、真实运行或继续追加评审链。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-DURATION-GATE-LIMITED-REREVIEW-20260805-001
+
+- 记录时间:`2026-08-05T18:22:39+08:00`
+- 审核阶段:既有审核任务恢复后的时长一致性 fail-closed 限定独立实现/输出复审。
+- 审核对象:`DEV-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-20260805-001`;需求:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 恢复交接:`HANDOFF-INFOADMIN-INFOREV-BILI-BRIDGE-DURATION-GATE-REREVIEW-RECOVERY-20260805-001`;correlation message:`msg_20260805173417306_68438e3d`;来源管理任务:`019fbb4d-2ccb-7bd2-93bc-f57332d484a9`;开发员任务:`019fb7c5-b721-7332-b5c3-7120176fe016`;请求方任务:`019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5`。
+- 审核边界:只复审 B 站完整视频下载桥的远端/本地时长一致性 fail-closed 修复,以及相关 multi-P/preview/authentication 边界;不创建替代任务、审计链或管理复审层,不重开已验收首个视频。
+- 冻结实物:code=`22229/541CD9A57E23976614B80CF731C6D4D49361A29BFBC029E9053C8E7093EC18F1`;tests=`15416/BDE24A5BE26BC10F534EA1BF7235B8066EDA6425BB432C06AC67BBD560EFA168`;example=`410/1581168C73BD0C320E46D42FE104CCB3594A3BF3D216098671794B2FB69868CC`;usage=`4423/40794C7933E8C6715DE558893D920B24652C494B100A7EDA3B34D9C6C97D750D`;worklog=`3262/F4CA408D1499A8CCDA536E462BE0F3EF3AF02E9ED993B50C01BE54FE98C68AC5`;需求=`4114/DC5807D6325AA13B1E0C72C92B9534003CC4F8046A1789AA70FCC98DECC9AB87`,均与冻结值一致。
+- 独立验证:`py_compile=PASS`;CLI help=`PASS`;目标 unittest=`15/15 PASS / 0.096 s`;project discover=`91/91 PASS / 54.533 s`;目标文件 `git diff --check=PASS`;交付文件 strict UTF-8/no BOM=`PASS`;治理=`OK / projects=1 / warnings=0`。
+- 独立定向反例:远端 `3133.95 s`、本地 `600.133313 s` 在任何发布预检与正式输出前抛出时长一致性错误;远端 `1206.067 s`、本地 `1206.067664 s` 通过,差值约 `0.000664 s`、门限 `3.0 s`;`needs_auth`、`premium_only`、`private`、`subscriber_only`、playlist 与 entries 六类状态均 fail closed。
+- 外部动作:network=`0`;Chrome=`0`;extension=`0`;download=`0`;real media=`0`;`F:\video`=`0`;正式 `ana-data`=`0`;schedule=`0`;cookie/token/credential access=`0`;实现/测试/fixture 修改=`0`。
+
+### Findings
+
+- 无阻断 finding。
+
+### 闭环结论
+
+1. `PASS / duration gate`:`validate_duration_consistency()` 对远端与本地时长执行正数/有限值校验,固定容差为 `max(3 seconds, remote_duration * 0.1%)`;超过容差即失败,等于容差允许通过。发布路径在目标预检、正式媒体链接与 mapping 写入前调用该门禁,缺陷样本不会产生正式输出;端到端反例同时证明暂存目录完成清理。
+2. `PASS / evidence`:通过样本的 mapping 固定记录 remote duration、local duration、delta 与 tolerance 四项;远端 `1206.067 s`/本地 `1206.067664 s` 的正常样本符合门限。metadata probe 仅声明 `METADATA_PASS_ONLY`,不表示下载或发布完成。
+3. `PASS / multi-P and auth`:playlist、非空 entries、多 P/带 query 的非规范 URL,以及 `needs_auth`、`premium_only`、`private`、`subscriber_only` 均不能进入正式发布;未发现通过 preview、认证或会员边界绕过时长门禁的路径。
+4. `PASS / preserved contracts`:无 cookie/浏览器凭据、no-overwrite、FFprobe 视频/音频流校验、SHA-256、BaseException 回滚与暂存清理合同未发现回退;代码、测试和证据保持冻结。
+5. `PASS / quarantine boundary`:已隔离的约 600 秒 preview 继续排除且不得转写;已验收首个视频不在本次范围,本结论不重新检查或改写其既有状态。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 交付结论:冻结修复可由开发员按原链交付请求方;请求方只能在其另行具备的真实路径/下载授权下使用,任何实际下载仍必须逐项通过时长、multi-P 与认证状态门禁。
+- 审核动作:本轮不授权、也未执行网络、Chrome、扩展、下载、真实媒体、`F:\video`、正式 `ana-data`、调度或凭据访问。
+- 后继评审:`NOT_REQUIRED_FOR_THIS_CLOSURE`;不创建替代任务、平行审计链或新增管理审核层。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-IMPLEMENTATION-OUTPUT-20260805-001
+
+- 记录时间:`2026-08-05T19:28:39+08:00`
+- 审核阶段:股票估值每日台账 MVP 唯一一次必要实现/输出独立审核。
+- 审核对象:`DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001`;权威需求=`ana-doc/股票估值/股票估值每日台账业务需求_v1.0.md`。
+- 请求交接:`HANDOFF-INFODEV2-INFOREV-STOCK-VALUATION-LEDGER-MVP-IMPLEMENTATION-REVIEW-20260805-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`;请求方=`case_analysis.analyst.valuation / 019fb338-fe89-7d52-9ba6-513e82de54d8`。
+- 审核边界:核对四表、正式 JSON 幂等导入、V1 四档、完整交易日/来源日期、单股隔离与整批回滚、版本有效期、V2 只读行情桥、查询/导出、单次运行脚本及正式库;不新增 provider,不修改 `dev/ana-dev`,不联网、不安装计划任务、不生成交易指令。
+- 冻结实现:core=`37237/2F914E94110ACDB2676BC26733CE2ABD8A6B5E8C63836D42242867D3B022124C`;market=`3977/D43675B41D92ED2382B967DD530E7364778F236A69B9459306EA30925A255561`;cli=`5583/A2350F34C86B9CBB35AF82273F8419A1F87775ACF64D09C3D85468ECD6974A34`;schema=`2707/47E505DAB5F8D71BA3200F35AA00EDBCE3C243FA99936D3C0C208EC8AF41D1F6`;run_daily=`1049/5937CD5C2E46D008A1C6B4C9F8DDF49BF87BB778CCFAAD9AFED59BD6CBDFA18B`;tests=`20818/90FC283156767BA9733C5391BB8ADA33057BFF458880FC51E42F8307A0C6A7D4`,均与交接值一致。
+- 正式输出只读复核:数据库=`163840/48AF0F2277FC43D98480D330BE49A43173404EDEDC2AF3666CBA473AAD24699C`;`quick_check=ok`;外键错误=`0`;四业务表;`security=125`、`valuation_version=136`、`daily_price=0`、`daily_judgement=0`;同日重复/有效期重叠/多开口版本=`0`;136 个 snapshot/report 路径、snapshot SHA-256 与 valuation_id 全部可复算。缺口=`325/7761E1A028C5FD46F6A8E412D4B01CD827D861AD0DC6F78D763402C207EFA132`,内容为一条确定性低优先级同日版本跳过。
+- 独立正向验证:目标 unittest=`15/15 PASS / 6.280 s`;project discover=`106/106 PASS / 60.799 s`;正式输入临时冷导入=`136 inserted / 1 gap / 1.343 s`;同库热导入=`0 inserted / 136 unchanged / 1.266 s`;CLI help、strict UTF-8/no BOM、行尾空白和治理=`PASS / OK / warnings=0`。
+- 外部动作:network=`0`;provider real call=`0`;schedule install=`0`;正式数据库写入=`0`;实现/测试/fixture 修改=`0`;`dev/ana-dev` 修改=`0`。
+
+### Findings
+
+#### F1(阻断,需求/集成合同):V2 只读桥没有证明“未复权且已完整收盘”的价格
+
+- 权威需求第 24、86、122—123、209 行要求最新完整交易日的未复权收盘价,且盘中值不得进入台账;冻结 V2 registry/provider 固定 `fqt=1`,而 ledger `market.py` 第 60 行无条件写 `is_complete_trading_day=True`,没有从适配器证据证明复权口径,也没有阻止当日收盘前的 K 线被硬标为完整。
+- `test_10` 只 mock 一个已经整理好的价格对象,未断言复权参数或同日盘中拒绝,因此不能证明该主合同。按当前实现运行真实 `daily` 可能把不符合需求口径的价格写入正式库并改变四档标签。
+
+#### F2(阻断,代码/版本时序):新复评在估值日当天生效,而需求规定次日起生效
+
+- `core.py` 第 502 行把每个版本 `active_from` 直接写为 `valuation_date`,第 529—532 行据此关闭上一版本。独立临时库反例导入 2026-08-04 旧版与 2026-08-06 新版后,对 2026-08-06 价格计算,实际绑定的新版本估值日为 `2026-08-06`;需求第 171 行要求“次日起每日判定使用新版本”。
+- 现有 `test_04` 只检查 2026-08-07,未检查复评当日,因而遗漏了生效日 off-by-one。
+
+#### F3(阻断,代码/幂等证据):导入版本只用 snapshot 哈希,计算 JSON 变化会被静默计为 unchanged
+
+- 估值区间、方法和多数指标实际来自 `calculation/valuation_results.json`,但 `core.py` 第 270 行仅以 snapshot SHA-256 生成 `source_hash/valuation_id`;`INSERT OR IGNORE` 后也未逐字段核对既有不可变版本。
+- 独立临时库反例保持 snapshot 不变,只把 calculation 的 `base_low` 从 10 改为 11;第二次导入实际为 `inserted=0 / unchanged=1`,数据库仍保存 10,且没有冲突缺口。形式上通过幂等,实质上未识别已导入输入变化,违反正式 JSON 可追溯和同一输入幂等的前提。
+
+#### F4(阻断,代码/正式输出提交):失败日仍发布空 latest,且 Markdown/CSV 不是成对原子提交
+
+- `cli.py` 第 112—116 行无论日更状态均调用 `export_latest()`。独立空行情 fixture 反例得到 `status=FAILED / exit=4 / failure_count=1`,但仍创建 `latest.md`、`latest.csv`,两者均为 0 行;这与本次证据声明“首次验证完整日成功前保持 absent”冲突,也会把无有效日价的文件误当最新总表。
+- `core.py` 第 845、866 行先后独立替换 CSV 与 Markdown。注入第二次写失败后,实际为新 CSV 已发布、旧 Markdown 保留;数据库虽按需求保留提交,但 `list/show/export` 和两份正式输出不再一致,不满足需求第 160、196 行的原子输出/导出失败合同。
+
+### 最小 required fixes
+
+1. `F1`:先由需求所有者/项目管理员冻结可实施口径。若坚持未复权,必须授权并审核一个能证明未复权的登记 V2 行情合同;不得继续把现有 `fqt=1` 输出宣称为未复权。ledger 桥必须基于明确证据设置 complete-day,至少对上海当日收盘前 fail closed,并增加复权参数与同日盘中/收盘后入口测试;不得新增自由 provider 或真实联网验收要求。
+2. `F2`:对已有上一版本的新复评,冻结 `active_from=valuation_date+1`、上一版本 `active_to=valuation_date`;首次版本可保持能覆盖其首个有效日。增加复评当日仍用旧版、次日切换新版及历史判定不回写测试。
+3. `F3`:不必破坏正式库现有 ID;在命中既有 `ticker+valuation_date+source_hash` 时逐字段核对所有导入不可变值及路径,任何 calculation 变化不得计为 unchanged,必须 fail closed/形成明确冲突缺口。补 calculation 单字段变化反例;如改为组合 fingerprint,必须同时给出已初始化正式库的兼容迁移口径。
+4. `F4`:只有至少一个可信 daily judgement 成功写入时才发布新 latest;首轮全部失败不得创建空 latest,已有 latest 在全失败时保持原 bytes。把 Markdown/CSV 改为成对提交,捕获 `BaseException` 并在任一临时写、flush/fsync、replace 或第二文件提交失败时恢复旧二文件/保持二文件均缺席。补全失败、第一/第二文件提交失败及 KeyboardInterrupt/SystemExit 注入测试。
+5. 修复后复跑目标测试、project discover、V1/V2 只读回归和正式输入的临时冷/热导入;更新工具说明、实现证据、worklog 与冻结哈希。无需联网、真实日更或安装计划任务;正式运行库在复审前继续保持只读且不得写入价格/判定。
+
+### 已通过且冻结的部分
+
+1. 四业务表与约束、正式库结构完整性、125/136 初始化事实及唯一诚实缺口通过。
+2. V1 四档边界、83 只离线日更、单股行情失败隔离、SQLite 非业务异常整批回滚、同日价格/判定不可变和无有效估值版本不留价格半状态通过。
+3. `list/show` 查询与正常 export 内容一致,200 行暖查询小于 1 秒;`run_daily.ps1` 不含计划任务安装原语。
+4. 没有修改 `dev/ana-dev`、新增 provider、真实联网、安装计划任务或生成交易指令;正式库没有伪价格/判定,现状可安全保留为只读初始化快照。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`4`
+- 当前安全边界:代码与正式初始化库可继续作为隔离开发/只读审计快照;不得执行正式 `daily`,不得把空或部分提交的 `latest.md/latest.csv` 作为交付完成。
+- 复审边界:只复审 F1—F4、对应非真空反例、更新后的冻结证据及合同未回退;不新增方案链、provider、网络或计划任务验收。
+- Open questions:`F1` 的未复权口径与冻结 V2 `fqt=1` 合同冲突,需需求所有者/项目管理员明确选择后才能编码闭环。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BACKHALF-LIMITED-IMPLEMENTATION-REVIEW-20260805-001
+
+- 记录时间:`2026-08-05T21:09:44+08:00`
+- 审核阶段:已登录会话产生的完整本地媒体文件接管后半程限定独立实现审核。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求:`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 请求交接:`HANDOFF-INFODEV-INFOREV-BILI-AUTHENTICATED-SESSION-BACKHALF-IMPLEMENTATION-REVIEW-20260805-001`;来源/回传任务:`019fb7c5-b721-7332-b5c3-7120176fe016`;请求方:`case_analysis.video_downloader / 019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5`。
+- 审核边界:只审核 `accept-browser-file` 在完整媒体已由用户合法界面落盘后的本地接管、验证和发布;不读取 Chrome profile/Cookie/token,不使用签名媒体 URL,不运行网络、浏览器、扩展、下载或真实媒体,不访问 `F:\video`、隔离 preview 或正式 `ana-data`,不修改实现、测试、文档或其他工作区文件。
+- 冻结实物:code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`,均与交接冻结值一致;worklog=`2351/5FA13EE55EAE69D90E8BE933885BB1D2B7C5A61F02FD931A7A271BEEED23A08B`。
+- 独立验证:内存编译=`PASS`;根 CLI help 与 `accept-browser-file` help=`PASS`;目标 unittest=`21/21 PASS / 0.143 s`;owner 范围 project 回归=`97/97 PASS / 53.846 s`;目标文件 `git diff --check=PASS`;strict UTF-8/no BOM/尾随空白=`PASS`;治理=`OK / projects=1 / warnings=0`。
+- 独立定向反例:空文件、伴随 `.crdownload`、增长中文件、目标碰撞、约 `600.133313 s` preview 对 `3133.95 s` 远端时长、发布期间 `KeyboardInterrupt`、映射脱敏与源/副本 SHA-256 绑定均按合同 fail closed 或通过;碰撞发生在外部命令前,失败后正式输出和暂存为零,源文件原 bytes 保持不变。
+- 外部动作:network=`0`;Chrome=`0`;extension/service/token=`0`;download=`0`;real media=`0`;signed media URL=`0`;credential access=`0`;`F:\video`=`0`;quarantine=`0`;正式 `ana-data`=`0`;实现/测试/文档修改=`0`。
+
+### Findings
+
+- 无阻断 finding。
+
+### 审核结论
+
+1. `PASS / exact binding and ingress validation`:命令只接受 batch 中唯一精确 BVID;本地输入必须已存在、非空、后缀属于 AV 容器、没有 `.crdownload/.part` 伴随文件,且在固定等待前后大小和修改时间稳定。非 AV、partial、增长、BVID 不匹配或目标碰撞均在正式发布前停止。
+2. `PASS / read-only copy and integrity`:源文件仅以只读方式打开;复制在目标目录的随机同卷暂存内以 CreateNew 完成,复制过程中计算源流 SHA-256、复核源大小/修改时间,并重新计算暂存 SHA-256。异常捕获覆盖 `BaseException`,暂存清理且不移动、重命名或删除源文件。
+3. `PASS / completeness and preview gate`:本地接管只复用公开无凭据 metadata probe,不调用下载命令;FFprobe 必须确认正时长且同时具有视频流和音频流,随后按 `max(3 s, remote duration × 0.1%)` 比较远近端时长。约 600 秒 preview 对约 3134 秒完整视频不能进入发布。
+4. `PASS / no-overwrite and mapping`:媒体用硬链接 CreateNew 发布,映射用 CreateNew 写入;映射创建或其他发布异常会回滚本次正式媒体并清理暂存。映射保存 canonical source、发布时间、字节数、媒体 SHA-256、远近端时长证据和 codec,并标记 `authorized_browser_file_handoff`;不记录原本地绝对路径、Cookie、token、Chrome profile 或签名分片地址。
+5. `PASS / preserved contracts`:既有公开 `probe/download` 的 `--ignore-config --no-cache-dir --no-cookies --no-cookies-from-browser --no-playlist`、multi-P/playlist、认证/会员状态、FFprobe AV、时长一致性、no-overwrite 和失败清理合同未发现回退;metadata probe 仍只表示 `METADATA_PASS_ONLY`,不表示下载或完整媒体已取得。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 授权范围:仅批准冻结实现作为“用户已通过合法界面取得单个完整本地 AV 文件之后”的自动接管后半程;不得据此宣称 Chrome/扩展完整文件入口已解决,也不授权读取认证状态、签名 URL、下载、真实媒体、`F:\video`、隔离 preview、正式 `ana-data` 或转写动作。
+- 入口阻断:`PRESERVED`。当前 Chrome/扩展没有满足安全边界的完整 AV 文件出口;TXT、MP3、封面、`.m4s`、partial、扩展任务完成状态及 600 秒 preview 均不得作为完整原件或交付完成证据。
+- 后继评审:本限定实现无需新增评审链;请求方只有在另行合法取得完整文件并具备对应真实路径/动作授权后,方可按文档运行后半程,且仍须逐项通过全部门禁。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-MYSQL-HOLD4-REREVIEW-20260805-001
+
+- 记录时间:`2026-08-05T21:49:43+08:00`
+- 审核阶段:用户变更后的 MySQL 单后端实现/输出集中独立复审,仅核对当前 MySQL 合同、前序 HOLD/4 的 F1—F4 闭环及已冻结业务边界。
+- 审核对象:`DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001`;权威需求=`ana-doc/股票估值/股票估值每日台账业务需求_v1.0.md`;管理员冻结决定=`HANDOFF-INFOADMIN-INFODEV2-STOCK-VALUATION-LEDGER-MVP-MYSQL-F1-DECISION-20260805-001`。
+- 请求交接:`HANDOFF-INFODEV2-INFOREV-STOCK-VALUATION-LEDGER-MVP-MYSQL-HOLD4-REREVIEW-20260805-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`;请求方=`case_analysis.analyst.valuation / 019fb338-fe89-7d52-9ba6-513e82de54d8`。
+- 当前合同:写库仅 `stock_valuation`;行情只读源仅 `trading_xuntou`;不得读取/写入 `tianxia`,不得使用 V2/provider/network fallback,不保留 SQLite 后端或把旧 SQLite 作为迁移源;凭据仅环境变量或未跟踪本机配置;公开 CLI 固定为 `init/daily/list/show/export`。
+- 冻结实物核验:提交列出的 package、schema、实现、测试、fixture、工具说明、证据、worklog 与权威需求共 16 项 bytes/SHA-256 均与实物一致;其中 core=`46772/875D29EF68007E6370A80E7D1399680C196AECE10874BE2FBB838899980C39B6`、market=`10906/5B4DC4CD8798C58073F1A0E25A9DC180F73330C5FAEF2ADF242A6AFD7D90D32F`、database=`4074/0CAD60E8ED76A96A5D9B05D653596497A9755A60C3BC01CF3DA6331769CC0B8B`、tests=`25112/099F796477D78539054A68D74994B5EE9D188DDBBDC96EB40C924908D76BC81A`。
+- 独立静态/入口核验:根 CLI help=`PASS`;PowerShell 5.1 parser=`PASS`;治理=`OK / projects=1 / warnings=0`;`market.py` 无 V2 导入/调用并通过只读连接读取 `trading_xuntou`;`database.py` 将生产写库限定为 `stock_valuation`、测试写库限定为显式开关下的 `stock_valuation_test_*`;未发现 `tianxia`、SQLite 产品后端、provider fallback、计划任务安装或交易指令回退。
+- 独立测试说明:当前 reviewer 进程没有配置 MySQL 测试凭据,提交的 MySQL 集成类按自身门禁安全跳过;本轮实际执行的纯单元项=`1 PASS / 1 integration class skipped`。因此不把开发员申报的 `19/19` 当作 reviewer 重跑结果;阻断结论来自冻结代码逐行检查与两个不需要数据库/外部动作的确定性故障注入。
+- 外部动作:network=`0`;生产 MySQL read/write=`0`;`trading_xuntou` read/write=`0`;V2/provider=`0`;production daily=`0`;formal latest publish=`0`;schedule install=`0`;`tianxia`=`0`;`dev/ana-dev` 修改=`0`;实现/测试/fixture 修改=`0`。
+
+### Findings
+
+#### F4-R1(阻断,双文件提交失败清理):staging 写入/fsync 位于 BaseException 状态机之外
+
+- `core.py:1006-1016` 先创建两份 `.pair-stage-*`、写入、flush、fsync;捕获/回滚 `BaseException` 的 `try` 直到 `core.py:1017` 才开始。因此任一 `mkstemp/fdopen/write/flush/fsync` 故障不会进入 `core.py:1029-1048` 的清理分支。
+- 独立临时目录反例在第二次 `fsync` 注入原样 `KeyboardInterrupt`:正式 pair 首次运行仍双缺席、已有 pair 仍保持 `old-csv/old-md`,但两种场景均实际遗留两份 `.pair-stage-*`。这没有闭合前序 required fix 明确包含的“任一临时写、flush/fsync”边界,也不符合本次交接和工具文档声称 staging/fsync 已纳入 BaseException 回滚的证据。
+- `test_14_latest_pair_rolls_back_first_second_and_baseexceptions` 只在 `.pair-stage-* -> latest.csv/latest.md` 的 `os.replace` 注入三类异常,并且只比较旧正式 pair;没有注入 stage create/write/flush/fsync,也没有断言 stage/backup 零残留,因此提交的 `19/19` 不能排除该反例。
+
+#### P1(阻断,冻结数据库原子性回退):单股判定失败会提交只有 daily_price 的半状态
+
+- 权威需求第 197 行要求数据库失败不得留下“价格已更新但判定未更新”的半状态,工具说明第 75 行进一步冻结“个别行失败则该证券零写入,`daily_price` 与 `daily_judgement` 不会出现价格半状态”。
+- `_candidate_from_package()` 只校验区间顺序(`core.py:218-225`),没有拒绝 `base_low<=0`;schema 也没有相应正数约束。`apply_daily_prices()` 在 `core.py:807-817` 先插入 `daily_price`,之后才在 `core.py:818-821` 调用会因 `base_low<=0` 抛出 `E_RANGE` 的 V1 判定;该 `LedgerError` 被 `core.py:874-875` 按单股缺口吞下,外层事务随后在 `core.py:876` 正常 commit。
+- 独立无数据库 cursor/connection 桩反例得到 `status=FAILED / failure=E_RANGE / reported_inserted_prices=1 / daily_price INSERT=1 / daily_judgement INSERT=0 / commit=true / rollback=false`。现有 `test_07` 仅覆盖“在插入价格之前发现无有效版本”,`test_18` 仅覆盖 MySQL 底层异常导致整批回滚;都没有覆盖价格插入后的业务 `LedgerError`,所以不能证明逐证券零写入合同。
+
+### 已关闭且冻结的部分
+
+1. `F1 PASS`:行情模块不再引用 V2;源 schema 在目标写入前证明;逐行验证 A 股身份、CNY、有限正收盘价、`trade_date<=as_of`、未复权、开市日/日历覆盖、上海 15:05 后最终加载时间及来源标识。实际源最大日期 `2026-02-24` 保持原日期且未伪装为当前日;正式日表/latest 仍为空。
+2. `F2 PASS`:首次估值版本从估值日生效;已有版本后的复评从估值日次日生效,上一版本结束于复评日;同日旧版、次日新版和历史判定不回写路径已在冻结实现/测试中闭合。
+3. `F3 PASS`:命中既有 valuation identity 时逐项比较所有导入不可变字段,包括 results-derived ranges/metrics/paths;同 snapshot 下计算结果变化形成 `E_IMMUTABLE_VERSION_CONFLICT` 并保留旧值。
+4. `F4 partial PASS`:全失败首次运行不创建正式 latest,已有成功 pair 在后续全失败时保持原字节;两次正式 replace 的 OSError/KeyboardInterrupt/SystemExit 回滚可由代码和测试证明。只有上述 staging 前半程失败清理仍阻断。
+5. MySQL 单后端、`trading_xuntou` SELECT-only、凭据不落盘/不回显、五个 CLI、无 V2/SQLite/tianxia/provider/计划任务/交易指令边界未发现回退;本结论不把历史 SQLite/V2 实现视为当前合同。
+
+### 最小 required fixes
+
+1. `F4-R1`:把两份 stage 的 create/write/flush/fsync 全部纳入统一 `BaseException` 所有权状态机;任何阶段失败都原样传播非普通异常、清理本次 stage/backup,并保持正式 pair 双缺席或恢复旧双文件。增加第一/第二文件的 create/write/flush/fsync `OSError`、`KeyboardInterrupt`、`SystemExit` 注入,逐例断言正式 pair、stage、backup 的完整终态和原异常。
+2. `P1`:确保每只证券的 price 与 judgement 作为不可分割单元。最小做法可在任何 INSERT 前完成版本区间/V1 判定及所有会抛业务错误的校验,或为每只证券使用 savepoint 并在任一 `LedgerError` 时回滚该证券;同时在正式估值导入/schema 层拒绝非正的判定区间。增加 `base_low<=0` 等“价格插入后才会失败”的入口反例,断言该证券 `daily_price=0`、`daily_judgement=0`,其他有效证券仍可提交并报告精确 gap。
+3. 修复仅需离线/隔离 MySQL 测试:补齐上述非真空矩阵,复跑目标套件和 project discover,更新工具说明、实现证据、worklog 与冻结 hashes。不得运行生产 daily、发布正式 latest、访问真实行情/provider、安装计划任务或修改 `dev/ana-dev`。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`2`
+- 当前安全边界:正式 MySQL 初始化数据及空 `daily_price/daily_judgement` 可继续保持只读;代码可用于隔离开发与无生产输出的修复验证。不得执行生产 `daily`,不得把当前实现或任何 partial/latest 临时实物作为完成交付。
+- 复审边界:仅复审 F4-R1、P1 两项最小修复与其非真空反例,并确认本次已经关闭的 F1—F3、F4 已通过子项和单后端边界未回退;无需网络、正式 daily/latest 或计划任务。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-MYSQL-F4-R1-P1-REREVIEW-20260805-001
+
+- 记录时间:`2026-08-05T22:29:42+08:00`
+- 审核阶段:前序 HOLD/2 的 F4-R1/P1-only 限定实现复审;复审进行中收到权威需求变更,因此只保留两个原阻断的快照子结论,不形成最终实现 PASS。
+- 审核对象:`DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001`;请求交接=`HANDOFF-INFODEV2-INFOREV-STOCK-VALUATION-LEDGER-MVP-MYSQL-F4-R1-P1-REREVIEW-20260805-001`;需求变更通知=`active_review_requirement_change_notice`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 原限定快照核验:core=`47078/5D6644CF745B76F8710D3B83B75260FD41959BFDCB5B30FF3AD282B959CE8D45`;tests=`30000/9960BDA0A803050724264665FE9969C6F9898F3B7C55083ACAC397C6E8EAADB4`;tool doc=`9380/0EC0EB4690D1BC75FBF89A0C7EA0FA656739317BD2140B096F0271710C4AC53B`;evidence=`9741/BEBFBA7DAB2769BBD91EF68377D249557A213B70382AE5DFEFDA434CA08CD413`;worklog=`4610/D40E9D5ACCE26AAAA1889E676CAA6655F70FB536F7973E1D59560EFF039B8589`,均与交接一致。
+- 需求变更事实:权威需求现为 `14710/64625A19A5FF1948014A3F9F1BDABED2379F5A15D44F0E9B2004B5EDE481DA41`,已把生产 `daily` 唯一价格来源改为 `trading_xuntou.cn_stock_kline_1d_front`;禁止读取 `cn_stock_kline_1d`、禁止 `dividend_type` 推断及 V2/network fallback,并新增专表最大日期必须满足请求日的合同。当前受审 market/tests/docs 正在被后续 delta 替换,故不再是最终交付快照。
+- 外部动作:production daily=`0`;formal latest=`0`;production MySQL read/write=`0`;market/provider/network=`0`;schedule install=`0`;实现/测试/正式输出修改=`0`;本审核仅写审计入口。
+
+### F4-R1/P1 子结论
+
+1. `F4-R1 CLOSED_IN_SUPERSEDED_SNAPSHOT`:`_write_pair` 的两份 stage create/write/flush/fsync 已进入统一 `BaseException` 状态机。审核员独立执行 24 组“第一/第二 stage × create/write/flush/fsync × OSError/KeyboardInterrupt/SystemExit”矩阵全部通过;旧正式 pair 原字节保持且 stage/backup 均为 0。原第二次 fsync 反例在首次 pair 双缺席和已有 pair 两种状态下也均为零残留、正式状态正确。
+2. `P1 CLOSED_IN_SUPERSEDED_SNAPSHOT`:`apply_daily_prices` 在任何 `daily_price` SELECT/INSERT 前转换区间并调用冻结 V1 判定。审核员无数据库桩以 `base_low=0` 重放原反例,结果为 `FAILED/E_RANGE / inserted_prices=0 / inserted_judgements=0 / price SELECT=0 / price INSERT=0 / judgement INSERT=0`;原“价格已写但判定未写”的半状态不能再复现。
+3. 本轮未独立重跑 MySQL 集成套件:reviewer 进程仍无本机测试凭据;开发员申报的 focused=`2/2`、target=`21/21`、project=`118/118` 仅作为冻结提交证据,不冒充 reviewer 重跑结果。两个子结论由代码、测试矩阵静态核对及独立无外部动作反例支撑。
+
+### 结论
+
+- 审计状态:`SUPERSEDED_REQUIREMENT_CHANGE`
+- 当前快照阻断问题数(仅 F4-R1/P1):`0`
+- 最终实现结论:`NOT_ISSUED`。不得把本记录解释为整体实现 PASS,也不得沿用旧 F1/market 结论;新需求已实质改变行情表、口径证明和日期门禁。
+- 后续入口:开发员完成专表 delta、离线验证并冻结新的 code/tests/docs/evidence hashes 后,提交一次新的集中实现复审;该复审应组合核对新 market 合同、F4-R1/P1 子结论未回退及全部正式边界。
+- 当前安全边界:继续禁止生产 `daily`、正式 latest、真实行情/provider、计划任务和任何正式数据写入。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001-DESIGN-20260805-001
+
+- 记录时间:`2026-08-05T23:01:39+08:00`
+- 审核阶段:exact `青枫浦上Q / BV1HA3o6oEJJ` 已登录会话扩展入口 V001 合并安全设计独立审核;实现、真实认证和下载继续冻结。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;管理授权=`HANDOFF-INFOADMIN-INFODEV-BILI-PLUGIN-FULL-VIDEO-NARROW-SCOPE-ASSIGN-20260805-001`。
+- 请求交接:`HANDOFF-INFODEV-INFOREV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001-DESIGN-REVIEW-20260805-001`;来源/回传任务=`019fb7c5-b721-7332-b5c3-7120176fe016`;请求方=`case_analysis.video_downloader / 019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5`。
+- 设计实物:`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001.md=22418/665DDDFE4247DEFEE69B6DD1E0CBBD128EDD75B8A32BA609C90C718AC7C97C71`;strict UTF-8/no BOM/trailing whitespace=`PASS`。
+- 冻结基线:bridge=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`,均与交接一致;冻结后半程审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BACKHALF-LIMITED-IMPLEMENTATION-REVIEW-20260805-001 / PASS/0`。
+- 独立依赖核验:隔离环境 yt-dlp=`2026.07.04`;其 `globals.py` 默认 `plugin_dirs=['default']`,`YoutubeDL.__init__` 在首次实例化时调用 `load_all_plugins()`;`download(url_list)` 会再次调用 `extract_info()`;下载器映射中 `m3u8 -> FFmpegFD`、`m3u8_native -> HlsFD`、`http_dash_segments -> DashSegmentsFD`。V001 对这些真实行为尚未冻结对应阻断机制。
+- 治理:`OK / projects=1 / warnings=0`。设计提交后的根级 outline/plan/execution log 因其他 owner 追加事项而继续增长;V001 设计、工作日志和冻结产品基线未漂移,不把并发 append-only 账本哈希差异作为方案 blocker。
+- 外部动作:real Cookie/token/signed URL=`0`;Chrome/extension install or modification=`0`;Native Host registry=`0`;network/download=`0`;real media/quarantine=`0`;`F:\video`=`0`;formal `ana-data`=`0`;product code/tests/config changes=`0`。
+
+### Findings
+
+#### F1(阻断,Native Messaging 身份与秘密输入合同):唯一 origin 没有绑定到可复算的项目扩展身份,Cookie/page schema 也未冻结到可实现字段
+
+- V001 第 58、144—153 行只要求安装时检查一个“32 位 Chrome ID”并写入唯一 `allowed_origins`,没有把该 ID 绑定到项目扩展固定公钥/manifest key、构建实物哈希或编译期 expected ID;安装脚本若接受任意格式正确的 ID,就可能把 host 授给错误扩展。主机协议也未要求校验 Chrome 启动时传入的 origin 参数与同一 pinned ID 一致。
+- 第 114—130 行宣称 unknown field fail-closed 和 exact field set,但没有枚举 `page_proof` 与 Cookie record 的完整键、类型、nullability、数值范围和 canonical 规则。Chrome Cookie 对象的 `hostOnly/session/expirationDate/secure/httpOnly/storeId/partitionKey/sameSite` 如何投影、当前 tab cookie store 如何绑定、partitioned cookie 如何处置、session/expiry 如何转成 Netscape 字段均未冻结;实现无法从方案得出唯一安全 wire schema,也无法写出非自证的 unknown-field/过期/分区反例。
+
+#### F2(阻断,yt-dlp 同进程秘密边界):设计声称“禁止插件加载”,但冻结版本默认加载用户插件,且 logger 不能覆盖全部第三方输出面
+
+- V001 第 172、253 行把“无插件”和 synthetic sentinel 作为硬门,却未定义在 import/首次 `YoutubeDL` 前如何关闭全局 plugin directories。冻结 yt-dlp 的实际默认值为 `['default']`,首次 API 实例化会执行 `load_all_plugins()`;用户插件可在持有内存 CookieJar/info_dict 的同一进程运行,直接破坏“只有受信任 host/yt-dlp 核心可见秘密”的威胁模型。
+- 第 134 行只配置 yt-dlp custom logger。冻结库仍有不经该 logger 的直接 stdout/stderr 写入路径(例如 CookieJar 解析告警),FFmpeg/其他子进程也必须显式重定向;当前测试只列 fake yt-dlp/CookieJar,不能证明真实冻结包在成功和畸形 Cookie/格式/异常路径上不会把 sentinel 写到协议 stdout、stderr 或 traceback。
+
+#### F3(阻断,metadata-to-download 绑定与签名 URL):预检对象没有绑定到实际下载,且“内置 HTTP”不足以阻止 FFmpeg 下载器接收 URL
+
+- V001 第 164 行先 `extract_info(download=False)`,第 174—182 行再描述下载,但没有冻结调用方式和对象所有权。冻结 API 的 `download([url])` 会重新 `extract_info()`;若实现走该自然路径,前一次通过 id/duration/DRM/AV 的 info/selected formats 与实际下载对象之间存在第二次解析窗口,门禁并未约束真正下载的格式。
+- 仅“不配置 external downloader”并不能证明签名 URL 不进 FFmpeg argv:冻结版本会为 `m3u8` 自动选择 `FFmpegFD`。方案没有冻结 selected/requested formats 的允许协议、实际 downloader class、fragment/manifest 拒绝规则或在第一媒体字节前的断言;fake yt-dlp 只检查 opts,不能证明真实调度器选择。
+- `merge_output_format='mkv'` 只约束合并路径;`bestvideo+bestaudio/best` 的单文件 fallback 未冻结必须 remux 为 MKV或 fail-closed。由此“同一已验证对象、仅本地 FFmpeg 输入、最终唯一 MKV”三个合同目前不能同时验收。
+
+#### F4(阻断,取消/断连与后代清理):没有可在 Windows/阻塞网络下兑现的有界进程所有权状态机
+
+- V001 第 83、126、208—213 行要求 cancel、Native Host 断连、Chrome 关闭和 BaseException 都结束后代并清空秘密,但未定义协议 reader 与阻塞 yt-dlp worker 的并发关系、socket/read timeout、retry/fragment retry、总墙钟、取消检查点、grace/kill 时限或 join 终点。若主线程阻塞在 metadata/HTTP,`cancel`/EOF 可能不能及时被消费,CookieJar 生命周期也无上界。
+- Python `finally`/顶层 `BaseException` 不能在 Chrome 强制终止 Native Host 或进程崩溃时运行;V001 没有 Windows Job Object `KILL_ON_JOB_CLOSE` 或等价的 OS 级后代所有权。第 257 行仅要求 fake yt-dlp/FFmpeg 后“后代=0”,无法证明真实 sleeping child/FFmpeg 在 pipe EOF、host kill、cancel 和超时时已退出。
+
+### 已通过且冻结的设计部分
+
+1. 单一 exact BVID、用户可见二次点击、无通用下载/批处理/自动触发、无外部消息、无 loopback/HTTP/WebSocket/token 文件的范围正确。
+2. Cookie 不进入 argv/env/config/storage/mapping/evidence、只使用内存 `io.StringIO` 的总体方向正确;Python 不承诺物理擦除的限制已诚实披露。
+3. 页面/full-duration、认证 metadata、single video、multi-P/live/DRM、AV、600 秒 preview、partial、候选唯一性及冻结 backhalf 的多层 fail-closed 顺序合理;正式媒体/mapping 仍由已 PASS 的后半程门禁发布。
+4. 实现审核前仅允许离线/合成测试,真实安装、Cookie、下载、注册表、`F:\video`、正式 `ana-data` 和转写继续为 0;冻结 bridge/tests/usage 及现有 TXT 扩展不得修改。
+
+### 最小 required fixes
+
+1. `F1`:在 V002 固定项目扩展 manifest key/public identity 与唯一 expected extension ID;installer 不接受任意 ID,只比较实际 ID 与 pinned ID,并生成 exact origin;host 启动时再校验 Chrome origin 参数。逐字段冻结 page proof/start/cookie schema、当前 store/partition 规则、canonical domain/path/session/expiry 转换及所有字节/数量上限;扩展只投影允许字段,不序列化原始 Cookie 对象。
+2. `F2`:冻结在 import/首次实例化前关闭 yt-dlp 全局插件的实际机制,并断言 plugin maps/directories 为空;Native protocol 使用独立保存的二进制句柄,真实 yt-dlp/CookieJar/FFmpeg 的 stdout/stderr 全部进入不持久化的丢弃 sink。用冻结真实包做无网络 synthetic sentinel 测试,覆盖合法与畸形 Cookie、direct warning、异常和关闭路径,而非只测 fake logger。
+3. `F3`:冻结“一次解析、同一对象下载”的具体 API 状态机,例如在真实下载前的 match-filter 中验证将被下载的同一 processed info,或把经验证的唯一对象直接交给下载处理;禁止再次按 URL 解析。逐个 requested format 校验 id、protocol、DRM、A/V、duration 和实际 downloader class,只允许不会把 URL交给子进程的内置类;任何 FFmpeg argv 含 URL/header/Cookie 即安全停止。明确单文件 fallback 的 MKV remux 或拒绝合同,并增加真实调度器选择/单次解析计数测试。
+4. `F4`:冻结可并发消费 status/cancel/EOF 的协议 reader、单 worker/cancel event、网络/metadata/fragment timeout 与 retry 上限、总任务墙钟和有界 grace/kill/join;所有子进程置于 Windows kill-on-close Job Object 或等价 OS 所有权。增加无网络真实 sleeping-child 测试,在 cancel、pipe EOF、host disconnect、KeyboardInterrupt/SystemExit、超时和模拟 host kill 后证明 PID 全灭、Cookie stream 关闭清零、stage 可安全回收且协议无 sentinel。
+5. V002 仍只允许修改设计与账本;四项闭环并独立 PASS 前,不得创建实现、测试、extension、Native Host、构建/安装脚本,不得执行任何真实认证或下载动作。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`4`
+- 当前安全边界:V001 可作为范围/威胁模型草案继续修订,但不是可安全实施合同;现有 TXT 扩展、localhost/token/SQLite/Cookie 文件链、冻结 backhalf 和全部正式产物保持原状。
+- 复审边界:仅复审 F1—F4 的 V002 闭环及已通过边界是否回退,一次返回剩余完整阻断;无需真实 Chrome、Cookie、下载、注册表或媒体。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-FRONT-ADJUSTED-CONSOLIDATED-REREVIEW-20260805-001
+
+- 记录时间:`2026-08-05T23:18:00+08:00`
+- 审核阶段:MySQL 单后端、首次 HOLD/4、后续 HOLD/2 修复及前复权专表 delta 的合并独立实现/输出复审。
+- 审核对象:`DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001`;权威需求=`ana-doc/股票估值/股票估值每日台账业务需求_v1.0.md=15012/ACD0EBABA5BF424513A139A736C5A62B2AF1E3EAE80B616E57DA2ECC22FF0F95`。
+- 请求交接:`HANDOFF-INFODEV2-INFOREV-STOCK-VALUATION-LEDGER-MVP-FRONT-ADJUSTED-CONSOLIDATED-REREVIEW-20260805-001`;替代冻结交接=`HANDOFF-INFODEV2-INFOREV-STOCK-VALUATION-LEDGER-MVP-FRONT-ADJUSTED-REPLACEMENT-20260805-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`;请求方=`case_analysis.analyst.valuation / 019fb338-fe89-7d52-9ba6-513e82de54d8`。
+- 当前冻结实现:core=`47078/5D6644CF745B76F8710D3B83B75260FD41959BFDCB5B30FF3AD282B959CE8D45`;market=`10087/F990ED84206FC66144F36500332BA57B229638D1371FC548E26F06A1D022E961`;tests=`31925/4E19DE08C46AD0367FD88C82C64D3622EA94114515CBA0030D8C10AAF8BA0723`;database/schema/cli/run_daily/README/fixture 均与 replacement handoff 冻结值一致。
+- 文档与证据:工具说明=`10074/9C75A5F84259C2EF41D92A2CCE5CE1C149A17F01DB39035E579CA29B3EB2B64B`;实现证据=`12168/F96E219DB571F2BDE2699AF2EB1F7293714CC752454A18F2DDFF3143B1155ADE`;worklog=`5601/7796A6C7DDAE6EF172CAE77535F4E6C1CB8003EECB01CF632C3433E8B41012DE`。
+- 独立静态/纯离线验证:replacement 哈希=`PASS`;`compileall=PASS`;CLI help=`PASS`;PowerShell 5.1 parser=`PASS`;纯单元 `MarketSchemaUnitTests=2/2 PASS`;目标文件 diff check=`PASS`。本 reviewer 无本机 MySQL 测试凭据,未把开发员申报的 `22/22`、project `119/119` 冒充为独立重跑。
+- 正式输出只读核验:`latest.csv=31060/C88986DF9CD008BF010623A3BF9BED49D675CF72975FF8E0B31DE3E54216E0A2`,共 124 个唯一 A 股 ticker、全部 `CNY`、全部交易日 `2026-08-05`;四档为偏低 3、基本合理 39、偏贵 39、明显偏贵 43,逐行按 close/base/optimistic 区间重算 mismatch=`0`。`latest.md=32890/98ECF8A0C7FE3141484A589A9533B8618BF1DEA5FDFE91614CE53F29ABE1F0F4` 声明 124 条且正文有 124 行;`latest_gaps.csv=192/E5BBD897B4A3BBE5A256C9E747F08DD2BEB4C23A1ACFB860D3184B20FDD555F7` 诚实记录唯一 `09880.HK` 不支持缺口。未查询或改写生产数据库,数据库四表规范哈希采用需求 owner 已交付、开发员只读复核的冻结证据。
+- 外部动作:production daily=`0`;formal latest write=`0`;network/provider=`0`;trading_xuntou DDL/DML=`0`;tianxia=`0`;schedule install=`0`;ana-dev change=`0`;实现/测试/正式输出修改=`0`。
+
+### Findings
+
+#### F1(阻断,完整交易日日历水位):滞后的 current 日历资产仍可把旧行情接受为请求日的最新完整交易日
+
+- 权威需求第 122—128 行要求 `cn_stock_kline_1d_front` 只有在其最大行情日期满足请求日时才可作为价格来源,日期滞后不得冒充当前价;工具说明第 71 行也声明正式日历必须证明“请求日以前最大开市日”。
+- `market.py` 第 176—203 行从 `status='current' / is_current=1` 的同一日历资产内计算 `MAX(c.trade_date<=as_of)`,但没有要求该资产的 `window_end` 覆盖请求 `as_of`。第 118—120 行仅要求 `window_end>=trade_date`,因此一个水位只到 8 月 4 日、但仍标记 current 的日历资产会自行证明 8 月 4 日是 8 月 5 日请求的“最大开市日”。
+- 审核员纯函数反例:`as_of=2026-08-05`、`trade_date=2026-08-04`、`window_end=2026-08-04`、`status=current/is_current=1`、`is_open=1`、`source=xtquant`、`updated_at=2026-08-04T16:10:00+08:00`,实际 `_record_from_row()` 成功返回 `PriceRecord(trade_date='2026-08-04')`。现有 test 10 反而把同类 `window_end=2026-08-10` 作为正例,test 21/22 只检查列名,没有覆盖 stale window。
+- 这不会推翻已发布 2026-08-05 正式输出的只读事实:该批文件本身全部为 2026-08-05 且标签重算一致;但当前实现若用于后续生产日更,可能在正式日历资产滞后时写入旧日价格或把旧日期当作当前可接受水位,违反 fail-closed 合同。
+
+### 最小 required fixes
+
+1. 在任何行情行投影及 `stock_valuation` 写入前,把正式日历资产的请求期覆盖作为全局硬门:所选唯一 current 资产必须满足 `window_end>=as_of/cutoff`,否则整体抛出明确的日历水位错误并保持目标四表与 `latest.md/latest.csv` 原 bytes;不能从同一滞后资产内部自证最大开市日。
+2. 在已证明覆盖请求期的 current 日历资产中再计算 `MAX(is_open=1 AND trade_date<=as_of)`,要求每只证券的专表行精确等于该日期;逐股缺行继续只产生显式 gap,不能回退旧表、`dividend_type`、V2/network 或静态证券表。
+3. 增加非真空入口反例:日历 `window_end<as_of` 且旧日行其余字段完全合法时,命令必须在任何目标写/latest 前失败;同时保留周末/休市请求在“日历已覆盖 as_of、最大开市日早于 as_of”时可正确使用最近开市日的正例,并保留 exact ticker/source、schema failure、行级隔离测试。
+4. 修复后冻结新的 market/tests/docs/evidence/worklog 完整哈希,复跑目标 MySQL 测试、project discover、静态/治理检查;无需也不得重跑已经由需求 owner 完成的 2026-08-05 生产 daily、网络/provider 或计划任务。
+
+### 已通过并冻结的部分
+
+1. MySQL 单后端和库边界通过:目标只写 `stock_valuation`,行情只读 `trading_xuntou`;无 SQLite、V2/network、`tianxia`、旧 `cn_stock_kline_1d`、`dividend_type` 或 `cn_stock_instrument_static` 运行依赖。
+2. F2/F3 通过:后续复评从估值日次日生效、旧版覆盖到复评日;同一估值身份逐项核对全部不可变字段,calculation 变化不能静默计为 unchanged。
+3. F4-R1/P1 未回退:两份 latest 的 stage create/write/flush/fsync 与 replace 均在 `BaseException` 状态机中;范围/V1 标签在任何 daily price SELECT/INSERT 前完成,原 `base_low<=0` 半状态不可复现。前序 `SUPERSEDED_REQUIREMENT_CHANGE` 中两项关闭子结论仍有效。
+4. exact ticker/source/suffix、CNY、正数 close、15:05 装载水位、source ID、行级失败隔离、四表事务回滚和 no-overwrite/成对 latest 提交方向通过;本轮未发现正式 124 股输出、唯一 HK 缺口或四档标签证据不一致。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`1`
+- 当前安全边界:2026-08-05 已发布的 124 股正式文件可继续作为该次需求 owner 运行的只读历史证据;当前代码可用于隔离修复和测试,但在 F1 闭环前不得再次执行生产 `daily`、覆盖正式 latest 或把后续日期结果视为已验收。
+- 复审边界:仅复审 F1 的日历请求期覆盖硬门、旧日/休市正反例及当前已通过合同是否回退;无需重开 MySQL 架构、F2/F3/F4/P1、provider 或正式生产运行。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-STALE-CALENDAR-FINAL-REREVIEW-20260805-001
+
+- 记录时间:`2026-08-05T23:24:00+08:00`
+- 审核阶段:前序合并实现 HOLD/1 的 F1 日历请求期覆盖单点终审。
+- 审核对象:`DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001`;请求交接=`HANDOFF-INFODEV2-INFOREV-STOCK-VALUATION-LEDGER-MVP-STALE-CALENDAR-FINAL-REPLACEMENT-20260805-001`;前序审计=`DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-FRONT-ADJUSTED-CONSOLIDATED-REREVIEW-20260805-001 / HOLD/1`。
+- 最终冻结实物:market=`10274/AE841AF79131DAED6BCD459544207EFC288454A879EE05F916BE1D7473D834E2`;tests=`31997/92E611CA089F1D3E48C1C28808880D34DA649D498E9287BD99F3EE81715535E0`;tool doc=`10202/52E39F0478257FF6D079D2FBE7DDBF356460934784B9DB58781BEAAAFD5C9D84`;evidence=`12322/5019F3A528CB9AA463FF22C3BE52E9BB296E18D3379F0B00DA7E645AD354A349`;worklog=`5867/6D7634A82CF8BBF0E21ADE176B83BEF459B91BC06BB17133C6E7911B7E4C966D`,均与 final replacement handoff 一致;其余实现与正式输出保持前序冻结值。
+- 独立限定验证:`_record_from_row()` 对 `as_of=2026-08-05 / window_end=2026-08-04 / trade_date=2026-08-04` 返回 `E_INCOMPLETE_TRADING_DAY`;对 `window_end=2026-08-05 / trade_date=2026-08-04` 的“请求期已覆盖、最近开市日早于 as_of”正例仍接受。`MarketSchemaUnitTests=2/2 PASS`;静态核对确认 expected CTE 与 outer calendar join 均要求 `window_end>=cutoff`,参数顺序与 SQL 占位一致。
+- 开发员冻结证据:target=`22/22 PASS / 86.679s`;project=`119/119 PASS / 140.964s`;compileall/CLI help/PowerShell parser/diff/UTF-8/secret scan/governance=`PASS`。本限定终审未重跑 MySQL 集成套件,不把开发员结果冒充 reviewer 重跑。
+- 正式只读证据未变:2026-08-05 `125/136/124/124`、A 股覆盖 `124/124`、四档 `3/39/39/43`、唯一 `09880.HK` 缺口及 `latest.md/latest.csv/latest_gaps.csv` 哈希均与前序审计一致;修复未重跑、未改写生产 daily/history/output。
+- 外部动作:production daily=`0`;formal output write=`0`;network/provider=`0`;schedule=`0`;trading_xuntou DDL/DML=`0`;tianxia/ana-dev=`0`;reviewer implementation/test modification=`0`。
+
+### Findings
+
+- 无阻断 finding。
+
+### 审核结论
+
+1. `F1 PASS`:正式日历资产在派生请求日以前最大开市日前必须先证明 `window_end>=as_of/cutoff`;同一条件在 outer join 重复约束,纯函数行投影也独立拒绝 stale window。滞后 current 资产不能再自证旧日价格为当前可接受水位。
+2. `休市/周末语义 PASS`:门禁要求日历资产覆盖请求期,而不是强制价格日期等于请求日;覆盖已证明后,仍可选择 `MAX(is_open=1 AND trade_date<=as_of)`,不会误拒绝休市日的最近完整交易日。
+3. `preserved contracts PASS`:case-sensitive exact ticker/source/suffix、前复权专表-only、15:05、CNY/正数、行级 gap、MySQL 单后端、F2/F3/F4-R1/P1、双文件原子提交、无 V2/network/SQLite/tianxia/provider 回退均未发现回退。
+4. `output PASS`:本结论确认当前实现与 2026-08-05 正式只读输出证据可作为本 MVP 的完整实现/输出交付;不授权或要求重复生产执行。未来 daily 仍须逐次通过当前专表、日历水位和行级证据门禁。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 交付状态:`IMPLEMENTATION_AND_EXISTING_OUTPUT_ACCEPTED`
+- 后续评审:`NOT_REQUIRED`;如未来新增港股来源、provider、SQLite/V2 回退、网络路径、计划任务安装或交易指令,须作为新需求另行治理,不属于本次 PASS。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-DESIGN-REREVIEW-20260805-001
+
+- 记录时间:`2026-08-05T23:33:00+08:00`
+- 审核阶段:V001 HOLD/4 的 F1—F4 限定安全设计复审;V001+V002 组成唯一合同,冲突处 V002 优先。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;目标仅限 `青枫浦上Q / BV1HA3o6oEJJ`。
+- 请求交接:`HANDOFF-INFODEV-INFOREV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-DESIGN-REREVIEW-20260805-001`;前序审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001-DESIGN-20260805-001 / HOLD/4`;来源/回传任务=`019fb7c5-b721-7332-b5c3-7120176fe016`;请求方=`case_analysis.video_downloader / 019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5`。
+- 设计实物:V001=`22418/665DDDFE4247DEFEE69B6DD1E0CBBD128EDD75B8A32BA609C90C718AC7C97C71`;V002=`23356/8526D942228B77C8A13FE8D6AC57B33925C59547FF25273E4A668DF2677564C2`,均与交接一致;V002 strict UTF-8/no BOM/trailing whitespace=`PASS`。
+- 冻结后半程:bridge=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`,均未漂移。
+- 独立身份复算:manifest `key` 严格 Base64 解码为 294-byte SPKI DER;SHA-256=`E83C2B2AF3CF011543FBA13FBC524D1122EEA68548F9F27B9F7A82B5D594666C`;前 128 bit nibble `a..p` 映射得到 `oidmclckpdmpabbfedplkbdplmfcenbb`,与 pinned ID 和 exact origin 一致。
+- 独立冻结依赖核对:yt-dlp `2026.07.04` 实物中 `globals.plugin_dirs` 默认确为 `['default']`;`plugins.load_plugins()` 在 `YTDLP_NO_PLUGINS` 或空 `plugin_dirs` 时立即返回,首次 `YoutubeDL` 初始化调用 `load_all_plugins()`。V002 的“stdlib 启动→NUL→设置环境→import→清空目录→真实加载并断言 maps/directories 全空→READY→才发送 secret”顺序可闭环前序 F2,而非继续依赖 custom logger。
+- 治理:`OK / projects=1 / warnings=0`。共享 outline/plan/execution log 可被并行 owner 继续 append;设计、worklog 和冻结后半程哈希一致,不把共享账本随后追加视为设计漂移。
+- 外部动作:implementation/tests/extension/host=`0`;real Cookie/token/signed URL=`0`;Chrome/registry/network/download/media=`0`;quarantine/F:\video/formal ana-data=`0`。
+
+### Findings
+
+- 无阻断 finding。
+
+### 审核结论
+
+1. `F1 PASS / fixed identity and wire schema`:manifest public key、DER hash、expected extension ID、exact origin、extension/host build identity 和 artifact manifest 形成可复算固定身份;安装器只能比较 observed ID,不能用任意 ID 铸造 origin;host 在 stdin/worker/secret 前复核 Chrome origin argv。协议 v2 对 frame、duplicate key、类型、整数/bool、page proof、start、cookie store/partition、domain/path/session/expiry 和 Cookie 数量/字节上限给出 exact fail-closed 合同。
+2. `F2 PASS / plugin and output isolation`:broker 不 import yt-dlp,worker 在任何 secret 进入前关闭真实插件入口并报告 `READY_PLUGIN_DISABLED`;协议 handle 独立、不可继承,fd1/fd2、worker、真实 CookieJar/yt-dlp/FFmpeg 输出统一进入非持久化 NUL。冻结真实包、畸形 Cookie、默认目录 sentinel plugin 与 sleeping FFmpeg 的无网络测试足以验证前序“真实包而非 fake logger”阻断。
+3. `F3 PASS / one extraction and downloader binding`:exact processed info 只解析一次;唯一 selected format 合并到同一 `download_info`,验证与 `process_info()` 使用同一对象并设置第二解析 guard。实际叶只允许 HTTPS `HttpFD/DashSegmentsFD`;FFmpegFD/HlsFD/ExternalFD/m3u8/未知协议在首媒体字节前拒绝。audit hook 禁 URL/header/Cookie 进入子进程,双流和单文件都只对本地文件生成唯一 MKV。
+4. `F4 PASS / bounded ownership`:reader/coordinator/worker 分工、阶段/总墙钟、retry、grace/kill/join 均有固定上限;worker 在 suspended 状态加入独立 `KILL_ON_JOB_CLOSE` Job Object 后才恢复,不能建立所有权即在 secret 前停止。具体实现必须按第 5.3 节由 worker 产生 yt-dlp/FFmpeg/bridge 后代并继承同一 job;broker 的“调度”不得实现为 job 外直接启动 bridge。真实 worker/child/grandchild 覆盖 cancel、EOF、disconnect、BaseException、timeout 和 host kill,可验证强杀时不依赖 Python finally。
+5. `preserved boundaries PASS`:exact BVID、第二次用户点击、无通用/批量下载、无 external messages/loopback/token 文件、内存秘密、页面/full-duration/DRM/multi-P/live/600 秒 preview/partial 门禁、冻结 backhalf 和实现审核前 offline-only 边界未回退。
+
+### 实现授权与验收边界
+
+- 允许新增既定 `dev/project-dev/bili_authenticated_extension/`、对应 test 子目录与一页用法,按 V001+V002 实现最小离线 extension/native host、synthetic AV 和真实 sleeping-child 测试。
+- 实现必须固定 yt-dlp 版本及相关模块哈希、pinned FFmpeg/host/bridge executable、worker-spawned job inheritance、秘密 sentinel 全树扫描和冻结 bridge 回归;任何从 broker 直接在 task job 外启动 bridge、二次 metadata 提取、URL 进入 FFmpeg argv或插件 maps 非空均为实现阻断。
+- 本 PASS 不授权读取真实 Cookie、真实 Chrome/会话、安装/修改扩展或 Native Host、写 HKCU、联网下载、读取隔离 preview、写 `F:\video`/正式 `ana-data` 或启动转写;上述真实动作仍须等实现审核 PASS 后由既有权限链另行明确授权。
+
+### 结论
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 设计状态:`V001_PLUS_V002_IMPLEMENTATION_READY_OFFLINE_ONLY`
+- 下一入口:完成最小实现、全部冻结离线/合成/真实 sleeping-child 测试并提交同一 reviewer 的必要实现审核;不创建平行任务或真实下载链。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-REVIEW-20260806-001
+
+- 记录时间:`2026-08-06T03:14:51+08:00`
+- 审核阶段:已通过 V001+V002 组合安全设计后的 offline-only 限定实现审核;真实安装、认证会话和下载继续冻结。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;唯一目标=`青枫浦上Q / BV1HA3o6oEJJ`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 请求交接:`HANDOFF-INFODEV-INFOREV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-REVIEW-20260806-001`;前序审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-DESIGN-REREVIEW-20260805-001 / PASS/0`;来源/回传任务=`019fb7c5-b721-7332-b5c3-7120176fe016`;请求方=`case_analysis.video_downloader / 019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5`。
+- 设计基线:V001=`22418/665DDDFE4247DEFEE69B6DD1E0CBBD128EDD75B8A32BA609C90C718AC7C97C71`;V002=`23356/8526D942228B77C8A13FE8D6AC57B33925C59547FF25273E4A668DF2677564C2`,均与交接一致。
+- 冻结实现清单:`source-artifact-manifest.json=2510/91E5E28CC21F9E214FE3A6D351D883AF3F12DA11C4DD9828D79934108D9367EC`;清单所列 15 个产品文件的 bytes/SHA-256 全部复算匹配,实际产品目录除清单自身外无漏项。测试树聚合=`7 files/612 bytes/1A97EF2CD9A4D7FA64D49A3890F22730622087C09DCFD5990A25B95230C7471B`;用法=`10499/9789F2B7CE0141E726A47CA396D89873BCCFD4260C1AA4210C766A92FB0ABCCA`;worklog=`5269/62C789E3271D821550BBC51A194E75CC9B546E581F6F00380C536BE6B3384AEF`,均与提交一致。
+- 冻结后半程未漂移:bridge=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`。
+- 独立执行:目标环境=`28/28 PASS / Ran 28 in 12.273s`,无 skip;冻结 bridge=`21/21 PASS / Ran 21 in 0.152s`;全项目=`127 tests / OK / 6 skipped / Ran 127 in 65.865s`。Python compile、Node check、PowerShell parser、scoped diff、strict UTF-8/no BOM/no trailing whitespace=`PASS`;治理=`OK/projects=1/warnings=0`。
+- 外部动作:real Cookie/session/Chrome/extension install/native host install/HKCU=`0`;network/download/real media/quarantine=`0`;`F:\video`/formal `ana-data`/transcription=`0`;reviewer 对实现、测试、说明和冻结 bridge 的修改=`0`。
+
+### Findings
+
+#### F1(阻断,实际 worker 无法建立固定 stage):环境清理删除了运行时必需的 `LOCALAPPDATA`
+
+- `worker.py:195-204` 把固定运行根定义为 `%LOCALAPPDATA%\project-info\bili-auth-ingress\BV1HA3o6oEJJ`,缺少 `LOCALAPPDATA` 即抛 `E_STAGE`;但 `worker.py:477-481` 的 sanitized environment allowlist 不含 `LOCALAPPDATA`,而 `_worker_main` 在 `native_host.py:452-455` 清空整个环境后才读取 secret、加载配置并进入 `run_authenticated_task()`。
+- reviewer 在目标环境按实际清空顺序执行,得到 `LOCALAPPDATA_present False` 和 `fixed_stage_root_error E_STAGE`。现有真实 worker 测试因源码目录没有 `config.json`,在更早的 `E_CONFIG` 处结束,没有走到 stage 根,因此 28/28 绿灯未发现此必现故障。当前快照即使未来合法安装并提供完整配置,也不能开始下载。
+
+#### F2(阻断,秘密前预检):第二次点击没有建立新的无秘密 ready/lease,陈旧 stage 清理也发生在 secret 进入之后
+
+- `background.js:233-258` 的第二次 `start` 仅复用第一次校验时的 `nativeReady`,随后立即调用 `cookies.getAll()` 并发送 secret start;它没有在 Cookie 读取前请求一次 fresh preflight。若 config/hash/正式输出碰撞在两次点击之间变化,broker 直到收到含 Cookie 的 start 后才在 `native_host.py:637-644` 复查并拒绝,违反“E_EXISTS/config/hash 在 Cookie 读取前失败”的冻结合同。
+- V002 要求 host crash 后的固定 stage 清理在任何新 secret 前完成;实际 `cleanup_stale_runs()` 位于 `worker.py:757-760`,只在 broker 已收到 start、worker 已报告 READY、broker 已把 secret frame 写入 pipe 后执行。stage 重解析点、未知残留或清理错误因此都只能在 secret 已进入 worker 后发现。
+
+#### F3(阻断,取消与正式发布竞态):本地验证后到 frozen bridge 提交前没有取消检查,coordinator 又过早把任务标为 CANCELED
+
+- `worker.py` 只在 766、797、800 行检查 cancel;进入 802—825 行的本地 remux/FFprobe/bridge 链后没有设计要求的 FFmpeg 前后、bridge 前后检查。`WorkerTask.cancel()` 又在 `native_host.py:312-319` 立即把任务设为 terminal CANCELED,然后才给予 worker 3 秒 grace;bridge 可在这段窗口内完成 CreateNew 正式媒体和 mapping。
+- reviewer 纯临时桩反例在 `probe_mkv()` 返回时把 cancel 置 true;当前 `run_authenticated_task()` 仍调用 `run_frozen_bridge()` 一次,并返回 `('BV1HA3o6oEJJ.mkv','BV1HA3o6oEJJ.download.json',True)`。这证明“UI 已收到 CANCELED”与“正式输出已提交”可同时发生;现有测试只证明 Job 能杀真实 sleeping descendants,没有覆盖产品入口的 cancel/commit 仲裁。
+
+#### F4(阻断,构建/安装产物不完整):构建为 one-directory,安装却只复制一个 EXE
+
+- `build_host.ps1:28-37` 明确使用 PyInstaller `--onedir`;这种输出由 EXE 与同目录依赖树共同组成。当前 build manifest 在 46—55 行只记录主 EXE,未绑定其余运行文件;`install_native_host.ps1:174-177` 也只复制该 EXE 和 config,不复制或复验 one-directory 依赖树。
+- 因此即使后续授权操作者完成真实构建,安装目录也不是可运行的已审核 host;同时未绑定依赖树使构建输出无法按 source artifact 证据做完整性验收。现有测试只验证“缺少固定 PyInstaller 时输出根不创建”,没有验证可运行的完整安装树。
+
+#### F5(阻断,安装 fail-closed/供应链):安装器接受任意自洽清单并可覆盖既有 HKCU 注册,失败时不回滚注册表
+
+- V002 要求安装器在 HKCU 前核对实现审核冻结的 artifact manifest。实际 `install_native_host.ps1:57-95` 只检查调用方提供清单的通用 schema/identity 与相互引用,没有固定本次 15 文件集合或审核冻结清单。现有 `test_installer_validation_only_has_no_registry_or_install_output` 正例反而构造仅含 `source.txt` 的一文件 source manifest、任意 `sys.executable` host 及自签 build manifest并成功得到 `VALIDATION_PASS_ONLY`,直接证明冻结实现身份未被安装门禁绑定;`build_host.ps1` 在 PyInstaller 前也未逐项复验 source manifest。
+- 安装路径 `install_native_host.ps1:188-190` 使用 `New-Item -Force` 和 `Set-Item`,会覆盖同名既有 Native Messaging 注册;catch 只删除 `$root`,不恢复或删除本次 HKCU 变更。重复安装或注册表中途失败不能满足 no-overwrite/原状态恢复。测试从未执行 `-Install` 的临时 registry provider 正反例,故没有覆盖设计冻结的 CreateNew、第二 origin/wildcard、首/中途失败和重复运行矩阵。
+
+### 已通过且冻结的实现部分
+
+1. extension public key/SPKI hash/expected ID/exact origin、manifest 权限、protocol v2 exact request schema、duplicate key/bool-as-int/frame 上限、page/cookie store/partition/domain/path/session/expiry 门禁均与设计一致。
+2. broker 在 origin 校验后才读 stdin;worker suspended→AssignProcessToJobObject→resume,broker Job handle non-inheritable且 kill-on-close。真实 worker/child/grandchild 七类终态与 abrupt owner death 均证明 PID 全灭,未发现 breakaway 或 broker 在 job 外启动 bridge。
+3. yt-dlp `2026.07.04` 模块哈希、import 前 `YTDLP_NO_PLUGINS`、plugin dirs/maps 全空、NUL 输出、内存 CookieJar、单次 `extract_info`、同一 `download_info`、HTTPS `HttpFD/DashSegmentsFD`、二次解析 guard和本地 MKV路径均通过现有实现与非真空测试。
+4. 冻结 `accept-browser-file` 后半程 21/21 通过;600 秒 preview、multi-P/auth、AV/时长/SHA-256/CreateNew/BaseException 合同未漂移。上述通过项不因 F1—F5 重开。
+
+### 最小 required fixes
+
+1. `F1`:保留并严格验证一个非秘密、绝对、本地且无重解析链的 `LOCALAPPDATA`/固定 stage 根,或在环境清理前用 Win32 known-folder 得到同一固定根;加入真实 worker 入口正例,必须越过 config 和 stage 创建而不是继续以 `E_CONFIG` 提前结束。
+2. `F2`:在第二次点击读取 Cookie 前增加一次无秘密 prepare/lease 状态机,原子完成 fresh config/hash/正式碰撞、固定 stage 陈旧运行清理、Job 分配和 plugin READY;任何失败保持 Cookie read/start-secret/network/stage-new=`0`。增加“第一次 READY 后 config/输出/stage 被改变”的入口反例。
+3. `F3`:按 V002 补齐每个本地 blocking stage 与 bridge 前后的 cancel 检查,并让 coordinator 只在 worker 确认 pre-commit cancellation 后宣告 CANCELED;定义 bridge 已进入/已提交时 cancel 与 COMPLETE 的唯一仲裁,保证任何 CANCELED 终态正式输出恒为 0。增加 probe 后 cancel、bridge 前 cancel、bridge 中 cancel、EOF/disconnect/timeout 的非真空入口测试与正式目录断言。
+4. `F4`:二选一闭环:改成经过冻结验证的单文件 host;或保留 `--onedir` 并对完整依赖树生成 name/bytes/SHA-256 清单,安装时 CreateNew 复制整树、复读复验后才生成 host manifest。增加合成 one-directory 多文件树的完整/缺件/篡改/中途失败测试;本修复复审不要求真实构建或安装。
+5. `F5`:让安装入口绑定本轮最终冻结 source/build receipt 和 exact 文件集合,禁止任意自签/缺件清单;构建前逐项复验 source 清单。HKCU 路径必须预检不存在、禁止 `-Force` 覆盖,并把 root 与 registry 首/中途 `OSError`/`BaseException` 纳入同一回滚状态机;用临时 registry provider 覆盖成功、重复、错误 origin/ID/hash、首/中途失败和原状态恢复,不写真实 HKCU。
+6. 修复仅限当前 extension 源、对应离线测试、用法/证据和账本;冻结 bridge 三文件不得改。冻结新 hashes 后只提交 F1—F5 限定复审;不得把修复过程扩展为真实 Chrome/Cookie、构建安装、HKCU、network/download、real media、`F:\video`、formal `ana-data` 或转写。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`5`
+- 当前安全边界:当前实现只可用于 F1—F5 的离线修复与临时测试;不得构建/安装扩展或 Native Host,不得读取真实 Cookie/会话、联网下载、读取隔离 preview、写正式媒体/数据或启动转写。冻结 bridge 与既有已验收第一条视频继续保持原状。
+- 复审边界:仅复审 F1—F5、补充的非真空离线证据及上述 passed contracts 是否回退;无需也不得进行任何真实认证、安装或下载动作。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-F1-F5-REREVIEW-20260806-001
+
+- 记录时间:`2026-08-06T04:27:47+08:00`
+- 审核阶段:前序实现审核 `HOLD/5` 的 F1—F5 限定独立复审;V001+V002 已通过设计合同、前序 passed-and-frozen 结论及冻结 `accept-browser-file` 后半程不重开。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;唯一目标=`青枫浦上Q / BV1HA3o6oEJJ`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`。
+- 请求交接:`HANDOFF-INFODEV-INFOREV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-F1-F5-REREVIEW-20260806-001`;前序审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-REVIEW-20260806-001 / HOLD/5`;来源/回传任务=`019fb7c5-b721-7332-b5c3-7120176fe016`。
+- 修复快照:source manifest=`2511/CE43F1BD7AB87EFAA6B98EB456EC3938E4DDFEBBAB36268E774746F307EF7C0A`,所列 15 个产品文件 bytes/SHA-256 独立复算全部匹配、无缺件;build script=`6322/7A6528C4F5019F59D59D7FD92E753CE8A9B62E8FDA1ABB6CD98004B032D56129`;installer=`15542/783A3E0227B11C1023684522A9BA54222781B926668361939002A6889A269EAB`;测试树聚合=`7 files/612 bytes/8BDA96E3CFE867E0E2906BE9CEF13B1CD39AAF1D5969A7B0A63EC38630D4A0E1`;用法=`11513/C4B6B0F2E4EE32D4FFA3E4AA633C56E701777B9ABD6EA3DC9012D80BC8915D69`;worklog=`6736/FA5169F52D841D37776CEA1A593F2E71457F009BD45E39875973372E3DAC7E35`。
+- 冻结后半程未漂移:code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`。
+- 独立执行:目标环境=`30/30 PASS / Ran 30 in 27.473s`;冻结 bridge=`21/21 PASS / Ran 21 in 0.182s`;全项目=`129 tests / OK / 6 skipped / Ran 129 in 78.568s`;Python compile、Node check、JSON parse、PowerShell parser、strict UTF-8/no BOM/no trailing whitespace、scoped diff=`PASS`;治理=`OK/projects=1/warnings=0`。
+- 外部动作:真实 Cookie/session/Chrome、正式 extension/native host build/install、HKCU、network/download、quarantine/real media、`F:\video`、formal `ana-data`、transcription=`0`;reviewer 对实现/测试/说明/冻结 bridge 修改=`0`。仅使用 `TemporaryDirectory`、fake PyInstaller、mock Chrome 和临时文件 registry provider 做离线反例。
+
+### Findings
+
+#### F2-R1(阻断,prepare/secret 并发仲裁):同一可见第二次点击可并发进入两条 start,导致 Cookie 读取和 secret frame 各发生两次
+
+- `sidepanel.js:45` 在点击处理开始时不立即禁用按钮或取得本地 in-flight lease;`background.js:202-220` 的 `freshNativePrepare()` 使用全局 `nativeRevision/lastNativeType/preparedTaskNonce`,没有 request/lease correlation;`background.js:260-305` 也没有在第一个 `await` 前原子设置 start-in-progress。两个并发 `start` 因而可以共享/交错 prepare 状态。
+- reviewer 将 mock `getAllCookieStores()` 延迟 20 ms 后并发执行 `Promise.all([dispatch('start'), dispatch('start')])`,当前实现实际得到 `cookieReads=2`、`prepareCount=3`(含前序失败 prepare)、`startCount=2`;两个 start 均已携带 Cookie secret,其中一个最终 `E_EXTENSION`。在真实 broker 中第二个 `start` 还会使 `WorkerTask.start()` 抛 `E_PREPARE`,`native_host.py:722-727` 随即 `task.terminate()`,可中止第一个合法任务。
+- 现有 `background_contract.mjs` 只覆盖顺序单击,故 30/30 绿灯未验证“一个 prepare lease 只能消费一次、一个用户动作最多一次 Cookie read/start secret”。这仍违反 F2 的 secret-free prepare/lease 和单任务合同。
+
+#### F4-R1(阻断,正式用法未同步):构建合同已改为 onefile,但交付说明仍两处声明 one-directory
+
+- `B站已登录会话完整视频扩展入口.md:31` 仍写 `PyInstaller 6.15.0,one-directory`,第 41 行仍称 `build_host.ps1` 生成 one-directory host;同一文件第 74、80、92 行和实际 `build_host.ps1:78-103` 则冻结为 `--onefile`、唯一 EXE。
+- 这是未来获授权操作者的正式构建/安装入口,互相矛盾的包装合同会使验收对象和文件完整性口径不唯一。F4 的代码路径与 fake builder 状态机已通过,但交付文档尚未闭环原阻断。
+
+#### F5-R1(阻断,审核快照没有外部信任锚):bundled source manifest 仍可与被改产品一起重签并被 build/installer 接受
+
+- `build_host.ps1:11-55` 与 `install_native_host.ps1:96-173` 只要求 source manifest 与脚本同目录、文件集/字段固定且内部 bytes/hash 自洽;两者均未比较本次审核冻结的 source manifest SHA-256 `CE43F1BD...7C0A` 或其他 reviewer/admin 发出的外部 receipt。build receipt 又由同一未锚定 build script 根据当前 manifest 自行生成,因此不能把“当前自洽”提升为“本轮已审核”。
+- reviewer 在纯临时复制中给 `worker.py` 追加 synthetic 字节、把 15 项 source manifest 重新计算为新哈希 `C8887646...6F14`,再用测试同类 fake PyInstaller 6.15.0 运行复制的 build script;结果 `returncode=0`,生成唯一 EXE 与 schema 2 receipt。随后复制的 installer 对该未审核 manifest/receipt 返回 `VALIDATION_PASS_ONLY`,而正式安装目录和临时 registry 均未创建。被改 worker=`877A79D8...913CE`,与本审计冻结物不一致但门禁仍通过。
+- 现有测试只覆盖“修改产品但不更新 manifest”和“把相同 manifest 复制到外部路径”;没有覆盖攻击者/误操作同时刷新同目录 manifest 与 build receipt 的反例。V002 第 2.1 节要求 HKCU 前核对实现审核冻结 artifact manifest hashes,当前仍未实现该信任来源。
+
+### 已通过并冻结的闭环
+
+1. `F1 PASS`:`LOCALAPPDATA` 在环境清理前按绝对本地、既有链无 reparse 校验并唯一保留;真实 job-owned worker 使用有效 config 越过旧 `E_CONFIG`,secret=0 时完成 stale cleanup/lease,EOF 后 stage/正式输出均为 0。
+2. `F3 PASS`:下载后、本地 remux/rename/候选/FFprobe 与 bridge 前均有 checkpoint;commit event 使 pre-commit cancel 终态正式输出为 0、bridge 已进入时 cancel 被拒绝并由 bridge 终态决定。现有 probe/commit/bridge 桩、coordinator timeout/cancel 及真实 Job 后代测试通过,未复现前序 CANCELED+正式输出并存。
+3. `F4 code PASS`:实际构建脚本固定 PyInstaller `6.15.0 --onefile`,输出根只允许唯一 EXE+schema 2 receipt;fake builder 成功/失败和 work/spec 清理通过。仅正式用法的两处 one-directory 陈述未同步。
+4. `F5 transaction PASS`:installer 已禁止 `-Force` 覆盖,root/registry 预检不存在,host/config/manifest 使用 CreateNew+复读,临时文件 registry provider 覆盖成功、重复及六个失败点并恢复空状态;当前剩余阻断仅是未绑定外部审核哈希的供应链身份。
+5. 前序 public key/SPKI/ID/origin/wire schema、插件禁用/NUL/内存 Cookie、单次解析/同一对象、downloader allowlist、本地 MKV、Job Object 后代所有权以及冻结 bridge 21/21 均未发现回退。
+
+### 最小 required fixes
+
+1. `F2-R1`:在 side panel/background 的第一个异步边界前取得不可重入的 start lease并立即禁用 start/retry;给 prepare 响应绑定一次性 request/lease ID,broker 原子消费一次且 duplicate prepare/start 只返回 `E_BUSY/E_PREPARE`、不得终止已启动的合法任务。新增延迟 Cookie-store 的并发双击入口反例,必须证明第二条在 Cookie 读取前失败、总 `cookieReads=1`、start secret frame=`1`、首任务不被终止。
+2. `F4-R1`:把正式用法第 31、41 行统一为 `PyInstaller 6.15.0 --onefile / no UPX / 唯一 EXE+schema 2 receipt`;全文件不得再出现 one-directory/onedir,且不改变真实动作冻结边界。
+3. `F5-R1`:为 build 和 install 增加来自本次正式审计/管理员确认、位于可变 source/build 树之外的 reviewer-approved source-manifest hash 信任锚,并在解析 manifest、调用 builder或复制产物前比较;真实 build 完成后、HKCU 前还须对 exact build receipt/EXE 做一次 hash-only 限定确认或使用等价的外部批准 receipt。新增“修改产品并同步重算同目录 source manifest/build receipt”的反例,给定原审核哈希时必须在 build/install 输出前失败。文档明确实现 PASS 最多授权 build,install/HKCU 仍须等 exact build receipt 确认。
+4. 仅修复 F2/F4/F5 对应 extension、离线测试、用法/证据/账本;F1/F3 和冻结 bridge 不重开。不得执行真实 PyInstaller 构建、安装、HKCU、Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或转写。
+
+### 结论
+
+- 审计状态:`HOLD`
+- 阻断问题数:`3`
+- 当前安全边界:当前快照仅可用于 F2-R1/F4-R1/F5-R1 的离线修复与临时测试;不得据此构建或安装正式 Extension/Native Host,不得读取真实 Cookie/session、操作 Chrome/HKCU、联网下载或触碰真实/正式媒体数据。冻结 bridge 与既有已验收视频保持原状。
+- 下一复审:只复审上述三项及本轮已通过合同是否回退;一次提交完整冻结哈希和纯离线证据,不新增任务或审核链。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-HOLD3-MINIMAL-OFFLINE-REPAIR-REREVIEW-20260806-001
+
+- 记录时间:`2026-08-06T12:18:05+08:00`
+- 审核阶段:同一事项、同一审核链内,前序 `HOLD/3` 的 F2-R1、F4-R1、F5-R1 限定实现复审;F1/F3、V001+V002 已通过合同及冻结 `accept-browser-file` 后半程不重开。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;唯一目标=`青枫浦上Q / BV1HA3o6oEJJ`。
+- 请求交接:`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-HOLD3-MINIMAL-OFFLINE-REPAIR-REREVIEW-20260806-001`;管理员 owner 续派=`HANDOFF-INFOADMIN-INFOREV-BILI-AUTHENTICATED-SESSION-HOLD3-OWNER-CONTINUATION-20260806-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 前序审计:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-F1-F5-REREVIEW-20260806-001 / HOLD/3`。
+- 修复快照:source manifest=`2511/039BE7791B517290F13458151A19B400C8DE516963FADB7354E22C680BE89709`,所列 15 个产品文件 bytes/SHA-256 独立复算全部匹配、文件集无差异;background=`13201/27C0598DD8D70659B1E518C841F2CC5908165458F99E8531D58A6B15F767BDE8`;sidepanel=`2364/88794C840C0491E0017E7E8D15A956E4AA28DCEB71950A991F4A40399C2449B4`;protocol=`11393/8E8A1BBB742B37358A2848B28D2A7B10ACA9B20167844388455CAAB59DBE422D`;native host=`29843/41CA31D688F39E53B5C7A11ACE1390C3BEA1D8E08D0FFFF49C4C62D320F42937`;build=`8303/78EFFE9D2704F8FB5680C191BD9F014CDAF7F60A3F20F000569B88453527F5B1`;installer=`19477/3016CE524AFDC3FD9A9F4DD4810F39BFAEE0A211A79DEA306FA54069C6C1490C`。
+- 测试/说明证据:测试树=`7 files/612 aggregate-manifest bytes/DA738E0155939CD668FED75E4B15C47D47D7CC4D1B00EA21EE4A7BACF45F1476`;用法=`15739/3B7EE09DA09DFE6ECCF313B976A7FE9A827E5A3DDD9A04C22E11F3E29BF99FB8`;secondary worklog=`1636/5A08AA001FC6BADBE0726A8D84AED1916786DE94D5A9C627AF905A0C341E22F6`。
+- reviewer-owned 树外批准回执:`ai-inforev/worklog/2026-08-06-bili-authenticated-session-source-approval.json=373/F6A56C14ED8DB1DFF7918F72CC5316D65866CED9B2D98368A88DF47D338FCBAA`;其 exact schema 绑定 source manifest=`2511/039BE7791B517290F13458151A19B400C8DE516963FADB7354E22C680BE89709`,位于 source/build 树外,`approved_by_role=dev.reviewer.project`,批准范围仅为 `controlled-build-source-manifest`。
+- 冻结后半程未漂移:bridge code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`。
+
+### Findings
+
+- 阻断 findings:`0`。
+
+### 原 HOLD/3 闭环
+
+1. `F2-R1 PASS`:side panel 在第一次异步边界前同步取得不可重入 UI lease并立即禁用 start/retry/validate;background 在任何 await/Cookie store 读取前同步取得唯一 start lease,prepare/start 以唯一 32 位小写十六进制 `prepare_id` 定向关联。延迟 Cookie-store 并发双击反例实际证明一成功一 `E_BUSY`、`cookieStoreReads=1`、`cookieReads=1`、携密 start=`1`;broker 的 duplicate/mismatch start 只返回 `E_PREPARE`,不再 terminate 已启动任务。
+2. `F4-R1 PASS`:正式操作口径已统一为固定 PyInstaller `6.15.0 --onefile --noupx`、唯一 EXE+schema 2 build receipt;原第 31/41 行矛盾已消失。全文仅剩一处明确声明“不再提供或暗示 onedir/one-directory”的否定性闭环说明,不构成可执行构建口径或验收歧义;真实 build=`0`。
+3. `F5-R1 PASS`:build 在解析 source manifest、调用 builder或创建 output 前,install 在解析 manifest、复制或 HKCU 前,均要求 source/build 树外批准回执;installer 还要求 exact EXE/build receipt 的独立 build approval。篡改产品并同步重算同目录 source manifest/build receipt 的离线反例,在旧外部 source approval 下于 output/install/registry 前 fail closed。开发员没有创建、自签或自批正式 trust receipt;本审计由 reviewer 对 exact source manifest 独立复算后生成上述 source approval。
+4. `passed/frozen 未回退`:F1 stage 环境、F3 cancel/commit 仲裁、public key/SPKI/ID/origin/wire schema、插件禁用/秘密内存边界、单次解析/下载器 allowlist、本地 MKV、Job Object 后代所有权及冻结 bridge 均未发现回退。
+
+### 独立验证
+
+- 目标环境:`31/31 PASS / Ran 31 in 32.841s`,无 skip;冻结 bridge:`21/21 PASS / Ran 21 in 0.231s`。
+- 全项目:`130 tests / OK / 6 skipped / Ran 130 in 83.965s`;skip 为非目标环境集成条件,不影响本轮目标测试的无 skip 结论。
+- Python compile、Node syntax、PowerShell parser、JSON、strict UTF-8/no BOM/no trailing whitespace、U+FFFD、scoped diff=`PASS`;治理=`OK/projects=1/warnings=0`。
+- 外部动作:正式/真实 build、install、HKCU、Chrome、Cookie/session、network/download、real media、`F:\video`、formal `ana-data`、transcription、secret persistence=`0`;reviewer 未修改产品、测试、用法或冻结 bridge。
+
+### 结论与后续门禁
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 实现结论:`HOLD3_MINIMAL_OFFLINE_REPAIR_ACCEPTED`。
+- 授权边界:允许获授权操作者仅使用本审计冻结 source manifest 与 reviewer-owned source approval 执行一次受控 build;本 PASS 不授权安装、HKCU、真实 Chrome/Cookie/session、网络下载、正式媒体/数据或转写。
+- 安装门禁:受控 build 完成后必须提交唯一 EXE 与 schema 2 build receipt 的 exact bytes/SHA-256,先由 `dev.reviewer.project` 或 `project.admin` 完成 hash-only 限定确认并形成树外 `install-exact-build` approval;在该后续确认 PASS 前,install/HKCU 继续冻结。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXACT-BUILD-HASH-ONLY-CONFIRMATION-20260806-001
+
+- 记录时间:`2026-08-06T14:53:29+08:00`
+- 审核阶段:同一事项、同一审核链内,已通过实现的唯一一次 exact-source 受控 build 的 hash-only 限定确认;不重开 HOLD/3 实现、F1/F3、冻结 bridge 或产品合同。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-EXACT-BUILD-HASH-ONLY-CONFIRMATION-20260806-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 前序实现审计:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-HOLD3-MINIMAL-OFFLINE-REPAIR-REREVIEW-20260806-001=334024/C063BD046AE56B1D24A6167623DFA696206E4EA17DE645253F89398ADECC3FDD / PASS/0`。
+- reviewer source approval:`ai-inforev/worklog/2026-08-06-bili-authenticated-session-source-approval.json=373/F6A56C14ED8DB1DFF7918F72CC5316D65866CED9B2D98368A88DF47D338FCBAA`;绑定 source manifest=`2511/039BE7791B517290F13458151A19B400C8DE516963FADB7354E22C680BE89709`。
+- 管理预置链:builder Python=`262144/5912D0884B23C0343983A864C6064242391E2265536F50B88624857E353882C9`;PyInstaller EXE=`108469/D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39`;wheelhouse manifest=`792/7A05B7637F8254ECE6C5B38D9C54D870FC3F62877C66C1FD52BC8766C9D64529`;provision receipt=`2027/B65F4184E8782394F2CC27C47CB8656C942E366FD80E8FA7A548CE5A3367BACF`,均与提交一致。
+- build script=`8303/78EFFE9D2704F8FB5680C191BD9F014CDAF7F60A3F20F000569B88453527F5B1`;开发执行证据记录 build count=`1`、exit=`0`、wall=`20.839s`、PyInstaller `6.15.0 --clean --noupx --onefile`、network/dependency install=`0`。
+
+### Findings
+
+- 阻断 findings:`0`。
+
+### Hash-only 独立确认
+
+1. 输出根 `dev/tmp/bili-auth-controlled-build-20260806-001` 恰好包含两个普通、非 reparse 文件,目录=`0`、`.build/.spec=0`:
+ - `project-info-bili-auth-native-host.exe=8608281/F0D3CE2EF12D7D305B8F765169CBF5430DF9F0D36D03FF4447A85378AFF8A26F`
+ - `build-artifact-manifest.json=995/9B989A32C92A4EB89911C9D5151F31B416BA8F265785CE575FDD1A23FBEDF63D`
+2. receipt strict UTF-8/no BOM/no trailing whitespace、无 duplicate key,exact schema 2;`target/extension_id/build identities/packaging=pyinstaller-onefile/pyinstaller_version=6.15.0` 均匹配。builder Python、build script、source manifest 的 bytes/SHA-256 全部复算一致。
+3. receipt 的唯一 `files[0]` 精确绑定上述 EXE 的 name/bytes/SHA-256;reviewer 仅静态读取首两字节确认 `MZ`,未加载或执行 EXE。
+4. source manifest 所列 15 个产品文件 bytes/SHA-256 复算 `15/15 PASS`,实际文件集无差异;source approval→source manifest→build receipt→EXE 链完整。
+5. 开发执行日志、工具说明与 secondary worklog 分别为 `191404/2BE532490D536BD54521B560F1DE7EB5CDFDD7DA5794F3483A124A673135B9F5`、`18047/A338B2D75296181162DD7498879067C39D7A33B271A8D200044156D2E9392F95`、`4048/3FD25884CD4AAA9D6041F435ED9DC535D1915803063D9B4AC0FB406AEBF29397`;均声明 EXE execution/install/HKCU/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription=`0`。输出根无日志、秘密或额外 sidecar;治理=`OK/projects=1/warnings=0`。
+
+### Reviewer-owned exact-build approval
+
+- 已生成 source/build 树外回执:`ai-inforev/worklog/2026-08-06-bili-authenticated-session-build-approval.json=594/244097BC466D969E046F0E64E9D3A1213F5AB64E12C9358DF0807B4BA0F8F816`。
+- exact schema 1:`approval_scope=install-exact-build`、`approved_by_role=dev.reviewer.project`、`status=APPROVED`;精确绑定 source manifest SHA-256、build receipt bytes/SHA-256 与 EXE bytes/SHA-256,位置不在 source/build 树内。
+
+### 结论与边界
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 结论:`EXACT_BUILD_HASH_ONLY_CONFIRMED`。
+- 上述 exact build 已满足 installer 的独立 build-identity 前置门禁;只允许未来获明确授权的操作者将同一 source approval、同一 build approval、同一 receipt 和同一 EXE 作为不可替换输入。任何 bytes/hash/path/output-set 漂移均须失败关闭并重新审核。
+- 本 hash-only PASS 未执行或自动授权当前回合的 install/HKCU,也不授权 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription;是否启动实际安装仍由项目管理员/请求方按原任务链另行明确调度。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD-CONTRACT-REPAIR-SOURCE-REVIEW-20260806-001
+
+- 记录时间:`2026-08-06T23:04:24+08:00`
+- 审核阶段:同一事项、同一审核链内,旧 exact build 被静态证明不可安装后的 yt-dlp 依赖/source/build-contract 限定复审;不重开 HOLD/3 实现 PASS、F1/F3、冻结 bridge 或产品合同。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD-CONTRACT-REPAIR-SOURCE-REVIEW-20260806-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 管理授权:`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD-CONTRACT-REPAIR-ASSIGN-20260806-001`;旧 EXE/receipt/approval 保持原 bytes/SHA-256,但状态仅为历史证据、`NOT_INSTALLABLE/SUPERSEDED_FOR_INSTALL`。
+- 提交快照:source manifest=`3029/D1CE8C00A50CF17E45FB915577369BADA4AD9937335290852F393DC6BD053FCF`;dependency manifest=`723/2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`;yt-dlp wheel=`3184705/F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266`;build script=`16099/8AC31F601FA71712154FCEC5B72E40BECAC32D8943F1B24E5D0B2D7E71E8666B`;installer=`20876/ECEC7AB1C8B6AB5D36CC686251C67990B2BCFEEA03165C6827B128C4664DD3FA`。
+- 证据:测试树=`7 files/612 aggregate-manifest bytes/BAF8F96215FFCF83A8B40A42B0B53ACC1766C5392EE768EEE19B8460CD107278`;工具说明=`22353/C730ADDEAA268AB31103A73E5D7AC5E43A93E8EEEE780DD35F018EC4033E33C6`;machine evidence=`4339/7E3E9E61D8CB7B1E91F5701365FD038068F218594197579AAD4BB552FD0072F5`。
+
+### Findings
+
+#### F1(阻断,exact-source 门禁只核对 manifest 自述集合,未核对实际源树集合)
+
+- `build_host.ps1:59-97` 只把硬编码的 17 个路径与 `sourceManifest.files` 比较并逐项复算;没有递归枚举 `sourceRoot` 的实际普通文件、目录/reparse 状态。因此,批准后新增但未列入 manifest 的文件不会被拒绝。`test_protocol.py:101-115` 同样只枚举根目录直接文件与 `dependencies/` 直接文件,忽略额外子目录和递归文件。
+- reviewer 在系统临时目录复制当前 source,新增未列入 manifest 的根级 `yt_dlp.py`,保持 source manifest 原字节,并生成仅供反例使用的临时 reviewer-style receipt;使用管理员预置 Python 调用 `build_host.ps1 -ValidateOnly`,实际 `exit=0`、返回 `VALIDATION_PASS_ONLY`、output absent。该未批准文件可在构建脚本目录进入 Python/PyInstaller 搜索面,违反“exact source manifest/17 项唯一集合”合同。
+- 当前真实源树在 reviewer 独立枚举下为 `17/17 PASS`,但批准回执只绑定 manifest bytes/SHA;若 build 门禁不能在实际执行前重新核对真实树,批准后的额外文件漂移无法 fail closed。
+
+#### F2(阻断,PyInstaller 版本门禁可由外部 `PYTHONPATH` 伪造,pinned builder 身份不成立)
+
+- `build_host.ps1:148-150` 通过继承环境的 `python -c importlib.metadata` 判断 PyInstaller 版本;`216-220` 明确保留并拼接调用方原 `PYTHONPATH`;`225` 再以同一受污染解释器执行 `python -m PyInstaller`。脚本没有使用 isolated mode,也未绑定管理端预置的 PyInstaller executable/provision receipt exact bytes/SHA。
+- reviewer 在系统临时目录放置伪造 `PyInstaller` package 与 `PyInstaller-6.15.0.dist-info`,只设置 `PYTHONPATH`,再对未改当前 source 运行 `-ValidateOnly`;实际 `exit=0` 并返回 `pyinstaller_version=6.15.0`。因此任意同名模块/metadata 可冒充获准 builder。
+- 现有 `test_native_host.py:658-718` 正是通过 `PYTHONPATH=fake-site` 注入伪 PyInstaller,并把脚本接受该 builder、生成 receipt 断言为成功;测试虽通过,却非真空地证明生产门禁可被绕过。相同入口还可能替换 `python -m pip`,破坏“仅离线固定 wheel / 固定 builder”供应链边界。
+
+### 已通过并冻结的检查
+
+1. 当前 source manifest 严格 JSON、无 duplicate key;所列 17 项与当前实际普通文件集合一致,bytes/SHA-256=`17/17 PASS`;dependency manifest 的 bytes/SHA 与 source manifest 绑定一致。
+2. wheel strict ZIP 检查通过:CRC、1059 项唯一名称、无路径逃逸/符号链接、RECORD 对全部非 RECORD 文件的 size/SHA-256 复算均 PASS;`Name=yt-dlp`、`Version=2026.7.4`、`Tag=py3-none-any`。38 条 `Requires-Dist` 全部带 extra 条件,非条件运行依赖=`0`。
+3. build/installer 的新 receipt 字段已绑定 dependency manifest、yt-dlp wheel、archive anchors 和 metadata;旧 source/receipt/approval 因 source hash/新字段不匹配不能满足新安装合同。该子结论不替代 F1/F2 的构建前门禁。
+4. reviewer 聚焦运行 source-manifest、build-contract、installer transaction 三项测试=`3/3 PASS / Ran 3 in 103.170s`;PowerShell/Python 静态检查与提交证据一致。上述绿灯不消除两项已实际复现的反例。
+5. replacement build、install、HKCU、EXE execution、Chrome/Cookie/session、media network/download、`F:\video`、formal `ana-data`、transcription=`0`;reviewer 未修改业务代码、测试、wheel、manifest、说明或冻结 bridge。
+
+### 最小 required fixes
+
+1. `F1`:在读取外部 source approval 后、任何 Python/pip/PyInstaller/output 动作前,递归枚举真实 source tree;除 source manifest 自身外,普通文件路径集合必须与 manifest 的 17 项逐项、大小写敏感完全一致,并拒绝额外根文件、额外/嵌套目录文件以及 source/dependencies 路径链上的 reparse。build 与 installer 共用或等价执行该 exact-tree gate。新增根级额外 `yt_dlp.py`、嵌套额外文件、reparse(本机支持时)反例,均须在 output/install/registry 前失败。
+2. `F2`:生产构建路径不得从调用方 `PYTHONPATH/PYTHONHOME/user-site` 解析 Python 模块,也不得把旧 `PYTHONPATH` 拼回构建环境。所有版本探针、pip、PyInstaller、archive viewer 使用隔离解释器/清洁环境;同时绑定并复算管理员批准的 exact Python、PyInstaller executable 或等价 tree-external provision receipt。新增伪 `PyInstaller`/伪 `pip` 的 `PYTHONPATH` 注入反例,必须证明伪模块未执行且不能形成 accepted receipt;当前 fake-builder 成功测试须改用不会被生产参数选择的明确测试 seam。
+3. 只修 F1/F2 及对应离线测试、说明/证据/账本;已通过 wheel 内容、receipt 新字段、installer transaction、HOLD/3 实现与冻结 bridge 不重开。修复后 replacement build 仍保持 0,先提交本 reviewer 做 F1/F2-only source 复审。
+
+### 结论与边界
+
+- 审计状态:`HOLD`
+- 阻断问题数:`2`
+- 结论:`SOURCE_APPROVAL_NOT_CREATED`;当前 source/dependency/build-contract 快照不得用于 replacement build。
+- 当前安全边界:旧与新 build 均不得安装;replacement build/install/HKCU/EXE execution 继续冻结。不得接触真实 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription。
+- 下一复审:只复审 F1/F2、相应非真空离线反例及已通过合同是否回退;不得新增任务或审核链。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD-CONTRACT-HOLD2-SOURCE-REREVIEW-20260807-001
+
+- 记录时间:`2026-08-07T01:07:09+08:00`
+- 审核阶段:同一事项、同一审核链内,仅复审前序 HOLD/2 的 F1 actual source-tree exact set/reparse 与 F2 clean exact-builder/no inherited `PYTHONPATH`;F1/F3 runtime、HOLD/3 已通过合同、冻结 bridge 与产品范围未重开。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD-CONTRACT-HOLD2-SOURCE-REREVIEW-20260807-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 提交快照:source manifest=`3029/E01C1320F8C7677B7435B4C8B87EA3688BA652585648D7CFD1F88D0DADB26958`;build script=`20430/3B0552ADA09D279D08FF89A84F59847C8F86BBC7606257DE8B26203CCBD42F92`;installer=`22247/F127BAE512BEE03AD78780BB1C98065398EF910EF1A6F9D59EE42463F4E2A62D`;dependency manifest=`723/2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`;yt-dlp wheel=`3184705/F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266`。
+- 构建信任输入:exact Python=`262144/5912D0884B23C0343983A864C6064242391E2265536F50B88624857E353882C9`;exact PyInstaller executable=`108469/D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39`;tree-external provision receipt=`2027/B65F4184E8782394F2CC27C47CB8656C942E366FD80E8FA7A548CE5A3367BACF`。
+
+### Findings
+
+- 阻断问题:无。
+- F1 已闭环:reviewer 独立递归枚举当前实际 source tree,除 manifest 自身外为大小写敏感 `17/17` 精确集合,逐文件 bytes/SHA-256 全匹配,source root、`dependencies/` 与全部子路径 reparse=`0`。`build_host.ps1:36-53,82-121` 与 `install_native_host.ps1:70-87,193-231` 均在 Python/build output 或 install/copy/HKCU 之前执行 actual-tree exact-set 与 reparse fail-closed 门禁。
+- F2 已闭环:`build_host.ps1:166-217` 在首次 Python 调用前复算 exact Python、PyInstaller executable 与 tree-external provision receipt 的固定 bytes/SHA-256及相互绑定,随后清除所有继承 `PYTHON*`;版本探针、pip、PyInstaller 与 archive viewer 均使用同一 exact Python 的 isolated 调用,不再拼回调用方 `PYTHONPATH`。schema 2 receipt 记录 PyInstaller executable/provision receipt,installer 在任何正式副作用前强制核对;旧 receipt/approval 不能满足新 source/hash/字段合同。
+- reviewer 重新执行前序反例与聚焦门禁:额外根级 `yt_dlp.py`、嵌套额外文件与 junction/reparse 均在 output 前失败;伪 `PyInstaller`/伪 `pip` 的 `PYTHONPATH` marker 未执行,`-ValidateOnly` 只由 exact builder 返回且不创建 output;installer transaction/source-manifest 复核通过。测试=`3/3 PASS / Ran 3 in 106.392s`。
+- machine evidence=`5519/EBB72CDFC9A389F8D57AAA2F9A4F5E068D49B9CD0CD337F29C43C0F62ED1C8AC`;提交的说明/worklog 与冻结哈希一致。旧 EXE=`8608281/F0D3CE2EF12D7D305B8F765169CBF5430DF9F0D36D03FF4447A85378AFF8A26F`、旧 receipt=`995/9B989A32C92A4EB89911C9D5151F31B416BA8F265785CE575FDD1A23FBEDF63D` 仅保留历史证据,继续 `NOT_INSTALLABLE/SUPERSEDED_FOR_INSTALL`。
+
+### Reviewer-owned source approval
+
+- 已创建 source/build 树外 approval:`ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-build-source-approval.json`=`373/26AF51848717C47A2915D633B258269EA79213A94BAFEFF9CE7D30CDC2A77F96`。
+- approval 仅绑定 source manifest=`3029/E01C1320F8C7677B7435B4C8B87EA3688BA652585648D7CFD1F88D0DADB26958`,scope=`controlled-build-source-manifest`;未创建、复用或更新 install-exact-build approval。
+
+### 结论与边界
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 结论:`HOLD2_F1_F2_CLOSED_SOURCE_APPROVED`。
+- 仅允许管理员已授权的唯一 owner 使用本次 exact source manifest、上述 reviewer approval、exact Python/PyInstaller/provision receipt,在新的不存在 output 目录执行一次 clean replacement build;任何 bytes/hash/path/tree/output-set 漂移均须失败关闭并重新审核。
+- 本 PASS 不授权 install/HKCU/EXE execution,也不授权 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription。replacement EXE/schema 2 receipt 形成后仍须沿同链做 hash-only 限定确认并取得新的 install-exact-build approval。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-SOURCE-REREVIEW-20260807-001
+
+- 记录时间:`2026-08-07T02:37:42+08:00`
+- 审核阶段:同一事项、同一审核链内,仅复审前次受控 replacement build 因 `copy_metadata('yt-dlp')` 无法发现临时 `.deps` distribution 而失败后的 metadata-discovery source 修复;前序 F1 actual source-tree/reparse、F2 exact Python/PyInstaller/provision receipt、HOLD/3 已通过合同、冻结 bridge 与产品范围不重开。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-SOURCE-REREVIEW-20260807-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 管理授权:`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-REBUILD-AUTH-20260807-001`;前次失败证据=`3253/34EEC108F0F27384F605C67AB4529682463196B8C0A5F03CC96E595DD61D19FA`,失败 output 保持不存在,repair build count=`0`。
+- 提交快照:source manifest=`3029/AC852D40CF2B6FE891BE25D2E6E068A876EAC60E07EE6E9DB6826AC1064B29B8`;build script=`25586/F5E9FC58313B2CB765772BF3A5C8605BCA9DD91A1FF74D0F03DBEB3174949845`;installer=`22247/F127BAE512BEE03AD78780BB1C98065398EF910EF1A6F9D59EE42463F4E2A62D`;dependency manifest=`723/2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`;yt-dlp wheel=`3184705/F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266`。
+- 独立快照复核:上述文件 bytes/SHA-256 全部匹配;实际 source tree 为大小写敏感 exact `17/17`、逐文件 hash 全匹配、reparse=`0`;候选 output `dev/tmp/bili-auth-controlled-build-ytdlp-20260807-002` 不存在;旧 reviewer approval 仍只绑定前一 manifest,未创建绑定本次 manifest 的新 approval。
+
+### Findings
+
+#### F1(阻断,未验证的 `.deps` 代码在完整性门禁前被执行)
+
+- `build_host.ps1:263-275` 将 pinned wheel 安装到 output-owned `.deps` 后,`build_host.ps1:276-279` 立即把该目录插入 `sys.path` 并执行 `import yt_dlp`;实际 `.deps` 的 canonical direct-child/owner、递归 no-reparse、wheel 派生精确文件集与 hash、`RECORD`、确定性 `INSTALLER/REQUESTED` 以及唯一 distribution/version 校验直到 `build_host.ps1:280-397` 才执行。因此,当前顺序违反“只有已验证 `.deps` 才可进入解释器并执行”的修复合同。
+- reviewer 在系统临时目录复制当前 source,仅修改临时脚本,使 pip 安装后、上述 legacy import probe 前向 `.deps/yt_dlp/__init__.py` 追加 marker 写入代码,并同步临时 manifest/临时 reviewer-style receipt;实际执行结果为 `exit=1`、`marker_executed=true`、`output_exists=false`、最终 exact-tree gate=`true`。这证明恶意或损坏的依赖代码已在后续 fail-closed 门禁发现篡改之前执行,output 回滚不能消除该前置代码执行风险。
+- 提交的 missing metadata/tampered package/wrong owner/extra distribution 用例与 reviewer 聚焦运行均能在最终门禁处失败,但没有断言“被篡改的 import-executed module 在门禁前未执行”。独立运行两个相关测试=`2/2 PASS / Ran 2 in 122.812s`,不构成对上述 marker 反例的闭环。
+
+### 已通过并冻结的检查
+
+1. source actual-tree exact set/reparse、exact Python/PyInstaller/provision receipt、继承 `PYTHON*` 清理及旧 F1/F2 修复未发现回退;dependency manifest 与 pinned wheel 的提交 hash 匹配。
+2. 前次 build failure 被如实保留,前次失败 output 与本次候选 output 均不存在;旧 EXE/receipt/approval 继续仅作历史证据且 `NOT_INSTALLABLE/SUPERSEDED_FOR_INSTALL`。
+3. 当前 repair build、install、HKCU、EXE execution、Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data`、transcription=`0`;reviewer 未修改产品、测试、wheel、manifest、说明或冻结 bridge。
+
+### 最小 required fixes
+
+1. 删除 `build_host.ps1:276-279` 的 pre-validation executable import probe,或将所有对 `.deps` 的 `sys.path` 注入/模块导入严格移到 exact-tree、reparse、wheel 派生 bytes/hash、`RECORD`、`INSTALLER/REQUESTED` 与唯一 `yt-dlp==2026.7.4` distribution 全部验证成功之后。现有 post-validation PyInstaller launcher 已可在执行 PyInstaller 前检查 `importlib.metadata.version('yt-dlp')`,不得保留第二条更早的可执行依赖入口。
+2. 增加非真空反例:在 pip install 后篡改 `.deps/yt_dlp/__init__.py`(或其他首次 import 必执行模块)写 marker;断言构建在完整性门禁失败、marker 不存在、output/receipt 不存在。保留既有 missing metadata、wrong owner、extra distribution 与最终清理断言。
+3. 仅修复上述顺序和对应离线测试/说明/证据;replacement build 继续为 `0`,先回到同链做 metadata-repair-only source 复审。不得安装、执行 EXE 或触发任何真实认证、联网、媒体和正式路径动作。
+
+### 结论与边界
+
+- 审计状态:`HOLD`
+- 阻断问题数:`1`
+- 结论:`SOURCE_APPROVAL_NOT_CREATED`;本次 source manifest=`3029/AC852D40CF2B6FE891BE25D2E6E068A876EAC60E07EE6E9DB6826AC1064B29B8` 不得用于 replacement build。
+- 当前安全边界:replacement build/install/HKCU/EXE execution 继续冻结;不得接触真实 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription。
+- 下一复审:仅复审本 F1 顺序闭环、marker 非执行反例及已通过合同是否回退;不新增任务、审核链或产品范围。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-PREVALIDATION-F1-SOURCE-REREVIEW-20260807-001
+
+- 记录时间:`2026-08-07T03:25:02+08:00`
+- 审核阶段:同一事项、同一审核链内,仅复审前序 HOLD/1 的 `F1_PREVALIDATION_DEPS_CODE_EXECUTION`;F1/F2 source/provision gates、HOLD/3 runtime contracts、冻结 bridge 与产品范围不重开。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-YTDLP-PREVALIDATION-F1-SOURCE-REREVIEW-20260807-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 前序审计:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-SOURCE-REREVIEW-20260807-001 / HOLD/1`。
+- 提交快照:build script=`25093/43816E7932A785321B545073FEAD9EB38198E5ED3B07F2469062895ADC41238F`;source manifest=`3029/463182C6980734B72831BDF9B387160F8AD21A03B52CBAB4AC74CA98CEB1B7AE`;test=`59374/55F7F4ECA14E370BAB1D5D1CA154BE8BFD1E602C5460B69670A376751C5E08EA`;dependency manifest=`723/2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`;wheel=`3184705/F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266`。
+- 说明/证据:工具说明=`31945/7A9D76F77A497A02366306659E24EA50EBA3270ACE1876B05DF563133E562F92`;secondary worklog=`12524/0512E88954B1972E26DDBC5C6552FA562A541014EE4CD6F117BB9930C3F1907C`;machine evidence=`2686/A3BA1EDB3C5C236C886B17821095481541DA1CAA7A349C3F4C25D279AF6BADF2`。
+
+### Findings
+
+- 阻断问题:无。
+
+### F1 闭环
+
+1. `build_host.ps1:263-275` 仅执行 isolated/no-index/no-deps/no-compile 的 pinned wheel 安装;旧的 pip 后 executable `installedProbe/import yt_dlp` 已删除。静态检索确认校验前不存在 `.deps` 的 `sys.path` 注入或模块 import。
+2. `build_host.ps1:276-393` 在执行任何 `.deps` 代码前,依次验证 `.deps` 为 output 的 canonical direct child、目录与递归子项均非 reparse、实际文件集合/bytes/SHA-256 与 pinned wheel 派生集合一致、`RECORD` 全路径/hash/size 一致、`INSTALLER/REQUESTED` 为确定值、distribution 恰为唯一 `yt-dlp==2026.7.4`,且拒绝 PyInstaller 注入。
+3. 首次把 `.deps` 放入 `sys.path` 的位置为 `build_host.ps1:394-411` 的 post-validation PyInstaller launcher;其先确认路径归属和 metadata version,再调用已批准 PyInstaller。未发现第二条提前 executable dependency 路径。
+4. 新反例在 TemporaryDirectory 的脚本副本中,于 pip 后向 `.deps/yt_dlp/__init__.py` 注入 marker 写入代码;integrity gate 非零、output absent、receipt absent、marker absent。reviewer 独立复跑该完整测试=`1/1 PASS / Ran 1 in 53.791s`。
+
+### Passed/frozen 未回退
+
+- reviewer 独立复算 actual source tree:除 source manifest 自身外大小写敏感 exact `17/17`,逐文件 bytes/SHA-256 全匹配,reparse=`0`;候选 output `dev/tmp/bili-auth-controlled-build-ytdlp-20260807-002` 不存在。
+- 冻结 bridge 保持:code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`。
+- 提交的 extension=`32 tests/OK/5 skipped`、bridge=`21/21 PASS`、project=`134 tests/OK/6 skipped` 及 parser/compile/UTF-8/governance 证据与冻结工件一致;本限定复审未重跑 build、安装或真实链路。
+- replacement build/install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription=`0`。
+
+### Reviewer-owned source approval
+
+- 已创建 source/build 树外 approval:`ai-inforev/worklog/2026-08-07-bili-authenticated-session-ytdlp-prevalidation-source-approval.json`=`373/54514FCD70D2ED85F33E48433B13241F845A329A3DBD2C089AF416F1D67B2CEB`。
+- approval exact schema 1,仅绑定 source manifest=`3029/463182C6980734B72831BDF9B387160F8AD21A03B52CBAB4AC74CA98CEB1B7AE`,scope=`controlled-build-source-manifest`,`approved_by_role=dev.reviewer.project`;未创建、复用或更新 install-exact-build approval。
+
+### 结论与边界
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 结论:`F1_PREVALIDATION_DEPS_CODE_EXECUTION_CLOSED_SOURCE_APPROVED`。
+- 仅允许管理员已授权的唯一 owner 使用本次 exact source manifest、新 reviewer approval、既有 exact Python/PyInstaller/provision receipt,在新的不存在 output 目录执行一次 clean replacement build;任何 bytes/hash/path/tree/output-set 漂移均须失败关闭并重新审核。
+- 本 PASS 不授权 install/HKCU/EXE execution,也不授权 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription。replacement EXE/schema 2 receipt 形成后仍须沿同链做 hash-only 限定确认并取得新的 install-exact-build approval。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-CONTRACT-SOURCE-REREVIEW-20260808-001
+
+- 记录时间:`2026-08-08T13:44:23+08:00`
+- 审核阶段:同一事项、同一审核链内,仅复审 archive-metadata contract source 快照;前序 source tree、prevalidation、HOLD/3 runtime contracts、冻结 bridge 与产品范围不重开。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;MB-X 消息=`msg_20260807042629035_d5f27466`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-CONTRACT-SOURCE-REREVIEW-20260807-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 提交冻结快照:source manifest=`3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`;build script=`32216/4E25D57D8133670D25BACF0E2A38D1C673DB9576413B22EBDC28304CAB6B2A33`;installer=`22467/DBF1A828DC343DCA4B52283B8EE189962007581E42D03D213A55461CE05CFEF6`;test=`62074/66B70D38E983806B82A2D00A1F741A79445E9C9CF80EA2C0951EED2935C1CC2B`;dependency manifest=`723/2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`;wheel=`3184705/F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266`;machine evidence=`2513/F885984DB369081F374AB1E655F70F63169B2612A77190C790B8B205DD4E47AE`。
+
+### Findings
+
+- 阻断问题:无。
+
+### Archive metadata contract 闭环
+
+1. 前序独立检查已确认 actual source tree 大小写敏感 exact `17/17`、逐文件 bytes/SHA-256 全匹配、reparse=`0`;本 continuation 未重做该检查。
+2. `build_host.ps1` 使用 PyInstaller `6.15.0 CArchiveReader` 结构化遍历顶层 CArchive 与嵌套 PYZ,规范化路径分隔符并拒绝重复归一化路径;固定要求 worker、`yt_dlp` 及 downloader/globals/plugins/version 模块锚点。
+3. metadata root 必须唯一为 `yt_dlp-2026.7.4.dist-info`,类型、Name/Version、精确 7 文件集合、逐文件 bytes 与固定 tree hash 均须匹配已验证 `.deps`;installer 同步强制 schema 2 receipt 的 archive verification、source/dependency/build 绑定与 exact EXE 绑定,旧 receipt/approval 不可复用。
+4. 既有离线实物树 `dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/metadata-install-layout-probe-no-compile/yt_dlp-2026.7.4.dist-info` 只读复算为精确 7 文件:`INSTALLER`、`METADATA`、`RECORD`、`REQUESTED`、`WHEEL`、`entry_points.txt`、`licenses/LICENSE`;reparse=`0`;按正式算法所得 tree SHA-256=`7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`,与 machine evidence 一致。
+5. 前序已完成的聚焦 archive 合同测试=`1/1 PASS / Ran 1 in 156.385s`,覆盖模块缺失、metadata 缺失、归一化重复、Name/Version 错误、不完整/额外 metadata 路径、替代内容及 Windows 分隔符正例;本 continuation 未重跑测试。
+6. 候选 output `dev/tmp/bili-auth-controlled-build-ytdlp-20260807-003` 保持不存在;build/install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription=`0`。
+
+### Reviewer-owned source approval
+
+- 已创建 source/build 树外 approval:`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-source-approval.json`=`373/A46A8D29A42B85E0DB83AA4EC5153B298BE9C24684637C6B7864AD23EF5B41E8`;其 exact schema 1 仅绑定 source manifest=`3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`,scope=`controlled-build-source-manifest`,`approved_by_role=dev.reviewer.project`。
+- 未创建、复用或更新 install-exact-build approval;后续 exact EXE/schema 2 receipt 仍须沿同链做 hash-only 限定确认。
+
+### 结论与边界
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 结论:`ARCHIVE_METADATA_CONTRACT_CLOSED_SOURCE_APPROVED`。
+- 仅允许管理员已授权的唯一 owner 使用本次 exact source manifest、上述 reviewer approval、既有 exact Python/PyInstaller/provision receipt,在新的不存在 output 目录执行一次 clean replacement build;任何 bytes/hash/path/tree/archive/output-set 漂移均须失败关闭并重新审核。
+- 本 PASS 不授权 install/HKCU/EXE execution,也不授权 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-TYPE-CONTRACT-SOURCE-REREVIEW-20260808-001
+
+- 记录时间:`2026-08-08T16:23:14+08:00`
+- 审核阶段:同一事项、同一审核链内,仅复审 build-003 的 `E_ARCHIVE_METADATA_TYPE` 假阴性及管理员授权的 exact `x` / Windows-promoted `b` metadata type 合同;前序 source-tree/reparse/dependency/clean-environment/prevalidation/path/file-set/bytes/identity/tree-hash gates、冻结 bridge/runtime 与产品范围不重开。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-TYPE-CONTRACT-SOURCE-REREVIEW-20260808-001`;MB-X 消息=`msg_20260808155805146_193e6c84`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 管理授权:`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-TYPE-CONTRACT-REPAIR-BUILD004-AUTH-20260808-001`;build-004 在本复审前保持 attempt=`0`、output absent。
+- 冻结快照:build script=`33020/3739436920FCEA8BA68DC04BBDAB0F346C08216F49D89A097C50075912A258A0`;installer=`22741/53A1D1AFD9E4571A2491AE66C5E721E77A2201DBFAA29C9094E42CE0CB684C2A`;source manifest=`3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`;test=`69608/2E7D6BE3B9607A34A90B283F2CDDBC65A31CD2AF02026EB97E8CED6C153075AB`;machine evidence=`4012/5880E90521F1B382C8B546A5814CBEC5DDEC826897F1EC498EEDE0C18C4263EB`。
+- build-003 原始失败证据:`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-003-blocked-20260808.json=3516/0FA8D0D9719D19C819F607E383D12B93021CE718489A742F740FBFEE13BBCC73`;attempt=`1`、retry=`0`、accepted EXE/receipt=`0/0`,失败 output 已回滚不存在。
+
+### Findings
+
+- 阻断问题:无。
+
+### Metadata type contract 闭环
+
+1. reviewer 独立复算 source manifest 与当前 actual source tree:大小写敏感 exact `17/17`,missing/extra/bad hash=`0/0/0`,source root 及递归子路径 reparse=`0`;提交的 build/installer/test/evidence bytes/SHA-256 均匹配。
+2. `build_host.ps1` 仅在唯一候选 metadata root 已锁定为 `yt_dlp-2026.7.4.dist-info` 且七个精确相对文件集合已冻结后,对这些成员接受 CArchive type `x` 或 Windows 因 `os.access(source, X_OK)` 提升的 `b`;`b` 不会扩展为任意路径、模块、脚本或其他数据许可。
+3. type `Z/a/d/l/m/n/o/s/z` 继续 `E_ARCHIVE_METADATA_TYPE` fail closed;错误 metadata 路径上的 `b` 不能成为合法根并以 `E_ARCHIVE_METADATA_DUPLICATE_OR_PATH` 失败。逐文件 bytes、Name/Version、固定七文件集合与 tree SHA-256 继续独立绑定。
+4. schema 2 receipt 新增实际 `archive_verification.metadata_type_codes`;builder 只接受非空且为 `b/x` 子集,installer 同步验证该字段以及固定 metadata tree hash、source/dependency/build/EXE 绑定。旧 receipt 与旧 source approval 不能满足本快照。
+5. reviewer 独立运行真实 PyInstaller CArchiveWriter 的 `x/b` 合同与 installer/receipt 事务两项窄测:`2/2 PASS / Ran 2 in 17.027s`。测试覆盖实际 writer 生成的两种类型、receipt type codes、installer 强制绑定与事务回滚;未运行正式 build。
+6. 提交证据中的 complete type/build gate 矩阵覆盖其他类型、错误路径 `b`、路径/文件集/bytes/Name/Version/tree-hash 漂移;bridge=`21/21 PASS`、project=`135 tests OK / 6 skipped` 与静态/治理证据未发现冻结合同回退。
+
+### Reviewer-owned source approval
+
+- 已创建 source/build 树外 approval:`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json`=`373/102B30405B81137BD3AC9B81CFA39A6A7BB21531115FB3F361F821B2A093335E`。
+- approval exact schema 1,仅绑定 source manifest=`3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`,scope=`controlled-build-source-manifest`,`approved_by_role=dev.reviewer.project`;未创建、复用或更新 install-exact-build approval。
+
+### 结论与边界
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 结论:`ARCHIVE_METADATA_TYPE_CONTRACT_CLOSED_SOURCE_APPROVED`。
+- 仅允许管理员已授权的唯一 owner 使用本次 exact source manifest、上述 reviewer approval 与既有 exact Python/PyInstaller/provision receipt,在新的不存在 output 目录执行一次 build-004;任何 bytes/hash/path/tree/archive/type/output-set 漂移均须失败关闭并重新审核。
+- 本 PASS 不授权 install/HKCU/EXE execution,也不授权 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription。build-004 形成 exact EXE/schema 2 receipt 后仍须沿同链做 hash-only 限定确认并取得 install-exact-build approval。
+- Open questions:无。
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-HASH-ONLY-CONFIRMATION-20260808-001
+
+- 记录时间:`2026-08-08T17:14:35+08:00`
+- 审核阶段:同一事项、同一审核链内,对 build-004 唯一 EXE 与 schema 2 receipt 做 exact-build hash-only 限定确认;不重开已通过 source/runtime 合同,不执行 EXE,不安装,不操作 HKCU 或任何真实认证、联网、媒体路径。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;MB-X 消息=`msg_20260808170546380_8f0fb84f`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-HASH-ONLY-CONFIRMATION-20260808-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 前序 source 审核:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-TYPE-CONTRACT-SOURCE-REREVIEW-20260808-001 / PASS/0`;reviewer source approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-archive-metadata-type-source-approval.json=373/102B30405B81137BD3AC9B81CFA39A6A7BB21531115FB3F361F821B2A093335E`,精确绑定 source manifest=`3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`。
+- build-004 提交实物:EXE=`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004/project-info-bili-auth-native-host.exe=21343775/DBF155BCC05B4DF55592D0A4B56612F8BE9C990B3B9AF7691F6A11648866B0B0`;schema 2 receipt=`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004/build-artifact-manifest.json=2874/0B36AA7DCA3AD80F8A370E96D50CAAFB386291EE2741305C7797ED663F5C2B27`;machine evidence=`3842/82F2DCAEC8F79E44AA73B740BAEEA84FF2F2FCC02EE0E003C2D71E3B632F8057`。
+
+### Findings
+
+#### F1(阻断):build-004 receipt 的 metadata tree hash 与同一 source-approved installer 的 preinstall 身份门禁不一致
+
+- reviewer 只读复算确认 receipt 的 `archive_verification.metadata_tree_sha256=32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。使用获批 PyInstaller 6.15.0 的 `CArchiveReader` 静态读取 EXE,得到唯一 `yt_dlp-2026.7.4.dist-info` 的精确七文件集合、type codes=`[b]`,按冻结算法复算 tree SHA-256 同为 `32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`;该检查未加载或执行 EXE。
+- 但同一 source manifest 所冻结的 `install_native_host.ps1=22741/53A1D1AFD9E4571A2491AE66C5E721E77A2201DBFAA29C9094E42CE0CB684C2A` 在第 259 行只接受 `archive_verification.metadata_tree_sha256=7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`,否则在复制产物或 HKCU 前抛出 `Build artifact receipt identity mismatch.`。
+- 因而当前 exact EXE/receipt 虽然自身 bytes/hash 与静态 archive 内容一致,却无法通过其获批源快照自身的 installer preinstall 合同;为其签发 `install-exact-build` approval 会把已知不可安装的构建错误提升为安装许可,属于阻断。
+
+### 已通过的 hash-only 检查
+
+1. output root 恰好包含两个普通、非 reparse 文件,目录与 `.build/.spec/.deps` 均为 0;EXE 前两字节为 `MZ`,未执行。
+2. EXE、receipt、machine evidence、source manifest、source approval、build script、dependency manifest、pinned wheel、exact Python/PyInstaller 与 provision receipt 的提交 bytes/SHA-256 均与实物匹配。
+3. receipt 为 strict UTF-8/no BOM/no trailing whitespace、无 duplicate key,schema 2、target/build identity、source/dependency/builder 绑定及唯一 EXE name/bytes/SHA-256 均匹配。
+4. 文档=`42401/387FEF1D591264AA0B412C82F8A187E161A38BF5F3674057D5B8A20FD9DDCB5D`、secondary worklog=`22062/E7A1E5ECC26C1835F239ADBC884F0024CEF0420F694BCD37DB030EC24BFE80F0`、执行日志=`215555/4BC01DB6C402A6CE00E205122E1A6A982EF166CEF8FC438E718792F23BF08272` 与提交一致;提交证据记录 install/HKCU/EXE execution/Chrome/Cookie/session/network/download/real media/`F:\video`/formal `ana-data`/transcription=`0`。
+
+### 最小 required fixes
+
+1. 保持当前 build-004 不安装、不执行、不签发 install approval。仅在同一任务/审核链内修正 build/installer 的 metadata tree hash 合同,使二者基于同一套已验证 metadata 七文件和同一 canonical tree-hash 算法得出并强制同一个值;不得放宽精确文件集、逐文件 bytes、Name/Version、type `b/x`、source/dependency/builder/EXE 绑定。
+2. 增加非真空离线反例:把生产等价 schema 2 receipt 交给 installer 的 preinstall validation,证明正确 current tree hash 在任何 copy/HKCU 前通过;旧 `7C9ABE...105C` 或任意漂移值必须在同一位置 fail closed,且 install root/HKCU/临时残留为 0。
+3. installer/source manifest 变更后,当前 build-004 receipt 的 source-manifest 绑定即过期;必须先完成同链 source 限定复审与新的 reviewer-owned source approval,再经管理员授权生成一个新的 absent-output exact build,最后重新提交 hash-only 确认。不得复用本轮未签发的 install approval,也不得在复审前重试安装。
+
+### 结论与边界
+
+- 审计状态:`HOLD`
+- 阻断问题数:`1`
+- 结论:`BUILD004_NOT_INSTALLABLE_NO_INSTALL_APPROVAL`
+- reviewer 未创建、复用或更新任何 `install-exact-build` approval。
+- 当前边界:build-004 EXE/receipt 仅可保留为冻结失败证据;install/HKCU/EXE execution 继续冻结,也不得触发 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription。
+- Open questions:无。
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-SOURCE-REREVIEW-20260808-001
+
+- 记录时间:`2026-08-08T19:55:10+08:00`
+- 审核阶段:同一事项、同一审核链内,仅复审 build-004 `HOLD/1` 的 canonical seven-file tree-hash source 修复;冻结 bridge/worker/runtime 及既有 source-tree/reparse/dependency/clean-environment/prevalidation/path/file-set/bytes/Name/Version/type `b|x` 合同不重开。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;MB-X 消息=`msg_20260808194541817_31967104`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-SOURCE-REREVIEW-20260808-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 前序审计:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-HASH-ONLY-CONFIRMATION-20260808-001 / HOLD/1`。
+- 冻结 source:build script=`35414/6184EBA087470BB5DBD6FEC29EB22068F8119FBDE2ED640715E8317B5A928C04`;installer=`24079/AA9667A3CD765B21058F234FD82DFDB9621161246041EC595E4BCB17DDCFDE86`;source manifest=`3681/333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535`;test=`70133/8913DC78EBB1780C29A518D7AC4DE90E4C8B0E8B8FF82106A3461326A3389045`;machine evidence=`3862/20F83A539477F0514BAB087F88036D3324F4BCD9950FF5C1409492CD17472CFB`。
+
+### Findings
+
+- 阻断 findings:`0`。
+
+### Canonical tree-hash 合同闭环
+
+1. reviewer 独立复算 actual source tree:除 source manifest 自身外大小写敏感 exact `17/17`,missing/extra/bad hash=`0/0/0`;source root、子目录与文件 reparse=`0`。dependency manifest=`723/2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`、pinned wheel=`3184705/F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266` 未漂移。
+2. `source-artifact-manifest.json#archive_metadata_contract` 是单一 golden definition,精确冻结 root=`yt_dlp-2026.7.4.dist-info`、七个 relative files、Name=`yt-dlp`、Version=`2026.7.4`、types=`[b,x]`、`SOURCE_DATE_EPOCH=1786207924`、canonical 算法及 tree SHA-256=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。
+3. build 与 installer 均先对该合同做 exact key/value/file-set 校验,再从同一 manifest 读取 canonical tree hash;build 对已验证 `.deps` 与静态 CArchive 分别复算并强制相等,receipt 只写入经复算通过的值;installer 在任何 copy/HKCU 前把 receipt 与同一 manifest 的 canonical 值精确比较。产品 build/installer 中历史 `7C9ABE...105C` 匹配数=`0`。
+4. build 仅在 pinned wheel 的 pip 子进程期间设置 `SOURCE_DATE_EPOCH`,并在 `finally` 中恢复原进程值。已有隔离实物树精确七文件、reparse=`0`,`RECORD=91484/433ED247B6625B8960AD19676BD04EC689640CCE050604A925A3EC013AF07D4F`;reviewer 按冻结算法复算 tree SHA-256=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`,Name/Version 唯一且匹配。
+5. reviewer 限定运行 installer production-equivalent receipt 与真实 CArchive writer `x/b` 两项窄测:`2/2 PASS / Ran 2 in 22.169s`。正例 canonical `32F1...E70` 返回 `VALIDATION_PASS_ONLY` 且 install root/registry 为 0;历史 `7C9...105C`、全零、任意漂移均在 copy/HKCU 前 fail closed;真实 writer 的 `x`、Windows-promoted `b` 均维持七文件 bytes/identity/tree 门禁。两次先行 unittest loader 调用因 hyphen 路径与 `-I` 排除 cwd 在测试体执行前报 import error,修正为目标测试目录非隔离模块加载后上述两项均通过,不构成产品或合同失败。
+
+### 其他静态与边界检查
+
+- PowerShell parser、Python in-memory compile、strict UTF-8/no BOM/no trailing whitespace、JSON parse、scoped diff=`PASS`;治理=`OK/projects=1/warnings=0`。
+- 提交的 synthetic exact-builder seam=`1/1 PASS / 348.093s`、frozen bridge=`21/21 PASS`、final project=`135 tests OK / 6 skipped` 证据与冻结工件一致,本限定复审未重跑长矩阵或正式 build。
+- build-004 EXE/receipt 保持 immutable `NOT_INSTALLABLE` 历史;build-005 attempt=`0`,`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005` 不存在。install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription=`0`。
+
+### Reviewer-owned source approval
+
+- 已创建 source/build 树外 approval:`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-canonical-tree-hash-source-approval.json=373/E0D621EB2C5E8149FE9B775CF7EA2F2ECB17C3FC3A6D4911D67CF2D24ACBD3EF`。
+- approval exact schema 1,仅绑定 source manifest=`3681/333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535`,scope=`controlled-build-source-manifest`,`approved_by_role=dev.reviewer.project`;未创建、复用或更新 `install-exact-build` approval。
+
+### 结论与边界
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 结论:`CANONICAL_TREE_HASH_CONTRACT_CLOSED_SOURCE_APPROVED`
+- 仅允许管理员已授权的唯一 owner 使用本次 exact source manifest、上述 reviewer source approval 与既有 exact Python/PyInstaller/provision receipt,在新的不存在 output 目录执行一次 build-005;任何 source/dependency/tree/archive/type/receipt/output-set 漂移必须 fail closed。
+- 本 PASS 不授权 install/HKCU/EXE execution,也不授权 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription。build-005 形成 exact EXE/schema 2 receipt 后仍须同链 hash-only 限定确认并取得新的 `install-exact-build` approval。
+- Open questions:无。
+
+## DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-005-HASH-ONLY-CONFIRMATION-20260808-001
+
+- 记录时间:`2026-08-08T20:34:29+08:00`
+- 审核阶段:同一事项、同一审核链内,仅对管理员授权的唯一 build-005 EXE、schema 2 receipt、source/dependency/builder 绑定、canonical metadata tree contract 与精确输出集合做静态 hash-only 限定确认;不重开已通过 source/runtime 合同,不执行 EXE,不安装,不写 HKCU,也不触发任何真实认证、联网、下载、媒体或正式路径动作。
+- 审核对象:`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;需求=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;请求=`HANDOFF-INFODEV2-INFOREV-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-005-HASH-ONLY-CONFIRMATION-20260808-001`;来源/回传任务=`019fbcbb-bed7-7c90-83ab-f50610f80d3a`。
+- 管理授权:`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-REPAIR-BUILD005-AUTH-20260808-001`。
+- 前序 source 审核:`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-SOURCE-REREVIEW-20260808-001 / PASS/0`;reviewer source approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-canonical-tree-hash-source-approval.json=373/E0D621EB2C5E8149FE9B775CF7EA2F2ECB17C3FC3A6D4911D67CF2D24ACBD3EF`,精确绑定 source manifest=`3681/333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535`。
+- build-005 提交实物:EXE=`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005/project-info-bili-auth-native-host.exe=21343716/CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE`;schema 2 receipt=`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005/build-artifact-manifest.json=2874/CC6663A15CDEB375167900730A9F956C9B09839558586E5DFC3D7FC57728ACC0`;machine evidence=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-005-success-20260808.json=4496/3FB4557AD81556068B27F6EBA10017883B511C52DDD66AA505150AAD4977CF57`。
+
+### Findings
+
+- 阻断 findings:`0`。
+
+### Hash-only 独立确认
+
+1. output root `dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005` 恰好包含上述两个普通、非 reparse 文件,目录=`0`,`.build/.spec/.deps=0`;EXE 前两字节静态读取为 `MZ`,审核中未加载或执行该 EXE。
+2. reviewer 独立复算 EXE、receipt、source approval、source manifest、dependency manifest、pinned wheel、exact Python、exact PyInstaller、provision receipt、build script 与 machine evidence 的 bytes/SHA-256,均与提交值一致。receipt 的唯一 `files[0]` 精确绑定 EXE name/bytes/SHA-256;source approval→source manifest→receipt→EXE 链完整。
+3. receipt 为 strict JSON、无 duplicate key、schema=`2`、UTF-8/no BOM/no trailing whitespace;target/build identity、`pyinstaller-onefile`、PyInstaller=`6.15.0`、yt-dlp=`2026.7.4`、source/dependency/wheel/Python/PyInstaller/provision/build-script 字段均与冻结实物精确一致。
+4. reviewer 使用获准 PyInstaller `CArchiveReader` 对 EXE 做纯静态读取:唯一 `yt_dlp-2026.7.4.dist-info` 恰好包含冻结七文件,实际 type codes=`[b]` 且属于允许集合 `[b,x]`,Name=`yt-dlp`、Version=`2026.7.4`;按 source manifest 冻结算法独立复算 metadata tree SHA-256=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`,与 source contract 和 receipt 完全一致;receipt 所列 required modules 缺失数=`0`。
+5. build 证据记录 attempt=`1`、retry=`0`、exit=`0`、wall=`66.8s`;历史 build-004 EXE/receipt hash 未变且继续仅为 `NOT_INSTALLABLE_HISTORY`,本次未复用其 approval。提交证据声明 install/HKCU/EXE execution/Chrome/Cookie/session/network/download/real media/`F:\video`/formal `ana-data`/transcription=`0`,静态实物检查未发现反证。
+
+### Reviewer-owned exact-build approval
+
+- 已创建 source/build 树外 approval:`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json=596/4D18A32DE9DFEB6B90C1A398087BD36CEAE09418D54E1F0DC485D6B95C962A44`。
+- approval exact schema 1:`approval_scope=install-exact-build`、`approved_by_role=dev.reviewer.project`、`status=APPROVED`;精确绑定 source manifest SHA-256、build-005 receipt bytes/SHA-256 与 build-005 EXE bytes/SHA-256。该 approval 不在 source/build 树内,且与历史 build-004 approval 不可互换。
+
+### 结论与边界
+
+- 审计状态:`PASS`
+- 阻断问题数:`0`
+- 结论:`BUILD005_EXACT_BUILD_HASH_ONLY_CONFIRMED`
+- build-005 已满足 installer 的独立 exact-build identity 前置门禁;未来只有获得另行明确安装调度的操作者,才可把同一 source approval、上述 build-005 approval、同一 schema 2 receipt 和同一 EXE 作为不可替换输入。任一 bytes/hash/path/output-set 漂移均须 fail closed 并重新审核。
+- 本 hash-only PASS 与 approval 本身不授权当前回合实际 install/HKCU,不授权 EXE execution,也不授权 Chrome/Cookie/session、network/download、real media、`F:\video`、formal `ana-data` 或 transcription。
+- Open questions:无。
diff --git "a/dev-doc/\345\274\200\345\217\221\346\211\247\350\241\214\346\227\245\345\277\227.md" "b/dev-doc/\345\274\200\345\217\221\346\211\247\350\241\214\346\227\245\345\277\227.md"
index efea5f4..d3a4d4f 100644
--- "a/dev-doc/\345\274\200\345\217\221\346\211\247\350\241\214\346\227\245\345\277\227.md"
+++ "b/dev-doc/\345\274\200\345\217\221\346\211\247\350\241\214\346\227\245\345\277\227.md"
@@ -877,3 +877,610 @@
- 最终实现复审和操作手册单文件确认均为 `PASS/0`;治理为 `OK / projects=1 / warnings=0`。请求方明确不要求网络、fixture、V2/V1 或 smoke 重跑,本次收口执行上述动作=`0`。
- 非阻断路径说明:worklog 实际路径为 `ai-infodev-2/worklog/2026-08-01-stock-valuation-v2-takeover.md`;最终交付中的简写不准确,实物 `8629/F75E4231E18AA96D64C059D0D8E3F280AD8A74AD4AA08307D781597A311474A7` 已被请求方核对,不影响产物或验收。
- 当前状态=`ACCEPTED_CLOSED`;代码、测试、fixture、文档、审计和真实限制证据保留。除非收到新事项,不再追加功能、联网重跑或扩展 provider/产品范围。
+
+## 2026-08-03T02:53:44+08:00 DEV-LOG-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-OFFLINE-IMPLEMENTATION-20260803-001
+
+- V003 限定方案审核=`DEV-AUDIT-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-DESIGN-20260803-001=PASS/0`;审计=`156643 bytes / 5B33406A04CA3FC006A296979288C47E18F4F25F34F77CB9B9F3C75D05EB1CCB`。已审核设计保持 `9128 bytes / C9EB544A7F0E1A160237E3F9C9E426B093AD37D82F22808E8B6A792BE92D1051` 未改。
+- 仅在既有脚本增加 512 项 float32 RGB 颜色直方图、随逻辑页保存的颜色特征和确定性 `select_main_sequence`;冻结门槛为覆盖率 `0.12`、单页间隙、标记数 `8`、全局占比 `0.50`、区间密度 `0.80`,无合格强主题或获胜区间覆盖全部候选时保留全部。稳定段、动画/去重、FFmpeg、PDF、CLI、原子提交和失败清理未改。
+- 实现=`dev/project-dev/extract_ppt_slides.py / 32133 bytes / 9C4F57ED56B01C9EE2BD68CDC532BC865F73EB3662E01B6600410C5660F1F605`;测试=`dev/project-dev/test/test_extract_ppt_slides.py / 35037 bytes / 63298A48F4F7944514301D0BC0AC619C58464651ABF8F53C23E15E85565106FA`;用法=`dev-doc/project-doc/会议录屏PPT页面提取工具.md / 6729 bytes / C883E7A37846621E9889DDA584D5EC16066A85848A732C3C92CDE891A0485D6C`。
+- 离线门禁将 PATH 置空以物理禁用 FFmpeg/FFprobe:py_compile PASS,CLI help PASS;目标测试=`29 discovered / 26 PASS / 3 SKIP / 0 FAIL`,project 回归=`48 discovered / 45 PASS / 3 SKIP / 0 FAIL`。3 项跳过均为既有需要 FFmpeg 的集成项;所有 V003 颜色、53 页结构、保守回退、非法输入、主流程顺序和连续重编号测试 PASS。
+- 既有 53 张候选 PNG 只读回放使用产品同一 `_gray_feature`/颜色直方图与 `select_main_sequence`,自动选择原 `slide_002..038`,生成独立 `sequence-filter-offline-v003.slides/pages/slide_001..037.png` 与 37 页 PDF;证据=`16268 bytes / A12AF6E0F1B3D4E7CBB2874924E07CA929CF22CE80BC3D7AE7D9E3527B7CDA60`。
+- 新 PDF=`58570040 bytes / 71C508E94852FBE36AA49DA1B17DDD3F634A82463A06E8AB029709517BB68535`;逐页内嵌图像与对应 PNG 的 `2560x1440 RGB` 字节和顺序严格一致。Poppler 独立解析=`37 pages / 2560x1440 pt / PDF 1.4`,首/中/末页渲染和联系表视觉抽查均为课件主体;联系表=`2018018 bytes / B6C66154847DBE95DBABFB08E38B7F74D4F85E3AB812143866581B5528225063`。
+- 回放前后旧 53 张 PNG 完整清单与旧 53 页 PDF 未改;旧 PDF=`112814367 bytes / FD289013234317F5D93EA41475C96794581E33CE2C466311E1E3A0DC2F6B299E`。请求方人工归档 PDF 也保持 `58570040 bytes / 71C508E94852FBE36AA49DA1B17DDD3F634A82463A06E8AB029709517BB68535`、创建/修改时间 `2026-08-01T14:04:32.3047538Z / 2026-08-02T11:42:28.9643289Z`;新离线 PDF 是从旧 53 张 PNG 独立重建,未读取人工归档作为输入。
+- 本阶段源视频读取=`0`、FFprobe=`0`、FFmpeg=`0`、源媒体运行=`0`、人工归档写入=`0`。当前状态=`PENDING_V003_LIMITED_IMPLEMENTATION_REVIEW`;实现审核 PASS 前不得运行真实媒体。
+
+## 2026-08-03T21:25:39+08:00 DEV-LOG-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-ONE-FULL-REAL-RUN-20260803-001
+
+- V003 限定实现审核=`DEV-AUDIT-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-IMPLEMENTATION-20260803-001=PASS/0`;审计=`163418 bytes / 2DB0AD6B7440CF0589ABF973B938A8CFA636616D879043A48F1C1B02523C1F50`。实跑前冻结实现、测试、用法哈希严格匹配审核记录,未先做短跑/预跑,未改代码或参数。
+- 完整源视频唯一运行一次:源=`1268392019 bytes / 12866.880 s / 3A6F62815602C10B6D31A3A53DDBF6D8D5176DCF614EF30E03E32C12F113C9D6`;输出=`real-full-v2.slides`;外层 runner=`0`,工具/外层耗时=`1108.593/1109.297 s`,stdout 明确记录候选=`53`、主课件=`37`、原图进度 `1..37` 各一次及完成标记,stderr=`0 bytes / E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855`。
+- 原始 `run_summary.json` 的 `Start-Process.ExitCode` 为 `null`,未伪造改写;等价退出证据结合外层 runner 0、完成标记、37 页进度、正式原子提交和空 stderr 判定有效退出 0,保存在 `run_exit_evidence.json=582 bytes / 1F4D818658AFE2AC8F6AAFD10E31D3184E8DA8A6E139338CC68CDA15D392BD13`。因该取证差异媒体重跑=`0`。
+- 输出根严格为 `pages/` + 一个 PDF;37 张 `2560x1440` PNG 连续编号,合计 `58728191 bytes`,与旧候选 `slide_002..038` 一一字节相同。PDF=`58570040 bytes / 71C508E94852FBE36AA49DA1B17DDD3F634A82463A06E8AB029709517BB68535`;37 页逐页 RGB/顺序一致,Poppler=`37 pages / 2560x1440 pt / PDF 1.4`,首/中/末页和完整联系表视觉复核 PASS。
+- 联系表=`real-full-v2-contact-sheet.png / 2017566 bytes / 2CD7FD012EB028EE5EDF4A7AC3EBE939E4DEF5738715C27CDD3EB31C4B6D1BD6`;只含课件主体,右上角小型人像窗口保留,开场与课后全屏人物、行情和软件画面未进入结果。
+- 权威进程树资源证据=`resource_samples.csv / 27085 bytes / 267EB6788F11E58628C21F749AF2FC42CB765E61BF93810B6416E74FE4A889D6`:覆盖根 Python 启动至退出,样本=`288`,实际平均/中位/最大间隔=`3861.728/1169/15805 ms`,采样错误=`0`;Python+FFmpeg 峰值=`351191040 bytes / 334.922 MiB`,首末各 20 样本峰值差=`7614464 bytes`,内存有界。两个追加密集采样尝试因本机进程枚举延迟被停止,只产生监控 CSV,不启动媒体、不作为权威汇总,说明=`sampling_notes.json / 1615 bytes / D67AC4168383781E60D02DE844BA3CDF47C465FA64A0782447B38375DD5591BB`。
+- 源视频运行前后大小、创建时间、修改时间和 SHA-256 全部相等;旧 53 页 PDF 仍为 `112814367 / FD289013234317F5D93EA41475C96794581E33CE2C466311E1E3A0DC2F6B299E`,请求方人工归档仍为 `58570040 / 71C508E94852FBE36AA49DA1B17DDD3F634A82463A06E8AB029709517BB68535`;暂存残留=`0`,未覆盖、移动、重命名或删除任何旧证据。
+- 总验证=`validation.json / 12009 bytes / 944BB27A83B34F00E92FBEF9D1C3DD91327243CC0D65CD6D8783A235F4EA2288`;视觉证据=`visual_validation.json / 2599 bytes / C1F3933CFEAC08388A2F7BB9645016A6F2532DBFB6CD4D7CEE610350166A70EE`;原始命令/日志/汇总均保留在 `real-full-v2-evidence/`。
+- 当前状态=`PENDING_REQUESTER_FINAL_REAL_MEDIA_ACCEPTANCE`;唯一完整运行次数已用尽,不再运行媒体、调参或修改冻结实现,直接向 `case_analysis.media_processor` 提交结果。
+
+## 2026-08-03T22:24:34+08:00 DEV-LOG-PROJECT-INFO-MEETING-RECORDING-SLIDE-EXTRACTION-MVP-V003-REQUESTER-FINAL-ACCEPTANCE-20260803-001
+
+- 请求方最终只读验收回执=`HANDOFF-MEDIA-INFODEV-MEETING-SLIDE-V003-REQUESTER-FINAL-ACCEPTANCE-20260803-001`,前序=`HANDOFF-INFODEV-MEDIA-MEETING-SLIDE-V003-ONE-FULL-REAL-RUN-RESULT-20260803-001`,结论=`ACCEPTED_CLOSED`。
+- 请求方逐页核对 37 页联系表与第 1/19/37 页原图:全部为顺序正确、清晰完整且无裁切的主课件;PPT 上小型人物窗口保留,开场/课后全屏人物、行情和软件页面均未进入输出。
+- 新 PNG 为 `slide_001..037` 连续命名,逐页 SHA-256 精确等于旧 `slide_002..038`;新 PDF=`58570040 bytes / 71C508E94852FBE36AA49DA1B17DDD3F634A82463A06E8AB029709517BB68535`。
+- 现有人工归档 PDF 与新自动输出字节数及 SHA-256 完全相同,因此无需归档替换;源视频复核=`1268392019 bytes / 3A6F62815602C10B6D31A3A53DDBF6D8D5176DCF614EF30E03E32C12F113C9D6`。
+- 本最终验收媒体运行=`0`、代码修改=`0`、归档写入=`0`;本次仅登记账本终态,未触碰代码、媒体、输出或归档。
+- 当前状态=`ACCEPTED_CLOSED`;唯一完整运行次数已用尽,停止本事项,不再运行、重试、调参或扩展。
+
+## 2026-08-04T11:11:48+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001-DESIGN-20260804-001
+
+- 收到 `HANDOFF-MEDIA-INFODEV-MEDIA-HOST-RESPONSIVENESS-20260804-001 / AUTHORIZED_MINIMAL_IMPLEMENTATION`;按 common/project 编码与审计规范判定为重型最小修复,方案审核 PASS 前不实现。
+- 基线:`extract_ppt_slides.py=32133/9C4F57ED56B01C9EE2BD68CDC532BC865F73EB3662E01B6600410C5660F1F605`;`transcribe_media.py=20996/309166EEFE11A74472EA355CA4AF87F9432A15393B9ABE6D69F7F1054055DE35`;对应测试基线=`41/41 PASS`,py_compile PASS。
+- 只读能力探测:FFmpeg=`cuda + h264_cuvid + hevc_cuvid + hwdownload + scale_cuda`;GPU=`NVIDIA GeForce RTX 3090/24576 MiB`。2 秒合成 H.264 以拟定 `h264_cuvid + hwdownload + threads 4/filter_threads 2` 命令运行 `exit=0`;合成文件=`197077 bytes / ECC13A894C706F95C27FE9AACAFA8398E91546A1409807C1E735405084F27E1E`。
+- V001 入口=`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001.md`;冻结 NVDEC 优先/一次显式受限 CPU 回退、FFmpeg 线程上限、BelowNormal 主子进程、Windows named mutex、离线门禁和实现审核后的唯一短片资源验收。
+- 本阶段业务源读取=`0`、长视频运行=`0`、真实短片生成=`0`、产品代码修改=`0`;仅写方案、账本和合成设计探针。
+- 当前状态=`PENDING_INDEPENDENT_DESIGN_REVIEW`;向 `dev.reviewer.project` 提交一次完整 V001 方案审核。
+
+## 2026-08-04T11:50:28+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-DESIGN-20260804-001
+
+- 收到 `HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-V001-DESIGN-REVIEW-RESULT-20260804-001 / HOLD_3_REVISE_DESIGN_ONLY`;审计=`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V001-DESIGN-20260804-001`,当时审计报告=`171737 bytes / C9857D954230147A316DD72E2EAC0798C13865859125C566FCC14196AABB6D28`。
+- V002 只闭环 F1—F3:以私有类型冻结硬件可归因失败并禁止算法/空结果/文件/清理/中断回退;用固定短序列实际比较 H.264/H.265 的 NVDEC/forced CPU 业务结果;把 CPU 采样改为 warm-up 丢弃、扫描阶段连续覆盖、非空 5 秒时间加权窗口和真实 10 秒高段。
+- V001 已通过的 named mutex、BelowNormal 生命周期、4/2 线程、产品边界、业务合同和实现审核后唯一 `<=600 s` 真实短片/一次 PPT 运行边界均未回退。
+- 本阶段产品代码修改=`0`、产品测试修改=`0`、业务源读取=`0`、FFprobe/FFmpeg=`0`、真实片段生成=`0`、媒体运行=`0`;仅新增 V002 并更新目录/账本。
+- 当前状态=`PENDING_V002_LIMITED_DESIGN_REREVIEW`;向 `dev.reviewer.project` 只提交 F1—F3 与 preserved contracts 的限定复审,PASS 前不进入实现。
+
+## 2026-08-04T12:48:36+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-OFFLINE-IMPLEMENTATION-20260804-001
+
+- V002 限定方案复审回执=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-V002-DESIGN-REREVIEW-RESULT-20260804-001 / PASS_0_IMPLEMENT_OFFLINE_SYNTHETIC_ONLY`;审计=`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-DESIGN-REREVIEW-20260804-001`,`178586 bytes / 2BF60D53A5C66F5D3B80F2EA5D0BFE6133E2D06BCC959391BB645D04D922E948`。
+- 产品实现:两个 CLI 共用 `Local\MBXMediaHeavyTaskV1` 非阻塞 named mutex 和 BelowNormal 根进程上下文;默认 FFprobe/FFmpeg 子进程使用 BelowNormal creation flag;FFmpeg 解码/滤镜上限为 `4/2`。转写 CLI 仅增加主机保护,large-v3、CUDA/float16/VAD、分块和四输出合同未变。
+- PPT 实现:FFprobe 读取 codec/pixel format;已验证 H.264/yuv420p 与 H.265/HEVC yuv420p/10bit 格式分别使用 `h264_cuvid/hevc_cuvid + CUDA + hwdownload`;不支持映射明确使用受限 CPU。只有私有 `HardwareDecodePathError` 的启动、非零、零帧、半帧可触发一次 CPU,算法/空页/文件/清理/中断均零回退,CPU 失败无第三次。
+- F1 桩覆盖启动/非零/零帧/半帧、算法错误、空结果、`KeyboardInterrupt`、`SystemExit`、硬件清理失败和 CPU 二次失败;调用序列分别为 `HW->CPU` 或单次 `HW`,没有第三次。双进程 mutex 实测第二进程即时返回 busy,首进程退出后锁可重取且根优先级恢复。
+- F2 合成:固定 RGB 稳定+动画+重复+切换序列分别编码 H.264/H.265,实际运行四组 `NVDEC/forced CPU`。每组=`19 samples / 5 stable candidates / animation_updated 1 / historical_duplicate 1 / new_page 3 / selected timestamps 6,10,18 s`;PNG/PDF 页数、顺序、RGB 和尺寸一致;两种硬件命令均把 hwaccel、decoder、threads 放在输入 `-i` 前。
+- F3 采样器=`dev/project-dev/test/monitor_media_host_responsiveness.ps1`;500 ms 目标周期、warm-up 丢弃、扫描 FFmpeg 连续覆盖、真实时间加权 5 秒窗口、每窗至少 10 成员、窗口数非零及实际高 CPU 段。合成 CSV 验证 `<85` PASS、`==85` FAIL、`10.000 s` FAIL、`9.999 s` 不触发该时长门禁、稀疏/零窗口/`1.6 s` 缺口均 `sampling_valid=false`。
+- 验证:py_compile PASS;PowerShell parser PASS;两个 CLI help PASS;目标测试=`52/52 PASS / 11.805 s`;project discover=`59/59 PASS / 12.844 s`;diff check PASS。真实业务源读取=`0`、真实片段生成=`0`、真实媒体运行=`0`。
+- 冻结实物:`extract_ppt_slides.py=38111/9AD12F95C0615034B1BB9C72F79DFC5F1150DE33E2DD8889CFF14534509BB41A`;`transcribe_media.py=25087/E889D78DEEEFBC061CD0F952157C83997704CD46AF833AAF452293B7434D96FD`;PPT test=`55492/C18A2ACB79A4C74E5D93184723E17E4C13683CA52B0CD86B2AD47AFB6DD75CEA`;transcribe test=`19507/1C58C4C9ED45A1276A7CFF4D7E376F133446F506CB1A4890D231D00A229B82AC`;sampler=`28062/EAB0479662C29EA52CE56619EA9E27491DDE40AC6656E2391E82DE2356B27AB7`。
+- 当前状态=`PENDING_LIMITED_IMPLEMENTATION_REVIEW`;向 `dev.reviewer.project` 提交完整实现/离线证据。实现审核 PASS 前不读取业务源、不生成真实短片、不运行真实媒体。
+
+## 2026-08-04T13:38:59+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-HOLD5-REPAIR-20260804-001
+
+- 首次限定实现审核=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-V002-IMPLEMENTATION-REVIEW-RESULT-20260804-001 / HOLD_5_FIX_OFFLINE_ONLY`;审计=`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-IMPLEMENTATION-20260804-001`,`190154 bytes / 6F27D578AAFC8C92AB6E98AFC5D3BC8DB648CB43D33D84F21171C245CF580DCD`。审核已独立确认原 52/52、59/59 与 H.264/H.265 四条合成链 PASS,本轮只闭环 F1—F5。
+- F1:`_scan_video_once` 在业务/算法、CPU、`KeyboardInterrupt/SystemExit` 活动异常叠加子进程回收或 stderr 关闭失败时,继续原样 `raise` 同一异常对象;清理失败只用异常 note 记录。硬件异常若伴随清理失败会被标记为不可安全回退,外层 CPU 调用数为 0;CPU 二次失败仍无第三次扫描。组合桩覆盖业务+清理、CPU+清理、BaseException+清理和硬件+清理。
+- F2:解码选择恢复为 codec-only:所有 H.264 固定先尝试 `h264_cuvid`,所有 H.265/HEVC 固定先尝试 `hevc_cuvid`,`pix_fmt` 只作探测证据;实际硬件 surface/transfer/FFmpeg 失败才进入唯一一次 4/2 线程 CPU。`h264/gbrp`、`hevc/p010le` 的决策桩已冻结,既有 yuv420p 四条真实合成链业务摘要继续完全一致。
+- F3/F4:采样器只锁定首个同时匹配 `fps=1 + rawvideo + map 0:v:0` 的稳定扫描 FFmpeg PID,到观察该 PID 退出的同一周期结束;后续逐页 FFmpeg 不延长阶段,多扫描 PID 直接使采样无效。PASS 同时要求 stdout decoder 日志与该 PID 完整 argv 证明 CUDA/cuvid、输入前 4/2 线程及 `hwdownload`;目标 exit=0 但资源门禁失败时采样器返回 3。逐样本新增逻辑处理器、decoder、三组 GPU 查询状态/原始行、任务 PID `GPU Process Memory` 实例名/路径/数值;汇总新增有效扫描样本、最长高 CPU 段起止、任务 CPU/RAM、GPU/decoder、全局与任务 PID 显存峰值。
+- F5:分析器仅以相邻实际 UTC 时间戳重算 interval,并核对报告 interval/previous;新增时间倒退、interval 伪造、previous 不一致、必填列缺失、扫描首/末归属、NVDEC argv 缺项反例。两个真实 CLI 在已持有同一 mutex 时均先于路径/暂存/FFprobe/FFmpeg/模型动作返回 busy;Win32 wait/get/set 失败桩逐项证明已创建句柄关闭及已取得锁释放。
+- 最终实物:`extract_ppt_slides.py=38070/DCA04653CB349CB6DE449A6D291344E3E5788FB035F07A6BB2670EBE1F11D30A`;`transcribe_media.py=25245/0A485447578DB24DEA84CB887041F6D0CCB3E00F3F40745965228F2D06EEED8F`;PPT test=`65196/50FCDCE8FF75711CF75599D9207C376DE8575C8EF193B9F81CC0F5B50CF02A4E`;transcribe test=`23080/1BD0D8B1392BD69F72CBCE1A279BBEF2B36B813E03147E298D366FEBC4524E95`;sampler=`43771/6B038A4A44F874A93DAA5B10F6B59CD5BD9DD246A5ADFA0BD109F7CAFF8AFEAE`;PPT 用法=`10630/5A0AC2B050E6E441D49E82AD55F4F1B12761EE9D26FC50C50F38784E5402C64E`;转写用法未变=`6788/286F3395ADA8FCEC3DDA04185929D99A773E4A1B7250508254B2F645647804E5`。
+- 验证:py_compile PASS;PowerShell parser PASS;两个 CLI help PASS;范围内 diff check PASS;目标=`55/55 PASS / 20.340 s`;project discover=`62/62 PASS / 23.183 s`。四条合成业务链仍各为 `19 samples / 5 candidates / animation 1 / duplicate 1 / 3 pages / 6,10,18 s`,PNG/PDF 页数、顺序、RGB 和尺寸一致。
+- 本轮业务源读取=`0`、业务 FFprobe/FFmpeg=`0`、真实片段生成=`0`、真实媒体运行=`0`;只运行离线桩、合成 H.264/H.265 与合成 CSV。当前状态=`PENDING_LIMITED_IMPLEMENTATION_REREVIEW`;正式 PASS 前不读取业务源、不生成或运行真实短片。
+
+## 2026-08-04T14:30:21+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-F3-R1-F4-R1-REPAIR-20260804-001
+
+- F1—F5 限定复审回执=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-V002-F1-F5-IMPLEMENTATION-REREVIEW-RESULT-20260804-001 / HOLD_2_FIX_SAMPLER_OFFLINE_ONLY`;审计=`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-V002-F1-F5-IMPLEMENTATION-REREVIEW-20260804-001`,`197519 bytes / 545244DD758FC5D8413325F29B24B58FFC22B6DF56973C6CF31EBEEE0FC9AFC8`。F1、F2、其余 F5 和全部产品合同正式关闭并冻结,本轮只修改测试侧采样器、PPT 离线测试及必要说明。
+- F3-R1:`Test-NvdecScanCommand` 新增目标 `-i` 前 `-hwaccel_output_format cuda` 强制证据;删除、错值、移到 `-i` 后以及缺 `hwdownload` 四类 argv 均使 `nvdec_scan_argv_invalid`。有效合成 CSV 补齐冻结参数,不再以缺参数命令冒充有效样本。
+- F3-R1 Run 控制流:新增完全无媒体的 Windows 进程/命令桩。根 stub 启动名为 `ffmpeg.exe` 的受限 CPU 稳定扫描进程,扫描退出后留出间隔,再启动带 `-frames:v 1` 的逐页进程,同时伪造 stdout 的 `NVDEC/CUDA (h264_cuvid)` 和 stderr 的 CPU 回退告警。采样器只锁定首个扫描 PID,到观察其退出的同一周期结束;之后逐页进程存在但全部 `scan_phase_active=false`。目标进程 exit=`0`,采样器明确 exit=`3`;`run_summary.json` 保留 target exit 0、两类 NVDEC 证据 false、resource acceptance false 和源指纹未变。
+- Run 桩首次执行还发现 PowerShell 5 对 `List[string]/List[object]` 直接 `@(...)` 传给 `ConvertTo-Json` 会在每周期报 `Argument types do not match` 并留下 0 样本;现改为显式 `ToArray()`,外层采样错误同时记录脚本栈。修复后 Run 桩产生连续样本并通过上述阶段/终态断言,不涉及产品脚本或媒体。
+- F4-R1:`task_pids`、`ffmpeg_command_lines`、`nvidia_smi_compute_task_rows` 全部进入 `$RequiredColumns` 和扫描阶段非空门禁;三个字段及既有 `gpu_query_raw_rows` 分别删除时均得到对应 `required_column_missing_*` 且 `sampling_valid=false`。
+- 冻结产品未变:`extract_ppt_slides.py=38070/DCA04653CB349CB6DE449A6D291344E3E5788FB035F07A6BB2670EBE1F11D30A`;`transcribe_media.py=25245/0A485447578DB24DEA84CB887041F6D0CCB3E00F3F40745965228F2D06EEED8F`;转写测试未变=`23080/1BD0D8B1392BD69F72CBCE1A279BBEF2B36B813E03147E298D366FEBC4524E95`;转写用法未变=`6788/286F3395ADA8FCEC3DDA04185929D99A773E4A1B7250508254B2F645647804E5`。
+- 本轮实物:PPT test=`72307/425F4C5D2C00F2E7260343CD961DCE0DC635184AE93F79446D12A8D4D489B06C`;sampler=`43995/8BAFFCE83980542F2FA7ED8BC939114CB09E77E7DB880E4DAD2CD66367B9D040`;PPT 用法=`10884/E9ABE70093728A92653CF8C8E14C59725E5CDF9BD5285648695F0B178E78BA3B`。
+- 验证:py_compile PASS;PowerShell parser PASS;两个 CLI help PASS;F3-R1/F4-R1 聚焦 `2/2 PASS / 31.409 s`;目标=`56/56 PASS / 38.662 s`;project discover=`63/63 PASS / 39.843 s`。H.264/H.265 四条已冻结合成链继续 PASS,业务摘要仍为 `19 samples / 5 candidates / 3 pages / 6,10,18 s` 且 PNG/PDF/RGB/顺序/尺寸一致。
+- 业务源读取=`0`、业务 FFprobe/FFmpeg=`0`、真实片段生成=`0`、真实媒体运行=`0`、长媒体运行=`0`;无媒体 Run 桩只执行临时复制的 Python 可执行文件,不读取或解码任何媒体。当前状态=`PENDING_F3_R1_F4_R1_ONLY_REREVIEW`;正式 PASS 前真实媒体授权继续为 `DENIED`。
+
+## 2026-08-04T14:48:50+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-F3-R1-R1-REPAIR-20260804-001
+
+- F3-R1/F4-R1 限定复审=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-F3-R1-F4-R1-REREVIEW-RESULT-20260804-001 / HOLD_1_FIX_F3_R1_R1_OFFLINE_ONLY`;审计=`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-F3-R1-F4-R1-REREVIEW-20260804-001`,`203157 bytes / B10786D4DB9B5CB93194687078BCC0026B505ABC2A2B85245F69F6C41E93C31B`。F4-R1 与 F3 argv/output-format 子项正式关闭,唯一问题为 3 秒无媒体扫描桩可能在 PowerShell 冷启动计数器初始化完成前退出。
+- 最小修复仅涉及测试侧采样器与对应 Run 测试:Processor 性能计数器创建及首值预热、GPU Process Memory 类别创建及实例枚举全部移到 `$Process.Start()` 前;随后向被测 stub 注入 `MBX_MEDIA_SAMPLER_READY=1` 继承标记,stub 未取得握手即明确失败。扫描存活期改为 5 秒只用于覆盖 500 ms 采样和退出观察,不再承担覆盖冷初始化的职责。
+- Run 测试增加静态顺序断言,冻结四项阻塞初始化/预热和 ready 标记均先于目标启动。修复后同一用例以三个全新 PowerShell 进程连续运行 `3/3 PASS`;每次仍证明唯一扫描 PID、退出后同周期、后续逐页不延长、伪 NVDEC stdout/CPU 回退不能假通过,以及 target exit=`0` / sampler exit=`3`。
+- 回归:PowerShell parser PASS;目标=`56/56 PASS / 41.771 s`;project discover=`63/63 PASS / 42.872 s`。H.264/H.265 四条冻结合成链继续为 `19 samples / 5 candidates / 3 pages / 6,10,18 s`,PNG/PDF/RGB/顺序/尺寸一致。
+- 本轮实物:PPT test=`73191 bytes / E9654F1B5D8832CE0CA925EC391BB3E733EA325DEE1B8D193CCD3612A9B63981`;sampler=`44142 bytes / E5909D0E3A3DEA875755E054EB10F78A530A54D08946C6DFE18AFFA52E429DF2`;PPT 用法=`11125 bytes / 53D824CB5B371F7C2A913842DB2C19C4AE0374FF848E2DDC0B6439D790926705`。两个产品脚本、转写测试与转写用法均未修改。
+- 业务源读取=`0`、业务 FFprobe/FFmpeg=`0`、真实片段生成=`0`、真实媒体运行=`0`、长媒体运行=`0`。当前状态=`PENDING_F3_R1_R1_ONLY_REREVIEW`;正式 PASS 前真实媒体授权继续为 `DENIED`。
+
+## 2026-08-04T15:39:19+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-BOUNDED-REAL-ACCEPTANCE-FAIL-20260804-001
+
+- F3-R1-R1-only 独立复审=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-F3-R1-R1-REREVIEW-RESULT-20260804-001 / PASS_0_AUTHORIZE_ONE_BOUNDED_REAL_PPT_ACCEPTANCE`;审计=`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-F3-R1-R1-REREVIEW-20260804-001`,`208317 bytes / 4C042AB86925332F810D45099E06F5AD8BDF9152CE26DAE4AFEEF7FB37F1C806`。授权严格限定为一个 `<=600 s` 独立片段和一次 PPT 资源运行,失败不得重试。
+- 原始业务源运行前后均为 `1268392019 bytes`、CreationTimeUtc=`2026-07-31T09:58:40.0836272Z`、LastWriteTimeUtc=`2026-07-31T09:58:56.6691997Z`、SHA-256=`3A6F62815602C10B6D31A3A53DDBF6D8D5176DCF614EF30E03E32C12F113C9D6`。只读流复制从 00:30:00 生成唯一独立片段 `source-1800-2390.mp4`;FFmpeg 完成行=`frame=17973 / time=00:09:50.04 / Lsize=25176 KiB`。PowerShell 5 `Start-Process.ExitCode` 为空令外层包装误报 1,但未重跑;只读 FFprobe 与实物确认片段=`H.264 2560x1440 yuvj420p + AAC`、`593.120 s / 25780542 bytes / 93F2208DB24501F591CB8719D1A350417ACAB7C93C12EFE84026D04247E1211F`,满足授权上限。
+- 唯一 PPT 资源运行:sampler exit=`3`,target exit=`0`,外层/目标耗时=`50.646/42.751 s`。输出 `ppt-output.slides` 含连续 7 张 RGB `2560x1440` PNG 和 7 页 PDF;PDF=`8902354 bytes / B0FC369A43F07E6A00A1966C11C7AECE7A736925E6F0995D4CFB5FAC395AC049`,暂存目录残留=`0`,片段前后四项指纹相等。
+- 实际资源数值:扫描 PID=`53652`,完整 argv 证明 `-hwaccel cuda -hwaccel_output_format cuda -c:v h264_cuvid`、输入前 4/2 线程及 `hwdownload`;GPU decoder 峰值=`100%`,GPU utilization=`30%`,全局显存=`2244 MiB`,任务专用显存=`386.605469 MiB`;有效扫描整机 CPU 单样本峰值=`18.205965%`,5 秒加权最高=`13.063579%`,高 CPU 段=`0`;任务 CPU=`1.890033%`,工作集=`214577152 bytes`,优先级证据 PASS。
+- 唯一阻断=`RESOURCE_SAMPLER_EVIDENCE_INVALID`:首两个完整 5 秒窗各只有 9 个成员,违反每窗至少 10 样本;扫描退出后逐页 FFmpeg 的 `Get-CimInstance` 消失竞态在采样器第 675 行产生 6 个 `CommandLine` 缺失错误,并使末行 previous/interval 不一致;原始 stdout 与 argv 可读确认 NVDEC,但 `run_summary.nvdec_command_log_evidence=false`。因此正式 `sampling_valid=false`、`cpu_gate_pass=false`、`resource_acceptance_pass=false`,不得把数值观察改写为验收 PASS。
+- 核心证据:`resource_samples.csv=162404/F85B2B90EF2F57245AC5BFD625AB7C3C09F09B3B06F11855D3E460561B58A839`;`resource_windows.csv=9264/E33DA3CF90AD23360183661E263E528185A13295BB5B7442D7E8D2FFC5E532C1`;`resource_summary.json=2650/4479CCB89A152693DCC39EF74569489E457440A896782BE7D0B02896545AAE29`;`run_summary.json=3257/A088EA2F244283CF996D4AE7B3D8B1AC3BD1E28C1B4DA112E22D8C37498C4045`;`stdout.log=503/E3D4B6AAE098D3B18B1C4B07BADA59048E6615CF55B8C6A213AEE2B9F7E4696F`;`stderr.log=0/E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855`。
+- 真实片段生成=`1/1`,真实 PPT 运行=`1/1`,重试=`0`,调参=`0`,并行转写=`0`,长媒体运行=`0`。当前状态=`BLOCKED_RESOURCE_SAMPLER_EVIDENCE_INVALID_NO_RERUN`;保留片段、输出与全部失败证据,等待请求方决定是否另行授权单一采样器修复,当前回合不得自行再跑。
+
+## 2026-08-04T16:40:17+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-SAMPLER-ONLY-REPAIR-20260804-001
+
+- 请求方只读确认 r1 正式终态保持 FAIL,并以 `HANDOFF-MEDIA-INFODEV-MEDIA-HOST-RESPONSIVENESS-SAMPLER-REPAIR-AUTH-20260804-001` 只授权 `monitor_media_host_responsiveness.ps1`、对应离线/桩测试和必要说明;两个产品脚本、r1 媒体/输出/证据及业务合同继续冻结。独立复审 PASS 前禁止 r2。
+- 首窗密度:目标启动前新增 CIM Win32_Process、nvidia-smi GPU/decode 与 compute-process 预热;500 ms 调度若因一次允许范围内阻塞落后,只把下一采样安排为 `elapsed+1 ms` 做单次受控追赶,不修改目标周期、每窗至少 10 样本、最大间隔 1.5 秒、85% 或 10 秒门限。桩的完整窗口不再出现 9 成员。
+- FFmpeg 消失竞态:对新 PID 查询命令行前后均以进程存活性定界;只有 Toolhelp 已见 FFmpeg 且错误后二次确认进程不存在时才累加 `command_line_disappearance_race_count` 并继续,进程仍存活但命令行缺失/为空仍原样抛出并使采样失败。进程属性读取和根进程末态 priority 采用同一“已退出才容忍”的严格边界。
+- CSV 一致性:`PreviousCounterTimestamp` 与 counter warm-up 状态只在样本完整加入 `$Samples` 后提交;桩在扫描结束后的逐页阶段注入一次采样异常,下一条 CSV 的 `previous_valid_timestamp_utc` 与 `interval_seconds` 仍逐行严格对应上一条已提交 timestamp,分析器不再产生 previous/interval mismatch。
+- NVDEC 证据:stdout/stderr 改为 `ReadToEndAsync()`,目标退出后完整取得并一次写入 UTF-8 无 BOM;门禁以 ASCII `NVDEC/CUDA (<decoder>)` 匹配,避免 Windows PowerShell 5 把无 BOM 脚本内中文字面量按 ANSI 解释。桩证明日志证据 true 但缺 `-hwaccel_output_format cuda` 的 argv 仍 false,资源结果不能被伪日志绕过。
+- 无媒体 Run 桩包含 24 个短命逐页 FFmpeg、100 ms 测试侧 CIM 延迟和一次提交前异常,稳定性全新进程连续 `3/3 PASS`;目标=`56/56 PASS / 52.007 s`,project discover=`63/63 PASS / 47.063 s`。H.264/H.265 四条冻结合成链继续为 `19 samples / 5 candidates / 3 pages / 6,10,18 s`,PNG/PDF/RGB/顺序/尺寸一致。
+- 本轮实物:sampler=`46515 bytes / A53BA7CD9806028460CF60AE6025624EB9BFA42E209A991CF1DDE7E899785177`;PPT test=`76255 bytes / 9FF695DAC3B819E7F3DCDF2EB48EE0EDB37D6E625BAC7F248BFAB9C4A537BC19`;产品冻结未变:extract=`38070/DCA04653CB349CB6DE449A6D291344E3E5788FB035F07A6BB2670EBE1F11D30A`,transcribe=`25245/0A485447578DB24DEA84CB887041F6D0CCB3E00F3F40745965228F2D06EEED8F`。
+- r1 目录及核心哈希未改,原始业务源读取=`0`、真实片段生成=`0`、真实媒体运行=`0`、重试=`0`、调参=`0`。当前状态=`PENDING_SAMPLER_ONLY_INDEPENDENT_REREVIEW`;复审 PASS 前不执行 r2。
+
+## 2026-08-04T17:37:28+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-R2-REAL-ACCEPTANCE-PASS-20260804-001
+
+- sampler-only 独立复审=`HANDOFF-INFOREV-INFODEV-MEDIA-HOST-RESPONSIVENESS-SAMPLER-ONLY-REPAIR-REREVIEW-RESULT-20260804-001 / PASS_0_AUTHORIZE_EXACTLY_ONE_R2_WITH_EXISTING_CLIP`;审计=`DEV-AUDIT-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-SAMPLER-ONLY-REPAIR-REREVIEW-20260804-001`,`214675 bytes / B88C36B15E276F4AD3B4BF498BB0AAFFFEBE386B91B5472E3F5E132C02CF654B`。授权只允许复用冻结片段一次,新建 r2 输出/证据,不得重截、覆盖 r1 或重试。
+- 运行前冻结片段=`25780542 bytes / 93F2208DB24501F591CB8719D1A350417ACAB7C93C12EFE84026D04247E1211F`;r1 四份核心证据哈希逐项匹配。r2 路径原先不存在,运行后片段四项指纹相等;没有读取或运行原 3.57 小时源,也没有生成新片段。
+- 唯一 r2:sampler exit=`0`、target exit=`0`,外层/目标耗时=`50.642/42.191 s`,产品 stdout 耗时=`41.578 s`;sample_count=`84`、sampling_errors=`0`、expected FFmpeg disappearance races=`2`,`sampling_valid=true`、`cpu_gate_pass=true`、`resource_acceptance_pass=true`。
+- CPU:有效扫描样本=`73`,扫描区间=`2026-08-04T09:35:58.2827894Z..09:36:34.7916998Z`;完整 5 秒窗口=`63`,最大间隔=`0.515724 s`,最差加权窗口=`11.546972%`,单样本整机 CPU 峰值=`16.135574%`,高 CPU 段=`0 s`。任务 CPU 峰值=`2.071035%`,工作集峰值=`214863872 bytes`。
+- GPU/解码:扫描 PID=`54576`,decoder=`h264_cuvid`;日志证据与完整 argv 证据均 true,argv 含 CUDA、output-format、cuvid、输入前 4/2 线程及 hwdownload;GPU utilization 峰值=`34%`,decoder 峰值=`100%`,全局显存=`2678 MiB`,任务 PID 专用显存=`380.605469 MiB`。根 Python 与扫描 FFmpeg BelowNormal=`PASS`。
+- 输出=`real-590s-r2/ppt-output.slides`:连续 7 张 RGB `2560x1440` PNG 和 7 页 PDF;逐文件 SHA-256 与 r1 产品输出完全相同。PDF=`8902354 bytes / B0FC369A43F07E6A00A1966C11C7AECE7A736925E6F0995D4CFB5FAC395AC049`,stderr=`0 bytes`,暂存残留=`0`。
+- r2 核心证据:`resource_samples.csv=195963/FB203E513E13426145AFECF988008CDB26E05424611FEAC87174559E98F19280`;`resource_windows.csv=9089/CE768653C2C44077E34968993EBF1447169FB1421097D362E85CC063C9A769CD`;`resource_summary.json=2351/8216FDA3840FF3E39538121BF781B267A8C9B2ED3651AE528B0AD89822A5D140`;`run_summary.json=1633/ADDA61B3EDF44659E7BAD766B107AD8B768250A7C91407C694AB14C667A5DD87`;`stdout.log=506/D45DBA936311CC63B75B1D46889BFE6843D28339904B43C63F5B9E3AE1D490D6`;`stderr.log=0/E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855`。
+- r1 核心证据仍为 `F85B2B90...58A839 / E33DA3CF...32C1 / 4479CCB8...E29 / A088EA2F...4045`,未覆盖失败事实。r2 运行=`1/1`,重试=`0`,调参=`0`,新片段=`0`,长媒体=`0`,并行转写=`0`。当前状态=`PENDING_REQUESTER_FINAL_R2_ACCEPTANCE`;不得再运行。
+
+## 2026-08-04T17:46:31+08:00 DEV-LOG-PROJECT-INFO-MEDIA-HOST-RESPONSIVENESS-REQUESTER-FINAL-ACCEPTANCE-CLOSED-20260804-001
+
+- 请求方收口消息=`msg_20260804174532890_a5237269`;handoff=`HANDOFF-MEDIA-INFODEV-MEDIA-HOST-RESPONSIVENESS-R2-FINAL-ACCEPTANCE-CLOSURE-20260804-001`;状态=`ACCEPTED_CLOSED`。
+- 请求方以只读方式独立复算并确认:r2 sampler/target exit=`0/0`,84 条样本、63 个完整窗口、sampling errors=`0`,三项门禁均 true;最大采样间隔=`0.515724 s`,最差 5 秒 CPU=`11.546972%`,高 CPU 段=`0 s`。
+- NVDEC=`h264_cuvid`,日志与 argv 证据均有效,decoder 峰值=`100%`;扫描进程均 BelowNormal。7 张 RGB PNG 与 7 页 PDF 和 r1 成功产品输出逐文件一致,源/片段未变,暂存残留=`0`。
+- 请求方确认 r1 历史 FAIL 四份核心证据保持原哈希、未覆盖;r2 唯一运行次数=`1/1` 已用尽。最终状态=`ACCEPTED_CLOSED`,保留产品代码、片段、r1 FAIL、r2 PASS 与全部证据;不再运行媒体、重试、调参、修改实现或扩大范围。
+
+## 2026-08-04T22:18:49+08:00 DEV-LOG-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-20260804-001
+
+- 上游=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;需求=`ai-video-downloader/draft/2026-08-04-哔哩哔哩动态采集需求.md`;owner=`dev.developer.project.secondary / infodev-2`;reviewer=`dev.reviewer.project / inforev`。
+- 分类:一个 stdlib 本地 CLI 的需求支持轻量 MVP,不写重型方案;实现只协调无认证导出和本地文件,不联网、不控制浏览器、不下载内容、不发送任务消息。
+- 实现:`dev/project-dev/bili_dynamic_collector.py`;示例=`dev/project-dev/bili_dynamic_collector.example.json`;测试=`dev/project-dev/test/test_bili_dynamic_collector.py`;fixture=`dev/project-dev/test/fixtures/bili_dynamic_collector/dynamic_items.json`;用法=`dev-doc/project-doc/B站博主动态采集辅助工具.md`。
+- 合同:`check` 完成时间窗、稳定标识去重、Windows stem、待办和 manifest;`move-completed` 完成映射唯一性、下载完成度/范围/链接/重名预检、no-overwrite 提交、SHA-256、失败事件与源删除;`handoff` 复核本地文件与 hash 后只生成 `sent=false` 草稿。
+- 状态:活动/成功项幂等跳过;`MOVE_FAILED` 可由下一次 check 追加 `TODO_QUEUED` 重试;源删除失败保留可信目标并以 exit 4 / `VIDEO_MOVED_SOURCE_RETAINED` 显式报告。
+- 离线验证:py_compile PASS;CLI help PASS;目标=`6/6 PASS / 0.610s`;project flat discover=`69/69 PASS / 49.873s`;diff check PASS;治理=`OK/projects=1/warnings=0`。
+- CLI smoke:首次 check=`TODO_GENERATED/input4/new3/old1`;55 字节非媒体 fixture move=`COMPLETE/VIDEO_MOVED/SHA-256 9F3A05E0...AE255`;handoff=`HANDOFF_DRAFT_READY/sent=false`;相同输入复跑=`NO_NEW_ITEMS/new0/duplicate3/old1`。同 state 重叠调用一方按 `E_STATE_LOCKED` 停止,随后无残留锁且串行成功。
+- 证据=`dev/tmp/bili-dynamic-collector-mvp/selfcheck.md`;工作记录=`ai-infodev-2/worklog/2026-08-04-bili-dynamic-collector-mvp.md`。
+- 外部动作:网络=`0`、浏览器=`0`、扩展交互=`0`、真实媒体=`0`、`F:\video` 写入=`0`、正式 `ana-data` 读写=`0`;未修改任何既有正式采集内容。
+- 当前状态=`PENDING_SHARED_REVIEWER_LIGHT_IMPLEMENTATION_REVIEW`;正式交付前提交共享审核员一次轻量实现审核。
+
+## 2026-08-04T22:53:23+08:00 DEV-LOG-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-TERMINAL-HOLD-20260804-001
+
+- 独立审核=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-REVIEW-RESULT-20260804-001 / HOLD_5_TERMINAL_SNAPSHOT`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-IMPLEMENTATION-20260804-001`。
+- 独立复验:冻结哈希匹配;py_compile/CLI help PASS;目标=`6/6 PASS / 0.642s`;project=`69/69 PASS / 51.946s`;diff/UTF-8/治理均 PASS;外部动作继续为 0。
+- 阻断 F1—F5:重型状态/事务工具无已审设计;映射选择器和批量同源预检不足;retained-source 警告重跑降级;manifest 秘密 gate 缺失;生成的 native handoff envelope 不完整。
+- 安全终态:只保留隔离开发快照与 dev/tmp/TemporaryDirectory 演示;禁止真实下载移动、生产调度/重叠运行、发送当前 handoff 草稿、正式 `F:\video` 或 `ana-data` 写入。
+- 请求方要求当前审查后立即收尾且不追加功能或评审链;因此不修改代码/测试、不自动开启后继设计或复审。当前状态=`TERMINAL_HOLD_DEVELOPMENT_SNAPSHOT_ONLY`。
+
+## 2026-08-05T00:35:00+08:00 DEV-LOG-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-20260805-001
+
+- 新授权:请求方在上一终态后明确“继续推进”,仅修 HOLD/5;owner/reviewer 保持 `dev.developer.project.secondary` / `dev.reviewer.project`。
+- 设计门禁:V001 与 V002 各经限定 `HOLD/1` 收敛,V003 最终回执=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-V003-ALLOWLIST-DESIGN-REVIEW-RESULT-20260805-001 / PASS/0`;PASS 前代码/配置/测试/fixture 未修改。
+- 实现:mapping 每个 selector 必须独立命中并共同一致;全批 entity 唯一先于状态,只有 TODO_QUEUED/MOVE_FAILED 做源/目标预检;同物理源拒绝且预检零业务副作用。VIDEO_MOVED 与 retained 终态不依赖现存源,retained 重跑固定 exit 4。manifest 三命令共同递归 secret gate。native handoff 配置精确 route、内容寻址 ID、完整 envelope 且不自动发送。
+- 实物:code=`56152/9B5C6FCE647C8E18822AB53488A57B6048A09997B62024B8FD2B593F0196DABF`;config=`1117/42A9AA7EE29ED061DC408C6C9A907FA7B20FD8C2B41605C54B6716A0FE388951`;tests=`24767/684F78F944F045C1352E4C09BFAFE598B7B5E9B5195EC944F5AF87D70EACC0D3`;fixture 保持=`1216/0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F`。
+- 验证:py_compile/CLI help PASS;目标=`11/11 PASS / 1.393s`;新鲜 TemporaryDirectory 闭环=`1/1 PASS / 0.226s`;project discover=`74/74 PASS / 48.657s`;diff check PASS。
+- 证据=`dev-doc/project-doc/B站博主动态采集辅助工具HOLD5修复实现与验证证据.md`;自检=`dev/tmp/bili-dynamic-collector-mvp-hold5-repair-20260805/selfcheck.md`。
+- 外部动作:network/browser/extension/download/real media/schedule/native send/`F:\video`/formal `ana-data` 全部 0。当前=`PENDING_LIMITED_IMPLEMENTATION_REREVIEW`。
+
+## 2026-08-05T01:05:00+08:00 DEV-LOG-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-REVIEW-20260805-001
+
+- 限定复审回执=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-IMPLEMENTATION-REVIEW-RESULT-20260805-001 / HOLD/2`;审计入口=`DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD5-REPAIR-IMPLEMENTATION-20260805-001`。
+- 独立复验:目标=`11/11 PASS / 1.426s`;新鲜闭环=`1/1 PASS / 0.228s`;project=`74/74 PASS / 48.897s`;其余静态/治理检查 PASS;外部动作 0。
+- 阻断:terminal projection 未验证状态所需 local_file/sha256/failure_reason;handoff 对 manifest entity_id/bvid/published_at 等 raw scalar 未阻止换行/控制字符注入。
+- 其余重型门禁、选择器/批量预检、完整字段 retained 粘性、manifest secret gate、native route/稳定 ID/sent=false 均 PASS 并冻结。
+- 本轮按上游要求返回明确剩余阻断,不自动续开修复或复审。状态=`HOLD_2_ISOLATED_DEVELOPMENT_SNAPSHOT_ONLY`。
+
+## 2026-08-05T09:20:00+08:00 DEV-LOG-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-REPAIR-20260805-001
+
+- 上游持续授权=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001 / CONTINUE_NOW`;只闭环前序实现复审 F1/F2,不新增设计或外部能力。
+- code 新增共享 manifest scalar/terminal validator:视频 moved/complete 必需非空无控制字符 local_file 和 64 位小写 SHA-256,retained 另需 failure_reason;load 与 projection 双门禁。
+- handoff 在创建前验证 internal entity ID、可选 BV、canonical UTC 时间、title/source URL/local path 与 SHA-256;CR/LF/控制字符或格式错误统一 `E_MANIFEST`,错误正文只含字段路径。
+- tests 新增 terminal 9 组和 handoff 10 组参数化 CLI/main 反例;全部断言 exit 3、SAFETY_STOP、raw manifest/业务树/目标不变、无 lock/partial/event,污染值不进 stdout。
+- 冻结:code=`60090/67B2E6FB34F100C57BF50A4D3067D6FDD6E47C5D3FBEF58D2CF5D8796B07AB8B`;tests=`31303/D82BFF8AD9B78FD749A7536FF9FED0486CF87D6B9FDDEE7947202C2414AAE322`;config=`1117/42A9AA7EE29ED061DC408C6C9A907FA7B20FD8C2B41605C54B6716A0FE388951`;fixture unchanged=`1216/0CD53C7C93D7E05B4A913EBF5223366E3A06A17E2474DF52BC64F0BB4EBEA71F`。
+- 验证:py_compile/help PASS;目标=`13/13 PASS / 2.294s`;新鲜闭环=`1/1 PASS / 0.227s`;project=`76/76 PASS / 61.424s`;diff PASS。
+- 外部动作:network/browser/extension/download/real media/schedule/native send/`F:\video`/formal `ana-data` 全部 0。当前=`PENDING_HOLD2_LIMITED_IMPLEMENTATION_REREVIEW`。
+
+## 2026-08-05T15:36:35+08:00 DEV-LOG-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-PASS-20260805-001
+
+- 限定复审回执=`HANDOFF-INFOREV-INFODEV2-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-REREVIEW-RESULT-20260805-001 / PASS/0`;阻断数=`0`。
+- 审核员独立确认:原阻断反例=`2/2 PASS / 0.919s`;目标=`13/13 PASS / 2.452s`;project=`76/76 PASS / 58.021s`;py_compile/help/diff/UTF-8/治理均 PASS。
+- 审计入口=`DEV-AUDIT-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-HOLD2-FAIL-CLOSED-LIMITED-IMPLEMENTATION-REREVIEW-20260805-001`;落盘时审计=`250254/43FD12548388E3760DCB52644273E54318591D44CE3D83E42013E1699F8D64B2`。
+- 结论:冻结实现可作为项目级本地辅助工具交付;无需网络、浏览器、下载、真实媒体、native send、调度或正式路径重跑。上述外部动作和路径仍未授权。
+
+## 2026-08-05 DEV-LOG-PROJECT-INFO-BILI-DYNAMIC-COLLECTOR-MVP-ACCEPTED-CLOSED-20260805-001
+
+- 请求方回执=`HANDOFF-VIDEO-DOWNLOADER-INFODEV2-BILI-DYNAMIC-COLLECTOR-PASS0-ACK-20260805-001 / ACCEPTED`;关联开发交付=`HANDOFF-INFODEV2-VIDEO-DOWNLOADER-BILI-DYNAMIC-COLLECTOR-MVP-PASS0-FINAL-20260805-001`。
+- 请求方确认两项 fail-closed 阻断、独立复审和证据链完整;事项=`ACCEPTED_CLOSED`,无需继续开发。
+- 请求方声明当前博主真实浏览器/下载、`F:\video\青枫浦上Q`、正式 `ana-data`、原生转写交接和每小时调度已有用户与项目配置授权;开发员未在本轮执行任何上述动作,也不以工具替代授权来源。
+- 后续新缺陷由视频下载员另行回传。
+
+## 2026-08-05T16:37:38+08:00 DEV-LOG-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-IMPLEMENTATION-20260805-001
+
+- 上游=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;事项=`DEV-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-20260805-001`;owner=`dev.developer.project`;requester=`case_analysis.video_downloader`。
+- 现场复现:Chrome 登录态/普通动态页可用,复杂视频页控制连续超时;本机扩展服务 health=ok,但公开后端 `mini_transcriber.py` 只下载最佳音频并转 MP3/TXT,无法形成完整视频原件。
+- 实现=`dev/project-dev/bili_video_download_bridge.py`,`20379 bytes / DD479D9E4531AEFFEAE0666C847140B1DFFF12CF142D95C197F729635635C489`;示例=`410 / 1581168C73BD0C320E46D42FE104CCB3594A3BF3D216098671794B2FB69868CC`;测试=`11154 / 231A768D4515B0C55719E701771EC75CEA4387958302C68D7065860102971334`;用法=`3775 / 67D3946E75D1C7A4BFA80958CF29DD6C46576BDB07062B426C3E6726CF63456C`。
+- 环境=`D:\Anaconda\envs\project-info-bili-download-bridge`,Python 3.12.13、yt-dlp 2026.7.4、现有 FFmpeg/FFprobe。2026.3.17 两条均 HTTP 412;升级后同一无 Cookie、`--simulate --dump-single-json` 探测=`2/2 PASS`,标题与时长分别为“A股缩量磨底阶段。”/1206.067 s、“流水淘沙不暂停,前波未灭后波生。(周复盘)”/3133.95 s。
+- 验证:py_compile PASS;CLI help PASS;目标 unittest=`11/11 PASS / 0.140 s`;diff check PASS;真实下载=`0`、媒体分片=`0`、`F:\video\青枫浦上Q` 写入=`0`、正式 `ana-data` 写入=`0`、Cookie/token/密码/验证码读取=`0`。
+- project discover=`86/87`;唯一失败来自既有 `monitor_media_host_responsiveness.ps1` 采样时序的 5 秒窗口密度波动,新增工具目标测试全过且没有引用该采样器;随后其所属测试文件运行时该测试本体 PASS。该既有波动提交审核员判断,不由本事项越界修改。
+- 同一冻结代码再次执行完整 project discover=`87/87 PASS / 55.019 s`;首轮波动未复现,产品代码、示例、测试和用法哈希均未变化。
+- 当前状态=`PENDING_SHARED_REVIEWER_LIGHT_IMPLEMENTATION_REVIEW`。通过后由视频下载员运行正式下载命令并现场核对两个完整媒体及映射;开发线程不代替请求方写正式目录。
+
+## 2026-08-05T17:04:33+08:00 DEV-LOG-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-REVIEW-RUNTIME-BLOCKER-20260805-001
+
+- 审核请求=`msg_20260805164245936_5d9bf839`;补充=`msg_20260805164941694_3d8f614c`,均已可靠写入 `dev.reviewer.project` inbox,visible dispatch 因会话不可见进入保留队列。
+- 无指定消息的 agent step=`run_20260805164511261_6cc511a6 / FAIL`;定向本审核消息=`run_20260805170433690_ebe31350 / FAIL`。共同根因:审核线程 rollout `C:\Users\Cai\.codex\sessions\2026\07\31\rollout-2026-07-31T18-43-46-019fb7c5-e1fd-7df0-8ec9-c87edea4215d.jsonl` 不以 session metadata 开始,Codex thread-store 在审核执行前停止。
+- 管理恢复请求=`msg_20260805170128438_316f3ac7` 已可靠入箱;开发员未清空队列、未修复审核会话、未自审,也未执行真实下载。
+- 同一冻结实现 project 完整回归=`87/87 PASS / 55.019 s`。当前按上游轻量交付合同转请求方普通功能验收,同时明确独立审核尚无结论。
+
+## 2026-08-05T17:32:45+08:00 DEV-LOG-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-DURATION-FAIL-CLOSED-20260805-001
+
+- 请求方真实证据:`BV1HA3o6oEJJ` 远端=`3133.95 s`,旧实现发布媒体=`600.133313 s`,属于会员预览误标 COMPLETE;请求方已可恢复隔离,转写=`0`。第一条 `BV1DVMX6XEPq=1206.067664/1206.067 s` 已验收交接,不在返修动作范围。
+- 根因:旧 `probe_media_file()` 只要求本地时长大于零、存在视频/音频流,没有把 `probe_item()` 的远端总时长带入正式发布门禁。
+- 修复:`validate_duration_consistency()` 固定 `max(3.0 s, remote*0.001)`;差值超限在 `_preflight_destination()`/`os.link()` 前抛 `ValidationError`。映射新增 `remote_duration_seconds/local_duration_seconds/duration_delta_seconds/duration_tolerance_seconds`。
+- 边界:probe 同格式选择但只标 `METADATA_PASS_ONLY`;playlist/entries 与 `needs_auth/premium_only/private/subscriber_only` fail-closed;600.133313 vs 3133.95 端到端桩正式输出=0、staging=0;1206.067664 vs 1206.067 通过。
+- 验证:py_compile/diff PASS;目标=`15/15 PASS / 0.093 s`;实际无下载 probe=`2/2 METADATA_PASS_ONLY`;project 首轮因并行真实转写占用 mutex 明确失败 3 项,未停止外部任务;释放后=`91/91 PASS / 54.225 s`;治理=`OK/projects1/warnings0`。
+- 外部动作:再次真实下载=0;`F:\video\青枫浦上Q`/`.quarantine` 读写=0;正式 `ana-data`=0;Cookie/token/密码/验证码=0。当前=`PENDING_DURATION_FAIL_CLOSED_INDEPENDENT_REVIEW`。
+
+## 2026-08-05T18:32:58+08:00 DEV-LOG-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-DURATION-GATE-PASS-DELIVERY-20260805-001
+
+- 限定复审回执=`HANDOFF-INFOREV-INFODEV-BILI-BRIDGE-DURATION-GATE-REREVIEW-RESULT-20260805-001 / PASS`;`blocking_findings=0`。
+- 审计=`DEV-AUDIT-PROJECT-INFO-BILI-CHROME-DOWNLOAD-BRIDGE-DURATION-GATE-LIMITED-REREVIEW-20260805-001`;审计文件=`dev-doc/开发审计报告.md`;复审时=`254598/9AF601032E09E09F43E0624D8D0385080EEB89DB9D6B1E030866D08EAD4923BA`。
+- 冻结 code=`22229/541CD9A57E23976614B80CF731C6D4D49361A29BFBC029E9053C8E7093EC18F1`;tests=`15416/BDE24A5BE26BC10F534EA1BF7235B8066EDA6425BB432C06AC67BBD560EFA168`。
+- 审核员独立验证:目标 `15/15 PASS`,项目 `91/91 PASS`,py_compile、CLI help、目标 diff、严格 UTF-8 无 BOM、治理均 PASS;network/Chrome/extension/download/real media/`F:\video`/formal `ana-data`/credentials 全部 0。
+- 交付:通过原角色链向 `case_analysis.video_downloader` 发送 `msg_20260805183258435_048db111`;inbox 写入成功,调度状态因目标无可用可见会话为 queued。
+- 勘误:首条交付消息误写代码/测试文件名;已发送纠正消息=`msg_20260805183550141_a01a1463`。正确路径=`dev/project-dev/bili_video_download_bridge.py` 与 `dev/project-dev/test/test_bili_video_download_bridge.py`;只读复核仍为 `22229/541CD9A57E23976614B80CF731C6D4D49361A29BFBC029E9053C8E7093EC18F1`、`15416/BDE24A5BE26BC10F534EA1BF7235B8066EDA6425BB432C06AC67BBD560EFA168`。
+- 终态:限定复审已关闭为 `PASS/0`,冻结修复快照已交付并等待请求方接收回执;不新建复审、不重跑真实下载。600 秒预览继续排除且不得转写,第一条已验收视频保持不变。
+
+## 2026-08-05T18:47:00+08:00 DEV-LOG-STOCK-VALUATION-LEDGER-MVP-IMPLEMENTATION-20260805-001
+
+- 事项=`DEV-STOCK-VALUATION-LEDGER-MVP-20260805-001`;owner=`dev.developer.project.secondary / infodev-2`;需求=`ana-doc/股票估值/股票估值每日台账业务需求_v1.0.md`。
+- 实现:标准库 SQLite 四表;CLI=`init/daily/list/show/export`;V1 四档边界直接对照;V2 `acquire_market` 行情桥;原子 `latest.md/latest.csv`;单次 `run_daily.ps1`。
+- 正式 83 家:CLI 冷=`83 inserted/0 gaps/0.7603499s`;热=`0 inserted/83 unchanged/0 gaps/0.7317935s`;离线 fixture 日更=`83 price/83 judgement`、四标签全覆盖、重复无新增。
+- 正式全量本地 `init`:`125 security/136 valuation_version/1 deterministic duplicate gap`;重复=`0 inserted/136 unchanged`。运行库=`163840/48AF0F2277FC43D98480D330BE49A43173404EDEDC2AF3666CBA473AAD24699C`,由同目录 `.gitignore` 排除。
+- 验证:目标=`15/15 PASS / 5.502s`;project=`106/106 PASS / 59.985s`;V1=`10/10 PASS / 0.171s`;V2=`59/59 PASS / 139.369s`;compileall/help/计划任务静态门禁 PASS;200 家暖查询断言 `<1s`。
+- 文本与治理:交付范围 strict UTF-8、BOM 0、行尾空白 0、diff check PASS;`mbx validate=OK/projects=1/warnings=0`。
+- 正式联网=`0`,因此 `daily_price/daily_judgement=0` 且未生成正式 `latest.md/latest.csv`;没有把离线价冒充正式最新价。计划任务安装=`0`;`ana-dev` 修改=`0`。
+- 工程说明=`dev-doc/project-doc/股票估值每日台账.md`;实现证据=`dev-doc/project-doc/股票估值每日台账实现与验收证据.md`;worklog=`ai-infodev-2/worklog/2026-08-05-stock-valuation-ledger-mvp.md`。
+- 当前=`PENDING_SINGLE_IMPLEMENTATION_OUTPUT_REVIEW`;下一步只提交共享审核员一次必要审核。
+
+## 2026-08-05T20:55:01+08:00 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BACKHALF-IMPLEMENTATION-20260805-001
+
+- 上游=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;事项=`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;owner=`dev.developer.project`;请求方=`case_analysis.video_downloader`。
+- 只读控制面结论:Chrome 目标页可完整播放 `52:14` 且显示“充电中”,但 `locator.downloadMedia` 无下载事件,`cua_download_media` 不受 Chrome 支持,页面资产无完整媒体;临时诊断 tab 已 finalize,真实下载=`0`。
+- 扩展公开源码回放=`2d4d91b5426f374c6cae6741afc2b06dcd6d1f5b`:唯一 side panel 按钮链为“添加任务 → 进行中/失败重试 → 已完成 → 下载 TXT”;后端固定下载最佳音频、转 MP3,结果 API 只返回 TXT,并会处理会话 Cookie,不能在本事项安全边界内提供完整音视频原件。
+- 唯一外部入口阻断:已安装 Chrome/扩展不存在既受支持、又不读取/导出/记录 Cookie、token 或签名媒体 URL的完整 AV 文件出口。TXT、MP3、封面、`.m4s`、partial、600 秒预览和扩展任务完成状态均不得替代完整原件。
+- 最小实现:在既有 `bili_video_download_bridge.py` 增加 `accept-browser-file` 后半程;按 batch+BVID 精确绑定,拒绝 partial/伴随临时文件与增长中文件,源文件只读复制到目标卷暂存,复用公开无凭据 metadata probe、FFprobe AV/时长门禁、双 SHA-256、CreateNew 发布、原子映射和 BaseException 清理。
+- 冻结 code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`。
+- 验证:py_compile/help/subcommand help PASS;目标=`21/21 PASS / 0.141s`;owner 范围 project=`97/97 PASS / 49.818s`。全 project=`97 PASS / 1 ERROR / 54.785s`,唯一错误是 secondary 正在迁移的 `test_stock_valuation_ledger.py` 仍导入旧类 `LedgerTests`,与本事项无调用或修改关系。
+- 静态/治理:目标 UTF-8 BOM=0、尾随空白=0;产品代码无 Cookie/profile/token/signed m4s/正式路径入口;`mbx validate=OK/projects1/warnings0`。
+- 外部动作:认证信息读取=`0`;签名 URL=`0`;下载=`0`;真实媒体=`0`;`F:\video\青枫浦上Q` 写入=`0`;正式 `ana-data` 写入=`0`。当前=`PENDING_LIMITED_IMPLEMENTATION_REVIEW_WITH_SINGLE_INGRESS_BLOCKER`。
+
+## 2026-08-05T21:13:57+08:00 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BACKHALF-PASS-20260805-001
+
+- 限定审核回执=`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-BACKHALF-IMPLEMENTATION-REVIEW-RESULT-20260805-001 / PASS/0`;阻断数=`0`。
+- 审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BACKHALF-LIMITED-IMPLEMENTATION-REVIEW-20260805-001`;入口=`dev-doc/开发审计报告.md:2203`;审核落盘=`268121/CDD56C64617D0D25F715C3DDB389DCB23CD28677C1A8662CBFBA3C8E2B3549B5`。
+- 审核员独立验证:in-memory compile、root/subcommand help、UTF-8/diff/治理均 PASS;目标=`21/21 PASS / 0.143s`;owner 范围 project=`97/97 PASS / 53.846s`;空文件、伴随 `.crdownload`、增长文件、collision、600 秒 preview、KeyboardInterrupt 回滚、映射脱敏/哈希绑定反例全过。
+- 结论仅批准“完整本地 AV 文件已经合法落盘之后”的 `accept-browser-file` 后半程。精确 batch+BVID、稳定/partial/AV/时长、目标卷复制、双 SHA-256、CreateNew 双文件发布、BaseException 清理和公开无凭据合同均通过。
+- `explicit_complete_file_ingress_blocker=PRESERVED`:当前 Chrome/扩展没有满足安全边界的完整文件出口;不得把 TXT、MP3、封面、m4s、partial、扩展完成状态或 600 秒 preview 冒充完整原件。PASS 不新增认证读取、签名 URL、下载、真实媒体、正式路径或转写授权。
+- 当前=`PASS_0_LOCAL_BACKHALF_READY_DELIVER_REQUESTER_WITH_INGRESS_BLOCKER`;不新增审核链。
+- 已通过 Codex 原生任务通信向 `case_analysis.video_downloader / 019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5` 单次发送交付=`HANDOFF-INFODEV-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-BACKHALF-PASS-DELIVERY-20260805-001`,包含精确扩展按钮链、入口停止条件、环境与 `accept-browser-file` 命令;不触发真实下载。
+
+## 2026-08-05T21:32:08+08:00 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BACKHALF-ACCEPTED-CLOSED-20260805-001
+
+- 请求方终态回执=`HANDOFF-VIDEO-DOWNLOADER-INFODEV-BILI-AUTHENTICATED-SESSION-BACKHALF-RECEIPT-20260805-001 / RECEIVED_PASS_INGRESS_BLOCKED`;关联交付=`HANDOFF-INFODEV-VIDEO-DOWNLOADER-BILI-AUTHENTICATED-SESSION-BACKHALF-PASS-DELIVERY-20260805-001`。
+- 请求方只读核对 code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`、tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`、usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`;root/subcommand help PASS,未出现 Cookie/profile/token 参数。
+- 请求方接受 `accept-browser-file` 仅用于完整授权 AV 文件可见下载完成后的安全后半程;`explicit_complete_file_ingress_blocker` 继续有效。当前扩展仍只导出 TXT,受控 Chrome 面仍无完整 AV 下载动作。
+- 本回执真实下载、Cookie 读写、签名 URL、manifest 完成标记、`F:\video` 写入和转写交接均为 0;600 秒 preview 继续隔离并排除。
+- 终态=`ACCEPTED_CLOSED_LOCAL_BACKHALF_INGRESS_BLOCKED`。不新增审核链;以后若合法完整文件出现,由视频下载员运行冻结命令并通过全部门禁后再交媒体处理员。
+
+## 2026-08-05T22:39:30+08:00 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001-DESIGN-20260805-001
+
+- 管理续开=`HANDOFF-INFOADMIN-INFODEV-BILI-PLUGIN-FULL-VIDEO-NARROW-SCOPE-ASSIGN-20260805-001 / APPROVED_NARROW_SCOPE_DESIGN_FIRST_SINGLE_OWNER`;观察回执确认唯一 owner、原 task 续开、无平行实现或审核链。
+- 已只读核对 public extension snapshot commit=`2d4d91b5426f374c6cae6741afc2b06dcd6d1f5b`:当前 TXT 流程会经 localhost/token/SQLite/Cookie 文件处理秘密,不能直接复用为完整视频入口。已核对本机 yt-dlp 2026.07.04 `cookiefile` 支持 text stream,可使用 `io.StringIO` 避免 Cookie 文件。
+- 新建设计=`dev-doc/project-doc/开发方案/CODE-DESIGN-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001.md`。设计固定项目自有相邻 side panel、唯一 origin Native Messaging、无 loopback、内存 CookieJar、单 BVID fail-closed、`bestvideo+bestaudio/best` + FFmpeg stream-copy MKV,并在清除认证上下文后调用冻结 `accept-browser-file`。
+- 产品实现、测试、配置和安装脚本尚未创建或修改;冻结 bridge code/tests/usage 未变。真实 Cookie、signed URL、Chrome/扩展、网络下载、注册表、真实媒体、`F:\video`、正式 `ana-data` 和隔离 preview 动作均为 0。
+- 当前状态=`PENDING_CONSOLIDATED_SECURITY_DESIGN_REVIEW_IMPLEMENTATION_FROZEN`;下一步只向既有 `dev.reviewer.project / inforev` 提交一次合并安全设计审核。
+
+## 2026-08-05T23:09:20+08:00 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-DESIGN-REPAIR-20260805-001
+
+- 审核回执=`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001-DESIGN-REVIEW-RESULT-20260805-001 / HOLD/4`;请求方返修=`HANDOFF-VIDEO-DOWNLOADER-INFODEV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V001-HOLD-RETURN-20260805-001`。
+- V002 固定 manifest public SPKI/hash、expected ID=`oidmclckpdmpabbfedplkbdplmfcenbb`、exact origin、build identity 和完整 page/start/cookie schema;partitioned Cookie fail-closed,store/domain/path/session/expiry 均唯一化。
+- worker 在真实 yt-dlp import 前设置 `YTDLP_NO_PLUGINS=1`,plugin dirs/maps必须为空且在 READY 前不接收 secret;broker 独占协议 handle,worker/yt-dlp/CookieJar/FFmpeg stdout/stderr 全进 `NUL`。
+- 下载固定一次 `extract_info`,经同一 processed/selected `download_info` 验证后直接 `process_info`;只允许真实 HttpFD/DashSegmentsFD,audit hook 在 CreateProcess 前拒绝 URL/header/Cookie argv;双流合并与单文件 fallback 均强制本地 stream-copy MKV。
+- broker reader 与 worker 并发;socket/retry/metadata/总墙钟/grace/kill/join 全部有界;worker suspended 后先加入 `KILL_ON_JOB_CLOSE` Job Object 再 resume,host kill 由 OS 终止全部后代。
+- V002 外部动作:implementation/test/config/install/Chrome/real Cookie/registry/network/download/media/`F:\video`/formal `ana-data` 全部为 0;冻结 backhalf 三文件哈希未变。当前=`PENDING_V002_FOCUSED_DESIGN_REREVIEW_IMPLEMENTATION_FROZEN`。
+
+## 2026-08-05T21:20:11+08:00 DEV-LOG-STOCK-VALUATION-LEDGER-MVP-MYSQL-HOLD4-REPAIR-20260805-001
+
+- 权威变更:需求 owner 将 `ana-doc/股票估值/股票估值每日台账业务需求_v1.0.md` 修订为 MySQL 单后端;管理员决定=`HANDOFF-INFOADMIN-INFODEV2-STOCK-VALUATION-LEDGER-MVP-MYSQL-F1-DECISION-20260805-001`。此前 SQLite/V2 行情桥记录仅为历史前像。
+- 实现:`mysql.connector`;目标库 `stock_valuation` 四表;行情库 `trading_xuntou` SELECT-only;无 SQLite 兼容、无 V2 provider 回退、无 `tianxia` 访问、无 ORM/API/服务。凭据只从环境或不入库本机配置读取且不回显。
+- F1:校验 `dividend_type='none'`、证券/市场/币种、正数有限 close、明确 trade_date、current 正式日历、15:05 最终水位、source/source_time;schema 不足整体写入前阻断,行级不足只跳过该股。实际水位=`2026-02-24`,未冒充当前价。
+- F2/F3/F4:后续估值版本从估值日次日生效;同身份逐不可变字段核对并以 `E_IMMUTABLE_VERSION_CONFLICT` fail-closed;全失败不发布 latest,成功双文件 staging/fsync/backup/replace/rollback 覆盖 OSError/KeyboardInterrupt/SystemExit。
+- 正式 MySQL 冷/热导入=`2.621571s/2.023388s`;计数=`125/136/0/0`、孤儿=0、热导入=`0 inserted/136 unchanged/1 gap`。未执行正式 daily 或 latest。
+- 旧试制 SQLite 在上述核验后按精确路径删除,删除前=`163840/48AF0F2277FC43D98480D330BE49A43173404EDEDC2AF3666CBA473AAD24699C`;专用 `.gitignore` 同步删除;未把旧库用作迁移源。
+- 验证:目标=`19/19 PASS / 82.174s`;project=`116/116 PASS / 145.514s`;compileall/CLI help/PowerShell 5.1 parser/strict UTF-8/no BOM/尾随空白/scoped diff/凭据扫描 PASS;治理=`OK/projects1/warnings0`。
+- 外部网络、V2 provider、正式行情日更、`tianxia`、`trading_xuntou` DDL/DML、计划任务安装、`ana-dev` 修改均为 0。当前=`PENDING_MYSQL_HOLD4_FOCUSED_IMPLEMENTATION_OUTPUT_REREVIEW`。
+
+## 2026-08-05 DEV-LOG-STOCK-VALUATION-LEDGER-MVP-MYSQL-HOLD2-REPAIR-20260805-001
+
+- 复审=`DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-MYSQL-HOLD4-REREVIEW-20260805-001 / HOLD/2`;审计落盘=`276466/2BC27E46CFE3AB1CA1AB60013E8CC4B0DD5C4CE4848AF81D1CFADEB8FE62713F`。F1—F3 关闭,只修 F4-R1/P1。
+- F4-R1:`_write_pair` 的 stage create/write/flush/fsync 移入统一 `BaseException` 状态机;定向矩阵覆盖第一/第二文件 × create/write/flush/fsync × OSError/KeyboardInterrupt/SystemExit,正式 pair 字节不变且 stage/backup=0。
+- P1:`apply_daily_prices` 在任何 price SELECT/INSERT 前完成区间有限性/正数/顺序和 V1 标签计算;`base_low=0` 返回 `FAILED/E_RANGE`,两日表计数均为 0。
+- 验证:定向=`2/2 PASS / 10.714s`;目标=`21/21 PASS / 92.799s`;project=`118/118 PASS / 150.858s`;py_compile PASS。
+- 正式 MySQL 计数、正式 latest、行情水位均未改变;生产 daily、真实行情、provider、计划任务、其他数据库动作均为 0。当前=`PENDING_F4_R1_P1_ONLY_IMPLEMENTATION_REREVIEW`。
+
+## 2026-08-05 DEV-LOG-STOCK-VALUATION-LEDGER-MVP-FRONT-TABLE-DELTA-20260805-001
+
+- 用户新口径=`requirement_change_front_adjusted_dedicated_table`:生产 daily 唯一价格表=`trading_xuntou.cn_stock_kline_1d_front`;禁 `cn_stock_kline_1d`、`dividend_type` 推断和 V2/network 回退。
+- 只读 schema 核对:专表字段=`id,symbol,trade_date,open,high,low,close,pre_close,volume,amount,turnover,source,source_batch_id,created_at,updated_at`;未查询/写入生产日价格。
+- 实现:专表必需列门禁;SQL 只引用专表;以正式日历计算请求日以前最大开市日并要求行情行精确匹配;`source_id` 固定为专表名,仍校验 `source` 与 `updated_at`。
+- 初步定向:专表行合同、只读/禁旧路径、缺列阻断、正向 schema 合同=`4/4 PASS / 11.408s`。生产 daily、正式 latest、外部网络、provider、计划任务、目标数据库新增写入均为 0。当前=`FRONT_TABLE_DELTA_TESTING`。
+
+## 2026-08-05T22:46:51+08:00 DEV-LOG-STOCK-VALUATION-LEDGER-MVP-FRONT-TABLE-FREEZE-20260805-001
+
+- 最终合同:生产行情只读 `trading_xuntou.cn_stock_kline_1d_front`;专表 `symbol` 精确等于台账 ticker 且后缀为 `SH/SZ/BJ`,静态证券表不参与运行门禁;source ID=`trading_xuntou.cn_stock_kline_1d_front:xtquant:front`。
+- 实现 SQL/静态检查不含通用表、`dividend_type` 或 V2/network;完整日继续绑定 current 正式日历、请求日以前最大开市日、行日期和上海 15:05 后 `updated_at`。F4-R1/P1 修复未回退。
+- 验证:专表聚焦=`4/4 PASS / 11.473s`;目标=`22/22 PASS / 92.119s`;project=`119/119 PASS / 156.103s`。测试仅创建/删除前缀受限的隔离 MySQL 数据库。
+- 需求 owner 回执=`PRODUCTION_DAILY_COMPLETED_DO_NOT_DUPLICATE`:2026-08-05 已写 124 price/124 judgement,A 股 124/124,标签 3/39/39/43,`09880.HK` 不支持;source/close/version/label mismatch 均为 0。
+- 开发线程生产 daily、正式历史改写、网络、V2 provider、`trading_xuntou` DDL/DML、`tianxia`、计划任务安装均为 0;只读核对正式四表计数、规范行哈希和三个 latest 输出哈希。当前=`PENDING_CONSOLIDATED_IMPLEMENTATION_REREVIEW`。
+
+## 2026-08-05T23:03:36+08:00 DEV-LOG-STOCK-VALUATION-LEDGER-MVP-EXACT-IDENTITY-FREEZE-20260805-001
+
+- 审核前自查发现前复权行身份曾先统一大写再比较,可能接受非 exact 源值;收紧为 `row.symbol == security.ticker` 原值精确相等,suffix 必须原值 `SH/SZ/BJ`,`row.source` 必须原值 `xtquant`。
+- 补 `300450.sz -> E_MARKET_IDENTITY` 与 `XTQUANT -> E_SOURCE` 入口反例;目标=`22/22 PASS / 91.232s`,project=`119/119 PASS / 144.696s`,compile/help/parser/diff/UTF-8/secret/governance 继续 PASS。
+- 正式 MySQL、正式 outputs、生产行情和外部动作增量均为 0;旧审核提交因 code/test 哈希漂移明确作废,后继只提交一次完整替代快照。当前=`REPLACEMENT_SNAPSHOT_READY`。
+
+## 2026-08-05 DEV-LOG-STOCK-VALUATION-LEDGER-MVP-STALE-CALENDAR-GATE-20260805-001
+
+- 审核员直接反例:`as_of=2026-08-05`、current 日历 `window_end=2026-08-04`、行情日 2026-08-04 曾被 `_record_from_row` 接受;违反请求视野水位证明。
+- 修复:expected CTE 和正式日历 join 均加 `a.window_end >= cutoff`;行消费边界要求 `window_end >= as_of`。补直接反例后目标=`22/22 PASS / 86.679s`、project=`119/119 PASS / 140.964s`。
+- 生产 daily、正式历史、outputs、网络/provider/计划任务和其他数据库写入增量=0。当前=`FINAL_REPLACEMENT_SNAPSHOT_READY`。
+
+## 2026-08-05 DEV-LOG-STOCK-VALUATION-LEDGER-MVP-FINAL-ACCEPTANCE-CLOSURE-20260805-001
+
+- 收到开发终态=`HANDOFF-INFODEV2-INFOADMIN-STOCK-VALUATION-LEDGER-MVP-FINAL-20260805-001` 与共享审核员独立回执=`HANDOFF-INFOREV-INFODEV2-STOCK-VALUATION-LEDGER-MVP-FINAL-REVIEW-RESULT-20260805-001`;审计=`DEV-AUDIT-PROJECT-INFO-STOCK-VALUATION-LEDGER-MVP-STALE-CALENDAR-FINAL-REREVIEW-20260805-001 / PASS/0`。
+- 只读核验冻结入口:`market.py=10274/AE841AF79131DAED6BCD459544207EFC288454A879EE05F916BE1D7473D834E2`;`core.py=47078/5D6644CF745B76F8710D3B83B75260FD41959BFDCB5B30FF3AD282B959CE8D45`;`test_ledger.py=31997/92E611CA089F1D3E48C1C28808880D34DA649D498E9287BD99F3EE81715535E0`;工具说明、验收证据与 worklog 哈希均匹配开发终态。
+- 审核冻结生产证据=`125 security / 136 valuation_version / 124 daily_price / 124 daily_judgement`;正式三个 latest 文件仍分别匹配 `98ECF8A0...F0F4`、`C88986DF...E0A2`、`E5BBD897...55F7`,未重复运行或改写。
+- 审后外部数据增量按开发员通知登记:需求方独立导入后 live 估值数据=`183 security / 194 valuation_version`;新 daily 被 authoritative calendar 陈旧水位正确阻断,日表和 124 行 latest 未变。该增量验证 fail-closed 门禁,不构成实现/测试回退或复审理由。
+- 本关闭动作执行 production daily、network/provider、计划任务安装、MySQL DDL/DML、`trading_xuntou`/`tianxia` 写入、正式输出改写、代码测试修改均为 `0`;旧 SQLite 文件保持不存在。
+- 治理结论:现有实现与现有输出按 `PASS/0` 接受,事项状态=`ACCEPTED_CLOSED`。后续仅按工具文档由运维在权威日历水位完备后运行 daily;扩展范围必须另立新需求。
+
+## 2026-08-06T02:54:43+08:00 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-20260806-001
+
+- 上游=`REQ-BILI-DYNAMIC-COLLECTOR-20260804-001`;事项=`DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001`;唯一目标=`青枫浦上Q / BV1HA3o6oEJJ`;owner=`dev.developer.project / infodev`。
+- 设计基线=`V001 22418/665DDDFE4247DEFEE69B6DD1E0CBBD128EDD75B8A32BA609C90C718AC7C97C71 + V002 23356/8526D942228B77C8A13FE8D6AC57B33925C59547FF25273E4A668DF2677564C2`;复审=`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-DESIGN-REREVIEW-RESULT-20260805-001 / PASS/0`。
+- 最小实现=`dev/project-dev/bili_authenticated_extension/`:固定 extension ID/origin/build identity 与 exact wire schema;二次用户点击;秘密前 host/config/hash/CreateNew 预检;内存 CookieJar;worker suspended 后先纳入 `KILL_ON_JOB_CLOSE` Job;yt-dlp import 前禁插件并隔离全部普通输出;单次解析/同一对象下载;仅 HttpFD/DashSegmentsFD;子进程 argv secret/URL/header 门禁;本地 stream-copy MKV;清除认证上下文后在同一 Job 内调用冻结 `accept-browser-file`。
+- source artifact manifest=`dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json|2510|91E5E28CC21F9E214FE3A6D351D883AF3F12DA11C4DD9828D79934108D9367EC`;测试树按 `name|bytes|sha256 LF` 聚合=`7 files/612 bytes/1A97EF2CD9A4D7FA64D49A3890F22730622087C09DCFD5990A25B95230C7471B`;用法=`dev-doc/project-doc/B站已登录会话完整视频扩展入口.md|10499|9789F2B7CE0141E726A47CA396D89873BCCFD4260C1AA4210C766A92FB0ABCCA`。
+- 冻结后半程未变:code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`。
+- 离线验证:目标环境=`28/28 PASS / 12.306s`;冻结 bridge=`21/21 PASS / 0.197s`;base 全项目=`127 tests / OK / 6 skipped / 61.658s`,其中扩展真实 yt-dlp 项只在目标环境运行并全部通过。py_compile、Node 语法、JSON、PowerShell parser、严格 UTF-8/no BOM/no trailing whitespace、root/accept-browser-file help 与 diff check 均 PASS;治理=`OK/projects1/warnings0`。
+- 真实 sleeping child 覆盖 worker/child/grandchild 的 suspended assign、cancel/EOF/disconnect/BaseException/host kill 等七类终态;合成 AV 只使用 TemporaryDirectory 和 FFmpeg lavfi;真实 Cookie、session、Chrome、扩展/host 安装、HKCU、network/download、quarantine/真实媒体、`F:\video`、正式 `ana-data`、转写均为 0。PyInstaller 构建与安装脚本仅完成离线 validation/fail-fast 测试,未执行构建、安装或注册表写入。
+- 当前=`PENDING_V002_LIMITED_IMPLEMENTATION_REVIEW`;下一步只沿既有 `dev.reviewer.project / inforev` 审核链提交一次实现审核,审核 PASS 前继续禁止安装和真实认证下载。
+
+## 2026-08-06T03:51:41+08:00 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-HOLD5-REPAIR-20260806-001
+
+- 首轮实现审核=`HANDOFF-INFOREV-INFODEV-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-REVIEW-RESULT-20260806-001 / HOLD/5`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXTENSION-INGRESS-V002-IMPLEMENTATION-REVIEW-20260806-001|318608|ACE96B2F28BAC4118275B84BDD7DA5DCB399459371EC7A3C9DCCEABEAF3704C0`;请求方按同一链返回修复,未建平行任务或审核链。
+- F1:`sanitized_environment()` 在清空环境前验证绝对本地、无 reparse 链的 `LOCALAPPDATA` 并唯一保留;真实 job-owned worker 用有效 config 越过旧 `E_CONFIG`,在 secret=0 时清除 stale run、创建 lease,EOF 后 stage 和正式输出均为 0。
+- F2:协议新增 exact secret-free `prepare`。第二次点击先 fresh prepare,broker 重新核验 config/hash/正式碰撞,worker 完成 Job、stage cleanup/lease 和 plugin READY 后才允许 Cookie store/read/start;mock Chrome 证明两次点击间 `E_CONFIG_HASH` 变化时 Cookie read/start secret 均为 0。
+- F3:下载后、本地 remux/rename/候选/FFprobe、bridge 前均有 cancel checkpoint;worker 与 broker 共享 commit event。commit 前 cancel 只有 worker/已确认 kill 后才宣告 `CANCELED` 且正式输出=0;commit 已开始则拒绝 cancel,由 bridge `COMPLETE/FAILED` 唯一决定。probe 后、commit 交界、bridge 内、EOF 和 timeout 入口矩阵通过。
+- F4:构建改为固定 PyInstaller `6.15.0 --onefile`;构建前逐项复验 bundled source manifest,输出根只允许唯一 EXE+schema 2 build receipt。fake pinned builder 实际执行成功/失败状态机,验证 work/spec 清理、唯一文件、receipt 及失败 output root=0;未执行真实 PyInstaller 构建。
+- F5:installer 只接受脚本同目录 exact source manifest、exact 15 文件集,以及 build receipt 同目录唯一 onefile;拒绝任意自洽外部 manifest、缺件、篡改、额外文件和既有注册。CreateNew 复制 host/config、复读 bytes/hash、CreateNew host manifest,再无 `-Force` 创建注册;任一异常回滚本次 registry/root。临时文件 registry provider 实际 `-Install` 覆盖成功、重复、错误 ID/receipt/tamper 和 after-root/payload/config/manifest/registry-key/value 六个失败点,真实 HKCU=0。
+- 修复快照:source artifact manifest=`dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json|2511|CE43F1BD7AB87EFAA6B98EB456EC3938E4DDFEBBAB36268E774746F307EF7C0A`;build script=`6322/7A6528C4F5019F59D59D7FD92E753CE8A9B62E8FDA1ABB6CD98004B032D56129`;installer=`15542/783A3E0227B11C1023684522A9BA54222781B926668361939002A6889A269EAB`;测试树=`7 files/612 bytes/8BDA96E3CFE867E0E2906BE9CEF13B1CD39AAF1D5969A7B0A63EC38630D4A0E1`;用法=`11513/C4B6B0F2E4EE32D4FFA3E4AA633C56E701777B9ABD6EA3DC9012D80BC8915D69`。
+- 冻结后半程未变:code=`28500/749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13`;tests=`22553/80D8C94DF6922820AA4643984E91209C16046364C20A6A04F6ADA22C8DC36C8B`;usage=`6931/8103EA6F2077BF6BB30B31BCDFB3786B057306A747C18286E2622D19F8393687`。
+- 验证:目标环境=`30/30 PASS / 27.613s`;冻结 bridge=`21/21 PASS / 0.204s`;base project=`129 tests / OK / 6 skipped / 80.102s`;py_compile、Node、JSON、PowerShell parser、strict UTF-8/no BOM/no trailing whitespace、scoped diff 和治理=`OK/projects1/warnings0` 均 PASS。
+- 外部动作:真实 Cookie/session/Chrome、extension/native host 正式构建或安装、HKCU、network/download、quarantine/real media、`F:\video`、formal `ana-data`、transcription 全部 0。仅使用 TemporaryDirectory 合成 AV、fake onefile builder、sleeping child 和临时文件 registry provider;当前=`PENDING_F1_F5_LIMITED_IMPLEMENTATION_REREVIEW`。
+
+## 2026-08-06T11:33:51+08:00 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-HOLD3-SAME-TASK-CONTINUATION-20260806-001
+
+- 项目管理员交接=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-HOLD3-MINIMAL-REPAIR-ASSIGN-20260806-001`;同一事项唯一 owner 从 `dev.developer.project / infodev` 临时续派至 `dev.developer.project.secondary / infodev-2`,没有并行实现、新 task 或新审核链;已向管理员返回 `ACCEPTED_SINGLE_OWNER_CONTINUATION`。
+- F2-R1:sidepanel/background 在第一异步边界前取得不可重入 lease 并立即禁用 start/retry;prepare/start 使用唯一 32 位小写十六进制 `prepare_id`。延迟 Cookie-store 双击反例断言 `cookieStoreReads=1`、`cookieReads=1`、携密 start=1、第二请求=`E_BUSY`,首任务不被终止。
+- F4-R1:正式说明统一为 pinned PyInstaller `6.15.0 --onefile --noupx`、唯一 EXE+schema2 receipt;真实 build=0。
+- F5-R1:build/install 分别在 builder、manifest 解析、复制和 HKCU 前验证 source/build 树外 reviewer/admin approval receipt;篡改产品并同步重算同目录 manifest/receipt 的反例 fail closed。开发员未创建或自批正式 trust receipt,测试仅使用外部 TemporaryDirectory fixture。
+- source artifact manifest=`dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json|2511|039BE7791B517290F13458151A19B400C8DE516963FADB7354E22C680BE89709`;冻结 bridge code/tests/usage 仍为 `749FC486...FF13 / 80D8C94D...6C8B / 8103EA6F...3687`。
+- 回归:扩展=`31/31 PASS / 29.893s`;冻结 bridge=`21/21 PASS / 0.238s`;project=`130 tests / OK / 6 skipped / 84.069s`;py_compile、Node、PowerShell parser、JSON、strict UTF-8/no BOM/no trailing、secret literal scan、governance=`OK/projects1/warnings0`。
+- 真实 build/install、HKCU、Chrome、Cookie/session、network/download、real media、`F:\video`、formal `ana-data`、transcription 全部 0。当前=`FROZEN_PENDING_HOLD3_LIMITED_IMPLEMENTATION_REREVIEW`。
+
+## 2026-08-06 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-HOLD3-PASS-20260806-001
+
+- 限定复审回执=`HANDOFF-INFOREV-INFODEV2-BILI-AUTHENTICATED-SESSION-HOLD3-MINIMAL-OFFLINE-REPAIR-REREVIEW-RESULT-20260806-001 / PASS/0`;audit=`dev-doc/开发审计报告.md:2595|334024|C063BD046AE56B1D24A6167623DFA696206E4EA17DE645253F89398ADECC3FDD`。
+- 审核员独立验证:target=`31/31 PASS / 32.841s`;bridge=`21/21 PASS / 0.231s`;project=`130 OK / 6 skipped / 83.965s`;source manifest 15/15 与测试树聚合 `DA738E...F1476` 一致。
+- reviewer-owned 外部 approval=`ai-inforev/worklog/2026-08-06-bili-authenticated-session-source-approval.json|373|F6A56C14ED8DB1DFF7918F72CC5316D65866CED9B2D98368A88DF47D338FCBAA`,批准范围仅 `controlled-build-source-manifest`。
+- 本回合真实 build/install/HKCU/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription 增量=0。install/HKCU 必须等待唯一 EXE 与 schema2 build receipt 的后继 hash-only 限定确认。
+- 当前=`HOLD3_MINIMAL_OFFLINE_REPAIR_ACCEPTED`;按原任务链向 project.admin 与 video-downloader 回传,不新建任务或审核链。
+
+## 2026-08-06 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-CONTROLLED-BUILD-PREFLIGHT-BLOCKED-20260806-001
+
+- 管理员授权=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-EXACT-SOURCE-CONTROLLED-BUILD-ASSIGN-20260806-001`;operator 接收回执已发送。
+- 预检通过:reviewer source approval=`373/F6A56C14...CBAA`;source manifest=`2511/039BE779...9709`;目标输出 `dev/tmp/bili-auth-controlled-build-20260806-001` 不存在。
+- 依赖门禁阻断:`D:\Anaconda\envs\project-info-bili-download-bridge\python.exe -B -m PyInstaller --version` 返回 `No module named PyInstaller`;PATH、常用 Anaconda/Python roots 与已安装 site-packages 未发现可用 PyInstaller 6.15.0。
+- 按合同未运行 `build_host.ps1`,`build_count=0`,output/EXE/receipt/work/spec=0;network/pip install/source/approval modification=0;install/HKCU/Chrome/Cookie/session/download/media/formal path 均为 0。
+- 当前=`E_BUILD_DEPENDENCY_PYINSTALLER_6_15_0_UNAVAILABLE`,向 project.admin 与 video-downloader 回传精确阻断;未向 reviewer 提交不存在的 exact-build 复审。
+
+## 2026-08-06 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-CONTROLLED-BUILD-BLOCKER-CONFIRMED-20260806-001
+
+- 管理员独立确认=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-CONTROLLED-BUILD-PREFLIGHT-BLOCKER-CONFIRMED-20260806-001`:Codex bundled Python、`D:\Anaconda`、`D:\ProgramData\anaconda3`、PATH、pip caches 与 Conda offline cache 均无精确 PyInstaller 6.15.0 可用项。
+- 决定=`BLOCKER_CONFIRMED_BUILD_REMAINS_FROZEN`;管理员已另行申请预置可复算的离线环境或新的明确供应链授权。当前线程停止且不重复 preflight。
+- 冻结状态:`build_count=0`、output absent;install/HKCU/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription 全部 0;无新 task/review chain。
+
+## 2026-08-06 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXACT-SOURCE-BUILD-20260806-001
+
+- 恢复授权=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-CONTROLLED-BUILD-RESUME-20260806-001`;管理端 provision=`HANDOFF-MGADMIN-INFOADMIN-BILI-PYINSTALLER-6-15-0-OFFLINE-ENV-PROVISION-TERMINAL-20260806-001`。
+- 预检:固定 Python=`262144/5912D088...82C9`、PyInstaller executable=`108469/D5DC4427...5C39`、module/exe version=`6.15.0`、pip check PASS、wheelhouse=`7/7 MATCH`、source=`15/15 MATCH`、reviewer approval/source manifest exact、output absent。
+- 唯一 build:`build_host.ps1` count=`1`、exit=`0`、wall=`20.839s`;PyInstaller 实际记录 Python 3.12.13、Windows 10、onefile、no UPX,未 PATH fallback、未联网或安装依赖。
+- 产物:`dev/tmp/bili-auth-controlled-build-20260806-001/project-info-bili-auth-native-host.exe|8608281|F0D3CE2EF12D7D305B8F765169CBF5430DF9F0D36D03FF4447A85378AFF8A26F`;`build-artifact-manifest.json|995|9B989A32C92A4EB89911C9D5151F31B416BA8F265785CE575FDD1A23FBEDF63D`。
+- 自检:输出恰好 2 个普通文件;`.build/.spec=0`;receipt schema2、builder/source/exe 链、MZ、UTF-8/no BOM/no trailing 均 PASS;构建控制台 secret/session/signed-URL 模式命中 0。
+- install/HKCU/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription=0;当前=`PENDING_EXACT_BUILD_HASH_ONLY_CONFIRMATION`,不执行 EXE、不安装、不自批 approval。
+
+## 2026-08-06 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-EXACT-BUILD-HASH-CONFIRMED-20260806-001
+
+- hash-only 回执=`HANDOFF-INFOREV-INFODEV2-BILI-AUTHENTICATED-SESSION-EXACT-BUILD-HASH-ONLY-CONFIRMATION-RESULT-20260806-001 / PASS/0`;audit=`dev-doc/开发审计报告.md:2634|338568|AC3B888F8940A472F2F6BF1E08539BE021C49B36A312DF739C7062554AE8C00C`。
+- 审核员独立复算 source 15/15、输出恰好两文件、EXE/receipt bytes+SHA、schema2、builder/build-script/source approval 链、MZ 与零越界证据全部通过;EXE execution=0。
+- reviewer build approval=`ai-inforev/worklog/2026-08-06-bili-authenticated-session-build-approval.json|594|244097BC466D969E046F0E64E9D3A1213F5AB64E12C9358DF0807B4BA0F8F816`,位于 source/build 树外,scope=`install-exact-build`。
+- exact build identity gate 已满足;actual install/HKCU=`NOT_EXECUTED`。后续安装须管理员/请求方另行调度,任何漂移重新审核。
+
+## 2026-08-06T15:07:44+08:00 DEV-LOG-PROJECT-INFO-HIBOR-NEW-EMULATOR-ADAPTER-20260806-001
+
+- 来源=`HANDOFF-YANBAO-INFODEV-HIBOR-NEW-EMULATOR-MINIMAL-ADAPTER-20260806-001`;事项=`DEV-PROJECT-INFO-HIBOR-NEW-EMULATOR-ADAPTER-20260806-001`;按项目“实用、快速落地”口径作为单脚本轻量实现,无重型方案。
+- 实现:`hibor_batch_executor.py` 新增 15 秒 `adb devices -l` 预检。显式串号必须精确在线;`auto` 只接受唯一一个 ready 记录;解析发生在批次根创建与内核调用之前,并只把真实串号写入既有 TaskSpec。
+- 未扩展:`HIBOR_EXACT_BATCH_V001` 根字段、逐项顺序执行、短路径、内核 TaskSpec 其余字段、`cache_root=/sdcard/Android/data/cn.com.hibor/files/myfile/` 和 `dev/ana-dev` 公共合同不变。共享 `/sdcard/Pictures` 与 `F:\hb` 仅登记为可选环境通道,未实现新传输逻辑。
+- 验证:py_compile/CLI help PASS;目标=`8/8 PASS / 1.762s`;project=`133 tests / OK / 6 skipped / 88.438s`;0/多设备、offline、unauthorized、缺失串号、ADB 非零和 preflight 零输出均通过。
+- 只读 smoke:`adb devices -l` 仅见 `emulator-5554 device`;缓存根=`PASS`;产品 `resolve_device_serial(..., auto)` 返回 `emulator-5554`。首次缓存检查因 shell 参数拆分未执行目标 test,修正引号后 PASS;未对设备或缓存写入。
+- 文件:code=`30052/79757D5A58936FFBBD3006A6274CA58581B1347CCA5E2E3B616232D3161B4F09`;tests=`13949/44152F32268D03E20BF62FE07F643EC3DEE102666018E2715851259D09F18051`;example=`1873/0556A05748AEAE78D4EE0DC4A5A427366922D6285EC808265394A5B4DFE82F75`;usage=`6744/FA83F484AC64DBEF348129FA8AC1EB5E22FFF0320EC7A070B0C583F71D722504`。
+- 外部动作:APP/report trigger/download/quota/formal archive/BATCH-031/cache mutation=`0`;状态=`IMPLEMENTED_PENDING_REQUESTER_ACCEPTANCE`。
+
+## 2026-08-06T15:57:47+08:00 DEV-LOG-PROJECT-INFO-HIBOR-NEW-EMULATOR-ADAPTER-ACCEPTANCE-20260806-001
+
+- 请求方普通验收回执=`HANDOFF-YANBAO-INFODEV-HIBOR-NEW-EMULATOR-MINIMAL-ADAPTER-ACCEPTANCE-20260806-001 / ACCEPTED`。
+- 验收通过:显式 `emulator-5554`、`auto` 恰好一个设备/fail-closed、批次根创建前预检、缓存根不变、示例/用法迁移说明,以及不修改 `dev/ana-dev`/公共合同/共享服务。
+- 已接受证据:目标=`8/8 PASS`;project=`133 tests / OK / 6 skipped`;实现最小且符合快速落地要求。
+- 范围分离:BATCH-031 的 MemuIME 输入兼容/失败后禁止 paste 已由 `dev.developer.ana.cai` 另项处理,不属于本适配;本线程不接管、不重复实现。
+- 关闭动作只登记文档;代码修改、测试、APP、下载、额度、正式归档、缓存修改均为 0。终态=`ACCEPTED_CLOSED`。
+
+## 2026-08-06 DEV-LOG-ANA-HIBOR-NEW-EMULATOR-INPUT-COMPAT-20260806-001
+
+- 修复 `adb.py`:安全字母数字查询改走 `adb shell input text`;非安全输入先精确检测 MemuIME;剪贴板服务失败立即停止且不会发 keyevent 279。
+- `monkey` 成功路径不变;仅在缺失/不可用诊断下回退导出的 `cn.com.hibor/.WelcomeActivity`,其他失败保持 fail closed。
+- UI 层原有 `n.text == query` 逐字回读保持,并新增不一致反例。
+- 验证:定向=`5/5 PASS`;完整 `test/hibor_fast_collection`=`59/59 PASS / 18.803s`;compileall、scoped diff check PASS。
+- 真实 APP、ADB、下载、触发、quota、正式归档、网络、数据库均为 `0`;当前=`IMPLEMENTED_PENDING_LIGHT_INDEPENDENT_REVIEW`。
+
+### 2026-08-06 DEV-LOG-ANA-HIBOR-NEW-EMULATOR-INPUT-COMPAT-PASS-20260806-001
+
+- 审核=`AUDIT-DEV-ANA-HIBOR-NEW-EMULATOR-INPUT-COMPAT-IMPLEMENTATION-V001 / PASS/0/0`;audit snapshot=`513947/2ccd98a1214b15ce1f5178a2b7a05c9fec2d42f42f928782fb30af2c0a83f0f2`。
+- 审核独立复跑定向=`5/5 PASS`、完整离线=`59/59 PASS`,compileall、scoped diff check、MB-X validate 均 PASS。
+- required_fixes=`NONE`;真实 APP/ADB/download/quota/archive 增量仍为 0;状态=`IMPLEMENTATION_ACCEPTED_CLOSED`。
+
+## 2026-08-06 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD-CONTRACT-REPAIR-20260806-001
+
+- 授权=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD-CONTRACT-REPAIR-ASSIGN-20260806-001`;同一 task、同一 owner、同一 reviewer 链。旧 EXE、schema 2 receipt 和 approval 保持 immutable history,状态=`NOT_INSTALLABLE / SUPERSEDED_FOR_INSTALL`。
+- 一次有界依赖获取只得到 `yt_dlp-2026.7.4-py3-none-any.whl|3184705|F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266`;wheel metadata 的无条件依赖闭包为空。dependency manifest=`723/2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D`。
+- 构建合同在输出副作用前校验 PyInstaller 6.15.0、source approval、17 文件 source manifest、dependency manifest、wheel exact set/hash/metadata;实际构建仅允许 `--no-index --find-links --no-deps` 临时安装、`--collect-all yt_dlp --copy-metadata yt-dlp --onefile --noupx`,并在 receipt 前递归验证 worker/yt_dlp 锚点与 package metadata。
+- source manifest=`3029/D1CE8C00A50CF17E45FB915577369BADA4AD9937335290852F393DC6BD053FCF`;目标=`31/31 PASS / 184.223s`,冻结 bridge=`21/21 PASS / 0.138s`,project=`133 tests / OK / 6 skipped / 172.091s`。第一次 project discover 使用缺少项目其他依赖的 bridge 专用环境,改用项目基础环境后完整通过。
+- 当前 replacement build_count=0;install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/formal `ana-data`/transcription=0。状态=`PENDING_YTDLP_SOURCE_REVIEW`,开发员未自批 trust receipt。
+
+## DEV-LOG-PROJECT-INFO-VIDEO-TEXT-HOST-WRITE-EOF-PROVENANCE-20260806-001
+
+- 事项:`DEV-PROJECT-INFO-VIDEO-TEXT-HOST-WRITE-EOF-STABILITY-20260806-001`
+- 输入:管理员相邻阻断 bug 指派、现场截图、active Registry/安装树、Chrome 扩展 1.0.5、上游 v1.0.5 tag/release/source。
+- 只读动作:读取 Registry 默认值、manifest/bat/文件元数据与 SHA-256;固定 annotated tag object `6c386da41865726f7b731553b820aeff2a5fd2ef` 和 full commit `537f424866fd3736f5845224208e3dcc67352b70`;下载并哈希 source archive;用 PyInstaller archive viewer 只读列出 active EXE CArchive 并提取 `host_win` marshal code object;用 Python 3.11 编译固定源并递归比较规范化 code fingerprint。
+- 映射结果:active `native-host.exe=1943105/BEAE2752888EFB468A5C38FC0685F6FE74F42D8BC9CE1C54432DF227BA61818F`;入口=`host_win`;active/upstream 规范化 code fingerprint 均为 `DB5F1CE9DE4695B9D72F1060D16BA70C0979C85A724D83BC9FC4B052EF54D753`,`match=True`。
+- 阻断结果:截图为 Electron/Node `WriteWrap` 未捕获错误;active Windows host 为 Python,且 `send_message()` 已捕获 header/payload/flush 的普通异常。实际 faulting process 尚未定位,不得把 `host.cjs` 或 active Python host 当作已证明修复对象。
+- 中止动作:未创建产品代码或测试,未构建、未安装、未修改 HKCU/installed root/Chrome,未执行已安装二进制,未读取 secret-bearing log/Cookie/token/URL/media。
+- 唯一恢复条件:下次弹窗时取得 faulting process PID/父 PID/可执行路径或等价 WER/Application Error 记录,并先完成不可变来源映射。
+- 详细证据:`dev-doc/project-doc/视频转文字助手EOF稳定性修复.md`。
+- 当前状态:`BLOCKED_BEFORE_IMPLEMENTATION_EXACT_FAULTING_PROCESS_REQUIRED`
+
+### 2026-08-06 范围纠正回执
+
+- 收到=`HANDOFF-INFOADMIN-INFODEV-VIDEO-TEXT-HOST-WRITE-EOF-SCOPE-CORRECTION-20260806-001 / EVIDENCE_AWAITING_NO_PRODUCT_MUTATION`。
+- 管理员接受 active host 来源反证,撤回 host-specific 因果声明;原安装树、源码指纹和零变更证据保留。
+- 执行决定:patch/build/install/replace=`0`;常驻 watcher=`0`;命令行、认证数据、媒体捕获=`0`。
+- 后续仅为事件驱动:若弹窗真实复发并且上游主动提供 PID/父 PID/可执行路径或等价 WER,才执行一次有界只读核对。
+- 关系纠正:本事项不再阻断 authenticated-download runtime release。
+- 当前状态=`EVIDENCE_AWAITING_NO_PRODUCT_MUTATION`。
+
+## 2026-08-06 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD-CONTRACT-HOLD2-CLOSURE-20260806-001
+
+- 前序原链 source review=`HOLD/2`,仅阻断实际源码树精确集合与继承 `PYTHONPATH` 注入;Electron/Node EOF 相邻事项已由管理员解耦,不属于本次下载 release blocker。
+- F1:build/installer 在 Python、输出、复制或 HKCU 前递归枚举实际源码树;除 source manifest 自身外,大小写敏感普通文件集合必须精确等于冻结 17 路径,根级/嵌套 extra 与 reparse 反例均 fail closed。
+- F2:build 显式绑定管理员批准的 Python=`262144/5912D0884B23C0343983A864C6064242391E2265536F50B88624857E353882C9`、PyInstaller executable=`108469/D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39` 和树外 provision receipt=`2027/B65F4184E8782394F2CC27C47CB8656C942E366FD80E8FA7A548CE5A3367BACF`;首次 Python 调用前移除全部继承 `PYTHON*`,全程 isolated 调用,不拼回外部 `PYTHONPATH`。伪 PyInstaller/pip 注入未执行、未产生输出;合成成功测试只使用临时修改脚本副本。
+- 冻结:build=`20430/3B0552ADA09D279D08FF89A84F59847C8F86BBC7606257DE8B26203CCBD42F92`;installer=`22247/F127BAE512BEE03AD78780BB1C98065398EF910EF1A6F9D59EE42463F4E2A62D`;source manifest=`3029/E01C1320F8C7677B7435B4C8B87EA3688BA652585648D7CFD1F88D0DADB26958`;dependency manifest/wheel 未漂移。
+- 验证:扩展=`31/31 PASS / 113.250s`;冻结 bridge=`21/21 PASS / 0.195s`;project=`133 tests / OK / 6 skipped / 171.664s`;PowerShell parser、Python 编译和 manifest exact-set PASS。
+- replacement build/install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/formal `ana-data`/transcription=0;状态=`PENDING_HOLD2_SOURCE_REREVIEW`,开发员未创建 source approval。
+
+## 2026-08-07 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-BLOCKED-20260807-001
+
+- 原审核员 F1/F2-only 复审=`PASS/0`;reviewer source approval=`373/26AF51848717C47A2915D633B258269EA79213A94BAFEFF9CE7D30CDC2A77F96`,绑定 source manifest=`3029/E01C1320F8C7677B7435B4C8B87EA3688BA652585648D7CFD1F88D0DADB26958`。
+- 单次 build 前 17/17 source、exact Python/PyInstaller/provision receipt 与新输出 absent 全部复算通过。唯一一次调用先以 `--no-index/--find-links/--no-deps` 成功安装 `yt-dlp==2026.7.4` 到临时 `.deps`,随后 PyInstaller spec 的 `copy_metadata('yt-dlp')` 因 metadata 搜索路径未包含该临时 distribution,返回 `PackageNotFoundError`;exit=1、约25.9s。
+- attempt_count=1,未重试;输出根已回滚为 absent,replacement EXE/receipt/work/spec/deps 均为0。历史不可安装 EXE/receipt 字节未变,source=17/17/reparse0。
+- install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/formal `ana-data`/transcription=0。状态=`BLOCKED_AFTER_SINGLE_BUILD_ATTEMPT/E_BUILD_METADATA_NOT_DISCOVERABLE`;等待管理员在原链决定新的最小 source 修订与 build authorization。
+
+## 2026-08-07 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-20260807-001
+
+- 管理授权=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-METADATA-REPAIR-REBUILD-AUTH-20260807-001`;同一 task/owner/reviewer,未新增依赖获取、任务或审核链。
+- `.deps` 现在必须是 owned output 根下 canonical 非 reparse 目录,并按 pinned wheel、pip 生成元数据与安装 RECORD 精确复算文件集/bytes/SHA;只允许 `yt-dlp 2026.7.4`,缺件、篡改、错误 owner、额外 distribution 或 PyInstaller module 均在 accepted output/receipt 前失败。
+- exact Python 继续以 `-I` 清洁启动,仅将已验证 `.deps` 在进程内插入 `sys.path`,真实 metadata version 通过后才 `run_module('PyInstaller')`;不恢复继承 `PYTHON*`/user-site/sitecustomize/外部 module path。
+- 冻结:build=`25586/F5E9FC58313B2CB765772BF3A5C8605BCA9DD91A1FF74D0F03DBEB3174949845`;source manifest=`3029/AC852D40CF2B6FE891BE25D2E6E068A876EAC60E07EE6E9DB6826AC1064B29B8`;test=`58501/369C4E240585371967AAC128BBCB6AA5320C293282796C216283F1BACEF28F92`。
+- 验证:metadata=`1/1 PASS / 51.241s`;门禁合并=`2/2 PASS / 128.251s`;扩展=`32 tests / OK / 5 skipped / 150.469s`;冻结 bridge=`21/21 PASS / 0.181s`;project=`134 tests / OK / 6 skipped / 203.739s`。
+- 本修复后 replacement build=0;install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/formal `ana-data`/transcription=0。状态=`PENDING_METADATA_REPAIR_SOURCE_REREVIEW`。
+
+## 2026-08-07 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-PREVALIDATION-CODE-EXECUTION-CLOSURE-20260807-001
+
+- metadata repair source rereview=`HOLD/1`;唯一阻断为 pip 后的旧 identity probe 在精确 `.deps` 完整性门前先插入 `sys.path` 并导入 `yt_dlp`,审核 marker 反例证明篡改代码曾可提前执行。
+- 仅删除该前置 import probe;`.deps` 现在先通过 canonical owner、递归非 reparse、wheel-derived exact set/bytes/SHA-256、RECORD、`INSTALLER/REQUESTED` 和唯一 `yt-dlp==2026.7.4` distribution 检查,PyInstaller launcher 才首次插入 `.deps` 与执行依赖代码。
+- 新入口反例在 pip 后篡改 `yt_dlp/__init__.py` 写 marker;实际完整性门 fail closed,output/receipt/marker 均不存在。
+- 冻结:build=`25093/43816E7932A785321B545073FEAD9EB38198E5ED3B07F2469062895ADC41238F`;source manifest=`3029/463182C6980734B72831BDF9B387160F8AD21A03B52CBAB4AC74CA98CEB1B7AE`;test=`59374/55F7F4ECA14E370BAB1D5D1CA154BE8BFD1E602C5460B69670A376751C5E08EA`。
+- 验证:聚焦=`1/1 PASS / 54.068s`;扩展=`32 tests / OK / 5 skipped / 149.629s`;bridge=`21/21 PASS / 0.204s`;project=`134 tests / OK / 6 skipped / 205.302s`;PowerShell/Python/source-manifest gates PASS。
+- replacement build/install/HKCU/EXE execution/Chrome/Cookie/session/media network/download/`F:\video`/formal `ana-data`/transcription=0;`002` 输出不存在。状态=`PENDING_F1_PREVALIDATION_SOURCE_REREVIEW`。
+- 机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/prevalidation-code-execution-closure-20260807.json|2686|A3BA1EDB3C5C236C886B17821095481541DA1CAA7A349C3F4C25D279AF6BADF2`;治理=`OK/projects=1/warnings=0`。
+
+## 2026-08-07 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-002-BLOCKED-20260807-001
+
+- F1-only source rereview=`PASS/0`;reviewer source approval=`373/54514FCD70D2ED85F33E48433B13241F845A329A3DBD2C089AF416F1D67B2CEB`,精确绑定 source manifest=`3029/463182C6980734B72831BDF9B387160F8AD21A03B52CBAB4AC74CA98CEB1B7AE`。
+- 构建前 source/dependency/wheel、exact Python/PyInstaller/provision receipt、历史产物和新输出 absent 全部 MATCH;`-ValidateOnly=PASS`。
+- 管理员条件授权下唯一一次 `002` build:离线 pip 安装 yt-dlp 成功,PyInstaller EXE 组装完成;receipt 前静态归档门因缺少固定 yt-dlp package metadata 返回 exit1。attempt_count=1、retry=0、墙钟约69.7s。
+- 新输出根已回滚 absent;accepted EXE/receipt=0,未提交 hash-only review。证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-002-blocked-20260807.json|2322|3E184118BEE03C8AA6D536B10C0B41F2B795FF8D4D56D72911B065CA9D50A257`。
+- 历史不可安装 EXE/receipt 保持原字节;install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription=0。状态=`BLOCKED_AFTER_SINGLE_BUILD_ATTEMPT/E_BUILD_ARCHIVE_METADATA_MISSING`;不自行修复或重试。
+
+## 2026-08-07 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-CONTRACT-REPAIR-20260807-001
+
+- 授权=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-REPAIR-REBUILD-AUTH-20260807-001`;同一 task/owner/reviewer,只修复 onefile 静态 metadata 合同。
+- 离线诊断确认 PyInstaller `copy_metadata('yt-dlp')` 会返回完整 dist-info;旧 `archive_viewer -r` 使用 `repr` 转义 Windows 反斜杠,文本正则因此产生 false negative。
+- build 改用 PyInstaller 6.15.0 `CArchiveReader` 结构化遍历 CArchive/PYZ;路径规范化、模块锚点、唯一根、7 文件 exact bytes/type、`Name/Version` 和树哈希均在 receipt 前强制校验。installer 同步精确要求新 method、`metadata_files=7` 与 `metadata_tree_sha256=7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`。
+- 离线反例覆盖缺失、重复、错 Name/Version、残缺、意外嵌套与无关分发替代;正常 Windows 反斜杠路径通过。聚焦=`1/1 PASS / 167.713s`;扩展=`32 tests / OK / 5 skipped / 239.755s`;bridge=`21/21 PASS / 0.147s`;project=`134 tests / OK / 6 skipped / 295.036s`;PowerShell parser PASS。
+- 冻结:build=`32216/4E25D57D8133670D25BACF0E2A38D1C673DB9576413B22EBDC28304CAB6B2A33`;installer=`22467/DBF1A828DC343DCA4B52283B8EE189962007581E42D03D213A55461CE05CFEF6`;source manifest=`3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`;test=`62074/66B70D38E983806B82A2D00A1F741A79445E9C9CF80EA2C0951EED2935C1CC2B`。
+- 机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-contract-repair-20260807.json`;production/replacement build/install/HKCU/EXE/Chrome/Cookie/session/network/media/正式路径均为0,候选 `003` 不存在;状态=`PENDING_ARCHIVE_METADATA_SOURCE_REREVIEW`。
+
+## 2026-08-07 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-ARCHIVE-METADATA-SOURCE-REVIEW-CAPACITY-BLOCKED-20260807-001
+
+- source review 入箱=`msg_20260807042629035_d5f27466`;审核员独立 source 17/17 与聚焦测试=`1/1 PASS / 156.385s` 完成后,turn `019fd8c1-e9de-78e3-b9c5-0758309b621e` 因 usage limit 以 `systemError` 终止;正式 verdict/audit/新 source approval 均未形成。
+- 管理升级入箱=`msg_20260807045308506_36bf3f9a`;project.admin turn `019fd8da-3bb5-7c70-9fcd-6a97b65a1253` 同样因 usage limit 终止,未形成同链恢复或 reviewer 重路由决定;两处报告可重试时间=`2026-08-08 13:31 +08:00`。
+- 部分聚焦 PASS 不作为批准使用;source manifest=`3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910` 保持冻结,candidate `003` absent,replacement build/install/HKCU/EXE/Chrome/Cookie/session/network/media/正式路径均为0。
+- 证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-source-review-capacity-blocked-20260807.json`;状态=`BLOCKED_REVIEW_CAPACITY`。恢复条件=有效 source-review PASS + 新 reviewer-owned 树外 approval,或 project.admin 明确 reviewer 重派。
+
+## 2026-08-08 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-003-BLOCKED-20260808-001
+
+- 容量恢复后的原审核员 source rereview=`PASS/0`;reviewer source approval=`373/A46A8D29A42B85E0DB83AA4EC5153B298BE9C24684637C6B7864AD23EF5B41E8`,绑定 source manifest=`3029/508F69CE49224F0B79BA24E25F47CE84E598C3D6747FF284A7B4FD32E7E19910`。
+- 构建前 exact inputs、source 17/17/reparse0、历史产物与新输出 absent 均匹配,`-ValidateOnly=PASS`。唯一授权 build-003 的离线 pip 和 PyInstaller onefile 组装完成,receipt 前结构化归档门因 `E_ARCHIVE_METADATA_TYPE` 返回 exit1;attempt=1、retry=0、wall=`81.921s`。
+- 新输出根已回滚 absent,accepted EXE/receipt=0/0。离线定位为 Windows 类型假阴性:PyInstaller 6.15.0 对 `os.access(..., X_OK)=true` 的 DATA 写入 CArchive type `b`,当前门只接受 `x`;未修改 source、未重试构建。
+- 机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-003-blocked-20260808.json`。历史不可安装产物未变;install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription=0。状态=`BLOCKED_AFTER_SINGLE_BUILD_ATTEMPT/E_BUILD_ARCHIVE_METADATA_TYPE_FALSE_REJECTION`,等待原链最小修复/复审/新构建授权。
+
+## 2026-08-08 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-ARCHIVE-METADATA-TYPE-REPAIR-20260808-001
+
+- 授权=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-TYPE-CONTRACT-REPAIR-BUILD004-AUTH-20260808-001`;只允许精确 7 项 yt-dlp metadata 使用 CArchive DATA `x` 或 Windows-promoted `b`,其余类型、路径、文件集与内容保持 fail closed。
+- schema 2 receipt 新增实际 `metadata_type_codes` 绑定;installer 同步只接受非空 `b/x`。逐文件 bytes、Name/Version、固定 tree hash 与模块锚点门未弱化。
+- 测试:real writer=`1/1 PASS / 2.086s`;installer+writer=`2/2 PASS / 17.198s`;完整类型/构建门=`1/1 PASS / 370.155s`;bridge=`21/21 PASS / 0.225s`;project=`135 tests / OK / 6 skipped / 538.519s`;PowerShell parser PASS。
+- 冻结:build=`33020/3739436920FCEA8BA68DC04BBDAB0F346C08216F49D89A097C50075912A258A0`;installer=`22741/53A1D1AFD9E4571A2491AE66C5E721E77A2201DBFAA29C9094E42CE0CB684C2A`;source manifest=`3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`;test=`69608/2E7D6BE3B9607A34A90B283F2CDDBC65A31CD2AF02026EB97E8CED6C153075AB`。
+- 证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/archive-metadata-type-contract-repair-20260808.json`;build-004/output/install/HKCU/EXE/Chrome/Cookie/session/network/media/正式路径=0,状态=`PENDING_ARCHIVE_METADATA_TYPE_SOURCE_REREVIEW`。
+
+## 2026-08-08 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-SUCCESS-20260808-001
+
+- 原审核员 archive-metadata-type source rereview=`PASS/0`;reviewer source approval=`373/102B30405B81137BD3AC9B81CFA39A6A7BB21531115FB3F361F821B2A093335E`,绑定 source manifest=`3029/21009AADBAC3F25AAA15712948232796E813A5973FDE497B11A334915A3C7EB3`。
+- 构建前 exact source 17/17/reparse0、dependency manifest、yt-dlp wheel、固定 Python/PyInstaller/provision receipt、新输出 absent 与 `-ValidateOnly` 全部匹配。管理员单次 build-004 条件授权仅调用一次:exit=`0`、wall=`86.8s`、attempt=`1`、retry=`0`。
+- 输出根=`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-004`;精确集合=两个普通非 reparse 文件、目录0、`.build/.spec/.deps` 均 absent。
+- EXE=`21343775/DBF155BCC05B4DF55592D0A4B56612F8BE9C990B3B9AF7691F6A11648866B0B0`,仅静态 `MZ` 检查,执行=0。schema2 receipt=`2874/0B36AA7DCA3AD80F8A370E96D50CAAFB386291EE2741305C7797ED663F5C2B27`,strict JSON/UTF-8、文件项和全部冻结绑定通过;归档 metadata=`7 files/type [b]/tree 32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。
+- 机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-004-success-20260808.json`。install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/`F:\video`/formal `ana-data`/transcription=0。状态=`PENDING_EXACT_BUILD_HASH_ONLY_CONFIRMATION`,新 install-exact-build approval 前不得安装。
+
+## 2026-08-08 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-HASH-ONLY-HOLD-20260808-001
+
+- 原审核员 exact-build hash-only 终态=`HOLD/1`;静态 EXE/receipt/output/source/dependency/builder 绑定全部匹配,唯一阻断=`F1_BUILD004_RECEIPT_INSTALLER_METADATA_TREE_HASH_MISMATCH`。
+- build-004 receipt 与审核员 CArchiveReader 复算 metadata tree hash=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`;冻结 installer=`22741/53A1D1AFD9E4571A2491AE66C5E721E77A2201DBFAA29C9094E42CE0CB684C2A` 只接受 `7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C`,故该 exact build 无法通过自身 preinstall 门。
+- 审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-004-HASH-ONLY-CONFIRMATION-20260808-001|376110|5EE85855516E114029ADFF537E23DEFFBB47DBF557E94AEE9AC4DCCB8C65E993`;install approval 未创建/复用/更新。
+- 机器终态证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-004-hash-review-hold-20260808.json`。build-004 仅保留为 NOT_INSTALLABLE 冻结证据;retry/install/HKCU/EXE execution/Chrome/Cookie/session/network/download/media/正式路径=0。等待管理员同链授权最小 source 修复,不自行修复或重建。
+
+## 2026-08-08 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-SOURCE-REPAIR-20260808-001
+
+- 管理授权=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-REPAIR-BUILD005-AUTH-20260808-001`;同 task/owner/reviewer,build-004 保持 NOT_INSTALLABLE 历史证据。
+- `source-artifact-manifest.json#archive_metadata_contract` 成为 builder/installer 的单一七文件 golden contract;算法按 path UTF-8/NUL/十进制长度/NUL/payload SHA-256 lower hex/LF 拼接后取 uppercase SHA-256,固定值=`32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`。
+- 固定 distlib `SOURCE_DATE_EPOCH=1786207924` 使精确 wheel 的 pip `RECORD` 可复现;builder 复算合同并绑定 receipt,installer 从同一受批 manifest 校验。旧 `7C9A...105C` 只保留在负例,产品脚本0处。
+- 验证:canonical installer+writer=`2/2 PASS / 22.274s`;合成 exact-builder seam=`1/1 PASS / 348.093s`;bridge=`21/21 PASS / 0.232s`;project=`135 tests / OK / 6 skipped / 489.917s`;PowerShell parser/Python compile/source 17/17/reparse0 PASS。
+- 冻结 source:build=`35414/6184EBA087470BB5DBD6FEC29EB22068F8119FBDE2ED640715E8317B5A928C04`;installer=`24079/AA9667A3CD765B21058F234FD82DFDB9621161246041EC595E4BCB17DDCFDE86`;manifest=`3681/333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535`;test=`70133/8913DC78EBB1780C29A518D7AC4DE90E4C8B0E8B8FF82106A3461326A3389045`。
+- 机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/canonical-tree-hash-contract-repair-20260808.json`。build-005 attempt=0/output absent;install/HKCU/EXE/Chrome/Cookie/session/network/media/正式路径=0。状态=`PENDING_CANONICAL_TREE_HASH_ONLY_SOURCE_REREVIEW`。
+
+## 2026-08-08 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD005-20260808-001
+
+- source 限定复审=`PASS/0`;审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-CANONICAL-TREE-HASH-SOURCE-REREVIEW-20260808-001|381790|9478849A3E760B3B38FFA79B9BFDB8F45BEA8FB0D6CBCB702852F65CB6A81501`;树外 approval=`373/E0D621EB2C5E8149FE9B775CF7EA2F2ECB17C3FC3A6D4911D67CF2D24ACBD3EF`,绑定 manifest=`3681/333BA59512C5F68B35C00B8C9738C8B26FB78285F05851F9F35BA3FC44819535`。
+- 构建前 exact source/dependency/wheel/Python/PyInstaller/provision receipt 与历史 build-004 均复算匹配,build-005 输出缺省;`ValidateOnly=PASS` 且输出仍缺省。
+- 唯一 build-005:attempt=1、retry=0、exit=0、wall=66.8s;输出根=`dev/tmp/bili-auth-controlled-build-ytdlp-20260808-005`,仅两个普通非 reparse 文件、目录0、临时 `.build/.spec/.deps` 0。
+- EXE=`21343716/CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE`,仅静态 MZ、执行0;schema2 receipt=`2874/CC6663A15CDEB375167900730A9F956C9B09839558586E5DFC3D7FC57728ACC0`,metadata=`7/[b]/32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70`,全部冻结绑定匹配。
+- 机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-005-success-20260808.json|4496|3FB4557AD81556068B27F6EBA10017883B511C52DDD66AA505150AAD4977CF57`。strict UTF-8/diff/governance=`PASS/PASS/OK projects1 warnings0`;install/HKCU/EXE/Chrome/Cookie/session/network/download/media/正式路径=0;状态=`PENDING_BUILD005_HASH_ONLY_CONFIRMATION`。
+
+## 2026-08-08 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD005-HASH-ONLY-PASS-20260808-001
+
+- 原审核员 hash-only 终态=`PASS/0`,审计=`DEV-AUDIT-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-YTDLP-REPLACEMENT-BUILD-005-HASH-ONLY-CONFIRMATION-20260808-001|386985|2F0DBEACE8584940D219CF89BAFD0F5AE6F5C4288B29C137CAC915776A3F7FEA`。
+- EXE=`21343716/CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE`、receipt=`2874/CC6663A15CDEB375167900730A9F956C9B09839558586E5DFC3D7FC57728ACC0` 及 source/dependency/builder/canonical metadata 全部独立匹配。
+- reviewer-owned install approval=`ai-inforev/worklog/2026-08-08-bili-authenticated-session-ytdlp-replacement-build-005-install-approval.json|596|4D18A32DE9DFEB6B90C1A398087BD36CEAE09418D54E1F0DC485D6B95C962A44`;终态机器证据=`dev/tmp/bili-auth-ytdlp-build-contract-repair-20260806-001/replacement-build-005-hash-review-pass-20260808.json|2454|3E4BC53C8419C7D43992F10811A685C5E810F760EA2EA25B0BE087D904F148BE`。
+- 当前未安装、未写 HKCU、未执行 EXE,真实动作均为0。exact-build identity 门已关闭;实际安装仍须管理员/请求方另行明确调度。
+
+## 2026-08-08 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BUILD005-INSTALL-PREFLIGHT-BLOCKED-20260808-001
+
+- 管理安装授权=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-YTDLP-BUILD005-INSTALL-EXACT-BUILD-AUTH-20260808-001`;先复算 exact build-005、schema2 receipt、source/install approvals、无秘密 config 与目标/HKCU 前像,均匹配且安装根/HKCU 缺省。
+- 不带 `-Install` 的 installer 在 `VALIDATION_PASS_ONLY` 阶段失败关闭:批准 source manifest 为17文件,实物树为19文件;额外项为两个 `__pycache__/*.pyc`。批准17文件 bytes/SHA 全匹配,reparse=0,但 exact source set 已漂移,故未进入安装。
+- 机器证据=`dev/tmp/bili-auth-install-build005-20260808-001/install-preflight-blocked-20260808.json|2269|413AD21E4C836E90E86D7E66CD3C580C24AC8E7BCCD2708A21F108E6E5075A86`;config=`1265/2F09B69AC9B8B82F90EB16673F71D3418AB73A94092C1785E5F697F50E6F2B50`。
+- 终态=`BLOCKED/E_SOURCE_TREE_SET_DRIFT`;validation attempt=1、install attempt=0、retry=0。安装根/HKCU 保持缺省;未执行 EXE,未访问 Chrome/Cookie/session,未联网/下载/写媒体或正式路径。未删除额外文件、未修复、未重试。
+
+## 2026-08-08 DEV-LOG-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-BUILD005-INSTALL-SUCCESS-20260808-001
+
+- 管理续授权=`HANDOFF-INFOADMIN-INFODEV2-BILI-AUTHENTICATED-SESSION-BUILD005-INSTALL-PREFLIGHT-CACHE-CLEANUP-RESUME-20260808-001`;只对前序已冻结的两个 `.pyc` 复核普通文件/非 reparse/bytes/SHA 后删除,并仅在为空时删除 `__pycache__` 目录。前序 blocker 证据保持不变。
+- 清理后 source=`17/17 exact`,missing/extra/hash mismatch/reparse=`0/0/0/0`;source manifest/approval、build receipt/EXE/install approval/config 全部保持冻结哈希。validation attempt 2=`VALIDATION_PASS_ONLY` 且安装根/HKCU 仍缺省。
+- 唯一实际安装 attempt=`1`、retry=`0`、SUCCESS;根=`C:\Users\Cai\AppData\Local\project-info\bili-auth-native-host\1.0.0+20260805.v002`,精确三文件、目录0、reparse0。EXE=`21343716/CABE09ACC0CC151B3C537F68B9AF1544D6CB5BCAF64FE3B3DB12055D52C732EE`;config=`1265/2F09B69AC9B8B82F90EB16673F71D3418AB73A94092C1785E5F697F50E6F2B50`;manifest=`433/A050B24FBFC879BEB25044D775AC43DC95F7BCDBDCCF153BF4198EF6B8E68B51`。
+- 健康检查=`PASS`:host/origin/path 精确,HKCU 只有期望默认 String 值、无子键,回滚合同静态可验证,目标 BV 输出仍为0。机器证据=`dev/tmp/bili-auth-install-build005-20260808-001/install-success-20260808.json|3713|2349894EE33541E54A7452C054C248239E735403F3D4B4ED5649586C3C897AAB`。
+- EXE execution/Chrome/Cookie/session/network/download/media/`F:\video` 写入/formal `ana-data`/transcription=`0`;本记录不授权 runtime use。
diff --git "a/dev-doc/\345\274\200\345\217\221\351\227\256\351\242\230\350\256\260\345\275\225.md" "b/dev-doc/\345\274\200\345\217\221\351\227\256\351\242\230\350\256\260\345\275\225.md"
index bee7479..8089202 100644
--- "a/dev-doc/\345\274\200\345\217\221\351\227\256\351\242\230\350\256\260\345\275\225.md"
+++ "b/dev-doc/\345\274\200\345\217\221\351\227\256\351\242\230\350\256\260\345\275\225.md"
@@ -367,3 +367,33 @@
- 当前审核事实:project 级 V002 已取得独立 `HOLD/8`,证明有效审核通路已恢复;该八项设计 blocker 继续由 V003 在同一 project 审核链闭合,仍阻止实现,但不属于旧审核会话基础设施问题。
- `ana-dev` 边界:全部只读,修改数为 `0`。
- 当前状态:`CLOSED_BY_VALID_PROJECT_REVIEWER_REASSIGNMENT`;旧会话阻断关闭,不表示 V003 方案通过或实现获准。
+
+## DEV-ISSUE-ANA-HIBOR-NEW-EMULATOR-CLIPBOARD-PASTE-20260806-001
+
+- 关联事项:`DEV-ANA-HIBOR-NEW-EMULATOR-INPUT-COMPAT-20260806-001`。
+- 类型:代码兼容与 fail-closed 安全缺陷。
+- 现象:原 `input_text` 在 `memu_clip_board_base64` 服务失败后仍发送 paste key;不支持该服务的新模拟器可能粘贴与任务无关的主机剪贴板内容。
+- 修复:安全 ASCII 直接使用 Android input text;非 ASCII/特殊输入需先确认 MemuIME,且服务成功后才可 paste;不支持环境在编辑前停止。
+- 回归:服务失败不 paste、未支持输入停止、六位代码成功及 UI 逐字回读均通过;真实设备动作=0。
+- 当前状态:`FIXED_PENDING_LIGHT_INDEPENDENT_REVIEW`。
+
+### 2026-08-06 关闭追加
+
+- 独立复核=`AUDIT-DEV-ANA-HIBOR-NEW-EMULATOR-INPUT-COMPAT-IMPLEMENTATION-V001 / PASS/0/0`。
+- required_fixes=`NONE`;问题状态=`CLOSED_FIXED_AND_REGRESSION_VERIFIED`。
+
+## 2026-08-06 VideoTextHost `write EOF` 弹窗来源不一致
+
+- 事项:`DEV-PROJECT-INFO-VIDEO-TEXT-HOST-WRITE-EOF-STABILITY-20260806-001`
+- 状态:`BLOCKED_EXACT_FAULTING_PROCESS_PROVENANCE_REQUIRED`
+- 单一阻断:截图是 Electron/Node `WriteWrap.onWriteComplete` 的未捕获 `write EOF`,但 Registry 指向的 active `native-host.exe` 已精确映射为上游 v1.0.5 Python `host_win.py`,且写入和 flush 已在 `try/except Exception` 内。
+- 证据:active EXE=`1943105 / BEAE2752888EFB468A5C38FC0685F6FE74F42D8BC9CE1C54432DF227BA61818F`;active/upstream 归一化 code fingerprint 均为 `DB5F1CE9DE4695B9D72F1060D16BA70C0979C85A724D83BC9FC4B052EF54D753`。
+- 影响:修改上游 `host.cjs` 或改造 active Python host 都无法证明会消除该 Electron/Node 弹窗,进入构建/安装会形成错误对象替换。
+- 解除条件:下次弹窗时只读取得 faulting process PID、父 PID、可执行文件路径或等价 WER/Application Error 记录;不收集命令行与认证秘密。
+- 详细证据:`dev-doc/project-doc/视频转文字助手EOF稳定性修复.md`。
+
+### 2026-08-06 范围纠正
+
+- 管理员已接受来源不一致反证并撤回 active Python host 的因果声明;原“需要 faulting process 证据才能修补”不再是一个活动开发阻断。
+- 问题处置从 `BLOCKED` 改为事件驱动的 `EVIDENCE_AWAITING_NO_PRODUCT_MUTATION`;未复发时不轮询、不开发、不变更产品。
+- 本事项已从 authenticated-download release gate 解除。
diff --git a/dev/project-dev/bili_authenticated_extension/__init__.py b/dev/project-dev/bili_authenticated_extension/__init__.py
new file mode 100644
index 0000000..5d6f4b3
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/__init__.py
@@ -0,0 +1,9 @@
+"""Exact-BVID authenticated Bilibili extension ingress.
+
+The package is deliberately narrow: it only supports the frozen
+``BV1HA3o6oEJJ`` task and never imports yt-dlp in the broker process.
+"""
+
+from .constants import CANONICAL_URL, TARGET_BVID
+
+__all__ = ["CANONICAL_URL", "TARGET_BVID"]
diff --git a/dev/project-dev/bili_authenticated_extension/background.js b/dev/project-dev/bili_authenticated_extension/background.js
new file mode 100644
index 0000000..049ae99
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/background.js
@@ -0,0 +1,388 @@
+const TARGET = "BV1HA3o6oEJJ";
+const CANONICAL_URL = "https://www.bilibili.com/video/BV1HA3o6oEJJ";
+const TARGET_PATH = "/video/BV1HA3o6oEJJ";
+const HOST = "com.project_info.bili_auth_ingress";
+const SCHEMA = 2;
+const EXTENSION_BUILD = "project-info-bili-auth-ingress/1.0.0+20260805.v002";
+const HOST_BUILD = "project-info-bili-auth-native-host/1.0.0+20260805.v002";
+const SIDEPANEL_URL = chrome.runtime.getURL("sidepanel.html");
+const EXPECTED_DURATION_MS = 3133950;
+const DURATION_TOLERANCE_MS = 3134;
+
+let currentProof = null;
+let currentTabId = null;
+let currentTaskNonce = null;
+let nativePort = null;
+let nativeReady = false;
+let nativeRevision = 0;
+let lastNativeType = null;
+let preparedTaskNonce = null;
+let preparedLeaseId = null;
+let activeStartLease = null;
+const prepareResponses = new Map();
+let safeState = {
+ phase: "IDLE",
+ progress: 0,
+ error_code: null,
+ formal_filename: null,
+ mapping_filename: null
+};
+
+chrome.runtime.onInstalled.addListener(() => {
+ chrome.sidePanel.setPanelBehavior({openPanelOnActionClick: true});
+});
+
+function randomNonce() {
+ const bytes = new Uint8Array(16);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");
+}
+
+function fixedError(code) {
+ const error = new Error(code);
+ error.code = code;
+ return error;
+}
+
+async function activeTargetTab() {
+ const tabs = await chrome.tabs.query({active: true, currentWindow: true});
+ if (tabs.length !== 1 || !Number.isInteger(tabs[0].id)) {
+ throw fixedError("E_PAGE_PROOF");
+ }
+ const parsed = new URL(tabs[0].url || "about:blank");
+ if (parsed.protocol !== "https:" || parsed.hostname !== "www.bilibili.com" || parsed.pathname !== TARGET_PATH) {
+ throw fixedError("E_PAGE_PROOF");
+ }
+ return tabs[0];
+}
+
+async function observePage(tab, nonce) {
+ const results = await chrome.scripting.executeScript({
+ target: {tabId: tab.id},
+ world: "ISOLATED",
+ args: [TARGET, CANONICAL_URL, nonce],
+ func: (target, canonicalUrl, taskNonce) => {
+ const videos = Array.from(document.querySelectorAll("video")).filter(video =>
+ video.readyState >= 1 && video.videoWidth > 0 && video.videoHeight > 0
+ );
+ if (location.hostname !== "www.bilibili.com" || location.pathname !== `/video/${target}` || videos.length !== 1) {
+ return null;
+ }
+ const video = videos[0];
+ return {
+ target,
+ canonical_url: canonicalUrl,
+ task_nonce: taskNonce,
+ observed_at_unix_ms: Date.now(),
+ observed_duration_ms: Math.round(video.duration * 1000),
+ video_width: video.videoWidth,
+ video_height: video.videoHeight,
+ ready_state: video.readyState,
+ eme_present: video.mediaKeys !== null
+ };
+ }
+ });
+ if (results.length !== 1 || !results[0].result) {
+ throw fixedError("E_PAGE_PROOF");
+ }
+ const proof = results[0].result;
+ const now = Date.now();
+ const exactKeys = [
+ "canonical_url", "eme_present", "observed_at_unix_ms", "observed_duration_ms",
+ "ready_state", "target", "task_nonce", "video_height", "video_width"
+ ];
+ if (Object.keys(proof).sort().join("|") !== exactKeys.sort().join("|") ||
+ proof.target !== TARGET || proof.canonical_url !== CANONICAL_URL ||
+ proof.task_nonce !== nonce || proof.eme_present !== false ||
+ !Number.isSafeInteger(proof.observed_at_unix_ms) ||
+ proof.observed_at_unix_ms < now - 60000 || proof.observed_at_unix_ms > now + 5000 ||
+ !Number.isSafeInteger(proof.observed_duration_ms) ||
+ Math.abs(proof.observed_duration_ms - EXPECTED_DURATION_MS) > DURATION_TOLERANCE_MS ||
+ !Number.isInteger(proof.video_width) || proof.video_width < 1 || proof.video_width > 7680 ||
+ !Number.isInteger(proof.video_height) || proof.video_height < 1 || proof.video_height > 4320 ||
+ !Number.isInteger(proof.ready_state) || proof.ready_state < 1 || proof.ready_state > 4) {
+ throw fixedError(proof.eme_present ? "E_DRM" : "E_PAGE_PROOF");
+ }
+ return proof;
+}
+
+async function validateCurrentPage({leaseId = null} = {}) {
+ if (activeStartLease !== null && activeStartLease !== leaseId) {
+ throw fixedError("E_BUSY");
+ }
+ const tab = await activeTargetTab();
+ const nonce = randomNonce();
+ const proof = await observePage(tab, nonce);
+ currentProof = proof;
+ currentTabId = tab.id;
+ if (nativePort) {
+ const previousPort = nativePort;
+ nativePort = null;
+ nativeReady = false;
+ preparedTaskNonce = null;
+ preparedLeaseId = null;
+ prepareResponses.clear();
+ previousPort.disconnect();
+ }
+ await awaitNativeReady();
+ safeState = {...safeState, phase: "READY", error_code: null};
+ return safeState;
+}
+
+function connectNative() {
+ if (nativePort) {
+ return;
+ }
+ nativeReady = false;
+ preparedTaskNonce = null;
+ preparedLeaseId = null;
+ prepareResponses.clear();
+ const port = chrome.runtime.connectNative(HOST);
+ nativePort = port;
+ port.onMessage.addListener(message => {
+ const safeKeys = new Set([
+ "schema", "type", "host_build", "target", "phase", "progress", "error_code",
+ "formal_filename", "mapping_filename", "prepare_id"
+ ]);
+ const basename = value => value === null || (typeof value === "string" && value.length > 0 && !/[\\/]/.test(value));
+ const prepareIdValid = message?.prepare_id === null ||
+ (typeof message?.prepare_id === "string" && /^[0-9a-f]{32}$/.test(message.prepare_id));
+ if (!message || Object.keys(message).some(key => !safeKeys.has(key)) ||
+ message.schema !== SCHEMA || message.host_build !== HOST_BUILD || message.target !== TARGET ||
+ !["IDLE", "READY", "CHECKING", "DOWNLOADING", "MERGING", "VALIDATING", "PUBLISHING", "COMPLETE", "FAILED", "CANCELED"].includes(message.phase) ||
+ !Number.isInteger(message.progress) || message.progress < 0 || message.progress > 100 ||
+ !basename(message.formal_filename) || !basename(message.mapping_filename) || !prepareIdValid ||
+ (message.type === "prepare" ? message.prepare_id === null : message.prepare_id !== null)) {
+ safeState = {...safeState, phase: "FAILED", error_code: "E_PROTOCOL"};
+ return;
+ }
+ nativeReady = nativeReady || (message.type === "hello" && message.phase === "READY");
+ lastNativeType = message.type;
+ if (message.type === "prepare") {
+ prepareResponses.set(message.prepare_id, {
+ phase: message.phase,
+ error_code: message.error_code
+ });
+ }
+ safeState = {
+ phase: message.phase,
+ progress: message.progress,
+ error_code: message.error_code,
+ formal_filename: message.formal_filename,
+ mapping_filename: message.mapping_filename
+ };
+ nativeRevision += 1;
+ });
+ port.onDisconnect.addListener(() => {
+ if (nativePort === port) {
+ nativePort = null;
+ nativeReady = false;
+ preparedTaskNonce = null;
+ preparedLeaseId = null;
+ prepareResponses.clear();
+ if (!["COMPLETE", "FAILED", "CANCELED"].includes(safeState.phase)) {
+ safeState = {...safeState, phase: "FAILED", error_code: "E_NATIVE_DISCONNECT"};
+ }
+ }
+ });
+ port.postMessage({
+ schema: SCHEMA,
+ type: "hello",
+ extension_build: EXTENSION_BUILD,
+ target: TARGET
+ });
+}
+
+async function freshNativeStatus() {
+ const prior = nativeRevision;
+ nativePort.postMessage({schema: SCHEMA, type: "status", target: TARGET});
+ const deadline = Date.now() + 5000;
+ while (nativeRevision === prior && Date.now() < deadline) {
+ await new Promise(resolve => setTimeout(resolve, 25));
+ }
+ if (nativeRevision === prior) {
+ throw fixedError("E_NATIVE_CONNECT");
+ }
+ return safeState;
+}
+
+async function awaitNativeReady() {
+ connectNative();
+ const deadline = Date.now() + 5000;
+ while (!nativeReady && Date.now() < deadline) {
+ if (safeState.error_code) {
+ throw fixedError(safeState.error_code);
+ }
+ await new Promise(resolve => setTimeout(resolve, 25));
+ }
+ if (!nativeReady) {
+ throw fixedError("E_NATIVE_CONNECT");
+ }
+}
+
+async function freshNativePrepare(proof, leaseId) {
+ await awaitNativeReady();
+ if (activeStartLease !== leaseId) {
+ throw fixedError("E_BUSY");
+ }
+ preparedTaskNonce = null;
+ preparedLeaseId = null;
+ const prepareId = randomNonce();
+ prepareResponses.delete(prepareId);
+ nativePort.postMessage({
+ schema: SCHEMA,
+ type: "prepare",
+ extension_build: EXTENSION_BUILD,
+ target: TARGET,
+ prepare_id: prepareId,
+ page_proof: proof
+ });
+ const deadline = Date.now() + 120000;
+ while (!prepareResponses.has(prepareId) && Date.now() < deadline) {
+ await new Promise(resolve => setTimeout(resolve, 25));
+ }
+ const response = prepareResponses.get(prepareId);
+ prepareResponses.delete(prepareId);
+ if (!response || response.phase !== "READY" || response.error_code) {
+ throw fixedError(response?.error_code || "E_PREPARE");
+ }
+ preparedTaskNonce = proof.task_nonce;
+ preparedLeaseId = leaseId;
+ return prepareId;
+}
+
+async function currentCookieStore(tabId) {
+ const stores = await chrome.cookies.getAllCookieStores();
+ const matches = stores.filter(store => Array.isArray(store.tabIds) && store.tabIds.includes(tabId));
+ if (matches.length !== 1 || !/^[0-9]{1,8}$/.test(matches[0].id)) {
+ throw fixedError("E_SECRET_INPUT");
+ }
+ return matches[0].id;
+}
+
+function projectCookie(cookie, storeId) {
+ if (cookie.partitionKey !== undefined) {
+ throw fixedError("E_SECRET_INPUT");
+ }
+ if (!cookie.name || !cookie.value) {
+ return null;
+ }
+ const session = Boolean(cookie.session);
+ const expiration = session ? null : Math.floor(cookie.expirationDate);
+ if (!session && (!Number.isSafeInteger(expiration) || expiration <= 0)) {
+ throw fixedError("E_SECRET_INPUT");
+ }
+ return {
+ name: String(cookie.name),
+ value: String(cookie.value),
+ domain: String(cookie.domain),
+ host_only: Boolean(cookie.hostOnly),
+ path: String(cookie.path),
+ secure: Boolean(cookie.secure),
+ http_only: Boolean(cookie.httpOnly),
+ same_site: String(cookie.sameSite || "unspecified"),
+ session,
+ expiration_unix: expiration,
+ store_id: storeId,
+ partition_key: null
+ };
+}
+
+async function startExactTask({retry = false} = {}) {
+ if (activeStartLease !== null) {
+ throw fixedError("E_BUSY");
+ }
+ const leaseId = randomNonce();
+ activeStartLease = leaseId;
+ try {
+ if (retry) {
+ await validateCurrentPage({leaseId});
+ }
+ const tab = await activeTargetTab();
+ if (!currentProof || currentTabId !== tab.id || Date.now() - currentProof.observed_at_unix_ms > 60000) {
+ throw fixedError("E_PAGE_PROOF");
+ }
+ const proof = currentProof;
+ const prepareId = await freshNativePrepare(proof, leaseId);
+ if (activeStartLease !== leaseId || preparedLeaseId !== leaseId || preparedTaskNonce !== proof.task_nonce) {
+ throw fixedError("E_PREPARE");
+ }
+ const storeId = await currentCookieStore(tab.id);
+ const rawCookies = await chrome.cookies.getAll({url: "https://www.bilibili.com/", storeId});
+ let records = [];
+ try {
+ records = rawCookies.map(cookie => projectCookie(cookie, storeId)).filter(Boolean);
+ if (records.length < 1 || records.length > 128) {
+ throw fixedError("E_SECRET_INPUT");
+ }
+ nativePort.postMessage({
+ schema: SCHEMA,
+ type: "start",
+ extension_build: EXTENSION_BUILD,
+ target: TARGET,
+ canonical_url: CANONICAL_URL,
+ cookie_store_id: storeId,
+ prepare_id: prepareId,
+ page_proof: proof,
+ cookies: records
+ });
+ currentTaskNonce = proof.task_nonce;
+ preparedTaskNonce = null;
+ preparedLeaseId = null;
+ safeState = {...safeState, phase: "CHECKING", progress: 0, error_code: null};
+ currentProof = null;
+ currentTabId = null;
+ return safeState;
+ } finally {
+ for (const record of records) {
+ if (record) {
+ record.name = "";
+ record.value = "";
+ }
+ }
+ records.fill(null);
+ rawCookies.fill(null);
+ }
+ } finally {
+ if (activeStartLease === leaseId) {
+ activeStartLease = null;
+ }
+ }
+}
+
+async function requestStatus() {
+ await awaitNativeReady();
+ return freshNativeStatus();
+}
+
+async function cancelTask() {
+ if (!currentTaskNonce) {
+ throw fixedError("E_CANCEL");
+ }
+ await awaitNativeReady();
+ nativePort.postMessage({schema: SCHEMA, type: "cancel", target: TARGET, task_nonce: currentTaskNonce});
+ return safeState;
+}
+
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (sender.id !== chrome.runtime.id || sender.url !== SIDEPANEL_URL || !message || typeof message.action !== "string") {
+ return false;
+ }
+ const actions = {
+ validate: () => validateCurrentPage(),
+ start: () => startExactTask(),
+ retry: () => startExactTask({retry: true}),
+ status: () => requestStatus(),
+ cancel: () => cancelTask()
+ };
+ const action = actions[message.action];
+ if (!action) {
+ sendResponse({ok: false, error_code: "E_PROTOCOL"});
+ return false;
+ }
+ action().then(
+ state => sendResponse({ok: true, state}),
+ error => sendResponse({ok: false, error_code: error.code || "E_EXTENSION"})
+ );
+ return true;
+});
diff --git a/dev/project-dev/bili_authenticated_extension/build_host.ps1 b/dev/project-dev/bili_authenticated_extension/build_host.ps1
new file mode 100644
index 0000000..5cf66bd
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/build_host.ps1
@@ -0,0 +1,724 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$Python,
+ [Parameter(Mandatory = $true)]
+ [string]$PyInstallerExecutable,
+ [Parameter(Mandatory = $true)]
+ [string]$ApprovedBuilderReceipt,
+ [Parameter(Mandatory = $true)]
+ [string]$OutputRoot,
+ [Parameter(Mandatory = $true)]
+ [string]$ApprovedSourceReceipt,
+ [string]$SourceArtifactManifest = (Join-Path (Split-Path -Parent $PSCommandPath) 'source-artifact-manifest.json'),
+ [switch]$ValidateOnly
+)
+
+$ErrorActionPreference = 'Stop'
+$sourceRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSCommandPath)).Path
+$expectedManifestPath = Join-Path $sourceRoot 'source-artifact-manifest.json'
+$resolvedSourceManifest = (Resolve-Path -LiteralPath $SourceArtifactManifest).Path
+if ($resolvedSourceManifest -cne $expectedManifestPath) {
+ throw 'Only the bundled reviewed source artifact manifest is accepted.'
+}
+
+function Get-StrictJson([string]$Path) {
+ $text = [System.IO.File]::ReadAllText($Path, [System.Text.UTF8Encoding]::new($false, $true))
+ return $text | ConvertFrom-Json
+}
+
+function Test-PathWithin([string]$Candidate, [string]$Root) {
+ $prefix = $Root.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
+ return $Candidate.Equals($Root, [StringComparison]::OrdinalIgnoreCase) -or
+ $Candidate.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
+}
+
+function Assert-ExactSourceTree([string]$Root, [string]$ManifestPath, [string[]]$ExpectedFiles) {
+ $rootItem = Get-Item -LiteralPath $Root
+ if ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw 'Source root must not be a reparse path.'
+ }
+ $actualFiles = @()
+ foreach ($item in @(Get-ChildItem -LiteralPath $Root -Force -Recurse)) {
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw 'Source tree contains a reparse path.'
+ }
+ if (-not $item.PSIsContainer -and $item.FullName -cne $ManifestPath) {
+ $actualFiles += $item.FullName.Substring($Root.Length + 1).Replace('\', '/')
+ }
+ }
+ if (Compare-Object -CaseSensitive ($ExpectedFiles | Sort-Object) @($actualFiles | Sort-Object)) {
+ throw 'Actual source tree file set does not exactly match the approved manifest.'
+ }
+}
+
+$resolvedOutput = [System.IO.Path]::GetFullPath($OutputRoot)
+if (-not [System.IO.Path]::IsPathRooted($resolvedOutput) -or $resolvedOutput.StartsWith('\\')) {
+ throw 'OutputRoot must be an absolute local path.'
+}
+$sourceReceiptPath = (Resolve-Path -LiteralPath $ApprovedSourceReceipt).Path
+$sourceReceiptItem = Get-Item -LiteralPath $sourceReceiptPath
+if ($sourceReceiptItem.PSIsContainer -or ($sourceReceiptItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
+ (Test-PathWithin $sourceReceiptPath $sourceRoot) -or (Test-PathWithin $sourceReceiptPath $resolvedOutput)) {
+ throw 'Approved source receipt must be an external regular non-reparse file.'
+}
+$sourceReceipt = Get-StrictJson $sourceReceiptPath
+$expectedSourceReceiptKeys = @(
+ 'schema', 'task_id', 'approval_scope', 'approved_by_role', 'status',
+ 'source_artifact_manifest_bytes', 'source_artifact_manifest_sha256'
+)
+if ((Compare-Object ($expectedSourceReceiptKeys | Sort-Object) @($sourceReceipt.PSObject.Properties.Name | Sort-Object)) -or
+ $sourceReceipt.schema -ne 1 -or
+ $sourceReceipt.task_id -cne 'DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001' -or
+ $sourceReceipt.approval_scope -cne 'controlled-build-source-manifest' -or
+ $sourceReceipt.approved_by_role -notin @('dev.reviewer.project', 'project.admin') -or
+ $sourceReceipt.status -cne 'APPROVED' -or
+ $sourceReceipt.source_artifact_manifest_sha256 -notmatch '^[A-F0-9]{64}$' -or
+ $sourceReceipt.source_artifact_manifest_bytes -ne (Get-Item -LiteralPath $resolvedSourceManifest).Length -or
+ $sourceReceipt.source_artifact_manifest_sha256 -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedSourceManifest).Hash) {
+ throw 'External approved source receipt does not match the source manifest.'
+}
+
+$expectedSourceFiles = @(
+ '__init__.py', 'background.js', 'build_host.ps1', 'config.example.json',
+ 'constants.py', 'dependencies/dependency-artifact-manifest.json',
+ 'dependencies/yt_dlp-2026.7.4-py3-none-any.whl',
+ 'install_native_host.ps1', 'job.py', 'manifest.json',
+ 'native-host-manifest.template.json', 'native_host.py', 'protocol.py',
+ 'sidepanel.css', 'sidepanel.html', 'sidepanel.js', 'worker.py'
+)
+Assert-ExactSourceTree $sourceRoot $resolvedSourceManifest $expectedSourceFiles
+$dependencyManifestPath = Join-Path $sourceRoot 'dependencies/dependency-artifact-manifest.json'
+$dependencyManifestItem = Get-Item -LiteralPath $dependencyManifestPath
+$sourceManifest = Get-StrictJson $resolvedSourceManifest
+if ($sourceManifest.schema -ne 1 -or $sourceManifest.target -cne 'BV1HA3o6oEJJ' -or
+ $sourceManifest.extension_id -cne 'oidmclckpdmpabbfedplkbdplmfcenbb' -or
+ $sourceManifest.extension_build -cne 'project-info-bili-auth-ingress/1.0.0+20260805.v002' -or
+ $sourceManifest.host_build -cne 'project-info-bili-auth-native-host/1.0.0+20260805.v002' -or
+ $sourceManifest.dependency_artifact_manifest_bytes -ne $dependencyManifestItem.Length -or
+ $sourceManifest.dependency_artifact_manifest_sha256 -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $dependencyManifestPath).Hash) {
+ throw 'Source artifact manifest identity mismatch.'
+}
+$metadataContract = $sourceManifest.archive_metadata_contract
+$expectedMetadataContractKeys = @(
+ 'schema', 'root', 'relative_files', 'distribution_name', 'distribution_version',
+ 'allowed_type_codes', 'source_date_epoch', 'tree_hash_algorithm', 'canonical_tree_sha256'
+)
+if ($null -eq $metadataContract -or
+ (Compare-Object ($expectedMetadataContractKeys | Sort-Object) @($metadataContract.PSObject.Properties.Name | Sort-Object)) -or
+ $metadataContract.schema -ne 1 -or
+ $metadataContract.root -cne 'yt_dlp-2026.7.4.dist-info' -or
+ (Compare-Object -CaseSensitive @(
+ 'INSTALLER', 'METADATA', 'RECORD', 'REQUESTED', 'WHEEL',
+ 'entry_points.txt', 'licenses/LICENSE'
+ ) @($metadataContract.relative_files)) -or
+ $metadataContract.distribution_name -cne 'yt-dlp' -or
+ $metadataContract.distribution_version -cne '2026.7.4' -or
+ (Compare-Object -CaseSensitive @('b', 'x') @($metadataContract.allowed_type_codes)) -or
+ $metadataContract.source_date_epoch -ne 1786207924 -or
+ $metadataContract.tree_hash_algorithm -cne 'sha256(path-utf8,nul,decimal-bytes-ascii,nul,payload-sha256-lower-hex-ascii,lf)-upper-hex-v1' -or
+ $metadataContract.canonical_tree_sha256 -notmatch '^[A-F0-9]{64}$') {
+ throw 'Source artifact metadata contract mismatch.'
+}
+$actualSourceFiles = @($sourceManifest.files | ForEach-Object { $_.path } | Sort-Object)
+if (Compare-Object -CaseSensitive ($expectedSourceFiles | Sort-Object) $actualSourceFiles) {
+ throw 'Source artifact manifest file set mismatch.'
+}
+foreach ($entry in $sourceManifest.files) {
+ if ($entry.path -notmatch '^[A-Za-z0-9._/-]+$' -or $entry.path.Contains('..') -or
+ $entry.sha256 -notmatch '^[A-F0-9]{64}$' -or $entry.bytes -lt 1) {
+ throw 'Source artifact manifest contains an invalid entry.'
+ }
+ $candidate = [System.IO.Path]::GetFullPath((Join-Path $sourceRoot $entry.path))
+ if (-not $candidate.StartsWith($sourceRoot + [System.IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Source artifact path escaped its root.'
+ }
+ $item = Get-Item -LiteralPath $candidate
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
+ $item.Length -ne $entry.bytes -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $candidate).Hash -cne $entry.sha256) {
+ throw 'Source artifact hash mismatch.'
+ }
+}
+
+$dependencyManifest = Get-StrictJson $dependencyManifestPath
+$expectedDependencyKeys = @(
+ 'schema', 'task_id', 'python_implementation', 'distribution', 'version',
+ 'design_version', 'acquisition', 'unconditional_runtime_dependencies', 'wheels'
+)
+$expectedAcquisitionKeys = @('source', 'index', 'method', 'acquired_at')
+$expectedWheelKeys = @('filename', 'distribution', 'version', 'bytes', 'sha256')
+if ((Compare-Object ($expectedDependencyKeys | Sort-Object) @($dependencyManifest.PSObject.Properties.Name | Sort-Object)) -or
+ (Compare-Object ($expectedAcquisitionKeys | Sort-Object) @($dependencyManifest.acquisition.PSObject.Properties.Name | Sort-Object)) -or
+ $dependencyManifest.schema -ne 1 -or
+ $dependencyManifest.task_id -cne 'DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001' -or
+ $dependencyManifest.python_implementation -cne 'CPython' -or
+ $dependencyManifest.distribution -cne 'yt-dlp' -or
+ $dependencyManifest.version -cne '2026.7.4' -or
+ $dependencyManifest.design_version -cne '2026.07.04' -or
+ $dependencyManifest.acquisition.source -cne 'PyPI' -or
+ $dependencyManifest.acquisition.index -cne 'https://pypi.org/simple' -or
+ $dependencyManifest.acquisition.method -cne 'pip download --no-deps --only-binary=:all:' -or
+ @($dependencyManifest.unconditional_runtime_dependencies).Count -ne 0 -or
+ @($dependencyManifest.wheels).Count -ne 1) {
+ throw 'Dependency artifact manifest identity or closure mismatch.'
+}
+$wheelEntry = @($dependencyManifest.wheels)[0]
+if ((Compare-Object ($expectedWheelKeys | Sort-Object) @($wheelEntry.PSObject.Properties.Name | Sort-Object)) -or
+ $wheelEntry.filename -cne 'yt_dlp-2026.7.4-py3-none-any.whl' -or
+ $wheelEntry.distribution -cne 'yt-dlp' -or
+ $wheelEntry.version -cne '2026.7.4' -or
+ $wheelEntry.bytes -lt 1 -or
+ $wheelEntry.sha256 -notmatch '^[A-F0-9]{64}$') {
+ throw 'Dependency wheel entry mismatch.'
+}
+$dependencyWheelhouse = Split-Path -Parent $dependencyManifestPath
+$wheelPath = Join-Path $dependencyWheelhouse $wheelEntry.filename
+$wheelItem = Get-Item -LiteralPath $wheelPath
+$wheelhouseFiles = @(Get-ChildItem -LiteralPath $dependencyWheelhouse -File -Force)
+if ($wheelhouseFiles.Count -ne 2 -or
+ (Compare-Object @('dependency-artifact-manifest.json', $wheelEntry.filename) @($wheelhouseFiles.Name | Sort-Object)) -or
+ ($wheelItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
+ $wheelItem.Length -ne $wheelEntry.bytes -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $wheelPath).Hash -cne $wheelEntry.sha256) {
+ throw 'Dependency wheelhouse file set or hash mismatch.'
+}
+
+$entry = Join-Path $sourceRoot 'native_host.py'
+$resolvedPython = (Resolve-Path -LiteralPath $Python).Path
+$pythonItem = Get-Item -LiteralPath $resolvedPython
+$resolvedPyInstallerExecutable = (Resolve-Path -LiteralPath $PyInstallerExecutable).Path
+$pyInstallerItem = Get-Item -LiteralPath $resolvedPyInstallerExecutable
+$builderReceiptPath = (Resolve-Path -LiteralPath $ApprovedBuilderReceipt).Path
+$builderReceiptItem = Get-Item -LiteralPath $builderReceiptPath
+$expectedBuilderReceiptBytes = 2027
+$expectedBuilderReceiptSha256 = 'B65F4184E8782394F2CC27C47CB8656C942E366FD80E8FA7A548CE5A3367BACF'
+$expectedBuilderPythonBytes = 262144
+$expectedBuilderPythonSha256 = '5912D0884B23C0343983A864C6064242391E2265536F50B88624857E353882C9'
+$expectedPyInstallerBytes = 108469
+$expectedPyInstallerSha256 = 'D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39'
+if (($pythonItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $pythonItem.PSIsContainer -or
+ ($pyInstallerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $pyInstallerItem.PSIsContainer -or
+ $builderReceiptItem.PSIsContainer -or ($builderReceiptItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
+ (Test-PathWithin $builderReceiptPath $sourceRoot) -or (Test-PathWithin $builderReceiptPath $resolvedOutput) -or
+ $pythonItem.Length -ne $expectedBuilderPythonBytes -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedPython).Hash -cne $expectedBuilderPythonSha256 -or
+ $pyInstallerItem.Length -ne $expectedPyInstallerBytes -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedPyInstallerExecutable).Hash -cne $expectedPyInstallerSha256 -or
+ $builderReceiptItem.Length -ne $expectedBuilderReceiptBytes -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $builderReceiptPath).Hash -cne $expectedBuilderReceiptSha256) {
+ throw 'Pinned builder executables or external provision receipt do not match the approved environment.'
+}
+$builderReceiptText = [System.IO.File]::ReadAllText($builderReceiptPath, [System.Text.UTF8Encoding]::new($false, $true))
+if (-not $builderReceiptText.Contains('request_handoff: HANDOFF-INFOADMIN-MGADMIN-BILI-PYINSTALLER-6-15-0-OFFLINE-ENV-PROVISION-20260806-001') -or
+ -not $builderReceiptText.Contains("environment_python: ``$resolvedPython``") -or
+ -not $builderReceiptText.Contains("environment_python_bytes_sha256: ``$expectedBuilderPythonBytes/$($expectedBuilderPythonSha256.ToLowerInvariant())``") -or
+ -not $builderReceiptText.Contains("pyinstaller_executable: ``$resolvedPyInstallerExecutable``") -or
+ -not $builderReceiptText.Contains("pyinstaller_executable_bytes_sha256: ``$expectedPyInstallerBytes/$($expectedPyInstallerSha256.ToLowerInvariant())``")) {
+ throw 'External builder provision receipt does not bind the supplied executables.'
+}
+if (Test-Path -LiteralPath $resolvedOutput) {
+ throw 'OutputRoot already exists; overwrite is forbidden.'
+}
+$pythonEnvironment = @{}
+foreach ($variable in @(Get-ChildItem Env: | Where-Object { $_.Name -like 'PYTHON*' })) {
+ $pythonEnvironment[$variable.Name] = $variable.Value
+}
+$outputCreated = $false
+try {
+ foreach ($name in @($pythonEnvironment.Keys)) {
+ Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue
+ }
+ $pyInstallerVersion = & $resolvedPython -I -B -c "import importlib.metadata; print(importlib.metadata.version('PyInstaller'))"
+ if ($LASTEXITCODE -ne 0 -or $pyInstallerVersion.Trim() -cne '6.15.0') {
+ throw 'PyInstaller 6.15.0 is required; no unpinned builder is allowed.'
+ }
+ $pipVersion = & $resolvedPython -I -B -m pip --version
+ if ($LASTEXITCODE -ne 0 -or -not $pipVersion) {
+ throw 'The pinned builder must provide pip for isolated offline dependency installation.'
+ }
+$wheelProbeScript = @'
+import email.parser
+import json
+import sys
+import zipfile
+
+with zipfile.ZipFile(sys.argv[1]) as wheel:
+ metadata_entries = [name for name in wheel.namelist() if name.endswith('.dist-info/METADATA')]
+ if len(metadata_entries) != 1:
+ raise SystemExit(7)
+ metadata = email.parser.BytesParser().parsebytes(wheel.read(metadata_entries[0]))
+ requirements = metadata.get_all('Requires-Dist') or []
+ print(json.dumps({
+ 'metadata_entry': metadata_entries[0],
+ 'name': metadata.get('Name'),
+ 'version': metadata.get('Version'),
+ 'unconditional': [item for item in requirements if 'extra ==' not in item],
+ }))
+'@
+ $wheelProbeJson = & $resolvedPython -I -B -c $wheelProbeScript $wheelPath
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Dependency wheel metadata could not be verified.'
+ }
+ $wheelProbe = $wheelProbeJson | ConvertFrom-Json
+ if ($wheelProbe.metadata_entry -cne 'yt_dlp-2026.7.4.dist-info/METADATA' -or
+ $wheelProbe.name -cne 'yt-dlp' -or $wheelProbe.version -cne '2026.7.4' -or
+ @($wheelProbe.unconditional).Count -ne 0) {
+ throw 'Dependency wheel metadata or unconditional closure mismatch.'
+ }
+ if ($ValidateOnly) {
+ [pscustomobject]@{
+ result = 'VALIDATION_PASS_ONLY'
+ pyinstaller_version = '6.15.0'
+ yt_dlp_version = '2026.7.4'
+ builder_provision_receipt_sha256 = $expectedBuilderReceiptSha256
+ dependency_artifact_manifest_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $dependencyManifestPath).Hash
+ } | ConvertTo-Json -Compress
+ return
+ }
+ [System.IO.Directory]::CreateDirectory($resolvedOutput) | Out-Null
+ $outputCreated = $true
+ $workRoot = Join-Path $resolvedOutput '.build'
+ $specRoot = Join-Path $resolvedOutput '.spec'
+ $dependencyRoot = Join-Path $resolvedOutput '.deps'
+ $previousSourceDateEpoch = [Environment]::GetEnvironmentVariable('SOURCE_DATE_EPOCH', 'Process')
+ try {
+ $env:SOURCE_DATE_EPOCH = [string]$metadataContract.source_date_epoch
+ & $resolvedPython -I -B -m pip install `
+ --isolated `
+ --disable-pip-version-check `
+ --no-index `
+ --find-links $dependencyWheelhouse `
+ --no-deps `
+ --no-compile `
+ --only-binary=:all: `
+ --target $dependencyRoot `
+ 'yt-dlp==2026.7.4'
+ $pipExitCode = $LASTEXITCODE
+ } finally {
+ if ($null -eq $previousSourceDateEpoch) {
+ Remove-Item Env:SOURCE_DATE_EPOCH -ErrorAction SilentlyContinue
+ } else {
+ $env:SOURCE_DATE_EPOCH = $previousSourceDateEpoch
+ }
+ }
+ if ($pipExitCode -ne 0) {
+ throw "Offline yt-dlp installation failed with exit code $pipExitCode."
+ }
+ $dependencyRootItem = Get-Item -LiteralPath $dependencyRoot
+ if (($dependencyRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
+ $dependencyRootItem.PSIsContainer -eq $false -or
+ (Split-Path -Parent $dependencyRootItem.FullName) -cne $resolvedOutput) {
+ throw 'Temporary dependency root is not the owned canonical .deps directory.'
+ }
+ foreach ($dependencyItem in @(Get-ChildItem -LiteralPath $dependencyRoot -Force -Recurse)) {
+ if ($dependencyItem.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw 'Temporary dependency tree contains a reparse path.'
+ }
+ }
+ $dependencyTreeProbeScript = @'
+import base64
+import csv
+import hashlib
+import importlib.metadata as metadata
+import io
+import json
+import pathlib
+import sys
+import zipfile
+
+root = pathlib.Path(sys.argv[1]).resolve(strict=True)
+owned = pathlib.Path(sys.argv[2]).resolve(strict=True)
+wheel = pathlib.Path(sys.argv[3]).resolve(strict=True)
+if root.name != '.deps' or root.parent != owned:
+ raise SystemExit(31)
+
+expected = {}
+record_name = 'yt_dlp-2026.7.4.dist-info/RECORD'
+with zipfile.ZipFile(wheel) as archive:
+ for name in archive.namelist():
+ if name.endswith('/'):
+ continue
+ payload = archive.read(name)
+ if '.data/data/' in name:
+ relative = name.split('.data/data/', 1)[1]
+ elif '.data/scripts/' in name:
+ continue
+ elif '.data/' in name:
+ raise SystemExit(32)
+ else:
+ relative = name
+ if relative != record_name:
+ expected[relative] = (len(payload), hashlib.sha256(payload).digest())
+
+for relative, payload in {
+ 'yt_dlp-2026.7.4.dist-info/INSTALLER': b'pip\n',
+ 'yt_dlp-2026.7.4.dist-info/REQUESTED': b'',
+}.items():
+ expected[relative] = (len(payload), hashlib.sha256(payload).digest())
+
+record_path = root / record_name
+if not record_path.is_file():
+ raise SystemExit(33)
+rows = list(csv.reader(io.StringIO(record_path.read_text(encoding='utf-8'))))
+if any(len(row) != 3 for row in rows):
+ raise SystemExit(34)
+recorded = {}
+for relative, encoded_hash, raw_size in rows:
+ normalized = relative.replace('\\', '/')
+ if normalized.startswith('../../'):
+ normalized = normalized[6:]
+ if normalized.startswith('/') or '..' in pathlib.PurePosixPath(normalized).parts or normalized in recorded:
+ raise SystemExit(35)
+ recorded[normalized] = (encoded_hash, raw_size)
+
+expected_names = set(expected) | {record_name, 'bin/yt-dlp.exe'}
+if set(recorded) != expected_names:
+ raise SystemExit(36)
+actual_names = {
+ path.relative_to(root).as_posix()
+ for path in root.rglob('*')
+ if path.is_file()
+}
+if actual_names != expected_names:
+ raise SystemExit(37)
+
+for relative, (encoded_hash, raw_size) in recorded.items():
+ target = root / pathlib.PurePosixPath(relative)
+ if not target.is_file():
+ raise SystemExit(38)
+ payload = target.read_bytes()
+ if relative == record_name:
+ if encoded_hash or raw_size:
+ raise SystemExit(39)
+ continue
+ expected_hash = 'sha256=' + base64.urlsafe_b64encode(hashlib.sha256(payload).digest()).rstrip(b'=').decode('ascii')
+ if encoded_hash != expected_hash or raw_size != str(len(payload)):
+ raise SystemExit(40)
+
+for relative, (size, digest) in expected.items():
+ payload = (root / pathlib.PurePosixPath(relative)).read_bytes()
+ if len(payload) != size or hashlib.sha256(payload).digest() != digest:
+ raise SystemExit(41)
+
+distributions = list(metadata.distributions(path=[str(root)]))
+identities = [(item.metadata.get('Name'), item.version) for item in distributions]
+if identities != [('yt-dlp', '2026.7.4')]:
+ raise SystemExit(42)
+if any(
+ part.lower() == 'pyinstaller' or part.lower().startswith('pyinstaller-')
+ for relative in actual_names
+ for part in pathlib.PurePosixPath(relative).parts
+):
+ raise SystemExit(43)
+print(json.dumps({'distribution': 'yt-dlp', 'version': '2026.7.4', 'files': len(actual_names)}))
+'@
+ $dependencyTreeJson = & $resolvedPython -I -S -B -c $dependencyTreeProbeScript $dependencyRoot $resolvedOutput $wheelPath
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Temporary dependency tree does not exactly match the pinned wheel-derived installation.'
+ }
+ $dependencyTree = $dependencyTreeJson | ConvertFrom-Json
+ if ($dependencyTree.distribution -cne 'yt-dlp' -or
+ $dependencyTree.version -cne '2026.7.4' -or
+ $dependencyTree.files -lt 1) {
+ throw 'Temporary dependency metadata discovery mismatch.'
+ }
+ $pyInstallerLaunchScript = @'
+import importlib.metadata as metadata
+import pathlib
+import runpy
+import sys
+
+root = pathlib.Path(sys.argv[1]).resolve(strict=True)
+owned = pathlib.Path(sys.argv[2]).resolve(strict=True)
+arguments = sys.argv[3:]
+if root.name != '.deps' or root.parent != owned:
+ raise SystemExit(51)
+sys.path.insert(0, str(root))
+if metadata.version('yt-dlp') != '2026.7.4':
+ raise SystemExit(52)
+sys.argv = ['PyInstaller', *arguments]
+runpy.run_module('PyInstaller', run_name='__main__', alter_sys=True)
+'@
+ & $resolvedPython -I -B -c $pyInstallerLaunchScript $dependencyRoot $resolvedOutput `
+ --noconfirm `
+ --clean `
+ --noupx `
+ --onefile `
+ --paths $dependencyRoot `
+ --collect-all yt_dlp `
+ --copy-metadata yt-dlp `
+ --name project-info-bili-auth-native-host `
+ --distpath $resolvedOutput `
+ --workpath $workRoot `
+ --specpath $specRoot `
+ $entry
+ if ($LASTEXITCODE -ne 0) {
+ throw "PyInstaller failed with exit code $LASTEXITCODE."
+ }
+ $hostExecutable = Join-Path $resolvedOutput 'project-info-bili-auth-native-host.exe'
+ if (-not (Test-Path -LiteralPath $hostExecutable -PathType Leaf)) {
+ throw 'Expected one-file host executable was not produced.'
+ }
+ $requiredArchiveModules = @(
+ 'bili_authenticated_extension.worker', 'yt_dlp', 'yt_dlp.downloader',
+ 'yt_dlp.globals', 'yt_dlp.plugins', 'yt_dlp.version'
+ )
+ $expectedMetadataEntry = $metadataContract.root + '/METADATA'
+ $archiveProbeScript = @'
+import email.parser
+import hashlib
+import json
+import pathlib
+import re
+import sys
+
+from PyInstaller.archive.readers import CArchiveReader
+
+REQUIRED_MODULES = {
+ 'bili_authenticated_extension.worker', 'yt_dlp', 'yt_dlp.downloader',
+ 'yt_dlp.globals', 'yt_dlp.plugins', 'yt_dlp.version',
+}
+
+
+class GateError(Exception):
+ def __init__(self, code, message):
+ super().__init__(message)
+ self.code = code
+
+
+def fail(code, message):
+ raise GateError(code, message)
+
+
+def normalize(raw):
+ if not isinstance(raw, str) or not raw or any(ord(ch) < 32 for ch in raw):
+ fail('E_ARCHIVE_PATH', 'Archive contains an invalid entry name.')
+ value = raw.replace('\\', '/')
+ parts = value.split('/')
+ if value.startswith('/') or any(part in ('', '.', '..') for part in parts) or ':' in parts[0]:
+ fail('E_ARCHIVE_PATH', 'Archive contains a non-canonical entry path.')
+ return '/'.join(parts)
+
+
+def identity(payload):
+ try:
+ message = email.parser.BytesParser().parsebytes(payload)
+ except Exception as exc:
+ fail('E_ARCHIVE_METADATA_IDENTITY', f'Cannot parse package metadata: {type(exc).__name__}.')
+ names = message.get_all('Name', [])
+ versions = message.get_all('Version', [])
+ if len(names) != 1 or len(versions) != 1:
+ fail('E_ARCHIVE_METADATA_IDENTITY', 'Package metadata identity is missing or ambiguous.')
+ return names[0], versions[0]
+
+
+def tree_hash(files):
+ digest = hashlib.sha256()
+ for relative in sorted(files):
+ payload = files[relative]
+ digest.update(relative.encode('utf-8'))
+ digest.update(b'\0')
+ digest.update(str(len(payload)).encode('ascii'))
+ digest.update(b'\0')
+ digest.update(hashlib.sha256(payload).hexdigest().encode('ascii'))
+ digest.update(b'\n')
+ return digest.hexdigest().upper()
+
+
+def main():
+ source_manifest = pathlib.Path(sys.argv[3]).resolve(strict=True)
+ contract = json.loads(source_manifest.read_text(encoding='utf-8'))['archive_metadata_contract']
+ expected_root = contract['root']
+ expected_files = set(contract['relative_files'])
+ allowed_metadata_types = set(contract['allowed_type_codes'])
+ expected_name = contract['distribution_name']
+ expected_version = contract['distribution_version']
+ expected_tree_hash = contract['canonical_tree_sha256']
+ executable = pathlib.Path(sys.argv[1]).resolve(strict=True)
+ dependency_root = pathlib.Path(sys.argv[2]).resolve(strict=True)
+ expected_root_path = (dependency_root / expected_root).resolve(strict=True)
+ if expected_root_path.parent != dependency_root or not expected_root_path.is_dir():
+ fail('E_EXPECTED_METADATA_TREE', 'Validated dependency metadata root is unavailable.')
+ expected = {
+ path.relative_to(expected_root_path).as_posix(): path.read_bytes()
+ for path in expected_root_path.rglob('*')
+ if path.is_file()
+ }
+ if set(expected) != expected_files:
+ fail('E_EXPECTED_METADATA_TREE', 'Validated dependency metadata file set is unexpected.')
+ if identity(expected['METADATA']) != (expected_name, expected_version):
+ fail('E_EXPECTED_METADATA_IDENTITY', 'Validated dependency metadata identity is unexpected.')
+
+ archive = CArchiveReader(str(executable))
+ normalized = {}
+ for raw, entry in archive.toc.items():
+ canonical = normalize(raw)
+ if canonical in normalized:
+ fail('E_ARCHIVE_DUPLICATE_PATH', 'Archive contains duplicate normalized paths.')
+ normalized[canonical] = (raw, entry[-1])
+
+ modules = set()
+ for canonical, (raw, typecode) in normalized.items():
+ modules.add(canonical)
+ if typecode == 'z':
+ embedded = archive.open_embedded_archive(raw)
+ for module_name in embedded.toc:
+ modules.add(normalize(module_name))
+ missing_modules = sorted(REQUIRED_MODULES - modules)
+ if missing_modules:
+ fail('E_ARCHIVE_MODULE_MISSING', 'Static archive is missing required module: ' + missing_modules[0])
+
+ roots = {}
+ for canonical, value in normalized.items():
+ parts = canonical.split('/')
+ for index, part in enumerate(parts):
+ if part.lower().endswith('.dist-info'):
+ root = '/'.join(parts[:index + 1])
+ relative = '/'.join(parts[index + 1:])
+ if not relative or relative in roots.setdefault(root, {}):
+ fail('E_ARCHIVE_DUPLICATE_METADATA', 'Archive package metadata paths are ambiguous.')
+ roots[root][relative] = value
+ break
+
+ candidates = set()
+ for root, files in roots.items():
+ basename = root.rsplit('/', 1)[-1]
+ if re.match(r'^yt[-_.]dlp-', basename, flags=re.IGNORECASE):
+ candidates.add(root)
+ metadata_entry = files.get('METADATA')
+ if metadata_entry is not None:
+ raw, _ = metadata_entry
+ payload = archive.extract(raw)
+ try:
+ name, _ = identity(payload)
+ except GateError:
+ if root in candidates:
+ raise
+ else:
+ if re.sub(r'[-_.]+', '-', name).lower() == 'yt-dlp':
+ candidates.add(root)
+ if len(candidates) == 0:
+ fail('E_ARCHIVE_METADATA_MISSING', 'Static archive is missing pinned yt-dlp package metadata.')
+ if candidates != {expected_root}:
+ fail('E_ARCHIVE_METADATA_DUPLICATE_OR_PATH', 'Static archive yt-dlp metadata root is duplicated or unexpected.')
+
+ archive_files = roots[expected_root]
+ if set(archive_files) != expected_files:
+ fail('E_ARCHIVE_METADATA_FILE_SET', 'Static archive yt-dlp metadata file set is incomplete or unexpected.')
+ actual = {}
+ metadata_types = set()
+ for relative, (raw, typecode) in archive_files.items():
+ if typecode not in allowed_metadata_types:
+ fail('E_ARCHIVE_METADATA_TYPE', 'Static archive metadata is not stored as an approved data representation.')
+ # PyInstaller 6.15.0 promotes DATA to CArchive type "b" on Windows
+ # when os.access(source, X_OK) is true. This loop is reached only after
+ # the unique metadata root and exact relative file set are frozen, so
+ # "b" is never treated as data outside these seven pinned members.
+ metadata_types.add(typecode)
+ actual[relative] = archive.extract(raw)
+ if identity(actual['METADATA']) != (expected_name, expected_version):
+ fail('E_ARCHIVE_METADATA_IDENTITY', 'Static archive yt-dlp Name/Version mismatch.')
+ for relative in sorted(expected_files):
+ if actual[relative] != expected[relative]:
+ fail('E_ARCHIVE_METADATA_BYTES', 'Static archive yt-dlp metadata bytes mismatch: ' + relative)
+ computed_expected_tree_hash = tree_hash(expected)
+ if computed_expected_tree_hash != expected_tree_hash:
+ fail('E_EXPECTED_METADATA_TREE_HASH', 'Validated dependency metadata tree does not match the approved canonical contract.')
+ if tree_hash(actual) != expected_tree_hash:
+ fail('E_ARCHIVE_METADATA_TREE_HASH', 'Static archive yt-dlp metadata tree hash mismatch.')
+ return {
+ 'status': 'PASS',
+ 'method': 'PyInstaller 6.15.0 CArchiveReader exact metadata bytes',
+ 'required_modules': sorted(REQUIRED_MODULES),
+ 'metadata_entry': expected_root + '/METADATA',
+ 'metadata_files': len(expected),
+ 'metadata_type_codes': sorted(metadata_types),
+ 'metadata_tree_sha256': expected_tree_hash,
+ }
+
+
+try:
+ print(json.dumps(main(), sort_keys=True, separators=(',', ':')))
+except GateError as exc:
+ print(json.dumps({'status': 'FAIL', 'code': exc.code, 'message': str(exc)}, sort_keys=True, separators=(',', ':')))
+'@
+ $archiveProbeJson = & $resolvedPython -I -B -c $archiveProbeScript $hostExecutable $dependencyRoot $resolvedSourceManifest
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Structured static PyInstaller archive inspection failed.'
+ }
+ $archiveProbe = $archiveProbeJson | ConvertFrom-Json
+ if ($archiveProbe.status -cne 'PASS') {
+ throw "Static archive verification failed [$($archiveProbe.code)]: $($archiveProbe.message)"
+ }
+ if ($archiveProbe.method -cne 'PyInstaller 6.15.0 CArchiveReader exact metadata bytes' -or
+ $archiveProbe.metadata_entry -cne $expectedMetadataEntry -or
+ $archiveProbe.metadata_files -ne 7 -or
+ $null -eq $archiveProbe.metadata_type_codes -or
+ @($archiveProbe.metadata_type_codes).Count -lt 1 -or
+ @($archiveProbe.metadata_type_codes | Where-Object { $_ -cnotin @($metadataContract.allowed_type_codes) }).Count -ne 0 -or
+ $archiveProbe.metadata_tree_sha256 -cne $metadataContract.canonical_tree_sha256 -or
+ (Compare-Object $requiredArchiveModules @($archiveProbe.required_modules))) {
+ throw 'Structured static archive verification result mismatch.'
+ }
+ foreach ($temporaryPath in @($workRoot, $specRoot, $dependencyRoot)) {
+ if (Test-Path -LiteralPath $temporaryPath) {
+ Remove-Item -LiteralPath $temporaryPath -Recurse -Force
+ }
+ }
+ $unexpected = @(Get-ChildItem -LiteralPath $resolvedOutput -Force | Where-Object { $_.Name -cne 'project-info-bili-auth-native-host.exe' })
+ if ($unexpected.Count -ne 0) {
+ throw 'One-file build produced an unexpected dependency tree.'
+ }
+ $hostItem = Get-Item -LiteralPath $hostExecutable
+ $buildManifest = [ordered]@{
+ schema = 2
+ target = 'BV1HA3o6oEJJ'
+ extension_id = 'oidmclckpdmpabbfedplkbdplmfcenbb'
+ extension_build = 'project-info-bili-auth-ingress/1.0.0+20260805.v002'
+ host_build = 'project-info-bili-auth-native-host/1.0.0+20260805.v002'
+ packaging = 'pyinstaller-onefile'
+ pyinstaller_version = '6.15.0'
+ yt_dlp_version = '2026.7.4'
+ builder_python_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedPython).Hash
+ pyinstaller_executable_bytes = $pyInstallerItem.Length
+ pyinstaller_executable_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedPyInstallerExecutable).Hash
+ builder_provision_receipt_bytes = $builderReceiptItem.Length
+ builder_provision_receipt_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $builderReceiptPath).Hash
+ build_script_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $PSCommandPath).Hash
+ source_artifact_manifest_bytes = (Get-Item -LiteralPath $resolvedSourceManifest).Length
+ source_artifact_manifest_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedSourceManifest).Hash
+ dependency_artifact_manifest_bytes = $dependencyManifestItem.Length
+ dependency_artifact_manifest_sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $dependencyManifestPath).Hash
+ yt_dlp_wheel_sha256 = $wheelEntry.sha256
+ archive_verification = [ordered]@{
+ status = 'PASS'
+ method = $archiveProbe.method
+ required_modules = $requiredArchiveModules
+ metadata_entry = $expectedMetadataEntry
+ metadata_files = $archiveProbe.metadata_files
+ metadata_type_codes = @($archiveProbe.metadata_type_codes)
+ metadata_tree_sha256 = $archiveProbe.metadata_tree_sha256
+ }
+ files = @([ordered]@{
+ path = $hostItem.Name
+ bytes = $hostItem.Length
+ sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $hostExecutable).Hash
+ })
+ }
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+ [System.IO.File]::WriteAllText(
+ (Join-Path $resolvedOutput 'build-artifact-manifest.json'),
+ ($buildManifest | ConvertTo-Json -Depth 5),
+ $utf8NoBom
+ )
+} catch {
+ if ($outputCreated -and (Test-Path -LiteralPath $resolvedOutput)) {
+ Remove-Item -LiteralPath $resolvedOutput -Recurse -Force
+ }
+ throw
+} finally {
+ foreach ($name in @($pythonEnvironment.Keys)) {
+ Set-Item -LiteralPath "Env:$name" -Value $pythonEnvironment[$name]
+ }
+}
diff --git a/dev/project-dev/bili_authenticated_extension/config.example.json b/dev/project-dev/bili_authenticated_extension/config.example.json
new file mode 100644
index 0000000..036dcb5
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/config.example.json
@@ -0,0 +1,18 @@
+{
+ "schema": 1,
+ "target": "BV1HA3o6oEJJ",
+ "canonical_url": "https://www.bilibili.com/video/BV1HA3o6oEJJ",
+ "ffmpeg": "C:\\ABSOLUTE\\ffmpeg.exe",
+ "ffmpeg_sha256": "REQUIRED_SHA256",
+ "ffprobe": "C:\\ABSOLUTE\\ffprobe.exe",
+ "ffprobe_sha256": "REQUIRED_SHA256",
+ "bridge_python": "C:\\ABSOLUTE\\python.exe",
+ "bridge_python_sha256": "REQUIRED_SHA256",
+ "bridge_script": "C:\\ABSOLUTE\\bili_video_download_bridge.py",
+ "bridge_script_sha256": "749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13",
+ "batch_json": "C:\\ABSOLUTE\\exact-batch.json",
+ "batch_json_sha256": "REQUIRED_SHA256",
+ "yt_dlp_executable": "C:\\ABSOLUTE\\yt-dlp.exe",
+ "yt_dlp_executable_sha256": "REQUIRED_SHA256",
+ "destination": "F:\\video\\青枫浦上Q"
+}
diff --git a/dev/project-dev/bili_authenticated_extension/constants.py b/dev/project-dev/bili_authenticated_extension/constants.py
new file mode 100644
index 0000000..36a3d66
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/constants.py
@@ -0,0 +1,83 @@
+"""Frozen identities and limits for the authenticated ingress."""
+
+from __future__ import annotations
+
+TARGET_BVID = "BV1HA3o6oEJJ"
+CANONICAL_URL = "https://www.bilibili.com/video/BV1HA3o6oEJJ"
+TARGET_PATH = "/video/BV1HA3o6oEJJ"
+EXPECTED_DURATION_MS = 3_133_950
+DURATION_TOLERANCE_MS = 3_134
+
+SCHEMA_VERSION = 2
+HOST_NAME = "com.project_info.bili_auth_ingress"
+EXTENSION_BUILD = "project-info-bili-auth-ingress/1.0.0+20260805.v002"
+HOST_BUILD = "project-info-bili-auth-native-host/1.0.0+20260805.v002"
+EXPECTED_EXTENSION_ID = "oidmclckpdmpabbfedplkbdplmfcenbb"
+EXPECTED_ORIGIN = f"chrome-extension://{EXPECTED_EXTENSION_ID}/"
+PUBLIC_KEY_DER_SHA256 = (
+ "E83C2B2AF3CF011543FBA13FBC524D1122EEA68548F9F27B9F7A82B5D594666C"
+)
+MANIFEST_PUBLIC_KEY = (
+ "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt2dT1HGYaI0DXM7zZwOTNBWX"
+ "TKlMBJMpyDVjRUc+v6bUotmLyoraC+ay2scy9UQluSZVYq0tS8qvQNNvuZOlc5w2bOEx"
+ "m4TH2IIKvaVO8nVthHBnNz2kXdiM8ItN0vPZEmS+8gpTCI1+6wPTuUglMoXpqYBYhii8"
+ "fJ5RkENRF3PRJBBigGt8soqdBFRY1QZUmpQv9dYw4dRq4L2C4QtBgClUg4bQpuCppiVZ"
+ "9LHbePi9IAjc9r9R93KLzpaBuXdJpfVRE5w/6YHnxP8ovXxBdl7XktmrdH3xj7mWT7Q7"
+ "ZBxkDNwn2RkruD45XgDD3yuNxOYSkLFMkseN+Ua69gSS4wIDAQAB"
+)
+
+MAX_INPUT_FRAME = 131_072
+MAX_OUTPUT_FRAME = 16_384
+MAX_COOKIE_COUNT = 128
+MAX_SAFE_INTEGER = (1 << 53) - 1
+
+SOCKET_TIMEOUT_SECONDS = 20
+EXTRACTOR_RETRIES = 1
+HTTP_RETRIES = 2
+FRAGMENT_RETRIES = 2
+FILE_ACCESS_RETRIES = 1
+METADATA_TIMEOUT_SECONDS = 120
+DOWNLOAD_TIMEOUT_SECONDS = 7_200
+BRIDGE_TIMEOUT_SECONDS = 900
+GRACEFUL_CANCEL_SECONDS = 3
+JOB_WAIT_SECONDS = 5
+THREAD_JOIN_SECONDS = 5
+
+YTDLP_VERSION = "2026.07.04"
+YTDLP_MODULE_SHA256 = {
+ "version.py": "3848D30C4990355374A1198AD6DAAAB212CD3EF2C92BEA32FA970D913B5EB79F",
+ "__init__.py": "AA2C9CF5A6F2E1D97EFB6EE13DDCE83CACCE4BEF7D6BDC15E5B5F53AF90BB902",
+ "YoutubeDL.py": "41E83B95FCE4A59F09746D0804D80CCFA8489C62C81D2CDFA20CE0E835611A17",
+ "plugins.py": "106991D3275A6A136CA46AE0B334CD5F8F988C77BDDD638E85CC3581FE8F652B",
+ "globals.py": "CD5AFD8A68685DC5C7A02B55D1502B4F9FBE1069A33B571F3B120ABB8C42CD23",
+ "cookies.py": "BAF3B9AF355B436D4DA1C03D88A93349D88833E6D452B255DD4DB93D953B2D21",
+ "extractor/common.py": "539BB835D6FBFBD32BCC2B4E743F2E99E61B8860DC1962B0D714A9DAEC569352",
+ "extractor/bilibili.py": "B99AD995A016B11097CDADFB64BC6DA55CD64129B76E7914EAFBC7E45685EC33",
+ "downloader/__init__.py": "0342E193CB315D6B89384645501761C47A0C5A70D289FE60FF43D67D591E17F7",
+ "downloader/common.py": "EE2E5CD7959FF3C19247E4CED7579037963E1A372875BC67A097B887A60D453B",
+ "downloader/http.py": "E111FFFDC894F68253CE3C5155FA1AA7390E58CAA9A33EEB28D9EF78134E2CD0",
+ "downloader/dash.py": "92B6C7C0944AF2620A1F9581DD6E1EF177059519B480581B990047B53F0AE4A9",
+ "downloader/hls.py": "EFE86E1183B652C1B96254462A79C31924551E5ABC990BBC765C43E307FA460A",
+ "downloader/external.py": "3577991E61D3E4055EB534AF7B9B7AE00E2A2D308A3944FE8CAF0C2E4391E11C",
+ "networking/__init__.py": "B3CA5D8084AEDA1349AF610F532D492225B3990245EB4DB88D084ECA2667CB4F",
+ "networking/common.py": "2647275C8A8C49DC61E36B371BE1D1794D639C015CA73BD9837D710045FF7BF9",
+ "networking/_helper.py": "F7C064184C9CBFBEB3C22BF41218004B30E602D1686599FBAA82FEF1E0C60799",
+ "networking/_urllib.py": "3454CE495D524833D8D978B86B5437B8C390E89B31E5E153CAF2D57C8DF557DE",
+ "utils/_utils.py": "9200BE7E89F04B51243FE20643BC0FAC16186A91FF6B588456E95BC2670F7EB8",
+ "postprocessor/__init__.py": "3960A70BCBE4E01B5E08020156B9F0316AF5D98EB3CB53E20479849C067DB505",
+ "postprocessor/common.py": "4C6BF888FED859FFFF3E649215686BBEC873670DFECA5F14AF3A21D1DA12CBEB",
+ "postprocessor/ffmpeg.py": "3FB12E0FDA8271D33EC3284F42D87A0141A1C3415B9B78B2790B12D536A670F5",
+}
+
+SAFE_PHASES = {
+ "IDLE",
+ "READY",
+ "CHECKING",
+ "DOWNLOADING",
+ "MERGING",
+ "VALIDATING",
+ "PUBLISHING",
+ "COMPLETE",
+ "FAILED",
+ "CANCELED",
+}
diff --git a/dev/project-dev/bili_authenticated_extension/dependencies/dependency-artifact-manifest.json b/dev/project-dev/bili_authenticated_extension/dependencies/dependency-artifact-manifest.json
new file mode 100644
index 0000000..1696342
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/dependencies/dependency-artifact-manifest.json
@@ -0,0 +1,24 @@
+{
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "python_implementation": "CPython",
+ "distribution": "yt-dlp",
+ "version": "2026.7.4",
+ "design_version": "2026.07.04",
+ "acquisition": {
+ "source": "PyPI",
+ "index": "https://pypi.org/simple",
+ "method": "pip download --no-deps --only-binary=:all:",
+ "acquired_at": "2026-08-06T15:55:53.7210025+08:00"
+ },
+ "unconditional_runtime_dependencies": [],
+ "wheels": [
+ {
+ "filename": "yt_dlp-2026.7.4-py3-none-any.whl",
+ "distribution": "yt-dlp",
+ "version": "2026.7.4",
+ "bytes": 3184705,
+ "sha256": "F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266"
+ }
+ ]
+}
diff --git a/dev/project-dev/bili_authenticated_extension/dependencies/yt_dlp-2026.7.4-py3-none-any.whl b/dev/project-dev/bili_authenticated_extension/dependencies/yt_dlp-2026.7.4-py3-none-any.whl
new file mode 100644
index 0000000..28962f8
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/dependencies/yt_dlp-2026.7.4-py3-none-any.whl
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f11f2b11d5a8ac4059f9bdf29fa4407dc7c6bb00c5097e95ca22a7a9db518266
+size 3184705
diff --git a/dev/project-dev/bili_authenticated_extension/install_native_host.ps1 b/dev/project-dev/bili_authenticated_extension/install_native_host.ps1
new file mode 100644
index 0000000..b491b9f
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/install_native_host.ps1
@@ -0,0 +1,451 @@
+[CmdletBinding(SupportsShouldProcess = $true)]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$ObservedExtensionId,
+ [Parameter(Mandatory = $true)]
+ [string]$HostExecutable,
+ [Parameter(Mandatory = $true)]
+ [string]$ConfigFile,
+ [Parameter(Mandatory = $true)]
+ [string]$InstallRoot,
+ [Parameter(Mandatory = $true)]
+ [string]$SourceArtifactManifest,
+ [Parameter(Mandatory = $true)]
+ [string]$BuildArtifactManifest,
+ [Parameter(Mandatory = $true)]
+ [string]$ApprovedSourceReceipt,
+ [Parameter(Mandatory = $true)]
+ [string]$ApprovedBuildReceipt,
+ [switch]$Install,
+ [Parameter(DontShow = $true)]
+ [switch]$TestFileRegistryProvider,
+ [Parameter(DontShow = $true)]
+ [string]$TestRegistryRoot,
+ [Parameter(DontShow = $true)]
+ [ValidateSet('none', 'after-root', 'after-payload', 'after-config', 'after-manifest', 'after-registry-key', 'after-registry-value')]
+ [string]$InjectFailure = 'none'
+)
+
+$ErrorActionPreference = 'Stop'
+$expectedId = 'oidmclckpdmpabbfedplkbdplmfcenbb'
+$expectedOrigin = 'chrome-extension://oidmclckpdmpabbfedplkbdplmfcenbb/'
+$expectedHostName = 'com.project_info.bili_auth_ingress'
+$expectedPublicDerHash = 'E83C2B2AF3CF011543FBA13FBC524D1122EEA68548F9F27B9F7A82B5D594666C'
+$expectedExtensionBuild = 'project-info-bili-auth-ingress/1.0.0+20260805.v002'
+$expectedHostBuild = 'project-info-bili-auth-native-host/1.0.0+20260805.v002'
+$expectedHostExecutable = 'project-info-bili-auth-native-host.exe'
+$publicKey = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt2dT1HGYaI0DXM7zZwOTNBWXTKlMBJMpyDVjRUc+v6bUotmLyoraC+ay2scy9UQluSZVYq0tS8qvQNNvuZOlc5w2bOExm4TH2IIKvaVO8nVthHBnNz2kXdiM8ItN0vPZEmS+8gpTCI1+6wPTuUglMoXpqYBYhii8fJ5RkENRF3PRJBBigGt8soqdBFRY1QZUmpQv9dYw4dRq4L2C4QtBgClUg4bQpuCppiVZ9LHbePi9IAjc9r9R93KLzpaBuXdJpfVRE5w/6YHnxP8ovXxBdl7XktmrdH3xj7mWT7Q7ZBxkDNwn2RkruD45XgDD3yuNxOYSkLFMkseN+Ua69gSS4wIDAQAB'
+
+function Stop-Injected([string]$Point) {
+ if ($InjectFailure -ceq $Point) {
+ throw "Injected installer failure: $Point"
+ }
+}
+
+function Get-StrictJson([string]$Path) {
+ $text = [System.IO.File]::ReadAllText($Path, [System.Text.UTF8Encoding]::new($false, $true))
+ return $text | ConvertFrom-Json
+}
+
+function Test-PathWithin([string]$Candidate, [string]$Root) {
+ $prefix = $Root.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
+ return $Candidate.Equals($Root, [StringComparison]::OrdinalIgnoreCase) -or
+ $Candidate.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
+}
+
+function Get-ExternalReceipt([string]$Path, [string[]]$ForbiddenRoots) {
+ $resolved = (Resolve-Path -LiteralPath $Path).Path
+ $item = Get-Item -LiteralPath $resolved
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
+ throw 'Approval receipt must be a regular non-reparse file.'
+ }
+ foreach ($forbidden in $ForbiddenRoots) {
+ if (Test-PathWithin $resolved $forbidden) {
+ throw 'Approval receipt must be outside the mutable source and build trees.'
+ }
+ }
+ return [pscustomobject]@{ Path = $resolved; Value = (Get-StrictJson $resolved) }
+}
+
+function Assert-ExactSourceTree([string]$Root, [string]$ManifestPath, [string[]]$ExpectedFiles) {
+ $rootItem = Get-Item -LiteralPath $Root
+ if ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw 'Source root must not be a reparse path.'
+ }
+ $actualFiles = @()
+ foreach ($item in @(Get-ChildItem -LiteralPath $Root -Force -Recurse)) {
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ throw 'Source tree contains a reparse path.'
+ }
+ if (-not $item.PSIsContainer -and $item.FullName -cne $ManifestPath) {
+ $actualFiles += $item.FullName.Substring($Root.Length + 1).Replace('\', '/')
+ }
+ }
+ if (Compare-Object -CaseSensitive ($ExpectedFiles | Sort-Object) @($actualFiles | Sort-Object)) {
+ throw 'Actual source tree file set does not exactly match the approved manifest.'
+ }
+}
+
+function Copy-CreateNew([string]$Source, [string]$Destination) {
+ $input = [System.IO.File]::Open($Source, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
+ try {
+ $output = [System.IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
+ try {
+ $input.CopyTo($output)
+ $output.Flush($true)
+ } finally {
+ $output.Dispose()
+ }
+ } finally {
+ $input.Dispose()
+ }
+}
+
+function Write-Utf8CreateNew([string]$Path, [string]$Text) {
+ $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text)
+ $stream = [System.IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
+ try {
+ $stream.Write($bytes, 0, $bytes.Length)
+ $stream.Flush($true)
+ } finally {
+ $stream.Dispose()
+ }
+}
+
+if ($ObservedExtensionId -cne $expectedId) {
+ throw 'Observed Chrome extension ID does not match the pinned identity.'
+}
+$der = [Convert]::FromBase64String($publicKey)
+$sha = [System.Security.Cryptography.SHA256]::Create()
+try {
+ $publicDigest = $sha.ComputeHash($der)
+ $derHash = -join ($publicDigest | ForEach-Object { $_.ToString('X2') })
+} finally {
+ $sha.Dispose()
+}
+if ($derHash -cne $expectedPublicDerHash) {
+ throw 'Pinned extension public key hash does not match.'
+}
+$alphabet = 'abcdefghijklmnop'
+$idBuilder = [System.Text.StringBuilder]::new()
+foreach ($value in $publicDigest[0..15]) {
+ $null = $idBuilder.Append($alphabet[$value -shr 4])
+ $null = $idBuilder.Append($alphabet[$value -band 15])
+}
+if ($idBuilder.ToString() -cne $expectedId) {
+ throw 'Pinned public key does not recompute to the expected extension ID.'
+}
+
+$sourceRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSCommandPath)).Path
+$expectedSourceManifestPath = Join-Path $sourceRoot 'source-artifact-manifest.json'
+$sourceManifestPath = (Resolve-Path -LiteralPath $SourceArtifactManifest).Path
+if ($sourceManifestPath -cne $expectedSourceManifestPath) {
+ throw 'Only the bundled reviewed source artifact manifest is accepted.'
+}
+$buildManifestPath = (Resolve-Path -LiteralPath $BuildArtifactManifest).Path
+if ([System.IO.Path]::GetFileName($buildManifestPath) -cne 'build-artifact-manifest.json') {
+ throw 'Build artifact manifest name mismatch.'
+}
+$buildRoot = Split-Path -Parent $buildManifestPath
+$resolvedHost = (Resolve-Path -LiteralPath $HostExecutable).Path
+
+$sourceApproval = Get-ExternalReceipt $ApprovedSourceReceipt @($sourceRoot, $buildRoot)
+$sourceReceipt = $sourceApproval.Value
+$expectedSourceReceiptKeys = @(
+ 'schema', 'task_id', 'approval_scope', 'approved_by_role', 'status',
+ 'source_artifact_manifest_bytes', 'source_artifact_manifest_sha256'
+)
+if ((Compare-Object ($expectedSourceReceiptKeys | Sort-Object) @($sourceReceipt.PSObject.Properties.Name | Sort-Object)) -or
+ $sourceReceipt.schema -ne 1 -or
+ $sourceReceipt.task_id -cne 'DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001' -or
+ $sourceReceipt.approval_scope -cne 'controlled-build-source-manifest' -or
+ $sourceReceipt.approved_by_role -notin @('dev.reviewer.project', 'project.admin') -or
+ $sourceReceipt.status -cne 'APPROVED' -or
+ $sourceReceipt.source_artifact_manifest_sha256 -notmatch '^[A-F0-9]{64}$' -or
+ $sourceReceipt.source_artifact_manifest_bytes -ne (Get-Item -LiteralPath $sourceManifestPath).Length -or
+ $sourceReceipt.source_artifact_manifest_sha256 -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $sourceManifestPath).Hash) {
+ throw 'External approved source receipt does not match the source manifest.'
+}
+
+$buildApproval = Get-ExternalReceipt $ApprovedBuildReceipt @($sourceRoot, $buildRoot)
+$buildReceipt = $buildApproval.Value
+$expectedBuildReceiptKeys = @(
+ 'schema', 'task_id', 'approval_scope', 'approved_by_role', 'status',
+ 'source_artifact_manifest_sha256', 'build_artifact_manifest_bytes',
+ 'build_artifact_manifest_sha256', 'host_executable_bytes', 'host_executable_sha256'
+)
+if ((Compare-Object ($expectedBuildReceiptKeys | Sort-Object) @($buildReceipt.PSObject.Properties.Name | Sort-Object)) -or
+ $buildReceipt.schema -ne 1 -or
+ $buildReceipt.task_id -cne 'DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001' -or
+ $buildReceipt.approval_scope -cne 'install-exact-build' -or
+ $buildReceipt.approved_by_role -notin @('dev.reviewer.project', 'project.admin') -or
+ $buildReceipt.status -cne 'APPROVED' -or
+ $buildReceipt.source_artifact_manifest_sha256 -cne $sourceReceipt.source_artifact_manifest_sha256 -or
+ $buildReceipt.build_artifact_manifest_sha256 -notmatch '^[A-F0-9]{64}$' -or
+ $buildReceipt.host_executable_sha256 -notmatch '^[A-F0-9]{64}$' -or
+ $buildReceipt.build_artifact_manifest_bytes -ne (Get-Item -LiteralPath $buildManifestPath).Length -or
+ $buildReceipt.build_artifact_manifest_sha256 -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $buildManifestPath).Hash -or
+ $buildReceipt.host_executable_bytes -ne (Get-Item -LiteralPath $resolvedHost).Length -or
+ $buildReceipt.host_executable_sha256 -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedHost).Hash) {
+ throw 'External approved build receipt does not match the exact build artifacts.'
+}
+
+$sourceManifest = Get-StrictJson $sourceManifestPath
+$expectedSourceFiles = @(
+ '__init__.py', 'background.js', 'build_host.ps1', 'config.example.json',
+ 'constants.py', 'dependencies/dependency-artifact-manifest.json',
+ 'dependencies/yt_dlp-2026.7.4-py3-none-any.whl',
+ 'install_native_host.ps1', 'job.py', 'manifest.json',
+ 'native-host-manifest.template.json', 'native_host.py', 'protocol.py',
+ 'sidepanel.css', 'sidepanel.html', 'sidepanel.js', 'worker.py'
+)
+Assert-ExactSourceTree $sourceRoot $sourceManifestPath $expectedSourceFiles
+$dependencyManifestPath = Join-Path $sourceRoot 'dependencies/dependency-artifact-manifest.json'
+$dependencyManifestItem = Get-Item -LiteralPath $dependencyManifestPath
+if ($sourceManifest.schema -ne 1 -or $sourceManifest.target -cne 'BV1HA3o6oEJJ' -or
+ $sourceManifest.extension_id -cne $expectedId -or
+ $sourceManifest.extension_build -cne $expectedExtensionBuild -or
+ $sourceManifest.host_build -cne $expectedHostBuild -or
+ $sourceManifest.dependency_artifact_manifest_bytes -ne $dependencyManifestItem.Length -or
+ $sourceManifest.dependency_artifact_manifest_sha256 -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $dependencyManifestPath).Hash -or
+ (Compare-Object -CaseSensitive ($expectedSourceFiles | Sort-Object) @($sourceManifest.files | ForEach-Object { $_.path } | Sort-Object))) {
+ throw 'Source artifact manifest identity or file set mismatch.'
+}
+$metadataContract = $sourceManifest.archive_metadata_contract
+$expectedMetadataContractKeys = @(
+ 'schema', 'root', 'relative_files', 'distribution_name', 'distribution_version',
+ 'allowed_type_codes', 'source_date_epoch', 'tree_hash_algorithm', 'canonical_tree_sha256'
+)
+if ($null -eq $metadataContract -or
+ (Compare-Object ($expectedMetadataContractKeys | Sort-Object) @($metadataContract.PSObject.Properties.Name | Sort-Object)) -or
+ $metadataContract.schema -ne 1 -or
+ $metadataContract.root -cne 'yt_dlp-2026.7.4.dist-info' -or
+ (Compare-Object -CaseSensitive @(
+ 'INSTALLER', 'METADATA', 'RECORD', 'REQUESTED', 'WHEEL',
+ 'entry_points.txt', 'licenses/LICENSE'
+ ) @($metadataContract.relative_files)) -or
+ $metadataContract.distribution_name -cne 'yt-dlp' -or
+ $metadataContract.distribution_version -cne '2026.7.4' -or
+ (Compare-Object -CaseSensitive @('b', 'x') @($metadataContract.allowed_type_codes)) -or
+ $metadataContract.source_date_epoch -ne 1786207924 -or
+ $metadataContract.tree_hash_algorithm -cne 'sha256(path-utf8,nul,decimal-bytes-ascii,nul,payload-sha256-lower-hex-ascii,lf)-upper-hex-v1' -or
+ $metadataContract.canonical_tree_sha256 -notmatch '^[A-F0-9]{64}$') {
+ throw 'Source artifact metadata contract mismatch.'
+}
+$seenSourcePaths = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+foreach ($entry in $sourceManifest.files) {
+ if ($entry.path -notmatch '^[A-Za-z0-9._/-]+$' -or $entry.path.Contains('..') -or
+ $entry.sha256 -notmatch '^[A-F0-9]{64}$' -or $entry.bytes -lt 1 -or
+ -not $seenSourcePaths.Add($entry.path)) {
+ throw 'Source artifact manifest contains an invalid entry.'
+ }
+ $candidate = [System.IO.Path]::GetFullPath((Join-Path $sourceRoot $entry.path))
+ if (-not $candidate.StartsWith($sourceRoot + [System.IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'Source artifact path escaped its root.'
+ }
+ $item = Get-Item -LiteralPath $candidate
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
+ $item.Length -ne $entry.bytes -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $candidate).Hash -cne $entry.sha256) {
+ throw 'Source artifact hash mismatch.'
+ }
+}
+
+$buildManifest = Get-StrictJson $buildManifestPath
+$buildScript = Join-Path $sourceRoot 'build_host.ps1'
+if ($buildManifest.schema -ne 2 -or $buildManifest.target -cne 'BV1HA3o6oEJJ' -or
+ $buildManifest.extension_id -cne $expectedId -or
+ $buildManifest.extension_build -cne $expectedExtensionBuild -or
+ $buildManifest.host_build -cne $expectedHostBuild -or
+ $buildManifest.packaging -cne 'pyinstaller-onefile' -or
+ $buildManifest.pyinstaller_version -cne '6.15.0' -or
+ $buildManifest.yt_dlp_version -cne '2026.7.4' -or
+ $buildManifest.pyinstaller_executable_bytes -ne 108469 -or
+ $buildManifest.pyinstaller_executable_sha256 -cne 'D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39' -or
+ $buildManifest.builder_provision_receipt_bytes -ne 2027 -or
+ $buildManifest.builder_provision_receipt_sha256 -cne 'B65F4184E8782394F2CC27C47CB8656C942E366FD80E8FA7A548CE5A3367BACF' -or
+ $buildManifest.build_script_sha256 -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $buildScript).Hash -or
+ $buildManifest.source_artifact_manifest_bytes -ne (Get-Item -LiteralPath $sourceManifestPath).Length -or
+ $buildManifest.source_artifact_manifest_sha256 -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $sourceManifestPath).Hash -or
+ $buildManifest.dependency_artifact_manifest_bytes -ne $dependencyManifestItem.Length -or
+ $buildManifest.dependency_artifact_manifest_sha256 -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $dependencyManifestPath).Hash -or
+ $buildManifest.yt_dlp_wheel_sha256 -cne 'F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266' -or
+ $buildManifest.archive_verification.status -cne 'PASS' -or
+ $buildManifest.archive_verification.method -cne 'PyInstaller 6.15.0 CArchiveReader exact metadata bytes' -or
+ $buildManifest.archive_verification.metadata_entry -cne ($metadataContract.root + '/METADATA') -or
+ $buildManifest.archive_verification.metadata_files -ne @($metadataContract.relative_files).Count -or
+ $null -eq $buildManifest.archive_verification.metadata_type_codes -or
+ @($buildManifest.archive_verification.metadata_type_codes).Count -lt 1 -or
+ @($buildManifest.archive_verification.metadata_type_codes | Where-Object { $_ -cnotin @($metadataContract.allowed_type_codes) }).Count -ne 0 -or
+ $buildManifest.archive_verification.metadata_tree_sha256 -cne $metadataContract.canonical_tree_sha256 -or
+ (Compare-Object @(
+ 'bili_authenticated_extension.worker', 'yt_dlp', 'yt_dlp.downloader',
+ 'yt_dlp.globals', 'yt_dlp.plugins', 'yt_dlp.version'
+ ) @($buildManifest.archive_verification.required_modules)) -or
+ $buildManifest.builder_python_sha256 -notmatch '^[A-F0-9]{64}$' -or
+ $null -eq $buildManifest.files -or $buildManifest.files.Count -ne 1) {
+ throw 'Build artifact receipt identity mismatch.'
+}
+$buildEntry = $buildManifest.files[0]
+if ($buildEntry.path -cne $expectedHostExecutable -or
+ $buildEntry.sha256 -notmatch '^[A-F0-9]{64}$' -or $buildEntry.bytes -lt 1) {
+ throw 'Build artifact receipt file set mismatch.'
+}
+$expectedBuiltHost = [System.IO.Path]::GetFullPath((Join-Path $buildRoot $buildEntry.path))
+if ($resolvedHost -cne $expectedBuiltHost) {
+ throw 'Host executable is not the exact build receipt payload.'
+}
+$hostItem = Get-Item -LiteralPath $resolvedHost
+if (($hostItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
+ $hostItem.Length -ne $buildEntry.bytes -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedHost).Hash -cne $buildEntry.sha256) {
+ throw 'Host executable does not match the build artifact receipt.'
+}
+$unexpectedBuildFiles = @(Get-ChildItem -LiteralPath $buildRoot -Force | Where-Object { $_.Name -notin @($expectedHostExecutable, 'build-artifact-manifest.json') })
+if ($unexpectedBuildFiles.Count -ne 0) {
+ throw 'Build root contains an unreviewed dependency or file.'
+}
+
+$config = (Resolve-Path -LiteralPath $ConfigFile).Path
+$root = [System.IO.Path]::GetFullPath($InstallRoot)
+if (-not [System.IO.Path]::IsPathRooted($root) -or $root.StartsWith('\\')) {
+ throw 'InstallRoot must be an absolute local path.'
+}
+$configItem = Get-Item -LiteralPath $config
+if (($configItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $configItem.PSIsContainer) {
+ throw 'Config file must be a regular non-reparse file.'
+}
+$configObject = Get-StrictJson $config
+$expectedConfigKeys = @(
+ 'schema', 'target', 'canonical_url', 'ffmpeg', 'ffmpeg_sha256', 'ffprobe',
+ 'ffprobe_sha256', 'bridge_python', 'bridge_python_sha256', 'bridge_script',
+ 'bridge_script_sha256', 'batch_json', 'batch_json_sha256', 'yt_dlp_executable',
+ 'yt_dlp_executable_sha256', 'destination'
+)
+$actualConfigKeys = @($configObject.PSObject.Properties.Name | Sort-Object)
+if ((Compare-Object ($expectedConfigKeys | Sort-Object) $actualConfigKeys) -or
+ $configObject.schema -ne 1 -or $configObject.target -cne 'BV1HA3o6oEJJ' -or
+ $configObject.canonical_url -cne 'https://www.bilibili.com/video/BV1HA3o6oEJJ' -or
+ $configObject.bridge_script_sha256 -cne '749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13') {
+ throw 'Host config identity mismatch.'
+}
+foreach ($name in @('ffmpeg', 'ffprobe', 'bridge_python', 'bridge_script', 'batch_json', 'yt_dlp_executable')) {
+ $value = $configObject.$name
+ $expectedHash = $configObject."${name}_sha256"
+ if (-not [System.IO.Path]::IsPathRooted($value) -or $value.StartsWith('\\') -or
+ $expectedHash -notmatch '^[A-F0-9]{64}$') {
+ throw 'Host config contains an unsafe pinned file.'
+ }
+ $resolvedValue = (Resolve-Path -LiteralPath $value).Path
+ $valueItem = Get-Item -LiteralPath $resolvedValue
+ if (($valueItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedValue).Hash -cne $expectedHash) {
+ throw 'Host config pinned file hash mismatch.'
+ }
+}
+if (-not [System.IO.Path]::IsPathRooted($configObject.destination) -or $configObject.destination.StartsWith('\\')) {
+ throw 'Host config destination must be an absolute local path.'
+}
+$resolvedDestination = (Resolve-Path -LiteralPath $configObject.destination).Path
+$destinationItem = Get-Item -LiteralPath $resolvedDestination
+if (-not $destinationItem.PSIsContainer -or ($destinationItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
+ throw 'Host config destination must be an existing non-reparse directory.'
+}
+if (Get-ChildItem -LiteralPath $resolvedDestination -File | Where-Object { $_.Name.StartsWith('BV1HA3o6oEJJ.', [StringComparison]::OrdinalIgnoreCase) }) {
+ throw 'Formal output already exists; overwrite is forbidden.'
+}
+if (Test-Path -LiteralPath $root) {
+ throw 'InstallRoot already exists; overwrite is forbidden.'
+}
+
+$registryPath = "HKCU:\Software\Google\Chrome\NativeMessagingHosts\$expectedHostName"
+if ($TestFileRegistryProvider) {
+ if (-not $TestRegistryRoot) {
+ throw 'TestRegistryRoot is required for the test file registry provider.'
+ }
+ $testRegistryBase = [System.IO.Path]::GetFullPath($TestRegistryRoot)
+ $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
+ if (-not $testRegistryBase.StartsWith($tempBase, [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'The test registry provider must be under the current temporary directory.'
+ }
+ $registryPath = Join-Path $testRegistryBase $expectedHostName
+} elseif ($TestRegistryRoot -or $InjectFailure -cne 'none') {
+ throw 'Test-only controls require TestFileRegistryProvider.'
+}
+if (Test-Path -LiteralPath $registryPath) {
+ throw 'Native Messaging registration already exists; overwrite is forbidden.'
+}
+
+if (-not $Install) {
+ [pscustomobject]@{
+ result = 'VALIDATION_PASS_ONLY'
+ extension_id = $expectedId
+ origin = $expectedOrigin
+ host_name = $expectedHostName
+ packaging = 'pyinstaller-onefile'
+ } | ConvertTo-Json -Compress
+ return
+}
+
+if ($PSCmdlet.ShouldProcess($root, 'Install exact-BVID Native Messaging host for current user')) {
+ try {
+ [System.IO.Directory]::CreateDirectory($root) | Out-Null
+ Stop-Injected 'after-root'
+ $installedHost = Join-Path $root $expectedHostExecutable
+ $installedConfig = Join-Path $root 'config.json'
+ Copy-CreateNew $resolvedHost $installedHost
+ if ((Get-Item -LiteralPath $installedHost).Length -ne $buildEntry.bytes -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $installedHost).Hash -cne $buildEntry.sha256) {
+ throw 'Installed host reread verification failed.'
+ }
+ Stop-Injected 'after-payload'
+ Copy-CreateNew $config $installedConfig
+ if ((Get-Item -LiteralPath $installedConfig).Length -ne $configItem.Length -or
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $installedConfig).Hash -cne (Get-FileHash -Algorithm SHA256 -LiteralPath $config).Hash) {
+ throw 'Installed config reread verification failed.'
+ }
+ Stop-Injected 'after-config'
+ $manifestPath = Join-Path $root 'native-host-manifest.json'
+ $manifest = [ordered]@{
+ name = $expectedHostName
+ description = 'project-info exact-BVID authenticated ingress'
+ path = $installedHost
+ type = 'stdio'
+ allowed_origins = @($expectedOrigin)
+ }
+ Write-Utf8CreateNew $manifestPath ($manifest | ConvertTo-Json -Depth 3)
+ $persistedManifest = Get-StrictJson $manifestPath
+ if ($persistedManifest.name -cne $expectedHostName -or
+ $persistedManifest.path -cne $installedHost -or
+ $persistedManifest.type -cne 'stdio' -or
+ $persistedManifest.allowed_origins.Count -ne 1 -or
+ $persistedManifest.allowed_origins[0] -cne $expectedOrigin) {
+ throw 'Native host manifest reread verification failed.'
+ }
+ Stop-Injected 'after-manifest'
+
+ if ($TestFileRegistryProvider) {
+ [System.IO.Directory]::CreateDirectory($registryPath) | Out-Null
+ } else {
+ New-Item -Path $registryPath -ErrorAction Stop | Out-Null
+ }
+ Stop-Injected 'after-registry-key'
+ if ($TestFileRegistryProvider) {
+ Write-Utf8CreateNew (Join-Path $registryPath 'default.value') $manifestPath
+ } else {
+ Set-Item -LiteralPath $registryPath -Value $manifestPath -ErrorAction Stop
+ }
+ Stop-Injected 'after-registry-value'
+ } catch {
+ # Both targets were proven absent before the transaction, so any
+ # surviving object belongs to this attempt even if a provider threw
+ # after partially creating it.
+ if (Test-Path -LiteralPath $registryPath) {
+ Remove-Item -LiteralPath $registryPath -Recurse -Force
+ }
+ if (Test-Path -LiteralPath $root) {
+ Remove-Item -LiteralPath $root -Recurse -Force
+ }
+ throw
+ }
+}
diff --git a/dev/project-dev/bili_authenticated_extension/job.py b/dev/project-dev/bili_authenticated_extension/job.py
new file mode 100644
index 0000000..39583ca
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/job.py
@@ -0,0 +1,415 @@
+"""Windows Job Object ownership for the authenticated worker tree.
+
+The worker is created suspended, assigned to a per-task kill-on-close job, and
+only then resumed. There is intentionally no non-Windows fallback.
+"""
+
+from __future__ import annotations
+
+import ctypes
+import os
+import subprocess
+from ctypes import wintypes
+from dataclasses import dataclass
+from typing import Iterable, Sequence
+
+
+class JobError(RuntimeError):
+ pass
+
+
+if os.name == "nt":
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+
+ CREATE_SUSPENDED = 0x00000004
+ CREATE_NO_WINDOW = 0x08000000
+ EXTENDED_STARTUPINFO_PRESENT = 0x00080000
+ STARTF_USESTDHANDLES = 0x00000100
+ HANDLE_FLAG_INHERIT = 0x00000001
+ PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
+ JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
+ WAIT_OBJECT_0 = 0
+ WAIT_TIMEOUT = 258
+ INFINITE = 0xFFFFFFFF
+ GENERIC_READ = 0x80000000
+ GENERIC_WRITE = 0x40000000
+ OPEN_EXISTING = 3
+ FILE_ATTRIBUTE_NORMAL = 0x80
+
+ class SECURITY_ATTRIBUTES(ctypes.Structure):
+ _fields_ = [
+ ("nLength", wintypes.DWORD),
+ ("lpSecurityDescriptor", wintypes.LPVOID),
+ ("bInheritHandle", wintypes.BOOL),
+ ]
+
+ class STARTUPINFOW(ctypes.Structure):
+ _fields_ = [
+ ("cb", wintypes.DWORD),
+ ("lpReserved", wintypes.LPWSTR),
+ ("lpDesktop", wintypes.LPWSTR),
+ ("lpTitle", wintypes.LPWSTR),
+ ("dwX", wintypes.DWORD),
+ ("dwY", wintypes.DWORD),
+ ("dwXSize", wintypes.DWORD),
+ ("dwYSize", wintypes.DWORD),
+ ("dwXCountChars", wintypes.DWORD),
+ ("dwYCountChars", wintypes.DWORD),
+ ("dwFillAttribute", wintypes.DWORD),
+ ("dwFlags", wintypes.DWORD),
+ ("wShowWindow", wintypes.WORD),
+ ("cbReserved2", wintypes.WORD),
+ ("lpReserved2", ctypes.POINTER(ctypes.c_ubyte)),
+ ("hStdInput", wintypes.HANDLE),
+ ("hStdOutput", wintypes.HANDLE),
+ ("hStdError", wintypes.HANDLE),
+ ]
+
+ class STARTUPINFOEXW(ctypes.Structure):
+ _fields_ = [
+ ("StartupInfo", STARTUPINFOW),
+ ("lpAttributeList", wintypes.LPVOID),
+ ]
+
+ class PROCESS_INFORMATION(ctypes.Structure):
+ _fields_ = [
+ ("hProcess", wintypes.HANDLE),
+ ("hThread", wintypes.HANDLE),
+ ("dwProcessId", wintypes.DWORD),
+ ("dwThreadId", wintypes.DWORD),
+ ]
+
+ class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
+ _fields_ = [
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
+ ("PerJobUserTimeLimit", ctypes.c_int64),
+ ("LimitFlags", wintypes.DWORD),
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
+ ("ActiveProcessLimit", wintypes.DWORD),
+ ("Affinity", ctypes.c_size_t),
+ ("PriorityClass", wintypes.DWORD),
+ ("SchedulingClass", wintypes.DWORD),
+ ]
+
+ class IO_COUNTERS(ctypes.Structure):
+ _fields_ = [
+ ("ReadOperationCount", ctypes.c_uint64),
+ ("WriteOperationCount", ctypes.c_uint64),
+ ("OtherOperationCount", ctypes.c_uint64),
+ ("ReadTransferCount", ctypes.c_uint64),
+ ("WriteTransferCount", ctypes.c_uint64),
+ ("OtherTransferCount", ctypes.c_uint64),
+ ]
+
+ class JOBOBJECT_EXTENDED_LIMIT_INFORMATION_STRUCT(ctypes.Structure):
+ _fields_ = [
+ ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
+ ("IoInfo", IO_COUNTERS),
+ ("ProcessMemoryLimit", ctypes.c_size_t),
+ ("JobMemoryLimit", ctypes.c_size_t),
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
+ ]
+
+ kernel32.CreateJobObjectW.argtypes = [ctypes.POINTER(SECURITY_ATTRIBUTES), wintypes.LPCWSTR]
+ kernel32.CreateJobObjectW.restype = wintypes.HANDLE
+ kernel32.SetInformationJobObject.argtypes = [
+ wintypes.HANDLE,
+ ctypes.c_int,
+ wintypes.LPVOID,
+ wintypes.DWORD,
+ ]
+ kernel32.SetInformationJobObject.restype = wintypes.BOOL
+ kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
+ kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
+ kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
+ kernel32.TerminateJobObject.restype = wintypes.BOOL
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
+ kernel32.CloseHandle.restype = wintypes.BOOL
+ kernel32.ResumeThread.argtypes = [wintypes.HANDLE]
+ kernel32.ResumeThread.restype = wintypes.DWORD
+ kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
+ kernel32.TerminateProcess.restype = wintypes.BOOL
+ kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
+ kernel32.WaitForSingleObject.restype = wintypes.DWORD
+ kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
+ kernel32.GetExitCodeProcess.restype = wintypes.BOOL
+ kernel32.SetHandleInformation.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD]
+ kernel32.SetHandleInformation.restype = wintypes.BOOL
+ kernel32.InitializeProcThreadAttributeList.argtypes = [
+ wintypes.LPVOID,
+ wintypes.DWORD,
+ wintypes.DWORD,
+ ctypes.POINTER(ctypes.c_size_t),
+ ]
+ kernel32.InitializeProcThreadAttributeList.restype = wintypes.BOOL
+ kernel32.UpdateProcThreadAttribute.argtypes = [
+ wintypes.LPVOID,
+ wintypes.DWORD,
+ ctypes.c_size_t,
+ wintypes.LPVOID,
+ ctypes.c_size_t,
+ wintypes.LPVOID,
+ wintypes.LPVOID,
+ ]
+ kernel32.UpdateProcThreadAttribute.restype = wintypes.BOOL
+ kernel32.DeleteProcThreadAttributeList.argtypes = [wintypes.LPVOID]
+ kernel32.DeleteProcThreadAttributeList.restype = None
+ kernel32.CreateProcessW.argtypes = [
+ wintypes.LPCWSTR,
+ wintypes.LPWSTR,
+ ctypes.POINTER(SECURITY_ATTRIBUTES),
+ ctypes.POINTER(SECURITY_ATTRIBUTES),
+ wintypes.BOOL,
+ wintypes.DWORD,
+ wintypes.LPVOID,
+ wintypes.LPCWSTR,
+ ctypes.POINTER(STARTUPINFOW),
+ ctypes.POINTER(PROCESS_INFORMATION),
+ ]
+ kernel32.CreateProcessW.restype = wintypes.BOOL
+ kernel32.CreateFileW.argtypes = [
+ wintypes.LPCWSTR,
+ wintypes.DWORD,
+ wintypes.DWORD,
+ ctypes.POINTER(SECURITY_ATTRIBUTES),
+ wintypes.DWORD,
+ wintypes.DWORD,
+ wintypes.HANDLE,
+ ]
+ kernel32.CreateFileW.restype = wintypes.HANDLE
+
+
+def _win_error(prefix: str) -> JobError:
+ return JobError(f"{prefix}: winerror={ctypes.get_last_error()}")
+
+
+def _close_handle(handle: int | None) -> None:
+ if os.name == "nt" and handle:
+ kernel32.CloseHandle(wintypes.HANDLE(handle))
+
+
+def set_handle_inheritable(handle: int, inheritable: bool) -> None:
+ if os.name != "nt":
+ raise JobError("Windows is required")
+ flags = HANDLE_FLAG_INHERIT if inheritable else 0
+ if not kernel32.SetHandleInformation(handle, HANDLE_FLAG_INHERIT, flags):
+ raise _win_error("SetHandleInformation")
+
+
+def create_event(inheritable: bool = True) -> int:
+ if os.name != "nt":
+ raise JobError("Windows is required")
+ create_event_w = kernel32.CreateEventW
+ create_event_w.argtypes = [ctypes.POINTER(SECURITY_ATTRIBUTES), wintypes.BOOL, wintypes.BOOL, wintypes.LPCWSTR]
+ create_event_w.restype = wintypes.HANDLE
+ sa = SECURITY_ATTRIBUTES(ctypes.sizeof(SECURITY_ATTRIBUTES), None, bool(inheritable))
+ handle = create_event_w(ctypes.byref(sa), True, False, None)
+ if not handle:
+ raise _win_error("CreateEventW")
+ return int(handle)
+
+
+def set_event(handle: int) -> None:
+ set_event_fn = kernel32.SetEvent
+ set_event_fn.argtypes = [wintypes.HANDLE]
+ set_event_fn.restype = wintypes.BOOL
+ if not set_event_fn(handle):
+ raise _win_error("SetEvent")
+
+
+def is_event_set(handle: int) -> bool:
+ result = kernel32.WaitForSingleObject(handle, 0)
+ if result == WAIT_OBJECT_0:
+ return True
+ if result == WAIT_TIMEOUT:
+ return False
+ raise _win_error("WaitForSingleObject(event)")
+
+
+@dataclass
+class JobProcess:
+ process_handle: int
+ pid: int
+
+ def wait(self, timeout_seconds: float | None = None) -> bool:
+ timeout_ms = INFINITE if timeout_seconds is None else max(0, int(timeout_seconds * 1000))
+ result = kernel32.WaitForSingleObject(self.process_handle, timeout_ms)
+ if result == WAIT_OBJECT_0:
+ return True
+ if result == WAIT_TIMEOUT:
+ return False
+ raise _win_error("WaitForSingleObject(process)")
+
+ def exit_code(self) -> int | None:
+ code = wintypes.DWORD()
+ if not kernel32.GetExitCodeProcess(self.process_handle, ctypes.byref(code)):
+ raise _win_error("GetExitCodeProcess")
+ return None if code.value == 259 else int(code.value)
+
+ def terminate(self, code: int = 125) -> None:
+ if not kernel32.TerminateProcess(self.process_handle, code):
+ error = ctypes.get_last_error()
+ if error != 5:
+ raise _win_error("TerminateProcess")
+
+ def close(self) -> None:
+ if self.process_handle:
+ _close_handle(self.process_handle)
+ self.process_handle = 0
+
+ def __enter__(self) -> "JobProcess":
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ self.close()
+
+
+class WindowsJob:
+ """One kill-on-close job whose handle is never inherited."""
+
+ def __init__(self) -> None:
+ if os.name != "nt":
+ raise JobError("E_JOB_OBJECT")
+ handle = kernel32.CreateJobObjectW(None, None)
+ if not handle:
+ raise _win_error("CreateJobObjectW")
+ self.handle = int(handle)
+ try:
+ info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION_STRUCT()
+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
+ if not kernel32.SetInformationJobObject(
+ self.handle,
+ JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
+ ctypes.byref(info),
+ ctypes.sizeof(info),
+ ):
+ raise _win_error("SetInformationJobObject")
+ set_handle_inheritable(self.handle, False)
+ except BaseException:
+ self.close()
+ raise
+
+ def launch_suspended(
+ self,
+ command: Sequence[str],
+ *,
+ stdin_handle: int,
+ stdout_handle: int,
+ stderr_handle: int,
+ inherited_handles: Iterable[int] = (),
+ cwd: str | None = None,
+ ) -> JobProcess:
+ handles = list(dict.fromkeys([stdin_handle, stdout_handle, stderr_handle, *inherited_handles]))
+ if not handles or any(not item for item in handles):
+ raise JobError("invalid inherited handle")
+ for handle in handles:
+ set_handle_inheritable(handle, True)
+
+ attribute_size = ctypes.c_size_t()
+ kernel32.InitializeProcThreadAttributeList(None, 1, 0, ctypes.byref(attribute_size))
+ attribute_buffer = ctypes.create_string_buffer(attribute_size.value)
+ attribute_list = ctypes.cast(attribute_buffer, wintypes.LPVOID)
+ if not kernel32.InitializeProcThreadAttributeList(
+ attribute_list, 1, 0, ctypes.byref(attribute_size)
+ ):
+ raise _win_error("InitializeProcThreadAttributeList")
+ raw_handles = (wintypes.HANDLE * len(handles))(*handles)
+ process_info = PROCESS_INFORMATION()
+ try:
+ if not kernel32.UpdateProcThreadAttribute(
+ attribute_list,
+ 0,
+ PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
+ ctypes.cast(raw_handles, wintypes.LPVOID),
+ ctypes.sizeof(raw_handles),
+ None,
+ None,
+ ):
+ raise _win_error("UpdateProcThreadAttribute")
+ startup = STARTUPINFOEXW()
+ startup.StartupInfo.cb = ctypes.sizeof(startup)
+ startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES
+ startup.StartupInfo.hStdInput = stdin_handle
+ startup.StartupInfo.hStdOutput = stdout_handle
+ startup.StartupInfo.hStdError = stderr_handle
+ startup.lpAttributeList = attribute_list
+ command_line = ctypes.create_unicode_buffer(subprocess.list2cmdline(list(command)))
+ flags = CREATE_SUSPENDED | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT
+ if not kernel32.CreateProcessW(
+ None,
+ command_line,
+ None,
+ None,
+ True,
+ flags,
+ None,
+ cwd,
+ ctypes.byref(startup.StartupInfo),
+ ctypes.byref(process_info),
+ ):
+ raise _win_error("CreateProcessW")
+ try:
+ if not kernel32.AssignProcessToJobObject(self.handle, process_info.hProcess):
+ raise _win_error("AssignProcessToJobObject")
+ if kernel32.ResumeThread(process_info.hThread) == 0xFFFFFFFF:
+ raise _win_error("ResumeThread")
+ return JobProcess(int(process_info.hProcess), int(process_info.dwProcessId))
+ except BaseException:
+ kernel32.TerminateProcess(process_info.hProcess, 125)
+ kernel32.WaitForSingleObject(process_info.hProcess, 5000)
+ _close_handle(int(process_info.hProcess))
+ raise
+ finally:
+ _close_handle(int(process_info.hThread))
+ finally:
+ kernel32.DeleteProcThreadAttributeList(attribute_list)
+ for handle in handles:
+ try:
+ set_handle_inheritable(handle, False)
+ except JobError:
+ pass
+
+ def terminate(self, code: int = 125) -> None:
+ if self.handle and not kernel32.TerminateJobObject(self.handle, code):
+ raise _win_error("TerminateJobObject")
+
+ def close(self) -> None:
+ if self.handle:
+ _close_handle(self.handle)
+ self.handle = 0
+
+ def __enter__(self) -> "WindowsJob":
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ self.close()
+
+
+def open_nul_handles() -> tuple[int, int, int]:
+ """Open inheritable NUL handles suitable for STARTUPINFOEX."""
+ if os.name != "nt":
+ raise JobError("Windows is required")
+ sa = SECURITY_ATTRIBUTES(ctypes.sizeof(SECURITY_ATTRIBUTES), None, True)
+ read_handle = kernel32.CreateFileW(
+ "NUL", GENERIC_READ, 3, ctypes.byref(sa), OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None
+ )
+ write_handle = kernel32.CreateFileW(
+ "NUL", GENERIC_WRITE, 3, ctypes.byref(sa), OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None
+ )
+ error_handle = kernel32.CreateFileW(
+ "NUL", GENERIC_WRITE, 3, ctypes.byref(sa), OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None
+ )
+ if not read_handle or not write_handle or not error_handle:
+ for handle in (read_handle, write_handle, error_handle):
+ if handle:
+ _close_handle(int(handle))
+ raise _win_error("CreateFileW(NUL)")
+ return int(read_handle), int(write_handle), int(error_handle)
+
+
+def close_handles(*handles: int) -> None:
+ for handle in handles:
+ _close_handle(handle)
diff --git a/dev/project-dev/bili_authenticated_extension/manifest.json b/dev/project-dev/bili_authenticated_extension/manifest.json
new file mode 100644
index 0000000..33a12d0
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/manifest.json
@@ -0,0 +1,28 @@
+{
+ "manifest_version": 3,
+ "name": "project-info Bilibili 完整视频入口",
+ "version": "1.0.0",
+ "version_name": "1.0.0+20260805.v002",
+ "description": "仅处理已授权的 BV1HA3o6oEJJ 完整视频任务。",
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt2dT1HGYaI0DXM7zZwOTNBWXTKlMBJMpyDVjRUc+v6bUotmLyoraC+ay2scy9UQluSZVYq0tS8qvQNNvuZOlc5w2bOExm4TH2IIKvaVO8nVthHBnNz2kXdiM8ItN0vPZEmS+8gpTCI1+6wPTuUglMoXpqYBYhii8fJ5RkENRF3PRJBBigGt8soqdBFRY1QZUmpQv9dYw4dRq4L2C4QtBgClUg4bQpuCppiVZ9LHbePi9IAjc9r9R93KLzpaBuXdJpfVRE5w/6YHnxP8ovXxBdl7XktmrdH3xj7mWT7Q7ZBxkDNwn2RkruD45XgDD3yuNxOYSkLFMkseN+Ua69gSS4wIDAQAB",
+ "permissions": [
+ "activeTab",
+ "cookies",
+ "nativeMessaging",
+ "scripting",
+ "sidePanel"
+ ],
+ "host_permissions": [
+ "https://www.bilibili.com/*"
+ ],
+ "background": {
+ "service_worker": "background.js",
+ "type": "module"
+ },
+ "side_panel": {
+ "default_path": "sidepanel.html"
+ },
+ "action": {
+ "default_title": "打开完整视频任务"
+ }
+}
diff --git a/dev/project-dev/bili_authenticated_extension/native-host-manifest.template.json b/dev/project-dev/bili_authenticated_extension/native-host-manifest.template.json
new file mode 100644
index 0000000..f46369b
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/native-host-manifest.template.json
@@ -0,0 +1,9 @@
+{
+ "name": "com.project_info.bili_auth_ingress",
+ "description": "project-info exact-BVID authenticated ingress",
+ "path": "__ABSOLUTE_HOST_EXECUTABLE__",
+ "type": "stdio",
+ "allowed_origins": [
+ "chrome-extension://oidmclckpdmpabbfedplkbdplmfcenbb/"
+ ]
+}
diff --git a/dev/project-dev/bili_authenticated_extension/native_host.py b/dev/project-dev/bili_authenticated_extension/native_host.py
new file mode 100644
index 0000000..c5c2bb8
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/native_host.py
@@ -0,0 +1,819 @@
+"""Native Messaging broker and job-owned worker entry point.
+
+The broker is stdlib-only and never imports yt-dlp. Chrome secrets are sent
+to a worker only after the worker proves that the frozen plugin boundary is
+disabled and that it has been assigned to a kill-on-close Windows Job Object.
+"""
+
+from __future__ import annotations
+
+import argparse
+import ctypes
+import hashlib
+import json
+import os
+import queue
+import re
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import Any, BinaryIO
+
+if __package__ in (None, ""):
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+ from bili_authenticated_extension.constants import ( # type: ignore[import-not-found]
+ EXPECTED_ORIGIN,
+ GRACEFUL_CANCEL_SECONDS,
+ JOB_WAIT_SECONDS,
+ MAX_INPUT_FRAME,
+ METADATA_TIMEOUT_SECONDS,
+ TARGET_BVID,
+ THREAD_JOIN_SECONDS,
+ )
+ from bili_authenticated_extension.job import ( # type: ignore[import-not-found]
+ WindowsJob,
+ close_handles,
+ create_event,
+ is_event_set,
+ open_nul_handles,
+ set_event,
+ )
+ from bili_authenticated_extension.protocol import ( # type: ignore[import-not-found]
+ ProtocolError,
+ read_frame,
+ safe_response,
+ strict_json_loads,
+ validate_message,
+ validate_origin_argv,
+ write_frame,
+ )
+else:
+ from .constants import (
+ EXPECTED_ORIGIN,
+ GRACEFUL_CANCEL_SECONDS,
+ JOB_WAIT_SECONDS,
+ MAX_INPUT_FRAME,
+ METADATA_TIMEOUT_SECONDS,
+ TARGET_BVID,
+ THREAD_JOIN_SECONDS,
+ )
+ from .job import (
+ WindowsJob,
+ close_handles,
+ create_event,
+ is_event_set,
+ open_nul_handles,
+ set_event,
+ )
+ from .protocol import (
+ ProtocolError,
+ read_frame,
+ safe_response,
+ strict_json_loads,
+ validate_message,
+ validate_origin_argv,
+ write_frame,
+ )
+
+
+def _config_path() -> Path:
+ base = Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else Path(__file__).resolve().parent
+ return base / "config.json"
+
+
+def _hash_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest().upper()
+
+
+def preflight_configuration(path: Path) -> str | None:
+ """Perform stdlib-only collision/config checks before any Cookie is read."""
+ expected = {
+ "schema",
+ "target",
+ "canonical_url",
+ "ffmpeg",
+ "ffmpeg_sha256",
+ "ffprobe",
+ "ffprobe_sha256",
+ "bridge_python",
+ "bridge_python_sha256",
+ "bridge_script",
+ "bridge_script_sha256",
+ "batch_json",
+ "batch_json_sha256",
+ "yt_dlp_executable",
+ "yt_dlp_executable_sha256",
+ "destination",
+ }
+ try:
+ raw = strict_json_loads(path.read_bytes())
+ if set(raw) != expected or raw["schema"] != 1:
+ return "E_CONFIG"
+ if raw["target"] != TARGET_BVID or raw["canonical_url"] != "https://www.bilibili.com/video/BV1HA3o6oEJJ":
+ return "E_CONFIG"
+ for name in ("ffmpeg", "ffprobe", "bridge_python", "bridge_script", "batch_json", "yt_dlp_executable"):
+ value = raw[name]
+ expected_hash = raw[f"{name}_sha256"]
+ if not isinstance(value, str) or not isinstance(expected_hash, str):
+ return "E_CONFIG"
+ candidate = Path(value)
+ if not candidate.is_absolute() or str(candidate).startswith("\\\\"):
+ return "E_CONFIG"
+ candidate = candidate.resolve(strict=True)
+ if not candidate.is_file() or candidate.is_symlink() or _hash_file(candidate) != expected_hash.upper():
+ return "E_CONFIG_HASH"
+ if raw["bridge_script_sha256"].upper() != "749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13":
+ return "E_CONFIG_HASH"
+ destination = Path(raw["destination"])
+ if not destination.is_absolute() or str(destination).startswith("\\\\"):
+ return "E_CONFIG"
+ destination = destination.resolve(strict=True)
+ if not destination.is_dir() or destination.is_symlink():
+ return "E_CONFIG"
+ if any(
+ child.is_file() and child.name.casefold().startswith(f"{TARGET_BVID}.".casefold())
+ for child in destination.iterdir()
+ ):
+ return "E_EXISTS"
+ except (OSError, ProtocolError, KeyError, TypeError, ValueError):
+ return "E_CONFIG"
+ return None
+
+
+def _worker_command(
+ input_handle: int,
+ control_handle: int,
+ cancel_handle: int,
+ commit_handle: int,
+ config_path: Path,
+) -> list[str]:
+ arguments = [
+ "--worker",
+ f"--input-handle={input_handle}",
+ f"--control-handle={control_handle}",
+ f"--cancel-handle={cancel_handle}",
+ f"--commit-handle={commit_handle}",
+ f"--config-path={config_path}",
+ ]
+ if getattr(sys, "frozen", False):
+ return [str(Path(sys.executable).resolve()), *arguments]
+ return [str(Path(sys.executable).resolve()), str(Path(__file__).resolve()), *arguments]
+
+
+def _fd_handle(fd: int) -> int:
+ import msvcrt
+
+ return int(msvcrt.get_osfhandle(fd))
+
+
+class WorkerTask:
+ """One worker, its task job, and sanitized control channel."""
+
+ def __init__(self, config_path: Path | None = None) -> None:
+ self.job: WindowsJob | None = None
+ self.process: Any = None
+ self.cancel_handle = 0
+ self.commit_handle = 0
+ self.config_path = _config_path() if config_path is None else config_path.resolve(strict=True)
+ self.input_writer: BinaryIO | None = None
+ self.control_reader: BinaryIO | None = None
+ self.control_queue: queue.Queue[dict[str, Any] | None] = queue.Queue()
+ self.control_thread: threading.Thread | None = None
+ self.phase = "CHECKING"
+ self.progress = 0
+ self.error_code: str | None = None
+ self.formal_filename: str | None = None
+ self.mapping_filename: str | None = None
+ self.task_nonce: str | None = None
+ self.terminal = False
+ self.task_started_at: float | None = None
+ self.phase_started_at: float | None = None
+ self.prepared = False
+ self.secret_started = False
+ self.prepare_id: str | None = None
+
+ def prepare(self, page_proof: dict[str, Any], prepare_id: str) -> None:
+ if self.process is not None:
+ raise ProtocolError("E_BUSY")
+ input_read_fd, input_write_fd = os.pipe()
+ control_read_fd, control_write_fd = os.pipe()
+ input_read_handle = _fd_handle(input_read_fd)
+ control_write_handle = _fd_handle(control_write_fd)
+ self.cancel_handle = create_event(inheritable=True)
+ self.commit_handle = create_event(inheritable=True)
+ nul_in, nul_out, nul_error = open_nul_handles()
+ self.job = WindowsJob()
+ try:
+ self.process = self.job.launch_suspended(
+ _worker_command(
+ input_read_handle,
+ control_write_handle,
+ self.cancel_handle,
+ self.commit_handle,
+ self.config_path,
+ ),
+ stdin_handle=nul_in,
+ stdout_handle=nul_out,
+ stderr_handle=nul_error,
+ inherited_handles=(
+ input_read_handle,
+ control_write_handle,
+ self.cancel_handle,
+ self.commit_handle,
+ ),
+ cwd=str(Path(__file__).resolve().parent.parent),
+ )
+ except BaseException:
+ os.close(input_read_fd)
+ os.close(input_write_fd)
+ os.close(control_read_fd)
+ os.close(control_write_fd)
+ self.close()
+ raise
+ finally:
+ close_handles(nul_in, nul_out, nul_error)
+ os.close(input_read_fd)
+ os.close(control_write_fd)
+ self.input_writer = os.fdopen(input_write_fd, "wb", buffering=0)
+ self.control_reader = os.fdopen(control_read_fd, "rb", buffering=0)
+ self.control_thread = threading.Thread(target=self._read_control, name="bili-auth-control", daemon=True)
+ self.control_thread.start()
+ deadline = time.monotonic() + METADATA_TIMEOUT_SECONDS
+ while time.monotonic() < deadline:
+ try:
+ message = self.control_queue.get(timeout=0.1)
+ except queue.Empty:
+ if self.process.wait(0):
+ break
+ continue
+ if message is None:
+ break
+ if message.get("type") == "ready" and message.get("code") == "READY_PLUGIN_DISABLED":
+ self.task_nonce = page_proof["task_nonce"]
+ self.prepare_id = prepare_id
+ self.phase = "READY"
+ self.prepared = True
+ return
+ if message.get("type") == "terminal":
+ self._apply_control(message)
+ break
+ self.error_code = "E_PLUGIN_BOUNDARY"
+ self.phase = "FAILED"
+ self.terminal = True
+ self.terminate()
+ raise ProtocolError("E_PLUGIN_BOUNDARY")
+
+ def start(self, start_message: dict[str, Any]) -> None:
+ if (
+ not self.prepared
+ or self.secret_started
+ or self.input_writer is None
+ or start_message["page_proof"]["task_nonce"] != self.task_nonce
+ or start_message["prepare_id"] != self.prepare_id
+ ):
+ raise ProtocolError("E_PREPARE")
+ write_frame(self.input_writer, start_message)
+ self.input_writer.close()
+ self.input_writer = None
+ self.secret_started = True
+ self.phase = "CHECKING"
+ self.task_started_at = time.monotonic()
+ self.phase_started_at = self.task_started_at
+ start_message["cookies"].clear()
+
+ def _read_control(self) -> None:
+ assert self.control_reader is not None
+ try:
+ while True:
+ payload = read_frame(self.control_reader, MAX_INPUT_FRAME)
+ if payload is None:
+ break
+ value = strict_json_loads(payload)
+ if _valid_control_message(value):
+ self.control_queue.put(value)
+ else:
+ self.control_queue.put({"schema": 1, "type": "terminal", "phase": "FAILED", "error_code": "E_CONTROL"})
+ break
+ except BaseException:
+ self.control_queue.put({"schema": 1, "type": "terminal", "phase": "FAILED", "error_code": "E_CONTROL"})
+ finally:
+ self.control_queue.put(None)
+
+ def _apply_control(self, value: dict[str, Any]) -> None:
+ message_type = value["type"]
+ if message_type == "progress":
+ if value["phase"] != self.phase:
+ self.phase_started_at = time.monotonic()
+ self.phase = value["phase"]
+ self.progress = value["progress"]
+ elif message_type == "terminal":
+ self.phase = value["phase"]
+ self.progress = 100 if self.phase == "COMPLETE" else self.progress
+ self.error_code = value.get("error_code")
+ self.formal_filename = value.get("formal_filename")
+ self.mapping_filename = value.get("mapping_filename")
+ self.terminal = True
+
+ def poll(self) -> None:
+ while True:
+ try:
+ value = self.control_queue.get_nowait()
+ except queue.Empty:
+ break
+ if value is not None:
+ self._apply_control(value)
+ if self.process is not None and self.process.wait(0) and not self.terminal:
+ self.phase = "FAILED"
+ self.error_code = "E_WORKER_EXIT"
+ self.terminal = True
+ deadline_error = self.deadline_error(time.monotonic())
+ if deadline_error is not None and not self.terminal:
+ self.phase = "FAILED"
+ self.error_code = deadline_error
+ self.terminal = True
+ self.terminate()
+
+ def deadline_error(self, now: float) -> str | None:
+ if self.task_started_at is None or self.terminal:
+ return None
+ if self.phase == "CHECKING" and self.phase_started_at is not None:
+ if now - self.phase_started_at >= METADATA_TIMEOUT_SECONDS:
+ return "E_METADATA_TIMEOUT"
+ if self.phase in {"DOWNLOADING", "MERGING", "VALIDATING", "PUBLISHING"}:
+ if now - self.task_started_at >= 7_200:
+ return "E_DOWNLOAD_TIMEOUT"
+ return None
+
+ def cancel(self, task_nonce: str) -> bool:
+ if self.terminal or self.task_nonce != task_nonce or not self.secret_started:
+ return False
+ if self.commit_handle and is_event_set(self.commit_handle):
+ return False
+ set_event(self.cancel_handle)
+ deadline = time.monotonic() + GRACEFUL_CANCEL_SECONDS
+ while time.monotonic() < deadline:
+ self.poll()
+ if self.terminal:
+ return self.phase == "CANCELED"
+ time.sleep(0.02)
+ if self.commit_handle and is_event_set(self.commit_handle):
+ return False
+ if self.job is not None:
+ self.job.terminate()
+ if self.process is not None and not self.process.wait(JOB_WAIT_SECONDS):
+ self.error_code = "E_PROCESS_OWNERSHIP"
+ return False
+ self.phase = "CANCELED"
+ self.error_code = None
+ self.terminal = True
+ self.close()
+ return True
+
+ def terminate(self) -> None:
+ if self.cancel_handle:
+ try:
+ set_event(self.cancel_handle)
+ except BaseException:
+ pass
+ if self.process is not None and not self.process.wait(GRACEFUL_CANCEL_SECONDS):
+ if self.job is not None:
+ self.job.terminate()
+ if not self.process.wait(JOB_WAIT_SECONDS):
+ self.error_code = "E_PROCESS_OWNERSHIP"
+ self.close()
+
+ def close(self) -> None:
+ if self.input_writer is not None:
+ self.input_writer.close()
+ self.input_writer = None
+ if self.control_reader is not None:
+ self.control_reader.close()
+ self.control_reader = None
+ if self.control_thread is not None and self.control_thread is not threading.current_thread():
+ self.control_thread.join(THREAD_JOIN_SECONDS)
+ self.control_thread = None
+ if self.process is not None:
+ self.process.close()
+ self.process = None
+ if self.job is not None:
+ self.job.close()
+ self.job = None
+ if self.cancel_handle:
+ close_handles(self.cancel_handle)
+ self.cancel_handle = 0
+ if self.commit_handle:
+ close_handles(self.commit_handle)
+ self.commit_handle = 0
+
+
+def _valid_control_message(value: dict[str, Any]) -> bool:
+ if value.get("schema") != 1 or value.get("type") not in {"ready", "progress", "terminal"}:
+ return False
+ if value["type"] == "ready":
+ return set(value) == {"schema", "type", "code"} and value["code"] in {
+ "READY_PLUGIN_DISABLED",
+ "E_PLUGIN_BOUNDARY",
+ }
+ if value["type"] == "progress":
+ return (
+ set(value) == {"schema", "type", "phase", "progress"}
+ and value["phase"] in {"CHECKING", "DOWNLOADING", "MERGING", "VALIDATING", "PUBLISHING"}
+ and isinstance(value["progress"], int)
+ and not isinstance(value["progress"], bool)
+ and 0 <= value["progress"] <= 100
+ )
+ allowed = {
+ "schema",
+ "type",
+ "phase",
+ "error_code",
+ "formal_filename",
+ "mapping_filename",
+ "cookie_stream_closed",
+ }
+ if set(value) != allowed or value["phase"] not in {"COMPLETE", "FAILED", "CANCELED"}:
+ return False
+ if not isinstance(value["cookie_stream_closed"], bool):
+ return False
+ error_code = value["error_code"]
+ if error_code is not None and (
+ not isinstance(error_code, str) or not re.fullmatch(r"E_[A-Z0-9_]{1,48}", error_code)
+ ):
+ return False
+ if value["phase"] == "COMPLETE":
+ return (
+ error_code is None
+ and value["cookie_stream_closed"] is True
+ and value["formal_filename"] == f"{TARGET_BVID}.mkv"
+ and value["mapping_filename"] == f"{TARGET_BVID}.download.json"
+ )
+ return (
+ value["formal_filename"] is None
+ and value["mapping_filename"] is None
+ and (value["phase"] == "CANCELED" or error_code is not None)
+ )
+
+
+def _redirect_worker_streams_to_nul() -> None:
+ flags = os.O_RDWR | getattr(os, "O_BINARY", 0)
+ nul_fd = os.open(os.devnull, flags)
+ try:
+ os.dup2(nul_fd, 1)
+ os.dup2(nul_fd, 2)
+ finally:
+ if nul_fd not in (1, 2):
+ os.close(nul_fd)
+
+
+def _open_inherited_handle(handle: int, mode: str) -> BinaryIO:
+ import msvcrt
+
+ flags = os.O_RDONLY if "r" in mode else os.O_WRONLY
+ flags |= getattr(os, "O_BINARY", 0)
+ fd = msvcrt.open_osfhandle(handle, flags)
+ return os.fdopen(fd, mode, buffering=0)
+
+
+def _worker_main(
+ input_handle: int,
+ control_handle: int,
+ cancel_handle: int,
+ commit_handle: int,
+ config_path: Path,
+) -> int:
+ _redirect_worker_streams_to_nul()
+ os.environ["YTDLP_NO_PLUGINS"] = "1"
+ control = _open_inherited_handle(control_handle, "wb")
+ worker_input = _open_inherited_handle(input_handle, "rb")
+ cookie_closed = False
+ prepared_run: Path | None = None
+ stage_root: Path | None = None
+ try:
+ if __package__ in (None, ""):
+ from bili_authenticated_extension.worker import ( # type: ignore[import-not-found]
+ CancelRequested,
+ HostConfig,
+ bootstrap_ytdlp,
+ cleanup_run_directory,
+ fixed_stage_root,
+ prepare_run_directory,
+ run_authenticated_task,
+ sanitized_environment,
+ )
+ else:
+ from .worker import (
+ CancelRequested,
+ HostConfig,
+ bootstrap_ytdlp,
+ cleanup_run_directory,
+ fixed_stage_root,
+ prepare_run_directory,
+ run_authenticated_task,
+ sanitized_environment,
+ )
+
+ safe_environment = sanitized_environment()
+ try:
+ bootstrap_ytdlp()
+ except BaseException:
+ write_frame(control, {"schema": 1, "type": "ready", "code": "E_PLUGIN_BOUNDARY"})
+ return 31
+ os.environ.clear()
+ os.environ.update(safe_environment)
+ config = HostConfig.load(config_path)
+ stage_root = fixed_stage_root()
+ prepared_run = prepare_run_directory(stage_root)
+ write_frame(control, {"schema": 1, "type": "ready", "code": "READY_PLUGIN_DISABLED"})
+ payload = read_frame(worker_input, MAX_INPUT_FRAME)
+ if payload is None:
+ raise CancelRequested()
+ start = strict_json_loads(payload)
+ validate_message(start)
+
+ def report(phase: str, progress: int) -> None:
+ write_frame(
+ control,
+ {"schema": 1, "type": "progress", "phase": phase, "progress": progress},
+ )
+
+ def closure_report(closed: bool) -> None:
+ nonlocal cookie_closed
+ cookie_closed = closed
+
+ formal, mapping, cookie_closed = run_authenticated_task(
+ start,
+ config,
+ cancel_check=lambda: is_event_set(cancel_handle),
+ report=report,
+ stage_root=stage_root,
+ prepared_run_directory=prepared_run,
+ commit_begin=lambda: set_event(commit_handle),
+ closure_report=closure_report,
+ )
+ prepared_run = None
+ write_frame(
+ control,
+ {
+ "schema": 1,
+ "type": "terminal",
+ "phase": "COMPLETE",
+ "error_code": None,
+ "formal_filename": formal,
+ "mapping_filename": mapping,
+ "cookie_stream_closed": cookie_closed,
+ },
+ )
+ return 0
+ except BaseException as exc:
+ error_code = getattr(exc, "code", None)
+ phase = "CANCELED" if type(exc).__name__ == "CancelRequested" else "FAILED"
+ if not isinstance(error_code, str) or not error_code.startswith("E_"):
+ error_code = None if phase == "CANCELED" else "E_WORKER"
+ try:
+ write_frame(
+ control,
+ {
+ "schema": 1,
+ "type": "terminal",
+ "phase": phase,
+ "error_code": error_code,
+ "formal_filename": None,
+ "mapping_filename": None,
+ "cookie_stream_closed": cookie_closed,
+ },
+ )
+ except BaseException:
+ pass
+ return 32
+ finally:
+ if prepared_run is not None and stage_root is not None:
+ try:
+ cleanup_run_directory(prepared_run, stage_root)
+ except BaseException:
+ pass
+ worker_input.close()
+ control.close()
+
+
+def _duplicate_protocol_output() -> BinaryIO:
+ import msvcrt
+ from ctypes import wintypes
+
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32.GetCurrentProcess.argtypes = []
+ kernel32.GetCurrentProcess.restype = wintypes.HANDLE
+ kernel32.DuplicateHandle.argtypes = [
+ wintypes.HANDLE,
+ wintypes.HANDLE,
+ wintypes.HANDLE,
+ ctypes.POINTER(wintypes.HANDLE),
+ wintypes.DWORD,
+ wintypes.BOOL,
+ wintypes.DWORD,
+ ]
+ kernel32.DuplicateHandle.restype = wintypes.BOOL
+ current = kernel32.GetCurrentProcess()
+ source = msvcrt.get_osfhandle(sys.stdout.buffer.fileno())
+ duplicate = wintypes.HANDLE()
+ duplicate_same_access = 0x2
+ if not kernel32.DuplicateHandle(
+ current,
+ wintypes.HANDLE(source),
+ current,
+ ctypes.byref(duplicate),
+ 0,
+ False,
+ duplicate_same_access,
+ ):
+ raise OSError(ctypes.get_last_error(), "DuplicateHandle")
+ fd = msvcrt.open_osfhandle(int(duplicate.value), os.O_WRONLY | getattr(os, "O_BINARY", 0))
+ protocol = os.fdopen(fd, "wb", buffering=0)
+ flags = os.O_RDWR | getattr(os, "O_BINARY", 0)
+ nul_fd = os.open(os.devnull, flags)
+ try:
+ os.dup2(nul_fd, 1)
+ os.dup2(nul_fd, 2)
+ finally:
+ if nul_fd not in (1, 2):
+ os.close(nul_fd)
+ return protocol
+
+
+def _reader_loop(source: BinaryIO, incoming: queue.Queue[dict[str, Any] | None]) -> None:
+ try:
+ while True:
+ payload = read_frame(source)
+ if payload is None:
+ incoming.put(None)
+ return
+ value = strict_json_loads(payload)
+ incoming.put(validate_message(value))
+ except BaseException:
+ incoming.put(None)
+
+
+def _task_response(task: WorkerTask | None, preflight_error: str | None = None) -> dict[str, Any]:
+ if task is None:
+ if preflight_error:
+ return safe_response("status", "FAILED", error_code=preflight_error)
+ return safe_response("status", "READY")
+ task.poll()
+ return safe_response(
+ "status",
+ task.phase,
+ progress=task.progress,
+ error_code=task.error_code,
+ formal_filename=task.formal_filename,
+ mapping_filename=task.mapping_filename,
+ )
+
+
+def _start_prepared_task(task: WorkerTask | None, message: dict[str, Any]) -> str | None:
+ if task is None or task.terminal or not task.prepared:
+ return "E_PREPARE"
+ try:
+ task.start(message)
+ except ProtocolError:
+ # A duplicate/mismatched start must never terminate the already-started task.
+ return "E_PREPARE"
+ except BaseException:
+ task.terminate()
+ return "E_PREPARE"
+ return None
+
+
+def broker_main(arguments: list[str]) -> int:
+ validate_origin_argv(arguments, EXPECTED_ORIGIN)
+ protocol_output = _duplicate_protocol_output()
+ incoming: queue.Queue[dict[str, Any] | None] = queue.Queue()
+ reader = threading.Thread(
+ target=_reader_loop,
+ args=(sys.stdin.buffer, incoming),
+ name="bili-auth-native-reader",
+ daemon=True,
+ )
+ reader.start()
+ task: WorkerTask | None = None
+ hello_complete = False
+ preflight_error = preflight_configuration(_config_path())
+ try:
+ while True:
+ if task is not None:
+ task.poll()
+ try:
+ message = incoming.get(timeout=0.1)
+ except queue.Empty:
+ continue
+ if message is None:
+ if task is not None and not task.terminal:
+ task.terminate()
+ return 0
+ message_type = message["type"]
+ if not hello_complete:
+ if message_type != "hello":
+ raise ProtocolError()
+ hello_complete = True
+ write_frame(
+ protocol_output,
+ safe_response(
+ "hello",
+ "FAILED" if preflight_error else "READY",
+ error_code=preflight_error,
+ ),
+ )
+ continue
+ if message_type == "hello":
+ raise ProtocolError()
+ if message_type == "status":
+ current_preflight = preflight_configuration(_config_path())
+ write_frame(protocol_output, _task_response(task, current_preflight))
+ elif message_type == "start":
+ start_error = _start_prepared_task(task, message)
+ if start_error is None:
+ write_frame(protocol_output, _task_response(task))
+ else:
+ write_frame(protocol_output, safe_response("start", "FAILED", error_code=start_error))
+ elif message_type == "prepare":
+ preflight_error = preflight_configuration(_config_path())
+ if preflight_error:
+ write_frame(protocol_output, safe_response("prepare", "FAILED", error_code=preflight_error, prepare_id=message["prepare_id"]))
+ continue
+ if task is not None and not task.terminal:
+ write_frame(protocol_output, safe_response("prepare", "FAILED", error_code="E_BUSY", prepare_id=message["prepare_id"]))
+ continue
+ if task is not None:
+ task.close()
+ task = WorkerTask()
+ try:
+ task.prepare(message["page_proof"], message["prepare_id"])
+ write_frame(protocol_output, safe_response("prepare", "READY", prepare_id=message["prepare_id"]))
+ except BaseException:
+ write_frame(protocol_output, safe_response("prepare", "FAILED", error_code="E_PLUGIN_BOUNDARY", prepare_id=message["prepare_id"]))
+ elif message_type == "cancel":
+ canceled = task is not None and task.cancel(message["task_nonce"])
+ write_frame(
+ protocol_output,
+ safe_response("cancel", "CANCELED" if canceled else "FAILED", error_code=None if canceled else "E_CANCEL"),
+ )
+ except BaseException:
+ if task is not None and not task.terminal:
+ task.terminate()
+ try:
+ write_frame(protocol_output, safe_response("error", "FAILED", error_code="E_PROTOCOL"))
+ except BaseException:
+ pass
+ return 2
+ finally:
+ if task is not None:
+ task.close()
+ protocol_output.close()
+ reader.join(THREAD_JOIN_SECONDS)
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(add_help=False)
+ parser.add_argument("--worker", action="store_true")
+ parser.add_argument("--input-handle", type=int)
+ parser.add_argument("--control-handle", type=int)
+ parser.add_argument("--cancel-handle", type=int)
+ parser.add_argument("--commit-handle", type=int)
+ parser.add_argument("--config-path")
+ parser.add_argument("native_arguments", nargs="*")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ raw_arguments = list(sys.argv[1:] if argv is None else argv)
+ if raw_arguments and raw_arguments[0] == "--worker":
+ args = _parser().parse_args(raw_arguments)
+ if args.native_arguments or not all(
+ isinstance(item, int) and item > 0
+ for item in (args.input_handle, args.control_handle, args.cancel_handle, args.commit_handle)
+ ):
+ return 2
+ if not args.config_path:
+ return 2
+ try:
+ config_path = Path(args.config_path).resolve(strict=True)
+ except OSError:
+ return 2
+ return _worker_main(
+ args.input_handle,
+ args.control_handle,
+ args.cancel_handle,
+ args.commit_handle,
+ config_path,
+ )
+ try:
+ return broker_main(raw_arguments)
+ except ProtocolError:
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/dev/project-dev/bili_authenticated_extension/protocol.py b/dev/project-dev/bili_authenticated_extension/protocol.py
new file mode 100644
index 0000000..de40d4e
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/protocol.py
@@ -0,0 +1,355 @@
+"""Strict Native Messaging wire validation.
+
+This module is stdlib-only and contains no downloader imports.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import struct
+import time
+import unicodedata
+from typing import Any, BinaryIO, Iterable
+
+from .constants import (
+ CANONICAL_URL,
+ DURATION_TOLERANCE_MS,
+ EXPECTED_DURATION_MS,
+ EXTENSION_BUILD,
+ HOST_BUILD,
+ MAX_COOKIE_COUNT,
+ MAX_INPUT_FRAME,
+ MAX_OUTPUT_FRAME,
+ MAX_SAFE_INTEGER,
+ SCHEMA_VERSION,
+ TARGET_BVID,
+ TARGET_PATH,
+)
+
+_NONCE_RE = re.compile(r"[0-9a-f]{32}\Z")
+_PREPARE_ID_RE = re.compile(r"[0-9a-f]{32}\Z")
+_STORE_RE = re.compile(r"[0-9]{1,8}\Z")
+_PARENT_RE = re.compile(r"--parent-window=[0-9]+\Z")
+_COOKIE_SAME_SITE = {"no_restriction", "lax", "strict", "unspecified"}
+
+
+class ProtocolError(Exception):
+ """A fixed-code input rejection that is safe to expose."""
+
+ def __init__(self, code: str = "E_PROTOCOL") -> None:
+ super().__init__(code)
+ self.code = code
+
+
+def _reject_constant(_: str) -> None:
+ raise ProtocolError()
+
+
+def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for key, value in pairs:
+ if key in result:
+ raise ProtocolError()
+ result[key] = value
+ return result
+
+
+def strict_json_loads(payload: bytes) -> dict[str, Any]:
+ try:
+ text = payload.decode("utf-8", errors="strict")
+ value = json.loads(
+ text,
+ object_pairs_hook=_unique_object,
+ parse_constant=_reject_constant,
+ )
+ except ProtocolError:
+ raise
+ except (UnicodeError, json.JSONDecodeError) as exc:
+ raise ProtocolError() from exc
+ if not isinstance(value, dict):
+ raise ProtocolError()
+ return value
+
+
+def encode_json(value: dict[str, Any], limit: int = MAX_OUTPUT_FRAME) -> bytes:
+ payload = json.dumps(
+ value,
+ ensure_ascii=True,
+ allow_nan=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ ).encode("utf-8")
+ if len(payload) > limit:
+ raise ProtocolError()
+ return payload
+
+
+def read_frame(stream: BinaryIO, limit: int = MAX_INPUT_FRAME) -> bytes | None:
+ header = stream.read(4)
+ if header == b"":
+ return None
+ if len(header) != 4:
+ raise ProtocolError()
+ (length,) = struct.unpack("<I", header)
+ if length == 0 or length > limit:
+ raise ProtocolError()
+ payload = stream.read(length)
+ if len(payload) != length:
+ raise ProtocolError()
+ return payload
+
+
+def write_frame(stream: BinaryIO, value: dict[str, Any]) -> None:
+ payload = encode_json(value)
+ stream.write(struct.pack("<I", len(payload)))
+ stream.write(payload)
+ stream.flush()
+
+
+def _exact_keys(value: dict[str, Any], expected: Iterable[str]) -> None:
+ if set(value) != set(expected):
+ raise ProtocolError()
+
+
+def _integer(value: Any, minimum: int, maximum: int) -> int:
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise ProtocolError()
+ if not minimum <= value <= maximum:
+ raise ProtocolError()
+ return value
+
+
+def _boolean(value: Any) -> bool:
+ if not isinstance(value, bool):
+ raise ProtocolError()
+ return value
+
+
+def _safe_string(value: Any, minimum: int, maximum_utf8: int) -> str:
+ if not isinstance(value, str):
+ raise ProtocolError()
+ encoded = value.encode("utf-8")
+ if len(encoded) < minimum or len(encoded) > maximum_utf8:
+ raise ProtocolError()
+ if any(ch in "\x00\t\r\n" or unicodedata.category(ch).startswith("C") for ch in value):
+ raise ProtocolError()
+ return value
+
+
+def validate_origin_argv(arguments: list[str], expected_origin: str) -> None:
+ if len(arguments) not in (1, 2) or arguments[0] != expected_origin:
+ raise ProtocolError("E_ORIGIN")
+ if len(arguments) == 2 and not _PARENT_RE.fullmatch(arguments[1]):
+ raise ProtocolError("E_ORIGIN")
+
+
+def _validate_common(value: dict[str, Any], message_type: str, keys: Iterable[str]) -> None:
+ _exact_keys(value, keys)
+ if _integer(value.get("schema"), SCHEMA_VERSION, SCHEMA_VERSION) != SCHEMA_VERSION:
+ raise ProtocolError()
+ if value.get("type") != message_type or value.get("target") != TARGET_BVID:
+ raise ProtocolError()
+
+
+def validate_hello(value: dict[str, Any]) -> dict[str, Any]:
+ _validate_common(value, "hello", {"schema", "type", "extension_build", "target"})
+ if value["extension_build"] != EXTENSION_BUILD:
+ raise ProtocolError("E_BUILD")
+ return value
+
+
+def validate_status(value: dict[str, Any]) -> dict[str, Any]:
+ _validate_common(value, "status", {"schema", "type", "target"})
+ return value
+
+
+def validate_cancel(value: dict[str, Any]) -> dict[str, Any]:
+ _validate_common(value, "cancel", {"schema", "type", "target", "task_nonce"})
+ if not isinstance(value["task_nonce"], str) or not _NONCE_RE.fullmatch(value["task_nonce"]):
+ raise ProtocolError()
+ return value
+
+
+def validate_prepare(value: dict[str, Any], now_ms: int | None = None) -> dict[str, Any]:
+ _validate_common(
+ value,
+ "prepare",
+ {"schema", "type", "extension_build", "target", "prepare_id", "page_proof"},
+ )
+ if value["extension_build"] != EXTENSION_BUILD:
+ raise ProtocolError("E_BUILD")
+ if not isinstance(value["prepare_id"], str) or not _PREPARE_ID_RE.fullmatch(value["prepare_id"]):
+ raise ProtocolError("E_PREPARE")
+ validate_page_proof(value["page_proof"], now_ms=now_ms)
+ return value
+
+
+def validate_page_proof(value: Any, now_ms: int | None = None) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ raise ProtocolError()
+ _exact_keys(
+ value,
+ {
+ "target",
+ "canonical_url",
+ "task_nonce",
+ "observed_at_unix_ms",
+ "observed_duration_ms",
+ "video_width",
+ "video_height",
+ "ready_state",
+ "eme_present",
+ },
+ )
+ if value["target"] != TARGET_BVID or value["canonical_url"] != CANONICAL_URL:
+ raise ProtocolError()
+ if not isinstance(value["task_nonce"], str) or not _NONCE_RE.fullmatch(value["task_nonce"]):
+ raise ProtocolError()
+ observed_at = _integer(value["observed_at_unix_ms"], 1, MAX_SAFE_INTEGER)
+ observed_duration = _integer(value["observed_duration_ms"], 1, MAX_SAFE_INTEGER)
+ _integer(value["video_width"], 1, 7680)
+ _integer(value["video_height"], 1, 4320)
+ _integer(value["ready_state"], 1, 4)
+ if _boolean(value["eme_present"]):
+ raise ProtocolError("E_DRM")
+ if abs(observed_duration - EXPECTED_DURATION_MS) > DURATION_TOLERANCE_MS:
+ raise ProtocolError("E_DURATION")
+ current = int(time.time() * 1000) if now_ms is None else now_ms
+ if observed_at < current - 60_000 or observed_at > current + 5_000:
+ raise ProtocolError("E_PAGE_PROOF")
+ return value
+
+
+def _cookie_path_matches(path: str) -> bool:
+ if path == "/":
+ return True
+ if not TARGET_PATH.startswith(path):
+ return False
+ return path.endswith("/") or len(path) == len(TARGET_PATH) or TARGET_PATH[len(path)] == "/"
+
+
+def validate_cookie(
+ value: Any,
+ store_id: str,
+ observed_at_unix_ms: int,
+) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ raise ProtocolError("E_SECRET_INPUT")
+ try:
+ _exact_keys(
+ value,
+ {
+ "name",
+ "value",
+ "domain",
+ "host_only",
+ "path",
+ "secure",
+ "http_only",
+ "same_site",
+ "session",
+ "expiration_unix",
+ "store_id",
+ "partition_key",
+ },
+ )
+ _safe_string(value["name"], 1, 256)
+ _safe_string(value["value"], 1, 4096)
+ host_only = _boolean(value["host_only"])
+ expected_domain = "www.bilibili.com" if host_only else ".bilibili.com"
+ if value["domain"] != expected_domain:
+ raise ProtocolError()
+ path = _safe_string(value["path"], 1, 1024)
+ if not path.startswith("/") or not _cookie_path_matches(path):
+ raise ProtocolError()
+ _boolean(value["secure"])
+ _boolean(value["http_only"])
+ session = _boolean(value["session"])
+ if value["same_site"] not in _COOKIE_SAME_SITE:
+ raise ProtocolError()
+ if value["store_id"] != store_id or value["partition_key"] is not None:
+ raise ProtocolError()
+ if session:
+ if value["expiration_unix"] is not None:
+ raise ProtocolError()
+ else:
+ expires = _integer(value["expiration_unix"], 1, MAX_SAFE_INTEGER)
+ if expires <= observed_at_unix_ms // 1000:
+ raise ProtocolError()
+ except ProtocolError as exc:
+ raise ProtocolError("E_SECRET_INPUT") from exc
+ return value
+
+
+def validate_start(value: dict[str, Any], now_ms: int | None = None) -> dict[str, Any]:
+ _validate_common(
+ value,
+ "start",
+ {
+ "schema",
+ "type",
+ "extension_build",
+ "target",
+ "canonical_url",
+ "cookie_store_id",
+ "prepare_id",
+ "page_proof",
+ "cookies",
+ },
+ )
+ if value["extension_build"] != EXTENSION_BUILD:
+ raise ProtocolError("E_BUILD")
+ if value["canonical_url"] != CANONICAL_URL:
+ raise ProtocolError()
+ if not isinstance(value["prepare_id"], str) or not _PREPARE_ID_RE.fullmatch(value["prepare_id"]):
+ raise ProtocolError("E_PREPARE")
+ store_id = value["cookie_store_id"]
+ if not isinstance(store_id, str) or not _STORE_RE.fullmatch(store_id):
+ raise ProtocolError("E_SECRET_INPUT")
+ proof = validate_page_proof(value["page_proof"], now_ms=now_ms)
+ cookies = value["cookies"]
+ if not isinstance(cookies, list) or not 1 <= len(cookies) <= MAX_COOKIE_COUNT:
+ raise ProtocolError("E_SECRET_INPUT")
+ for cookie in cookies:
+ validate_cookie(cookie, store_id, proof["observed_at_unix_ms"])
+ return value
+
+
+def validate_message(value: dict[str, Any], now_ms: int | None = None) -> dict[str, Any]:
+ message_type = value.get("type")
+ if message_type == "hello":
+ return validate_hello(value)
+ if message_type == "status":
+ return validate_status(value)
+ if message_type == "cancel":
+ return validate_cancel(value)
+ if message_type == "prepare":
+ return validate_prepare(value, now_ms=now_ms)
+ if message_type == "start":
+ return validate_start(value, now_ms=now_ms)
+ raise ProtocolError()
+
+
+def safe_response(
+ message_type: str,
+ phase: str,
+ *,
+ progress: int = 0,
+ error_code: str | None = None,
+ formal_filename: str | None = None,
+ mapping_filename: str | None = None,
+ prepare_id: str | None = None,
+) -> dict[str, Any]:
+ return {
+ "schema": SCHEMA_VERSION,
+ "type": message_type,
+ "host_build": HOST_BUILD,
+ "target": TARGET_BVID,
+ "phase": phase,
+ "progress": max(0, min(100, int(progress))),
+ "error_code": error_code,
+ "formal_filename": formal_filename,
+ "mapping_filename": mapping_filename,
+ "prepare_id": prepare_id,
+ }
diff --git a/dev/project-dev/bili_authenticated_extension/sidepanel.css b/dev/project-dev/bili_authenticated_extension/sidepanel.css
new file mode 100644
index 0000000..31b551f
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/sidepanel.css
@@ -0,0 +1,9 @@
+:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
+body { margin: 0; }
+main { display: grid; gap: 12px; padding: 18px; }
+h1 { font-size: 20px; margin: 0; }
+.target { font-family: ui-monospace, monospace; font-weight: 700; }
+.confirm { display: flex; gap: 8px; align-items: flex-start; }
+button { min-height: 38px; }
+progress { width: 100%; }
+#detail { overflow-wrap: anywhere; }
diff --git a/dev/project-dev/bili_authenticated_extension/sidepanel.html b/dev/project-dev/bili_authenticated_extension/sidepanel.html
new file mode 100644
index 0000000..7db5abe
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/sidepanel.html
@@ -0,0 +1,27 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <title>完整视频任务</title>
+ <link rel="stylesheet" href="sidepanel.css">
+</head>
+<body>
+ <main>
+ <h1>完整视频任务</h1>
+ <p class="target">仅限 BV1HA3o6oEJJ</p>
+ <p>请确认当前页面显示“充电中”,播放器总时长为 52:14,并可正常播放完整内容。</p>
+ <label class="confirm"><input id="confirmed" type="checkbox"> 我已在页面中完成上述可见确认</label>
+ <button id="validate" type="button">1. 校验当前页面</button>
+ <button id="start" type="button" disabled>2. 添加完整视频任务</button>
+ <button id="retry" type="button" hidden>失败重试</button>
+ <button id="cancel" type="button" hidden>取消任务</button>
+ <section aria-live="polite">
+ <div id="phase">状态:IDLE</div>
+ <progress id="progress" max="100" value="0"></progress>
+ <div id="detail"></div>
+ </section>
+ </main>
+ <script src="sidepanel.js"></script>
+</body>
+</html>
diff --git a/dev/project-dev/bili_authenticated_extension/sidepanel.js b/dev/project-dev/bili_authenticated_extension/sidepanel.js
new file mode 100644
index 0000000..cf99b19
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/sidepanel.js
@@ -0,0 +1,71 @@
+const confirmed = document.querySelector("#confirmed");
+const validateButton = document.querySelector("#validate");
+const startButton = document.querySelector("#start");
+const retryButton = document.querySelector("#retry");
+const cancelButton = document.querySelector("#cancel");
+const phase = document.querySelector("#phase");
+const progress = document.querySelector("#progress");
+const detail = document.querySelector("#detail");
+let startUiLease = false;
+
+function render(state) {
+ if (!state) return;
+ phase.textContent = `状态:${state.phase}`;
+ progress.value = Number.isInteger(state.progress) ? state.progress : 0;
+ detail.textContent = state.error_code
+ ? `错误码:${state.error_code}`
+ : state.formal_filename
+ ? `已完成:${state.formal_filename}`
+ : "";
+ const active = ["CHECKING", "DOWNLOADING", "MERGING", "VALIDATING", "PUBLISHING"].includes(state.phase);
+ cancelButton.hidden = !active;
+ retryButton.hidden = state.phase !== "FAILED";
+ retryButton.disabled = startUiLease;
+ validateButton.disabled = active || startUiLease;
+ startButton.disabled = startUiLease || state.phase !== "READY";
+}
+
+async function call(action) {
+ const response = await chrome.runtime.sendMessage({action});
+ if (!response?.ok) {
+ render({phase: "FAILED", progress: 0, error_code: response?.error_code || "E_EXTENSION"});
+ return null;
+ }
+ render(response.state);
+ return response.state;
+}
+
+async function callStart(action) {
+ if (startUiLease) return;
+ startUiLease = true;
+ startButton.disabled = true;
+ retryButton.disabled = true;
+ validateButton.disabled = true;
+ try {
+ await call(action);
+ } finally {
+ startUiLease = false;
+ }
+}
+
+validateButton.addEventListener("click", async () => {
+ if (!confirmed.checked) {
+ render({phase: "FAILED", progress: 0, error_code: "E_VISIBLE_CONFIRMATION"});
+ return;
+ }
+ const state = await call("validate");
+ startButton.disabled = state?.phase !== "READY";
+});
+
+startButton.addEventListener("click", () => callStart("start"));
+retryButton.addEventListener("click", async () => {
+ if (!confirmed.checked) {
+ render({phase: "FAILED", progress: 0, error_code: "E_VISIBLE_CONFIRMATION"});
+ return;
+ }
+ await callStart("retry");
+});
+cancelButton.addEventListener("click", () => call("cancel"));
+
+call("status");
+setInterval(() => call("status"), 1000);
diff --git a/dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json b/dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json
new file mode 100644
index 0000000..1f597aa
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/source-artifact-manifest.json
@@ -0,0 +1,118 @@
+{
+ "schema": 1,
+ "target": "BV1HA3o6oEJJ",
+ "extension_id": "oidmclckpdmpabbfedplkbdplmfcenbb",
+ "extension_build": "project-info-bili-auth-ingress/1.0.0+20260805.v002",
+ "host_build": "project-info-bili-auth-native-host/1.0.0+20260805.v002",
+ "archive_metadata_contract": {
+ "schema": 1,
+ "root": "yt_dlp-2026.7.4.dist-info",
+ "relative_files": [
+ "INSTALLER",
+ "METADATA",
+ "RECORD",
+ "REQUESTED",
+ "WHEEL",
+ "entry_points.txt",
+ "licenses/LICENSE"
+ ],
+ "distribution_name": "yt-dlp",
+ "distribution_version": "2026.7.4",
+ "allowed_type_codes": [
+ "b",
+ "x"
+ ],
+ "source_date_epoch": 1786207924,
+ "tree_hash_algorithm": "sha256(path-utf8,nul,decimal-bytes-ascii,nul,payload-sha256-lower-hex-ascii,lf)-upper-hex-v1",
+ "canonical_tree_sha256": "32F1DC23F6704966AE4511CB173318CD11FCBA031323028D268D9F2488589E70"
+ },
+ "dependency_artifact_manifest_bytes": 723,
+ "dependency_artifact_manifest_sha256": "2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D",
+ "files": [
+ {
+ "path": "__init__.py",
+ "bytes": 290,
+ "sha256": "AD80D1743ECD344FA10B28F0C3B2702EC18EF4C2136B7BF90E516597C99C5FF1"
+ },
+ {
+ "path": "background.js",
+ "bytes": 13201,
+ "sha256": "27C0598DD8D70659B1E518C841F2CC5908165458F99E8531D58A6B15F767BDE8"
+ },
+ {
+ "path": "build_host.ps1",
+ "bytes": 35414,
+ "sha256": "6184EBA087470BB5DBD6FEC29EB22068F8119FBDE2ED640715E8317B5A928C04"
+ },
+ {
+ "path": "config.example.json",
+ "bytes": 760,
+ "sha256": "FDFACA2935A29027848DEB11B746576957CC72766BA9B6161C7D7AD7C318DC8C"
+ },
+ {
+ "path": "constants.py",
+ "bytes": 3868,
+ "sha256": "B5FE4157F1747BF7E8928793860AF02F98DA98C992B57FCC43104F8726554096"
+ },
+ {
+ "path": "dependencies/dependency-artifact-manifest.json",
+ "bytes": 723,
+ "sha256": "2943A0EF7968523A342C28824CACC16E2E0D8324D36DEEEE2E6FBABB910AC27D"
+ },
+ {
+ "path": "dependencies/yt_dlp-2026.7.4-py3-none-any.whl",
+ "bytes": 3184705,
+ "sha256": "F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266"
+ },
+ {
+ "path": "install_native_host.ps1",
+ "bytes": 24079,
+ "sha256": "AA9667A3CD765B21058F234FD82DFDB9621161246041EC595E4BCB17DDCFDE86"
+ },
+ {
+ "path": "job.py",
+ "bytes": 15586,
+ "sha256": "09540CBF4F7693D22C0AB91E9C45D379ABA472156298F84296143485C6E0D42A"
+ },
+ {
+ "path": "manifest.json",
+ "bytes": 1011,
+ "sha256": "9B5CEB76758D69C3A9F7DEBDE5A55B026E70BA064769180A6F959A04283EA865"
+ },
+ {
+ "path": "native-host-manifest.template.json",
+ "bytes": 265,
+ "sha256": "2E55FD8915258557964CDC2C00491C83194D4B6F7B9AEAC0FC18C2A331C10918"
+ },
+ {
+ "path": "native_host.py",
+ "bytes": 29843,
+ "sha256": "41CA31D688F39E53B5C7A11ACE1390C3BEA1D8E08D0FFFF49C4C62D320F42937"
+ },
+ {
+ "path": "protocol.py",
+ "bytes": 11393,
+ "sha256": "8E8A1BBB742B37358A2848B28D2A7B10ACA9B20167844388455CAAB59DBE422D"
+ },
+ {
+ "path": "sidepanel.css",
+ "bytes": 400,
+ "sha256": "46AD95FC47D623CBE72A8FB060E238BC6066C152DD2C63DE9E0740D92BE4D01C"
+ },
+ {
+ "path": "sidepanel.html",
+ "bytes": 1094,
+ "sha256": "9836EE44AAFE4002EAB27AB77AB249BC416908C565138A956A19480F663EB3EB"
+ },
+ {
+ "path": "sidepanel.js",
+ "bytes": 2364,
+ "sha256": "88794C840C0491E0017E7E8D15A956E4AA28DCEB71950A991F4A40399C2449B4"
+ },
+ {
+ "path": "worker.py",
+ "bytes": 33118,
+ "sha256": "4A37BB7788D29D095A59812D6596E947094737669B9AD36B66D8088F27B03B78"
+ }
+ ]
+}
diff --git a/dev/project-dev/bili_authenticated_extension/worker.py b/dev/project-dev/bili_authenticated_extension/worker.py
new file mode 100644
index 0000000..6d1a2ca
--- /dev/null
+++ b/dev/project-dev/bili_authenticated_extension/worker.py
@@ -0,0 +1,914 @@
+"""Authenticated download worker security boundary.
+
+This module is imported only by the worker mode. It owns yt-dlp, local-only
+FFmpeg operations, and the frozen ``accept-browser-file`` bridge invocation.
+Signed media URLs and cookies never leave this process memory.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import importlib.util
+import io
+import json
+import math
+import os
+import shutil
+import subprocess
+import sys
+import time
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Callable, Iterable, Sequence
+from urllib.parse import urlparse
+
+from .constants import (
+ BRIDGE_TIMEOUT_SECONDS,
+ CANONICAL_URL,
+ DURATION_TOLERANCE_MS,
+ EXPECTED_DURATION_MS,
+ EXTRACTOR_RETRIES,
+ FILE_ACCESS_RETRIES,
+ FRAGMENT_RETRIES,
+ HTTP_RETRIES,
+ SOCKET_TIMEOUT_SECONDS,
+ TARGET_BVID,
+ YTDLP_MODULE_SHA256,
+ YTDLP_VERSION,
+)
+from .protocol import ProtocolError, strict_json_loads, validate_start
+
+FROZEN_BRIDGE_SHA256 = "749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13"
+
+
+class WorkerError(Exception):
+ def __init__(self, code: str) -> None:
+ super().__init__(code)
+ self.code = code
+
+
+class CancelRequested(BaseException):
+ pass
+
+
+class NullLogger:
+ def debug(self, _: object) -> None:
+ return None
+
+ def info(self, _: object) -> None:
+ return None
+
+ def warning(self, _: object) -> None:
+ return None
+
+ def error(self, _: object) -> None:
+ return None
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest().upper()
+
+
+def verify_frozen_ytdlp() -> Path:
+ """Verify the source package without importing it."""
+ spec = importlib.util.find_spec("yt_dlp")
+ if spec is None or spec.origin is None:
+ raise WorkerError("E_YTDLP_FROZEN")
+ root = Path(spec.origin).resolve().parent
+ for relative, expected in YTDLP_MODULE_SHA256.items():
+ path = root / Path(relative)
+ if not path.is_file() or sha256_file(path) != expected:
+ raise WorkerError("E_YTDLP_FROZEN")
+ version_path = root / "version.py"
+ version_scope: dict[str, Any] = {}
+ exec(compile(version_path.read_bytes(), str(version_path), "exec"), version_scope)
+ if version_scope.get("__version__") != YTDLP_VERSION:
+ raise WorkerError("E_YTDLP_FROZEN")
+ return root
+
+
+def bootstrap_ytdlp() -> tuple[Any, Any]:
+ """Disable every plugin source before the first yt-dlp import."""
+ os.environ["YTDLP_NO_PLUGINS"] = "1"
+ verify_frozen_ytdlp()
+ import yt_dlp # noqa: PLC0415 - deliberately after the environment gate
+ import yt_dlp.globals as yt_globals # noqa: PLC0415
+ import yt_dlp.plugins as yt_plugins # noqa: PLC0415
+
+ yt_globals.plugin_dirs.value = []
+ yt_plugins.load_all_plugins()
+ plugin_overrides = dict(yt_globals.plugin_ies_overrides.value)
+ if not (
+ yt_globals.plugin_dirs.value == []
+ and yt_plugins.directories() == []
+ and yt_globals.plugin_ies.value == {}
+ and yt_globals.plugin_pps.value == {}
+ and plugin_overrides == {}
+ ):
+ raise WorkerError("E_PLUGIN_BOUNDARY")
+ return yt_dlp, yt_globals
+
+
+@dataclass(frozen=True)
+class HostConfig:
+ ffmpeg: Path
+ ffprobe: Path
+ bridge_python: Path
+ bridge_script: Path
+ batch_json: Path
+ yt_dlp_executable: Path
+ destination: Path
+
+ @staticmethod
+ def _safe_absolute_file(value: Any, expected_hash: Any) -> Path:
+ if not isinstance(value, str) or not isinstance(expected_hash, str):
+ raise WorkerError("E_CONFIG")
+ path = Path(value)
+ if not path.is_absolute() or str(path).startswith("\\\\"):
+ raise WorkerError("E_CONFIG")
+ resolved = path.resolve(strict=True)
+ if not resolved.is_file() or resolved.is_symlink():
+ raise WorkerError("E_CONFIG")
+ if sha256_file(resolved) != expected_hash.upper():
+ raise WorkerError("E_CONFIG_HASH")
+ return resolved
+
+ @classmethod
+ def load(cls, path: Path) -> "HostConfig":
+ try:
+ if path.is_symlink():
+ raise WorkerError("E_CONFIG")
+ raw = strict_json_loads(path.read_bytes())
+ except (OSError, UnicodeError, json.JSONDecodeError, ProtocolError) as exc:
+ raise WorkerError("E_CONFIG") from exc
+ expected = {
+ "schema",
+ "target",
+ "canonical_url",
+ "ffmpeg",
+ "ffmpeg_sha256",
+ "ffprobe",
+ "ffprobe_sha256",
+ "bridge_python",
+ "bridge_python_sha256",
+ "bridge_script",
+ "bridge_script_sha256",
+ "batch_json",
+ "batch_json_sha256",
+ "yt_dlp_executable",
+ "yt_dlp_executable_sha256",
+ "destination",
+ }
+ if not isinstance(raw, dict) or set(raw) != expected:
+ raise WorkerError("E_CONFIG")
+ if raw["schema"] != 1 or raw["target"] != TARGET_BVID or raw["canonical_url"] != CANONICAL_URL:
+ raise WorkerError("E_CONFIG")
+ bridge_script = cls._safe_absolute_file(raw["bridge_script"], raw["bridge_script_sha256"])
+ if raw["bridge_script_sha256"].upper() != FROZEN_BRIDGE_SHA256:
+ raise WorkerError("E_CONFIG_HASH")
+ destination = Path(raw["destination"])
+ if not destination.is_absolute() or str(destination).startswith("\\\\"):
+ raise WorkerError("E_CONFIG")
+ destination = destination.resolve(strict=True)
+ if not destination.is_dir() or destination.is_symlink():
+ raise WorkerError("E_CONFIG")
+ return cls(
+ ffmpeg=cls._safe_absolute_file(raw["ffmpeg"], raw["ffmpeg_sha256"]),
+ ffprobe=cls._safe_absolute_file(raw["ffprobe"], raw["ffprobe_sha256"]),
+ bridge_python=cls._safe_absolute_file(
+ raw["bridge_python"], raw["bridge_python_sha256"]
+ ),
+ bridge_script=bridge_script,
+ batch_json=cls._safe_absolute_file(raw["batch_json"], raw["batch_json_sha256"]),
+ yt_dlp_executable=cls._safe_absolute_file(
+ raw["yt_dlp_executable"], raw["yt_dlp_executable_sha256"]
+ ),
+ destination=destination,
+ )
+
+
+def _is_reparse(path: Path) -> bool:
+ try:
+ attributes = path.lstat().st_file_attributes
+ except AttributeError:
+ return path.is_symlink()
+ return bool(attributes & 0x400)
+
+
+def _reject_reparse_path(path: Path, stop: Path | None = None) -> None:
+ current = path
+ stop_value = stop.resolve(strict=False) if stop is not None else None
+ while True:
+ if current.exists() and _is_reparse(current):
+ raise WorkerError("E_STAGE")
+ if (stop_value is not None and current.resolve(strict=False) == stop_value) or current.parent == current:
+ break
+ current = current.parent
+
+
+def validated_local_app_data() -> Path:
+ """Return the one non-secret environment path retained by the worker."""
+ value = os.environ.get("LOCALAPPDATA")
+ if not value:
+ raise WorkerError("E_STAGE")
+ candidate = Path(value)
+ if not candidate.is_absolute() or str(candidate).startswith("\\\\"):
+ raise WorkerError("E_STAGE")
+ _reject_reparse_path(candidate)
+ try:
+ resolved = candidate.resolve(strict=True)
+ except OSError as exc:
+ raise WorkerError("E_STAGE") from exc
+ if not resolved.is_dir() or _is_reparse(resolved):
+ raise WorkerError("E_STAGE")
+ return resolved
+
+
+def fixed_stage_root() -> Path:
+ local_app_data = validated_local_app_data()
+ logical_root = (
+ local_app_data
+ / "project-info"
+ / "bili-auth-ingress"
+ / TARGET_BVID
+ )
+ _reject_reparse_path(logical_root, local_app_data)
+ resolved = logical_root.resolve(strict=False)
+ _ensure_within(resolved, local_app_data)
+ return resolved
+
+
+def _ensure_within(path: Path, root: Path) -> Path:
+ resolved = path.resolve(strict=False)
+ try:
+ resolved.relative_to(root.resolve(strict=False))
+ except ValueError as exc:
+ raise WorkerError("E_STAGE") from exc
+ return resolved
+
+
+def _reject_reparse_chain(path: Path, stop: Path) -> None:
+ _reject_reparse_path(path, stop)
+
+
+def cleanup_stale_runs(root: Path, *, boundary: Path | None = None) -> None:
+ """Remove only uncommitted run-* directories below the fixed stage root."""
+ allowed_root = fixed_stage_root() if boundary is None else boundary.resolve()
+ _ensure_within(root, allowed_root)
+ if not root.exists():
+ return
+ _reject_reparse_chain(root, allowed_root)
+ for child in root.iterdir():
+ if not child.name.startswith("run-") or not child.is_dir() or child.is_symlink():
+ raise WorkerError("E_STAGE")
+ _ensure_within(child, root)
+ shutil.rmtree(child)
+
+
+def create_run_directory(root: Path | None = None) -> Path:
+ stage_root = fixed_stage_root() if root is None else root.resolve()
+ _ensure_within(stage_root, stage_root)
+ stage_root.mkdir(parents=True, exist_ok=True)
+ _reject_reparse_chain(stage_root, stage_root)
+ for _ in range(8):
+ candidate = stage_root / f"run-{uuid.uuid4().hex}"
+ try:
+ candidate.mkdir(exist_ok=False)
+ return candidate
+ except FileExistsError:
+ continue
+ raise WorkerError("E_STAGE")
+
+
+def prepare_run_directory(root: Path | None = None) -> Path:
+ """Clean stale runs and create the secret-free task lease."""
+ stage_root = fixed_stage_root() if root is None else root.resolve()
+ cleanup_stale_runs(stage_root, boundary=stage_root if root is not None else None)
+ return create_run_directory(stage_root)
+
+
+def cleanup_run_directory(run_directory: Path, stage_root: Path) -> None:
+ _ensure_within(run_directory, stage_root)
+ if run_directory.exists():
+ if not run_directory.is_dir() or _is_reparse(run_directory):
+ raise WorkerError("E_STAGE")
+ shutil.rmtree(run_directory)
+ if stage_root.exists() and not any(stage_root.iterdir()):
+ stage_root.rmdir()
+
+
+def build_cookie_stream(start: dict[str, Any]) -> io.StringIO:
+ """Convert validated Chrome records to an in-memory Netscape jar."""
+ validate_start(start)
+ stream = io.StringIO(newline="\n")
+ stream.write("# Netscape HTTP Cookie File\n")
+ for cookie in start["cookies"]:
+ domain = cookie["domain"]
+ if cookie["http_only"]:
+ domain = f"#HttpOnly_{domain}"
+ fields = (
+ domain,
+ "FALSE" if cookie["host_only"] else "TRUE",
+ cookie["path"],
+ "TRUE" if cookie["secure"] else "FALSE",
+ "0" if cookie["session"] else str(cookie["expiration_unix"]),
+ cookie["name"],
+ cookie["value"],
+ )
+ stream.write("\t".join(fields) + "\n")
+ stream.seek(0)
+ return stream
+
+
+def close_cookie_stream(stream: io.StringIO | None) -> bool:
+ if stream is None:
+ return True
+ try:
+ stream.seek(0)
+ stream.truncate(0)
+ finally:
+ stream.close()
+ return stream.closed
+
+
+def _finite_number(value: Any) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise WorkerError("E_METADATA")
+ converted = float(value)
+ if not math.isfinite(converted):
+ raise WorkerError("E_METADATA")
+ return converted
+
+
+def validate_processed_info(info: Any) -> dict[str, Any]:
+ if not isinstance(info, dict) or info.get("id") != TARGET_BVID:
+ raise WorkerError("E_METADATA")
+ if info.get("entries") not in (None, []) or info.get("_type") not in (None, "video"):
+ raise WorkerError("E_MULTI_PART")
+ if info.get("playlist_count") not in (None, 1) or info.get("playlist_index") not in (None, 1):
+ raise WorkerError("E_MULTI_PART")
+ if info.get("is_live") is True or info.get("live_status") not in (None, "not_live"):
+ raise WorkerError("E_LIVE")
+ if info.get("has_drm") is True:
+ raise WorkerError("E_DRM")
+ if info.get("availability") not in (None, "public", "unlisted"):
+ raise WorkerError("E_ENTITLEMENT")
+ duration_ms = round(_finite_number(info.get("duration")) * 1000)
+ if abs(duration_ms - EXPECTED_DURATION_MS) > DURATION_TOLERANCE_MS:
+ raise WorkerError("E_DURATION")
+ formats = info.get("formats")
+ if not isinstance(formats, list) or not formats:
+ raise WorkerError("E_FORMAT")
+ return info
+
+
+def _format_leaves(download_info: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
+ requested = download_info.get("requested_formats")
+ if requested is None:
+ leaves = [download_info]
+ single = True
+ else:
+ if not isinstance(requested, list) or len(requested) != 2:
+ raise WorkerError("E_FORMAT")
+ if not all(isinstance(item, dict) for item in requested):
+ raise WorkerError("E_FORMAT")
+ leaves = requested
+ single = False
+ return leaves, single
+
+
+def validate_download_info(
+ download_info: Any,
+ params: dict[str, Any],
+ *,
+ downloader_resolver: Callable[..., Any] | None = None,
+) -> tuple[list[dict[str, Any]], bool]:
+ if not isinstance(download_info, dict) or download_info.get("id") != TARGET_BVID:
+ raise WorkerError("E_FORMAT")
+ leaves, single = _format_leaves(download_info)
+ if single:
+ if download_info.get("vcodec") in (None, "none") or download_info.get("acodec") in (None, "none"):
+ raise WorkerError("E_FORMAT")
+ else:
+ video_only = sum(
+ leaf.get("vcodec") not in (None, "none") and leaf.get("acodec") == "none"
+ for leaf in leaves
+ )
+ audio_only = sum(
+ leaf.get("vcodec") == "none" and leaf.get("acodec") not in (None, "none")
+ for leaf in leaves
+ )
+ if video_only != 1 or audio_only != 1:
+ raise WorkerError("E_FORMAT")
+ if downloader_resolver is None:
+ from yt_dlp.downloader import get_suitable_downloader # noqa: PLC0415
+
+ downloader_resolver = get_suitable_downloader
+ for leaf in leaves:
+ if leaf.get("has_drm") is True:
+ raise WorkerError("E_DRM")
+ url = leaf.get("url")
+ protocol = leaf.get("protocol")
+ if not isinstance(url, str) or urlparse(url).scheme != "https":
+ raise WorkerError("E_FORMAT")
+ if protocol not in {"https", "http_dash_segments"}:
+ raise WorkerError("E_DOWNLOADER")
+ downloader = downloader_resolver(leaf, params)
+ if getattr(downloader, "__name__", "") not in {"HttpFD", "DashSegmentsFD"}:
+ raise WorkerError("E_DOWNLOADER")
+ return leaves, single
+
+
+def prepare_download_info(
+ ydl: Any,
+ *,
+ downloader_resolver: Callable[..., Any] | None = None,
+) -> tuple[dict[str, Any], bool, tuple[str, ...]]:
+ extract_count = 0
+ original_extract = ydl.extract_info
+
+ def one_extract(*args: Any, **kwargs: Any) -> Any:
+ nonlocal extract_count
+ extract_count += 1
+ if extract_count != 1:
+ raise WorkerError("E_SECOND_EXTRACT")
+ return original_extract(*args, **kwargs)
+
+ ydl.extract_info = one_extract
+ processed = ydl.extract_info(CANONICAL_URL, download=False, process=True)
+ validate_processed_info(processed)
+ selector = ydl.build_format_selector("bestvideo+bestaudio/best")
+ selected = list(ydl._select_formats(ydl._get_formats(processed), selector))
+ if len(selected) != 1:
+ raise WorkerError("E_FORMAT")
+ download_info = ydl._copy_infodict(processed)
+ download_info.update(selected[0])
+ leaves, single = validate_download_info(
+ download_info,
+ ydl.params,
+ downloader_resolver=downloader_resolver,
+ )
+ signed_urls = tuple(str(leaf["url"]) for leaf in leaves)
+
+ def reject_second_extract(*_: Any, **__: Any) -> Any:
+ raise WorkerError("E_SECOND_EXTRACT")
+
+ ydl.extract_info = reject_second_extract
+ if extract_count != 1:
+ raise WorkerError("E_SECOND_EXTRACT")
+ return download_info, single, signed_urls
+
+
+class SubprocessPolicy:
+ """Pre-CreateProcess audit for local-only child command lines."""
+
+ def __init__(
+ self,
+ run_root: Path,
+ executables: Iterable[Path],
+ secrets: Iterable[str] = (),
+ ) -> None:
+ self.run_root = run_root.resolve()
+ self.executables = {os.path.normcase(str(item.resolve())) for item in executables}
+ self.secrets = {item.casefold() for item in secrets if item}
+
+ def check(self, event: str, arguments: tuple[Any, ...]) -> None:
+ if event != "subprocess.Popen":
+ return
+ executable, argv, _cwd, environment = arguments
+ if not isinstance(executable, (str, os.PathLike)) or not isinstance(argv, (list, tuple)):
+ raise WorkerError("E_SUBPROCESS_POLICY")
+ executable_key = os.path.normcase(str(Path(executable).resolve()))
+ if executable_key not in self.executables:
+ raise WorkerError("E_SUBPROCESS_POLICY")
+ text_args = [str(item) for item in argv]
+ folded = "\x00".join(text_args).casefold()
+ forbidden = ("://", "-headers", "-cookies", "authorization", "cookie:", "referer:", "user-agent:")
+ if any(item in folded for item in forbidden) or any(item in folded for item in self.secrets):
+ raise WorkerError("E_SUBPROCESS_POLICY")
+ if environment is not None:
+ encoded_env = "\x00".join(f"{key}={value}" for key, value in environment.items()).casefold()
+ if any(item in encoded_env for item in self.secrets):
+ raise WorkerError("E_SUBPROCESS_POLICY")
+ if executable_key.endswith("ffmpeg.exe") or executable_key.endswith("ffprobe.exe"):
+ for index, argument in enumerate(text_args[:-1]):
+ if argument == "-i":
+ self._local_path(text_args[index + 1], must_exist=True)
+ output = text_args[-1]
+ if output not in {"-", "NUL"} and not output.startswith("-"):
+ self._local_path(output, must_exist=False)
+
+ def _local_path(self, value: str, *, must_exist: bool) -> Path:
+ path = Path(value)
+ if not path.is_absolute() or str(path).startswith("\\\\"):
+ raise WorkerError("E_SUBPROCESS_POLICY")
+ try:
+ resolved = path.resolve(strict=must_exist)
+ except OSError as exc:
+ raise WorkerError("E_SUBPROCESS_POLICY") from exc
+ try:
+ resolved.relative_to(self.run_root)
+ except ValueError as exc:
+ raise WorkerError("E_SUBPROCESS_POLICY") from exc
+ if must_exist and (not resolved.is_file() or resolved.is_symlink()):
+ raise WorkerError("E_SUBPROCESS_POLICY")
+ return resolved
+
+ def install(self) -> None:
+ sys.addaudithook(self.check)
+
+
+def sanitized_environment() -> dict[str, str]:
+ allowed = {"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "COMSPEC"}
+ result = {key: value for key, value in os.environ.items() if key.upper() in allowed}
+ result["LOCALAPPDATA"] = str(validated_local_app_data())
+ result["YTDLP_NO_PLUGINS"] = "1"
+ return result
+
+
+def _run_local(
+ command: Sequence[str],
+ timeout: int,
+ *,
+ capture_stdout: bool = False,
+) -> subprocess.CompletedProcess[bytes]:
+ return subprocess.run(
+ list(command),
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE if capture_stdout else subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ check=False,
+ timeout=timeout,
+ env=sanitized_environment(),
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
+ )
+
+
+def remux_single_to_mkv(ffmpeg: Path, source: Path, destination: Path) -> None:
+ if destination.exists():
+ raise WorkerError("E_COLLISION")
+ try:
+ result = _run_local(
+ [
+ str(ffmpeg),
+ "-nostdin",
+ "-v",
+ "error",
+ "-i",
+ str(source),
+ "-map",
+ "0:v:0",
+ "-map",
+ "0:a:0",
+ "-c",
+ "copy",
+ "-map_metadata",
+ "-1",
+ "-f",
+ "matroska",
+ str(destination),
+ ],
+ timeout=BRIDGE_TIMEOUT_SECONDS,
+ )
+ except subprocess.TimeoutExpired as exc:
+ destination.unlink(missing_ok=True)
+ raise WorkerError("E_MERGE") from exc
+ if result.returncode != 0 or not destination.is_file() or destination.stat().st_size <= 0:
+ destination.unlink(missing_ok=True)
+ raise WorkerError("E_MERGE")
+
+
+def merge_local_streams(ffmpeg: Path, video: Path, audio: Path, destination: Path) -> None:
+ if destination.exists():
+ raise WorkerError("E_COLLISION")
+ try:
+ result = _run_local(
+ [
+ str(ffmpeg),
+ "-nostdin",
+ "-v",
+ "error",
+ "-i",
+ str(video),
+ "-i",
+ str(audio),
+ "-map",
+ "0:v:0",
+ "-map",
+ "1:a:0",
+ "-c",
+ "copy",
+ "-map_metadata",
+ "-1",
+ "-f",
+ "matroska",
+ str(destination),
+ ],
+ timeout=BRIDGE_TIMEOUT_SECONDS,
+ )
+ except subprocess.TimeoutExpired as exc:
+ destination.unlink(missing_ok=True)
+ raise WorkerError("E_MERGE") from exc
+ if result.returncode != 0 or not destination.is_file() or destination.stat().st_size <= 0:
+ destination.unlink(missing_ok=True)
+ raise WorkerError("E_MERGE")
+
+
+def probe_mkv(ffprobe: Path, candidate: Path) -> None:
+ try:
+ result = _run_local(
+ [
+ str(ffprobe),
+ "-v",
+ "error",
+ "-show_entries",
+ "format=format_name:stream=codec_type",
+ "-of",
+ "json",
+ str(candidate),
+ ],
+ timeout=120,
+ capture_stdout=True,
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise WorkerError("E_MEDIA_VALIDATION") from exc
+ try:
+ payload = json.loads(result.stdout.decode("utf-8"))
+ except (UnicodeError, json.JSONDecodeError) as exc:
+ raise WorkerError("E_MEDIA_VALIDATION") from exc
+ stream_types = {item.get("codec_type") for item in payload.get("streams", []) if isinstance(item, dict)}
+ format_name = payload.get("format", {}).get("format_name", "")
+ if result.returncode != 0 or {"video", "audio"} - stream_types or "matroska" not in format_name:
+ raise WorkerError("E_MEDIA_VALIDATION")
+
+
+def validate_unique_candidate(run_directory: Path) -> Path:
+ forbidden_suffixes = {".part", ".tmp", ".crdownload", ".txt", ".json", ".url"}
+ files = [item for item in run_directory.iterdir() if item.is_file()]
+ if any(item.suffix.casefold() in forbidden_suffixes for item in files):
+ raise WorkerError("E_STAGE")
+ candidates = [item for item in files if item.suffix.casefold() == ".mkv"]
+ if len(candidates) != 1 or len(files) != 1:
+ raise WorkerError("E_STAGE")
+ return candidates[0]
+
+
+def _bridge_command(config: HostConfig, candidate: Path) -> list[str]:
+ return [
+ str(config.bridge_python),
+ str(config.bridge_script),
+ "--input",
+ str(config.batch_json),
+ "--yt-dlp",
+ str(config.yt_dlp_executable),
+ "accept-browser-file",
+ "--bvid",
+ TARGET_BVID,
+ "--media-file",
+ str(candidate),
+ "--destination",
+ str(config.destination),
+ "--ffprobe",
+ str(config.ffprobe),
+ ]
+
+
+def run_frozen_bridge(config: HostConfig, candidate: Path) -> tuple[str, str]:
+ try:
+ result = _run_local(
+ _bridge_command(config, candidate),
+ BRIDGE_TIMEOUT_SECONDS,
+ capture_stdout=True,
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise WorkerError("E_BACKHALF") from exc
+ if result.returncode != 0:
+ raise WorkerError("E_BACKHALF")
+ try:
+ payload = json.loads(result.stdout.decode("utf-8"))
+ except (UnicodeError, json.JSONDecodeError) as exc:
+ raise WorkerError("E_BACKHALF") from exc
+ if not isinstance(payload, dict) or payload.get("result") != "PASS":
+ raise WorkerError("E_BACKHALF")
+ items = payload.get("items")
+ if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict):
+ raise WorkerError("E_BACKHALF")
+ item = items[0]
+ required = {"bvid", "local_file", "bytes", "sha256", "duration_seconds", "acquisition_mode"}
+ if not required.issubset(item) or item["bvid"] != TARGET_BVID:
+ raise WorkerError("E_BACKHALF")
+ if item["acquisition_mode"] != "authorized_browser_file_handoff":
+ raise WorkerError("E_BACKHALF")
+ formal = item["local_file"]
+ mapping = f"{TARGET_BVID}.download.json"
+ if formal != f"{TARGET_BVID}.mkv":
+ raise WorkerError("E_BACKHALF")
+ formal_path = config.destination / formal
+ mapping_path = config.destination / mapping
+ if not formal_path.is_file() or not mapping_path.is_file():
+ raise WorkerError("E_BACKHALF")
+ try:
+ duration_ms = round(_finite_number(item["duration_seconds"]) * 1000)
+ except WorkerError as exc:
+ raise WorkerError("E_BACKHALF") from exc
+ if abs(duration_ms - EXPECTED_DURATION_MS) > DURATION_TOLERANCE_MS:
+ raise WorkerError("E_BACKHALF")
+ formal_sha = sha256_file(formal_path)
+ if (
+ isinstance(item["bytes"], bool)
+ or not isinstance(item["bytes"], int)
+ or item["bytes"] != formal_path.stat().st_size
+ or not isinstance(item["sha256"], str)
+ or item["sha256"] != formal_sha
+ ):
+ raise WorkerError("E_BACKHALF")
+ try:
+ persisted = strict_json_loads(mapping_path.read_bytes())
+ except (OSError, ProtocolError) as exc:
+ raise WorkerError("E_BACKHALF") from exc
+ matched_fields = {
+ "bvid": TARGET_BVID,
+ "source": CANONICAL_URL,
+ "local_file": formal,
+ "bytes": item["bytes"],
+ "sha256": formal_sha,
+ "acquisition_mode": "authorized_browser_file_handoff",
+ }
+ if any(persisted.get(key) != expected for key, expected in matched_fields.items()):
+ raise WorkerError("E_BACKHALF")
+ try:
+ persisted_duration_ms = round(_finite_number(persisted.get("duration_seconds")) * 1000)
+ except WorkerError as exc:
+ raise WorkerError("E_BACKHALF") from exc
+ if persisted_duration_ms != duration_ms:
+ raise WorkerError("E_BACKHALF")
+ return formal, mapping
+
+
+def ytdlp_options(
+ config: HostConfig,
+ run_directory: Path,
+ cookie_stream: io.StringIO,
+ progress_hook: Callable[[dict[str, Any]], None],
+) -> dict[str, Any]:
+ return {
+ "cookiefile": cookie_stream,
+ "format": "bestvideo+bestaudio/best",
+ "merge_output_format": "mkv",
+ "outtmpl": str(run_directory / "%(id)s.%(format_id)s.%(ext)s"),
+ "noplaylist": True,
+ "continuedl": False,
+ "overwrites": False,
+ "cachedir": False,
+ "quiet": True,
+ "no_warnings": True,
+ "logger": NullLogger(),
+ "progress_hooks": [progress_hook],
+ "postprocessor_hooks": [progress_hook],
+ "socket_timeout": SOCKET_TIMEOUT_SECONDS,
+ "extractor_retries": EXTRACTOR_RETRIES,
+ "retries": HTTP_RETRIES,
+ "fragment_retries": FRAGMENT_RETRIES,
+ "file_access_retries": FILE_ACCESS_RETRIES,
+ "retry_sleep_functions": {
+ "http": lambda _attempt: 1,
+ "fragment": lambda _attempt: 1,
+ "file_access": lambda _attempt: 1,
+ "extractor": lambda _attempt: 1,
+ },
+ "ffmpeg_location": str(config.ffmpeg),
+ "writethumbnail": False,
+ "writesubtitles": False,
+ "writeautomaticsub": False,
+ "writeinfojson": False,
+ "writedescription": False,
+ "writecomments": False,
+ "getcomments": False,
+ "allow_playlist_files": False,
+ "external_downloader": {},
+ }
+
+
+def run_authenticated_task(
+ start: dict[str, Any],
+ config: HostConfig,
+ *,
+ cancel_check: Callable[[], bool],
+ report: Callable[[str, int], None],
+ stage_root: Path | None = None,
+ prepared_run_directory: Path | None = None,
+ commit_begin: Callable[[], None] | None = None,
+ closure_report: Callable[[bool], None] | None = None,
+) -> tuple[str, str, bool]:
+ """Run the exact task. The caller must already own this worker in a job."""
+ validate_start(start)
+ root = fixed_stage_root() if stage_root is None else stage_root.resolve()
+ if prepared_run_directory is None:
+ run_directory = prepare_run_directory(root)
+ else:
+ run_directory = prepared_run_directory.resolve(strict=True)
+ _ensure_within(run_directory, root)
+ if not run_directory.is_dir() or _is_reparse(run_directory):
+ raise WorkerError("E_STAGE")
+ cookie_stream: io.StringIO | None = None
+ cookie_closed = False
+ committed = False
+ outcome: tuple[str, str, bool] | None = None
+ try:
+ def checkpoint() -> None:
+ if cancel_check():
+ raise CancelRequested()
+
+ checkpoint()
+ cookie_stream = build_cookie_stream(start)
+ yt_dlp, _ = bootstrap_ytdlp()
+ secret_values = [
+ value
+ for cookie in start["cookies"]
+ for value in (cookie["name"], cookie["value"])
+ ]
+ policy = SubprocessPolicy(
+ run_directory,
+ {config.ffmpeg, config.ffprobe, config.bridge_python},
+ secret_values,
+ )
+ policy.install()
+
+ def progress_hook(status: dict[str, Any]) -> None:
+ if cancel_check():
+ raise CancelRequested()
+ downloaded = status.get("downloaded_bytes")
+ total = status.get("total_bytes") or status.get("total_bytes_estimate")
+ progress = 0
+ if isinstance(downloaded, (int, float)) and isinstance(total, (int, float)) and total > 0:
+ progress = max(0, min(95, int(float(downloaded) * 95 / float(total))))
+ report("DOWNLOADING", progress)
+
+ opts = ytdlp_options(config, run_directory, cookie_stream, progress_hook)
+ with yt_dlp.YoutubeDL(opts) as ydl:
+ report("CHECKING", 0)
+ download_info, single, signed_urls = prepare_download_info(ydl)
+ policy.secrets.update(url.casefold() for url in signed_urls)
+ checkpoint()
+ ydl.process_info(download_info)
+ checkpoint()
+ report("MERGING", 96)
+ files = [item for item in run_directory.iterdir() if item.is_file()]
+ if single:
+ originals = [item for item in files if item.suffix.casefold() not in {".part", ".tmp"}]
+ if len(originals) != 1:
+ raise WorkerError("E_STAGE")
+ final = run_directory / "complete.mkv"
+ checkpoint()
+ remux_single_to_mkv(config.ffmpeg, originals[0], final)
+ checkpoint()
+ originals[0].unlink()
+ else:
+ mkv_candidates = [item for item in files if item.suffix.casefold() == ".mkv"]
+ if len(mkv_candidates) != 1:
+ raise WorkerError("E_STAGE")
+ final = run_directory / "complete.mkv"
+ if mkv_candidates[0] != final:
+ if final.exists():
+ raise WorkerError("E_COLLISION")
+ checkpoint()
+ mkv_candidates[0].rename(final)
+ checkpoint()
+ checkpoint()
+ candidate = validate_unique_candidate(run_directory)
+ checkpoint()
+ probe_mkv(config.ffprobe, candidate)
+ checkpoint()
+ cookie_closed = close_cookie_stream(cookie_stream)
+ cookie_stream = None
+ report("VALIDATING", 98)
+ checkpoint()
+ report("PUBLISHING", 99)
+ if commit_begin is not None:
+ commit_begin()
+ checkpoint()
+ formal, mapping = run_frozen_bridge(config, candidate)
+ committed = True
+ outcome = (formal, mapping, cookie_closed)
+ finally:
+ active_error = sys.exc_info()[1]
+ if cookie_stream is not None:
+ cookie_closed = close_cookie_stream(cookie_stream)
+ if closure_report is not None:
+ closure_report(cookie_closed)
+ try:
+ cleanup_run_directory(run_directory, root)
+ except OSError as exc:
+ if not committed:
+ if active_error is not None:
+ active_error.add_note("stage cleanup failed")
+ else:
+ raise WorkerError("E_STAGE") from exc
+ if outcome is None:
+ raise WorkerError("E_WORKER")
+ return outcome
diff --git a/dev/project-dev/bili_dynamic_collector.example.json b/dev/project-dev/bili_dynamic_collector.example.json
new file mode 100644
index 0000000..891f889
--- /dev/null
+++ b/dev/project-dev/bili_dynamic_collector.example.json
@@ -0,0 +1,38 @@
+{
+ "schema_version": 1,
+ "creator": {
+ "name": "青枫浦上Q",
+ "dynamic_url": "https://space.bilibili.com/1420210197/dynamic"
+ },
+ "timezone": "Asia/Shanghai",
+ "window_hours": 72,
+ "minimum_complete_age_seconds": 30,
+ "title_max_length": 48,
+ "allowed_source_hosts": [
+ "space.bilibili.com",
+ "www.bilibili.com",
+ "t.bilibili.com",
+ "b23.tv"
+ ],
+ "allowed_video_extensions": [
+ ".mp4",
+ ".mkv",
+ ".mov",
+ ".webm"
+ ],
+ "native_handoff": {
+ "project_id": "project-info",
+ "source_ai_id": "video-downloader",
+ "source_thread_id": "019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5",
+ "source_role_instance_id": "case_analysis.video_downloader",
+ "target_ai_id": "media-processor",
+ "target_thread_id": "019fb7a4-bdfd-79f2-bd6b-e67e2b7d8efd",
+ "target_role_instance_id": "case_analysis.media_processor",
+ "reply_thread_id": "019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5"
+ },
+ "paths": {
+ "state_dir": "tmp/bili-dynamic-collector-mvp/state",
+ "download_dir": "tmp/bili-dynamic-collector-mvp/downloads",
+ "video_dir": "tmp/bili-dynamic-collector-mvp/videos"
+ }
+}
diff --git a/dev/project-dev/bili_dynamic_collector.py b/dev/project-dev/bili_dynamic_collector.py
new file mode 100644
index 0000000..f2a0d48
--- /dev/null
+++ b/dev/project-dev/bili_dynamic_collector.py
@@ -0,0 +1,1479 @@
+#!/usr/bin/env python3
+"""Local Bilibili dynamic collection coordinator.
+
+This tool deliberately has no network or browser integration. It consumes
+metadata exported by a person/browser extension and coordinates local files.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import sys
+import uuid
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Iterable, Mapping, Sequence
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+
+SCHEMA_VERSION = 1
+MANIFEST_SCHEMA_VERSION = 1
+SECRET_KEY_PATTERN = re.compile(
+ r"(?:password|passwd|cookie|token|secret|authorization|captcha|session|"
+ r"口令|密码|令牌|验证码|会话)",
+ re.IGNORECASE,
+)
+WINDOWS_INVALID_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
+WINDOWS_RESERVED_NAMES = {
+ "CON",
+ "PRN",
+ "AUX",
+ "NUL",
+ *(f"COM{i}" for i in range(1, 10)),
+ *(f"LPT{i}" for i in range(1, 10)),
+}
+CONTENT_TYPE_LABELS = {
+ "text": "文字",
+ "article": "专栏",
+ "image": "图片",
+ "video": "视频",
+}
+TODO_ACTIONS = {
+ "text": "EXPORT_FULL_TEXT_AS_UTF8_TXT",
+ "article": "EXPORT_FULL_ARTICLE_AS_UTF8_TXT",
+ "image": "DOWNLOAD_ORIGINAL_IMAGES",
+ "video": "OPEN_PLAY_PAGE_AND_USE_INSTALLED_EXTENSION",
+}
+ACTIVE_OR_SUCCESS_STATUSES = {
+ "TODO_QUEUED",
+ "VIDEO_MOVED",
+ "VIDEO_MOVED_SOURCE_RETAINED",
+ "PROCESSING_HANDOFF_CONFIRMED",
+ "PROCESSING",
+ "COMPLETE",
+}
+RETRYABLE_STATUSES = {
+ "QUEUE_FAILED",
+ "MOVE_FAILED",
+ "PROCESSING_FAILED",
+}
+TEMP_DOWNLOAD_SUFFIXES = {
+ ".crdownload",
+ ".part",
+ ".partial",
+ ".tmp",
+ ".download",
+}
+DEFAULT_VIDEO_EXTENSIONS = {".mp4", ".mkv", ".mov", ".webm"}
+FILE_ATTRIBUTE_REPARSE_POINT = 0x400
+THREAD_ID_PATTERN = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+LOWER_SHA256_PATTERN = re.compile(r"[0-9a-f]{64}")
+INTERNAL_ENTITY_ID_PATTERN = re.compile(r"[0-9a-f]{24}")
+CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
+
+
+class CollectorError(Exception):
+ """Expected contract or safety failure."""
+
+ def __init__(
+ self,
+ code: str,
+ message: str,
+ *,
+ details: Mapping[str, Any] | None = None,
+ safety: bool = False,
+ ) -> None:
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.details = dict(details or {})
+ self.safety = safety
+
+
+@dataclass(frozen=True)
+class NativeHandoffRoute:
+ project_id: str
+ source_ai_id: str
+ source_thread_id: str
+ source_role_instance_id: str
+ target_ai_id: str
+ target_thread_id: str
+ target_role_instance_id: str
+ reply_thread_id: str
+
+ def as_dict(self) -> dict[str, str]:
+ return {
+ "project_id": self.project_id,
+ "source_ai_id": self.source_ai_id,
+ "source_thread_id": self.source_thread_id,
+ "source_role_instance_id": self.source_role_instance_id,
+ "target_ai_id": self.target_ai_id,
+ "target_thread_id": self.target_thread_id,
+ "target_role_instance_id": self.target_role_instance_id,
+ "reply_thread_id": self.reply_thread_id,
+ }
+
+
+@dataclass(frozen=True)
+class CollectorConfig:
+ creator_name: str
+ creator_dynamic_url: str
+ timezone_name: str
+ window_hours: int
+ state_dir: Path
+ download_dir: Path
+ video_dir: Path
+ minimum_complete_age_seconds: int
+ title_max_length: int
+ allowed_source_hosts: frozenset[str]
+ allowed_video_extensions: frozenset[str]
+ native_handoff: NativeHandoffRoute
+
+ @property
+ def manifest_path(self) -> Path:
+ return self.state_dir / "manifest.jsonl"
+
+ @property
+ def queues_dir(self) -> Path:
+ return self.state_dir / "queues"
+
+ @property
+ def handoffs_dir(self) -> Path:
+ return self.state_dir / "handoffs"
+
+ @property
+ def lock_path(self) -> Path:
+ return self.state_dir / ".collector.lock"
+
+ @property
+ def tz(self) -> ZoneInfo:
+ return ZoneInfo(self.timezone_name)
+
+
+class StateLock:
+ """Small fail-fast lock for overlapping local/scheduled invocations."""
+
+ def __init__(self, path: Path) -> None:
+ self.path = path
+ self.fd: int | None = None
+
+ def __enter__(self) -> "StateLock":
+ ensure_directory(self.path.parent, create=True)
+ try:
+ self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
+ except FileExistsError as exc:
+ raise CollectorError(
+ "E_STATE_LOCKED",
+ "Another collector invocation is active, or a stale lock needs manual review.",
+ details={"lock_path": str(self.path)},
+ safety=True,
+ ) from exc
+ payload = json.dumps(
+ {"pid": os.getpid(), "created_at": utc_now().isoformat()},
+ ensure_ascii=False,
+ sort_keys=True,
+ ).encode("utf-8")
+ os.write(self.fd, payload)
+ os.fsync(self.fd)
+ return self
+
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
+ if self.fd is not None:
+ os.close(self.fd)
+ self.fd = None
+ try:
+ self.path.unlink()
+ except FileNotFoundError:
+ pass
+
+
+def utc_now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def parse_datetime(value: Any, field: str) -> datetime:
+ if not isinstance(value, str) or not value.strip():
+ raise CollectorError("E_INPUT_SCHEMA", f"{field} must be a non-empty ISO-8601 string.")
+ text = value.strip()
+ if text.endswith("Z"):
+ text = text[:-1] + "+00:00"
+ try:
+ parsed = datetime.fromisoformat(text)
+ except ValueError as exc:
+ raise CollectorError("E_INPUT_SCHEMA", f"{field} is not valid ISO-8601: {value!r}.") from exc
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
+ raise CollectorError("E_INPUT_SCHEMA", f"{field} must include a UTC offset or Z.")
+ return parsed
+
+
+def canonical_datetime(value: datetime) -> str:
+ return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def reject_secret_keys(value: Any, path: str = "$") -> None:
+ if isinstance(value, Mapping):
+ for key, child in value.items():
+ key_text = str(key)
+ if SECRET_KEY_PATTERN.search(key_text):
+ raise CollectorError(
+ "E_SECRET_FIELD",
+ "Authentication or session fields are forbidden.",
+ details={"field": f"{path}.{key_text}"},
+ safety=True,
+ )
+ reject_secret_keys(child, f"{path}.{key_text}")
+ elif isinstance(value, list):
+ for index, child in enumerate(value):
+ reject_secret_keys(child, f"{path}[{index}]")
+
+
+def load_json(path: Path, description: str) -> Any:
+ lexical_lstat_chain(path, allow_missing_leaf=False)
+ if not path.is_file():
+ raise CollectorError("E_INPUT_PATH", f"{description} is not a regular file: {path}")
+ try:
+ raw = path.read_text(encoding="utf-8")
+ except UnicodeDecodeError as exc:
+ raise CollectorError("E_INPUT_ENCODING", f"{description} must be strict UTF-8: {path}") from exc
+ try:
+ value = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise CollectorError(
+ "E_INPUT_JSON",
+ f"{description} is not valid JSON: {path}",
+ details={"line": exc.lineno, "column": exc.colno},
+ ) from exc
+ reject_secret_keys(value)
+ return value
+
+
+def absolute_lexical(path: Path) -> Path:
+ return Path(os.path.abspath(os.fspath(path)))
+
+
+def is_reparse(stat_result: os.stat_result) -> bool:
+ return bool(getattr(stat_result, "st_file_attributes", 0) & FILE_ATTRIBUTE_REPARSE_POINT)
+
+
+def lexical_lstat_chain(path: Path, *, allow_missing_leaf: bool) -> None:
+ absolute = absolute_lexical(path)
+ parts = absolute.parts
+ if not parts:
+ raise CollectorError("E_PATH", "Empty path is not allowed.", safety=True)
+ current = Path(parts[0])
+ for index, part in enumerate(parts[1:], start=1):
+ current = current / part
+ try:
+ info = os.lstat(current)
+ except FileNotFoundError:
+ if allow_missing_leaf:
+ return
+ raise CollectorError(
+ "E_PATH_MISSING",
+ f"Required path does not exist: {current}",
+ safety=True,
+ )
+ if os.path.islink(current) or is_reparse(info):
+ raise CollectorError(
+ "E_PATH_REPARSE",
+ f"Symlink, junction, or reparse path is not allowed: {current}",
+ safety=True,
+ )
+ if index < len(parts) - 1 and not current.is_dir():
+ raise CollectorError(
+ "E_PATH_PARENT",
+ f"Path parent is not a directory: {current}",
+ safety=True,
+ )
+
+
+def ensure_directory(path: Path, *, create: bool) -> Path:
+ absolute = absolute_lexical(path)
+ lexical_lstat_chain(absolute, allow_missing_leaf=create)
+ if not absolute.exists():
+ if not create:
+ raise CollectorError("E_PATH_MISSING", f"Directory does not exist: {absolute}", safety=True)
+ missing: list[Path] = []
+ cursor = absolute
+ while not cursor.exists():
+ missing.append(cursor)
+ cursor = cursor.parent
+ lexical_lstat_chain(cursor, allow_missing_leaf=False)
+ for candidate in reversed(missing):
+ candidate.mkdir()
+ lexical_lstat_chain(candidate, allow_missing_leaf=False)
+ if not absolute.is_dir():
+ raise CollectorError("E_PATH_TYPE", f"Expected directory: {absolute}", safety=True)
+ return absolute
+
+
+def path_within(child: Path, parent: Path) -> bool:
+ try:
+ return os.path.commonpath((str(child), str(parent))) == str(parent)
+ except ValueError:
+ return False
+
+
+def config_path(base: Path, value: Any, field: str) -> Path:
+ if not isinstance(value, str) or not value.strip():
+ raise CollectorError("E_CONFIG", f"{field} must be a non-empty path string.")
+ candidate = Path(value)
+ if not candidate.is_absolute():
+ candidate = base / candidate
+ return absolute_lexical(candidate)
+
+
+def validate_url(value: Any, field: str, allowed_hosts: Iterable[str]) -> str:
+ if not isinstance(value, str) or not value.strip():
+ raise CollectorError("E_INPUT_SCHEMA", f"{field} must be a non-empty HTTPS URL.")
+ parts = urlsplit(value.strip())
+ host = (parts.hostname or "").lower()
+ if parts.scheme.lower() != "https" or not host or parts.username or parts.password:
+ raise CollectorError("E_SOURCE_URL", f"{field} must be an HTTPS URL without credentials.", safety=True)
+ if host not in set(allowed_hosts):
+ raise CollectorError(
+ "E_SOURCE_HOST",
+ f"{field} host is not registered: {host}",
+ details={"allowed_hosts": sorted(allowed_hosts)},
+ safety=True,
+ )
+ return canonical_url(value)
+
+
+def canonical_url(value: str) -> str:
+ parts = urlsplit(value.strip())
+ host = (parts.hostname or "").lower()
+ port = f":{parts.port}" if parts.port else ""
+ path = parts.path.rstrip("/") or "/"
+ query = urlencode(sorted(parse_qsl(parts.query, keep_blank_values=True)), doseq=True)
+ return urlunsplit((parts.scheme.lower(), host + port, path, query, ""))
+
+
+def load_config(path: Path) -> CollectorConfig:
+ value = load_json(path, "config")
+ if not isinstance(value, Mapping) or value.get("schema_version") != SCHEMA_VERSION:
+ raise CollectorError("E_CONFIG", f"config.schema_version must equal {SCHEMA_VERSION}.")
+ creator = value.get("creator")
+ paths = value.get("paths")
+ native_handoff = value.get("native_handoff")
+ if not isinstance(creator, Mapping) or not isinstance(paths, Mapping):
+ raise CollectorError("E_CONFIG", "config.creator and config.paths must be objects.")
+ if not isinstance(native_handoff, Mapping):
+ raise CollectorError("E_CONFIG", "config.native_handoff must be an object.")
+ route_fields = (
+ "project_id",
+ "source_ai_id",
+ "source_thread_id",
+ "source_role_instance_id",
+ "target_ai_id",
+ "target_thread_id",
+ "target_role_instance_id",
+ "reply_thread_id",
+ )
+ route_values: dict[str, str] = {}
+ for field in route_fields:
+ raw = native_handoff.get(field)
+ if not isinstance(raw, str) or not raw.strip():
+ raise CollectorError("E_CONFIG", f"native_handoff.{field} must be a non-empty string.")
+ route_values[field] = raw.strip()
+ for field in (
+ "project_id",
+ "source_ai_id",
+ "source_role_instance_id",
+ "target_ai_id",
+ "target_role_instance_id",
+ ):
+ if re.fullmatch(r"[A-Za-z0-9._-]{1,128}", route_values[field]) is None:
+ raise CollectorError("E_CONFIG", f"native_handoff.{field} contains unsafe characters.")
+ for field in ("source_thread_id", "target_thread_id", "reply_thread_id"):
+ if THREAD_ID_PATTERN.fullmatch(route_values[field]) is None:
+ raise CollectorError("E_CONFIG", f"native_handoff.{field} must be a lowercase UUID.")
+ creator_name = creator.get("name")
+ if not isinstance(creator_name, str) or not creator_name.strip():
+ raise CollectorError("E_CONFIG", "creator.name must be a non-empty string.")
+ timezone_name = value.get("timezone", "Asia/Shanghai")
+ if not isinstance(timezone_name, str):
+ raise CollectorError("E_CONFIG", "timezone must be a zoneinfo name.")
+ try:
+ ZoneInfo(timezone_name)
+ except ZoneInfoNotFoundError as exc:
+ raise CollectorError("E_CONFIG", f"Unknown timezone: {timezone_name}") from exc
+ allowed_hosts_raw = value.get(
+ "allowed_source_hosts",
+ ["space.bilibili.com", "www.bilibili.com", "t.bilibili.com", "b23.tv"],
+ )
+ if not isinstance(allowed_hosts_raw, list) or not allowed_hosts_raw:
+ raise CollectorError("E_CONFIG", "allowed_source_hosts must be a non-empty list.")
+ allowed_hosts = frozenset(str(item).strip().lower() for item in allowed_hosts_raw)
+ if any(not host or "/" in host or ":" in host for host in allowed_hosts):
+ raise CollectorError("E_CONFIG", "allowed_source_hosts contains an invalid hostname.")
+ dynamic_url = validate_url(creator.get("dynamic_url"), "creator.dynamic_url", allowed_hosts)
+ window_hours = value.get("window_hours", 72)
+ minimum_age = value.get("minimum_complete_age_seconds", 30)
+ title_max = value.get("title_max_length", 48)
+ if not isinstance(window_hours, int) or not 1 <= window_hours <= 24 * 31:
+ raise CollectorError("E_CONFIG", "window_hours must be an integer from 1 to 744.")
+ if not isinstance(minimum_age, int) or not 0 <= minimum_age <= 3600:
+ raise CollectorError("E_CONFIG", "minimum_complete_age_seconds must be 0..3600.")
+ if not isinstance(title_max, int) or not 8 <= title_max <= 96:
+ raise CollectorError("E_CONFIG", "title_max_length must be 8..96.")
+ extensions_raw = value.get("allowed_video_extensions", sorted(DEFAULT_VIDEO_EXTENSIONS))
+ if not isinstance(extensions_raw, list) or not extensions_raw:
+ raise CollectorError("E_CONFIG", "allowed_video_extensions must be a non-empty list.")
+ extensions = frozenset(str(item).lower() for item in extensions_raw)
+ if any(not re.fullmatch(r"\.[a-z0-9]{1,8}", item) for item in extensions):
+ raise CollectorError("E_CONFIG", "allowed_video_extensions contains an invalid suffix.")
+ base = path.parent
+ return CollectorConfig(
+ creator_name=creator_name.strip(),
+ creator_dynamic_url=dynamic_url,
+ timezone_name=timezone_name,
+ window_hours=window_hours,
+ state_dir=config_path(base, paths.get("state_dir"), "paths.state_dir"),
+ download_dir=config_path(base, paths.get("download_dir"), "paths.download_dir"),
+ video_dir=config_path(base, paths.get("video_dir"), "paths.video_dir"),
+ minimum_complete_age_seconds=minimum_age,
+ title_max_length=title_max,
+ allowed_source_hosts=allowed_hosts,
+ allowed_video_extensions=extensions,
+ native_handoff=NativeHandoffRoute(**route_values),
+ )
+
+
+def clean_identifier(value: Any, field: str) -> str | None:
+ if value is None or value == "":
+ return None
+ if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value.strip()):
+ raise CollectorError("E_INPUT_SCHEMA", f"{field} has an invalid identifier.")
+ return value.strip()
+
+
+def normalize_item(raw: Any, config: CollectorConfig, index: int) -> dict[str, Any]:
+ if not isinstance(raw, Mapping):
+ raise CollectorError("E_INPUT_SCHEMA", f"items[{index}] must be an object.")
+ dynamic_id = clean_identifier(raw.get("dynamic_id"), f"items[{index}].dynamic_id")
+ opus_id = clean_identifier(raw.get("opus_id"), f"items[{index}].opus_id")
+ bvid = clean_identifier(raw.get("bvid"), f"items[{index}].bvid")
+ if bvid is not None and not re.fullmatch(r"BV[0-9A-Za-z]{10}", bvid, flags=re.IGNORECASE):
+ raise CollectorError("E_INPUT_SCHEMA", f"items[{index}].bvid is not a valid BV identifier.")
+ if not any((dynamic_id, opus_id, bvid)):
+ raise CollectorError(
+ "E_INPUT_SCHEMA",
+ f"items[{index}] needs at least one of dynamic_id, opus_id, or bvid.",
+ )
+ content_type = raw.get("content_type")
+ if content_type not in CONTENT_TYPE_LABELS:
+ raise CollectorError(
+ "E_INPUT_SCHEMA",
+ f"items[{index}].content_type must be one of {sorted(CONTENT_TYPE_LABELS)}.",
+ )
+ title = raw.get("title")
+ if not isinstance(title, str) or not title.strip():
+ raise CollectorError("E_INPUT_SCHEMA", f"items[{index}].title must be non-empty.")
+ published_at = parse_datetime(raw.get("published_at"), f"items[{index}].published_at")
+ source_url = validate_url(raw.get("source_url"), f"items[{index}].source_url", config.allowed_source_hosts)
+ if bvid is not None:
+ bvid = "BV" + bvid[2:]
+ item = {
+ "dynamic_id": dynamic_id,
+ "opus_id": opus_id,
+ "bvid": bvid,
+ "content_type": content_type,
+ "published_at": canonical_datetime(published_at),
+ "title": " ".join(title.split()),
+ "source_url": source_url,
+ }
+ item["dedupe_keys"] = dedupe_keys(item)
+ return item
+
+
+def dedupe_keys(item: Mapping[str, Any]) -> list[str]:
+ keys: list[str] = []
+ if item.get("dynamic_id"):
+ keys.append(f"dynamic:{str(item['dynamic_id']).lower()}")
+ if item.get("opus_id"):
+ keys.append(f"opus:{str(item['opus_id']).lower()}")
+ if item.get("bvid"):
+ keys.append(f"bvid:{str(item['bvid']).lower()}")
+ if item.get("source_url"):
+ keys.append(f"url:{canonical_url(str(item['source_url']))}")
+ return sorted(set(keys))
+
+
+def sanitize_windows_component(value: str, max_length: int) -> str:
+ cleaned = WINDOWS_INVALID_CHARS.sub("_", value)
+ cleaned = re.sub(r"\s+", " ", cleaned).strip(" .")
+ if not cleaned:
+ cleaned = "untitled"
+ if cleaned.upper().split(".", 1)[0] in WINDOWS_RESERVED_NAMES:
+ cleaned = "_" + cleaned
+ if len(cleaned) > max_length:
+ cleaned = cleaned[:max_length].rstrip(" .")
+ return cleaned or "untitled"
+
+
+def suggested_base(item: Mapping[str, Any], config: CollectorConfig) -> str:
+ published = parse_datetime(item["published_at"], "published_at").astimezone(config.tz)
+ title = sanitize_windows_component(str(item["title"]), config.title_max_length)
+ return f"{published:%Y%m%d-%H%M%S}_{CONTENT_TYPE_LABELS[str(item['content_type'])]}_{title}"
+
+
+def entity_id_for_keys(keys: Sequence[str]) -> str:
+ return hashlib.sha256("\n".join(sorted(keys)).encode("utf-8")).hexdigest()[:24]
+
+
+def canonical_json_bytes(value: Any, *, newline: bool = True) -> bytes:
+ payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ return (payload + ("\n" if newline else "")).encode("utf-8")
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(block)
+ return digest.hexdigest()
+
+
+def atomic_replace_bytes(path: Path, payload: bytes) -> None:
+ ensure_directory(path.parent, create=True)
+ temp = path.parent / f".{path.name}.{uuid.uuid4().hex}.tmp"
+ try:
+ with temp.open("xb") as handle:
+ handle.write(payload)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temp, path)
+ finally:
+ try:
+ temp.unlink()
+ except FileNotFoundError:
+ pass
+
+
+def atomic_write_new_or_reuse(path: Path, payload: bytes) -> str:
+ ensure_directory(path.parent, create=True)
+ if path.exists():
+ lexical_lstat_chain(path, allow_missing_leaf=False)
+ if path.is_file() and path.read_bytes() == payload:
+ return "REUSED"
+ raise CollectorError("E_OUTPUT_EXISTS", f"Output already exists and differs: {path}", safety=True)
+ temp = path.parent / f".{path.name}.{uuid.uuid4().hex}.tmp"
+ try:
+ with temp.open("xb") as handle:
+ handle.write(payload)
+ handle.flush()
+ os.fsync(handle.fileno())
+ try:
+ os.link(temp, path)
+ except FileExistsError as exc:
+ raise CollectorError("E_OUTPUT_EXISTS", f"Output appeared during commit: {path}", safety=True) from exc
+ except OSError as exc:
+ raise CollectorError(
+ "E_ATOMIC_CREATE",
+ f"Filesystem cannot perform safe no-overwrite commit for {path}: {exc}",
+ safety=True,
+ ) from exc
+ return "GENERATED"
+ finally:
+ try:
+ temp.unlink()
+ except FileNotFoundError:
+ pass
+
+
+def manifest_text(
+ event: Mapping[str, Any],
+ field: str,
+ path: str,
+ *,
+ allow_empty: bool = False,
+) -> str:
+ value = event.get(field)
+ if not isinstance(value, str) or (not allow_empty and not value.strip()):
+ raise CollectorError(
+ "E_MANIFEST",
+ f"Manifest field {field} is missing or invalid.",
+ details={"field": f"{path}.{field}"},
+ safety=True,
+ )
+ if CONTROL_CHARACTER_PATTERN.search(value):
+ raise CollectorError(
+ "E_MANIFEST",
+ f"Manifest field {field} contains a control character.",
+ details={"field": f"{path}.{field}"},
+ safety=True,
+ )
+ return value
+
+
+def manifest_sha256(event: Mapping[str, Any], path: str) -> str:
+ value = manifest_text(event, "sha256", path)
+ if LOWER_SHA256_PATTERN.fullmatch(value) is None:
+ raise CollectorError(
+ "E_MANIFEST",
+ "Manifest field sha256 must be 64 lowercase hexadecimal characters.",
+ details={"field": f"{path}.sha256"},
+ safety=True,
+ )
+ return value
+
+
+def validate_terminal_manifest_evidence(event: Mapping[str, Any], path: str) -> None:
+ if event.get("content_type") != "video":
+ return
+ status = event.get("status")
+ if status not in {"VIDEO_MOVED", "VIDEO_MOVED_SOURCE_RETAINED", "COMPLETE"}:
+ return
+ manifest_text(event, "local_file", path)
+ manifest_sha256(event, path)
+ if status == "VIDEO_MOVED_SOURCE_RETAINED":
+ manifest_text(event, "failure_reason", path)
+
+
+def validate_handoff_manifest_evidence(event: Mapping[str, Any], path: str) -> None:
+ entity_id = manifest_text(event, "entity_id", path)
+ if INTERNAL_ENTITY_ID_PATTERN.fullmatch(entity_id) is None:
+ raise CollectorError(
+ "E_MANIFEST",
+ "Manifest field entity_id has an invalid internal format.",
+ details={"field": f"{path}.entity_id"},
+ safety=True,
+ )
+ bvid = event.get("bvid")
+ if bvid not in (None, ""):
+ if not isinstance(bvid, str) or CONTROL_CHARACTER_PATTERN.search(bvid):
+ raise CollectorError(
+ "E_MANIFEST",
+ "Manifest field bvid has an invalid format.",
+ details={"field": f"{path}.bvid"},
+ safety=True,
+ )
+ if re.fullmatch(r"BV[0-9A-Za-z]{10}", bvid, flags=re.IGNORECASE) is None:
+ raise CollectorError(
+ "E_MANIFEST",
+ "Manifest field bvid has an invalid format.",
+ details={"field": f"{path}.bvid"},
+ safety=True,
+ )
+ published_at = manifest_text(event, "published_at", path)
+ try:
+ published = parse_datetime(published_at, f"{path}.published_at")
+ except CollectorError as exc:
+ raise CollectorError(
+ "E_MANIFEST",
+ "Manifest field published_at is not canonical offset-aware time.",
+ details={"field": f"{path}.published_at"},
+ safety=True,
+ ) from exc
+ if published_at != canonical_datetime(published):
+ raise CollectorError(
+ "E_MANIFEST",
+ "Manifest field published_at is not canonical UTC time.",
+ details={"field": f"{path}.published_at"},
+ safety=True,
+ )
+ manifest_sha256(event, path)
+ for field in ("title", "source_url", "local_file"):
+ manifest_text(event, field, path)
+
+
+def load_manifest(path: Path) -> list[dict[str, Any]]:
+ if not path.exists():
+ return []
+ lexical_lstat_chain(path, allow_missing_leaf=False)
+ if not path.is_file():
+ raise CollectorError("E_MANIFEST", f"Manifest is not a regular file: {path}", safety=True)
+ events: list[dict[str, Any]] = []
+ try:
+ lines = path.read_text(encoding="utf-8").splitlines()
+ except UnicodeDecodeError as exc:
+ raise CollectorError("E_MANIFEST", "Manifest is not strict UTF-8.", safety=True) from exc
+ for line_number, line in enumerate(lines, start=1):
+ if not line.strip():
+ raise CollectorError("E_MANIFEST", f"Blank manifest line at {line_number}.", safety=True)
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError as exc:
+ raise CollectorError("E_MANIFEST", f"Invalid JSON at manifest line {line_number}.", safety=True) from exc
+ reject_secret_keys(event, f"$manifest[{line_number}]")
+ if not isinstance(event, dict) or event.get("manifest_schema_version") != MANIFEST_SCHEMA_VERSION:
+ raise CollectorError("E_MANIFEST", f"Invalid schema at manifest line {line_number}.", safety=True)
+ validate_terminal_manifest_evidence(event, f"$manifest[{line_number}]")
+ events.append(event)
+ return events
+
+
+def append_manifest(path: Path, events: Sequence[Mapping[str, Any]]) -> None:
+ if not events:
+ return
+ previous = path.read_bytes() if path.exists() else b""
+ if previous and not previous.endswith(b"\n"):
+ raise CollectorError("E_MANIFEST", "Manifest does not end with a newline.", safety=True)
+ addition = b"".join(canonical_json_bytes(event) for event in events)
+ atomic_replace_bytes(path, previous + addition)
+
+
+def latest_entities(events: Sequence[Mapping[str, Any]]) -> tuple[dict[str, dict[str, Any]], dict[str, set[str]]]:
+ latest: dict[str, dict[str, Any]] = {}
+ token_map: dict[str, set[str]] = {}
+ for event in events:
+ entity_id = event.get("entity_id")
+ if not isinstance(entity_id, str):
+ raise CollectorError("E_MANIFEST", "Manifest event lacks entity_id.", safety=True)
+ latest[entity_id] = dict(event)
+ keys = event.get("dedupe_keys")
+ if not isinstance(keys, list) or not all(isinstance(key, str) for key in keys):
+ raise CollectorError("E_MANIFEST", "Manifest event has invalid dedupe_keys.", safety=True)
+ for key in keys:
+ token_map.setdefault(key, set()).add(entity_id)
+ return latest, token_map
+
+
+def resolve_entity(keys: Sequence[str], token_map: Mapping[str, set[str]]) -> str | None:
+ candidates: set[str] = set()
+ for key in keys:
+ candidates.update(token_map.get(key, set()))
+ if len(candidates) > 1:
+ raise CollectorError(
+ "E_IDENTITY_CONFLICT",
+ "Stable identifiers map to multiple manifest entities.",
+ details={"dedupe_keys": list(keys), "entity_ids": sorted(candidates)},
+ safety=True,
+ )
+ return next(iter(candidates), None)
+
+
+def manifest_event(
+ config: CollectorConfig,
+ item: Mapping[str, Any],
+ *,
+ entity_id: str,
+ status: str,
+ collected_at: datetime,
+ suggested_stem: str,
+ local_file: str | None = None,
+ sha256: str | None = None,
+ failure_reason: str | None = None,
+ video_processing_status: str | None = None,
+ text_path: str | None = None,
+) -> dict[str, Any]:
+ if video_processing_status is None:
+ video_processing_status = "PENDING_DOWNLOAD" if item["content_type"] == "video" else "NOT_APPLICABLE"
+ return {
+ "manifest_schema_version": MANIFEST_SCHEMA_VERSION,
+ "event_id": uuid.uuid4().hex,
+ "entity_id": entity_id,
+ "creator": config.creator_name,
+ "dynamic_id": item.get("dynamic_id"),
+ "opus_id": item.get("opus_id"),
+ "bvid": item.get("bvid"),
+ "content_type": item["content_type"],
+ "published_at": item["published_at"],
+ "title": item["title"],
+ "source_url": item["source_url"],
+ "dedupe_keys": sorted(set(item["dedupe_keys"])),
+ "suggested_stem": suggested_stem,
+ "local_file": local_file,
+ "sha256": sha256,
+ "collected_at": canonical_datetime(collected_at),
+ "status": status,
+ "failure_reason": failure_reason,
+ "video_processing_status": video_processing_status,
+ "text_path": text_path,
+ }
+
+
+def allocate_stem(base: str, entity_id: str, latest: Mapping[str, Mapping[str, Any]]) -> str:
+ for existing_id, event in latest.items():
+ if existing_id == entity_id and isinstance(event.get("suggested_stem"), str):
+ return str(event["suggested_stem"])
+ occupied = {
+ str(event["suggested_stem"]).casefold()
+ for existing_id, event in latest.items()
+ if existing_id != entity_id and isinstance(event.get("suggested_stem"), str)
+ }
+ candidate = base
+ index = 1
+ while candidate.casefold() in occupied:
+ candidate = f"{base}_{index:02d}"
+ index += 1
+ return candidate
+
+
+def check_items(config: CollectorConfig, input_path: Path, now: datetime) -> dict[str, Any]:
+ value = load_json(input_path, "dynamic export")
+ if not isinstance(value, Mapping) or value.get("schema_version") != SCHEMA_VERSION:
+ raise CollectorError("E_INPUT_SCHEMA", f"dynamic export schema_version must equal {SCHEMA_VERSION}.")
+ if value.get("creator") not in (None, config.creator_name):
+ raise CollectorError("E_INPUT_CREATOR", "Export creator does not match config creator.", safety=True)
+ raw_items = value.get("items")
+ if not isinstance(raw_items, list):
+ raise CollectorError("E_INPUT_SCHEMA", "dynamic export items must be a list.")
+ normalized = [normalize_item(item, config, index) for index, item in enumerate(raw_items)]
+ cutoff = now - timedelta(hours=config.window_hours)
+ events = load_manifest(config.manifest_path)
+ latest, token_map = latest_entities(events)
+ new_items: list[dict[str, Any]] = []
+ skipped_duplicate = 0
+ skipped_old = 0
+ skipped_future = 0
+ seen_input_tokens: set[str] = set()
+ for item in sorted(normalized, key=lambda row: (row["published_at"], row["source_url"])):
+ published = parse_datetime(item["published_at"], "published_at").astimezone(timezone.utc)
+ if published > now:
+ skipped_future += 1
+ continue
+ if published < cutoff:
+ skipped_old += 1
+ continue
+ keys = item["dedupe_keys"]
+ if seen_input_tokens.intersection(keys):
+ skipped_duplicate += 1
+ continue
+ seen_input_tokens.update(keys)
+ entity_id = resolve_entity(keys, token_map) or entity_id_for_keys(keys)
+ previous = latest.get(entity_id)
+ if previous and previous.get("status") in ACTIVE_OR_SUCCESS_STATUSES:
+ skipped_duplicate += 1
+ continue
+ if previous and previous.get("status") not in RETRYABLE_STATUSES:
+ raise CollectorError(
+ "E_STATUS",
+ f"Unsupported latest manifest status: {previous.get('status')}",
+ details={"entity_id": entity_id},
+ safety=True,
+ )
+ stem = allocate_stem(suggested_base(item, config), entity_id, latest)
+ event = manifest_event(
+ config,
+ item,
+ entity_id=entity_id,
+ status="TODO_QUEUED",
+ collected_at=now,
+ suggested_stem=stem,
+ )
+ todo = {
+ "entity_id": entity_id,
+ "dynamic_id": item["dynamic_id"],
+ "opus_id": item["opus_id"],
+ "bvid": item["bvid"],
+ "content_type": item["content_type"],
+ "published_at": item["published_at"],
+ "title": item["title"],
+ "source_url": item["source_url"],
+ "action": TODO_ACTIONS[item["content_type"]],
+ "suggested_stem": stem,
+ "image_name_pattern": f"{stem}_{{sequence:02d}}" if item["content_type"] == "image" else None,
+ }
+ new_items.append({"event": event, "todo": todo})
+ latest[entity_id] = event
+ for key in keys:
+ token_map.setdefault(key, set()).add(entity_id)
+ queue_path: Path | None = None
+ queue_state: str | None = None
+ if new_items:
+ queue = {
+ "schema_version": SCHEMA_VERSION,
+ "creator": config.creator_name,
+ "creator_dynamic_url": config.creator_dynamic_url,
+ "generated_at": canonical_datetime(now),
+ "window_hours": config.window_hours,
+ "window_start": canonical_datetime(cutoff),
+ "window_end": canonical_datetime(now),
+ "items": [entry["todo"] for entry in new_items],
+ "browser_interaction_required": True,
+ "authentication_data_allowed": False,
+ }
+ payload = canonical_json_bytes(queue)
+ digest = hashlib.sha256(payload).hexdigest()[:12]
+ local_time = now.astimezone(config.tz)
+ queue_path = config.queues_dir / f"{local_time:%Y%m%d-%H%M%S}_todo_{digest}.json"
+ queue_state = atomic_write_new_or_reuse(queue_path, payload)
+ try:
+ append_manifest(config.manifest_path, [entry["event"] for entry in new_items])
+ except BaseException:
+ if queue_state == "GENERATED":
+ try:
+ queue_path.unlink()
+ except FileNotFoundError:
+ pass
+ raise
+ return {
+ "status": "TODO_GENERATED" if new_items else "NO_NEW_ITEMS",
+ "creator": config.creator_name,
+ "input_items": len(normalized),
+ "new_items": len(new_items),
+ "skipped_duplicate": skipped_duplicate,
+ "skipped_old": skipped_old,
+ "skipped_future": skipped_future,
+ "queue_path": str(queue_path) if queue_path else None,
+ "queue_write": queue_state,
+ "manifest_path": str(config.manifest_path),
+ "window_start": canonical_datetime(cutoff),
+ "window_end": canonical_datetime(now),
+ }
+
+
+def load_mapping(path: Path, config: CollectorConfig) -> list[dict[str, Any]]:
+ value = load_json(path, "completed download mapping")
+ if not isinstance(value, Mapping) or value.get("schema_version") != SCHEMA_VERSION:
+ raise CollectorError("E_INPUT_SCHEMA", f"mapping schema_version must equal {SCHEMA_VERSION}.")
+ raw_items = value.get("items")
+ if not isinstance(raw_items, list) or not raw_items:
+ raise CollectorError("E_INPUT_SCHEMA", "mapping.items must be a non-empty list.")
+ normalized: list[dict[str, Any]] = []
+ for index, raw in enumerate(raw_items):
+ if not isinstance(raw, Mapping):
+ raise CollectorError("E_INPUT_SCHEMA", f"mapping.items[{index}] must be an object.")
+ source_file = raw.get("source_file")
+ if not isinstance(source_file, str) or not source_file.strip():
+ raise CollectorError("E_INPUT_SCHEMA", f"mapping.items[{index}].source_file is required.")
+ selectors = {
+ name: clean_identifier(raw.get(name), f"mapping.items[{index}].{name}")
+ for name in ("dynamic_id", "opus_id", "bvid")
+ }
+ source_url = raw.get("source_url")
+ if source_url is not None:
+ source_url = validate_url(
+ source_url,
+ f"mapping.items[{index}].source_url",
+ config.allowed_source_hosts,
+ )
+ if not any(selectors.values()) and not source_url:
+ raise CollectorError("E_INPUT_SCHEMA", f"mapping.items[{index}] needs one stable selector.")
+ normalized.append({"source_file": source_file.strip(), **selectors, "source_url": source_url})
+ return normalized
+
+
+def mapping_keys(mapping: Mapping[str, Any]) -> list[str]:
+ item = {
+ "dynamic_id": mapping.get("dynamic_id"),
+ "opus_id": mapping.get("opus_id"),
+ "bvid": mapping.get("bvid"),
+ "source_url": canonical_url(mapping["source_url"]) if mapping.get("source_url") else None,
+ }
+ return dedupe_keys(item)
+
+
+def resolve_mapping_entity(mapping: Mapping[str, Any], token_map: Mapping[str, set[str]]) -> str:
+ resolved: list[str] = []
+ for key in mapping_keys(mapping):
+ candidates = token_map.get(key, set())
+ if not candidates:
+ raise CollectorError(
+ "E_MAPPING_SELECTOR_UNKNOWN",
+ "A supplied mapping selector does not identify a manifest entity.",
+ details={"selector": key},
+ safety=True,
+ )
+ if len(candidates) != 1:
+ raise CollectorError(
+ "E_MAPPING_SELECTOR_CONFLICT",
+ "A supplied mapping selector identifies multiple manifest entities.",
+ details={"selector": key, "entity_ids": sorted(candidates)},
+ safety=True,
+ )
+ resolved.append(next(iter(candidates)))
+ if not resolved:
+ raise CollectorError("E_MAPPING_SELECTOR_UNKNOWN", "Mapping has no usable selector.", safety=True)
+ if len(set(resolved)) != 1:
+ raise CollectorError(
+ "E_MAPPING_SELECTOR_CONFLICT",
+ "Supplied mapping selectors identify different manifest entities.",
+ details={"entity_ids": sorted(set(resolved))},
+ safety=True,
+ )
+ return resolved[0]
+
+
+def resolve_download_source(value: str, download_dir: Path) -> Path:
+ source = Path(value)
+ if not source.is_absolute():
+ source = download_dir / source
+ source = absolute_lexical(source)
+ lexical_lstat_chain(source, allow_missing_leaf=False)
+ download_resolved = download_dir.resolve(strict=True)
+ source_resolved = source.resolve(strict=True)
+ if not path_within(source_resolved, download_resolved):
+ raise CollectorError(
+ "E_SOURCE_OUTSIDE_DOWNLOAD_DIR",
+ f"Download source is outside configured download_dir: {source}",
+ safety=True,
+ )
+ if not source.is_file():
+ raise CollectorError("E_SOURCE_TYPE", f"Download source is not a regular file: {source}", safety=True)
+ if source.suffix.lower() in TEMP_DOWNLOAD_SUFFIXES:
+ raise CollectorError("E_DOWNLOAD_INCOMPLETE", f"Temporary download suffix is not complete: {source}", safety=True)
+ return source
+
+
+def resolve_move_mapping(
+ mapping: Mapping[str, Any],
+ latest: Mapping[str, Mapping[str, Any]],
+ token_map: Mapping[str, set[str]],
+) -> dict[str, Any]:
+ entity_id = resolve_mapping_entity(mapping, token_map)
+ if entity_id not in latest:
+ raise CollectorError("E_MAPPING_NOT_FOUND", "Mapping does not identify a queued manifest item.", safety=True)
+ event = dict(latest[entity_id])
+ if event.get("content_type") != "video":
+ raise CollectorError("E_MAPPING_TYPE", "Completed download mapping must identify a video.", safety=True)
+ return {"entity_id": entity_id, "event": event, "mapping": mapping}
+
+
+def classify_move_mapping(resolved: Mapping[str, Any]) -> dict[str, Any]:
+ entity_id = str(resolved["entity_id"])
+ event = dict(resolved["event"])
+ mapping = resolved["mapping"]
+ status = event.get("status")
+ if status in {"VIDEO_MOVED", "COMPLETE"}:
+ validate_terminal_manifest_evidence(event, "$latest")
+ return {"entity_id": entity_id, "status": "ALREADY_MOVED", "event": event, "mapping": mapping}
+ if status == "VIDEO_MOVED_SOURCE_RETAINED":
+ validate_terminal_manifest_evidence(event, "$latest")
+ return {
+ "entity_id": entity_id,
+ "status": "VIDEO_MOVED_SOURCE_RETAINED",
+ "event": event,
+ "mapping": mapping,
+ }
+ if status not in {"TODO_QUEUED", "MOVE_FAILED"}:
+ raise CollectorError("E_STATUS", f"Video cannot move from status {status!r}.", safety=True)
+ return {"entity_id": entity_id, "status": "ACTIVE_MOVE", "event": event, "mapping": mapping}
+
+
+def preflight_active_move(
+ config: CollectorConfig,
+ classified: Mapping[str, Any],
+ now: datetime,
+) -> dict[str, Any]:
+ mapping = classified["mapping"]
+ entity_id = str(classified["entity_id"])
+ event = dict(classified["event"])
+ source = resolve_download_source(str(mapping["source_file"]), config.download_dir)
+ suffix = source.suffix.lower()
+ if suffix not in config.allowed_video_extensions:
+ raise CollectorError(
+ "E_VIDEO_EXTENSION",
+ f"Video extension is not allowed: {suffix}",
+ details={"allowed": sorted(config.allowed_video_extensions)},
+ safety=True,
+ )
+ stat = source.stat()
+ age = now.timestamp() - stat.st_mtime
+ if age < config.minimum_complete_age_seconds:
+ raise CollectorError(
+ "E_DOWNLOAD_TOO_NEW",
+ "Download file is too new to be considered complete.",
+ details={"age_seconds": max(age, 0), "required_seconds": config.minimum_complete_age_seconds},
+ safety=True,
+ )
+ target = config.video_dir / f"{event['suggested_stem']}{suffix}"
+ lexical_lstat_chain(target, allow_missing_leaf=True)
+ if target.exists():
+ raise CollectorError("E_TARGET_EXISTS", f"Target exists; refusing to overwrite: {target}", safety=True)
+ source_identity: tuple[Any, ...]
+ if stat.st_ino:
+ source_identity = ("inode", stat.st_dev, stat.st_ino)
+ else:
+ source_identity = ("path", os.path.normcase(str(source.resolve(strict=True))))
+ return {
+ "entity_id": entity_id,
+ "status": "READY",
+ "event": event,
+ "source": source,
+ "target": target,
+ "source_stat": (stat.st_size, stat.st_mtime_ns),
+ "source_identity": source_identity,
+ }
+
+
+def copy_commit_no_overwrite(source: Path, target: Path, expected_stat: tuple[int, int]) -> tuple[str, int]:
+ ensure_directory(target.parent, create=False)
+ temp = target.parent / f".{target.name}.{uuid.uuid4().hex}.partial"
+ digest = hashlib.sha256()
+ total = 0
+ try:
+ with source.open("rb") as source_handle, temp.open("xb") as target_handle:
+ for block in iter(lambda: source_handle.read(1024 * 1024), b""):
+ target_handle.write(block)
+ digest.update(block)
+ total += len(block)
+ target_handle.flush()
+ os.fsync(target_handle.fileno())
+ after = source.stat()
+ if (after.st_size, after.st_mtime_ns) != expected_stat or total != after.st_size:
+ raise CollectorError("E_SOURCE_CHANGED", f"Source changed during copy: {source}", safety=True)
+ if sha256_file(temp) != digest.hexdigest():
+ raise CollectorError("E_COPY_HASH", "Copied video failed SHA-256 verification.", safety=True)
+ try:
+ os.link(temp, target)
+ except FileExistsError as exc:
+ raise CollectorError("E_TARGET_EXISTS", f"Target appeared during commit: {target}", safety=True) from exc
+ except OSError as exc:
+ raise CollectorError(
+ "E_ATOMIC_CREATE",
+ f"Filesystem cannot perform safe no-overwrite target commit: {exc}",
+ safety=True,
+ ) from exc
+ return digest.hexdigest(), total
+ finally:
+ try:
+ temp.unlink()
+ except FileNotFoundError:
+ pass
+
+
+def append_move_failure(
+ config: CollectorConfig,
+ event: Mapping[str, Any],
+ entity_id: str,
+ now: datetime,
+ error: BaseException,
+) -> None:
+ if event.get("status") in {"VIDEO_MOVED", "VIDEO_MOVED_SOURCE_RETAINED", "COMPLETE"}:
+ return
+ failure = manifest_event(
+ config,
+ event,
+ entity_id=entity_id,
+ status="MOVE_FAILED",
+ collected_at=now,
+ suggested_stem=str(event["suggested_stem"]),
+ failure_reason=f"{type(error).__name__}: {error}",
+ video_processing_status="PENDING_DOWNLOAD",
+ )
+ append_manifest(config.manifest_path, [failure])
+
+
+def move_completed(config: CollectorConfig, mapping_path: Path, now: datetime) -> dict[str, Any]:
+ ensure_directory(config.download_dir, create=False)
+ ensure_directory(config.video_dir, create=False)
+ mappings = load_mapping(mapping_path, config)
+ events = load_manifest(config.manifest_path)
+ latest, token_map = latest_entities(events)
+ resolved = [resolve_move_mapping(mapping, latest, token_map) for mapping in mappings]
+ entity_ids = [str(item["entity_id"]) for item in resolved]
+ if len(entity_ids) != len(set(entity_ids)):
+ raise CollectorError("E_ENTITY_DUPLICATE", "A manifest entity appears more than once in the batch.", safety=True)
+ classified = [classify_move_mapping(item) for item in resolved]
+
+ prepared: list[dict[str, Any]] = []
+ for item in classified:
+ if item["status"] == "ACTIVE_MOVE":
+ prepared.append(preflight_active_move(config, item, now))
+ else:
+ prepared.append(item)
+ source_identities = [item["source_identity"] for item in prepared if item["status"] == "READY"]
+ if len(source_identities) != len(set(source_identities)):
+ raise CollectorError("E_SOURCE_DUPLICATE", "A physical source file appears more than once in the batch.", safety=True)
+ targets = [str(item["target"]).casefold() for item in prepared if item["status"] == "READY"]
+ if len(targets) != len(set(targets)):
+ raise CollectorError("E_TARGET_CONFLICT", "Multiple mappings resolve to the same target.", safety=True)
+ results: list[dict[str, Any]] = []
+ for item in prepared:
+ if item["status"] == "ALREADY_MOVED":
+ event = item["event"]
+ results.append(
+ {
+ "entity_id": item["entity_id"],
+ "status": "ALREADY_MOVED",
+ "target": event.get("local_file"),
+ "sha256": event.get("sha256"),
+ }
+ )
+ continue
+ if item["status"] == "VIDEO_MOVED_SOURCE_RETAINED":
+ event = item["event"]
+ results.append(
+ {
+ "entity_id": item["entity_id"],
+ "status": "VIDEO_MOVED_SOURCE_RETAINED",
+ "target": event.get("local_file"),
+ "sha256": event.get("sha256"),
+ "source_delete_error": event.get("failure_reason"),
+ }
+ )
+ continue
+ source = item["source"]
+ target = item["target"]
+ event = item["event"]
+ digest: str | None = None
+ committed = False
+ try:
+ digest, size = copy_commit_no_overwrite(source, target, item["source_stat"])
+ committed = True
+ moved_event = manifest_event(
+ config,
+ event,
+ entity_id=item["entity_id"],
+ status="VIDEO_MOVED",
+ collected_at=now,
+ suggested_stem=event["suggested_stem"],
+ local_file=str(target),
+ sha256=digest,
+ video_processing_status="READY_FOR_HANDOFF",
+ )
+ append_manifest(config.manifest_path, [moved_event])
+ except BaseException as exc:
+ if committed:
+ try:
+ if target.is_file() and digest and sha256_file(target) == digest:
+ target.unlink()
+ except BaseException as rollback_error:
+ if hasattr(exc, "add_note"):
+ exc.add_note(f"Could not remove unrecorded target: {rollback_error}")
+ try:
+ append_move_failure(config, event, item["entity_id"], now, exc)
+ except BaseException as evidence_error:
+ if hasattr(exc, "add_note"):
+ exc.add_note(f"Could not append MOVE_FAILED evidence: {evidence_error}")
+ raise
+ source_delete_failed: str | None = None
+ try:
+ current = source.stat()
+ if (current.st_size, current.st_mtime_ns) != item["source_stat"]:
+ raise CollectorError("E_SOURCE_CHANGED", "Source changed before deletion; retained source.", safety=True)
+ source.unlink()
+ except BaseException as exc:
+ source_delete_failed = f"{type(exc).__name__}: {exc}"
+ retained_event = manifest_event(
+ config,
+ event,
+ entity_id=item["entity_id"],
+ status="VIDEO_MOVED_SOURCE_RETAINED",
+ collected_at=now,
+ suggested_stem=event["suggested_stem"],
+ local_file=str(target),
+ sha256=digest,
+ failure_reason=source_delete_failed,
+ video_processing_status="READY_FOR_HANDOFF",
+ )
+ append_manifest(config.manifest_path, [retained_event])
+ results.append(
+ {
+ "entity_id": item["entity_id"],
+ "status": "VIDEO_MOVED_SOURCE_RETAINED" if source_delete_failed else "VIDEO_MOVED",
+ "source": str(source),
+ "target": str(target),
+ "bytes": size,
+ "sha256": digest,
+ "source_delete_error": source_delete_failed,
+ }
+ )
+ return {
+ "status": "COMPLETE_WITH_RETAINED_SOURCE"
+ if any(row["status"] == "VIDEO_MOVED_SOURCE_RETAINED" for row in results)
+ else "COMPLETE",
+ "manifest_path": str(config.manifest_path),
+ "items": results,
+ }
+
+
+def handoff_markdown(config: CollectorConfig, ready: Sequence[Mapping[str, Any]], _generated_at: datetime) -> str:
+ route = config.native_handoff.as_dict()
+ evidence = [
+ {
+ "entity_id": str(event["entity_id"]),
+ "bvid": str(event.get("bvid") or ""),
+ "published_at": str(event["published_at"]),
+ "title": str(event["title"]),
+ "source_url": str(event["source_url"]),
+ "local_file": str(event["local_file"]),
+ "sha256": str(event["sha256"]),
+ }
+ for event in ready
+ ]
+ fingerprint = canonical_json_bytes({"route": route, "videos": evidence}, newline=False)
+ handoff_id = "HANDOFF-BILI-DYNAMIC-VIDEO-PROCESSING-" + hashlib.sha256(fingerprint).hexdigest()[:24].upper()
+ lines = [
+ "<codex_native_handoff>",
+ f"project_id={route['project_id']}",
+ "message_type=video_processing_request",
+ f"handoff_id={handoff_id}",
+ f"source_ai_id={route['source_ai_id']}",
+ f"source_thread_id={route['source_thread_id']}",
+ f"source_role_instance_id={route['source_role_instance_id']}",
+ f"target_ai_id={route['target_ai_id']}",
+ f"target_thread_id={route['target_thread_id']}",
+ f"target_role_instance_id={route['target_role_instance_id']}",
+ f"reply_thread_id={route['reply_thread_id']}",
+ "status=PROCESSING_REQUESTED",
+ "",
+ "scope:",
+ f"- creator_json={json.dumps(config.creator_name, ensure_ascii=False)}",
+ f"- video_count={len(evidence)}",
+ "- Process only the verified local video files listed below; do not collect or download content.",
+ "",
+ "evidence:",
+ ]
+ for item in evidence:
+ lines.extend(
+ [
+ f"- entity_id={item['entity_id']}",
+ f" bvid={item['bvid']}",
+ f" published_at={item['published_at']}",
+ f" title_json={json.dumps(item['title'], ensure_ascii=False)}",
+ f" source_url_json={json.dumps(item['source_url'], ensure_ascii=False)}",
+ f" local_file_json={json.dumps(item['local_file'], ensure_ascii=False)}",
+ f" sha256={item['sha256']}",
+ ]
+ )
+ lines.extend(
+ [
+ "",
+ "expected_action:",
+ "- Verify each local_file and sha256, then process it through the existing local media workflow.",
+ "- Return processing status and evidence to reply_thread_id; this tool does not send the handoff.",
+ "</codex_native_handoff>",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def generate_handoff(
+ config: CollectorConfig,
+ output: Path | None,
+ now: datetime,
+) -> dict[str, Any]:
+ events = load_manifest(config.manifest_path)
+ latest, _ = latest_entities(events)
+ ready = sorted(
+ (
+ event
+ for event in latest.values()
+ if event.get("content_type") == "video"
+ and event.get("status") in {"VIDEO_MOVED", "VIDEO_MOVED_SOURCE_RETAINED"}
+ and event.get("video_processing_status") == "READY_FOR_HANDOFF"
+ and event.get("local_file")
+ and event.get("sha256")
+ ),
+ key=lambda event: (str(event["published_at"]), str(event["entity_id"])),
+ )
+ if not ready:
+ return {"status": "NO_READY_VIDEOS", "video_count": 0, "handoff_path": None}
+ video_root = ensure_directory(config.video_dir, create=False).resolve(strict=True)
+ for index, event in enumerate(ready):
+ validate_handoff_manifest_evidence(event, f"$handoff[{index}]")
+ local_path = absolute_lexical(Path(str(event["local_file"])))
+ lexical_lstat_chain(local_path, allow_missing_leaf=False)
+ local_resolved = local_path.resolve(strict=True)
+ if not path_within(local_resolved, video_root) or not local_path.is_file():
+ raise CollectorError(
+ "E_HANDOFF_FILE",
+ f"Handoff video is missing or outside configured video_dir: {local_path}",
+ safety=True,
+ )
+ if sha256_file(local_path) != event["sha256"]:
+ raise CollectorError(
+ "E_HANDOFF_HASH",
+ f"Handoff video SHA-256 no longer matches manifest: {local_path}",
+ safety=True,
+ )
+ markdown = handoff_markdown(config, ready, now)
+ payload = markdown.encode("utf-8")
+ digest = hashlib.sha256(payload).hexdigest()[:12]
+ target = absolute_lexical(output) if output else config.handoffs_dir / f"video_processor_handoff_{digest}.md"
+ state = atomic_write_new_or_reuse(target, payload)
+ return {
+ "status": "HANDOFF_DRAFT_READY",
+ "video_count": len(ready),
+ "handoff_path": str(target),
+ "sha256": hashlib.sha256(payload).hexdigest(),
+ "write": state,
+ "sent": False,
+ }
+
+
+def parse_now(value: str | None) -> datetime:
+ return parse_datetime(value, "--now").astimezone(timezone.utc) if value else utc_now()
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Coordinate Bilibili dynamic collection from local, credential-free exports.",
+ )
+ parser.add_argument("--config", required=True, type=Path, help="UTF-8 JSON config path")
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ check = subparsers.add_parser("check", help="Filter recent items and generate a de-duplicated todo queue")
+ check.add_argument("--input", required=True, type=Path, help="Credential-free exported item list")
+ check.add_argument("--now", help="Optional offset-aware ISO-8601 clock for deterministic runs")
+ move = subparsers.add_parser("move-completed", help="Safely move mapped, completed video downloads")
+ move.add_argument("--mapping", required=True, type=Path, help="Completed download mapping JSON")
+ move.add_argument("--now", help="Optional offset-aware ISO-8601 clock for deterministic runs")
+ handoff = subparsers.add_parser("handoff", help="Generate an unsent canonical Codex-native video handoff")
+ handoff.add_argument("--output", type=Path, help="Optional no-overwrite Markdown output path")
+ handoff.add_argument("--now", help="Optional offset-aware ISO-8601 clock for deterministic runs")
+ return parser
+
+
+def run(argv: Sequence[str] | None = None) -> tuple[int, dict[str, Any]]:
+ args = build_parser().parse_args(argv)
+ config_path_value = absolute_lexical(args.config)
+ config = load_config(config_path_value)
+ now = parse_now(args.now)
+ ensure_directory(config.state_dir, create=True)
+ with StateLock(config.lock_path):
+ if args.command == "check":
+ result = check_items(config, absolute_lexical(args.input), now)
+ elif args.command == "move-completed":
+ result = move_completed(config, absolute_lexical(args.mapping), now)
+ elif args.command == "handoff":
+ output = None
+ if args.output:
+ output = args.output if args.output.is_absolute() else Path.cwd() / args.output
+ result = generate_handoff(config, output, now)
+ else: # pragma: no cover - argparse owns this contract
+ raise CollectorError("E_COMMAND", f"Unknown command: {args.command}")
+ result = {"schema_version": SCHEMA_VERSION, "ok": True, **result}
+ code = 4 if result.get("status") == "COMPLETE_WITH_RETAINED_SOURCE" else 0
+ return code, result
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ try:
+ code, payload = run(argv)
+ except CollectorError as exc:
+ code = 3 if exc.safety else 2
+ payload = {
+ "schema_version": SCHEMA_VERSION,
+ "ok": False,
+ "status": "SAFETY_STOP" if exc.safety else "INPUT_ERROR",
+ "error_code": exc.code,
+ "error_message": exc.message,
+ "details": exc.details,
+ }
+ except KeyboardInterrupt:
+ payload = {
+ "schema_version": SCHEMA_VERSION,
+ "ok": False,
+ "status": "INTERRUPTED",
+ "error_code": "E_INTERRUPTED",
+ "error_message": "Interrupted by user.",
+ "details": {},
+ }
+ code = 130
+ except Exception as exc: # fail closed without exposing a traceback or secrets
+ payload = {
+ "schema_version": SCHEMA_VERSION,
+ "ok": False,
+ "status": "FAILED",
+ "error_code": "E_INTERNAL",
+ "error_message": f"{type(exc).__name__}: {exc}",
+ "details": {},
+ }
+ code = 1
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
+ return code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/dev/project-dev/bili_video_download_bridge.example.json b/dev/project-dev/bili_video_download_bridge.example.json
new file mode 100644
index 0000000..f645229
--- /dev/null
+++ b/dev/project-dev/bili_video_download_bridge.example.json
@@ -0,0 +1,16 @@
+{
+ "schema_version": "1.0",
+ "batch_id": "qingfengpushangq-20260805",
+ "items": [
+ {
+ "bvid": "BV1DVMX6XEPq",
+ "source_url": "https://www.bilibili.com/video/BV1DVMX6XEPq",
+ "published_at": "2026-08-03T19:39:03+08:00"
+ },
+ {
+ "bvid": "BV1HA3o6oEJJ",
+ "source_url": "https://www.bilibili.com/video/BV1HA3o6oEJJ",
+ "published_at": "2026-08-02T21:11:11+08:00"
+ }
+ ]
+}
diff --git a/dev/project-dev/bili_video_download_bridge.py b/dev/project-dev/bili_video_download_bridge.py
new file mode 100644
index 0000000..24573fb
--- /dev/null
+++ b/dev/project-dev/bili_video_download_bridge.py
@@ -0,0 +1,806 @@
+#!/usr/bin/env python3
+"""Validate and publish complete Bilibili videos without exposing credentials.
+
+The bridge deliberately accepts only canonical Bilibili video URLs. It never
+reads Chrome profiles, cookies, extension tokens, or signed media URLs. A
+download is published only after ffprobe confirms that the file contains both
+video and audio streams. An authorized browser download may be handed to the
+bridge as a completed local file; the bridge never initiates that browser
+download or inspects the authenticated session.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import math
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+import uuid
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Callable, Iterable, Sequence
+from urllib.parse import urlsplit
+
+
+SCHEMA_VERSION = "1.0"
+BVID_RE = re.compile(r"BV[0-9A-Za-z]{10}\Z")
+BATCH_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
+CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
+URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
+MEDIA_SUFFIXES = {".flv", ".m4v", ".mkv", ".mov", ".mp4", ".webm"}
+FORBIDDEN_SUFFIXES = {".part", ".m4s", ".webp"}
+DURATION_ABSOLUTE_TOLERANCE_SECONDS = 3.0
+DURATION_RELATIVE_TOLERANCE = 0.001
+LOCAL_FILE_SETTLE_SECONDS = 1.0
+CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
+BELOW_NORMAL_PRIORITY_CLASS = getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0)
+RunCommand = Callable[..., subprocess.CompletedProcess[str]]
+Sleeper = Callable[[float], None]
+
+
+class BridgeError(Exception):
+ """Base class for safe, user-facing bridge failures."""
+
+
+class InputError(BridgeError):
+ """The batch input or command arguments are invalid."""
+
+
+class AccessError(BridgeError):
+ """The public, credential-free remote operation failed."""
+
+
+class ValidationError(BridgeError):
+ """A downloaded artifact is not a complete video."""
+
+
+class CollisionError(BridgeError):
+ """A formal output already exists and cannot be overwritten."""
+
+
+@dataclass(frozen=True)
+class VideoItem:
+ bvid: str
+ source_url: str
+ published_at: str
+ title: str | None = None
+
+
+@dataclass(frozen=True)
+class BatchSpec:
+ batch_id: str
+ items: tuple[VideoItem, ...]
+
+
+@dataclass(frozen=True)
+class MediaFacts:
+ duration_seconds: float
+ format_name: str
+ video_codec: str
+ audio_codec: str
+
+
+def _creationflags() -> int:
+ return CREATE_NO_WINDOW | BELOW_NORMAL_PRIORITY_CLASS
+
+
+def _require_plain_text(value: Any, field: str, *, maximum: int) -> str:
+ if not isinstance(value, str):
+ raise InputError(f"{field} must be a string")
+ if not value or len(value) > maximum or CONTROL_RE.search(value):
+ raise InputError(f"{field} is empty, too long, or contains control characters")
+ return value
+
+
+def _canonical_source(value: Any, bvid: str, field: str) -> str:
+ source = _require_plain_text(value, field, maximum=200)
+ parsed = urlsplit(source)
+ expected_path = f"/video/{bvid}"
+ if (
+ parsed.scheme != "https"
+ or parsed.hostname != "www.bilibili.com"
+ or parsed.username is not None
+ or parsed.password is not None
+ or parsed.port is not None
+ or parsed.path.rstrip("/") != expected_path
+ or parsed.query
+ or parsed.fragment
+ ):
+ raise InputError(f"{field} must be the canonical credential-free URL for {bvid}")
+ return f"https://www.bilibili.com{expected_path}"
+
+
+def _published_at(value: Any, field: str) -> str:
+ text = _require_plain_text(value, field, maximum=64)
+ try:
+ parsed = datetime.fromisoformat(text)
+ except ValueError as exc:
+ raise InputError(f"{field} must be an ISO-8601 timestamp") from exc
+ if parsed.utcoffset() is None:
+ raise InputError(f"{field} must include a timezone offset")
+ return text
+
+
+def load_batch(path: Path) -> BatchSpec:
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
+ raise InputError(f"cannot read UTF-8 batch JSON: {path}") from exc
+ if not isinstance(raw, dict) or raw.get("schema_version") != SCHEMA_VERSION:
+ raise InputError(f"schema_version must be {SCHEMA_VERSION!r}")
+ batch_id = _require_plain_text(raw.get("batch_id"), "batch_id", maximum=64)
+ if not BATCH_ID_RE.fullmatch(batch_id):
+ raise InputError("batch_id contains unsupported characters")
+ raw_items = raw.get("items")
+ if not isinstance(raw_items, list) or not 1 <= len(raw_items) <= 10:
+ raise InputError("items must contain 1 to 10 entries")
+
+ items: list[VideoItem] = []
+ seen: set[str] = set()
+ for index, raw_item in enumerate(raw_items):
+ prefix = f"items[{index}]"
+ if not isinstance(raw_item, dict):
+ raise InputError(f"{prefix} must be an object")
+ unknown = set(raw_item) - {"bvid", "source_url", "published_at", "title"}
+ if unknown:
+ raise InputError(f"{prefix} contains unsupported fields: {sorted(unknown)}")
+ bvid = _require_plain_text(raw_item.get("bvid"), f"{prefix}.bvid", maximum=12)
+ if not BVID_RE.fullmatch(bvid):
+ raise InputError(f"{prefix}.bvid is invalid")
+ if bvid.casefold() in seen:
+ raise InputError(f"duplicate bvid: {bvid}")
+ seen.add(bvid.casefold())
+ title_value = raw_item.get("title")
+ title = None if title_value is None else _require_plain_text(
+ title_value, f"{prefix}.title", maximum=300
+ )
+ items.append(
+ VideoItem(
+ bvid=bvid,
+ source_url=_canonical_source(
+ raw_item.get("source_url"), bvid, f"{prefix}.source_url"
+ ),
+ published_at=_published_at(raw_item.get("published_at"), f"{prefix}.published_at"),
+ title=title,
+ )
+ )
+ return BatchSpec(batch_id=batch_id, items=tuple(items))
+
+
+def _resolve_executable(value: str, field: str) -> str:
+ candidate = shutil.which(value)
+ if candidate:
+ return candidate
+ path = Path(value).expanduser()
+ if path.is_file():
+ return str(path.resolve())
+ raise InputError(f"{field} executable was not found: {value}")
+
+
+def _privacy_args() -> list[str]:
+ return [
+ "--ignore-config",
+ "--no-cache-dir",
+ "--no-cookies",
+ "--no-cookies-from-browser",
+ "--no-playlist",
+ ]
+
+
+def build_probe_command(yt_dlp: str, item: VideoItem) -> list[str]:
+ return [
+ yt_dlp,
+ *_privacy_args(),
+ "--simulate",
+ "--format",
+ "bv*+ba/b",
+ "--dump-single-json",
+ "--no-warnings",
+ item.source_url,
+ ]
+
+
+def build_download_command(
+ yt_dlp: str,
+ ffmpeg: str,
+ item: VideoItem,
+ stage_dir: Path,
+ result_path_file: Path,
+) -> list[str]:
+ return [
+ yt_dlp,
+ *_privacy_args(),
+ "--ffmpeg-location",
+ ffmpeg,
+ "--format",
+ "bv*+ba/b",
+ "--merge-output-format",
+ "mp4",
+ "--retries",
+ "2",
+ "--fragment-retries",
+ "2",
+ "--file-access-retries",
+ "2",
+ "--no-overwrites",
+ "--paths",
+ str(stage_dir),
+ "--paths",
+ f"temp:{stage_dir / 'temp'}",
+ "--output",
+ "%(id)s.%(ext)s",
+ "--print-to-file",
+ f"after_move:filepath",
+ str(result_path_file),
+ item.source_url,
+ ]
+
+
+def _safe_process_error(stderr: str, *, maximum_lines: int = 8) -> str:
+ lines = [line.strip() for line in stderr.splitlines() if line.strip()]
+ safe = [URL_RE.sub("[URL_REDACTED]", line) for line in lines[-maximum_lines:]]
+ return " | ".join(safe)[:2000] or "no diagnostic text"
+
+
+def _run(
+ command: Sequence[str],
+ *,
+ runner: RunCommand = subprocess.run,
+ cwd: Path | None = None,
+) -> subprocess.CompletedProcess[str]:
+ return runner(
+ list(command),
+ cwd=None if cwd is None else str(cwd),
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ check=False,
+ shell=False,
+ creationflags=_creationflags(),
+ )
+
+
+def probe_item(
+ item: VideoItem,
+ yt_dlp: str,
+ *,
+ runner: RunCommand = subprocess.run,
+) -> dict[str, Any]:
+ completed = _run(build_probe_command(yt_dlp, item), runner=runner)
+ if completed.returncode != 0:
+ raise AccessError(
+ "credential-free metadata probe failed; login/access may be required: "
+ + _safe_process_error(completed.stderr)
+ )
+ try:
+ metadata = json.loads(completed.stdout)
+ except json.JSONDecodeError as exc:
+ raise AccessError("yt-dlp returned invalid metadata JSON") from exc
+ if not isinstance(metadata, dict) or metadata.get("id") != item.bvid:
+ raise AccessError(f"remote identity did not match requested {item.bvid}")
+ if metadata.get("_type") not in {None, "video"} or metadata.get("entries") is not None:
+ raise AccessError(f"{item.bvid} resolved to a playlist or multi-video result")
+ duration = metadata.get("duration")
+ if (
+ isinstance(duration, bool)
+ or not isinstance(duration, (int, float))
+ or not math.isfinite(float(duration))
+ or duration <= 0
+ ):
+ raise AccessError(f"{item.bvid} has no positive finite duration")
+ if metadata.get("is_live") is True or metadata.get("live_status") in {
+ "is_live",
+ "is_upcoming",
+ }:
+ raise AccessError(f"{item.bvid} is live or upcoming, not a complete video")
+ if metadata.get("availability") in {
+ "needs_auth",
+ "premium_only",
+ "private",
+ "subscriber_only",
+ }:
+ raise AccessError(f"{item.bvid} is not available without authentication")
+ remote_title = str(metadata.get("title") or item.title or item.bvid)
+ remote_title = CONTROL_RE.sub(" ", remote_title).strip()[:300] or item.bvid
+ return {
+ "bvid": item.bvid,
+ "source_url": item.source_url,
+ "title": remote_title,
+ "duration_seconds": float(duration),
+ "availability": metadata.get("availability"),
+ "probe": "METADATA_PASS_ONLY",
+ }
+
+
+def _read_result_path(path_file: Path, stage_dir: Path) -> Path:
+ try:
+ lines = [line.strip() for line in path_file.read_text(encoding="utf-8").splitlines() if line.strip()]
+ except OSError as exc:
+ raise ValidationError("yt-dlp did not record a completed output path") from exc
+ if len(lines) != 1:
+ raise ValidationError("yt-dlp must produce exactly one completed media path")
+ output = Path(lines[0])
+ if not output.is_absolute():
+ output = stage_dir / output
+ resolved = output.resolve()
+ try:
+ resolved.relative_to(stage_dir.resolve())
+ except ValueError as exc:
+ raise ValidationError("yt-dlp output escaped the isolated staging directory") from exc
+ if not resolved.is_file():
+ raise ValidationError("recorded yt-dlp output is not a regular file")
+ return resolved
+
+
+def probe_media_file(
+ path: Path,
+ ffprobe: str,
+ *,
+ runner: RunCommand = subprocess.run,
+) -> MediaFacts:
+ suffix = path.suffix.casefold()
+ if suffix in FORBIDDEN_SUFFIXES or suffix not in MEDIA_SUFFIXES:
+ raise ValidationError(f"unsupported or incomplete media suffix: {suffix or '<none>'}")
+ command = [
+ ffprobe,
+ "-v",
+ "error",
+ "-show_entries",
+ "format=duration,format_name:stream=codec_type,codec_name",
+ "-of",
+ "json",
+ str(path),
+ ]
+ completed = _run(command, runner=runner)
+ if completed.returncode != 0:
+ raise ValidationError("ffprobe failed: " + _safe_process_error(completed.stderr))
+ try:
+ raw = json.loads(completed.stdout)
+ streams = raw["streams"]
+ duration = float(raw["format"]["duration"])
+ format_name = str(raw["format"]["format_name"])
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise ValidationError("ffprobe returned incomplete media metadata") from exc
+ video = next((stream for stream in streams if stream.get("codec_type") == "video"), None)
+ audio = next((stream for stream in streams if stream.get("codec_type") == "audio"), None)
+ if (
+ not math.isfinite(duration)
+ or duration <= 0
+ or not isinstance(video, dict)
+ or not isinstance(audio, dict)
+ ):
+ raise ValidationError("artifact must have positive duration plus video and audio streams")
+ return MediaFacts(
+ duration_seconds=duration,
+ format_name=format_name,
+ video_codec=str(video.get("codec_name") or "unknown"),
+ audio_codec=str(audio.get("codec_name") or "unknown"),
+ )
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for block in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(block)
+ return digest.hexdigest()
+
+
+def validate_stable_local_media(
+ path: Path,
+ *,
+ sleeper: Sleeper = time.sleep,
+) -> Path:
+ """Return a resolved, completed local media file without modifying it."""
+
+ try:
+ resolved = path.expanduser().resolve(strict=True)
+ except OSError as exc:
+ raise ValidationError("browser handoff media file does not exist") from exc
+ if not resolved.is_file():
+ raise ValidationError("browser handoff media path must be a regular file")
+ suffix = resolved.suffix.casefold()
+ if suffix in FORBIDDEN_SUFFIXES or suffix not in MEDIA_SUFFIXES:
+ raise ValidationError(f"unsupported or incomplete media suffix: {suffix or '<none>'}")
+ for partial_suffix in (".crdownload", ".part"):
+ if Path(f"{resolved}{partial_suffix}").exists():
+ raise ValidationError("browser download is still partial; wait for it to finish")
+ try:
+ before = resolved.stat()
+ sleeper(LOCAL_FILE_SETTLE_SECONDS)
+ after = resolved.stat()
+ except OSError as exc:
+ raise ValidationError("cannot inspect browser handoff media stability") from exc
+ before_facts = (before.st_size, before.st_mtime_ns)
+ after_facts = (after.st_size, after.st_mtime_ns)
+ if before.st_size <= 0 or before_facts != after_facts:
+ raise ValidationError("browser handoff media is not stable; wait for download completion")
+ return resolved
+
+
+def copy_local_media_to_stage(source: Path, staged: Path) -> str:
+ """Copy a stable handoff file to target-volume staging and verify its hash."""
+
+ try:
+ before = source.stat()
+ digest = hashlib.sha256()
+ with source.open("rb") as source_handle, staged.open("xb") as target_handle:
+ for block in iter(lambda: source_handle.read(1024 * 1024), b""):
+ digest.update(block)
+ target_handle.write(block)
+ target_handle.flush()
+ os.fsync(target_handle.fileno())
+ after = source.stat()
+ except BaseException:
+ try:
+ staged.unlink(missing_ok=True)
+ except OSError:
+ pass
+ raise
+ if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
+ staged.unlink(missing_ok=True)
+ raise ValidationError("browser handoff media changed while it was copied")
+ source_sha = digest.hexdigest()
+ if sha256_file(staged) != source_sha:
+ staged.unlink(missing_ok=True)
+ raise ValidationError("staged copy hash did not match browser handoff media")
+ return source_sha
+
+
+def validate_duration_consistency(remote_seconds: float, local_seconds: float) -> dict[str, float]:
+ if (
+ not math.isfinite(remote_seconds)
+ or not math.isfinite(local_seconds)
+ or remote_seconds <= 0
+ or local_seconds <= 0
+ ):
+ raise ValidationError("remote and local durations must be positive finite values")
+ tolerance = max(
+ DURATION_ABSOLUTE_TOLERANCE_SECONDS,
+ remote_seconds * DURATION_RELATIVE_TOLERANCE,
+ )
+ delta = abs(local_seconds - remote_seconds)
+ if delta > tolerance:
+ raise ValidationError(
+ "downloaded duration does not match remote duration; possible preview or wrong part: "
+ f"remote={remote_seconds:.6f}s local={local_seconds:.6f}s "
+ f"tolerance={tolerance:.6f}s"
+ )
+ return {
+ "remote_duration_seconds": remote_seconds,
+ "local_duration_seconds": local_seconds,
+ "duration_delta_seconds": delta,
+ "duration_tolerance_seconds": tolerance,
+ }
+
+
+def _write_json_create_new(path: Path, payload: dict[str, Any]) -> None:
+ encoded = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
+ created = False
+ try:
+ with path.open("xb") as target:
+ created = True
+ target.write(encoded)
+ target.flush()
+ os.fsync(target.fileno())
+ except BaseException:
+ if created:
+ try:
+ path.unlink(missing_ok=True)
+ except OSError:
+ pass
+ raise
+
+
+def _preflight_destination(destination: Path, bvid: str) -> None:
+ collisions = [
+ child
+ for child in destination.iterdir()
+ if child.is_file() and child.name.casefold().startswith(f"{bvid}.".casefold())
+ ]
+ if collisions:
+ raise CollisionError(f"output already exists for {bvid}; overwrite is forbidden")
+
+
+def publish_validated_media(
+ source: Path,
+ destination: Path,
+ item: VideoItem,
+ metadata: dict[str, Any],
+ facts: MediaFacts,
+ *,
+ mapping_extra: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ duration_evidence = validate_duration_consistency(
+ float(metadata["duration_seconds"]), facts.duration_seconds
+ )
+ _preflight_destination(destination, item.bvid)
+ media_path = destination / f"{item.bvid}{source.suffix.casefold()}"
+ mapping_path = destination / f"{item.bvid}.download.json"
+ size_bytes = source.stat().st_size
+ if size_bytes <= 0:
+ raise ValidationError("downloaded artifact is empty")
+ source_sha = sha256_file(source)
+ created: list[Path] = []
+ try:
+ os.link(source, media_path)
+ created.append(media_path)
+ published_sha = sha256_file(media_path)
+ if published_sha != source_sha:
+ raise ValidationError("published media hash did not match validated staging media")
+ mapping: dict[str, Any] = {
+ "schema_version": SCHEMA_VERSION,
+ "bvid": item.bvid,
+ "source": item.source_url,
+ "published_at": item.published_at,
+ "title": metadata["title"],
+ "local_file": media_path.name,
+ "bytes": size_bytes,
+ "sha256": published_sha,
+ "duration_seconds": facts.duration_seconds,
+ **duration_evidence,
+ "format_name": facts.format_name,
+ "video_codec": facts.video_codec,
+ "audio_codec": facts.audio_codec,
+ "completed_at": datetime.now(timezone.utc).isoformat(),
+ }
+ if mapping_extra:
+ overlap = set(mapping).intersection(mapping_extra)
+ if overlap:
+ raise ValidationError(f"mapping extension overlaps protected fields: {sorted(overlap)}")
+ mapping.update(mapping_extra)
+ _write_json_create_new(mapping_path, mapping)
+ created.append(mapping_path)
+ return mapping
+ except BaseException:
+ for path in reversed(created):
+ try:
+ path.unlink(missing_ok=True)
+ except OSError:
+ pass
+ raise
+
+
+def _cleanup_stage(
+ stage_dir: Path,
+ stage_parent: Path,
+ *,
+ committed: bool,
+) -> str | None:
+ active_error = sys.exc_info()[1]
+ try:
+ shutil.rmtree(stage_dir, ignore_errors=False)
+ if stage_parent.exists() and not any(stage_parent.iterdir()):
+ stage_parent.rmdir()
+ except OSError as exc:
+ if committed:
+ return f"staging cleanup requires attention: {type(exc).__name__}"
+ if active_error is not None:
+ active_error.add_note(f"staging cleanup also failed: {type(exc).__name__}")
+ else:
+ raise ValidationError(f"staging cleanup failed: {type(exc).__name__}") from exc
+ return None
+
+
+def download_item(
+ item: VideoItem,
+ destination: Path,
+ batch_id: str,
+ yt_dlp: str,
+ ffmpeg: str,
+ ffprobe: str,
+ *,
+ runner: RunCommand = subprocess.run,
+) -> dict[str, Any]:
+ _preflight_destination(destination, item.bvid)
+ metadata = probe_item(item, yt_dlp, runner=runner)
+ stage_parent = destination / ".bili-download-staging"
+ stage_dir = stage_parent / f"{batch_id}-{item.bvid}-{uuid.uuid4().hex[:8]}"
+ stage_dir.mkdir(parents=True, exist_ok=False)
+ result_path_file = stage_dir / "completed-path.txt"
+ committed = False
+ cleanup_warning: str | None = None
+ try:
+ command = build_download_command(yt_dlp, ffmpeg, item, stage_dir, result_path_file)
+ completed = _run(command, runner=runner, cwd=stage_dir)
+ if completed.returncode != 0:
+ raise AccessError("complete video download failed: " + _safe_process_error(completed.stderr))
+ source = _read_result_path(result_path_file, stage_dir)
+ facts = probe_media_file(source, ffprobe, runner=runner)
+ mapping = publish_validated_media(source, destination, item, metadata, facts)
+ committed = True
+ finally:
+ cleanup_warning = _cleanup_stage(stage_dir, stage_parent, committed=committed)
+ result: dict[str, Any] = {"status": "COMPLETE", **mapping}
+ if cleanup_warning:
+ result["warning"] = cleanup_warning
+ return result
+
+
+def _find_batch_item(batch: BatchSpec, bvid: str) -> VideoItem:
+ if not BVID_RE.fullmatch(bvid):
+ raise InputError("bvid is invalid")
+ matches = [item for item in batch.items if item.bvid == bvid]
+ if len(matches) != 1:
+ raise InputError(f"bvid must identify exactly one item in the batch: {bvid}")
+ return matches[0]
+
+
+def accept_browser_file(
+ item: VideoItem,
+ source_file: Path,
+ destination: Path,
+ batch_id: str,
+ yt_dlp: str,
+ ffprobe: str,
+ *,
+ runner: RunCommand = subprocess.run,
+ sleeper: Sleeper = time.sleep,
+) -> dict[str, Any]:
+ """Validate a completed browser-produced file and publish a read-only copy."""
+
+ if not destination.is_dir():
+ raise InputError("destination must already exist and be a directory")
+ _preflight_destination(destination, item.bvid)
+ source = validate_stable_local_media(source_file, sleeper=sleeper)
+ metadata = probe_item(item, yt_dlp, runner=runner)
+ stage_parent = destination / ".bili-download-staging"
+ stage_dir = stage_parent / f"{batch_id}-{item.bvid}-{uuid.uuid4().hex[:8]}"
+ stage_dir.mkdir(parents=True, exist_ok=False)
+ staged = stage_dir / f"{item.bvid}{source.suffix.casefold()}"
+ committed = False
+ cleanup_warning: str | None = None
+ try:
+ source_sha = copy_local_media_to_stage(source, staged)
+ facts = probe_media_file(staged, ffprobe, runner=runner)
+ mapping = publish_validated_media(
+ staged,
+ destination,
+ item,
+ metadata,
+ facts,
+ mapping_extra={
+ "acquisition_mode": "authorized_browser_file_handoff",
+ "handoff_source_sha256": source_sha,
+ },
+ )
+ committed = True
+ finally:
+ cleanup_warning = _cleanup_stage(stage_dir, stage_parent, committed=committed)
+ result: dict[str, Any] = {"status": "COMPLETE", **mapping}
+ if cleanup_warning:
+ result["warning"] = cleanup_warning
+ return result
+
+
+def probe_batch(
+ batch: BatchSpec,
+ yt_dlp: str,
+ *,
+ runner: RunCommand = subprocess.run,
+) -> tuple[list[dict[str, Any]], int]:
+ results: list[dict[str, Any]] = []
+ failures = 0
+ for item in batch.items:
+ try:
+ results.append(probe_item(item, yt_dlp, runner=runner))
+ except BridgeError as exc:
+ failures += 1
+ results.append({"bvid": item.bvid, "probe": "FAIL", "error": str(exc)})
+ return results, failures
+
+
+def download_batch(
+ batch: BatchSpec,
+ destination: Path,
+ yt_dlp: str,
+ ffmpeg: str,
+ ffprobe: str,
+ *,
+ runner: RunCommand = subprocess.run,
+) -> tuple[list[dict[str, Any]], int]:
+ if not destination.is_dir():
+ raise InputError("destination must already exist and be a directory")
+ results: list[dict[str, Any]] = []
+ failures = 0
+ for item in batch.items:
+ try:
+ results.append(
+ download_item(
+ item,
+ destination,
+ batch.batch_id,
+ yt_dlp,
+ ffmpeg,
+ ffprobe,
+ runner=runner,
+ )
+ )
+ except BridgeError as exc:
+ failures += 1
+ results.append({"bvid": item.bvid, "status": "FAIL", "error": str(exc)})
+ return results, failures
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description=(
+ "Safely download public Bilibili videos or accept a completed browser file "
+ "without exposing browser credentials."
+ )
+ )
+ parser.add_argument("--input", required=True, type=Path, help="UTF-8 batch JSON")
+ parser.add_argument("--yt-dlp", default="yt-dlp", help="yt-dlp executable")
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ subparsers.add_parser("probe", help="credential-free metadata check; downloads no media")
+ download = subparsers.add_parser("download", help="download, validate, and publish complete media")
+ download.add_argument("--destination", required=True, type=Path)
+ download.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg executable")
+ download.add_argument("--ffprobe", default="ffprobe", help="ffprobe executable")
+ handoff = subparsers.add_parser(
+ "accept-browser-file",
+ help="validate and publish one already-completed local browser media file",
+ )
+ handoff.add_argument("--bvid", required=True, help="exact BVID from the batch JSON")
+ handoff.add_argument("--media-file", required=True, type=Path)
+ handoff.add_argument("--destination", required=True, type=Path)
+ handoff.add_argument("--ffprobe", default="ffprobe", help="ffprobe executable")
+ return parser
+
+
+def main(argv: Iterable[str] | None = None) -> int:
+ args = _parser().parse_args(list(argv) if argv is not None else None)
+ try:
+ batch = load_batch(args.input)
+ yt_dlp = _resolve_executable(args.yt_dlp, "yt-dlp")
+ if args.command == "probe":
+ results, failures = probe_batch(batch, yt_dlp)
+ elif args.command == "download":
+ ffmpeg = _resolve_executable(args.ffmpeg, "ffmpeg")
+ ffprobe = _resolve_executable(args.ffprobe, "ffprobe")
+ results, failures = download_batch(batch, args.destination, yt_dlp, ffmpeg, ffprobe)
+ else:
+ ffprobe = _resolve_executable(args.ffprobe, "ffprobe")
+ item = _find_batch_item(batch, args.bvid)
+ results = [
+ accept_browser_file(
+ item,
+ args.media_file,
+ args.destination,
+ batch.batch_id,
+ yt_dlp,
+ ffprobe,
+ )
+ ]
+ failures = 0
+ payload = {
+ "batch_id": batch.batch_id,
+ "command": args.command,
+ "result": (
+ "METADATA_PASS_ONLY"
+ if args.command == "probe" and failures == 0
+ else "PASS"
+ if failures == 0
+ else "PARTIAL_OR_FAILED"
+ ),
+ "success_count": len(results) - failures,
+ "failure_count": failures,
+ "items": results,
+ }
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ return 0 if failures == 0 else 2
+ except BridgeError as exc:
+ print(json.dumps({"result": "SAFETY_STOP", "error": str(exc)}, ensure_ascii=False))
+ return 3
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/dev/project-dev/extract_ppt_slides.py b/dev/project-dev/extract_ppt_slides.py
index 1d0ced7..0e7a20e 100644
--- a/dev/project-dev/extract_ppt_slides.py
+++ b/dev/project-dev/extract_ppt_slides.py
@@ -20,6 +20,18 @@
import numpy as np
from PIL import Image
+MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
+if MODULE_DIRECTORY not in sys.path:
+ sys.path.insert(0, MODULE_DIRECTORY)
+
+from transcribe_media import (
+ MEDIA_DECODE_THREADS,
+ MEDIA_FILTER_THREADS,
+ MediaHostGuardError,
+ _subprocess_creation_flags,
+ media_host_guard,
+)
+
SCAN_MAX_WIDTH = 320
FEATURE_WIDTH = 64
@@ -40,6 +52,13 @@
DEDUPE_HASH_DISTANCE = 7
EDGE_THRESHOLD = 18
CONTENT_SCORE_TOLERANCE = 0.012
+PALETTE_BINS_PER_CHANNEL = 8
+PALETTE_BIN_COUNT = PALETTE_BINS_PER_CHANNEL ** 3
+SEQUENCE_THEME_COVERAGE = 0.12
+SEQUENCE_THEME_MAX_GAP = 1
+SEQUENCE_THEME_MIN_MARKERS = 8
+SEQUENCE_THEME_MIN_SHARE = 0.50
+SEQUENCE_THEME_MIN_DENSITY = 0.80
PROCESS_WAIT_SECONDS = 5.0
STDERR_TAIL_BYTES = 8192
@@ -48,11 +67,30 @@
"""Expected, user-facing extraction failure."""
+class PartialFrameError(SlideExtractionError):
+ """A rawvideo stream ended in the middle of one frame."""
+
+
+class HardwareDecodePathError(SlideExtractionError):
+ """The NVDEC decoder/transfer pipe did not yield a complete frame stream."""
+
+
@dataclass(frozen=True)
class VideoInfo:
width: int
height: int
duration: float | None
+ codec_name: str | None = None
+ pixel_format: str | None = None
+
+
+@dataclass(frozen=True)
+class ScanResult:
+ pages: tuple[LogicalPage, ...]
+ sample_count: int
+ stable_candidate_count: int
+ classification_counts: tuple[tuple[str, int], ...]
+ decoder: str | None
@dataclass(frozen=True)
@@ -60,6 +98,7 @@
sample_index: int
relative_seconds: float
gray: np.ndarray
+ palette_histogram: np.ndarray
perceptual_hash: int
content_score: float
sharpness: float
@@ -69,6 +108,7 @@
class PageCandidate:
timestamp: float
feature: np.ndarray
+ palette_histogram: np.ndarray
perceptual_hash: int
content_score: float
sharpness: float
@@ -78,6 +118,7 @@
class LogicalPage:
logical_anchor_feature: np.ndarray
current_feature: np.ndarray
+ current_palette: np.ndarray
current_hash: int
current_timestamp: float
current_score: float
@@ -96,12 +137,18 @@
if not chunks:
return None
actual = size - remaining
- raise SlideExtractionError(
+ raise PartialFrameError(
f"rawvideo 半帧 EOF:期望 {size} bytes,实际 {actual} bytes"
)
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
+
+
+def _attach_cleanup_note(error: BaseException, detail: str) -> None:
+ error.add_note(f"清理诊断:{detail}")
+ if isinstance(error, HardwareDecodePathError):
+ setattr(error, "_media_cleanup_failed", True)
def _tail(path: Path, limit: int = STDERR_TAIL_BYTES) -> str:
@@ -153,6 +200,7 @@
stdout=subprocess.DEVNULL,
stderr=stderr_handle,
shell=False,
+ creationflags=_subprocess_creation_flags(),
)
return_code = process.wait()
if return_code != 0:
@@ -200,7 +248,7 @@
"-select_streams",
"v:0",
"-show_entries",
- "stream=index,width,height,codec_type:format=duration",
+ "stream=index,width,height,codec_name,pix_fmt,codec_type:format=duration",
"-of",
"json",
"-o",
@@ -214,13 +262,21 @@
stream = streams[0]
width = int(stream["width"])
height = int(stream["height"])
+ codec_name = str(stream.get("codec_name") or "").strip().lower() or None
+ pixel_format = str(stream.get("pix_fmt") or "").strip().lower() or None
duration_raw = (payload.get("format") or {}).get("duration")
duration = float(duration_raw) if duration_raw not in (None, "N/A") else None
except (OSError, ValueError, TypeError, KeyError, IndexError, json.JSONDecodeError) as exc:
raise SlideExtractionError(f"无法解析第一条视频流信息:{exc}") from exc
if width <= 0 or height <= 0:
raise SlideExtractionError("第一条视频流宽高无效")
- return VideoInfo(width=width, height=height, duration=duration)
+ return VideoInfo(
+ width=width,
+ height=height,
+ duration=duration,
+ codec_name=codec_name,
+ pixel_format=pixel_format,
+ )
def _scan_size(info: VideoInfo) -> tuple[int, int]:
@@ -229,8 +285,28 @@
return width, height
+def _palette_histogram(rgb_array: np.ndarray) -> np.ndarray:
+ """Return a normalized 8x8x8 RGB histogram without retaining the RGB frame."""
+ if rgb_array.ndim != 3 or rgb_array.shape[2] != 3 or rgb_array.size == 0:
+ raise SlideExtractionError("颜色主题特征要求非空 RGB 图像")
+ if rgb_array.dtype != np.uint8:
+ raise SlideExtractionError("颜色主题特征要求 uint8 RGB 图像")
+ quantized = rgb_array >> 5
+ indexes = (
+ quantized[:, :, 0].astype(np.int16) * PALETTE_BINS_PER_CHANNEL ** 2
+ + quantized[:, :, 1].astype(np.int16) * PALETTE_BINS_PER_CHANNEL
+ + quantized[:, :, 2].astype(np.int16)
+ )
+ histogram = np.bincount(indexes.reshape(-1), minlength=PALETTE_BIN_COUNT).astype(
+ np.float32
+ )
+ histogram /= float(indexes.size)
+ return histogram
+
+
def _gray_feature(rgb: bytes, width: int, height: int, index: int) -> FrameFeature:
array = np.frombuffer(rgb, dtype=np.uint8).reshape((height, width, 3))
+ palette_histogram = _palette_histogram(array)
with Image.fromarray(array, mode="RGB") as image:
feature_height = max(1, round(height * FEATURE_WIDTH / width))
gray_image = image.convert("L").resize(
@@ -260,6 +336,7 @@
sample_index=index,
relative_seconds=float(index),
gray=gray,
+ palette_histogram=palette_histogram,
perceptual_hash=perceptual_hash,
content_score=float(edge_density + 0.25 * contrast),
sharpness=sharpness,
@@ -305,6 +382,7 @@
return PageCandidate(
timestamp=best.relative_seconds,
feature=best.gray,
+ palette_histogram=best.palette_histogram.copy(),
perceptual_hash=best.perceptual_hash,
content_score=best.content_score,
sharpness=best.sharpness,
@@ -412,6 +490,7 @@
if self._animation(candidate, recent):
if candidate.content_score >= recent.current_score - CONTENT_SCORE_TOLERANCE:
recent.current_feature = candidate.feature
+ recent.current_palette = candidate.palette_histogram.copy()
recent.current_hash = candidate.perceptual_hash
recent.current_timestamp = candidate.timestamp
recent.current_score = candidate.content_score
@@ -424,6 +503,7 @@
LogicalPage(
logical_anchor_feature=candidate.feature.copy(),
current_feature=candidate.feature,
+ current_palette=candidate.palette_histogram.copy(),
current_hash=candidate.perceptual_hash,
current_timestamp=candidate.timestamp,
current_score=candidate.content_score,
@@ -433,57 +513,213 @@
return "new_page"
-def _scan_command(ffmpeg: str, source: Path, scan_width: int, scan_height: int) -> list[str]:
- filter_graph = (
- f"setpts=PTS-STARTPTS,fps=fps=1:start_time=0,"
- f"scale={scan_width}:{scan_height}:flags=bilinear"
+def _validated_page_palettes(pages: Sequence[LogicalPage]) -> np.ndarray:
+ palettes: list[np.ndarray] = []
+ for index, page in enumerate(pages, 1):
+ palette = np.asarray(page.current_palette)
+ if palette.shape != (PALETTE_BIN_COUNT,):
+ raise SlideExtractionError(
+ f"第 {index} 个稳定候选颜色主题维度错误:{palette.shape}"
+ )
+ if not np.all(np.isfinite(palette)) or np.any(palette < 0):
+ raise SlideExtractionError(f"第 {index} 个稳定候选颜色主题包含非法数值")
+ total = float(np.sum(palette, dtype=np.float64))
+ if not math.isclose(total, 1.0, rel_tol=1e-4, abs_tol=1e-4):
+ raise SlideExtractionError(
+ f"第 {index} 个稳定候选颜色主题未归一化:sum={total:.6f}"
+ )
+ palettes.append(palette.astype(np.float32, copy=False))
+ return np.stack(palettes)
+
+
+def select_main_sequence(pages: Sequence[LogicalPage]) -> list[LogicalPage]:
+ """Conservatively keep the dominant contiguous visual-theme sequence."""
+ if not pages:
+ raise SlideExtractionError("主课件序列过滤没有稳定候选页面")
+ palettes = _validated_page_palettes(pages)
+ total_pages = len(pages)
+ best_score: tuple[int, int, float, int, int] | None = None
+ best_bounds: tuple[int, int] | None = None
+
+ for color_bin in range(PALETTE_BIN_COUNT):
+ marker_indexes = np.flatnonzero(
+ palettes[:, color_bin] >= SEQUENCE_THEME_COVERAGE
+ ).tolist()
+ if not marker_indexes:
+ continue
+ groups: list[list[int]] = []
+ current_group = [int(marker_indexes[0])]
+ for marker_index in marker_indexes[1:]:
+ marker_index = int(marker_index)
+ if marker_index - current_group[-1] <= SEQUENCE_THEME_MAX_GAP + 1:
+ current_group.append(marker_index)
+ else:
+ groups.append(current_group)
+ current_group = [marker_index]
+ groups.append(current_group)
+
+ for group in groups:
+ marker_count = len(group)
+ start, end = group[0], group[-1]
+ span = end - start + 1
+ share = marker_count / total_pages
+ density = marker_count / span
+ if (
+ marker_count < SEQUENCE_THEME_MIN_MARKERS
+ or share < SEQUENCE_THEME_MIN_SHARE
+ or density < SEQUENCE_THEME_MIN_DENSITY
+ ):
+ continue
+ coverage_sum = float(
+ np.sum(palettes[start : end + 1, color_bin], dtype=np.float64)
+ )
+ score = (marker_count, span, coverage_sum, -start, -color_bin)
+ if best_score is None or score > best_score:
+ best_score = score
+ best_bounds = (start, end)
+
+ if best_bounds is None:
+ return list(pages)
+ start, end = best_bounds
+ if start == 0 and end == total_pages - 1:
+ return list(pages)
+ return list(pages[start : end + 1])
+
+
+def _decoder_for_codec(
+ codec_name: str | None,
+ pixel_format: str | None = None,
+) -> str | None:
+ del pixel_format # evidence only; codec decides whether the single NVDEC attempt is made
+ return {"h264": "h264_cuvid", "hevc": "hevc_cuvid"}.get(codec_name or "")
+
+
+def _scan_command(
+ ffmpeg: str,
+ source: Path,
+ scan_width: int,
+ scan_height: int,
+ decoder: str | None = None,
+) -> list[str]:
+ filters = []
+ if decoder is not None:
+ filters.extend(("hwdownload", "format=nv12"))
+ filters.extend(
+ (
+ "setpts=PTS-STARTPTS",
+ "fps=fps=1:start_time=0",
+ f"scale={scan_width}:{scan_height}:flags=bilinear",
+ )
)
- return [
+ command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
- "-i",
- str(source),
+ ]
+ if decoder is not None:
+ command.extend(
+ (
+ "-xerror",
+ "-hwaccel",
+ "cuda",
+ "-hwaccel_output_format",
+ "cuda",
+ "-c:v",
+ decoder,
+ )
+ )
+ command.extend(
+ (
+ "-threads",
+ str(MEDIA_DECODE_THREADS),
+ "-filter_threads",
+ str(MEDIA_FILTER_THREADS),
+ "-filter_complex_threads",
+ str(MEDIA_FILTER_THREADS),
+ "-i",
+ str(source),
+ )
+ )
+ command.extend(
+ (
"-map",
"0:v:0",
"-vf",
- filter_graph,
+ ",".join(filters),
"-pix_fmt",
"rgb24",
"-f",
"rawvideo",
"-",
- ]
+ )
+ )
+ return command
-def scan_video(source: Path, staging: Path, info: VideoInfo) -> list[LogicalPage]:
+def _scan_video_once(
+ source: Path,
+ staging: Path,
+ info: VideoInfo,
+ *,
+ decoder: str | None,
+) -> ScanResult:
scan_width, scan_height = _scan_size(info)
frame_bytes = scan_width * scan_height * 3
- stderr_path = staging / ".scan.stderr.log"
+ path_label = decoder or "cpu"
+ stderr_path = staging / f".scan-{path_label}.stderr.log"
stderr_handle: BinaryIO | None = None
process: subprocess.Popen[bytes] | None = None
active_error: BaseException | None = None
detector = StableSegmentDetector()
classifier = PageClassifier()
samples = 0
+ stable_candidates = 0
+ classifications: dict[str, int] = {}
try:
- stderr_handle = stderr_path.open("wb")
- process = subprocess.Popen(
- _scan_command(_which_or_error("ffmpeg"), source, scan_width, scan_height),
- stdin=subprocess.DEVNULL,
- stdout=subprocess.PIPE,
- stderr=stderr_handle,
- shell=False,
+ try:
+ stderr_handle = stderr_path.open("wb")
+ except OSError as exc:
+ raise SlideExtractionError(f"FFmpeg 稳定帧扫描打开 stderr 失败:{exc}") from exc
+ command = _scan_command(
+ _which_or_error("ffmpeg"),
+ source,
+ scan_width,
+ scan_height,
+ decoder,
)
+ try:
+ process = subprocess.Popen(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=stderr_handle,
+ shell=False,
+ creationflags=_subprocess_creation_flags(),
+ )
+ except BaseException as exc:
+ if not isinstance(exc, Exception):
+ raise
+ if decoder is not None:
+ raise HardwareDecodePathError(
+ f"{decoder} 启动失败:{exc}"
+ ) from exc
+ raise SlideExtractionError(f"受限 CPU 扫描启动失败:{exc}") from exc
assert process.stdout is not None
while True:
- raw = read_exact(process.stdout, frame_bytes)
+ try:
+ raw = read_exact(process.stdout, frame_bytes)
+ except PartialFrameError as exc:
+ if decoder is not None:
+ raise HardwareDecodePathError(f"{decoder} 输出半帧:{exc}") from exc
+ raise
if raw is None:
break
candidate = detector.add(_gray_feature(raw, scan_width, scan_height, samples))
if candidate is not None:
- classifier.consume(candidate)
+ stable_candidates += 1
+ classification = classifier.consume(candidate)
+ classifications[classification] = classifications.get(classification, 0) + 1
samples += 1
if samples % 300 == 0:
if info.duration and info.duration > 0:
@@ -493,26 +729,32 @@
print(f"扫描进度:{samples} 秒", flush=True)
candidate = detector.flush()
if candidate is not None:
- classifier.consume(candidate)
- process.stdout.close()
+ stable_candidates += 1
+ classification = classifier.consume(candidate)
+ classifications[classification] = classifications.get(classification, 0) + 1
+ try:
+ process.stdout.close()
+ except OSError as exc:
+ raise SlideExtractionError(f"FFmpeg 稳定帧扫描关闭 stdout 失败:{exc}") from exc
return_code = process.wait()
if return_code != 0:
stderr_handle.flush()
detail = _tail(stderr_path)
suffix = f":{detail}" if detail else ""
- raise SlideExtractionError(f"FFmpeg 稳定帧扫描失败(exit={return_code}){suffix}")
+ message = f"FFmpeg 稳定帧扫描失败(exit={return_code}){suffix}"
+ if decoder is not None:
+ raise HardwareDecodePathError(f"{decoder} {message}")
+ raise SlideExtractionError(message)
except BaseException as exc:
active_error = exc
cleanup_error = _attempt_reap(process) if process is not None else None
if not isinstance(exc, Exception):
+ if cleanup_error is not None:
+ _attach_cleanup_note(exc, f"子进程回收失败:{cleanup_error}")
raise
if cleanup_error is not None:
- raise SlideExtractionError(
- f"FFmpeg 稳定帧扫描失败:{exc};子进程清理失败:{cleanup_error}"
- ) from exc
- if isinstance(exc, SlideExtractionError):
- raise
- raise SlideExtractionError(f"FFmpeg 稳定帧扫描失败:{exc}") from exc
+ _attach_cleanup_note(exc, f"子进程回收失败:{cleanup_error}")
+ raise
finally:
if stderr_handle is not None:
try:
@@ -522,11 +764,53 @@
raise SlideExtractionError(
f"FFmpeg 稳定帧扫描关闭 stderr 失败:{close_error}"
) from close_error
+ _attach_cleanup_note(active_error, f"关闭 stderr 失败:{close_error}")
if samples == 0:
+ if decoder is not None:
+ raise HardwareDecodePathError(f"{decoder} 未输出任何完整扫描帧")
raise SlideExtractionError("第一条视频流没有可扫描画面")
- if not classifier.pages:
+ return ScanResult(
+ pages=tuple(classifier.pages),
+ sample_count=samples,
+ stable_candidate_count=stable_candidates,
+ classification_counts=tuple(sorted(classifications.items())),
+ decoder=decoder,
+ )
+
+
+def scan_video(source: Path, staging: Path, info: VideoInfo) -> list[LogicalPage]:
+ decoder = _decoder_for_codec(info.codec_name, info.pixel_format)
+ if decoder is None:
+ print(
+ "警告:视频编码没有 H.264/H.265 NVDEC 映射,"
+ "使用 4 解码线程/2 滤镜线程的受限 CPU。",
+ file=sys.stderr,
+ flush=True,
+ )
+ result = _scan_video_once(source, staging, info, decoder=None)
+ path_label = "受限 CPU"
+ else:
+ try:
+ result = _scan_video_once(source, staging, info, decoder=decoder)
+ path_label = f"NVDEC/CUDA ({decoder})"
+ except HardwareDecodePathError as exc:
+ if getattr(exc, "_media_cleanup_failed", False):
+ raise
+ print(
+ f"警告:NVDEC 不可用({decoder}):{exc};改用 4 解码线程/2 滤镜线程的受限 CPU。",
+ file=sys.stderr,
+ flush=True,
+ )
+ result = _scan_video_once(source, staging, info, decoder=None)
+ path_label = "受限 CPU(NVDEC 一次回退)"
+ print(
+ f"扫描解码路径:{path_label};解码线程={MEDIA_DECODE_THREADS};"
+ f"滤镜线程={MEDIA_FILTER_THREADS}",
+ flush=True,
+ )
+ if not result.pages:
raise SlideExtractionError("未发现持续至少 3 个采样点的稳定页面")
- return classifier.pages
+ return list(result.pages)
def _extract_command(ffmpeg: str, source: Path, timestamp: float, destination: Path) -> list[str]:
@@ -537,6 +821,8 @@
"error",
"-ss",
f"{timestamp:.3f}",
+ "-threads",
+ str(MEDIA_DECODE_THREADS),
"-i",
str(source),
"-map",
@@ -716,8 +1002,12 @@
flush=True,
)
stage = "扫描稳定页面"
- pages = scan_video(source, staging, info)
- print(f"稳定页面:{len(pages)}", flush=True)
+ candidates = scan_video(source, staging, info)
+ pages = select_main_sequence(candidates)
+ print(
+ f"稳定候选:{len(candidates)};主课件页面:{len(pages)}",
+ flush=True,
+ )
stage = "提取原分辨率 PNG"
png_paths = extract_original_pages(source, staging, info, pages)
stage = "生成合并 PDF"
@@ -759,12 +1049,13 @@
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
- extract_slides(args.video, args.output)
+ with media_host_guard():
+ extract_slides(args.video, args.output)
return 0
except KeyboardInterrupt:
print("错误:用户中断,暂存已清理。", file=sys.stderr)
return 130
- except SlideExtractionError as exc:
+ except (SlideExtractionError, MediaHostGuardError) as exc:
print(f"错误:{exc}", file=sys.stderr)
return 2
diff --git a/dev/project-dev/hibor_batch_example.json b/dev/project-dev/hibor_batch_example.json
index eecbdd4..c0601c3 100644
--- a/dev/project-dev/hibor_batch_example.json
+++ b/dev/project-dev/hibor_batch_example.json
@@ -7,7 +7,7 @@
"quota_ledger": "ana-data/tmp/report-collection-control/daily_quota_YYYY-MM-DD.csv",
"adb_executable": "adb",
"pdfinfo_executable": "pdfinfo",
- "device_serial": "YOUR-DEVICE-SERIAL",
+ "device_serial": "emulator-5554",
"source_scope": {
"ui": {
"search_entry_texts": ["研究报告"],
diff --git a/dev/project-dev/hibor_batch_executor.py b/dev/project-dev/hibor_batch_executor.py
index 9b6e393..2bfd787 100644
--- a/dev/project-dev/hibor_batch_executor.py
+++ b/dev/project-dev/hibor_batch_executor.py
@@ -19,6 +19,8 @@
MAX_ITEMS = 10
MAX_GENERATED_PATH_CHARS = 220
DEFAULT_ITEM_TIMEOUT_SECONDS = 660
+ADB_PREFLIGHT_TIMEOUT_SECONDS = 15
+AUTO_DEVICE_SERIAL = "auto"
PACKAGE_NAME = "cn.com.hibor"
CACHE_ROOT = "/sdcard/Android/data/cn.com.hibor/files/myfile/"
SHORT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,23}$")
@@ -313,6 +315,86 @@
)
+def _parse_adb_devices(stdout: bytes) -> dict[str, str]:
+ try:
+ text = stdout.decode("utf-8")
+ except UnicodeError as exc:
+ raise BatchInputError("adb devices output is not valid UTF-8") from exc
+ lines = [line.strip() for line in text.splitlines()]
+ try:
+ header_index = lines.index("List of devices attached")
+ except ValueError as exc:
+ raise BatchInputError("adb devices output is missing its header") from exc
+
+ devices: dict[str, str] = {}
+ for line in lines[header_index + 1:]:
+ if not line or line.startswith("*"):
+ continue
+ fields = line.split()
+ if len(fields) < 2:
+ raise BatchInputError(f"cannot parse adb device row: {line}")
+ serial, state = fields[0], fields[1]
+ if serial in devices:
+ raise BatchInputError(f"adb returned duplicate device serial: {serial}")
+ devices[serial] = state
+ return devices
+
+
+def resolve_device_serial(
+ adb_executable: str,
+ requested_serial: str,
+ *,
+ cwd: Path,
+ environment: Mapping[str, str],
+ runner: Runner | None = None,
+ timeout_seconds: int = ADB_PREFLIGHT_TIMEOUT_SECONDS,
+) -> str:
+ if timeout_seconds < 1:
+ raise BatchInputError("ADB preflight timeout must be positive")
+ runner = runner or _default_runner
+ try:
+ completed = runner(
+ [adb_executable, "devices", "-l"], cwd, environment, timeout_seconds
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise BatchInputError(
+ f"ADB device preflight timed out after {timeout_seconds}s"
+ ) from exc
+ except OSError as exc:
+ raise BatchInputError(
+ f"cannot run ADB device preflight: {type(exc).__name__}"
+ ) from exc
+ if int(completed.returncode) != 0:
+ stderr = _as_bytes(completed.stderr).decode("utf-8", errors="replace").strip()
+ detail = stderr[-500:] if stderr else f"exit code {completed.returncode}"
+ raise BatchInputError(f"ADB device preflight failed: {detail}")
+
+ devices = _parse_adb_devices(_as_bytes(completed.stdout))
+ if requested_serial == AUTO_DEVICE_SERIAL:
+ if len(devices) != 1:
+ raise BatchInputError(
+ "runtime.device_serial=auto requires exactly one connected ADB device; "
+ f"found {len(devices)}"
+ )
+ serial, state = next(iter(devices.items()))
+ if state != "device":
+ raise BatchInputError(
+ f"the only connected ADB device is not ready: {serial} ({state})"
+ )
+ return serial
+
+ state = devices.get(requested_serial)
+ if state is None:
+ raise BatchInputError(
+ f"configured ADB device is not connected: {requested_serial}"
+ )
+ if state != "device":
+ raise BatchInputError(
+ f"configured ADB device is not ready: {requested_serial} ({state})"
+ )
+ return requested_serial
+
+
def _as_bytes(value: bytes | str | None) -> bytes:
if value is None:
return b""
@@ -409,6 +491,7 @@
python_executable: str | None = None,
kernel_entry: Path | None = None,
runner: Runner | None = None,
+ device_runner: Runner | None = None,
item_timeout_seconds: int = DEFAULT_ITEM_TIMEOUT_SECONDS,
) -> dict[str, Any]:
if item_timeout_seconds < 1:
@@ -421,6 +504,17 @@
batch_root = project_root / "ana-data" / "tmp" / "hibor-runs" / batch["batch_id"]
if batch_root.exists() or batch_root.is_symlink():
raise BatchInputError(f"batch output already exists; choose a new batch_id: {batch_root}")
+
+ environment = dict(os.environ)
+ environment["PYTHONUTF8"] = "1"
+ environment["PYTHONIOENCODING"] = "utf-8"
+ batch["runtime"]["device_serial"] = resolve_device_serial(
+ batch["runtime"]["adb_executable"],
+ batch["runtime"]["device_serial"],
+ cwd=project_root,
+ environment=environment,
+ runner=device_runner,
+ )
task_specs = [
build_task_spec(batch, item, index, batch_root / "r" / f"{index:02d}")
@@ -439,9 +533,6 @@
started_ns = time.monotonic_ns()
rows: list[dict[str, Any]] = []
item_timings: list[dict[str, Any]] = []
- environment = dict(os.environ)
- environment["PYTHONUTF8"] = "1"
- environment["PYTHONIOENCODING"] = "utf-8"
for index, (item, task_spec) in enumerate(zip(batch["items"], task_specs), 1):
task_path = batch_root / "t" / f"{index:02d}.json"
diff --git a/dev/project-dev/test/bili_authenticated_extension/__init__.py b/dev/project-dev/test/bili_authenticated_extension/__init__.py
new file mode 100644
index 0000000..9ba7167
--- /dev/null
+++ b/dev/project-dev/test/bili_authenticated_extension/__init__.py
@@ -0,0 +1,12 @@
+"""Tests for the exact-BVID authenticated extension ingress.
+
+Full project discovery imports this directory as ``bili_authenticated_extension``.
+Extend that package path to the product directory so it cannot shadow the
+actual implementation modules.
+"""
+
+from pathlib import Path
+
+_product_package = Path(__file__).resolve().parents[2] / "bili_authenticated_extension"
+if str(_product_package) not in __path__:
+ __path__.append(str(_product_package))
diff --git a/dev/project-dev/test/bili_authenticated_extension/background_contract.mjs b/dev/project-dev/test/bili_authenticated_extension/background_contract.mjs
new file mode 100644
index 0000000..cb8ad70
--- /dev/null
+++ b/dev/project-dev/test/bili_authenticated_extension/background_contract.mjs
@@ -0,0 +1,203 @@
+import fs from "node:fs";
+import vm from "node:vm";
+
+const source = fs.readFileSync(process.argv[2], "utf8");
+const extensionId = "oidmclckpdmpabbfedplkbdplmfcenbb";
+const sidepanelUrl = `chrome-extension://${extensionId}/sidepanel.html`;
+let runtimeListener = null;
+let pageValid = true;
+let cookieReads = 0;
+let cookieStoreReads = 0;
+let nativeConnects = 0;
+let prepareError = null;
+let cookieStoreDelayMs = 0;
+const nativeMessages = [];
+
+function response(type, phase = "READY", errorCode = null, prepareId = null) {
+ return {
+ schema: 2,
+ type,
+ host_build: "project-info-bili-auth-native-host/1.0.0+20260805.v002",
+ target: "BV1HA3o6oEJJ",
+ phase,
+ progress: 0,
+ error_code: errorCode,
+ formal_filename: null,
+ mapping_filename: null,
+ prepare_id: prepareId
+ };
+}
+
+function nativePort() {
+ nativeConnects += 1;
+ let messageListener = null;
+ let disconnectListener = null;
+ return {
+ onMessage: {addListener(listener) { messageListener = listener; }},
+ onDisconnect: {addListener(listener) { disconnectListener = listener; }},
+ postMessage(message) {
+ nativeMessages.push(structuredClone(message));
+ if (message.type === "hello") {
+ setTimeout(() => messageListener(response("hello")), 0);
+ } else if (message.type === "status") {
+ setTimeout(() => messageListener(response("status")), 0);
+ } else if (message.type === "prepare") {
+ setTimeout(() => messageListener(response(
+ "prepare",
+ prepareError ? "FAILED" : "READY",
+ prepareError,
+ message.prepare_id
+ )), 0);
+ }
+ },
+ disconnect() {
+ if (disconnectListener) setTimeout(disconnectListener, 0);
+ }
+ };
+}
+
+const chrome = {
+ runtime: {
+ id: extensionId,
+ getURL(path) { return `chrome-extension://${extensionId}/${path}`; },
+ onInstalled: {addListener() {}},
+ onMessage: {addListener(listener) { runtimeListener = listener; }},
+ connectNative() { return nativePort(); }
+ },
+ sidePanel: {setPanelBehavior() {}},
+ tabs: {
+ async query() {
+ return [{id: 41, url: "https://www.bilibili.com/video/BV1HA3o6oEJJ"}];
+ }
+ },
+ scripting: {
+ async executeScript(options) {
+ if (!pageValid) return [{result: null}];
+ const [target, canonicalUrl, nonce] = options.args;
+ return [{result: {
+ target,
+ canonical_url: canonicalUrl,
+ task_nonce: nonce,
+ observed_at_unix_ms: Date.now(),
+ observed_duration_ms: 3133950,
+ video_width: 2560,
+ video_height: 1440,
+ ready_state: 4,
+ eme_present: false
+ }}];
+ }
+ },
+ cookies: {
+ async getAllCookieStores() {
+ cookieStoreReads += 1;
+ if (cookieStoreDelayMs) await new Promise(resolve => setTimeout(resolve, cookieStoreDelayMs));
+ return [{id: "0", tabIds: [41]}];
+ },
+ async getAll() {
+ cookieReads += 1;
+ return [{
+ name: "SYNTHETIC_NAME",
+ value: "SYNTHETIC_VALUE",
+ domain: ".bilibili.com",
+ hostOnly: false,
+ path: "/",
+ secure: true,
+ httpOnly: true,
+ sameSite: "unspecified",
+ session: true
+ }];
+ }
+ }
+};
+
+vm.runInNewContext(source, {
+ chrome,
+ crypto: globalThis.crypto,
+ URL,
+ Date,
+ Error,
+ Number,
+ Object,
+ Array,
+ Set,
+ String,
+ Boolean,
+ Math,
+ RegExp,
+ Promise,
+ structuredClone,
+ setTimeout,
+ clearTimeout
+}, {filename: "background.js"});
+
+if (!runtimeListener) throw new Error("runtime listener missing");
+
+function dispatch(action, sender = {id: extensionId, url: sidepanelUrl}) {
+ return new Promise(resolve => {
+ const keepAlive = runtimeListener({action}, sender, resolve);
+ if (!keepAlive) resolve(undefined);
+ });
+}
+
+pageValid = false;
+let result = await dispatch("validate");
+if (result.ok || cookieReads !== 0 || nativeConnects !== 0) {
+ throw new Error("invalid page crossed the external-action boundary");
+}
+
+pageValid = true;
+result = await dispatch("validate");
+if (!result.ok || result.state.phase !== "READY" || cookieReads !== 0 || nativeConnects !== 1) {
+ throw new Error("validate did not stop before Cookie access");
+}
+
+prepareError = "E_CONFIG_HASH";
+result = await dispatch("start");
+if (result.ok || result.error_code !== "E_CONFIG_HASH" || cookieReads !== 0) {
+ throw new Error("fresh prepare failure did not stop before Cookie access");
+}
+const failedPrepare = nativeMessages.find(message => message.type === "prepare");
+if (!failedPrepare || Object.prototype.hasOwnProperty.call(failedPrepare, "cookies")) {
+ throw new Error("prepare was not secret-free");
+}
+
+prepareError = null;
+cookieStoreDelayMs = 20;
+const concurrent = await Promise.all([dispatch("start"), dispatch("start")]);
+const successes = concurrent.filter(item => item?.ok);
+const busy = concurrent.filter(item => !item?.ok && item?.error_code === "E_BUSY");
+if (successes.length !== 1 || busy.length !== 1 ||
+ successes[0].state.phase !== "CHECKING" || cookieStoreReads !== 1 || cookieReads !== 1) {
+ throw new Error("concurrent start did not fail closed before the second Cookie read");
+}
+cookieStoreDelayMs = 0;
+const prepareIndex = nativeMessages.findIndex(message => message.type === "prepare" && message.page_proof);
+const startIndex = nativeMessages.findIndex(message => message.type === "start");
+if (prepareIndex < 0 || startIndex <= prepareIndex) {
+ throw new Error("start secret crossed before fresh prepare");
+}
+const firstStart = nativeMessages.find(message => message.type === "start");
+if (!firstStart || firstStart.cookies.length !== 1 || firstStart.cookies[0].value !== "SYNTHETIC_VALUE") {
+ throw new Error("projected Cookie schema was not delivered");
+}
+if (!/^[0-9a-f]{32}$/.test(firstStart.prepare_id) ||
+ firstStart.prepare_id !== nativeMessages.find(message => message.type === "prepare" && message.prepare_id === firstStart.prepare_id)?.prepare_id ||
+ nativeMessages.filter(message => message.type === "start").length !== 1) {
+ throw new Error("prepare lease was not correlated and consumed exactly once");
+}
+if (Object.keys(firstStart.cookies[0]).sort().join("|") !== [
+ "domain", "expiration_unix", "host_only", "http_only", "name", "partition_key",
+ "path", "same_site", "secure", "session", "store_id", "value"
+].sort().join("|")) {
+ throw new Error("Cookie projection contains missing or extra fields");
+}
+
+result = await dispatch("retry");
+if (!result.ok || cookieStoreReads !== 2 || cookieReads !== 2 || nativeConnects !== 2) {
+ throw new Error("retry did not revalidate, reconnect, and reread Cookie once");
+}
+
+const rejected = await dispatch("validate", {id: "wrong", url: sidepanelUrl});
+if (rejected !== undefined) throw new Error("untrusted sender was accepted");
+
+process.stdout.write("BACKGROUND_CONTRACT_PASS\n");
diff --git a/dev/project-dev/test/bili_authenticated_extension/sleep_tree_helper.py b/dev/project-dev/test/bili_authenticated_extension/sleep_tree_helper.py
new file mode 100644
index 0000000..ed3037f
--- /dev/null
+++ b/dev/project-dev/test/bili_authenticated_extension/sleep_tree_helper.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import pathlib
+import subprocess
+import sys
+import time
+
+PROJECT_DEV = pathlib.Path(__file__).resolve().parents[2]
+if str(PROJECT_DEV) not in sys.path:
+ sys.path.insert(0, str(PROJECT_DEV))
+
+
+def record(path: pathlib.Path, role: str) -> None:
+ with path.open("a", encoding="ascii") as target:
+ target.write(json.dumps({"role": role, "pid": os.getpid()}) + "\n")
+ target.flush()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--role", choices=("worker", "child", "grandchild", "owner"), required=True)
+ parser.add_argument("--pid-file", type=pathlib.Path, required=True)
+ args = parser.parse_args()
+ record(args.pid_file, args.role)
+ if args.role == "owner":
+ from bili_authenticated_extension.job import WindowsJob, close_handles, open_nul_handles
+
+ nul_in, nul_out, nul_error = open_nul_handles()
+ job = WindowsJob()
+ process = job.launch_suspended(
+ [sys.executable, __file__, "--role", "worker", "--pid-file", str(args.pid_file)],
+ stdin_handle=nul_in,
+ stdout_handle=nul_out,
+ stderr_handle=nul_error,
+ )
+ close_handles(nul_in, nul_out, nul_error)
+ del process
+ time.sleep(300)
+ return 0
+ if args.role == "worker":
+ subprocess.Popen(
+ [sys.executable, __file__, "--role", "child", "--pid-file", str(args.pid_file)],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ elif args.role == "child":
+ subprocess.Popen(
+ [sys.executable, __file__, "--role", "grandchild", "--pid-file", str(args.pid_file)],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ time.sleep(300)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/dev/project-dev/test/bili_authenticated_extension/test_job.py b/dev/project-dev/test/bili_authenticated_extension/test_job.py
new file mode 100644
index 0000000..d371d0b
--- /dev/null
+++ b/dev/project-dev/test/bili_authenticated_extension/test_job.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+import ctypes
+import json
+import os
+import pathlib
+import sys
+import subprocess
+import tempfile
+import time
+import unittest
+from ctypes import wintypes
+
+PROJECT_DEV = pathlib.Path(__file__).resolve().parents[2]
+if str(PROJECT_DEV) not in sys.path:
+ sys.path.insert(0, str(PROJECT_DEV))
+
+from bili_authenticated_extension.job import WindowsJob, close_handles, open_nul_handles # noqa: E402
+
+HELPER = pathlib.Path(__file__).with_name("sleep_tree_helper.py")
+
+
+def wait_records(path: pathlib.Path, count: int, timeout: float = 8.0) -> list[dict]:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if path.exists():
+ try:
+ records = [json.loads(line) for line in path.read_text(encoding="ascii").splitlines() if line]
+ except (OSError, json.JSONDecodeError):
+ records = []
+ if len(records) >= count:
+ return records
+ time.sleep(0.05)
+ raise AssertionError(f"expected {count} PID records")
+
+
+def pid_signaled(pid: int, timeout: float = 5.0) -> bool:
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ synchronize = 0x00100000
+ handle = kernel32.OpenProcess(synchronize, False, pid)
+ if not handle:
+ return True
+ try:
+ return kernel32.WaitForSingleObject(handle, int(timeout * 1000)) == 0
+ finally:
+ kernel32.CloseHandle(handle)
+
+
+@unittest.skipUnless(os.name == "nt", "Windows Job Object test")
+class JobObjectTests(unittest.TestCase):
+ def test_suspended_assign_resume_and_all_descendants_die_for_seven_terminal_causes(self) -> None:
+ causes = ("cancel", "eof", "disconnect", "keyboard_interrupt", "system_exit", "timeout", "host_kill")
+ for cause in causes:
+ with self.subTest(cause=cause), tempfile.TemporaryDirectory() as temporary:
+ pid_file = pathlib.Path(temporary) / "pids.jsonl"
+ nul_in, nul_out, nul_error = open_nul_handles()
+ job = WindowsJob()
+ process = None
+ try:
+ process = job.launch_suspended(
+ [sys.executable, str(HELPER), "--role", "worker", "--pid-file", str(pid_file)],
+ stdin_handle=nul_in,
+ stdout_handle=nul_out,
+ stderr_handle=nul_error,
+ )
+ records = wait_records(pid_file, 3)
+ self.assertEqual({"worker", "child", "grandchild"}, {item["role"] for item in records})
+ if cause == "host_kill":
+ job.close()
+ else:
+ job.terminate()
+ self.assertTrue(process.wait(5))
+ for item in records:
+ self.assertTrue(pid_signaled(item["pid"]), item)
+ finally:
+ if process is not None:
+ process.close()
+ job.close()
+ close_handles(nul_in, nul_out, nul_error)
+
+ def test_abrupt_job_owner_death_kills_real_worker_child_and_grandchild(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ pid_file = pathlib.Path(temporary) / "owner-pids.jsonl"
+ owner = subprocess.Popen(
+ [sys.executable, str(HELPER), "--role", "owner", "--pid-file", str(pid_file)],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ try:
+ records = wait_records(pid_file, 4)
+ self.assertEqual(
+ {"owner", "worker", "child", "grandchild"},
+ {item["role"] for item in records},
+ )
+ descendants = [item for item in records if item["role"] != "owner"]
+ owner.kill()
+ owner.wait(timeout=5)
+ for item in descendants:
+ self.assertTrue(pid_signaled(item["pid"]), item)
+ finally:
+ if owner.poll() is None:
+ owner.kill()
+ owner.wait(timeout=5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/dev/project-dev/test/bili_authenticated_extension/test_native_host.py b/dev/project-dev/test/bili_authenticated_extension/test_native_host.py
new file mode 100644
index 0000000..bfa6bb6
--- /dev/null
+++ b/dev/project-dev/test/bili_authenticated_extension/test_native_host.py
@@ -0,0 +1,1360 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import pathlib
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+import io
+import struct
+import importlib.util
+import os
+import shutil
+import zipfile
+from unittest import mock
+
+os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
+
+PROJECT_DEV = pathlib.Path(__file__).resolve().parents[2]
+if str(PROJECT_DEV) not in sys.path:
+ sys.path.insert(0, str(PROJECT_DEV))
+
+from bili_authenticated_extension.constants import CANONICAL_URL, EXTENSION_BUILD, TARGET_BVID # noqa: E402
+from bili_authenticated_extension.native_host import ( # noqa: E402
+ WorkerTask,
+ _valid_control_message,
+ preflight_configuration,
+)
+import bili_authenticated_extension.native_host as native_host_module # noqa: E402
+from bili_authenticated_extension.constants import EXPECTED_ORIGIN # noqa: E402
+from bili_authenticated_extension.job import create_event, is_event_set, set_event # noqa: E402
+from bili_authenticated_extension.protocol import ProtocolError, read_frame, strict_json_loads # noqa: E402
+
+ROOT = PROJECT_DEV / "bili_authenticated_extension"
+BRIDGE = PROJECT_DEV / "bili_video_download_bridge.py"
+PROJECT_ROOT = PROJECT_DEV.parents[1]
+BUILDER_ROOT = PROJECT_ROOT / "dev" / "tmp" / "pyinstaller-6.15.0-offline"
+BUILDER_PYTHON = BUILDER_ROOT / "venv" / "Scripts" / "python.exe"
+PYINSTALLER_EXE = BUILDER_ROOT / "venv" / "Scripts" / "pyinstaller.exe"
+BUILDER_RECEIPT = BUILDER_ROOT / "provision-receipt.md"
+HISTORICAL_METADATA_TREE_SHA256 = "7C9ABE2387AF3594593BB162C29757B10693F90DD61606272089DB9815C2105C"
+
+
+def digest(path: pathlib.Path) -> str:
+ return hashlib.sha256(path.read_bytes()).hexdigest().upper()
+
+
+def metadata_contract(source_manifest: pathlib.Path) -> dict:
+ return json.loads(source_manifest.read_text(encoding="utf-8"))["archive_metadata_contract"]
+
+
+def write_source_approval(path: pathlib.Path, source_manifest: pathlib.Path) -> None:
+ path.write_text(json.dumps({
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "controlled-build-source-manifest",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_bytes": source_manifest.stat().st_size,
+ "source_artifact_manifest_sha256": digest(source_manifest),
+ }), encoding="utf-8")
+
+
+def write_build_approval(
+ path: pathlib.Path,
+ source_manifest: pathlib.Path,
+ build_manifest: pathlib.Path,
+ host_path: pathlib.Path,
+) -> None:
+ path.write_text(json.dumps({
+ "schema": 1,
+ "task_id": "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001",
+ "approval_scope": "install-exact-build",
+ "approved_by_role": "dev.reviewer.project",
+ "status": "APPROVED",
+ "source_artifact_manifest_sha256": digest(source_manifest),
+ "build_artifact_manifest_bytes": build_manifest.stat().st_size,
+ "build_artifact_manifest_sha256": digest(build_manifest),
+ "host_executable_bytes": host_path.stat().st_size,
+ "host_executable_sha256": digest(host_path),
+ }), encoding="utf-8")
+
+
+def refresh_source_manifest(source_root: pathlib.Path) -> pathlib.Path:
+ manifest_path = source_root / "source-artifact-manifest.json"
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ dependency_manifest = source_root / "dependencies" / "dependency-artifact-manifest.json"
+ manifest["dependency_artifact_manifest_bytes"] = dependency_manifest.stat().st_size
+ manifest["dependency_artifact_manifest_sha256"] = digest(dependency_manifest)
+ for entry in manifest["files"]:
+ product = source_root / entry["path"]
+ entry["bytes"] = product.stat().st_size
+ entry["sha256"] = digest(product)
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ return manifest_path
+
+
+def start_message() -> dict:
+ return {
+ "schema": 2,
+ "type": "start",
+ "extension_build": EXTENSION_BUILD,
+ "target": TARGET_BVID,
+ "canonical_url": CANONICAL_URL,
+ "cookie_store_id": "0",
+ "prepare_id": "e" * 32,
+ "page_proof": {
+ "target": TARGET_BVID,
+ "canonical_url": CANONICAL_URL,
+ "task_nonce": "c" * 32,
+ "observed_at_unix_ms": int(time.time() * 1000),
+ "observed_duration_ms": 3_133_950,
+ "video_width": 1920,
+ "video_height": 1080,
+ "ready_state": 4,
+ "eme_present": False,
+ },
+ "cookies": [{
+ "name": "SYNTHETIC_JOB_COOKIE",
+ "value": "SYNTHETIC_JOB_VALUE",
+ "domain": ".bilibili.com",
+ "host_only": False,
+ "path": "/",
+ "secure": True,
+ "http_only": True,
+ "same_site": "unspecified",
+ "session": True,
+ "expiration_unix": None,
+ "store_id": "0",
+ "partition_key": None,
+ }],
+ }
+
+
+class NativeHostTests(unittest.TestCase):
+ def test_duplicate_start_does_not_terminate_started_task(self) -> None:
+ class StartedTask:
+ terminal = False
+ prepared = True
+
+ def __init__(self) -> None:
+ self.terminated = False
+
+ def start(self, _message: dict) -> None:
+ raise ProtocolError("E_PREPARE")
+
+ def terminate(self) -> None:
+ self.terminated = True
+
+ task = StartedTask()
+ self.assertEqual("E_PREPARE", native_host_module._start_prepared_task(task, start_message()))
+ self.assertFalse(task.terminated)
+
+ def test_worker_control_terminal_cannot_expose_path_or_false_close_marker(self) -> None:
+ complete = {
+ "schema": 1,
+ "type": "terminal",
+ "phase": "COMPLETE",
+ "error_code": None,
+ "formal_filename": f"{TARGET_BVID}.mkv",
+ "mapping_filename": f"{TARGET_BVID}.download.json",
+ "cookie_stream_closed": True,
+ }
+ self.assertTrue(_valid_control_message(complete))
+ for key, value in (
+ ("formal_filename", "C:\\secret\\file.mkv"),
+ ("mapping_filename", "../mapping.json"),
+ ("cookie_stream_closed", False),
+ ("error_code", "third-party message"),
+ ):
+ changed = dict(complete)
+ changed[key] = value
+ with self.subTest(key=key):
+ self.assertFalse(_valid_control_message(changed))
+
+ def test_broker_checks_origin_before_stdin_and_returns_only_safe_hello(self) -> None:
+ entry = ROOT / "native_host.py"
+ hello = {
+ "schema": 2,
+ "type": "hello",
+ "extension_build": EXTENSION_BUILD,
+ "target": TARGET_BVID,
+ }
+ encoded = json.dumps(hello, separators=(",", ":")).encode("utf-8")
+ framed = struct.pack("<I", len(encoded)) + encoded
+ wrong = subprocess.run(
+ [sys.executable, str(entry), "chrome-extension://wrong/"],
+ input=framed,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=10,
+ )
+ self.assertEqual(2, wrong.returncode)
+ self.assertEqual(b"", wrong.stdout)
+ self.assertEqual(b"", wrong.stderr)
+
+ accepted = subprocess.run(
+ [sys.executable, str(entry), EXPECTED_ORIGIN, "--parent-window=17"],
+ input=framed,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=10,
+ )
+ self.assertEqual(0, accepted.returncode)
+ stream = io.BytesIO(accepted.stdout)
+ payload = read_frame(stream)
+ self.assertIsNotNone(payload)
+ response = strict_json_loads(payload)
+ self.assertEqual("hello", response["type"])
+ self.assertEqual("FAILED", response["phase"])
+ self.assertEqual("E_CONFIG", response["error_code"])
+ self.assertIsNone(read_frame(stream))
+ self.assertEqual(b"", accepted.stderr)
+
+ def test_preflight_hashes_and_collision_run_before_secret(self) -> None:
+ ffmpeg = pathlib.Path(subprocess.check_output(["where.exe", "ffmpeg"], text=True).splitlines()[0])
+ ffprobe = pathlib.Path(subprocess.check_output(["where.exe", "ffprobe"], text=True).splitlines()[0])
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ destination = root / "destination"
+ destination.mkdir()
+ batch = root / "batch.json"
+ batch.write_text("{}\n", encoding="utf-8")
+ config = root / "config.json"
+ payload = {
+ "schema": 1,
+ "target": TARGET_BVID,
+ "canonical_url": CANONICAL_URL,
+ "ffmpeg": str(ffmpeg),
+ "ffmpeg_sha256": digest(ffmpeg),
+ "ffprobe": str(ffprobe),
+ "ffprobe_sha256": digest(ffprobe),
+ "bridge_python": str(pathlib.Path(sys.executable)),
+ "bridge_python_sha256": digest(pathlib.Path(sys.executable)),
+ "bridge_script": str(BRIDGE),
+ "bridge_script_sha256": digest(BRIDGE),
+ "batch_json": str(batch),
+ "batch_json_sha256": digest(batch),
+ "yt_dlp_executable": str(pathlib.Path(sys.executable)),
+ "yt_dlp_executable_sha256": digest(pathlib.Path(sys.executable)),
+ "destination": str(destination),
+ }
+ config.write_text(json.dumps(payload), encoding="utf-8")
+ self.assertIsNone(preflight_configuration(config))
+ (destination / f"{TARGET_BVID}.mkv").write_bytes(b"collision")
+ self.assertEqual("E_EXISTS", preflight_configuration(config))
+ (destination / f"{TARGET_BVID}.mkv").unlink()
+ payload["ffmpeg_sha256"] = "0" * 64
+ config.write_text(json.dumps(payload), encoding="utf-8")
+ self.assertEqual("E_CONFIG_HASH", preflight_configuration(config))
+
+ @unittest.skipUnless(importlib.util.find_spec("yt_dlp"), "frozen yt-dlp environment is required")
+ def test_real_worker_prepares_config_stage_and_stale_cleanup_before_secret(self) -> None:
+ ffmpeg = pathlib.Path(shutil.which("ffmpeg") or "").resolve()
+ ffprobe = pathlib.Path(shutil.which("ffprobe") or "").resolve()
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ local_app_data = root / "local-app-data"
+ local_app_data.mkdir()
+ stage = local_app_data / "project-info" / "bili-auth-ingress" / TARGET_BVID
+ stale = stage / "run-stale"
+ stale.mkdir(parents=True)
+ (stale / "partial.bin").write_bytes(b"stale")
+ destination = root / "destination"
+ destination.mkdir()
+ batch = root / "batch.json"
+ batch.write_text("{}\n", encoding="utf-8")
+ config = root / "config.json"
+ payload = {
+ "schema": 1,
+ "target": TARGET_BVID,
+ "canonical_url": CANONICAL_URL,
+ "ffmpeg": str(ffmpeg),
+ "ffmpeg_sha256": digest(ffmpeg),
+ "ffprobe": str(ffprobe),
+ "ffprobe_sha256": digest(ffprobe),
+ "bridge_python": str(pathlib.Path(sys.executable)),
+ "bridge_python_sha256": digest(pathlib.Path(sys.executable)),
+ "bridge_script": str(BRIDGE),
+ "bridge_script_sha256": digest(BRIDGE),
+ "batch_json": str(batch),
+ "batch_json_sha256": digest(batch),
+ "yt_dlp_executable": str(pathlib.Path(sys.executable)),
+ "yt_dlp_executable_sha256": digest(pathlib.Path(sys.executable)),
+ "destination": str(destination),
+ }
+ config.write_text(json.dumps(payload), encoding="utf-8")
+ task = WorkerTask(config)
+ message = start_message()
+ try:
+ with mock.patch.dict(os.environ, {"LOCALAPPDATA": str(local_app_data)}):
+ task.prepare(message["page_proof"], message["prepare_id"])
+ self.assertTrue(task.prepared)
+ self.assertEqual("READY", task.phase)
+ self.assertFalse(stale.exists())
+ leases = list(stage.glob("run-*"))
+ self.assertEqual(1, len(leases))
+ self.assertEqual(1, len(message["cookies"]))
+ assert task.input_writer is not None
+ task.input_writer.close()
+ task.input_writer = None
+ deadline = time.monotonic() + 10
+ while time.monotonic() < deadline and not task.terminal:
+ task.poll()
+ time.sleep(0.05)
+ self.assertTrue(task.terminal)
+ self.assertEqual("CANCELED", task.phase)
+ self.assertEqual([], list(destination.iterdir()))
+ self.assertFalse(stage.exists())
+ finally:
+ task.close()
+
+ def test_frozen_metadata_and_total_deadlines_are_not_open_ended(self) -> None:
+ task = WorkerTask()
+ task.task_started_at = 100.0
+ task.phase_started_at = 100.0
+ task.phase = "CHECKING"
+ self.assertIsNone(task.deadline_error(219.999))
+ self.assertEqual("E_METADATA_TIMEOUT", task.deadline_error(220.0))
+ task.phase = "DOWNLOADING"
+ self.assertIsNone(task.deadline_error(7_299.999))
+ self.assertEqual("E_DOWNLOAD_TIMEOUT", task.deadline_error(7_300.0))
+
+ def test_coordinator_cancel_commit_and_timeout_have_unique_terminal(self) -> None:
+ class FakeProcess:
+ def __init__(self) -> None:
+ self.done = False
+
+ def wait(self, _timeout=0):
+ return self.done
+
+ def close(self):
+ return None
+
+ class FakeJob:
+ def __init__(self, process: FakeProcess) -> None:
+ self.process = process
+
+ def terminate(self):
+ self.process.done = True
+
+ def close(self):
+ return None
+
+ nonce = "d" * 32
+
+ committed = WorkerTask(ROOT / "config.example.json")
+ committed.cancel_handle = create_event()
+ committed.commit_handle = create_event()
+ committed.secret_started = True
+ committed.task_nonce = nonce
+ set_event(committed.commit_handle)
+ try:
+ self.assertFalse(committed.cancel(nonce))
+ self.assertFalse(is_event_set(committed.cancel_handle))
+ self.assertFalse(committed.terminal)
+ finally:
+ committed.close()
+
+ canceled = WorkerTask(ROOT / "config.example.json")
+ canceled.cancel_handle = create_event()
+ canceled.commit_handle = create_event()
+ canceled.secret_started = True
+ canceled.task_nonce = nonce
+ canceled.process = FakeProcess()
+ canceled.job = FakeJob(canceled.process)
+ with mock.patch.object(native_host_module, "GRACEFUL_CANCEL_SECONDS", 0.01):
+ self.assertTrue(canceled.cancel(nonce))
+ self.assertTrue(canceled.terminal)
+ self.assertEqual("CANCELED", canceled.phase)
+ self.assertIsNone(canceled.formal_filename)
+ self.assertIsNone(canceled.mapping_filename)
+
+ timed_out = WorkerTask(ROOT / "config.example.json")
+ timed_out.cancel_handle = create_event()
+ timed_out.commit_handle = create_event()
+ timed_out.secret_started = True
+ timed_out.task_nonce = nonce
+ timed_out.process = FakeProcess()
+ timed_out.job = FakeJob(timed_out.process)
+ timed_out.task_started_at = 1.0
+ timed_out.phase_started_at = 1.0
+ timed_out.phase = "DOWNLOADING"
+ with mock.patch.object(native_host_module, "GRACEFUL_CANCEL_SECONDS", 0.01):
+ timed_out.poll()
+ self.assertTrue(timed_out.terminal)
+ self.assertEqual("FAILED", timed_out.phase)
+ self.assertEqual("E_DOWNLOAD_TIMEOUT", timed_out.error_code)
+ self.assertIsNone(timed_out.formal_filename)
+ self.assertIsNone(timed_out.mapping_filename)
+
+ def test_installer_onefile_receipt_transaction_and_file_registry_provider(self) -> None:
+ script = ROOT / "install_native_host.ps1"
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ config = root / "config.json"
+ destination = root / "destination"
+ destination.mkdir()
+ pinned_python = pathlib.Path(sys.executable)
+ source_file = root / "batch.json"
+ source_file.write_text("{}\n", encoding="ascii")
+ config.write_text(json.dumps({
+ "schema": 1,
+ "target": TARGET_BVID,
+ "canonical_url": CANONICAL_URL,
+ "ffmpeg": str(pinned_python),
+ "ffmpeg_sha256": digest(pinned_python),
+ "ffprobe": str(pinned_python),
+ "ffprobe_sha256": digest(pinned_python),
+ "bridge_python": str(pinned_python),
+ "bridge_python_sha256": digest(pinned_python),
+ "bridge_script": str(BRIDGE),
+ "bridge_script_sha256": "749FC486B0F42315BD463F11771FE2A7C71CAB53DD9AC2E411CE82E1175DFF13",
+ "batch_json": str(source_file),
+ "batch_json_sha256": digest(source_file),
+ "yt_dlp_executable": str(pinned_python),
+ "yt_dlp_executable_sha256": digest(pinned_python),
+ "destination": str(destination),
+ }), encoding="utf-8")
+ source_manifest = ROOT / "source-artifact-manifest.json"
+ build_root = root / "build"
+ build_root.mkdir()
+ host_path = build_root / "project-info-bili-auth-native-host.exe"
+ shutil.copyfile(sys.executable, host_path)
+ build_manifest = build_root / "build-artifact-manifest.json"
+ build_manifest.write_text(json.dumps({
+ "schema": 2,
+ "target": TARGET_BVID,
+ "extension_id": "oidmclckpdmpabbfedplkbdplmfcenbb",
+ "extension_build": "project-info-bili-auth-ingress/1.0.0+20260805.v002",
+ "host_build": "project-info-bili-auth-native-host/1.0.0+20260805.v002",
+ "packaging": "pyinstaller-onefile",
+ "pyinstaller_version": "6.15.0",
+ "yt_dlp_version": "2026.7.4",
+ "builder_python_sha256": digest(pathlib.Path(sys.executable)),
+ "pyinstaller_executable_bytes": 108469,
+ "pyinstaller_executable_sha256": "D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39",
+ "builder_provision_receipt_bytes": 2027,
+ "builder_provision_receipt_sha256": "B65F4184E8782394F2CC27C47CB8656C942E366FD80E8FA7A548CE5A3367BACF",
+ "build_script_sha256": digest(ROOT / "build_host.ps1"),
+ "source_artifact_manifest_bytes": source_manifest.stat().st_size,
+ "source_artifact_manifest_sha256": digest(source_manifest),
+ "dependency_artifact_manifest_bytes": (ROOT / "dependencies" / "dependency-artifact-manifest.json").stat().st_size,
+ "dependency_artifact_manifest_sha256": digest(ROOT / "dependencies" / "dependency-artifact-manifest.json"),
+ "yt_dlp_wheel_sha256": "F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266",
+ "archive_verification": {
+ "status": "PASS",
+ "method": "PyInstaller 6.15.0 CArchiveReader exact metadata bytes",
+ "required_modules": [
+ "bili_authenticated_extension.worker", "yt_dlp", "yt_dlp.downloader",
+ "yt_dlp.globals", "yt_dlp.plugins", "yt_dlp.version",
+ ],
+ "metadata_entry": "yt_dlp-2026.7.4.dist-info/METADATA",
+ "metadata_files": 7,
+ "metadata_type_codes": ["x"],
+ "metadata_tree_sha256": metadata_contract(source_manifest)["canonical_tree_sha256"],
+ },
+ "files": [{"path": host_path.name, "bytes": host_path.stat().st_size, "sha256": digest(host_path)}],
+ }), encoding="utf-8")
+ trust_root = root / "trust"
+ trust_root.mkdir()
+ source_approval = trust_root / "approved-source.json"
+ build_approval = trust_root / "approved-build.json"
+ write_source_approval(source_approval, source_manifest)
+ write_build_approval(build_approval, source_manifest, build_manifest, host_path)
+ install_root = root / "not-created"
+ registry_root = root / "registry"
+ command = [
+ "powershell.exe",
+ "-NoProfile",
+ "-File",
+ str(script),
+ "-ObservedExtensionId",
+ "oidmclckpdmpabbfedplkbdplmfcenbb",
+ "-HostExecutable",
+ str(host_path),
+ "-ConfigFile",
+ str(config),
+ "-InstallRoot",
+ str(install_root),
+ "-SourceArtifactManifest",
+ str(source_manifest),
+ "-BuildArtifactManifest",
+ str(build_manifest),
+ "-ApprovedSourceReceipt",
+ str(source_approval),
+ "-ApprovedBuildReceipt",
+ str(build_approval),
+ "-TestFileRegistryProvider",
+ "-TestRegistryRoot",
+ str(registry_root),
+ ]
+ result = subprocess.run(command, check=False, text=True, capture_output=True)
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertIn("VALIDATION_PASS_ONLY", result.stdout)
+ self.assertFalse(install_root.exists())
+ self.assertFalse((root / "registry" / "com.project_info.bili_auth_ingress").exists())
+
+ valid_build_manifest_text = build_manifest.read_text(encoding="utf-8")
+ for rejected_hash in (HISTORICAL_METADATA_TREE_SHA256, "0" * 64, "A" * 64):
+ tree_drift = json.loads(valid_build_manifest_text)
+ tree_drift["archive_verification"]["metadata_tree_sha256"] = rejected_hash
+ build_manifest.write_text(json.dumps(tree_drift), encoding="utf-8")
+ write_build_approval(build_approval, source_manifest, build_manifest, host_path)
+ tree_drift_result = subprocess.run(command, check=False, text=True, capture_output=True)
+ self.assertNotEqual(0, tree_drift_result.returncode)
+ self.assertIn("Build artifact receipt identity mismatch", tree_drift_result.stderr)
+ self.assertFalse(install_root.exists())
+ self.assertFalse((root / "registry" / "com.project_info.bili_auth_ingress").exists())
+ build_manifest.write_text(valid_build_manifest_text, encoding="utf-8")
+ write_build_approval(build_approval, source_manifest, build_manifest, host_path)
+ wrong = list(command)
+ wrong[wrong.index("oidmclckpdmpabbfedplkbdplmfcenbb")] = "a" * 32
+ result = subprocess.run(wrong, check=False, text=True, capture_output=True)
+ self.assertNotEqual(0, result.returncode)
+ self.assertFalse(install_root.exists())
+
+ extra_source = root / "installer-extra-source"
+ shutil.copytree(ROOT, extra_source)
+ (extra_source / "nested").mkdir()
+ (extra_source / "nested" / "unreviewed.py").write_text("raise SystemExit(99)\n", encoding="utf-8")
+ extra_command = [
+ str(extra_source / "install_native_host.ps1") if value == str(script)
+ else str(extra_source / "source-artifact-manifest.json") if value == str(source_manifest)
+ else value
+ for value in command
+ ]
+ extra_result = subprocess.run(extra_command, check=False, text=True, capture_output=True)
+ self.assertNotEqual(0, extra_result.returncode)
+ self.assertIn("Actual source tree file set", extra_result.stderr)
+ self.assertFalse(install_root.exists())
+
+ arbitrary_manifest = root / "source-artifact-manifest.json"
+ arbitrary_manifest.write_text(source_manifest.read_text(encoding="utf-8"), encoding="utf-8")
+ arbitrary = list(command)
+ arbitrary[arbitrary.index(str(source_manifest))] = str(arbitrary_manifest)
+ result = subprocess.run(arbitrary, check=False, text=True, capture_output=True)
+ self.assertNotEqual(0, result.returncode)
+
+ install_command = [
+ *command,
+ "-Install",
+ ]
+ result = subprocess.run(install_command, check=False, text=True, capture_output=True)
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertTrue((install_root / host_path.name).is_file())
+ registry_key = registry_root / "com.project_info.bili_auth_ingress"
+ self.assertEqual(
+ str(install_root / "native-host-manifest.json"),
+ (registry_key / "default.value").read_text(encoding="utf-8"),
+ )
+ repeated = subprocess.run(install_command, check=False, text=True, capture_output=True)
+ self.assertNotEqual(0, repeated.returncode)
+
+ for point in (
+ "after-root",
+ "after-payload",
+ "after-config",
+ "after-manifest",
+ "after-registry-key",
+ "after-registry-value",
+ ):
+ case_install = root / f"install-{point}"
+ case_registry = root / f"registry-{point}"
+ case_command = list(command)
+ case_command[case_command.index(str(install_root))] = str(case_install)
+ case_command[case_command.index(str(registry_root))] = str(case_registry)
+ case_command.extend([
+ "-Install",
+ "-InjectFailure",
+ point,
+ ])
+ failed = subprocess.run(case_command, check=False, text=True, capture_output=True)
+ self.assertNotEqual(0, failed.returncode, point)
+ self.assertFalse(case_install.exists(), point)
+ self.assertFalse((case_registry / "com.project_info.bili_auth_ingress").exists(), point)
+
+ host_path.write_bytes(host_path.read_bytes() + b"tamper")
+ tampered = subprocess.run(command, check=False, text=True, capture_output=True)
+ self.assertNotEqual(0, tampered.returncode)
+
+ altered_source = root / "altered-source"
+ shutil.copytree(ROOT, altered_source)
+ with (altered_source / "worker.py").open("ab") as target:
+ target.write(b"\n# synthetic source drift\n")
+ altered_manifest = refresh_source_manifest(altered_source)
+ altered_build = root / "altered-build"
+ altered_build.mkdir()
+ altered_host = altered_build / host_path.name
+ shutil.copyfile(sys.executable, altered_host)
+ altered_build_manifest = altered_build / build_manifest.name
+ altered_build_manifest.write_text(json.dumps({
+ "schema": 2,
+ "target": TARGET_BVID,
+ "extension_id": "oidmclckpdmpabbfedplkbdplmfcenbb",
+ "extension_build": "project-info-bili-auth-ingress/1.0.0+20260805.v002",
+ "host_build": "project-info-bili-auth-native-host/1.0.0+20260805.v002",
+ "packaging": "pyinstaller-onefile",
+ "pyinstaller_version": "6.15.0",
+ "yt_dlp_version": "2026.7.4",
+ "builder_python_sha256": digest(pathlib.Path(sys.executable)),
+ "pyinstaller_executable_bytes": 108469,
+ "pyinstaller_executable_sha256": "D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39",
+ "builder_provision_receipt_bytes": 2027,
+ "builder_provision_receipt_sha256": "B65F4184E8782394F2CC27C47CB8656C942E366FD80E8FA7A548CE5A3367BACF",
+ "build_script_sha256": digest(altered_source / "build_host.ps1"),
+ "source_artifact_manifest_bytes": altered_manifest.stat().st_size,
+ "source_artifact_manifest_sha256": digest(altered_manifest),
+ "dependency_artifact_manifest_bytes": (altered_source / "dependencies" / "dependency-artifact-manifest.json").stat().st_size,
+ "dependency_artifact_manifest_sha256": digest(altered_source / "dependencies" / "dependency-artifact-manifest.json"),
+ "yt_dlp_wheel_sha256": "F11F2B11D5A8AC4059F9BDF29FA4407DC7C6BB00C5097E95CA22A7A9DB518266",
+ "archive_verification": {
+ "status": "PASS",
+ "method": "PyInstaller 6.15.0 CArchiveReader exact metadata bytes",
+ "required_modules": [
+ "bili_authenticated_extension.worker", "yt_dlp", "yt_dlp.downloader",
+ "yt_dlp.globals", "yt_dlp.plugins", "yt_dlp.version",
+ ],
+ "metadata_entry": "yt_dlp-2026.7.4.dist-info/METADATA",
+ "metadata_files": 7,
+ "metadata_type_codes": ["x"],
+ "metadata_tree_sha256": metadata_contract(altered_manifest)["canonical_tree_sha256"],
+ },
+ "files": [{"path": altered_host.name, "bytes": altered_host.stat().st_size, "sha256": digest(altered_host)}],
+ }), encoding="utf-8")
+ altered_build_approval = trust_root / "altered-build-approved.json"
+ write_build_approval(altered_build_approval, altered_manifest, altered_build_manifest, altered_host)
+ altered_install = root / "altered-install"
+ altered_registry = root / "altered-registry"
+ altered_command = list(command)
+ replacements = {
+ str(script): str(altered_source / "install_native_host.ps1"),
+ str(host_path): str(altered_host),
+ str(source_manifest): str(altered_manifest),
+ str(build_manifest): str(altered_build_manifest),
+ str(build_approval): str(altered_build_approval),
+ str(install_root): str(altered_install),
+ str(registry_root): str(altered_registry),
+ }
+ altered_command = [replacements.get(value, value) for value in altered_command]
+ altered_command.append("-Install")
+ altered_result = subprocess.run(altered_command, check=False, text=True, capture_output=True)
+ self.assertNotEqual(0, altered_result.returncode)
+ self.assertIn("approved source receipt", altered_result.stderr.lower())
+ self.assertFalse(altered_install.exists())
+ self.assertFalse((altered_registry / "com.project_info.bili_auth_ingress").exists())
+
+ def test_build_script_rejects_unpinned_or_missing_builder_before_output(self) -> None:
+ script = ROOT / "build_host.ps1"
+ source = script.read_text(encoding="utf-8")
+ self.assertIn("--onefile", source)
+ self.assertNotIn("--onedir", source)
+ with tempfile.TemporaryDirectory() as temporary:
+ temporary_root = pathlib.Path(temporary)
+ output_root = temporary_root / "not-created"
+ trust_root = temporary_root / "trust"
+ trust_root.mkdir()
+ source_approval = trust_root / "approved-source.json"
+ write_source_approval(source_approval, ROOT / "source-artifact-manifest.json")
+ result = subprocess.run(
+ [
+ "powershell.exe",
+ "-NoProfile",
+ "-File",
+ str(script),
+ "-Python",
+ sys.executable,
+ "-PyInstallerExecutable",
+ sys.executable,
+ "-ApprovedBuilderReceipt",
+ str(BUILDER_RECEIPT),
+ "-OutputRoot",
+ str(output_root),
+ "-ApprovedSourceReceipt",
+ str(source_approval),
+ "-SourceArtifactManifest",
+ str(ROOT / "source-artifact-manifest.json"),
+ ],
+ check=False,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ self.assertNotEqual(0, result.returncode)
+ self.assertIn("Pinned builder executables", result.stderr)
+ self.assertFalse(output_root.exists())
+
+ copied_source = temporary_root / "copied-source"
+ shutil.copytree(ROOT, copied_source)
+ with (copied_source / "worker.py").open("ab") as target:
+ target.write(b"\n# tampered\n")
+ tampered_output = temporary_root / "tampered-source-output"
+ tampered_build = subprocess.run(
+ [
+ "powershell.exe",
+ "-NoProfile",
+ "-File",
+ str(copied_source / "build_host.ps1"),
+ "-Python",
+ str(BUILDER_PYTHON),
+ "-PyInstallerExecutable",
+ str(PYINSTALLER_EXE),
+ "-ApprovedBuilderReceipt",
+ str(BUILDER_RECEIPT),
+ "-OutputRoot",
+ str(tampered_output),
+ "-ApprovedSourceReceipt",
+ str(source_approval),
+ "-SourceArtifactManifest",
+ str(copied_source / "source-artifact-manifest.json"),
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ )
+ self.assertNotEqual(0, tampered_build.returncode)
+ self.assertIn("Source artifact hash mismatch", tampered_build.stderr)
+ self.assertFalse(tampered_output.exists())
+
+ for case_name, extra_path in (
+ ("extra-root", pathlib.Path("yt_dlp.py")),
+ ("extra-nested", pathlib.Path("nested") / "unreviewed.py"),
+ ):
+ extra_source = temporary_root / case_name
+ shutil.copytree(ROOT, extra_source)
+ (extra_source / extra_path).parent.mkdir(parents=True, exist_ok=True)
+ (extra_source / extra_path).write_text("raise SystemExit(91)\n", encoding="utf-8")
+ extra_approval = trust_root / f"{case_name}-approved.json"
+ write_source_approval(extra_approval, extra_source / "source-artifact-manifest.json")
+ extra_output = temporary_root / f"{case_name}-output"
+ extra_result = subprocess.run(
+ [
+ "powershell.exe", "-NoProfile", "-File", str(extra_source / "build_host.ps1"),
+ "-Python", str(BUILDER_PYTHON),
+ "-PyInstallerExecutable", str(PYINSTALLER_EXE),
+ "-ApprovedBuilderReceipt", str(BUILDER_RECEIPT),
+ "-OutputRoot", str(extra_output),
+ "-ApprovedSourceReceipt", str(extra_approval),
+ "-SourceArtifactManifest", str(extra_source / "source-artifact-manifest.json"),
+ "-ValidateOnly",
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ )
+ self.assertNotEqual(0, extra_result.returncode, case_name)
+ self.assertIn("Actual source tree file set", extra_result.stderr, case_name)
+ self.assertFalse(extra_output.exists(), case_name)
+
+ reparse_source = temporary_root / "reparse-source"
+ shutil.copytree(ROOT, reparse_source)
+ reparse_target = temporary_root / "reparse-target"
+ reparse_target.mkdir()
+ (reparse_target / "unreviewed.py").write_text("raise SystemExit(92)\n", encoding="utf-8")
+ junction = reparse_source / "linked-source"
+ junction_environment = dict(
+ os.environ,
+ TEST_JUNCTION_LINK=str(junction),
+ TEST_JUNCTION_TARGET=str(reparse_target),
+ )
+ junction_result = subprocess.run(
+ [
+ "powershell.exe", "-NoProfile", "-Command",
+ "New-Item -ItemType Junction -Path $env:TEST_JUNCTION_LINK -Target $env:TEST_JUNCTION_TARGET | Out-Null",
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ env=junction_environment,
+ )
+ self.assertEqual(0, junction_result.returncode, junction_result.stderr)
+ reparse_approval = trust_root / "reparse-source-approved.json"
+ write_source_approval(reparse_approval, reparse_source / "source-artifact-manifest.json")
+ reparse_output = temporary_root / "reparse-output"
+ reparse_result = subprocess.run(
+ [
+ "powershell.exe", "-NoProfile", "-File", str(reparse_source / "build_host.ps1"),
+ "-Python", str(BUILDER_PYTHON),
+ "-PyInstallerExecutable", str(PYINSTALLER_EXE),
+ "-ApprovedBuilderReceipt", str(BUILDER_RECEIPT),
+ "-OutputRoot", str(reparse_output),
+ "-ApprovedSourceReceipt", str(reparse_approval),
+ "-SourceArtifactManifest", str(reparse_source / "source-artifact-manifest.json"),
+ "-ValidateOnly",
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ )
+ self.assertNotEqual(0, reparse_result.returncode)
+ self.assertIn("reparse path", reparse_result.stderr)
+ self.assertFalse(reparse_output.exists())
+
+ fake_site = temporary_root / "fake-site"
+ package = fake_site / "PyInstaller"
+ metadata = fake_site / "PyInstaller-6.15.0.dist-info"
+ package.mkdir(parents=True)
+ metadata.mkdir()
+ (metadata / "METADATA").write_text(
+ "Metadata-Version: 2.1\nName: PyInstaller\nVersion: 6.15.0\n",
+ encoding="utf-8",
+ )
+ (package / "__init__.py").write_text("", encoding="utf-8")
+ (package / "__main__.py").write_text(
+ "import os, pathlib, sys\n"
+ "args=sys.argv[1:]\n"
+ "if '--onefile' not in args or '--onedir' in args: raise SystemExit(8)\n"
+ "if args[args.index('--collect-all')+1] != 'yt_dlp': raise SystemExit(10)\n"
+ "if args[args.index('--copy-metadata')+1] != 'yt-dlp': raise SystemExit(11)\n"
+ "if '--paths' not in args: raise SystemExit(12)\n"
+ "def value(flag): return args[args.index(flag)+1]\n"
+ "dist=pathlib.Path(value('--distpath')); work=pathlib.Path(value('--workpath')); spec=pathlib.Path(value('--specpath'))\n"
+ "dist.mkdir(parents=True,exist_ok=True); work.mkdir(parents=True,exist_ok=True); spec.mkdir(parents=True,exist_ok=True)\n"
+ "(dist/(value('--name')+'.exe')).write_bytes(b'synthetic-onefile')\n"
+ "raise SystemExit(9 if os.environ.get('FAKE_PYINSTALLER_FAIL') else 0)\n",
+ encoding="utf-8",
+ )
+ archive_package = package / "archive"
+ archive_package.mkdir(parents=True)
+ (archive_package / "__init__.py").write_text("", encoding="utf-8")
+ (archive_package / "readers.py").write_text(
+ "import os, pathlib\n"
+ "class Embedded:\n"
+ " def __init__(self):\n"
+ " names={'bili_authenticated_extension.worker','yt_dlp','yt_dlp.downloader','yt_dlp.globals','yt_dlp.plugins','yt_dlp.version'}\n"
+ " if os.environ.get('FAKE_ARCHIVE_OMIT_YTDLP'): names.discard('yt_dlp.downloader')\n"
+ " self.toc={name:None for name in names}\n"
+ "class CArchiveReader:\n"
+ " def __init__(self, executable):\n"
+ " root=pathlib.Path(executable).parent/'.deps'/'yt_dlp-2026.7.4.dist-info'\n"
+ " payloads={p.relative_to(root).as_posix():p.read_bytes() for p in root.rglob('*') if p.is_file()}\n"
+ " if os.environ.get('FAKE_ARCHIVE_OMIT_METADATA'): payloads={}\n"
+ " if os.environ.get('FAKE_ARCHIVE_INCOMPLETE_METADATA'): payloads.pop('WHEEL',None)\n"
+ " if os.environ.get('FAKE_ARCHIVE_WRONG_VERSION') and 'METADATA' in payloads:\n"
+ " payloads['METADATA']=payloads['METADATA'].replace(b'\\nVersion: 2026.7.4\\n',b'\\nVersion: 2026.7.3\\n',1)\n"
+ " if os.environ.get('FAKE_ARCHIVE_WRONG_NAME') and 'METADATA' in payloads:\n"
+ " payloads['METADATA']=payloads['METADATA'].replace(b'\\nName: yt-dlp\\n',b'\\nName: unrelated\\n',1)\n"
+ " if os.environ.get('FAKE_ARCHIVE_WRONG_BYTES') and 'entry_points.txt' in payloads:\n"
+ " payloads['entry_points.txt']+=b'\\n'\n"
+ " archive_root='yt_dlp-2026.7.4.dist-info'\n"
+ " if os.environ.get('FAKE_ARCHIVE_UNEXPECTED_PATH'): archive_root='nested/'+archive_root\n"
+ " if os.environ.get('FAKE_ARCHIVE_SUBSTITUTE'): archive_root='unrelated-1.0.dist-info'\n"
+ " self.payloads={}\n"
+ " self.toc={'PYZ.pyz':(0,0,0,0,'z')}\n"
+ " metadata_type=os.environ.get('FAKE_ARCHIVE_METADATA_TYPE','x')\n"
+ " for relative,payload in payloads.items():\n"
+ " raw=(archive_root+'/'+relative).replace('/','\\\\')\n"
+ " self.payloads[raw]=payload; self.toc[raw]=(0,0,0,0,metadata_type)\n"
+ " if os.environ.get('FAKE_ARCHIVE_DUPLICATE_METADATA') and self.payloads:\n"
+ " raw=next(iter(self.payloads)); duplicate=raw.replace('\\\\','/')\n"
+ " self.payloads[duplicate]=self.payloads[raw]; self.toc[duplicate]=(0,0,0,0,'x')\n"
+ " def extract(self, name): return self.payloads[name]\n"
+ " def open_embedded_archive(self, name): return Embedded()\n",
+ encoding="utf-8",
+ )
+ injection_marker = temporary_root / "pythonpath-injection-executed.txt"
+ (package / "__init__.py").write_text(
+ f"import pathlib\npathlib.Path({str(injection_marker)!r}).write_text('PyInstaller imported')\n",
+ encoding="utf-8",
+ )
+ fake_pip = fake_site / "pip"
+ fake_pip.mkdir()
+ (fake_pip / "__init__.py").write_text("", encoding="utf-8")
+ (fake_pip / "__main__.py").write_text(
+ f"import pathlib\npathlib.Path({str(injection_marker)!r}).write_text('pip executed')\nraise SystemExit(95)\n",
+ encoding="utf-8",
+ )
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = str(fake_site)
+ production_validate_output = temporary_root / "production-validate-output"
+ production_validate_command = [
+ "powershell.exe",
+ "-NoProfile",
+ "-File",
+ str(script),
+ "-Python",
+ str(BUILDER_PYTHON),
+ "-PyInstallerExecutable",
+ str(PYINSTALLER_EXE),
+ "-ApprovedBuilderReceipt",
+ str(BUILDER_RECEIPT),
+ "-OutputRoot",
+ str(production_validate_output),
+ "-ApprovedSourceReceipt",
+ str(source_approval),
+ "-SourceArtifactManifest",
+ str(ROOT / "source-artifact-manifest.json"),
+ "-ValidateOnly",
+ ]
+ validated = subprocess.run(
+ production_validate_command,
+ check=False,
+ text=True,
+ capture_output=True,
+ env=environment,
+ )
+ self.assertEqual(0, validated.returncode, validated.stderr)
+ self.assertIn("VALIDATION_PASS_ONLY", validated.stdout)
+ self.assertFalse(production_validate_output.exists())
+ self.assertFalse(injection_marker.exists())
+ shutil.rmtree(fake_pip)
+
+ fake_pyinstaller_exe = temporary_root / "fake-pyinstaller.exe"
+ fake_pyinstaller_exe.write_bytes(b"synthetic-pyinstaller-executable")
+ synthetic_builder_receipt = temporary_root / "synthetic-builder-provision.md"
+ synthetic_builder_receipt.write_text(
+ "# Synthetic test-only builder provision\n\n"
+ "request_handoff: HANDOFF-INFOADMIN-MGADMIN-BILI-PYINSTALLER-6-15-0-OFFLINE-ENV-PROVISION-20260806-001\n"
+ "scope: project-info 私有、隔离、可离线复用\n"
+ f"environment_python: `{BUILDER_PYTHON}`\n"
+ f"environment_python_bytes_sha256: `{BUILDER_PYTHON.stat().st_size}/{digest(BUILDER_PYTHON).lower()}`\n"
+ f"pyinstaller_executable: `{fake_pyinstaller_exe}`\n"
+ f"pyinstaller_executable_bytes_sha256: `{fake_pyinstaller_exe.stat().st_size}/{digest(fake_pyinstaller_exe).lower()}`\n",
+ encoding="utf-8",
+ )
+ seam_source = temporary_root / "test-only-builder-seam-source"
+ shutil.copytree(ROOT, seam_source)
+ seam_script = seam_source / "build_host.ps1"
+ seam_text = seam_script.read_text(encoding="utf-8")
+ replacements = {
+ "$expectedBuilderReceiptBytes = 2027": f"$expectedBuilderReceiptBytes = {synthetic_builder_receipt.stat().st_size}",
+ "$expectedBuilderReceiptSha256 = 'B65F4184E8782394F2CC27C47CB8656C942E366FD80E8FA7A548CE5A3367BACF'": f"$expectedBuilderReceiptSha256 = '{digest(synthetic_builder_receipt)}'",
+ "$expectedBuilderPythonBytes = 262144": f"$expectedBuilderPythonBytes = {BUILDER_PYTHON.stat().st_size}",
+ "$expectedBuilderPythonSha256 = '5912D0884B23C0343983A864C6064242391E2265536F50B88624857E353882C9'": f"$expectedBuilderPythonSha256 = '{digest(BUILDER_PYTHON)}'",
+ "$expectedPyInstallerBytes = 108469": f"$expectedPyInstallerBytes = {fake_pyinstaller_exe.stat().st_size}",
+ "$expectedPyInstallerSha256 = 'D5DC4427C5E5D417457DAE6FD8B50EF2AFA0A4CE5FDFD5767AB20C5B56555C39'": f"$expectedPyInstallerSha256 = '{digest(fake_pyinstaller_exe)}'",
+ }
+ for before, after in replacements.items():
+ self.assertIn(before, seam_text)
+ seam_text = seam_text.replace(before, after)
+ seam_text = seam_text.replace("-I -S -B", "-S -B").replace("-I -B", "-B")
+ seam_marker = " $pyInstallerVersion = & $resolvedPython -B"
+ self.assertIn(seam_marker, seam_text)
+ fake_site_ps = str(fake_site).replace("'", "''")
+ seam_text = seam_text.replace(
+ seam_marker,
+ f" $env:PYTHONPATH = '{fake_site_ps}'\n{seam_marker}",
+ 1,
+ )
+ seam_script.write_text(seam_text, encoding="utf-8")
+ seam_manifest = refresh_source_manifest(seam_source)
+ seam_approval = trust_root / "test-only-seam-source-approved.json"
+ write_source_approval(seam_approval, seam_manifest)
+ built = temporary_root / "built"
+ build_command = [
+ "powershell.exe", "-NoProfile", "-File", str(seam_script),
+ "-Python", str(BUILDER_PYTHON),
+ "-PyInstallerExecutable", str(fake_pyinstaller_exe),
+ "-ApprovedBuilderReceipt", str(synthetic_builder_receipt),
+ "-OutputRoot", str(built),
+ "-ApprovedSourceReceipt", str(seam_approval),
+ "-SourceArtifactManifest", str(seam_manifest),
+ ]
+
+ result = subprocess.run(build_command, check=False, text=True, capture_output=True, env=environment)
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertEqual(
+ {"project-info-bili-auth-native-host.exe", "build-artifact-manifest.json"},
+ {item.name for item in built.iterdir()},
+ )
+ receipt = json.loads((built / "build-artifact-manifest.json").read_text(encoding="utf-8"))
+ self.assertEqual("pyinstaller-onefile", receipt["packaging"])
+ self.assertEqual("2026.7.4", receipt["yt_dlp_version"])
+ self.assertEqual("PASS", receipt["archive_verification"]["status"])
+ self.assertEqual(["x"], receipt["archive_verification"]["metadata_type_codes"])
+ self.assertEqual(
+ digest(seam_source / "dependencies" / "dependency-artifact-manifest.json"),
+ receipt["dependency_artifact_manifest_sha256"],
+ )
+ self.assertEqual(1, len(receipt["files"]))
+ self.assertEqual("project-info-bili-auth-native-host.exe", receipt["files"][0]["path"])
+
+ promoted_output = temporary_root / "promoted-b-build"
+ promoted_command = list(build_command)
+ promoted_command[promoted_command.index(str(built))] = str(promoted_output)
+ promoted_environment = dict(environment, FAKE_ARCHIVE_METADATA_TYPE="b")
+ promoted = subprocess.run(
+ promoted_command,
+ check=False,
+ text=True,
+ capture_output=True,
+ env=promoted_environment,
+ )
+ self.assertEqual(0, promoted.returncode, promoted.stderr)
+ promoted_receipt = json.loads(
+ (promoted_output / "build-artifact-manifest.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual(["b"], promoted_receipt["archive_verification"]["metadata_type_codes"])
+
+ for rejected_type in ("Z", "a", "d", "l", "m", "n", "o", "s", "z"):
+ rejected_output = temporary_root / f"rejected-type-{ord(rejected_type)}"
+ rejected_command = list(build_command)
+ rejected_command[rejected_command.index(str(built))] = str(rejected_output)
+ rejected_environment = dict(environment, FAKE_ARCHIVE_METADATA_TYPE=rejected_type)
+ rejected = subprocess.run(
+ rejected_command,
+ check=False,
+ text=True,
+ capture_output=True,
+ env=rejected_environment,
+ )
+ self.assertNotEqual(0, rejected.returncode, rejected_type)
+ self.assertIn("E_ARCHIVE_METADATA_TYPE", rejected.stderr, rejected_type)
+ self.assertFalse(rejected_output.exists(), rejected_type)
+
+ unbound_b_output = temporary_root / "unbound-b-metadata"
+ unbound_b_command = list(build_command)
+ unbound_b_command[unbound_b_command.index(str(built))] = str(unbound_b_output)
+ unbound_b_environment = dict(
+ environment,
+ FAKE_ARCHIVE_METADATA_TYPE="b",
+ FAKE_ARCHIVE_UNEXPECTED_PATH="1",
+ )
+ unbound_b = subprocess.run(
+ unbound_b_command,
+ check=False,
+ text=True,
+ capture_output=True,
+ env=unbound_b_environment,
+ )
+ self.assertNotEqual(0, unbound_b.returncode)
+ self.assertIn("E_ARCHIVE_METADATA_DUPLICATE_OR_PATH", unbound_b.stderr)
+ self.assertFalse(unbound_b_output.exists())
+
+ resigned_source = temporary_root / "resigned-source"
+ shutil.copytree(ROOT, resigned_source)
+ with (resigned_source / "worker.py").open("ab") as target:
+ target.write(b"\n# synthetic source drift\n")
+ resigned_manifest = refresh_source_manifest(resigned_source)
+ resigned_output = temporary_root / "resigned-output"
+ resigned_command = [
+ "powershell.exe", "-NoProfile", "-File", str(resigned_source / "build_host.ps1"),
+ "-Python", str(BUILDER_PYTHON),
+ "-PyInstallerExecutable", str(PYINSTALLER_EXE),
+ "-ApprovedBuilderReceipt", str(BUILDER_RECEIPT),
+ "-OutputRoot", str(resigned_output),
+ "-ApprovedSourceReceipt", str(source_approval),
+ "-SourceArtifactManifest", str(resigned_manifest),
+ ]
+ resigned = subprocess.run(resigned_command, check=False, text=True, capture_output=True, env=environment)
+ self.assertNotEqual(0, resigned.returncode)
+ self.assertIn("approved source receipt", resigned.stderr.lower())
+ self.assertFalse(resigned_output.exists())
+
+ failed_output = temporary_root / "failed-build"
+ failed_command = list(build_command)
+ failed_command[failed_command.index(str(built))] = str(failed_output)
+ failed_environment = dict(environment, FAKE_PYINSTALLER_FAIL="1")
+ result = subprocess.run(failed_command, check=False, text=True, capture_output=True, env=failed_environment)
+ self.assertNotEqual(0, result.returncode)
+ self.assertFalse(failed_output.exists())
+
+ for variable, expected_error in (
+ ("FAKE_ARCHIVE_OMIT_YTDLP", "E_ARCHIVE_MODULE_MISSING"),
+ ("FAKE_ARCHIVE_OMIT_METADATA", "E_ARCHIVE_METADATA_MISSING"),
+ ("FAKE_ARCHIVE_DUPLICATE_METADATA", "E_ARCHIVE_DUPLICATE_PATH"),
+ ("FAKE_ARCHIVE_WRONG_VERSION", "E_ARCHIVE_METADATA_IDENTITY"),
+ ("FAKE_ARCHIVE_WRONG_NAME", "E_ARCHIVE_METADATA_IDENTITY"),
+ ("FAKE_ARCHIVE_WRONG_BYTES", "E_ARCHIVE_METADATA_BYTES"),
+ ("FAKE_ARCHIVE_INCOMPLETE_METADATA", "E_ARCHIVE_METADATA_FILE_SET"),
+ ("FAKE_ARCHIVE_UNEXPECTED_PATH", "E_ARCHIVE_METADATA_DUPLICATE_OR_PATH"),
+ ("FAKE_ARCHIVE_SUBSTITUTE", "E_ARCHIVE_METADATA_DUPLICATE_OR_PATH"),
+ ):
+ archive_output = temporary_root / variable.lower()
+ archive_command = list(build_command)
+ archive_command[archive_command.index(str(built))] = str(archive_output)
+ archive_environment = dict(environment, **{variable: "1"})
+ archived = subprocess.run(
+ archive_command,
+ check=False,
+ text=True,
+ capture_output=True,
+ env=archive_environment,
+ )
+ self.assertNotEqual(0, archived.returncode)
+ self.assertIn(expected_error, archived.stderr)
+ self.assertFalse(archive_output.exists())
+
+ incomplete_source = temporary_root / "incomplete-dependency-source"
+ shutil.copytree(ROOT, incomplete_source)
+ missing_wheel = incomplete_source / "dependencies" / "yt_dlp-2026.7.4-py3-none-any.whl"
+ missing_wheel.unlink()
+ incomplete_manifest_path = incomplete_source / "source-artifact-manifest.json"
+ incomplete_manifest = json.loads(incomplete_manifest_path.read_text(encoding="utf-8"))
+ incomplete_manifest["files"] = [
+ item for item in incomplete_manifest["files"] if item["path"] != "dependencies/yt_dlp-2026.7.4-py3-none-any.whl"
+ ]
+ incomplete_manifest_path.write_text(
+ json.dumps(incomplete_manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ incomplete_approval = trust_root / "incomplete-source-approved.json"
+ write_source_approval(incomplete_approval, incomplete_manifest_path)
+ incomplete_output = temporary_root / "incomplete-output"
+ incomplete_result = subprocess.run(
+ [
+ "powershell.exe", "-NoProfile", "-File", str(incomplete_source / "build_host.ps1"),
+ "-Python", str(BUILDER_PYTHON),
+ "-PyInstallerExecutable", str(PYINSTALLER_EXE),
+ "-ApprovedBuilderReceipt", str(BUILDER_RECEIPT),
+ "-OutputRoot", str(incomplete_output),
+ "-ApprovedSourceReceipt", str(incomplete_approval),
+ "-SourceArtifactManifest", str(incomplete_manifest_path),
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ env=environment,
+ )
+ self.assertNotEqual(0, incomplete_result.returncode)
+ self.assertIn("Actual source tree file set", incomplete_result.stderr)
+ self.assertFalse(incomplete_output.exists())
+
+ tampered_dependency_source = temporary_root / "tampered-dependency-source"
+ shutil.copytree(ROOT, tampered_dependency_source)
+ tampered_wheel = tampered_dependency_source / "dependencies" / "yt_dlp-2026.7.4-py3-none-any.whl"
+ tampered_wheel.write_bytes(tampered_wheel.read_bytes() + b"tamper")
+ tampered_dependency_manifest = refresh_source_manifest(tampered_dependency_source)
+ tampered_dependency_approval = trust_root / "tampered-dependency-approved.json"
+ write_source_approval(tampered_dependency_approval, tampered_dependency_manifest)
+ tampered_dependency_output = temporary_root / "tampered-dependency-output"
+ tampered_dependency_result = subprocess.run(
+ [
+ "powershell.exe", "-NoProfile", "-File", str(tampered_dependency_source / "build_host.ps1"),
+ "-Python", str(BUILDER_PYTHON),
+ "-PyInstallerExecutable", str(PYINSTALLER_EXE),
+ "-ApprovedBuilderReceipt", str(BUILDER_RECEIPT),
+ "-OutputRoot", str(tampered_dependency_output),
+ "-ApprovedSourceReceipt", str(tampered_dependency_approval),
+ "-SourceArtifactManifest", str(tampered_dependency_manifest),
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ env=environment,
+ )
+ self.assertNotEqual(0, tampered_dependency_result.returncode)
+ self.assertIn("Dependency wheelhouse file set or hash mismatch", tampered_dependency_result.stderr)
+ self.assertFalse(tampered_dependency_output.exists())
+
+ def test_real_carchive_writer_accepts_x_and_windows_promoted_b_metadata(self) -> None:
+ build_source = (ROOT / "build_host.ps1").read_text(encoding="utf-8")
+ start_marker = "$archiveProbeScript = @'\n"
+ end_marker = "\n'@\n $archiveProbeJson ="
+ self.assertIn(start_marker, build_source)
+ self.assertIn(end_marker, build_source)
+ probe = build_source.split(start_marker, 1)[1].split(end_marker, 1)[0]
+ wheel_path = ROOT / "dependencies" / "yt_dlp-2026.7.4-py3-none-any.whl"
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ dependency_root = root / ".deps"
+ metadata_root = dependency_root / "yt_dlp-2026.7.4.dist-info"
+ installed = subprocess.run(
+ [
+ str(BUILDER_PYTHON), "-I", "-B", "-m", "pip", "install",
+ "--no-index", "--find-links", str(wheel_path.parent), "--no-deps",
+ "--no-compile", "--target", str(dependency_root), "yt-dlp==2026.7.4",
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ env=dict(
+ os.environ,
+ SOURCE_DATE_EPOCH=str(metadata_contract(ROOT / "source-artifact-manifest.json")["source_date_epoch"]),
+ ),
+ )
+ self.assertEqual(0, installed.returncode, installed.stderr)
+ self.assertTrue(metadata_root.is_dir())
+ self.assertTrue(os.access(metadata_root / "METADATA", os.X_OK))
+
+ writer = (
+ "import pathlib,sys\n"
+ "from PyInstaller.archive.writers import CArchiveWriter\n"
+ "out=pathlib.Path(sys.argv[1]); metadata=pathlib.Path(sys.argv[2]); kind=sys.argv[3]\n"
+ "dummy=out.with_suffix('.payload'); dummy.write_bytes(b'anchor')\n"
+ "modules={'bili_authenticated_extension.worker','yt_dlp','yt_dlp.downloader','yt_dlp.globals','yt_dlp.plugins','yt_dlp.version'}\n"
+ "entries=[(name,str(dummy),True,'x') for name in sorted(modules)]\n"
+ "entries += [('yt_dlp-2026.7.4.dist-info/'+p.relative_to(metadata).as_posix(),str(p),True,kind) for p in sorted(metadata.rglob('*')) if p.is_file()]\n"
+ "CArchiveWriter(str(out),entries,'python311.dll')\n"
+ )
+ for metadata_type in ("x", "b"):
+ archive_path = root / f"metadata-{metadata_type}.pkg"
+ written = subprocess.run(
+ [
+ str(BUILDER_PYTHON), "-I", "-B", "-c", writer,
+ str(archive_path), str(metadata_root), metadata_type,
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ )
+ self.assertEqual(0, written.returncode, written.stderr)
+ checked = subprocess.run(
+ [
+ str(BUILDER_PYTHON), "-I", "-B", "-c", probe,
+ str(archive_path), str(dependency_root),
+ str(ROOT / "source-artifact-manifest.json"),
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ )
+ self.assertEqual(0, checked.returncode, checked.stderr)
+ result = json.loads(checked.stdout)
+ self.assertEqual("PASS", result["status"], result)
+ self.assertEqual([metadata_type], result["metadata_type_codes"], result)
+
+ def test_dependency_tree_metadata_discovery_is_exact_and_injection_safe(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ temporary_root = pathlib.Path(temporary)
+ trust_root = temporary_root / "trust"
+ trust_root.mkdir()
+
+ def run_modified(name: str, modify, environment=None):
+ source = temporary_root / f"source-{name}"
+ shutil.copytree(ROOT, source)
+ script = source / "build_host.ps1"
+ text = script.read_text(encoding="utf-8")
+ updated = modify(text)
+ self.assertNotEqual(text, updated)
+ script.write_text(updated, encoding="utf-8")
+ manifest = refresh_source_manifest(source)
+ approval = trust_root / f"approved-{name}.json"
+ write_source_approval(approval, manifest)
+ output = temporary_root / f"output-{name}"
+ result = subprocess.run(
+ [
+ "powershell.exe", "-NoProfile", "-File", str(script),
+ "-Python", str(BUILDER_PYTHON),
+ "-PyInstallerExecutable", str(PYINSTALLER_EXE),
+ "-ApprovedBuilderReceipt", str(BUILDER_RECEIPT),
+ "-OutputRoot", str(output),
+ "-ApprovedSourceReceipt", str(approval),
+ "-SourceArtifactManifest", str(manifest),
+ ],
+ check=False,
+ text=True,
+ capture_output=True,
+ env=environment,
+ )
+ self.assertNotEqual(0, result.returncode)
+ self.assertFalse(output.exists())
+ self.assertFalse((output / "build-artifact-manifest.json").exists())
+ return result
+
+ post_install_validation_anchor = (
+ " $dependencyRootItem = Get-Item -LiteralPath $dependencyRoot"
+ )
+
+ def inject_after_install(statement: str):
+ def modifier(text: str) -> str:
+ self.assertIn(post_install_validation_anchor, text)
+ return text.replace(
+ post_install_validation_anchor,
+ f" {statement}\n{post_install_validation_anchor}",
+ 1,
+ )
+ return modifier
+
+ failures = (
+ (
+ "missing-metadata",
+ inject_after_install(
+ "Remove-Item -LiteralPath (Join-Path $dependencyRoot 'yt_dlp-2026.7.4.dist-info\\METADATA') -Force"
+ ),
+ "Temporary dependency tree does not exactly match",
+ ),
+ (
+ "tampered-package",
+ inject_after_install(
+ "[IO.File]::AppendAllText((Join-Path $dependencyRoot 'yt_dlp\\version.py'), '# test tamper')"
+ ),
+ "Temporary dependency tree does not exactly match",
+ ),
+ (
+ "extra-distribution",
+ inject_after_install(
+ "$extra = Join-Path $dependencyRoot 'unexpected-1.0.dist-info'; "
+ "[IO.Directory]::CreateDirectory($extra) | Out-Null; "
+ "[IO.File]::WriteAllText((Join-Path $extra 'METADATA'), "
+ "'Metadata-Version: 2.1`nName: unexpected`nVersion: 1.0`n'); "
+ "[IO.File]::WriteAllText((Join-Path $extra 'RECORD'), '')"
+ ),
+ "Temporary dependency tree does not exactly match",
+ ),
+ )
+ for name, modifier, expected_error in failures:
+ with self.subTest(name=name):
+ failed = run_modified(name, modifier)
+ self.assertIn(expected_error, failed.stderr)
+
+ dependency_import_marker = temporary_root / "tampered-dependency-import-executed.txt"
+ dependency_import_marker_path = dependency_import_marker.as_posix()
+ tampered_import = run_modified(
+ "tampered-first-import-module",
+ inject_after_install(
+ "$payload = \"`nimport pathlib`npathlib.Path(r'"
+ + dependency_import_marker_path
+ + "').write_text('executed', encoding='utf-8')`n\"; "
+ "[IO.File]::AppendAllText((Join-Path $dependencyRoot 'yt_dlp\\__init__.py'), $payload)"
+ ),
+ )
+ self.assertIn("Temporary dependency tree does not exactly match", tampered_import.stderr)
+ self.assertFalse(dependency_import_marker.exists())
+
+ tree_call = (
+ "$dependencyTreeJson = & $resolvedPython -I -S -B -c "
+ "$dependencyTreeProbeScript $dependencyRoot $resolvedOutput $wheelPath"
+ )
+
+ def wrong_owner(text: str) -> str:
+ self.assertIn(tree_call, text)
+ return text.replace(
+ tree_call,
+ tree_call.replace("$resolvedOutput", "$dependencyWheelhouse"),
+ 1,
+ )
+
+ wrong = run_modified("wrong-owner", wrong_owner)
+ self.assertIn("Temporary dependency tree does not exactly match", wrong.stderr)
+
+ fake_site = temporary_root / "fake-site"
+ fake_pyinstaller = fake_site / "PyInstaller"
+ fake_pip = fake_site / "pip"
+ fake_pyinstaller.mkdir(parents=True)
+ fake_pip.mkdir()
+ marker = temporary_root / "inherited-pythonpath-executed.txt"
+ marker_code = (
+ "import pathlib\n"
+ f"pathlib.Path({str(marker)!r}).write_text('executed', encoding='utf-8')\n"
+ )
+ (fake_pyinstaller / "__init__.py").write_text(marker_code, encoding="utf-8")
+ (fake_pyinstaller / "__main__.py").write_text(marker_code, encoding="utf-8")
+ (fake_pip / "__init__.py").write_text(marker_code, encoding="utf-8")
+ (fake_pip / "__main__.py").write_text(marker_code, encoding="utf-8")
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = str(fake_site)
+ launch_anchor = "$pyInstallerLaunchScript = @'"
+
+ def stop_after_real_metadata(text: str) -> str:
+ self.assertIn(launch_anchor, text)
+ return text.replace(
+ launch_anchor,
+ " throw 'TEST_REAL_METADATA_DISCOVERY_PASS'\n" + launch_anchor,
+ 1,
+ )
+
+ discovered = run_modified(
+ "real-metadata-clean-environment",
+ stop_after_real_metadata,
+ environment,
+ )
+ self.assertIn("TEST_REAL_METADATA_DISCOVERY_PASS", discovered.stderr)
+ self.assertFalse(marker.exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/dev/project-dev/test/bili_authenticated_extension/test_protocol.py b/dev/project-dev/test/bili_authenticated_extension/test_protocol.py
new file mode 100644
index 0000000..038dd22
--- /dev/null
+++ b/dev/project-dev/test/bili_authenticated_extension/test_protocol.py
@@ -0,0 +1,236 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+import io
+import json
+import pathlib
+import struct
+import sys
+import subprocess
+import unittest
+
+PROJECT_DEV = pathlib.Path(__file__).resolve().parents[2]
+if str(PROJECT_DEV) not in sys.path:
+ sys.path.insert(0, str(PROJECT_DEV))
+
+from bili_authenticated_extension.constants import ( # noqa: E402
+ CANONICAL_URL,
+ EXPECTED_EXTENSION_ID,
+ EXPECTED_ORIGIN,
+ EXTENSION_BUILD,
+ MANIFEST_PUBLIC_KEY,
+ PUBLIC_KEY_DER_SHA256,
+ TARGET_BVID,
+)
+from bili_authenticated_extension.protocol import ( # noqa: E402
+ ProtocolError,
+ read_frame,
+ strict_json_loads,
+ validate_cancel,
+ validate_hello,
+ validate_origin_argv,
+ validate_prepare,
+ validate_start,
+)
+
+ROOT = PROJECT_DEV / "bili_authenticated_extension"
+
+
+def valid_start(now_ms: int = 2_000_000_000_000) -> dict:
+ return {
+ "schema": 2,
+ "type": "start",
+ "extension_build": EXTENSION_BUILD,
+ "target": TARGET_BVID,
+ "canonical_url": CANONICAL_URL,
+ "cookie_store_id": "0",
+ "prepare_id": "b" * 32,
+ "page_proof": {
+ "target": TARGET_BVID,
+ "canonical_url": CANONICAL_URL,
+ "task_nonce": "a" * 32,
+ "observed_at_unix_ms": now_ms,
+ "observed_duration_ms": 3_133_950,
+ "video_width": 1920,
+ "video_height": 1080,
+ "ready_state": 4,
+ "eme_present": False,
+ },
+ "cookies": [
+ {
+ "name": "synthetic_name",
+ "value": "synthetic_value",
+ "domain": ".bilibili.com",
+ "host_only": False,
+ "path": "/",
+ "secure": True,
+ "http_only": True,
+ "same_site": "unspecified",
+ "session": True,
+ "expiration_unix": None,
+ "store_id": "0",
+ "partition_key": None,
+ }
+ ],
+ }
+
+
+class ProtocolTests(unittest.TestCase):
+ def test_public_key_recomputes_hash_and_extension_id(self) -> None:
+ der = base64.b64decode(MANIFEST_PUBLIC_KEY, validate=True)
+ digest = hashlib.sha256(der).hexdigest().upper()
+ self.assertEqual(PUBLIC_KEY_DER_SHA256, digest)
+ alphabet = "abcdefghijklmnop"
+ extension_id = "".join(alphabet[nibble] for byte in bytes.fromhex(digest[:32]) for nibble in (byte >> 4, byte & 15))
+ self.assertEqual(EXPECTED_EXTENSION_ID, extension_id)
+
+ def test_manifest_permissions_and_native_origin_are_exact(self) -> None:
+ manifest = json.loads((ROOT / "manifest.json").read_text(encoding="utf-8"))
+ self.assertEqual(MANIFEST_PUBLIC_KEY, manifest["key"])
+ self.assertEqual(
+ {"activeTab", "cookies", "nativeMessaging", "scripting", "sidePanel"},
+ set(manifest["permissions"]),
+ )
+ self.assertEqual(["https://www.bilibili.com/*"], manifest["host_permissions"])
+ for forbidden in ("downloads", "storage", "debugger", "webRequest", "content_scripts", "externally_connectable"):
+ self.assertNotIn(forbidden, json.dumps(manifest))
+ native = json.loads((ROOT / "native-host-manifest.template.json").read_text(encoding="utf-8"))
+ self.assertEqual([EXPECTED_ORIGIN], native["allowed_origins"])
+
+ def test_source_artifact_manifest_binds_every_product_file(self) -> None:
+ artifact_path = ROOT / "source-artifact-manifest.json"
+ artifact = json.loads(artifact_path.read_text(encoding="utf-8"))
+ self.assertEqual(EXPECTED_EXTENSION_ID, artifact["extension_id"])
+ expected_names = {
+ item.relative_to(ROOT).as_posix()
+ for item in ROOT.rglob("*")
+ if item.is_file() and item != artifact_path
+ }
+ self.assertEqual(expected_names, {entry["path"] for entry in artifact["files"]})
+ for entry in artifact["files"]:
+ path = ROOT / entry["path"]
+ payload = path.read_bytes()
+ self.assertEqual(entry["bytes"], len(payload), entry["path"])
+ self.assertEqual(entry["sha256"], hashlib.sha256(payload).hexdigest().upper(), entry["path"])
+ dependency_path = ROOT / "dependencies" / "dependency-artifact-manifest.json"
+ dependency_payload = dependency_path.read_bytes()
+ self.assertEqual(len(dependency_payload), artifact["dependency_artifact_manifest_bytes"])
+ self.assertEqual(
+ hashlib.sha256(dependency_payload).hexdigest().upper(),
+ artifact["dependency_artifact_manifest_sha256"],
+ )
+ dependency = json.loads(dependency_payload.decode("utf-8"))
+ self.assertEqual("yt-dlp", dependency["distribution"])
+ self.assertEqual("2026.7.4", dependency["version"])
+ self.assertEqual([], dependency["unconditional_runtime_dependencies"])
+ self.assertEqual(1, len(dependency["wheels"]))
+
+ def test_origin_and_handshake_exact(self) -> None:
+ validate_origin_argv([EXPECTED_ORIGIN], EXPECTED_ORIGIN)
+ validate_origin_argv([EXPECTED_ORIGIN, "--parent-window=42"], EXPECTED_ORIGIN)
+ for args in ([], ["chrome-extension://wrong/"], [EXPECTED_ORIGIN, "--bad"], [EXPECTED_ORIGIN, "x", "y"]):
+ with self.subTest(args=args), self.assertRaises(ProtocolError):
+ validate_origin_argv(list(args), EXPECTED_ORIGIN)
+ validate_hello({"schema": 2, "type": "hello", "extension_build": EXTENSION_BUILD, "target": TARGET_BVID})
+
+ def test_start_and_cookie_contract(self) -> None:
+ baseline = valid_start()
+ prepare = {
+ "schema": 2,
+ "type": "prepare",
+ "extension_build": EXTENSION_BUILD,
+ "target": TARGET_BVID,
+ "prepare_id": "b" * 32,
+ "page_proof": dict(baseline["page_proof"]),
+ }
+ self.assertIs(prepare, validate_prepare(prepare, now_ms=2_000_000_000_000))
+ with self.assertRaises(ProtocolError):
+ validate_prepare(dict(prepare, cookies=[]), now_ms=2_000_000_000_000)
+ with self.assertRaises(ProtocolError):
+ validate_prepare(dict(prepare, prepare_id="wrong"), now_ms=2_000_000_000_000)
+ self.assertIs(baseline, validate_start(baseline, now_ms=2_000_000_000_000))
+ mutations = []
+ for key, value in (
+ ("target", "BV1WRONG"),
+ ("canonical_url", "https://example.invalid/"),
+ ("cookie_store_id", "x"),
+ ("prepare_id", "wrong"),
+ ):
+ changed = valid_start()
+ changed[key] = value
+ mutations.append(changed)
+ for key, value in (
+ ("observed_duration_ms", 600_133),
+ ("ready_state", True),
+ ("eme_present", True),
+ ("video_width", 0),
+ ("observed_at_unix_ms", 1_999_999_900_000),
+ ):
+ changed = valid_start()
+ changed["page_proof"][key] = value
+ mutations.append(changed)
+ for key, value in (
+ ("domain", ".example.com"),
+ ("partition_key", {"topLevelSite": "https://example.invalid"}),
+ ("store_id", "1"),
+ ("value", ""),
+ ("path", "/wrong"),
+ ("expiration_unix", 3),
+ ):
+ changed = valid_start()
+ changed["cookies"][0][key] = value
+ mutations.append(changed)
+ changed = valid_start()
+ changed["unknown"] = 1
+ mutations.append(changed)
+ for changed in mutations:
+ with self.subTest(changed=changed), self.assertRaises(ProtocolError):
+ validate_start(changed, now_ms=2_000_000_000_000)
+
+ def test_duplicate_nonfinite_and_oversized_frames_fail(self) -> None:
+ with self.assertRaises(ProtocolError):
+ strict_json_loads(b'{"schema":2,"schema":2}')
+ with self.assertRaises(ProtocolError):
+ strict_json_loads(b'{"x":NaN}')
+ with self.assertRaises(ProtocolError):
+ read_frame(io.BytesIO(struct.pack("<I", 131_073)), 131_072)
+ with self.assertRaises(ProtocolError):
+ read_frame(io.BytesIO(struct.pack("<I", 4) + b"xx"), 131_072)
+
+ def test_cancel_has_no_secret_fields(self) -> None:
+ value = {"schema": 2, "type": "cancel", "target": TARGET_BVID, "task_nonce": "0" * 32}
+ validate_cancel(value)
+ changed = dict(value, cookie="synthetic")
+ with self.assertRaises(ProtocolError):
+ validate_cancel(changed)
+
+ def test_extension_sources_have_no_forbidden_control_surface(self) -> None:
+ sources = "\n".join((ROOT / name).read_text(encoding="utf-8") for name in ("background.js", "sidepanel.js"))
+ for forbidden in (
+ "chrome.downloads",
+ "chrome.storage",
+ "externally_connectable",
+ "127.0.0.1",
+ "localhost",
+ "cookies.getAll({domain",
+ ):
+ self.assertNotIn(forbidden, sources)
+ self.assertIn('chrome.cookies.getAll({url: "https://www.bilibili.com/", storeId})', sources)
+ self.assertIn("crypto.getRandomValues", sources)
+
+ def test_mock_chrome_requires_two_clicks_and_rereads_on_retry(self) -> None:
+ node_test = pathlib.Path(__file__).with_name("background_contract.mjs")
+ result = subprocess.run(
+ ["node", str(node_test), str(ROOT / "background.js")],
+ check=False,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertEqual("BACKGROUND_CONTRACT_PASS", result.stdout.strip())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/dev/project-dev/test/bili_authenticated_extension/test_worker.py b/dev/project-dev/test/bili_authenticated_extension/test_worker.py
new file mode 100644
index 0000000..686b26a
--- /dev/null
+++ b/dev/project-dev/test/bili_authenticated_extension/test_worker.py
@@ -0,0 +1,479 @@
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+import importlib.util
+from unittest import mock
+
+PROJECT_DEV = pathlib.Path(__file__).resolve().parents[2]
+if str(PROJECT_DEV) not in sys.path:
+ sys.path.insert(0, str(PROJECT_DEV))
+
+from bili_authenticated_extension.constants import CANONICAL_URL, EXTENSION_BUILD, TARGET_BVID # noqa: E402
+from bili_authenticated_extension.worker import ( # noqa: E402
+ CancelRequested,
+ HostConfig,
+ SubprocessPolicy,
+ WorkerError,
+ bootstrap_ytdlp,
+ build_cookie_stream,
+ close_cookie_stream,
+ merge_local_streams,
+ prepare_download_info,
+ probe_mkv,
+ remux_single_to_mkv,
+ run_authenticated_task,
+ run_frozen_bridge,
+ sha256_file,
+ validate_download_info,
+ verify_frozen_ytdlp,
+ ytdlp_options,
+)
+
+
+def valid_start() -> dict:
+ now_ms = int(__import__("time").time() * 1000)
+ return {
+ "schema": 2,
+ "type": "start",
+ "extension_build": EXTENSION_BUILD,
+ "target": TARGET_BVID,
+ "canonical_url": CANONICAL_URL,
+ "cookie_store_id": "0",
+ "prepare_id": "d" * 32,
+ "page_proof": {
+ "target": TARGET_BVID,
+ "canonical_url": CANONICAL_URL,
+ "task_nonce": "b" * 32,
+ "observed_at_unix_ms": now_ms,
+ "observed_duration_ms": 3_133_950,
+ "video_width": 1920,
+ "video_height": 1080,
+ "ready_state": 4,
+ "eme_present": False,
+ },
+ "cookies": [{
+ "name": "SYNTHETIC_COOKIE_NAME",
+ "value": "SYNTHETIC_COOKIE_VALUE",
+ "domain": ".bilibili.com",
+ "host_only": False,
+ "path": "/",
+ "secure": True,
+ "http_only": True,
+ "same_site": "unspecified",
+ "session": True,
+ "expiration_unix": None,
+ "store_id": "0",
+ "partition_key": None,
+ }],
+ }
+
+
+class FakeYDL:
+ def __init__(self) -> None:
+ self.params = {}
+ self.extract_count = 0
+ self.processed_object = None
+ video = {
+ "format_id": "v",
+ "url": "https://example.invalid/video",
+ "protocol": "https",
+ "vcodec": "h264",
+ "acodec": "none",
+ "has_drm": False,
+ }
+ audio = {
+ "format_id": "a",
+ "url": "https://example.invalid/audio",
+ "protocol": "http_dash_segments",
+ "vcodec": "none",
+ "acodec": "aac",
+ "has_drm": False,
+ }
+ self.info = {
+ "id": TARGET_BVID,
+ "_type": "video",
+ "duration": 3133.95,
+ "is_live": False,
+ "has_drm": False,
+ "formats": [video, audio],
+ }
+ self.selected = {
+ "id": TARGET_BVID,
+ "vcodec": "h264",
+ "acodec": "aac",
+ "requested_formats": [video, audio],
+ }
+
+ def extract_info(self, url, download=False, process=True):
+ self.extract_count += 1
+ assert url == CANONICAL_URL and download is False and process is True
+ return self.info
+
+ def build_format_selector(self, spec):
+ assert spec == "bestvideo+bestaudio/best"
+ return object()
+
+ def _get_formats(self, info):
+ assert info is self.info
+ return info["formats"]
+
+ def _select_formats(self, formats, selector):
+ del formats, selector
+ yield self.selected
+
+ @staticmethod
+ def _copy_infodict(info):
+ return dict(info)
+
+ def process_info(self, info):
+ self.processed_object = info
+
+
+class WorkerContractTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.ffmpeg = pathlib.Path(shutil.which("ffmpeg") or "")
+ cls.ffprobe = pathlib.Path(shutil.which("ffprobe") or "")
+
+ @unittest.skipUnless(importlib.util.find_spec("yt_dlp"), "frozen yt-dlp environment is required")
+ def test_frozen_real_package_and_plugins(self) -> None:
+ root = verify_frozen_ytdlp()
+ self.assertTrue(root.is_dir())
+ yt_dlp, globals_module = bootstrap_ytdlp()
+ self.assertEqual("2026.07.04", yt_dlp.version.__version__)
+ self.assertEqual([], globals_module.plugin_dirs.value)
+ self.assertEqual({}, globals_module.plugin_ies.value)
+ self.assertEqual({}, globals_module.plugin_pps.value)
+
+ @unittest.skipUnless(importlib.util.find_spec("yt_dlp"), "frozen yt-dlp environment is required")
+ def test_default_plugin_directory_sentinel_cannot_load(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ plugin_dir = root / "yt_dlp_plugins" / "extractor"
+ plugin_dir.mkdir(parents=True)
+ marker = root / "loaded.marker"
+ plugin = plugin_dir / "sentinel.py"
+ plugin.write_text(
+ "from pathlib import Path\n"
+ f"Path({str(marker)!r}).write_text('loaded', encoding='ascii')\n",
+ encoding="utf-8",
+ )
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = os.pathsep.join((str(root), str(PROJECT_DEV)))
+ environment.pop("YTDLP_NO_PLUGINS", None)
+ result = subprocess.run(
+ [sys.executable, "-c", "from bili_authenticated_extension.worker import bootstrap_ytdlp; bootstrap_ytdlp()"],
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=environment,
+ )
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertFalse(marker.exists())
+ self.assertEqual(b"", result.stdout)
+ self.assertEqual(b"", result.stderr)
+
+ @unittest.skipUnless(importlib.util.find_spec("yt_dlp"), "frozen yt-dlp environment is required")
+ def test_real_cookiejar_uses_memory_stream_and_sentinel_is_not_output(self) -> None:
+ yt_dlp, _ = bootstrap_ytdlp()
+ start = valid_start()
+ stream = build_cookie_stream(start)
+ self.assertIsInstance(stream, io.StringIO)
+ capture_out, capture_error = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(capture_out), contextlib.redirect_stderr(capture_error):
+ with yt_dlp.YoutubeDL({"cookiefile": stream, "logger": type("L", (), {"debug": lambda *_: None, "info": lambda *_: None, "warning": lambda *_: None, "error": lambda *_: None})()}) as ydl:
+ names = {cookie.name for cookie in ydl.cookiejar}
+ self.assertIn("SYNTHETIC_COOKIE_NAME", names)
+ close_cookie_stream(stream)
+ self.assertTrue(stream.closed)
+ self.assertNotIn("SYNTHETIC_COOKIE_VALUE", capture_out.getvalue() + capture_error.getvalue())
+
+ def test_one_extract_same_object_and_second_extract_guard(self) -> None:
+ ydl = FakeYDL()
+ def resolver(leaf, _params):
+ return type("HttpFD" if leaf["protocol"] == "https" else "DashSegmentsFD", (), {})
+
+ info, single, signed_urls = prepare_download_info(ydl, downloader_resolver=resolver)
+ self.assertFalse(single)
+ self.assertEqual(2, len(signed_urls))
+ ydl.process_info(info)
+ self.assertIs(info, ydl.processed_object)
+ self.assertEqual(1, ydl.extract_count)
+ with self.assertRaises(WorkerError) as caught:
+ ydl.extract_info(CANONICAL_URL)
+ self.assertEqual("E_SECOND_EXTRACT", caught.exception.code)
+
+ @unittest.skipUnless(importlib.util.find_spec("yt_dlp"), "frozen yt-dlp environment is required")
+ def test_real_downloader_dispatch_and_rejections(self) -> None:
+ bootstrap_ytdlp()
+ valid = FakeYDL().selected | {"id": TARGET_BVID}
+ leaves, single = validate_download_info(valid, {})
+ self.assertEqual(2, len(leaves))
+ self.assertFalse(single)
+ for protocol in ("m3u8", "m3u8_native", "rtmp", "unknown"):
+ changed = json.loads(json.dumps(valid))
+ changed["requested_formats"][0]["protocol"] = protocol
+ with self.subTest(protocol=protocol), self.assertRaises(WorkerError):
+ validate_download_info(changed, {})
+ changed = json.loads(json.dumps(valid))
+ changed["requested_formats"][0]["url"] = "http://example.invalid/video"
+ with self.assertRaises(WorkerError):
+ validate_download_info(changed, {})
+
+ def test_subprocess_policy_rejects_url_header_secret_and_nonlocal(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary).resolve()
+ local = root / "input.mp4"
+ local.write_bytes(b"fixture")
+ destination = root / "output.mkv"
+ policy = SubprocessPolicy(root, {self.ffmpeg}, {"SYNTHETIC_SECRET"})
+ policy.check("subprocess.Popen", (str(self.ffmpeg), [str(self.ffmpeg), "-i", str(local), str(destination)], None, {}))
+ for argv in (
+ [str(self.ffmpeg), "-i", "https://example.invalid/signed", str(destination)],
+ [str(self.ffmpeg), "-headers", "SYNTHETIC_SECRET", "-i", str(local), str(destination)],
+ [str(self.ffmpeg), "-i", str(root.parent / "outside.mp4"), str(destination)],
+ ):
+ with self.subTest(argv=argv), self.assertRaises(WorkerError):
+ policy.check("subprocess.Popen", (str(self.ffmpeg), argv, None, {}))
+
+ def test_real_audit_hook_blocks_before_createprocess(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ marker = root / "child.marker"
+ script = root / "audit_test.py"
+ child_code = f"import pathlib; pathlib.Path({str(marker)!r}).write_text('ran')"
+ script.write_text(
+ "import pathlib, subprocess, sys\n"
+ "from bili_authenticated_extension.worker import SubprocessPolicy, WorkerError\n"
+ f"root=pathlib.Path({str(root)!r})\n"
+ "SubprocessPolicy(root,{pathlib.Path(sys.executable)}).install()\n"
+ "try:\n"
+ f" subprocess.run([sys.executable,'-c',{child_code!r},'https://example.invalid/signed'])\n"
+ "except WorkerError:\n"
+ " pass\n"
+ "else:\n"
+ " raise SystemExit(9)\n",
+ encoding="utf-8",
+ )
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = str(PROJECT_DEV)
+ result = subprocess.run(
+ [sys.executable, str(script)],
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=environment,
+ )
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertFalse(marker.exists())
+ self.assertNotIn(b"signed", result.stdout + result.stderr)
+
+ def test_frozen_retry_and_output_options(self) -> None:
+ config = HostConfig(
+ ffmpeg=self.ffmpeg,
+ ffprobe=self.ffprobe,
+ bridge_python=pathlib.Path(sys.executable),
+ bridge_script=PROJECT_DEV / "bili_video_download_bridge.py",
+ batch_json=PROJECT_DEV / "test" / "test_bili_video_download_bridge.py",
+ yt_dlp_executable=pathlib.Path(sys.executable),
+ destination=PROJECT_DEV,
+ )
+ stream = io.StringIO()
+ try:
+ options = ytdlp_options(config, PROJECT_DEV, stream, lambda _: None)
+ self.assertEqual("bestvideo+bestaudio/best", options["format"])
+ self.assertEqual("mkv", options["merge_output_format"])
+ self.assertEqual(20, options["socket_timeout"])
+ self.assertEqual(1, options["extractor_retries"])
+ self.assertEqual(2, options["retries"])
+ self.assertEqual(2, options["fragment_retries"])
+ self.assertEqual(1, options["file_access_retries"])
+ self.assertEqual({}, options["external_downloader"])
+ for sleeper in options["retry_sleep_functions"].values():
+ self.assertEqual(1, sleeper(99))
+ finally:
+ stream.close()
+
+ def test_bridge_result_must_match_persisted_mapping_and_complete_mkv(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ destination = root / "destination"
+ destination.mkdir()
+ formal = destination / f"{TARGET_BVID}.mkv"
+ formal.write_bytes(b"synthetic-mkv")
+ formal_sha = sha256_file(formal)
+ mapping_path = destination / f"{TARGET_BVID}.download.json"
+ persisted = {
+ "bvid": TARGET_BVID,
+ "source": CANONICAL_URL,
+ "local_file": formal.name,
+ "bytes": formal.stat().st_size,
+ "sha256": formal_sha,
+ "duration_seconds": 3133.95,
+ "acquisition_mode": "authorized_browser_file_handoff",
+ }
+ mapping_path.write_text(json.dumps(persisted), encoding="utf-8")
+ item = {"status": "COMPLETE", **persisted}
+ output = json.dumps({"result": "PASS", "items": [item]}).encode("utf-8")
+ config = HostConfig(
+ ffmpeg=self.ffmpeg,
+ ffprobe=self.ffprobe,
+ bridge_python=pathlib.Path(sys.executable),
+ bridge_script=PROJECT_DEV / "bili_video_download_bridge.py",
+ batch_json=PROJECT_DEV / "test" / "test_bili_video_download_bridge.py",
+ yt_dlp_executable=pathlib.Path(sys.executable),
+ destination=destination,
+ )
+ completed = subprocess.CompletedProcess([], 0, stdout=output, stderr=b"")
+ with mock.patch("bili_authenticated_extension.worker._run_local", return_value=completed):
+ self.assertEqual((formal.name, mapping_path.name), run_frozen_bridge(config, root / "candidate.mkv"))
+ persisted["sha256"] = "0" * 64
+ mapping_path.write_text(json.dumps(persisted), encoding="utf-8")
+ with self.assertRaises(WorkerError):
+ run_frozen_bridge(config, root / "candidate.mkv")
+
+ @unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg is required")
+ def test_real_synthetic_single_and_dual_local_mkv_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary).resolve()
+ combined = root / "combined.mp4"
+ video = root / "video.mp4"
+ audio = root / "audio.m4a"
+ commands = [
+ [str(self.ffmpeg), "-v", "error", "-f", "lavfi", "-i", "color=c=blue:s=320x180:r=10", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(combined)],
+ [str(self.ffmpeg), "-v", "error", "-f", "lavfi", "-i", "color=c=red:s=320x180:r=10", "-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-an", str(video)],
+ [str(self.ffmpeg), "-v", "error", "-f", "lavfi", "-i", "sine=frequency=880:sample_rate=48000", "-t", "1", "-c:a", "aac", str(audio)],
+ ]
+ for command in commands:
+ subprocess.run(command, check=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ single = root / "single.mkv"
+ dual = root / "dual.mkv"
+ remux_single_to_mkv(self.ffmpeg, combined, single)
+ merge_local_streams(self.ffmpeg, video, audio, dual)
+ probe_mkv(self.ffprobe, single)
+ probe_mkv(self.ffprobe, dual)
+ self.assertGreater(single.stat().st_size, 0)
+ self.assertGreater(dual.stat().st_size, 0)
+
+ def test_cancel_checkpoints_and_commit_arbitration(self) -> None:
+ class TaskYDL(FakeYDL):
+ def __init__(self, run_root: pathlib.Path) -> None:
+ super().__init__()
+ self.run_root = run_root
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+ def process_info(self, info):
+ super().process_info(info)
+ (self.run_root / "download.mp4").write_bytes(b"synthetic")
+
+ class YtDlpModule:
+ def __init__(self, run_root: pathlib.Path) -> None:
+ self.run_root = run_root
+
+ def YoutubeDL(self, _options):
+ return TaskYDL(self.run_root)
+
+ def config_for(destination: pathlib.Path) -> HostConfig:
+ return HostConfig(
+ ffmpeg=self.ffmpeg,
+ ffprobe=self.ffprobe,
+ bridge_python=pathlib.Path(sys.executable),
+ bridge_script=PROJECT_DEV / "bili_video_download_bridge.py",
+ batch_json=PROJECT_DEV / "test" / "test_bili_video_download_bridge.py",
+ yt_dlp_executable=pathlib.Path(sys.executable),
+ destination=destination,
+ )
+
+ for point in ("after-probe", "at-commit", "inside-bridge"):
+ with self.subTest(point=point), tempfile.TemporaryDirectory() as temporary:
+ root = pathlib.Path(temporary)
+ stage = root / "stage"
+ destination = root / "destination"
+ destination.mkdir()
+ canceled = False
+ bridge_calls = 0
+
+ def remux(_ffmpeg, _source, output):
+ output.write_bytes(b"synthetic-mkv")
+
+ def probe(_ffprobe, _candidate):
+ nonlocal canceled
+ if point == "after-probe":
+ canceled = True
+
+ def commit_begin():
+ nonlocal canceled
+ if point == "at-commit":
+ canceled = True
+
+ def bridge(_config, _candidate):
+ nonlocal bridge_calls, canceled
+ bridge_calls += 1
+ if point == "inside-bridge":
+ canceled = True
+ (destination / f"{TARGET_BVID}.mkv").write_bytes(b"formal")
+ (destination / f"{TARGET_BVID}.download.json").write_text("{}", encoding="utf-8")
+ return (f"{TARGET_BVID}.mkv", f"{TARGET_BVID}.download.json")
+
+ with (
+ mock.patch(
+ "bili_authenticated_extension.worker.bootstrap_ytdlp",
+ return_value=(YtDlpModule(stage / "run-placeholder"), object()),
+ ) as bootstrap,
+ mock.patch("bili_authenticated_extension.worker.remux_single_to_mkv", side_effect=remux),
+ mock.patch("bili_authenticated_extension.worker.probe_mkv", side_effect=probe),
+ mock.patch("bili_authenticated_extension.worker.run_frozen_bridge", side_effect=bridge),
+ mock.patch("bili_authenticated_extension.worker.SubprocessPolicy.install"),
+ mock.patch(
+ "bili_authenticated_extension.worker.prepare_download_info",
+ return_value=({"id": TARGET_BVID}, True, []),
+ ),
+ ):
+ # The fake downloader needs the actual run directory, which is created by the product helper.
+ def module_factory():
+ run_directory = next(stage.glob("run-*"))
+ return YtDlpModule(run_directory), object()
+
+ bootstrap.side_effect = module_factory
+ if point in {"after-probe", "at-commit"}:
+ with self.assertRaises(CancelRequested):
+ run_authenticated_task(
+ valid_start(),
+ config_for(destination),
+ cancel_check=lambda: canceled,
+ report=lambda *_: None,
+ stage_root=stage,
+ commit_begin=commit_begin,
+ )
+ self.assertEqual(0, bridge_calls)
+ self.assertEqual([], list(destination.iterdir()))
+ else:
+ result = run_authenticated_task(
+ valid_start(),
+ config_for(destination),
+ cancel_check=lambda: canceled,
+ report=lambda *_: None,
+ stage_root=stage,
+ commit_begin=commit_begin,
+ )
+ self.assertEqual(1, bridge_calls)
+ self.assertEqual(f"{TARGET_BVID}.mkv", result[0])
+ self.assertEqual(2, len(list(destination.iterdir())))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/dev/project-dev/test/fixtures/bili_dynamic_collector/dynamic_items.json b/dev/project-dev/test/fixtures/bili_dynamic_collector/dynamic_items.json
new file mode 100644
index 0000000..af4bbf3
--- /dev/null
+++ b/dev/project-dev/test/fixtures/bili_dynamic_collector/dynamic_items.json
@@ -0,0 +1,42 @@
+{
+ "schema_version": 1,
+ "creator": "青枫浦上Q",
+ "items": [
+ {
+ "dynamic_id": "dynamic-video-001",
+ "opus_id": null,
+ "bvid": "BV1TEST00001",
+ "content_type": "video",
+ "published_at": "2026-08-04T11:00:00+08:00",
+ "title": "脱敏视频样例",
+ "source_url": "https://www.bilibili.com/video/BV1TEST00001"
+ },
+ {
+ "dynamic_id": "dynamic-text-001",
+ "opus_id": "opus-text-001",
+ "bvid": null,
+ "content_type": "text",
+ "published_at": "2026-08-04T10:00:00+08:00",
+ "title": "收盘:观察?",
+ "source_url": "https://www.bilibili.com/opus/opus-text-001"
+ },
+ {
+ "dynamic_id": "dynamic-old-001",
+ "opus_id": null,
+ "bvid": null,
+ "content_type": "article",
+ "published_at": "2026-07-31T09:00:00+08:00",
+ "title": "窗口外样例",
+ "source_url": "https://www.bilibili.com/read/cv-old-001"
+ },
+ {
+ "dynamic_id": "dynamic-future-001",
+ "opus_id": null,
+ "bvid": null,
+ "content_type": "image",
+ "published_at": "2026-08-04T13:00:00+08:00",
+ "title": "未来时间样例",
+ "source_url": "https://www.bilibili.com/opus/dynamic-future-001"
+ }
+ ]
+}
diff --git a/dev/project-dev/test/monitor_media_host_responsiveness.ps1 b/dev/project-dev/test/monitor_media_host_responsiveness.ps1
new file mode 100644
index 0000000..30a6dd7
--- /dev/null
+++ b/dev/project-dev/test/monitor_media_host_responsiveness.ps1
@@ -0,0 +1,974 @@
+[CmdletBinding(DefaultParameterSetName = "Run")]
+param(
+ [Parameter(Mandatory = $true, ParameterSetName = "Run")]
+ [string]$PythonPath,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "Run")]
+ [string]$ScriptPath,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "Run")]
+ [string]$VideoPath,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "Run")]
+ [string]$OutputPath,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "Run")]
+ [Parameter(Mandatory = $true, ParameterSetName = "Analyze")]
+ [string]$EvidenceDirectory,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "Analyze")]
+ [string]$AnalyzeSamplesPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+$SampleIntervalMilliseconds = 500
+$MaximumIntervalSeconds = 1.5
+$RollingWindowSeconds = 5.0
+$MinimumWindowMembers = 10
+$CpuThresholdPercent = 85.0
+$MaximumHighCpuSecondsExclusive = 10.0
+$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+
+function Resolve-Leaf([string]$Path, [string]$Label) {
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
+ throw "$Label does not exist: $Path"
+ }
+ return (Resolve-Path -LiteralPath $Path).Path
+}
+
+function Resolve-FuturePath([string]$Path) {
+ return [System.IO.Path]::GetFullPath($Path)
+}
+
+function Write-JsonFile([string]$Path, $Value) {
+ $Json = $Value | ConvertTo-Json -Depth 30
+ [System.IO.File]::WriteAllText($Path, $Json + [Environment]::NewLine, $Utf8NoBom)
+}
+
+function Convert-ToBoolean($Value) {
+ if ($Value -is [bool]) {
+ return $Value
+ }
+ return [string]$Value -match '^(?i:true|1|yes)$'
+}
+
+function Get-RowText($Row, [string]$Name) {
+ $Property = $Row.PSObject.Properties[$Name]
+ if ($null -eq $Property -or $null -eq $Property.Value) {
+ return ""
+ }
+ return [string]$Property.Value
+}
+
+function Convert-ToMetric([string]$Value) {
+ if ([string]::IsNullOrWhiteSpace($Value)) {
+ return [double]::NaN
+ }
+ return [double]::Parse($Value, [Globalization.CultureInfo]::InvariantCulture)
+}
+
+function Test-ScanFfmpegCommand([string]$CommandLine) {
+ return (
+ $CommandLine -match '(?i)(?:^|\s)-map\s+0:v:0(?:\s|$)' -and
+ $CommandLine -match '(?i)fps=fps=1:start_time=0' -and
+ $CommandLine -match '(?i)(?:^|\s)-f\s+rawvideo(?:\s|$)' -and
+ $CommandLine -match '(?i)(?:^|\s)-pix_fmt\s+rgb24(?:\s|$)'
+ )
+}
+
+function Get-DecoderFromCommand([string]$CommandLine) {
+ if ($CommandLine -match '(?i)(?:^|\s)-c:v\s+(h264_cuvid|hevc_cuvid)(?:\s|$)') {
+ return [string]$Matches[1].ToLowerInvariant()
+ }
+ return ""
+}
+
+function Test-NvdecScanCommand([string]$CommandLine, [string]$Decoder) {
+ if ([string]::IsNullOrWhiteSpace($CommandLine) -or
+ $Decoder -notmatch '^(h264_cuvid|hevc_cuvid)$') {
+ return $false
+ }
+ $InputMatch = [regex]::Match($CommandLine, '(?i)(?:^|\s)-i(?:\s|$)')
+ if (-not $InputMatch.Success) {
+ return $false
+ }
+ $InputPrefix = $CommandLine.Substring(0, $InputMatch.Index + $InputMatch.Length)
+ return (
+ $InputPrefix -match '(?i)(?:^|\s)-hwaccel\s+cuda(?:\s|$)' -and
+ $InputPrefix -match '(?i)(?:^|\s)-hwaccel_output_format\s+cuda(?:\s|$)' -and
+ $InputPrefix -match "(?i)(?:^|\s)-c:v\s+$([regex]::Escape($Decoder))(?:\s|$)" -and
+ $InputPrefix -match '(?i)(?:^|\s)-threads\s+4(?:\s|$)' -and
+ $InputPrefix -match '(?i)(?:^|\s)-filter_threads\s+2(?:\s|$)' -and
+ $InputPrefix -match '(?i)(?:^|\s)-filter_complex_threads\s+2(?:\s|$)' -and
+ $CommandLine -match '(?i)hwdownload,format=nv12' -and
+ (Test-ScanFfmpegCommand $CommandLine)
+ )
+}
+
+function Quote-ProcessArgument([string]$Value) {
+ if ($Value.Length -eq 0) {
+ return '""'
+ }
+ if ($Value -notmatch '[\s"]') {
+ return $Value
+ }
+ return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"'
+}
+
+function Get-OverlapSeconds([DateTime]$LeftStart, [DateTime]$LeftEnd, [DateTime]$RightStart, [DateTime]$RightEnd) {
+ $Start = if ($LeftStart -gt $RightStart) { $LeftStart } else { $RightStart }
+ $End = if ($LeftEnd -lt $RightEnd) { $LeftEnd } else { $RightEnd }
+ if ($End -le $Start) {
+ return 0.0
+ }
+ return ($End - $Start).TotalSeconds
+}
+
+function Complete-HighCpuSegment($Segments, $Current) {
+ if ($null -eq $Current) {
+ return $null
+ }
+ [void]$Segments.Add([pscustomobject][ordered]@{
+ start_timestamp_utc = $Current.start.ToString("o")
+ end_timestamp_utc = $Current.end.ToString("o")
+ duration_seconds = [Math]::Round(($Current.end - $Current.start).TotalSeconds, 6)
+ member_rows = ($Current.members -join ";")
+ termination_reason = [string]$Current.reason
+ })
+ return $null
+}
+
+function Analyze-ResourceSamples([string]$SamplesPath, [string]$OutputDirectory) {
+ $Rows = @(Import-Csv -LiteralPath $SamplesPath)
+ $Problems = New-Object System.Collections.Generic.List[string]
+ $RequiredColumns = @(
+ "timestamp_utc", "previous_valid_timestamp_utc", "interval_seconds",
+ "counter_warmup", "cpu_valid", "host_cpu_percent",
+ "scan_phase_active", "scan_ffmpeg_present", "scan_ffmpeg_exit_observed",
+ "scan_ffmpeg_pid", "scan_ffmpeg_command_line", "decoder",
+ "logical_processor_count", "task_cpu_percent", "task_working_set_bytes",
+ "task_pids", "root_priority", "ffmpeg_priorities", "ffmpeg_command_lines",
+ "gpu_query_status", "gpu_query_raw_rows", "gpu_utilization_percent",
+ "gpu_decoder_utilization_percent", "gpu_memory_used_mib",
+ "nvidia_smi_compute_query_status", "nvidia_smi_compute_raw_rows",
+ "nvidia_smi_compute_task_rows",
+ "gpu_process_memory_query_status", "gpu_process_memory_instances",
+ "task_gpu_dedicated_memory_mib"
+ )
+ $Headers = if ($Rows.Count -gt 0) { @($Rows[0].PSObject.Properties.Name) } else { @() }
+ if ($Rows.Count -eq 0) {
+ [void]$Problems.Add("samples_empty")
+ }
+ foreach ($Column in $RequiredColumns) {
+ if ($Headers -notcontains $Column) {
+ [void]$Problems.Add("required_column_missing_$Column")
+ }
+ }
+
+ $Parsed = New-Object System.Collections.Generic.List[object]
+ $PreviousParsedTimestamp = $null
+ for ($Index = 0; $Index -lt $Rows.Count; $Index++) {
+ $Row = $Rows[$Index]
+ $RowNumber = $Index + 2
+ try {
+ $Timestamp = [DateTime]::Parse(
+ (Get-RowText $Row "timestamp_utc"),
+ [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::RoundtripKind
+ ).ToUniversalTime()
+ } catch {
+ [void]$Problems.Add("timestamp_invalid_row_$RowNumber")
+ continue
+ }
+ $Previous = $null
+ $PreviousText = Get-RowText $Row "previous_valid_timestamp_utc"
+ if (-not [string]::IsNullOrWhiteSpace($PreviousText)) {
+ try {
+ $Previous = [DateTime]::Parse(
+ $PreviousText,
+ [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::RoundtripKind
+ ).ToUniversalTime()
+ } catch {
+ [void]$Problems.Add("previous_timestamp_invalid_row_$RowNumber")
+ }
+ }
+ if ($Index -eq 0) {
+ if ($null -ne $Previous) {
+ [void]$Problems.Add("first_row_previous_must_be_empty")
+ }
+ $ActualInterval = 0.0
+ } else {
+ if ($null -eq $Previous) {
+ [void]$Problems.Add("previous_timestamp_missing_row_$RowNumber")
+ } elseif ([Math]::Abs(($Previous - $PreviousParsedTimestamp).TotalMilliseconds) -gt 1.0) {
+ [void]$Problems.Add("previous_timestamp_mismatch_row_$RowNumber")
+ }
+ $ActualInterval = ($Timestamp - $PreviousParsedTimestamp).TotalSeconds
+ if ($ActualInterval -le 0) {
+ [void]$Problems.Add("timestamp_not_strictly_increasing_row_$RowNumber")
+ }
+ }
+ $ReportedIntervalText = Get-RowText $Row "interval_seconds"
+ try {
+ $ReportedInterval = Convert-ToMetric $ReportedIntervalText
+ if ([double]::IsNaN($ReportedInterval) -or [Math]::Abs($ReportedInterval - $ActualInterval) -gt 0.001) {
+ [void]$Problems.Add("interval_mismatch_row_$RowNumber")
+ }
+ } catch {
+ [void]$Problems.Add("interval_invalid_row_$RowNumber")
+ }
+
+ $ScanActive = Convert-ToBoolean (Get-RowText $Row "scan_phase_active")
+ $Warmup = Convert-ToBoolean (Get-RowText $Row "counter_warmup")
+ if ($ScanActive) {
+ foreach ($Field in $RequiredColumns) {
+ if ($Field -eq "previous_valid_timestamp_utc" -and $Index -eq 0) { continue }
+ if (($Field -eq "host_cpu_percent" -or $Field -eq "task_cpu_percent") -and $Warmup) { continue }
+ if ([string]::IsNullOrWhiteSpace((Get-RowText $Row $Field))) {
+ [void]$Problems.Add("required_value_missing_${Field}_row_$RowNumber")
+ }
+ }
+ foreach ($StatusField in @(
+ "gpu_query_status", "nvidia_smi_compute_query_status", "gpu_process_memory_query_status"
+ )) {
+ if ((Get-RowText $Row $StatusField) -ne "ok") {
+ [void]$Problems.Add("query_not_ok_${StatusField}_row_$RowNumber")
+ }
+ }
+ }
+ $PreviousForInterval = if ($Index -eq 0) { $null } else { $PreviousParsedTimestamp }
+ [void]$Parsed.Add([pscustomobject]@{
+ row_number = $RowNumber
+ timestamp = $Timestamp
+ previous = $PreviousForInterval
+ interval = $ActualInterval
+ warmup = $Warmup
+ cpu_valid = Convert-ToBoolean (Get-RowText $Row "cpu_valid")
+ scan_active = $ScanActive
+ scan_present = Convert-ToBoolean (Get-RowText $Row "scan_ffmpeg_present")
+ scan_exit_observed = Convert-ToBoolean (Get-RowText $Row "scan_ffmpeg_exit_observed")
+ scan_pid = Get-RowText $Row "scan_ffmpeg_pid"
+ scan_command = Get-RowText $Row "scan_ffmpeg_command_line"
+ decoder = Get-RowText $Row "decoder"
+ logical_processors = Convert-ToMetric (Get-RowText $Row "logical_processor_count")
+ host_cpu = Convert-ToMetric (Get-RowText $Row "host_cpu_percent")
+ task_cpu = Convert-ToMetric (Get-RowText $Row "task_cpu_percent")
+ task_ram = Convert-ToMetric (Get-RowText $Row "task_working_set_bytes")
+ gpu_util = Convert-ToMetric (Get-RowText $Row "gpu_utilization_percent")
+ decoder_util = Convert-ToMetric (Get-RowText $Row "gpu_decoder_utilization_percent")
+ gpu_memory = Convert-ToMetric (Get-RowText $Row "gpu_memory_used_mib")
+ task_gpu_memory = Convert-ToMetric (Get-RowText $Row "task_gpu_dedicated_memory_mib")
+ })
+ $PreviousParsedTimestamp = $Timestamp
+ }
+
+ $Active = @($Parsed | Where-Object { $_.scan_active })
+ if ($Active.Count -eq 0) {
+ [void]$Problems.Add("scan_ffmpeg_not_observed")
+ } else {
+ $FirstActiveIndex = [array]::IndexOf([object[]]$Parsed.ToArray(), $Active[0])
+ $LastActiveIndex = [array]::IndexOf([object[]]$Parsed.ToArray(), $Active[-1])
+ for ($Index = $FirstActiveIndex; $Index -le $LastActiveIndex; $Index++) {
+ if (-not $Parsed[$Index].scan_active) {
+ [void]$Problems.Add("scan_phase_not_contiguous_row_$($Parsed[$Index].row_number)")
+ }
+ }
+ if (-not $Active[0].scan_present) {
+ [void]$Problems.Add("scan_phase_first_row_not_present")
+ }
+ if ($Active[-1].scan_present -or -not $Active[-1].scan_exit_observed) {
+ [void]$Problems.Add("scan_phase_last_row_not_exit_observation")
+ }
+ $ScanPids = @($Active | ForEach-Object { $_.scan_pid } | Where-Object { $_ } | Select-Object -Unique)
+ if ($ScanPids.Count -ne 1) {
+ [void]$Problems.Add("scan_pid_not_unique")
+ }
+ $ScanCommands = @($Active | ForEach-Object { $_.scan_command } | Where-Object { $_ } | Select-Object -Unique)
+ if ($ScanCommands.Count -ne 1) {
+ [void]$Problems.Add("scan_command_not_unique")
+ }
+ $Decoders = @($Active | ForEach-Object { $_.decoder } | Where-Object { $_ } | Select-Object -Unique)
+ if ($Decoders.Count -ne 1) {
+ [void]$Problems.Add("decoder_not_unique")
+ }
+ $LogicalProcessorCounts = @($Active | ForEach-Object { $_.logical_processors } | Select-Object -Unique)
+ if ($LogicalProcessorCounts.Count -ne 1 -or [double]$LogicalProcessorCounts[0] -le 0) {
+ [void]$Problems.Add("logical_processor_count_invalid")
+ }
+ if ($ScanCommands.Count -eq 1 -and $Decoders.Count -eq 1 -and
+ -not (Test-NvdecScanCommand $ScanCommands[0] $Decoders[0])) {
+ [void]$Problems.Add("nvdec_scan_argv_invalid")
+ }
+ }
+ $ValidActive = @($Active | Where-Object {
+ -not $_.warmup -and $_.cpu_valid -and
+ $null -ne $_.previous -and
+ -not [double]::IsNaN($_.host_cpu)
+ })
+ if ($ValidActive.Count -eq 0) {
+ [void]$Problems.Add("no_valid_scan_cpu_samples")
+ }
+ foreach ($Row in $ValidActive) {
+ if ($Row.interval -le 0 -or $Row.interval -gt $MaximumIntervalSeconds) {
+ [void]$Problems.Add("invalid_interval_row_$($Row.row_number)=$($Row.interval)")
+ }
+ }
+
+ $ScanStart = $null
+ $ScanEnd = $null
+ if ($ValidActive.Count -gt 0) {
+ $ScanStart = $ValidActive[0].previous
+ $ScanEnd = $ValidActive[-1].timestamp
+ }
+ $Windows = New-Object System.Collections.Generic.List[object]
+ if ($null -ne $ScanStart -and $null -ne $ScanEnd) {
+ foreach ($EndRow in $ValidActive) {
+ $WindowEnd = $EndRow.timestamp
+ $WindowStart = $WindowEnd.AddSeconds(-$RollingWindowSeconds)
+ if ($WindowStart -lt $ScanStart) {
+ continue
+ }
+ $Members = New-Object System.Collections.Generic.List[object]
+ $Coverage = 0.0
+ $WeightedCpu = 0.0
+ $MaximumInterval = 0.0
+ foreach ($Row in $ValidActive) {
+ $Overlap = Get-OverlapSeconds $Row.previous $Row.timestamp $WindowStart $WindowEnd
+ if ($Overlap -le 0) {
+ continue
+ }
+ [void]$Members.Add($Row)
+ $Coverage += $Overlap
+ $WeightedCpu += $Row.host_cpu * $Overlap
+ $MaximumInterval = [Math]::Max($MaximumInterval, $Row.interval)
+ }
+ $MemberCount = $Members.Count
+ $Dense = (
+ $Coverage -ge ($RollingWindowSeconds - 0.001) -and
+ $MemberCount -ge $MinimumWindowMembers -and
+ $MaximumInterval -le $MaximumIntervalSeconds
+ )
+ $WindowCpu = if ($Coverage -gt 0) { $WeightedCpu / $Coverage } else { [double]::NaN }
+ [void]$Windows.Add([pscustomobject][ordered]@{
+ window_start_utc = $WindowStart.ToString("o")
+ window_end_utc = $WindowEnd.ToString("o")
+ member_rows = (($Members | ForEach-Object { $_.row_number }) -join ";")
+ member_count = $MemberCount
+ coverage_seconds = [Math]::Round($Coverage, 6)
+ maximum_interval_seconds = [Math]::Round($MaximumInterval, 6)
+ weighted_host_cpu_percent = if ([double]::IsNaN($WindowCpu)) { "" } else { [Math]::Round($WindowCpu, 6) }
+ density_valid = $Dense
+ cpu_below_85 = (-not [double]::IsNaN($WindowCpu) -and $WindowCpu -lt $CpuThresholdPercent)
+ })
+ if (-not $Dense) {
+ [void]$Problems.Add("window_density_invalid_$($WindowEnd.ToString('o'))")
+ }
+ }
+ }
+ if ($Windows.Count -eq 0) {
+ [void]$Problems.Add("window_count_zero")
+ }
+
+ $HighSegments = New-Object System.Collections.Generic.List[object]
+ $CurrentSegment = $null
+ foreach ($Row in $ValidActive) {
+ $IsHigh = $Row.host_cpu -ge $CpuThresholdPercent
+ $IntervalContinuous = $Row.interval -gt 0 -and $Row.interval -le $MaximumIntervalSeconds
+ if ($IsHigh -and $IntervalContinuous) {
+ if ($null -eq $CurrentSegment) {
+ $CurrentSegment = [pscustomobject]@{
+ start = $Row.previous
+ end = $Row.timestamp
+ members = New-Object System.Collections.Generic.List[int]
+ reason = "scan_end"
+ }
+ } elseif ([Math]::Abs(($Row.previous - $CurrentSegment.end).TotalMilliseconds) -gt 1.0) {
+ $CurrentSegment.reason = "timestamp_gap"
+ $CurrentSegment = Complete-HighCpuSegment $HighSegments $CurrentSegment
+ $CurrentSegment = [pscustomobject]@{
+ start = $Row.previous
+ end = $Row.timestamp
+ members = New-Object System.Collections.Generic.List[int]
+ reason = "scan_end"
+ }
+ }
+ $CurrentSegment.end = $Row.timestamp
+ [void]$CurrentSegment.members.Add([int]$Row.row_number)
+ } else {
+ if ($null -ne $CurrentSegment) {
+ $CurrentSegment.reason = if ($IsHigh) { "invalid_interval" } else { "cpu_below_threshold" }
+ $CurrentSegment = Complete-HighCpuSegment $HighSegments $CurrentSegment
+ }
+ }
+ }
+ $CurrentSegment = Complete-HighCpuSegment $HighSegments $CurrentSegment
+
+ $MaximumHighSeconds = 0.0
+ $LongestHighSegment = $null
+ if ($HighSegments.Count -gt 0) {
+ $MaximumHighSeconds = [double](($HighSegments | Measure-Object -Property duration_seconds -Maximum).Maximum)
+ $LongestHighSegment = $HighSegments | Sort-Object { [double]$_.duration_seconds } -Descending | Select-Object -First 1
+ }
+ $AllWindowCpuPass = $Windows.Count -gt 0 -and @(
+ $Windows | Where-Object { -not (Convert-ToBoolean $_.cpu_below_85) }
+ ).Count -eq 0
+ $SamplingValid = $Problems.Count -eq 0
+ $CpuGatePass = (
+ $SamplingValid -and
+ $AllWindowCpuPass -and
+ $MaximumHighSeconds -lt $MaximumHighCpuSecondsExclusive
+ )
+ $MaximumObservedInterval = 0.0
+ if ($ValidActive.Count -gt 0) {
+ $MaximumObservedInterval = [double](($ValidActive | Measure-Object -Property interval -Maximum).Maximum)
+ }
+ $WorstWindow = $null
+ if ($Windows.Count -gt 0) {
+ $WorstWindow = $Windows | Sort-Object { [double]$_.weighted_host_cpu_percent } -Descending | Select-Object -First 1
+ }
+ function Get-MetricPeak([object[]]$MetricRows, [string]$Property) {
+ $Values = @($MetricRows | ForEach-Object { $_.$Property } | Where-Object { -not [double]::IsNaN([double]$_) })
+ if ($Values.Count -eq 0) { return $null }
+ return [double](($Values | Measure-Object -Maximum).Maximum)
+ }
+
+ $WindowsPath = Join-Path $OutputDirectory "resource_windows.csv"
+ $SegmentsPath = Join-Path $OutputDirectory "high_cpu_segments.csv"
+ $SummaryPath = Join-Path $OutputDirectory "resource_summary.json"
+ $Windows | Export-Csv -LiteralPath $WindowsPath -NoTypeInformation -Encoding UTF8
+ $HighSegments | Export-Csv -LiteralPath $SegmentsPath -NoTypeInformation -Encoding UTF8
+ $Summary = [ordered]@{
+ generated_at_utc = [DateTime]::UtcNow.ToString("o")
+ sampling_valid = $SamplingValid
+ sampling_problems = @($Problems)
+ counter_warmup_rows = @($Parsed | Where-Object { $_.warmup }).Count
+ valid_scan_sample_count = $ValidActive.Count
+ scan_start_utc = if ($null -eq $ScanStart) { $null } else { $ScanStart.ToString("o") }
+ scan_end_utc = if ($null -eq $ScanEnd) { $null } else { $ScanEnd.ToString("o") }
+ scan_ffmpeg_pid = if ($Active.Count -eq 0) { $null } else { $Active[0].scan_pid }
+ scan_ffmpeg_command_line = if ($Active.Count -eq 0) { $null } else { $Active[0].scan_command }
+ decoder = if ($Active.Count -eq 0) { $null } else { $Active[0].decoder }
+ logical_processor_count = if ($Active.Count -eq 0) { $null } else { $Active[0].logical_processors }
+ nvdec_scan_argv_evidence = if ($Active.Count -eq 0) {
+ $false
+ } else {
+ Test-NvdecScanCommand $Active[0].scan_command $Active[0].decoder
+ }
+ configured_interval_milliseconds = $SampleIntervalMilliseconds
+ maximum_observed_interval_seconds = [Math]::Round($MaximumObservedInterval, 6)
+ rolling_window_seconds = $RollingWindowSeconds
+ minimum_window_members = $MinimumWindowMembers
+ window_count = $Windows.Count
+ worst_window = $WorstWindow
+ all_windows_host_cpu_strictly_below_85 = $AllWindowCpuPass
+ high_cpu_threshold_percent = $CpuThresholdPercent
+ maximum_high_cpu_segment_seconds = [Math]::Round($MaximumHighSeconds, 6)
+ longest_high_cpu_segment = $LongestHighSegment
+ maximum_high_cpu_seconds_exclusive = $MaximumHighCpuSecondsExclusive
+ cpu_gate_pass = $CpuGatePass
+ task_cpu_peak_percent = Get-MetricPeak $ValidActive "task_cpu"
+ task_working_set_peak_bytes = Get-MetricPeak $ValidActive "task_ram"
+ gpu_utilization_peak_percent = Get-MetricPeak $ValidActive "gpu_util"
+ gpu_decoder_utilization_peak_percent = Get-MetricPeak $ValidActive "decoder_util"
+ gpu_memory_used_peak_mib = Get-MetricPeak $ValidActive "gpu_memory"
+ task_gpu_dedicated_memory_peak_mib = Get-MetricPeak $ValidActive "task_gpu_memory"
+ }
+ Write-JsonFile $SummaryPath $Summary
+ return [pscustomobject]@{
+ Summary = $Summary
+ SummaryPath = $SummaryPath
+ WindowsPath = $WindowsPath
+ SegmentsPath = $SegmentsPath
+ }
+}
+
+if ($PSCmdlet.ParameterSetName -eq "Analyze") {
+ $AnalyzeSamplesPath = Resolve-Leaf $AnalyzeSamplesPath "samples CSV"
+ $EvidenceDirectory = Resolve-FuturePath $EvidenceDirectory
+ if (-not (Test-Path -LiteralPath $EvidenceDirectory -PathType Container)) {
+ [void](New-Item -ItemType Directory -Path $EvidenceDirectory)
+ }
+ $Analysis = Analyze-ResourceSamples $AnalyzeSamplesPath $EvidenceDirectory
+ Write-Output "sampling_valid=$($Analysis.Summary.sampling_valid)"
+ Write-Output "window_count=$($Analysis.Summary.window_count)"
+ Write-Output "cpu_gate_pass=$($Analysis.Summary.cpu_gate_pass)"
+ exit 0
+}
+
+$PythonPath = Resolve-Leaf $PythonPath "Python"
+$ScriptPath = Resolve-Leaf $ScriptPath "media script"
+$VideoPath = Resolve-Leaf $VideoPath "test video"
+$OutputPath = Resolve-FuturePath $OutputPath
+$EvidenceDirectory = Resolve-FuturePath $EvidenceDirectory
+if (Test-Path -LiteralPath $EvidenceDirectory) {
+ throw "Evidence directory already exists; refusing to overwrite: $EvidenceDirectory"
+}
+[void](New-Item -ItemType Directory -Path $EvidenceDirectory)
+
+$StdoutPath = Join-Path $EvidenceDirectory "stdout.log"
+$StderrPath = Join-Path $EvidenceDirectory "stderr.log"
+$SamplesPath = Join-Path $EvidenceDirectory "resource_samples.csv"
+$CommandPath = Join-Path $EvidenceDirectory "command.json"
+$SourceBeforePath = Join-Path $EvidenceDirectory "source_before.json"
+$SourceAfterPath = Join-Path $EvidenceDirectory "source_after.json"
+[System.IO.File]::WriteAllText($StdoutPath, [string]::Empty, $Utf8NoBom)
+[System.IO.File]::WriteAllText($StderrPath, [string]::Empty, $Utf8NoBom)
+
+function Get-SourceSnapshot([string]$Path) {
+ $Item = Get-Item -LiteralPath $Path
+ return [ordered]@{
+ path = $Item.FullName
+ size_bytes = [int64]$Item.Length
+ creation_time_utc = $Item.CreationTimeUtc.ToString("o")
+ last_write_time_utc = $Item.LastWriteTimeUtc.ToString("o")
+ sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
+ }
+}
+Write-JsonFile $SourceBeforePath (Get-SourceSnapshot $VideoPath)
+
+if (-not ("NativeProcessTree" -as [type])) {
+ Add-Type -TypeDefinition @"
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+public static class NativeProcessTree {
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct PROCESSENTRY32 {
+ public uint dwSize; public uint cntUsage; public uint th32ProcessID;
+ public IntPtr th32DefaultHeapID; public uint th32ModuleID; public uint cntThreads;
+ public uint th32ParentProcessID; public int pcPriClassBase; public uint dwFlags;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExeFile;
+ }
+ public sealed class Entry {
+ public int ProcessId; public int ParentProcessId; public string Name;
+ }
+ [DllImport("kernel32.dll", SetLastError=true)] private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid);
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern bool Process32FirstW(IntPtr snapshot, ref PROCESSENTRY32 entry);
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern bool Process32NextW(IntPtr snapshot, ref PROCESSENTRY32 entry);
+ [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle);
+ public static Entry[] Snapshot() {
+ IntPtr handle = CreateToolhelp32Snapshot(2, 0);
+ if (handle == new IntPtr(-1)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
+ var result = new List<Entry>();
+ try {
+ var item = new PROCESSENTRY32(); item.dwSize = (uint)Marshal.SizeOf(item);
+ if (Process32FirstW(handle, ref item)) do {
+ result.Add(new Entry { ProcessId=(int)item.th32ProcessID, ParentProcessId=(int)item.th32ParentProcessID, Name=item.szExeFile });
+ item.dwSize = (uint)Marshal.SizeOf(item);
+ } while (Process32NextW(handle, ref item));
+ } finally { CloseHandle(handle); }
+ return result.ToArray();
+ }
+}
+"@
+}
+
+$Arguments = @("-X", "utf8", "-u", "-B", $ScriptPath, $VideoPath, "--output", $OutputPath)
+$ArgumentString = (($Arguments | ForEach-Object { Quote-ProcessArgument $_ }) -join " ")
+$StartInfo = New-Object System.Diagnostics.ProcessStartInfo
+$StartInfo.FileName = $PythonPath
+$StartInfo.Arguments = $ArgumentString
+$StartInfo.WorkingDirectory = (Get-Location).Path
+$StartInfo.UseShellExecute = $false
+$StartInfo.CreateNoWindow = $true
+$StartInfo.RedirectStandardOutput = $true
+$StartInfo.RedirectStandardError = $true
+$StartInfo.StandardOutputEncoding = [Text.Encoding]::UTF8
+$StartInfo.StandardErrorEncoding = [Text.Encoding]::UTF8
+$Process = New-Object System.Diagnostics.Process
+$Process.StartInfo = $StartInfo
+$Samples = New-Object System.Collections.Generic.List[object]
+$SamplingErrors = New-Object System.Collections.Generic.List[string]
+$HostCpuCounter = New-Object Diagnostics.PerformanceCounter("Processor", "% Processor Time", "_Total", $true)
+[void]$HostCpuCounter.NextValue()
+$GpuMemoryCategory = $null
+try {
+ $GpuMemoryCategory = New-Object Diagnostics.PerformanceCounterCategory("GPU Process Memory")
+ [void]$GpuMemoryCategory.GetInstanceNames()
+} catch {
+ [void]$SamplingErrors.Add("$([DateTime]::UtcNow.ToString('o')) GPU Process Memory category unavailable: $($_.Exception.Message)")
+}
+$CimWarmup = @(Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -Property ProcessId,CommandLine -ErrorAction Stop)
+if ($CimWarmup.Count -ne 1 -or [string]::IsNullOrWhiteSpace([string]$CimWarmup[0].CommandLine)) {
+ throw "CIM Win32_Process command-line warm-up failed"
+}
+$GpuWarmupRows = @(& nvidia-smi --query-gpu=utilization.gpu,utilization.decoder,memory.used --format=csv,noheader,nounits 2>&1)
+if ($LASTEXITCODE -ne 0 -or $GpuWarmupRows.Count -eq 0) {
+ throw "nvidia-smi GPU/decode warm-up failed"
+}
+$ComputeWarmupRows = @(& nvidia-smi --query-compute-apps=pid,used_gpu_memory --format=csv,noheader,nounits 2>&1)
+if ($LASTEXITCODE -ne 0) {
+ throw "nvidia-smi compute-process warm-up failed"
+}
+$TestCimDelayMilliseconds = 0
+if (-not [string]::IsNullOrWhiteSpace($env:MBX_MEDIA_SAMPLER_TEST_CIM_DELAY_MS)) {
+ $TestCimDelayMilliseconds = [int]$env:MBX_MEDIA_SAMPLER_TEST_CIM_DELAY_MS
+ if ($TestCimDelayMilliseconds -lt 0 -or $TestCimDelayMilliseconds -gt 1000) {
+ throw "MBX_MEDIA_SAMPLER_TEST_CIM_DELAY_MS must be 0..1000"
+ }
+}
+$InjectSampleFailureOnce = $env:MBX_MEDIA_SAMPLER_TEST_FAIL_SAMPLE_ONCE -eq "1"
+$InjectedSampleFailureCount = 0
+$CommandLineDisappearanceRaceCount = 0
+$StartInfo.EnvironmentVariables["MBX_MEDIA_SAMPLER_READY"] = "1"
+[void]$Process.Start()
+$StdoutTask = $Process.StandardOutput.ReadToEndAsync()
+$StderrTask = $Process.StandardError.ReadToEndAsync()
+$RootPid = [int]$Process.Id
+$StartUtc = [DateTime]::UtcNow
+$Stopwatch = [Diagnostics.Stopwatch]::StartNew()
+$PreviousCpuByPid = @{}
+$PreviousCounterTimestamp = $null
+$CommandLinesByPid = @{}
+$FirstCounter = $true
+$LastSchedule = 0L
+$ScanPid = $null
+$ScanCommandLine = ""
+$ScanDecoder = ""
+$ScanPidWasPresent = $false
+$ScanPhaseClosed = $false
+$ScanFfmpegSeen = $false
+$MultipleScanPidsSeen = $false
+$ScanCloseCommitted = $false
+$LogicalProcessorCount = [Environment]::ProcessorCount
+
+do {
+ try {
+ $Now = [DateTime]::UtcNow
+ $HostCpu = [double]$HostCpuCounter.NextValue()
+ $Warmup = $FirstCounter
+ $CpuValid = -not $Warmup -and -not [double]::IsNaN($HostCpu) -and -not [double]::IsInfinity($HostCpu)
+ $Interval = if ($null -eq $PreviousCounterTimestamp) { 0.0 } else { ($Now - $PreviousCounterTimestamp).TotalSeconds }
+ $PreviousText = if ($null -eq $PreviousCounterTimestamp) { "" } else { $PreviousCounterTimestamp.ToString("o") }
+
+ $Snapshot = @([NativeProcessTree]::Snapshot())
+ $TaskPids = New-Object 'System.Collections.Generic.HashSet[int]'
+ [void]$TaskPids.Add($RootPid)
+ do {
+ $Added = $false
+ foreach ($Entry in $Snapshot) {
+ if ($TaskPids.Contains([int]$Entry.ParentProcessId) -and -not $TaskPids.Contains([int]$Entry.ProcessId)) {
+ [void]$TaskPids.Add([int]$Entry.ProcessId)
+ $Added = $true
+ }
+ }
+ } while ($Added)
+
+ $WorkingSet = 0L
+ $TaskCpu = 0.0
+ $Priorities = New-Object System.Collections.Generic.List[object]
+ $FfmpegPids = New-Object System.Collections.Generic.List[int]
+ foreach ($PidValue in $TaskPids) {
+ $SnapshotEntry = $Snapshot | Where-Object { [int]$_.ProcessId -eq [int]$PidValue } | Select-Object -First 1
+ $SnapshotWasFfmpeg = $null -ne $SnapshotEntry -and [string]$SnapshotEntry.Name -ieq "ffmpeg.exe"
+ try {
+ $TaskProcess = Get-Process -Id $PidValue -ErrorAction Stop
+ $WorkingSet += [int64]$TaskProcess.WorkingSet64
+ $CpuTotal = [double]$TaskProcess.TotalProcessorTime.TotalSeconds
+ if ($CpuValid -and $Interval -gt 0 -and $PreviousCpuByPid.ContainsKey($PidValue)) {
+ $Delta = [Math]::Max(0.0, $CpuTotal - [double]$PreviousCpuByPid[$PidValue])
+ $TaskCpu += $Delta / ($Interval * [Environment]::ProcessorCount) * 100.0
+ }
+ $PreviousCpuByPid[$PidValue] = $CpuTotal
+ [void]$Priorities.Add([ordered]@{ pid=$PidValue; name=$TaskProcess.ProcessName; priority=[string]$TaskProcess.PriorityClass })
+ if ($TaskProcess.ProcessName -ieq "ffmpeg") {
+ [void]$FfmpegPids.Add([int]$PidValue)
+ if (-not $CommandLinesByPid.ContainsKey($PidValue)) {
+ if ($TestCimDelayMilliseconds -gt 0 -and $null -ne $ScanPid) {
+ Start-Sleep -Milliseconds $TestCimDelayMilliseconds
+ }
+ $CimRows = @(Get-CimInstance Win32_Process -Filter "ProcessId=$PidValue" -Property ProcessId,CommandLine -ErrorAction Stop)
+ if ($CimRows.Count -eq 0) {
+ $StillRunning = Get-Process -Id $PidValue -ErrorAction SilentlyContinue
+ if ($null -eq $StillRunning) {
+ $CommandLineDisappearanceRaceCount += 1
+ continue
+ }
+ throw "CommandLine query returned no row for live FFmpeg PID $PidValue"
+ }
+ $QueriedCommandLine = [string]$CimRows[0].CommandLine
+ if ([string]::IsNullOrWhiteSpace($QueriedCommandLine)) {
+ throw "CommandLine query returned an empty value for live FFmpeg PID $PidValue"
+ }
+ $CommandLinesByPid[$PidValue] = $QueriedCommandLine
+ }
+ $CandidateCommand = [string]$CommandLinesByPid[$PidValue]
+ if (Test-ScanFfmpegCommand $CandidateCommand) {
+ if ($null -eq $ScanPid) {
+ $ScanPid = [int]$PidValue
+ $ScanCommandLine = $CandidateCommand
+ $ScanDecoder = Get-DecoderFromCommand $CandidateCommand
+ } elseif ([int]$ScanPid -ne [int]$PidValue) {
+ $MultipleScanPidsSeen = $true
+ [void]$SamplingErrors.Add("$($Now.ToString('o')) multiple stable-scan FFmpeg PIDs: $ScanPid and $PidValue")
+ }
+ }
+ }
+ } catch {
+ $StillRunningAfterError = Get-Process -Id $PidValue -ErrorAction SilentlyContinue
+ if ($null -eq $StillRunningAfterError) {
+ if ($SnapshotWasFfmpeg) {
+ $CommandLineDisappearanceRaceCount += 1
+ }
+ continue
+ }
+ throw
+ }
+ }
+
+ $ScanPresent = $null -ne $ScanPid -and $TaskPids.Contains([int]$ScanPid) -and $FfmpegPids.Contains([int]$ScanPid)
+ $ScanPhaseActive = $false
+ $ScanExitObserved = $false
+ if (-not $ScanPhaseClosed -and $null -ne $ScanPid) {
+ if ($ScanPresent) {
+ $ScanPhaseActive = $true
+ $ScanPidWasPresent = $true
+ $ScanFfmpegSeen = $true
+ } elseif ($ScanPidWasPresent) {
+ $ScanPhaseActive = $true
+ $ScanExitObserved = $true
+ $ScanPhaseClosed = $true
+ }
+ }
+
+ $GpuQueryStatus = "ok"
+ $GpuUtil = [double]::NaN
+ $DecoderUtil = [double]::NaN
+ $GpuMemory = [double]::NaN
+ $GpuRows = @(& nvidia-smi --query-gpu=utilization.gpu,utilization.decoder,memory.used --format=csv,noheader,nounits 2>&1)
+ $GpuExitCode = $LASTEXITCODE
+ $GpuRawRows = [string](ConvertTo-Json -Compress -InputObject @($GpuRows | ForEach-Object { [string]$_ }))
+ try {
+ if ($GpuExitCode -ne 0 -or $GpuRows.Count -eq 0) { throw "exit=$GpuExitCode rows=$GpuRawRows" }
+ $GpuParts = @($GpuRows[0] -split ',' | ForEach-Object { $_.Trim() })
+ if ($GpuParts.Count -lt 3) { throw "unexpected row: $($GpuRows[0])" }
+ $GpuUtil = Convert-ToMetric $GpuParts[0]
+ $DecoderUtil = Convert-ToMetric $GpuParts[1]
+ $GpuMemory = Convert-ToMetric $GpuParts[2]
+ } catch {
+ $GpuQueryStatus = "error"
+ [void]$SamplingErrors.Add("$($Now.ToString('o')) nvidia-smi GPU/decode query failed: $($_.Exception.Message)")
+ }
+
+ $ComputeQueryStatus = "ok"
+ $ComputeRows = @(& nvidia-smi --query-compute-apps=pid,used_gpu_memory --format=csv,noheader,nounits 2>&1)
+ $ComputeExitCode = $LASTEXITCODE
+ $ComputeRawRows = [string](ConvertTo-Json -Compress -InputObject @($ComputeRows | ForEach-Object { [string]$_ }))
+ $ComputeTaskRows = New-Object System.Collections.Generic.List[string]
+ if ($ComputeExitCode -ne 0) {
+ $ComputeQueryStatus = "error"
+ [void]$SamplingErrors.Add("$($Now.ToString('o')) nvidia-smi compute query failed: exit=$ComputeExitCode rows=$ComputeRawRows")
+ } else {
+ foreach ($ComputeRow in $ComputeRows) {
+ if ([string]$ComputeRow -match '^\s*(\d+)\s*,') {
+ if ($TaskPids.Contains([int]$Matches[1])) { [void]$ComputeTaskRows.Add([string]$ComputeRow) }
+ }
+ }
+ }
+
+ $DedicatedBytes = 0.0
+ $GpuProcessMemoryStatus = "ok"
+ $GpuProcessInstances = New-Object System.Collections.Generic.List[object]
+ try {
+ if ($null -eq $GpuMemoryCategory) { throw "GPU Process Memory category unavailable" }
+ foreach ($Instance in @($GpuMemoryCategory.GetInstanceNames())) {
+ if ([string]$Instance -notmatch '(?i)(?:^|_)pid_(\d+)(?:_|$)') { continue }
+ $CounterPid = [int]$Matches[1]
+ if (-not $TaskPids.Contains($CounterPid)) { continue }
+ $CounterPath = "\GPU Process Memory($Instance)\Dedicated Usage"
+ $Counter = New-Object Diagnostics.PerformanceCounter("GPU Process Memory", "Dedicated Usage", [string]$Instance, $true)
+ try {
+ $CounterValue = [double]$Counter.NextValue()
+ $DedicatedBytes += $CounterValue
+ [void]$GpuProcessInstances.Add([ordered]@{
+ pid = $CounterPid
+ instance_name = [string]$Instance
+ counter_path = $CounterPath
+ dedicated_usage_bytes = [int64]$CounterValue
+ })
+ } finally { $Counter.Dispose() }
+ }
+ } catch {
+ $GpuProcessMemoryStatus = "error"
+ [void]$SamplingErrors.Add("$($Now.ToString('o')) GPU Process Memory query failed: $($_.Exception.Message)")
+ }
+
+ if ($InjectSampleFailureOnce -and $ScanCloseCommitted -and $InjectedSampleFailureCount -eq 0) {
+ $InjectedSampleFailureCount = 1
+ throw "injected post-scan sample failure before CSV commit"
+ }
+
+ $RootPriorityRecord = $Priorities | Where-Object { $_.pid -eq $RootPid } | Select-Object -First 1
+ if ($null -eq $RootPriorityRecord) {
+ $Process.Refresh()
+ if (-not $Process.HasExited) {
+ throw "Root process priority unavailable while PID $RootPid is still running"
+ }
+ $RootPriorityText = "Exited"
+ } else {
+ $RootPriorityText = [string]$RootPriorityRecord.priority
+ }
+
+ [void]$Samples.Add([pscustomobject][ordered]@{
+ timestamp_utc = $Now.ToString("o")
+ previous_valid_timestamp_utc = $PreviousText
+ interval_seconds = [Math]::Round($Interval, 6)
+ counter_warmup = $Warmup
+ cpu_valid = $CpuValid
+ host_cpu_percent = if ($CpuValid) { [Math]::Round($HostCpu, 6) } else { "" }
+ scan_phase_active = $ScanPhaseActive
+ scan_ffmpeg_present = $ScanPresent
+ scan_ffmpeg_exit_observed = $ScanExitObserved
+ scan_ffmpeg_pid = if ($null -eq $ScanPid) { "" } else { [int]$ScanPid }
+ scan_ffmpeg_command_line = $ScanCommandLine
+ decoder = $ScanDecoder
+ logical_processor_count = $LogicalProcessorCount
+ task_pids = (($TaskPids | Sort-Object) -join ";")
+ task_cpu_percent = if ($CpuValid) { [Math]::Round($TaskCpu, 6) } else { "" }
+ task_working_set_bytes = $WorkingSet
+ root_priority = $RootPriorityText
+ ffmpeg_priorities = [string](ConvertTo-Json -Compress -InputObject @(
+ $Priorities | Where-Object { $FfmpegPids.Contains([int]$_.pid) }
+ ))
+ ffmpeg_command_lines = [string](ConvertTo-Json -Compress -InputObject @(
+ $CommandLinesByPid.GetEnumerator() | ForEach-Object {
+ [ordered]@{ pid=[int]$_.Key; command_line=[string]$_.Value }
+ }
+ ))
+ gpu_query_status = $GpuQueryStatus
+ gpu_query_raw_rows = $GpuRawRows
+ gpu_utilization_percent = if ([double]::IsNaN($GpuUtil)) { "" } else { $GpuUtil }
+ gpu_decoder_utilization_percent = if ([double]::IsNaN($DecoderUtil)) { "" } else { $DecoderUtil }
+ gpu_memory_used_mib = if ([double]::IsNaN($GpuMemory)) { "" } else { $GpuMemory }
+ nvidia_smi_compute_query_status = $ComputeQueryStatus
+ nvidia_smi_compute_raw_rows = $ComputeRawRows
+ nvidia_smi_compute_task_rows = [string](ConvertTo-Json -Compress -InputObject $ComputeTaskRows.ToArray())
+ gpu_process_memory_query_status = $GpuProcessMemoryStatus
+ gpu_process_memory_instances = [string](ConvertTo-Json -Compress -InputObject $GpuProcessInstances.ToArray())
+ task_gpu_dedicated_memory_mib = [Math]::Round($DedicatedBytes / 1MB, 6)
+ })
+ $PreviousCounterTimestamp = $Now
+ $FirstCounter = $false
+ if ($ScanExitObserved) {
+ $ScanCloseCommitted = $true
+ }
+ } catch {
+ [void]$SamplingErrors.Add(
+ "$([DateTime]::UtcNow.ToString('o')) $($_.Exception.Message) at $($_.ScriptStackTrace)"
+ )
+ }
+
+ $Process.Refresh()
+ if (-not $Process.HasExited) {
+ $LastSchedule += $SampleIntervalMilliseconds
+ if ($LastSchedule -le $Stopwatch.ElapsedMilliseconds) {
+ $LastSchedule = $Stopwatch.ElapsedMilliseconds + 1
+ }
+ Start-Sleep -Milliseconds ([int][Math]::Max(1, $LastSchedule - $Stopwatch.ElapsedMilliseconds))
+ }
+} while (-not $Process.HasExited)
+
+$Process.WaitForExit()
+$StdoutText = [string]$StdoutTask.GetAwaiter().GetResult()
+$StderrText = [string]$StderrTask.GetAwaiter().GetResult()
+[System.IO.File]::WriteAllText($StdoutPath, $StdoutText, $Utf8NoBom)
+[System.IO.File]::WriteAllText($StderrPath, $StderrText, $Utf8NoBom)
+$HostCpuCounter.Dispose()
+$Stopwatch.Stop()
+$EndUtc = [DateTime]::UtcNow
+$Samples | Export-Csv -LiteralPath $SamplesPath -NoTypeInformation -Encoding UTF8
+$Analysis = Analyze-ResourceSamples $SamplesPath $EvidenceDirectory
+
+$SourceAfter = Get-SourceSnapshot $VideoPath
+Write-JsonFile $SourceAfterPath $SourceAfter
+$SourceBefore = Get-Content -LiteralPath $SourceBeforePath -Raw -Encoding UTF8 | ConvertFrom-Json
+$SourceUnchanged = (
+ [int64]$SourceBefore.size_bytes -eq [int64]$SourceAfter.size_bytes -and
+ [string]$SourceBefore.creation_time_utc -eq [string]$SourceAfter.creation_time_utc -and
+ [string]$SourceBefore.last_write_time_utc -eq [string]$SourceAfter.last_write_time_utc -and
+ [string]$SourceBefore.sha256 -eq [string]$SourceAfter.sha256
+)
+$AllPrioritiesBelowNormal = @($Samples | Where-Object {
+ $_.scan_ffmpeg_present -and (
+ $_.root_priority -ne "BelowNormal" -or
+ $_.ffmpeg_priorities -match '"priority":"(?!BelowNormal)'
+ )
+}).Count -eq 0
+$NvdecLogEvidence = $false
+$ExpectedNvdecLog = ""
+if (-not [string]::IsNullOrWhiteSpace($ScanDecoder)) {
+ $NormalizedScanDecoder = ([string]$ScanDecoder).Trim().ToLowerInvariant()
+ $ExpectedNvdecLog = "NVDEC/CUDA ($NormalizedScanDecoder)"
+ $PersistedStdoutText = [IO.File]::ReadAllText($StdoutPath, [Text.Encoding]::UTF8)
+ $NvdecLogEvidence = $PersistedStdoutText.Contains($ExpectedNvdecLog)
+}
+$NvdecCommandEvidence = Test-NvdecScanCommand $ScanCommandLine $ScanDecoder
+$SamplingValid = (
+ $Analysis.Summary.sampling_valid -and
+ $SamplingErrors.Count -eq 0 -and
+ $ScanFfmpegSeen -and
+ -not $MultipleScanPidsSeen -and
+ $ScanPhaseClosed -and
+ $AllPrioritiesBelowNormal
+)
+$ResourcePass = (
+ [int]$Process.ExitCode -eq 0 -and
+ $SamplingValid -and
+ $Analysis.Summary.cpu_gate_pass -and
+ $NvdecLogEvidence -and
+ $NvdecCommandEvidence -and
+ $SourceUnchanged
+)
+$RunSummary = [ordered]@{
+ generated_at_utc = [DateTime]::UtcNow.ToString("o")
+ target_exit_code = [int]$Process.ExitCode
+ elapsed_seconds = [Math]::Round($Stopwatch.Elapsed.TotalSeconds, 3)
+ root_python_pid = $RootPid
+ sample_count = $Samples.Count
+ sampling_errors = @($SamplingErrors)
+ command_line_disappearance_race_count = $CommandLineDisappearanceRaceCount
+ injected_sample_failure_count = $InjectedSampleFailureCount
+ sampling_valid = $SamplingValid
+ cpu_gate_pass = $Analysis.Summary.cpu_gate_pass
+ scan_ffmpeg_seen = $ScanFfmpegSeen
+ scan_ffmpeg_pid = $ScanPid
+ scan_ffmpeg_command_line = $ScanCommandLine
+ decoder = $ScanDecoder
+ all_scan_priorities_below_normal = $AllPrioritiesBelowNormal
+ nvdec_command_log_evidence = $NvdecLogEvidence
+ nvdec_expected_log = $ExpectedNvdecLog
+ nvdec_scan_argv_evidence = $NvdecCommandEvidence
+ valid_scan_sample_count = $Analysis.Summary.valid_scan_sample_count
+ longest_high_cpu_segment = $Analysis.Summary.longest_high_cpu_segment
+ task_cpu_peak_percent = $Analysis.Summary.task_cpu_peak_percent
+ task_working_set_peak_bytes = $Analysis.Summary.task_working_set_peak_bytes
+ gpu_utilization_peak_percent = $Analysis.Summary.gpu_utilization_peak_percent
+ gpu_decoder_utilization_peak_percent = $Analysis.Summary.gpu_decoder_utilization_peak_percent
+ gpu_memory_used_peak_mib = $Analysis.Summary.gpu_memory_used_peak_mib
+ task_gpu_dedicated_memory_peak_mib = $Analysis.Summary.task_gpu_dedicated_memory_peak_mib
+ source_unchanged = $SourceUnchanged
+ resource_acceptance_pass = $ResourcePass
+}
+Write-JsonFile (Join-Path $EvidenceDirectory "run_summary.json") $RunSummary
+Write-JsonFile $CommandPath ([ordered]@{
+ executable = $PythonPath
+ arguments = $Arguments
+ command_line = "$PythonPath $ArgumentString"
+ working_directory = (Get-Location).Path
+ start_time_utc = $StartUtc.ToString("o")
+ end_time_utc = $EndUtc.ToString("o")
+ exit_code = [int]$Process.ExitCode
+})
+Write-Output "target_exit_code=$([int]$Process.ExitCode)"
+Write-Output "sampling_valid=$SamplingValid"
+Write-Output "cpu_gate_pass=$($Analysis.Summary.cpu_gate_pass)"
+Write-Output "resource_acceptance_pass=$ResourcePass"
+if ([int]$Process.ExitCode -ne 0) {
+ exit ([int]$Process.ExitCode)
+}
+if (-not $ResourcePass) {
+ exit 3
+}
+exit 0
diff --git a/dev/project-dev/test/test_bili_dynamic_collector.py b/dev/project-dev/test/test_bili_dynamic_collector.py
new file mode 100644
index 0000000..31e2505
--- /dev/null
+++ b/dev/project-dev/test/test_bili_dynamic_collector.py
@@ -0,0 +1,653 @@
+from __future__ import annotations
+
+import hashlib
+import importlib.util
+import io
+import json
+import os
+import sys
+import tempfile
+import unittest
+from contextlib import redirect_stdout
+from datetime import datetime
+from pathlib import Path
+from unittest import mock
+
+
+MODULE_PATH = Path(__file__).resolve().parents[1] / "bili_dynamic_collector.py"
+SPEC = importlib.util.spec_from_file_location("bili_dynamic_collector", MODULE_PATH)
+assert SPEC and SPEC.loader
+collector = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = collector
+SPEC.loader.exec_module(collector)
+
+FIXTURE = Path(__file__).resolve().parent / "fixtures" / "bili_dynamic_collector" / "dynamic_items.json"
+NOW = "2026-08-04T12:00:00+08:00"
+
+
+class BiliDynamicCollectorTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temp = tempfile.TemporaryDirectory()
+ self.root = Path(self.temp.name)
+ self.config_path = self.root / "config.json"
+ self.state_dir = self.root / "state"
+ self.download_dir = self.root / "downloads"
+ self.video_dir = self.root / "videos"
+ self.download_dir.mkdir()
+ self.video_dir.mkdir()
+ self.write_json(
+ self.config_path,
+ {
+ "schema_version": 1,
+ "creator": {
+ "name": "青枫浦上Q",
+ "dynamic_url": "https://space.bilibili.com/1420210197/dynamic",
+ },
+ "timezone": "Asia/Shanghai",
+ "window_hours": 72,
+ "minimum_complete_age_seconds": 0,
+ "title_max_length": 48,
+ "allowed_source_hosts": [
+ "space.bilibili.com",
+ "www.bilibili.com",
+ "t.bilibili.com",
+ "b23.tv",
+ ],
+ "allowed_video_extensions": [".mp4", ".mkv", ".mov", ".webm"],
+ "native_handoff": {
+ "project_id": "project-info",
+ "source_ai_id": "video-downloader",
+ "source_thread_id": "019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5",
+ "source_role_instance_id": "case_analysis.video_downloader",
+ "target_ai_id": "media-processor",
+ "target_thread_id": "019fb7a4-bdfd-79f2-bd6b-e67e2b7d8efd",
+ "target_role_instance_id": "case_analysis.media_processor",
+ "reply_thread_id": "019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5",
+ },
+ "paths": {
+ "state_dir": "state",
+ "download_dir": "downloads",
+ "video_dir": "videos",
+ },
+ },
+ )
+
+ def tearDown(self) -> None:
+ self.temp.cleanup()
+
+ @staticmethod
+ def write_json(path: Path, value: object) -> None:
+ path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
+
+ def run_check(self) -> tuple[int, dict[str, object]]:
+ return collector.run(
+ ["--config", str(self.config_path), "check", "--input", str(FIXTURE), "--now", NOW]
+ )
+
+ def run_main_json(self, argv: list[str]) -> tuple[int, dict[str, object], str]:
+ stream = io.StringIO()
+ with redirect_stdout(stream):
+ code = collector.main(argv)
+ text = stream.getvalue()
+ return code, json.loads(text), text
+
+ def queue_video(self) -> dict[str, object]:
+ code, result = self.run_check()
+ self.assertEqual(0, code)
+ self.assertEqual("TODO_GENERATED", result["status"])
+ manifest = collector.load_manifest(self.state_dir / "manifest.jsonl")
+ return next(event for event in manifest if event["content_type"] == "video")
+
+ def test_windows_name_is_safe_and_preserves_timestamp(self) -> None:
+ self.assertEqual("_CON", collector.sanitize_windows_component("CON", 48))
+ self.assertEqual("a_b_c", collector.sanitize_windows_component('a<b>c. ', 48))
+ config = collector.load_config(self.config_path)
+ item = collector.normalize_item(
+ {
+ "dynamic_id": "d1",
+ "content_type": "text",
+ "published_at": "2026-08-04T10:00:00+08:00",
+ "title": "午盘: A/B?",
+ "source_url": "https://www.bilibili.com/opus/d1",
+ },
+ config,
+ 0,
+ )
+ self.assertEqual("20260804-100000_文字_午盘_ A_B_", collector.suggested_base(item, config))
+
+ def test_check_filters_window_and_is_idempotent(self) -> None:
+ code, first = self.run_check()
+ self.assertEqual(0, code)
+ self.assertEqual("TODO_GENERATED", first["status"])
+ self.assertEqual(2, first["new_items"])
+ self.assertEqual(1, first["skipped_old"])
+ self.assertEqual(1, first["skipped_future"])
+ queue_path = Path(first["queue_path"])
+ self.assertTrue(queue_path.is_file())
+ queue = json.loads(queue_path.read_text(encoding="utf-8"))
+ self.assertEqual(2, len(queue["items"]))
+ self.assertTrue(queue["browser_interaction_required"])
+ self.assertFalse(queue["authentication_data_allowed"])
+ self.assertEqual(2, len(collector.load_manifest(self.state_dir / "manifest.jsonl")))
+
+ code, second = self.run_check()
+ self.assertEqual(0, code)
+ self.assertEqual("NO_NEW_ITEMS", second["status"])
+ self.assertEqual(0, second["new_items"])
+ self.assertIsNone(second["queue_path"])
+ self.assertEqual([queue_path], list((self.state_dir / "queues").glob("*.json")))
+ self.assertEqual(2, len(collector.load_manifest(self.state_dir / "manifest.jsonl")))
+
+ def test_safe_move_hash_manifest_and_handoff_are_idempotent(self) -> None:
+ queued = self.queue_video()
+ source = self.download_dir / "extension-result.mp4"
+ payload = b"small synthetic completed video fixture"
+ source.write_bytes(payload)
+ completed_mtime = datetime.fromisoformat("2026-08-04T11:30:00+08:00").timestamp()
+ os.utime(source, (completed_mtime, completed_mtime))
+ mapping = self.root / "mapping.json"
+ self.write_json(
+ mapping,
+ {
+ "schema_version": 1,
+ "items": [{"source_file": source.name, "bvid": "BV1TEST00001"}],
+ },
+ )
+
+ code, moved = collector.run(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(0, code)
+ self.assertEqual("COMPLETE", moved["status"])
+ self.assertFalse(source.exists())
+ target = Path(moved["items"][0]["target"])
+ self.assertEqual(f"{queued['suggested_stem']}.mp4", target.name)
+ self.assertEqual(payload, target.read_bytes())
+ expected_hash = hashlib.sha256(payload).hexdigest()
+ self.assertEqual(expected_hash, moved["items"][0]["sha256"])
+ latest, _ = collector.latest_entities(collector.load_manifest(self.state_dir / "manifest.jsonl"))
+ event = latest[queued["entity_id"]]
+ self.assertEqual("VIDEO_MOVED", event["status"])
+ self.assertEqual("READY_FOR_HANDOFF", event["video_processing_status"])
+ self.assertEqual(expected_hash, event["sha256"])
+
+ manifest_before_rerun = (self.state_dir / "manifest.jsonl").read_bytes()
+ code, moved_again = collector.run(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(0, code)
+ self.assertEqual("ALREADY_MOVED", moved_again["items"][0]["status"])
+ self.assertEqual(expected_hash, moved_again["items"][0]["sha256"])
+ self.assertEqual(manifest_before_rerun, (self.state_dir / "manifest.jsonl").read_bytes())
+ self.assertEqual(payload, target.read_bytes())
+
+ code, handoff = collector.run(
+ ["--config", str(self.config_path), "handoff", "--now", NOW]
+ )
+ self.assertEqual(0, code)
+ self.assertEqual("GENERATED", handoff["write"])
+ draft = Path(handoff["handoff_path"])
+ text = draft.read_text(encoding="utf-8")
+ self.assertIn("message_type=video_processing_request", text)
+ self.assertIn("status=PROCESSING_REQUESTED", text)
+ self.assertRegex(text, r"handoff_id=HANDOFF-BILI-DYNAMIC-VIDEO-PROCESSING-[0-9A-F]{24}")
+ for field in (
+ "source_ai_id=video-downloader",
+ "source_thread_id=019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5",
+ "source_role_instance_id=case_analysis.video_downloader",
+ "target_ai_id=media-processor",
+ "target_thread_id=019fb7a4-bdfd-79f2-bd6b-e67e2b7d8efd",
+ "target_role_instance_id=case_analysis.media_processor",
+ "reply_thread_id=019fcc5d-798f-7ea1-8325-3a4d1f2dc5a5",
+ "scope:",
+ "evidence:",
+ "expected_action:",
+ ):
+ self.assertIn(field, text)
+ self.assertNotIn("generated_at=", text)
+ self.assertIn(json.dumps(str(target), ensure_ascii=False), text)
+ self.assertNotIn("cookie", text.lower())
+
+ code, repeated = collector.run(
+ ["--config", str(self.config_path), "handoff", "--now", "2026-08-04T13:00:00+08:00"]
+ )
+ self.assertEqual(0, code)
+ self.assertEqual("REUSED", repeated["write"])
+ self.assertEqual(draft, Path(repeated["handoff_path"]))
+
+ target.write_bytes(b"tampered after manifest")
+ config = collector.load_config(self.config_path)
+ with collector.StateLock(config.lock_path):
+ with self.assertRaises(collector.CollectorError) as caught:
+ collector.generate_handoff(config, None, collector.parse_now(NOW))
+ self.assertEqual("E_HANDOFF_HASH", caught.exception.code)
+
+ def test_existing_target_preflight_has_zero_business_side_effects(self) -> None:
+ queued = self.queue_video()
+ source = self.download_dir / "extension-result.mp4"
+ source.write_bytes(b"new download")
+ completed_mtime = datetime.fromisoformat("2026-08-04T11:30:00+08:00").timestamp()
+ os.utime(source, (completed_mtime, completed_mtime))
+ target = self.video_dir / f"{queued['suggested_stem']}.mp4"
+ target.write_bytes(b"existing unrelated file")
+ mapping = self.root / "mapping.json"
+ self.write_json(
+ mapping,
+ {"schema_version": 1, "items": [{"source_file": source.name, "dynamic_id": "dynamic-video-001"}]},
+ )
+ manifest_before = (self.state_dir / "manifest.jsonl").read_bytes()
+ config = collector.load_config(self.config_path)
+ with collector.StateLock(config.lock_path):
+ with self.assertRaisesRegex(collector.CollectorError, "refusing to overwrite") as caught:
+ collector.move_completed(config, mapping, collector.parse_now(NOW))
+ self.assertEqual("E_TARGET_EXISTS", caught.exception.code)
+ self.assertEqual(b"existing unrelated file", target.read_bytes())
+ self.assertEqual(b"new download", source.read_bytes())
+ self.assertEqual(manifest_before, (self.state_dir / "manifest.jsonl").read_bytes())
+ latest, _ = collector.latest_entities(collector.load_manifest(self.state_dir / "manifest.jsonl"))
+ self.assertEqual("TODO_QUEUED", latest[queued["entity_id"]]["status"])
+
+ code, retry = self.run_check()
+ self.assertEqual(0, code)
+ self.assertEqual("NO_NEW_ITEMS", retry["status"])
+
+ def test_incomplete_or_outside_download_is_a_safety_stop(self) -> None:
+ queued = self.queue_video()
+ incomplete = self.download_dir / "clip.part"
+ incomplete.write_bytes(b"partial")
+ mapping = self.root / "mapping.json"
+ self.write_json(
+ mapping,
+ {"schema_version": 1, "items": [{"source_file": incomplete.name, "bvid": queued["bvid"]}]},
+ )
+ code = collector.main(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(3, code)
+ self.assertTrue(incomplete.exists())
+
+ outside = self.root / "outside.mp4"
+ outside.write_bytes(b"outside")
+ self.write_json(
+ mapping,
+ {"schema_version": 1, "items": [{"source_file": str(outside), "bvid": queued["bvid"]}]},
+ )
+ code = collector.main(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(3, code)
+ self.assertTrue(outside.exists())
+
+ def test_exact_selectors_and_batch_uniqueness_fail_before_business_writes(self) -> None:
+ queued = self.queue_video()
+ events = collector.load_manifest(self.state_dir / "manifest.jsonl")
+ text_event = next(event for event in events if event["content_type"] == "text")
+ source = self.download_dir / "one.mp4"
+ source.write_bytes(b"one physical source")
+ completed_mtime = datetime.fromisoformat("2026-08-04T11:30:00+08:00").timestamp()
+ os.utime(source, (completed_mtime, completed_mtime))
+ manifest_path = self.state_dir / "manifest.jsonl"
+
+ cases = [
+ (
+ "unknown supplied selector",
+ [{"source_file": source.name, "bvid": queued["bvid"], "dynamic_id": "unknown-dynamic"}],
+ "E_MAPPING_SELECTOR_UNKNOWN",
+ ),
+ (
+ "selectors disagree",
+ [
+ {
+ "source_file": source.name,
+ "bvid": queued["bvid"],
+ "dynamic_id": text_event["dynamic_id"],
+ }
+ ],
+ "E_MAPPING_SELECTOR_CONFLICT",
+ ),
+ (
+ "entity repeated",
+ [
+ {"source_file": source.name, "bvid": queued["bvid"]},
+ {"source_file": source.name, "dynamic_id": queued["dynamic_id"]},
+ ],
+ "E_ENTITY_DUPLICATE",
+ ),
+ ]
+ for label, items, expected_code in cases:
+ with self.subTest(label=label):
+ mapping = self.root / "mapping.json"
+ self.write_json(mapping, {"schema_version": 1, "items": items})
+ manifest_before = manifest_path.read_bytes()
+ code, payload, _ = self.run_main_json(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(3, code)
+ self.assertEqual(expected_code, payload["error_code"])
+ self.assertEqual(manifest_before, manifest_path.read_bytes())
+ self.assertEqual(b"one physical source", source.read_bytes())
+ self.assertEqual([], list(self.video_dir.iterdir()))
+
+ second = dict(queued)
+ second.update(
+ {
+ "event_id": "second-video-event",
+ "entity_id": "second-video-entity",
+ "dynamic_id": "dynamic-video-002",
+ "bvid": "BV1TEST00002",
+ "source_url": "https://www.bilibili.com/video/BV1TEST00002",
+ "dedupe_keys": [
+ "bvid:bv1test00002",
+ "dynamic:dynamic-video-002",
+ "url:https://www.bilibili.com/video/BV1TEST00002",
+ ],
+ "suggested_stem": str(queued["suggested_stem"]) + "_second",
+ }
+ )
+ collector.append_manifest(manifest_path, [second])
+ mapping = self.root / "same-source.json"
+ self.write_json(
+ mapping,
+ {
+ "schema_version": 1,
+ "items": [
+ {"source_file": source.name, "bvid": queued["bvid"]},
+ {"source_file": str(source), "bvid": second["bvid"]},
+ ],
+ },
+ )
+ manifest_before = manifest_path.read_bytes()
+ code, payload, _ = self.run_main_json(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(3, code)
+ self.assertEqual("E_SOURCE_DUPLICATE", payload["error_code"])
+ self.assertEqual(manifest_before, manifest_path.read_bytes())
+ self.assertEqual(b"one physical source", source.read_bytes())
+ self.assertEqual([], list(self.video_dir.iterdir()))
+
+ def test_retained_source_warning_is_sticky_with_or_without_source(self) -> None:
+ queued = self.queue_video()
+ source = self.download_dir / "retained.mp4"
+ payload_bytes = b"retained synthetic bytes"
+ source.write_bytes(payload_bytes)
+ completed_mtime = datetime.fromisoformat("2026-08-04T11:30:00+08:00").timestamp()
+ os.utime(source, (completed_mtime, completed_mtime))
+ mapping = self.root / "mapping.json"
+ self.write_json(
+ mapping,
+ {"schema_version": 1, "items": [{"source_file": source.name, "bvid": queued["bvid"]}]},
+ )
+ path_class = type(source)
+ original_unlink = path_class.unlink
+
+ def fail_only_source(path: Path, *args: object, **kwargs: object) -> None:
+ if path == source:
+ raise PermissionError("injected source delete failure")
+ original_unlink(path, *args, **kwargs)
+
+ with mock.patch.object(path_class, "unlink", fail_only_source):
+ code, first = collector.run(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(4, code)
+ self.assertEqual("VIDEO_MOVED_SOURCE_RETAINED", first["items"][0]["status"])
+ target = Path(first["items"][0]["target"])
+ expected_hash = hashlib.sha256(payload_bytes).hexdigest()
+ self.assertEqual(expected_hash, first["items"][0]["sha256"])
+ self.assertTrue(source.exists())
+ manifest_before = (self.state_dir / "manifest.jsonl").read_bytes()
+
+ code, second = collector.run(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(4, code)
+ self.assertEqual("VIDEO_MOVED_SOURCE_RETAINED", second["items"][0]["status"])
+ self.assertEqual(first["items"][0]["source_delete_error"], second["items"][0]["source_delete_error"])
+ self.assertEqual(manifest_before, (self.state_dir / "manifest.jsonl").read_bytes())
+ self.assertEqual(payload_bytes, target.read_bytes())
+
+ source.unlink()
+ code, third = collector.run(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(4, code)
+ self.assertEqual("VIDEO_MOVED_SOURCE_RETAINED", third["items"][0]["status"])
+ self.assertEqual(expected_hash, third["items"][0]["sha256"])
+ self.assertEqual(first["items"][0]["source_delete_error"], third["items"][0]["source_delete_error"])
+ self.assertEqual(manifest_before, (self.state_dir / "manifest.jsonl").read_bytes())
+ self.assertEqual(payload_bytes, target.read_bytes())
+
+ def test_disallowed_latest_statuses_fail_before_stage_b(self) -> None:
+ queued = self.queue_video()
+ source = self.download_dir / "sentinel.mp4"
+ source.write_bytes(b"sentinel source")
+ mapping = self.root / "mapping.json"
+ self.write_json(
+ mapping,
+ {"schema_version": 1, "items": [{"source_file": source.name, "bvid": queued["bvid"]}]},
+ )
+ manifest_path = self.state_dir / "manifest.jsonl"
+ status_cases: list[object] = [
+ "PROCESSING_HANDOFF_CONFIRMED",
+ "PROCESSING",
+ "PROCESSING_FAILED",
+ None,
+ "",
+ "UNKNOWN_STATE",
+ ]
+ for status in status_cases:
+ with self.subTest(status=status):
+ event = dict(queued)
+ if status is None:
+ event.pop("status", None)
+ else:
+ event["status"] = status
+ manifest_path.write_bytes(collector.canonical_json_bytes(event))
+ before = {
+ str(path.relative_to(self.root)): path.read_bytes()
+ for path in self.root.rglob("*")
+ if path.is_file()
+ }
+ code, payload, _ = self.run_main_json(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ after = {
+ str(path.relative_to(self.root)): path.read_bytes()
+ for path in self.root.rglob("*")
+ if path.is_file()
+ }
+ self.assertEqual(3, code)
+ self.assertEqual("SAFETY_STOP", payload["status"])
+ self.assertEqual("E_STATUS", payload["error_code"])
+ self.assertEqual(before, after)
+ self.assertEqual([], list(self.video_dir.iterdir()))
+ self.assertFalse((self.state_dir / ".collector.lock").exists())
+
+ def test_manifest_secret_gate_is_shared_by_all_commands(self) -> None:
+ queued = self.queue_video()
+ source = self.download_dir / "secret-check.mp4"
+ source.write_bytes(b"source remains")
+ mapping = self.root / "mapping.json"
+ self.write_json(
+ mapping,
+ {"schema_version": 1, "items": [{"source_file": source.name, "bvid": queued["bvid"]}]},
+ )
+ manifest_path = self.state_dir / "manifest.jsonl"
+ base_event = dict(queued)
+ commands = [
+ ["check", "--input", str(FIXTURE), "--now", NOW],
+ ["move-completed", "--mapping", str(mapping), "--now", NOW],
+ ["handoff", "--now", NOW],
+ ]
+ for index, command in enumerate(commands):
+ with self.subTest(command=command[0]):
+ event = dict(base_event)
+ if index == 0:
+ event["cookie"] = "forbidden-manifest-value"
+ else:
+ event["nested"] = {"token": "forbidden-manifest-value"}
+ manifest_path.write_bytes(collector.canonical_json_bytes(event))
+ before = manifest_path.read_bytes()
+ code, payload, stdout = self.run_main_json(["--config", str(self.config_path), *command])
+ self.assertEqual(3, code)
+ self.assertEqual("E_SECRET_FIELD", payload["error_code"])
+ self.assertNotIn("forbidden-manifest-value", stdout)
+ self.assertEqual(before, manifest_path.read_bytes())
+ self.assertEqual(b"source remains", source.read_bytes())
+ self.assertEqual([], list(self.video_dir.iterdir()))
+
+ def test_terminal_projection_rejects_missing_blank_or_malformed_evidence(self) -> None:
+ queued = self.queue_video()
+ mapping = self.root / "terminal-mapping.json"
+ self.write_json(
+ mapping,
+ {"schema_version": 1, "items": [{"source_file": "source-is-absent.mp4", "bvid": queued["bvid"]}]},
+ )
+ manifest_path = self.state_dir / "manifest.jsonl"
+ valid_target = str(self.video_dir / "persisted-target.mp4")
+ cases = [
+ ("moved missing local", "VIDEO_MOVED", "local_file", None, True),
+ ("moved blank local", "VIDEO_MOVED", "local_file", " ", False),
+ ("moved control local", "VIDEO_MOVED", "local_file", "bad\npath", False),
+ ("moved missing sha", "VIDEO_MOVED", "sha256", None, True),
+ ("moved blank sha", "VIDEO_MOVED", "sha256", "", False),
+ ("moved malformed sha", "VIDEO_MOVED", "sha256", "A" * 64, False),
+ ("retained missing reason", "VIDEO_MOVED_SOURCE_RETAINED", "failure_reason", None, True),
+ ("retained blank reason", "VIDEO_MOVED_SOURCE_RETAINED", "failure_reason", " ", False),
+ ("retained control reason", "VIDEO_MOVED_SOURCE_RETAINED", "failure_reason", "bad\rreason", False),
+ ]
+ for label, status, field, value, remove_field in cases:
+ with self.subTest(label=label):
+ event = dict(queued)
+ event.update(
+ {
+ "status": status,
+ "local_file": valid_target,
+ "sha256": "a" * 64,
+ "failure_reason": "PermissionError: retained source",
+ "video_processing_status": "READY_FOR_HANDOFF",
+ }
+ )
+ if remove_field:
+ event.pop(field, None)
+ else:
+ event[field] = value
+ manifest_path.write_bytes(collector.canonical_json_bytes(event))
+ before = {
+ str(path.relative_to(self.root)): path.read_bytes()
+ for path in self.root.rglob("*")
+ if path.is_file()
+ }
+ code, payload, stdout = self.run_main_json(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ after = {
+ str(path.relative_to(self.root)): path.read_bytes()
+ for path in self.root.rglob("*")
+ if path.is_file()
+ }
+ self.assertEqual(3, code)
+ self.assertEqual("SAFETY_STOP", payload["status"])
+ self.assertEqual("E_MANIFEST", payload["error_code"])
+ self.assertEqual(before, after)
+ self.assertNotIn("bad\npath", stdout)
+ self.assertNotIn("bad\rreason", stdout)
+ if isinstance(value, str) and value.strip():
+ self.assertNotIn(value, stdout)
+ self.assertEqual([], list(self.video_dir.iterdir()))
+ self.assertFalse((self.state_dir / ".collector.lock").exists())
+
+ def test_handoff_rejects_control_or_invalid_manifest_scalars_without_writes(self) -> None:
+ queued = self.queue_video()
+ source = self.download_dir / "handoff-source.mp4"
+ source_bytes = b"handoff scalar validation bytes"
+ source.write_bytes(source_bytes)
+ completed_mtime = datetime.fromisoformat("2026-08-04T11:30:00+08:00").timestamp()
+ os.utime(source, (completed_mtime, completed_mtime))
+ mapping = self.root / "handoff-mapping.json"
+ self.write_json(
+ mapping,
+ {"schema_version": 1, "items": [{"source_file": source.name, "bvid": queued["bvid"]}]},
+ )
+ code, moved = collector.run(
+ ["--config", str(self.config_path), "move-completed", "--mapping", str(mapping), "--now", NOW]
+ )
+ self.assertEqual(0, code)
+ target = Path(moved["items"][0]["target"])
+ latest, _ = collector.latest_entities(collector.load_manifest(self.state_dir / "manifest.jsonl"))
+ moved_event = dict(latest[queued["entity_id"]])
+ manifest_path = self.state_dir / "manifest.jsonl"
+ cases = [
+ ("entity newline", "entity_id", str(moved_event["entity_id"]) + "\nstatus=FORGED"),
+ ("entity format", "entity_id", "not-an-internal-id"),
+ ("bvid newline", "bvid", str(moved_event["bvid"]) + "\r\nstatus=FORGED"),
+ ("bvid format", "bvid", "NOT-A-BVID"),
+ ("published newline", "published_at", str(moved_event["published_at"]) + "\nstatus=FORGED"),
+ ("published noncanonical", "published_at", "2026-08-04T03:00:00+00:00"),
+ ("title newline", "title", str(moved_event["title"]) + "\nstatus=FORGED"),
+ ("source url newline", "source_url", str(moved_event["source_url"]) + "\nstatus=FORGED"),
+ ("local file newline", "local_file", str(moved_event["local_file"]) + "\nstatus=FORGED"),
+ ("sha newline", "sha256", str(moved_event["sha256"]) + "\nstatus=FORGED"),
+ ]
+ for label, field, value in cases:
+ with self.subTest(label=label):
+ event = dict(moved_event)
+ event[field] = value
+ manifest_path.write_bytes(collector.canonical_json_bytes(event))
+ before = {
+ str(path.relative_to(self.root)): path.read_bytes()
+ for path in self.root.rglob("*")
+ if path.is_file()
+ }
+ code, payload, stdout = self.run_main_json(
+ ["--config", str(self.config_path), "handoff", "--now", NOW]
+ )
+ after = {
+ str(path.relative_to(self.root)): path.read_bytes()
+ for path in self.root.rglob("*")
+ if path.is_file()
+ }
+ self.assertEqual(3, code)
+ self.assertEqual("SAFETY_STOP", payload["status"])
+ self.assertEqual("E_MANIFEST", payload["error_code"])
+ self.assertNotIn("status=FORGED", stdout)
+ self.assertNotIn(value, stdout)
+ self.assertEqual(before, after)
+ self.assertEqual(source_bytes, target.read_bytes())
+ self.assertFalse((self.state_dir / ".collector.lock").exists())
+
+ def test_native_handoff_route_is_required_and_validated_before_state_write(self) -> None:
+ base = json.loads(self.config_path.read_text(encoding="utf-8"))
+ for label, mutate in (
+ ("missing field", lambda route: route.pop("target_thread_id")),
+ ("invalid thread", lambda route: route.__setitem__("target_thread_id", "NOT-A-THREAD")),
+ ("envelope injection", lambda route: route.__setitem__("target_ai_id", "media-processor\nstatus=FORGED")),
+ ):
+ with self.subTest(label=label):
+ invalid = json.loads(json.dumps(base))
+ mutate(invalid["native_handoff"])
+ config = self.root / f"invalid-{label}.json"
+ self.write_json(config, invalid)
+ self.assertFalse(self.state_dir.exists())
+ code, payload, _ = self.run_main_json(
+ ["--config", str(config), "check", "--input", str(FIXTURE), "--now", NOW]
+ )
+ self.assertEqual(2, code)
+ self.assertEqual("E_CONFIG", payload["error_code"])
+ self.assertFalse(self.state_dir.exists())
+
+ def test_secret_fields_are_rejected_and_not_persisted(self) -> None:
+ exported = json.loads(FIXTURE.read_text(encoding="utf-8"))
+ exported["cookie"] = "forbidden-value"
+ bad = self.root / "bad.json"
+ self.write_json(bad, exported)
+ code = collector.main(
+ ["--config", str(self.config_path), "check", "--input", str(bad), "--now", NOW]
+ )
+ self.assertEqual(3, code)
+ self.assertFalse((self.state_dir / "manifest.jsonl").exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/dev/project-dev/test/test_bili_video_download_bridge.py b/dev/project-dev/test/test_bili_video_download_bridge.py
new file mode 100644
index 0000000..00163d1
--- /dev/null
+++ b/dev/project-dev/test/test_bili_video_download_bridge.py
@@ -0,0 +1,511 @@
+import importlib.util
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+MODULE_PATH = Path(__file__).resolve().parents[1] / "bili_video_download_bridge.py"
+SPEC = importlib.util.spec_from_file_location("bili_video_download_bridge", MODULE_PATH)
+bridge = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+sys.modules[SPEC.name] = bridge
+SPEC.loader.exec_module(bridge)
+
+
+def completed(stdout="", stderr="", returncode=0):
+ return subprocess.CompletedProcess([], returncode, stdout, stderr)
+
+
+class BatchInputTests(unittest.TestCase):
+ def test_example_loads_two_exact_canonical_targets(self):
+ example = MODULE_PATH.with_name("bili_video_download_bridge.example.json")
+ batch = bridge.load_batch(example)
+ self.assertEqual("qingfengpushangq-20260805", batch.batch_id)
+ self.assertEqual(["BV1DVMX6XEPq", "BV1HA3o6oEJJ"], [item.bvid for item in batch.items])
+ self.assertTrue(all(not url.endswith(".webp") for url in (item.source_url for item in batch.items)))
+
+ def test_rejects_noncanonical_query_cover_and_duplicate(self):
+ base = {
+ "schema_version": "1.0",
+ "batch_id": "batch-1",
+ "items": [
+ {
+ "bvid": "BV1DVMX6XEPq",
+ "source_url": "https://www.bilibili.com/video/BV1DVMX6XEPq?token=secret",
+ "published_at": "2026-08-03T19:39:03+08:00",
+ }
+ ],
+ }
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "batch.json"
+ path.write_text(json.dumps(base), encoding="utf-8")
+ with self.assertRaises(bridge.InputError):
+ bridge.load_batch(path)
+ base["items"][0]["source_url"] = "https://example.test/cover.webp"
+ path.write_text(json.dumps(base), encoding="utf-8")
+ with self.assertRaises(bridge.InputError):
+ bridge.load_batch(path)
+ base["items"][0]["source_url"] = "https://www.bilibili.com/video/BV1DVMX6XEPq"
+ base["items"].append(dict(base["items"][0]))
+ path.write_text(json.dumps(base), encoding="utf-8")
+ with self.assertRaises(bridge.InputError):
+ bridge.load_batch(path)
+
+
+class CommandTests(unittest.TestCase):
+ def setUp(self):
+ self.item = bridge.VideoItem(
+ bvid="BV1DVMX6XEPq",
+ source_url="https://www.bilibili.com/video/BV1DVMX6XEPq",
+ published_at="2026-08-03T19:39:03+08:00",
+ )
+
+ def test_probe_command_forbids_implicit_credentials_and_download(self):
+ command = bridge.build_probe_command("yt-dlp", self.item)
+ self.assertIn("--ignore-config", command)
+ self.assertIn("--no-cookies", command)
+ self.assertIn("--no-cookies-from-browser", command)
+ self.assertIn("--simulate", command)
+ self.assertNotIn("--netrc", command)
+ self.assertNotIn("--cookies", command)
+ self.assertNotIn("--cookies-from-browser", command)
+ self.assertEqual(self.item.source_url, command[-1])
+
+ def test_download_command_requests_complete_merged_media_in_staging(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ stage = Path(tmp) / "stage"
+ result = stage / "completed-path.txt"
+ command = bridge.build_download_command("yt-dlp", "ffmpeg", self.item, stage, result)
+ self.assertEqual("bv*+ba/b", command[command.index("--format") + 1])
+ self.assertEqual("mp4", command[command.index("--merge-output-format") + 1])
+ self.assertEqual("2", command[command.index("--retries") + 1])
+ self.assertIn("--no-overwrites", command)
+ self.assertIn("after_move:filepath", command)
+ self.assertNotIn("m4s", " ".join(command).casefold())
+
+ def test_probe_rejects_remote_identity_mismatch(self):
+ runner = mock.Mock(return_value=completed(json.dumps({"id": "BV0000000000", "duration": 12})))
+ with self.assertRaises(bridge.AccessError):
+ bridge.probe_item(self.item, "yt-dlp", runner=runner)
+
+ def test_successful_probe_is_labeled_metadata_only(self):
+ runner = mock.Mock(
+ return_value=completed(
+ json.dumps({"id": self.item.bvid, "duration": 1206.067, "is_live": False})
+ )
+ )
+ result = bridge.probe_item(self.item, "yt-dlp", runner=runner)
+ self.assertEqual("METADATA_PASS_ONLY", result["probe"])
+
+ def test_probe_rejects_playlist_and_auth_boundaries(self):
+ cases = [
+ {"id": self.item.bvid, "duration": 100, "_type": "playlist"},
+ {"id": self.item.bvid, "duration": 100, "entries": []},
+ {"id": self.item.bvid, "duration": 100, "availability": "needs_auth"},
+ {"id": self.item.bvid, "duration": 100, "availability": "premium_only"},
+ ]
+ for metadata in cases:
+ with self.subTest(metadata=metadata):
+ runner = mock.Mock(return_value=completed(json.dumps(metadata)))
+ with self.assertRaises(bridge.AccessError):
+ bridge.probe_item(self.item, "yt-dlp", runner=runner)
+
+ def test_process_diagnostic_redacts_urls(self):
+ safe = bridge._safe_process_error("failed https://signed.example/x?token=secret")
+ self.assertNotIn("secret", safe)
+ self.assertIn("[URL_REDACTED]", safe)
+
+ def test_browser_file_command_has_no_cookie_or_download_switch(self):
+ args = bridge._parser().parse_args(
+ [
+ "--input",
+ "batch.json",
+ "accept-browser-file",
+ "--bvid",
+ self.item.bvid,
+ "--media-file",
+ "complete.mp4",
+ "--destination",
+ "destination",
+ ]
+ )
+ self.assertEqual("accept-browser-file", args.command)
+ self.assertFalse(hasattr(args, "browser_profile"))
+ self.assertFalse(hasattr(args, "cookies"))
+
+
+class ValidationAndPublishTests(unittest.TestCase):
+ def setUp(self):
+ self.item = bridge.VideoItem(
+ bvid="BV1DVMX6XEPq",
+ source_url="https://www.bilibili.com/video/BV1DVMX6XEPq",
+ published_at="2026-08-03T19:39:03+08:00",
+ )
+
+ def test_ffprobe_requires_video_and_audio_and_rejects_cover(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ video = Path(tmp) / "x.mp4"
+ video.write_bytes(b"media")
+ valid = {
+ "streams": [
+ {"codec_type": "video", "codec_name": "h264"},
+ {"codec_type": "audio", "codec_name": "aac"},
+ ],
+ "format": {"duration": "10.5", "format_name": "mov,mp4"},
+ }
+ facts = bridge.probe_media_file(
+ video, "ffprobe", runner=mock.Mock(return_value=completed(json.dumps(valid)))
+ )
+ self.assertEqual("h264", facts.video_codec)
+ valid["streams"] = valid["streams"][:1]
+ with self.assertRaises(bridge.ValidationError):
+ bridge.probe_media_file(
+ video, "ffprobe", runner=mock.Mock(return_value=completed(json.dumps(valid)))
+ )
+ cover = Path(tmp) / "cover.webp"
+ cover.write_bytes(b"RIFF")
+ with self.assertRaises(bridge.ValidationError):
+ bridge.probe_media_file(cover, "ffprobe", runner=mock.Mock())
+
+ def test_publish_create_new_hash_mapping_and_collision(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / "staging.mp4"
+ source.write_bytes(b"complete-video-and-audio")
+ destination = root / "destination"
+ destination.mkdir()
+ facts = bridge.MediaFacts(12.5, "mov,mp4", "h264", "aac")
+ result = bridge.publish_validated_media(
+ source,
+ destination,
+ self.item,
+ {"title": "test title", "duration_seconds": 12.5},
+ facts,
+ )
+ media = destination / "BV1DVMX6XEPq.mp4"
+ mapping = destination / "BV1DVMX6XEPq.download.json"
+ self.assertTrue(media.is_file())
+ self.assertTrue(mapping.is_file())
+ self.assertEqual(bridge.sha256_file(source), result["sha256"])
+ self.assertEqual(result, json.loads(mapping.read_text(encoding="utf-8")))
+ with self.assertRaises(bridge.CollisionError):
+ bridge.publish_validated_media(
+ source,
+ destination,
+ self.item,
+ {"title": "test title", "duration_seconds": 12.5},
+ facts,
+ )
+
+ def test_publish_rolls_back_media_when_mapping_create_fails(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / "staging.mp4"
+ source.write_bytes(b"complete-video-and-audio")
+ destination = root / "destination"
+ destination.mkdir()
+ facts = bridge.MediaFacts(12.5, "mov,mp4", "h264", "aac")
+ with mock.patch.object(bridge, "_write_json_create_new", side_effect=KeyboardInterrupt):
+ with self.assertRaises(KeyboardInterrupt):
+ bridge.publish_validated_media(
+ source,
+ destination,
+ self.item,
+ {"title": "test title", "duration_seconds": 12.5},
+ facts,
+ )
+
+ def test_duration_consistency_allows_mux_drift_and_rejects_preview(self):
+ accepted = bridge.validate_duration_consistency(1206.067, 1206.067664)
+ self.assertLess(accepted["duration_delta_seconds"], accepted["duration_tolerance_seconds"])
+ tolerance = max(3.0, 4000.0 * 0.001)
+ bridge.validate_duration_consistency(4000.0, 4000.0 + tolerance)
+ with self.assertRaises(bridge.ValidationError):
+ bridge.validate_duration_consistency(4000.0, 4000.0 + tolerance + 0.000001)
+ with self.assertRaisesRegex(bridge.ValidationError, "possible preview or wrong part"):
+ bridge.validate_duration_consistency(3133.95, 600.133313)
+ self.assertEqual([], list(destination.iterdir()))
+
+
+class BatchFlowTests(unittest.TestCase):
+ def test_download_item_stub_closes_complete_media_mapping_and_cleanup(self):
+ item = bridge.VideoItem(
+ "BV1DVMX6XEPq",
+ "https://www.bilibili.com/video/BV1DVMX6XEPq",
+ "2026-08-03T19:39:03+08:00",
+ )
+
+ def runner(command, **_kwargs):
+ if "--dump-single-json" in command:
+ return completed(
+ json.dumps(
+ {
+ "id": item.bvid,
+ "title": "A股缩量磨底阶段。",
+ "duration": 1206.067,
+ "is_live": False,
+ }
+ )
+ )
+ if "--format" in command:
+ stage = Path(command[command.index("--paths") + 1])
+ media = stage / f"{item.bvid}.mp4"
+ media.write_bytes(b"stub-complete-media")
+ path_file = Path(command[command.index("--print-to-file") + 2])
+ path_file.write_text(str(media), encoding="utf-8")
+ return completed()
+ if "-show_entries" in command:
+ return completed(
+ json.dumps(
+ {
+ "streams": [
+ {"codec_type": "video", "codec_name": "h264"},
+ {"codec_type": "audio", "codec_name": "aac"},
+ ],
+ "format": {"duration": "1206.067", "format_name": "mov,mp4"},
+ }
+ )
+ )
+ raise AssertionError(command)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ destination = Path(tmp)
+ result = bridge.download_item(
+ item,
+ destination,
+ "batch-1",
+ "yt-dlp",
+ "ffmpeg",
+ "ffprobe",
+ runner=runner,
+ )
+ self.assertEqual("COMPLETE", result["status"])
+ self.assertTrue((destination / f"{item.bvid}.mp4").is_file())
+ self.assertTrue((destination / f"{item.bvid}.download.json").is_file())
+ self.assertFalse((destination / ".bili-download-staging").exists())
+
+ def test_download_batch_continues_after_one_item_failure(self):
+ items = (
+ bridge.VideoItem("BV1DVMX6XEPq", "https://www.bilibili.com/video/BV1DVMX6XEPq", "2026-08-03T19:39:03+08:00"),
+ bridge.VideoItem("BV1HA3o6oEJJ", "https://www.bilibili.com/video/BV1HA3o6oEJJ", "2026-08-02T21:11:11+08:00"),
+ )
+ batch = bridge.BatchSpec("batch-1", items)
+ with tempfile.TemporaryDirectory() as tmp:
+ with mock.patch.object(
+ bridge,
+ "download_item",
+ side_effect=[bridge.AccessError("public access denied"), {"status": "COMPLETE"}],
+ ) as mocked:
+ results, failures = bridge.download_batch(
+ batch, Path(tmp), "yt-dlp", "ffmpeg", "ffprobe", runner=mock.Mock()
+ )
+ self.assertEqual(2, mocked.call_count)
+ self.assertEqual(1, failures)
+ self.assertEqual(["FAIL", "COMPLETE"], [item["status"] for item in results])
+
+ def test_preview_duration_mismatch_never_publishes_and_cleans_staging(self):
+ item = bridge.VideoItem(
+ "BV1HA3o6oEJJ",
+ "https://www.bilibili.com/video/BV1HA3o6oEJJ",
+ "2026-08-02T21:11:11+08:00",
+ )
+
+ def runner(command, **_kwargs):
+ if "--dump-single-json" in command:
+ return completed(
+ json.dumps(
+ {
+ "id": item.bvid,
+ "title": "full remote video",
+ "duration": 3133.95,
+ "is_live": False,
+ }
+ )
+ )
+ if "--format" in command:
+ stage = Path(command[command.index("--paths") + 1])
+ media = stage / f"{item.bvid}.mp4"
+ media.write_bytes(b"premium-preview-only")
+ path_file = Path(command[command.index("--print-to-file") + 2])
+ path_file.write_text(str(media), encoding="utf-8")
+ return completed()
+ if "-show_entries" in command:
+ return completed(
+ json.dumps(
+ {
+ "streams": [
+ {"codec_type": "video", "codec_name": "h264"},
+ {"codec_type": "audio", "codec_name": "aac"},
+ ],
+ "format": {"duration": "600.133313", "format_name": "mov,mp4"},
+ }
+ )
+ )
+ raise AssertionError(command)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ destination = Path(tmp)
+ with self.assertRaisesRegex(bridge.ValidationError, "possible preview or wrong part"):
+ bridge.download_item(
+ item,
+ destination,
+ "batch-1",
+ "yt-dlp",
+ "ffmpeg",
+ "ffprobe",
+ runner=runner,
+ )
+ self.assertEqual([], list(destination.iterdir()))
+
+
+class BrowserFileHandoffTests(unittest.TestCase):
+ def setUp(self):
+ self.item = bridge.VideoItem(
+ "BV1HA3o6oEJJ",
+ "https://www.bilibili.com/video/BV1HA3o6oEJJ",
+ "2026-08-02T21:11:11+08:00",
+ )
+
+ def runner_with_duration(self, local_duration):
+ def runner(command, **_kwargs):
+ if "--dump-single-json" in command:
+ self.assertIn("--no-cookies", command)
+ self.assertIn("--no-cookies-from-browser", command)
+ return completed(
+ json.dumps(
+ {
+ "id": self.item.bvid,
+ "title": "流水淘沙不暂停,前波未灭后波生。(周复盘)",
+ "duration": 3133.95,
+ "is_live": False,
+ }
+ )
+ )
+ if "-show_entries" in command:
+ return completed(
+ json.dumps(
+ {
+ "streams": [
+ {"codec_type": "video", "codec_name": "h264"},
+ {"codec_type": "audio", "codec_name": "aac"},
+ ],
+ "format": {
+ "duration": str(local_duration),
+ "format_name": "mov,mp4",
+ },
+ }
+ )
+ )
+ raise AssertionError(command)
+
+ return mock.Mock(side_effect=runner)
+
+ def test_accept_browser_file_publishes_copy_and_keeps_source_unchanged(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / "browser-download.mp4"
+ source.write_bytes(b"complete-authorized-video-with-audio")
+ source_before = (source.read_bytes(), source.stat().st_mtime_ns)
+ destination = root / "destination"
+ destination.mkdir()
+ runner = self.runner_with_duration(3133.9504)
+ result = bridge.accept_browser_file(
+ self.item,
+ source,
+ destination,
+ "batch-1",
+ "yt-dlp",
+ "ffprobe",
+ runner=runner,
+ sleeper=lambda _seconds: None,
+ )
+ self.assertEqual("COMPLETE", result["status"])
+ self.assertEqual("authorized_browser_file_handoff", result["acquisition_mode"])
+ self.assertEqual(result["sha256"], result["handoff_source_sha256"])
+ self.assertEqual(source_before, (source.read_bytes(), source.stat().st_mtime_ns))
+ published = destination / f"{self.item.bvid}.mp4"
+ mapping = destination / f"{self.item.bvid}.download.json"
+ self.assertTrue(published.is_file())
+ self.assertTrue(mapping.is_file())
+ self.assertEqual(source.read_bytes(), published.read_bytes())
+ self.assertFalse((destination / ".bili-download-staging").exists())
+ commands = [call.args[0] for call in runner.call_args_list]
+ self.assertEqual(2, len(commands))
+ self.assertTrue(all("--cookies-from-browser" not in command for command in commands))
+ self.assertTrue(all("--cookies" not in command for command in commands))
+
+ def test_rejects_partial_and_growing_browser_files(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ partial = root / "video.mp4.crdownload"
+ partial.write_bytes(b"partial")
+ with self.assertRaisesRegex(bridge.ValidationError, "incomplete media suffix"):
+ bridge.validate_stable_local_media(partial, sleeper=lambda _seconds: None)
+
+ growing = root / "growing-video.mp4"
+ growing.write_bytes(b"first")
+
+ def grow(_seconds):
+ with growing.open("ab") as target:
+ target.write(b"more")
+
+ with self.assertRaisesRegex(bridge.ValidationError, "not stable"):
+ bridge.validate_stable_local_media(growing, sleeper=grow)
+
+ def test_preview_handoff_fails_before_publication_and_cleans_stage(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / "browser-download.mp4"
+ source.write_bytes(b"preview-only")
+ destination = root / "destination"
+ destination.mkdir()
+ with self.assertRaisesRegex(bridge.ValidationError, "possible preview or wrong part"):
+ bridge.accept_browser_file(
+ self.item,
+ source,
+ destination,
+ "batch-1",
+ "yt-dlp",
+ "ffprobe",
+ runner=self.runner_with_duration(600.133313),
+ sleeper=lambda _seconds: None,
+ )
+ self.assertEqual([], list(destination.iterdir()))
+ self.assertEqual(b"preview-only", source.read_bytes())
+
+ def test_base_exception_after_copy_cleans_stage_and_keeps_source(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / "browser-download.mp4"
+ source.write_bytes(b"complete-media")
+ destination = root / "destination"
+ destination.mkdir()
+ runner = self.runner_with_duration(3133.95)
+ with mock.patch.object(bridge, "probe_media_file", side_effect=KeyboardInterrupt):
+ with self.assertRaises(KeyboardInterrupt):
+ bridge.accept_browser_file(
+ self.item,
+ source,
+ destination,
+ "batch-1",
+ "yt-dlp",
+ "ffprobe",
+ runner=runner,
+ sleeper=lambda _seconds: None,
+ )
+ self.assertEqual([], list(destination.iterdir()))
+ self.assertEqual(b"complete-media", source.read_bytes())
+
+ def test_browser_handoff_requires_exact_batch_bvid(self):
+ batch = bridge.BatchSpec("batch-1", (self.item,))
+ self.assertIs(self.item, bridge._find_batch_item(batch, self.item.bvid))
+ with self.assertRaises(bridge.InputError):
+ bridge._find_batch_item(batch, "BV1DVMX6XEPq")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/dev/project-dev/test/test_extract_ppt_slides.py b/dev/project-dev/test/test_extract_ppt_slides.py
index e7301a9..3d28e79 100644
--- a/dev/project-dev/test/test_extract_ppt_slides.py
+++ b/dev/project-dev/test/test_extract_ppt_slides.py
@@ -1,8 +1,11 @@
from __future__ import annotations
+import csv
+from datetime import datetime, timedelta, timezone
import hashlib
import importlib.util
import io
+import json
import os
from pathlib import Path
import re
@@ -10,6 +13,7 @@
import subprocess
import sys
import tempfile
+import textwrap
import unittest
from unittest import mock
import zlib
@@ -26,21 +30,63 @@
SPEC.loader.exec_module(slides)
-def feature(index: int, array: np.ndarray, score: float = 0.2, sharpness: float | None = None):
+def palette(marker_bin: int | None = None, coverage: float = 0.20):
+ histogram = np.full(slides.PALETTE_BIN_COUNT, 1 / slides.PALETTE_BIN_COUNT, np.float32)
+ if marker_bin is not None:
+ histogram.fill((1.0 - coverage) / (slides.PALETTE_BIN_COUNT - 1))
+ histogram[marker_bin] = coverage
+ histogram /= float(np.sum(histogram, dtype=np.float64))
+ return histogram
+
+
+def feature(
+ index: int,
+ array: np.ndarray,
+ score: float = 0.2,
+ sharpness: float | None = None,
+ page_palette: np.ndarray | None = None,
+):
value = int(np.mean(array))
phash = int.from_bytes(hashlib.sha256(array.tobytes()).digest()[:8], "big")
return slides.FrameFeature(
sample_index=index,
relative_seconds=float(index),
gray=array.astype(np.uint8).copy(),
+ palette_histogram=(palette() if page_palette is None else page_palette.copy()),
perceptual_hash=phash,
content_score=score,
sharpness=float(value if sharpness is None else sharpness),
)
-def candidate(timestamp: float, array: np.ndarray, score: float, phash: int = 0):
- return slides.PageCandidate(timestamp, array.astype(np.uint8).copy(), phash, score, 1.0)
+def candidate(
+ timestamp: float,
+ array: np.ndarray,
+ score: float,
+ phash: int = 0,
+ page_palette: np.ndarray | None = None,
+):
+ return slides.PageCandidate(
+ timestamp,
+ array.astype(np.uint8).copy(),
+ palette() if page_palette is None else page_palette.copy(),
+ phash,
+ score,
+ 1.0,
+ )
+
+
+def logical_page(order: int, page_palette: np.ndarray | None = None):
+ page_feature = np.full((8, 8), order % 256, np.uint8)
+ return slides.LogicalPage(
+ logical_anchor_feature=page_feature.copy(),
+ current_feature=page_feature,
+ current_palette=palette() if page_palette is None else page_palette.copy(),
+ current_hash=order,
+ current_timestamp=float(order),
+ current_score=0.2,
+ first_seen_order=order,
+ )
def sha256(path: Path) -> str:
@@ -148,20 +194,915 @@
recent = np.full((8, 8), 4, np.uint8)
classifier.consume(candidate(1, historical, 0.20, 0))
classifier.pages.append(
- slides.LogicalPage(recent.copy(), recent.copy(), 0, 2.0, 0.20, 1)
+ slides.LogicalPage(recent.copy(), recent.copy(), palette(), 0, 2.0, 0.20, 1)
)
action = classifier.consume(candidate(3, historical, 0.22, 0))
self.assertEqual(action, "animation_updated")
self.assertEqual(len(classifier.pages), 2)
+class MainSequenceTests(unittest.TestCase):
+ def test_palette_histogram_distinguishes_dark_blue_from_black(self):
+ image = np.array(
+ [
+ [[16, 16, 48], [16, 16, 48]],
+ [[16, 16, 16], [16, 16, 16]],
+ ],
+ dtype=np.uint8,
+ )
+ histogram = slides._palette_histogram(image)
+ self.assertEqual(histogram.shape, (512,))
+ self.assertTrue(np.all(np.isfinite(histogram)))
+ self.assertAlmostEqual(float(np.sum(histogram)), 1.0, places=6)
+ self.assertEqual(float(histogram[1]), 0.5)
+ self.assertEqual(float(histogram[0]), 0.5)
+
+ def test_selects_old_pages_002_through_038_and_keeps_two_gaps(self):
+ marker_pages = {2, *range(4, 9), *range(10, 39)}
+ pages = [
+ logical_page(index - 1, palette(1) if index in marker_pages else palette())
+ for index in range(1, 54)
+ ]
+ selected = slides.select_main_sequence(pages)
+ self.assertEqual(selected, pages[1:38])
+ self.assertIs(selected[1], pages[2]) # old slide_003: first one-page gap
+ self.assertIs(selected[7], pages[8]) # old slide_009: second one-page gap
+ self.assertEqual([page.first_seen_order for page in selected], list(range(1, 38)))
+
+ def test_conservative_gates_keep_all_candidates(self):
+ cases = {
+ "markers": [palette(1) if index < 7 else palette() for index in range(10)],
+ "share": [palette(1) if 3 <= index <= 10 else palette() for index in range(20)],
+ "density": [
+ palette(1) if index in {0, 2, 4, 6, 7, 8, 9, 10} else palette()
+ for index in range(11)
+ ],
+ }
+ for name, histograms in cases.items():
+ with self.subTest(name=name):
+ pages = [logical_page(index, value) for index, value in enumerate(histograms)]
+ selected = slides.select_main_sequence(pages)
+ self.assertEqual(selected, pages)
+ self.assertTrue(all(left is right for left, right in zip(selected, pages)))
+
+ def test_all_theme_pages_are_preserved(self):
+ pages = [logical_page(index, palette(7)) for index in range(12)]
+ selected = slides.select_main_sequence(pages)
+ self.assertEqual(selected, pages)
+ self.assertTrue(all(left is right for left, right in zip(selected, pages)))
+
+ def test_invalid_palette_is_explicit_failure(self):
+ invalid = [
+ np.zeros(5, np.float32),
+ np.full(512, np.nan, np.float32),
+ np.zeros(512, np.float32),
+ ]
+ for value in invalid:
+ with self.subTest(shape=value.shape, finite=bool(np.all(np.isfinite(value)))):
+ with self.assertRaises(slides.SlideExtractionError):
+ slides.select_main_sequence([logical_page(0, value)])
+
+
class ProcessAndCommandTests(unittest.TestCase):
def test_command_freezes_time_mapping(self):
scan = slides._scan_command("ffmpeg", Path("x.mp4"), 320, 180)
self.assertIn("setpts=PTS-STARTPTS,fps=fps=1:start_time=0,scale=320:180:flags=bilinear", scan)
+ input_index = scan.index("-i")
+ self.assertLess(scan.index("-threads"), input_index)
+ self.assertEqual(scan[scan.index("-threads") + 1], str(slides.MEDIA_DECODE_THREADS))
+ self.assertEqual(scan[scan.index("-filter_threads") + 1], str(slides.MEDIA_FILTER_THREADS))
extract = slides._extract_command("ffmpeg", Path("x.mp4"), 12.0, Path("x.png"))
self.assertEqual(extract[extract.index("-ss") + 1], "12.000")
+ self.assertLess(extract.index("-threads"), extract.index("-i"))
self.assertEqual(feature(9, np.zeros((2, 2), np.uint8)).relative_seconds, 9.0)
+
+ def test_hardware_commands_place_decoder_and_threads_before_input(self):
+ for codec, decoder in (("h264", "h264_cuvid"), ("hevc", "hevc_cuvid")):
+ with self.subTest(codec=codec):
+ self.assertEqual(slides._decoder_for_codec(codec, "yuv420p"), decoder)
+ command = slides._scan_command(
+ "ffmpeg", Path(f"{codec}.mp4"), 320, 180, decoder
+ )
+ input_index = command.index("-i")
+ for option in ("-hwaccel", "-hwaccel_output_format", "-c:v", "-threads"):
+ self.assertLess(command.index(option), input_index)
+ self.assertEqual(command[command.index("-hwaccel") + 1], "cuda")
+ self.assertEqual(command[command.index("-c:v") + 1], decoder)
+ self.assertEqual(
+ command[command.index("-threads") + 1],
+ str(slides.MEDIA_DECODE_THREADS),
+ )
+ self.assertIn("hwdownload,format=nv12", command[command.index("-vf") + 1])
+ self.assertEqual(slides._decoder_for_codec("h264", "gbrp"), "h264_cuvid")
+ self.assertEqual(slides._decoder_for_codec("hevc", "p010le"), "hevc_cuvid")
+ self.assertIsNone(slides._decoder_for_codec("vp9", "yuv420p"))
+
+ def test_hardware_external_failures_have_typed_boundary(self):
+ class FakeStdout:
+ def __init__(self, payloads):
+ self.payloads = list(payloads)
+
+ def read(self, size):
+ return self.payloads.pop(0) if self.payloads else b""
+
+ def close(self):
+ return None
+
+ class FakeProcess:
+ def __init__(self, payloads, code):
+ self.stdout = FakeStdout(payloads)
+ self.code = code
+
+ def wait(self, timeout=None):
+ return self.code
+
+ def poll(self):
+ return self.code
+
+ def terminate(self):
+ return None
+
+ def kill(self):
+ return None
+
+ info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
+ cases = (
+ ("nonzero", FakeProcess([], 7), "exit=7"),
+ ("zero", FakeProcess([], 0), "未输出任何完整"),
+ ("partial", FakeProcess([b"x", b""], 0), "输出半帧"),
+ )
+ with tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_which_or_error", return_value="ffmpeg"
+ ):
+ root = Path(raw)
+ with mock.patch.object(slides.subprocess, "Popen", side_effect=OSError("start denied")):
+ with self.assertRaisesRegex(slides.HardwareDecodePathError, "启动失败"):
+ slides._scan_video_once(Path("x.mp4"), root, info, decoder="h264_cuvid")
+ for name, process, pattern in cases:
+ with self.subTest(name=name), mock.patch.object(
+ slides.subprocess, "Popen", return_value=process
+ ):
+ with self.assertRaisesRegex(slides.HardwareDecodePathError, pattern):
+ slides._scan_video_once(
+ Path("x.mp4"), root, info, decoder="h264_cuvid"
+ )
+
+ def test_hardware_cleanup_failure_does_not_trigger_cpu_fallback(self):
+ info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
+ hardware_error = slides.HardwareDecodePathError("hardware-root")
+ slides._attach_cleanup_note(hardware_error, "cleanup-failed")
+ with tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_scan_video_once", side_effect=hardware_error
+ ) as scan:
+ with self.assertRaises(slides.HardwareDecodePathError) as caught:
+ slides.scan_video(Path("x.mp4"), Path(raw), info)
+ self.assertIs(caught.exception, hardware_error)
+ self.assertEqual(scan.call_count, 1)
+ self.assertIn("cleanup-failed", "\n".join(caught.exception.__notes__))
+
+ def test_active_errors_keep_identity_when_process_cleanup_fails(self):
+ frame_bytes = 320 * 180 * 3
+
+ class Stream:
+ def __init__(self, error=None, partial=False):
+ self.error = error
+ self.partial = partial
+ self.calls = 0
+
+ def read(self, size):
+ self.calls += 1
+ if self.error is not None:
+ raise self.error
+ if self.partial:
+ return b"x" if self.calls == 1 else b""
+ return b"\0" * frame_bytes if self.calls == 1 else b""
+
+ def close(self):
+ return None
+
+ class FakeProcess:
+ def __init__(self, stream):
+ self.stdout = stream
+
+ def wait(self, timeout=None):
+ return 0
+
+ def poll(self):
+ return None
+
+ def terminate(self):
+ return None
+
+ cleanup = OSError("cleanup-failed")
+ info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
+ cases = (
+ ("business", None, ValueError("business-root")),
+ ("cpu", None, slides.SlideExtractionError("cpu-root")),
+ ("base", KeyboardInterrupt(), None),
+ )
+ for name, read_error, feature_error in cases:
+ root_error = read_error or feature_error
+ stream = Stream(error=read_error)
+ patches = [
+ mock.patch.object(slides, "_which_or_error", return_value="ffmpeg"),
+ mock.patch.object(slides.subprocess, "Popen", return_value=FakeProcess(stream)),
+ mock.patch.object(slides, "_attempt_reap", return_value=cleanup),
+ ]
+ if feature_error is not None:
+ patches.append(mock.patch.object(slides, "_gray_feature", side_effect=feature_error))
+ with self.subTest(name=name), tempfile.TemporaryDirectory() as raw:
+ for item in patches:
+ item.start()
+ try:
+ with self.assertRaises(type(root_error)) as caught:
+ slides._scan_video_once(
+ Path("x.mp4"), Path(raw), info, decoder=None
+ )
+ self.assertIs(caught.exception, root_error)
+ self.assertIn("cleanup-failed", "\n".join(caught.exception.__notes__))
+ finally:
+ for item in reversed(patches):
+ item.stop()
+
+ partial_process = FakeProcess(Stream(partial=True))
+ with tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_which_or_error", return_value="ffmpeg"
+ ), mock.patch.object(slides.subprocess, "Popen", return_value=partial_process), mock.patch.object(
+ slides, "_attempt_reap", return_value=cleanup
+ ):
+ with self.assertRaises(slides.HardwareDecodePathError) as caught:
+ slides._scan_video_once(
+ Path("x.mp4"), Path(raw), info, decoder="h264_cuvid"
+ )
+ hardware_error = caught.exception
+ self.assertTrue(getattr(hardware_error, "_media_cleanup_failed", False))
+ self.assertIn("cleanup-failed", "\n".join(hardware_error.__notes__))
+ with tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_scan_video_once", side_effect=hardware_error
+ ) as scan:
+ with self.assertRaises(slides.HardwareDecodePathError) as caught_again:
+ slides.scan_video(Path("x.mp4"), Path(raw), info)
+ self.assertIs(caught_again.exception, hardware_error)
+ self.assertEqual(scan.call_count, 1)
+
+ def test_product_popen_uses_below_normal_creation_flags(self):
+ class FakeProcess:
+ stdout = None
+
+ def wait(self, timeout=None):
+ return 0
+
+ def poll(self):
+ return 0
+
+ with tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_subprocess_creation_flags", return_value=12345
+ ), mock.patch.object(slides.subprocess, "Popen", return_value=FakeProcess()) as popen:
+ slides._run_file_command(["tool"], Path(raw) / "stderr.log", "stage")
+ self.assertEqual(popen.call_args.kwargs["creationflags"], 12345)
+
+ def test_fallback_only_handles_hardware_error_and_never_retries_cpu(self):
+ info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
+ page = logical_page(0)
+ success = slides.ScanResult((page,), 10, 2, (("new_page", 1),), None)
+ hardware_error = slides.HardwareDecodePathError("decoder failed")
+ with tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_scan_video_once", side_effect=[hardware_error, success]
+ ) as scan, mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
+ self.assertEqual(slides.scan_video(Path("x.mp4"), Path(raw), info), [page])
+ self.assertEqual([call.kwargs["decoder"] for call in scan.call_args_list], [
+ "h264_cuvid", None
+ ])
+ self.assertIn("decoder failed", stderr.getvalue())
+
+ cpu_error = slides.SlideExtractionError("cpu root cause")
+ with tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_scan_video_once", side_effect=[hardware_error, cpu_error]
+ ) as scan:
+ with self.assertRaises(slides.SlideExtractionError) as caught:
+ slides.scan_video(Path("x.mp4"), Path(raw), info)
+ self.assertIs(caught.exception, cpu_error)
+ self.assertEqual(scan.call_count, 2)
+
+ def test_algorithm_empty_and_baseexception_never_fall_back(self):
+ info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
+ empty = slides.ScanResult((), 10, 0, (), "h264_cuvid")
+ with tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_scan_video_once", return_value=empty
+ ) as scan:
+ with self.assertRaisesRegex(slides.SlideExtractionError, "未发现"):
+ slides.scan_video(Path("x.mp4"), Path(raw), info)
+ scan.assert_called_once()
+ for error in (RuntimeError("classifier failed"), KeyboardInterrupt(), SystemExit(9)):
+ with self.subTest(error=type(error).__name__), tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_scan_video_once", side_effect=error
+ ) as scan:
+ with self.assertRaises(type(error)) as caught:
+ slides.scan_video(Path("x.mp4"), Path(raw), info)
+ self.assertIs(caught.exception, error)
+ scan.assert_called_once()
+
+ def test_algorithm_exception_inside_hardware_scan_preserves_original_type(self):
+ frame_bytes = 320 * 180 * 3
+
+ class FullFrameStream:
+ def __init__(self):
+ self.done = False
+
+ def read(self, size):
+ if self.done:
+ return b""
+ self.done = True
+ return b"\0" * frame_bytes
+
+ def close(self):
+ return None
+
+ class FakeProcess:
+ stdout = FullFrameStream()
+
+ def wait(self, timeout=None):
+ return 0
+
+ def poll(self):
+ return 0
+
+ root_error = ValueError("feature root cause")
+ info = slides.VideoInfo(320, 180, 10.0, "h264", "yuv420p")
+ with tempfile.TemporaryDirectory() as raw, mock.patch.object(
+ slides, "_which_or_error", return_value="ffmpeg"
+ ), mock.patch.object(slides.subprocess, "Popen", return_value=FakeProcess()), mock.patch.object(
+ slides, "_gray_feature", side_effect=root_error
+ ):
+ with self.assertRaises(ValueError) as caught:
+ slides._scan_video_once(
+ Path("x.mp4"), Path(raw), info, decoder="h264_cuvid"
+ )
+ self.assertIs(caught.exception, root_error)
+
+ @unittest.skipUnless(shutil.which("powershell"), "Windows PowerShell is required")
+ def test_resource_sampler_parser_rejects_cpu_boundaries_and_empty_windows(self):
+ sampler = Path(__file__).resolve().parent / "monitor_media_host_responsiveness.ps1"
+
+ fields = (
+ "timestamp_utc", "previous_valid_timestamp_utc", "interval_seconds",
+ "counter_warmup", "cpu_valid", "host_cpu_percent",
+ "scan_phase_active", "scan_ffmpeg_present", "scan_ffmpeg_exit_observed",
+ "scan_ffmpeg_pid", "scan_ffmpeg_command_line", "decoder",
+ "logical_processor_count", "task_cpu_percent", "task_working_set_bytes",
+ "task_pids", "root_priority", "ffmpeg_priorities", "ffmpeg_command_lines",
+ "gpu_query_status", "gpu_query_raw_rows", "gpu_utilization_percent",
+ "gpu_decoder_utilization_percent", "gpu_memory_used_mib",
+ "nvidia_smi_compute_query_status", "nvidia_smi_compute_raw_rows",
+ "nvidia_smi_compute_task_rows",
+ "gpu_process_memory_query_status", "gpu_process_memory_instances",
+ "task_gpu_dedicated_memory_mib",
+ )
+ scan_command = (
+ "ffmpeg -hwaccel cuda -hwaccel_output_format cuda "
+ "-c:v h264_cuvid -threads 4 "
+ "-filter_threads 2 -filter_complex_threads 2 -i x.mp4 -map 0:v:0 "
+ "-vf hwdownload,format=nv12,fps=fps=1:start_time=0 "
+ "-pix_fmt rgb24 -f rawvideo -"
+ )
+
+ def make_row(current, previous, interval, cpu, *, warmup, present, exited):
+ return {
+ "timestamp_utc": current.isoformat().replace("+00:00", "Z"),
+ "previous_valid_timestamp_utc": (
+ "" if previous is None else previous.isoformat().replace("+00:00", "Z")
+ ),
+ "interval_seconds": f"{interval:.6f}",
+ "counter_warmup": str(warmup).lower(),
+ "cpu_valid": str(not warmup).lower(),
+ "host_cpu_percent": "" if warmup else f"{cpu:.6f}",
+ "scan_phase_active": "true",
+ "scan_ffmpeg_present": str(present).lower(),
+ "scan_ffmpeg_exit_observed": str(exited).lower(),
+ "scan_ffmpeg_pid": "4242",
+ "scan_ffmpeg_command_line": scan_command,
+ "decoder": "h264_cuvid",
+ "logical_processor_count": "44",
+ "task_cpu_percent": "" if warmup else "12.5",
+ "task_working_set_bytes": "123456789",
+ "task_pids": "101;4242",
+ "root_priority": "BelowNormal",
+ "ffmpeg_priorities": (
+ "[]" if not present else '[{"pid":4242,"priority":"BelowNormal"}]'
+ ),
+ "ffmpeg_command_lines": (
+ '[{"pid":4242,"command_line":' + json.dumps(scan_command) + "}]"
+ ),
+ "gpu_query_status": "ok",
+ "gpu_query_raw_rows": '["20, 31, 2048"]',
+ "gpu_utilization_percent": "20",
+ "gpu_decoder_utilization_percent": "31",
+ "gpu_memory_used_mib": "2048",
+ "nvidia_smi_compute_query_status": "ok",
+ "nvidia_smi_compute_raw_rows": '["4242, N/A"]',
+ "nvidia_smi_compute_task_rows": '["4242, N/A"]',
+ "gpu_process_memory_query_status": "ok",
+ "gpu_process_memory_instances": (
+ '[{"pid":4242,"instance_name":"pid_4242_luid_0",'
+ '"counter_path":"path","dedicated_usage_bytes":1048576}]'
+ ),
+ "task_gpu_dedicated_memory_mib": "1",
+ }
+
+ def analyze_case(root, name, intervals, cpu_values, mutate=None, omit_field=None):
+ case = root / name
+ case.mkdir()
+ samples = case / "samples.csv"
+ current = datetime(2026, 8, 4, tzinfo=timezone.utc)
+ rows = [make_row(current, None, 0, 0, warmup=True, present=True, exited=False)]
+ previous = current
+ for interval, cpu in zip(intervals, cpu_values):
+ current += timedelta(seconds=interval)
+ rows.append(make_row(
+ current, previous, interval, cpu,
+ warmup=False, present=True, exited=False,
+ ))
+ previous = current
+ current += timedelta(seconds=0.5)
+ rows.append(make_row(
+ current, previous, 0.5, 20.0,
+ warmup=False, present=False, exited=True,
+ ))
+ if mutate is not None:
+ mutate(rows)
+ output_fields = tuple(field for field in fields if field != omit_field)
+ with samples.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=output_fields, extrasaction="ignore")
+ writer.writeheader()
+ writer.writerows(rows)
+ evidence = case / "evidence"
+ result = subprocess.run(
+ [
+ shutil.which("powershell"), "-NoProfile", "-File", str(sampler),
+ "-AnalyzeSamplesPath", str(samples), "-EvidenceDirectory", str(evidence),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ return json.loads((evidence / "resource_summary.json").read_text(encoding="utf-8-sig"))
+
+ with tempfile.TemporaryDirectory() as raw:
+ root = Path(raw)
+ low = analyze_case(root, "low", [0.5] * 22, [84.9] * 22)
+ self.assertTrue(low["sampling_valid"])
+ self.assertGreater(low["window_count"], 0)
+ self.assertTrue(low["cpu_gate_pass"])
+
+ equal = analyze_case(root, "equal", [0.5] * 22, [85.0] * 22)
+ self.assertTrue(equal["sampling_valid"])
+ self.assertFalse(equal["all_windows_host_cpu_strictly_below_85"])
+ self.assertFalse(equal["cpu_gate_pass"])
+
+ exact_ten = analyze_case(
+ root, "exact-ten", [0.5] * 21, [85.0] * 20 + [20.0]
+ )
+ self.assertEqual(exact_ten["maximum_high_cpu_segment_seconds"], 10.0)
+ self.assertFalse(exact_ten["cpu_gate_pass"])
+
+ below_ten = analyze_case(
+ root, "below-ten", [0.49995] * 20 + [0.5], [85.0] * 20 + [20.0]
+ )
+ self.assertEqual(below_ten["maximum_high_cpu_segment_seconds"], 9.999)
+
+ sparse = analyze_case(root, "sparse", [1.0] * 12, [20.0] * 12)
+ self.assertFalse(sparse["sampling_valid"])
+ self.assertTrue(any("window_density_invalid" in item for item in sparse["sampling_problems"]))
+
+ zero_window = analyze_case(root, "zero-window", [0.5] * 8, [20.0] * 8)
+ self.assertFalse(zero_window["sampling_valid"])
+ self.assertEqual(zero_window["window_count"], 0)
+
+ gap = analyze_case(
+ root, "gap", [0.5] * 10 + [1.6] + [0.5] * 10, [20.0] * 21
+ )
+ self.assertFalse(gap["sampling_valid"])
+ self.assertGreater(gap["maximum_observed_interval_seconds"], 1.5)
+
+ reversed_time = analyze_case(
+ root, "time-reversal", [0.5] * 12, [20.0] * 12,
+ mutate=lambda rows: rows[5].__setitem__("timestamp_utc", rows[3]["timestamp_utc"]),
+ )
+ self.assertFalse(reversed_time["sampling_valid"])
+ self.assertTrue(any(
+ "timestamp_not_strictly_increasing" in item
+ for item in reversed_time["sampling_problems"]
+ ))
+
+ interval_mismatch = analyze_case(
+ root, "interval-mismatch", [0.5] * 12, [20.0] * 12,
+ mutate=lambda rows: rows[4].__setitem__("interval_seconds", "9.0"),
+ )
+ self.assertFalse(interval_mismatch["sampling_valid"])
+ self.assertTrue(any(
+ "interval_mismatch" in item for item in interval_mismatch["sampling_problems"]
+ ))
+
+ previous_mismatch = analyze_case(
+ root, "previous-mismatch", [0.5] * 12, [20.0] * 12,
+ mutate=lambda rows: rows[4].__setitem__(
+ "previous_valid_timestamp_utc", rows[1]["timestamp_utc"]
+ ),
+ )
+ self.assertFalse(previous_mismatch["sampling_valid"])
+ self.assertTrue(any(
+ "previous_timestamp_mismatch" in item
+ for item in previous_mismatch["sampling_problems"]
+ ))
+
+ for omitted in (
+ "gpu_query_raw_rows", "task_pids", "ffmpeg_command_lines",
+ "nvidia_smi_compute_task_rows",
+ ):
+ with self.subTest(omitted=omitted):
+ missing_field = analyze_case(
+ root, f"missing-{omitted}", [0.5] * 12, [20.0] * 12,
+ omit_field=omitted,
+ )
+ self.assertFalse(missing_field["sampling_valid"])
+ self.assertIn(
+ f"required_column_missing_{omitted}",
+ missing_field["sampling_problems"],
+ )
+
+ first_not_present = analyze_case(
+ root, "first-coverage", [0.5] * 12, [20.0] * 12,
+ mutate=lambda rows: rows[0].__setitem__("scan_ffmpeg_present", "false"),
+ )
+ self.assertFalse(first_not_present["sampling_valid"])
+ self.assertIn("scan_phase_first_row_not_present", first_not_present["sampling_problems"])
+
+ last_not_exit = analyze_case(
+ root, "last-coverage", [0.5] * 12, [20.0] * 12,
+ mutate=lambda rows: rows[-1].__setitem__("scan_ffmpeg_exit_observed", "false"),
+ )
+ self.assertFalse(last_not_exit["sampling_valid"])
+ self.assertIn("scan_phase_last_row_not_exit_observation", last_not_exit["sampling_problems"])
+
+ argv_rewrites = {
+ "missing-output-format": lambda value: value.replace(
+ "-hwaccel_output_format cuda ", ""
+ ),
+ "wrong-output-format": lambda value: value.replace(
+ "-hwaccel_output_format cuda", "-hwaccel_output_format qsv"
+ ),
+ "late-output-format": lambda value: value.replace(
+ "-hwaccel_output_format cuda ", ""
+ ).replace("-i x.mp4", "-i x.mp4 -hwaccel_output_format cuda"),
+ "missing-hwdownload": lambda value: value.replace(
+ "hwdownload,format=nv12,", ""
+ ),
+ }
+ for name, rewrite in argv_rewrites.items():
+ with self.subTest(argv_case=name):
+ invalid_argv = analyze_case(
+ root, f"invalid-{name}", [0.5] * 12, [20.0] * 12,
+ mutate=lambda rows, transform=rewrite: [
+ row.__setitem__(
+ "scan_ffmpeg_command_line",
+ transform(row["scan_ffmpeg_command_line"]),
+ )
+ for row in rows
+ ],
+ )
+ self.assertFalse(invalid_argv["sampling_valid"])
+ self.assertIn("nvdec_scan_argv_invalid", invalid_argv["sampling_problems"])
+
+ @unittest.skipUnless(
+ os.name == "nt" and shutil.which("powershell") and shutil.which("nvidia-smi"),
+ "Windows PowerShell and nvidia-smi are required",
+ )
+ def test_resource_sampler_run_rejects_forged_nvdec_and_excludes_page_ffmpeg(self):
+ sampler = Path(__file__).resolve().parent / "monitor_media_host_responsiveness.ps1"
+ sampler_text = sampler.read_text(encoding="utf-8")
+ process_start = sampler_text.index("[void]$Process.Start()")
+ self.assertLess(
+ sampler_text.index("$HostCpuCounter = New-Object"), process_start
+ )
+ self.assertLess(sampler_text.index("$HostCpuCounter.NextValue()"), process_start)
+ self.assertLess(
+ sampler_text.index("$GpuMemoryCategory = New-Object"), process_start
+ )
+ self.assertLess(sampler_text.index("$GpuMemoryCategory.GetInstanceNames()"), process_start)
+ self.assertLess(
+ sampler_text.index('$StartInfo.EnvironmentVariables["MBX_MEDIA_SAMPLER_READY"]'),
+ process_start,
+ )
+ with tempfile.TemporaryDirectory() as raw:
+ root = Path(raw)
+ fake_ffmpeg = root / "ffmpeg.exe"
+ shutil.copy2(sys.executable, fake_ffmpeg)
+ child = root / "fake_ffmpeg_child.py"
+ child.write_text(
+ "import sys, time\n"
+ "time.sleep(float(sys.argv[sys.argv.index('--sleep') + 1]))\n",
+ encoding="utf-8",
+ )
+ target = root / "stub_media_target.py"
+ target.write_text(
+ textwrap.dedent(
+ f"""
+ import ctypes
+ import os
+ from pathlib import Path
+ import subprocess
+ import sys
+ import time
+
+ sys.stdout.reconfigure(encoding="utf-8")
+ sys.stderr.reconfigure(encoding="utf-8")
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32.SetPriorityClass(kernel32.GetCurrentProcess(), 0x00004000)
+ creationflags = 0x00004000
+ if os.environ.get("MBX_MEDIA_SAMPLER_READY") != "1":
+ raise RuntimeError("sampler-ready handshake missing")
+ fake_ffmpeg = Path({str(fake_ffmpeg)!r})
+ child = Path({str(child)!r})
+ scan = subprocess.Popen([
+ str(fake_ffmpeg), str(child), "--sleep", "7.0",
+ "-hwaccel", "cuda", "-c:v", "h264_cuvid",
+ "-threads", "4", "-filter_threads", "2",
+ "-filter_complex_threads", "2", "-i", "fake.mp4",
+ "-map", "0:v:0", "-vf",
+ "hwdownload,format=nv12,setpts=PTS-STARTPTS,"
+ "fps=fps=1:start_time=0,scale=320:180",
+ "-pix_fmt", "rgb24", "-f", "rawvideo", "-",
+ ], creationflags=creationflags)
+ scan.wait()
+ print("警告:NVDEC 不可用;改用受限 CPU。", file=sys.stderr, flush=True)
+ print(
+ "扫描解码路径:NVDEC/CUDA (h264_cuvid);解码线程=4;滤镜线程=2",
+ flush=True,
+ )
+ time.sleep(1.5)
+ for index in range(24):
+ churn = subprocess.Popen([
+ str(fake_ffmpeg), str(child), "--sleep", "0.04",
+ "-threads", "4", "-i", "fake.mp4",
+ "-frames:v", "1", "-n", f"race-{{index}}.png",
+ ], creationflags=creationflags)
+ churn.wait()
+ page = subprocess.Popen([
+ str(fake_ffmpeg), str(child), "--sleep", "4.0",
+ "-threads", "4", "-i", "fake.mp4",
+ "-frames:v", "1", "-n", "fake.png",
+ ], creationflags=creationflags)
+ page.wait()
+ """
+ ).lstrip(),
+ encoding="utf-8",
+ )
+ source = root / "source.stub"
+ source.write_bytes(b"read-only source fingerprint")
+ evidence = root / "evidence"
+ result = subprocess.run(
+ [
+ shutil.which("powershell"), "-NoProfile", "-File", str(sampler),
+ "-PythonPath", sys.executable,
+ "-ScriptPath", str(target),
+ "-VideoPath", str(source),
+ "-OutputPath", str(root / "unused-output"),
+ "-EvidenceDirectory", str(evidence),
+ ],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ env={
+ **os.environ,
+ "PYTHONIOENCODING": "utf-8",
+ "PYTHONUTF8": "1",
+ "MBX_MEDIA_SAMPLER_TEST_CIM_DELAY_MS": "100",
+ "MBX_MEDIA_SAMPLER_TEST_FAIL_SAMPLE_ONCE": "1",
+ },
+ timeout=90,
+ )
+ self.assertEqual(result.returncode, 3, result.stderr + result.stdout)
+ run_summary = json.loads(
+ (evidence / "run_summary.json").read_text(encoding="utf-8-sig")
+ )
+ self.assertEqual(run_summary["target_exit_code"], 0)
+ self.assertFalse(run_summary["resource_acceptance_pass"])
+ self.assertTrue(
+ run_summary["nvdec_command_log_evidence"],
+ json.dumps(
+ {
+ "run_summary": run_summary,
+ "stdout": (evidence / "stdout.log").read_text("utf-8"),
+ },
+ ensure_ascii=False,
+ ),
+ )
+ self.assertFalse(run_summary["nvdec_scan_argv_evidence"])
+ self.assertGreater(run_summary["command_line_disappearance_race_count"], 0)
+ self.assertEqual(run_summary["injected_sample_failure_count"], 1)
+ self.assertEqual(
+ len(run_summary["sampling_errors"]), 1, run_summary["sampling_errors"]
+ )
+ self.assertIn("injected post-scan sample failure", run_summary["sampling_errors"][0])
+ self.assertTrue(run_summary["source_unchanged"])
+ self.assertIn("NVDEC/CUDA (h264_cuvid)", (evidence / "stdout.log").read_text("utf-8"))
+ self.assertIn("改用受限 CPU", (evidence / "stderr.log").read_text("utf-8"))
+
+ with (evidence / "resource_samples.csv").open(
+ newline="", encoding="utf-8-sig"
+ ) as handle:
+ rows = list(csv.DictReader(handle))
+ resource_summary = json.loads(
+ (evidence / "resource_summary.json").read_text(encoding="utf-8-sig")
+ )
+ self.assertFalse(resource_summary["sampling_valid"], resource_summary)
+ self.assertEqual(
+ resource_summary["sampling_problems"], ["nvdec_scan_argv_invalid"]
+ )
+ self.assertTrue(resource_summary["all_windows_host_cpu_strictly_below_85"])
+ self.assertTrue(resource_summary["window_count"] > 0)
+ self.assertFalse(any(
+ "previous_timestamp" in problem or "interval_mismatch" in problem
+ for problem in resource_summary["sampling_problems"]
+ ))
+ for index, row in enumerate(rows):
+ if index == 0:
+ self.assertEqual(row["previous_valid_timestamp_utc"], "")
+ self.assertAlmostEqual(float(row["interval_seconds"]), 0.0, places=6)
+ continue
+ current = datetime.fromisoformat(row["timestamp_utc"].replace("Z", "+00:00"))
+ previous = datetime.fromisoformat(
+ rows[index - 1]["timestamp_utc"].replace("Z", "+00:00")
+ )
+ reported_previous = datetime.fromisoformat(
+ row["previous_valid_timestamp_utc"].replace("Z", "+00:00")
+ )
+ self.assertLess(abs((reported_previous - previous).total_seconds()), 0.001)
+ self.assertLess(
+ abs(float(row["interval_seconds"]) - (current - previous).total_seconds()),
+ 0.001,
+ )
+ active_indexes = [
+ index for index, row in enumerate(rows)
+ if row["scan_phase_active"].lower() == "true"
+ ]
+ self.assertGreaterEqual(
+ len(active_indexes), 2,
+ json.dumps({"rows": rows, "run_summary": run_summary}, ensure_ascii=False),
+ )
+ self.assertEqual(len({rows[index]["scan_ffmpeg_pid"] for index in active_indexes}), 1)
+ last_active = active_indexes[-1]
+ self.assertEqual(rows[last_active]["scan_ffmpeg_present"].lower(), "false")
+ self.assertEqual(rows[last_active]["scan_ffmpeg_exit_observed"].lower(), "true")
+ later_rows = rows[last_active + 1 :]
+ self.assertTrue(later_rows)
+ self.assertTrue(any("-frames:v" in row["ffmpeg_command_lines"] for row in later_rows))
+ self.assertTrue(all(row["scan_phase_active"].lower() == "false" for row in later_rows))
+
+ @unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg is required")
+ def test_h264_h265_nvdec_and_cpu_product_chains_are_equivalent(self):
+ ffmpeg = shutil.which("ffmpeg")
+ capabilities = subprocess.check_output(
+ [ffmpeg, "-hide_banner", "-encoders"], text=True, errors="replace"
+ ) + subprocess.check_output(
+ [ffmpeg, "-hide_banner", "-decoders"], text=True, errors="replace"
+ )
+ required = ("libx264", "libx265", "h264_cuvid", "hevc_cuvid")
+ missing = [name for name in required if name not in capabilities]
+ if missing:
+ self.skipTest(f"missing FFmpeg capabilities: {missing}")
+
+ with tempfile.TemporaryDirectory() as raw:
+ root = Path(raw)
+ frames_dir = root / "frames"
+ frames_dir.mkdir()
+ size = (320, 180)
+
+ def page(kind, animated=False):
+ colors = {"a": (245, 245, 240), "b": (20, 45, 85), "c": (232, 210, 70)}
+ image = Image.new("RGB", size, colors[kind])
+ draw = ImageDraw.Draw(image)
+ draw.rectangle((0, 0, 319, 30), fill=(25, 90, 160))
+ draw.rectangle((30, 55, 285, 70), fill=(30, 30, 30))
+ if kind != "a" or animated:
+ draw.rectangle((30, 95, 250, 110), fill=(35, 35, 35))
+ if animated:
+ draw.rectangle((260, 140, 280, 160), fill=(210, 30, 30))
+ return image
+
+ transition_colors = ((100, 100, 100), (120, 25, 130), (20, 150, 90), (180, 65, 20))
+ frames = [page("a")] * 3 + [Image.new("RGB", size, transition_colors[0])]
+ frames += [page("a", True)] * 3 + [Image.new("RGB", size, transition_colors[1])]
+ frames += [page("b")] * 3 + [Image.new("RGB", size, transition_colors[2])]
+ frames += [page("a", True)] * 3 + [Image.new("RGB", size, transition_colors[3])]
+ frames += [page("c")] * 3
+ for index, image in enumerate(frames):
+ image.save(frames_dir / f"frame_{index:03d}.png")
+
+ codec_settings = {
+ "h264": ("libx264", "h264_cuvid"),
+ "hevc": ("libx265", "hevc_cuvid"),
+ }
+ evidence = []
+ for codec, (encoder, decoder) in codec_settings.items():
+ source = root / f"sequence-{codec}.mp4"
+ command = [
+ ffmpeg, "-hide_banner", "-loglevel", "error", "-framerate", "1",
+ "-i", str(frames_dir / "frame_%03d.png"), "-threads", "4",
+ "-c:v", encoder, "-preset", "ultrafast",
+ ]
+ if codec == "h264":
+ command.extend(("-qp", "0"))
+ else:
+ command.extend(("-x265-params", "lossless=1:pools=4"))
+ command.extend(("-pix_fmt", "yuv420p", "-n", str(source)))
+ subprocess.run(command, check=True, timeout=120)
+ probe_staging = root / f"probe-{codec}"
+ probe_staging.mkdir()
+ info = slides.probe_video(source, probe_staging)
+ self.assertEqual(info.codec_name, codec)
+ self.assertEqual(info.pixel_format, "yuv420p")
+
+ hardware_staging = root / f"scan-{codec}-hardware"
+ cpu_staging = root / f"scan-{codec}-cpu"
+ hardware_staging.mkdir()
+ cpu_staging.mkdir()
+ hardware = slides._scan_video_once(
+ source, hardware_staging, info, decoder=decoder
+ )
+ cpu = slides._scan_video_once(source, cpu_staging, info, decoder=None)
+ self.assertEqual(hardware.sample_count, cpu.sample_count)
+ self.assertEqual(hardware.stable_candidate_count, cpu.stable_candidate_count)
+ self.assertEqual(hardware.classification_counts, cpu.classification_counts)
+ self.assertEqual(len(hardware.pages), len(cpu.pages))
+ hardware_selected = slides.select_main_sequence(hardware.pages)
+ cpu_selected = slides.select_main_sequence(cpu.pages)
+ self.assertEqual(
+ [page.first_seen_order for page in hardware_selected],
+ [page.first_seen_order for page in cpu_selected],
+ )
+ self.assertEqual(len(hardware_selected), len(cpu_selected))
+ self.assertGreaterEqual(len(hardware_selected), 3)
+ for hardware_page, cpu_page in zip(hardware_selected, cpu_selected):
+ self.assertAlmostEqual(
+ hardware_page.current_timestamp,
+ cpu_page.current_timestamp,
+ delta=0.001,
+ )
+
+ product_summaries = []
+ for label, selected in (("hardware", hardware_selected), ("cpu", cpu_selected)):
+ output = root / f"product-{codec}-{label}"
+ output.mkdir()
+ pngs = slides.extract_original_pages(source, output, info, selected)
+ pdf = output / "slides.pdf"
+ slides.write_image_pdf(pngs, pdf)
+ png_summary = []
+ for path in pngs:
+ with Image.open(path) as image:
+ png_summary.append((path.name, image.mode, image.size))
+ product_summaries.append((png_summary, pdf_rgb_pages(pdf)))
+ self.assertEqual(product_summaries[0][0], product_summaries[1][0])
+ self.assertEqual(
+ [page[0] for page in product_summaries[0][1]],
+ [page[0] for page in product_summaries[1][1]],
+ )
+ self.assertEqual(
+ len(product_summaries[0][1]), len(product_summaries[1][1])
+ )
+ hardware_command = slides._scan_command(
+ ffmpeg, source, *slides._scan_size(info), decoder
+ )
+ cpu_command = slides._scan_command(
+ ffmpeg, source, *slides._scan_size(info), None
+ )
+ evidence.extend((
+ {
+ "codec": codec,
+ "path": "nvdec",
+ "decoder": decoder,
+ "argv": hardware_command,
+ "samples": hardware.sample_count,
+ "stable_candidates": hardware.stable_candidate_count,
+ "classifications": dict(hardware.classification_counts),
+ "selected_pages": len(hardware_selected),
+ "timestamps": [page.current_timestamp for page in hardware_selected],
+ },
+ {
+ "codec": codec,
+ "path": "cpu",
+ "decoder": None,
+ "argv": cpu_command,
+ "samples": cpu.sample_count,
+ "stable_candidates": cpu.stable_candidate_count,
+ "classifications": dict(cpu.classification_counts),
+ "selected_pages": len(cpu_selected),
+ "timestamps": [page.current_timestamp for page in cpu_selected],
+ },
+ ))
+ print("MEDIA_HOST_SYNTHETIC_EVIDENCE=" + json.dumps(evidence, ensure_ascii=False))
@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg is required")
def test_vfr_nonzero_pts_scan_and_exact_seek_share_relative_time(self):
@@ -281,10 +1222,9 @@
source = root / "source.mp4"
source.write_bytes(b"stub")
output = root / "result"
- page_feature = np.zeros((8, 8), np.uint8)
- logical_page = slides.LogicalPage(page_feature, page_feature, 0, 1.0, 0.2, 0)
+ page = logical_page(0)
with mock.patch.object(slides, "probe_video", return_value=slides.VideoInfo(640, 360, 10)), mock.patch.object(
- slides, "scan_video", return_value=[logical_page]
+ slides, "scan_video", return_value=[page]
), mock.patch.object(slides, "_which_or_error", return_value="ffmpeg"), mock.patch.object(
slides.subprocess, "Popen", side_effect=make_process
):
@@ -575,6 +1515,46 @@
self.assertFalse(output.exists())
self.assertFalse(any(root.glob(".result.staging-*")))
+ def test_main_sequence_filter_runs_once_before_original_extraction(self):
+ with tempfile.TemporaryDirectory() as raw:
+ root = Path(raw)
+ source = root / "source.mp4"
+ source.write_bytes(b"stub")
+ output = root / "result"
+ candidates = [logical_page(index, palette(1)) for index in range(10)]
+ selected = candidates[1:9]
+ with mock.patch.object(
+ slides, "probe_video", return_value=slides.VideoInfo(640, 360, 10)
+ ), mock.patch.object(
+ slides, "scan_video", return_value=candidates
+ ), mock.patch.object(
+ slides, "select_main_sequence", return_value=selected
+ ) as sequence_filter, mock.patch.object(
+ slides, "extract_original_pages", return_value=[]
+ ) as original_extraction, mock.patch.object(slides, "write_image_pdf"):
+ self.assertEqual(slides.extract_slides(str(source), str(output)), output)
+ sequence_filter.assert_called_once_with(candidates)
+ self.assertIs(original_extraction.call_args.args[3], selected)
+
+ def test_selected_pages_are_renumbered_continuously(self):
+ with tempfile.TemporaryDirectory() as raw:
+ staging = Path(raw)
+ selected = [logical_page(index, palette(1)) for index in range(3, 6)]
+
+ def write_png(command, stderr_path, stage):
+ destination = Path(command[-1])
+ Image.new("RGB", (64, 36), (20, 40, 80)).save(destination)
+
+ with mock.patch.object(slides, "_which_or_error", return_value="ffmpeg"), mock.patch.object(
+ slides, "_run_file_command", side_effect=write_png
+ ):
+ outputs = slides.extract_original_pages(
+ Path("source.mp4"), staging, slides.VideoInfo(64, 36, 10), selected
+ )
+ self.assertEqual([path.name for path in outputs], [
+ "slide_001.png", "slide_002.png", "slide_003.png"
+ ])
+
def test_commit_conflict_is_wrapped_and_staging_is_removed(self):
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
@@ -582,7 +1562,7 @@
source.write_bytes(b"stub")
output = root / "result"
with mock.patch.object(slides, "probe_video", return_value=slides.VideoInfo(640, 360, 10)), mock.patch.object(
- slides, "scan_video", return_value=[]
+ slides, "scan_video", return_value=[logical_page(0)]
), mock.patch.object(slides, "extract_original_pages", return_value=[]), mock.patch.object(
slides, "write_image_pdf"
), mock.patch.object(slides.os, "rename", side_effect=FileExistsError("race")):
diff --git a/dev/project-dev/test/test_hibor_batch_executor.py b/dev/project-dev/test/test_hibor_batch_executor.py
index 4b1b791..bfb8ec5 100644
--- a/dev/project-dev/test/test_hibor_batch_executor.py
+++ b/dev/project-dev/test/test_hibor_batch_executor.py
@@ -3,6 +3,7 @@
import csv
import json
from pathlib import Path
+import subprocess
import sys
import tempfile
import unittest
@@ -110,7 +111,7 @@
"quota_ledger": "ana-data/tmp/report-collection-control/daily_quota_test.csv",
"adb_executable": "adb",
"pdfinfo_executable": "pdfinfo",
- "device_serial": "127.0.0.1:21503",
+ "device_serial": "emulator-5554",
"source_scope": source_scope(fail_index),
},
"items": items,
@@ -125,11 +126,32 @@
(self.root / "ana-data" / "tmp").mkdir(parents=True)
self.stub = self.root / "stub_cli.py"
self.stub.write_text(STUB_CLI, encoding="utf-8")
+ self.adb_calls: list[tuple[list[str], Path, int]] = []
def tearDown(self) -> None:
self.temp.cleanup()
- def run_batch(self, value: dict) -> dict:
+ def make_device_runner(
+ self,
+ stdout: bytes = b"List of devices attached\nemulator-5554\tdevice product:sdk\n",
+ *,
+ returncode: int = 0,
+ stderr: bytes = b"",
+ ):
+ def device_runner(command, cwd, environment, timeout_seconds):
+ self.adb_calls.append((list(command), cwd, timeout_seconds))
+ return subprocess.CompletedProcess(
+ list(command), returncode, stdout=stdout, stderr=stderr
+ )
+
+ return device_runner
+
+ def run_batch(
+ self,
+ value: dict,
+ *,
+ adb_stdout: bytes = b"List of devices attached\nemulator-5554\tdevice product:sdk\n",
+ ) -> dict:
path = self.root / f"{value['batch_id']}.json"
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
return batch_executor.execute_batch(
@@ -138,6 +160,7 @@
kernel_root=self.root,
python_executable=sys.executable,
kernel_entry=self.stub,
+ device_runner=self.make_device_runner(adb_stdout),
)
def test_all_success_creates_exact_tasks_and_batch_outputs(self):
@@ -159,6 +182,10 @@
self.assertEqual((task["min_screens"], task["normal_max_screens"]), (1, 1))
self.assertEqual(task["expected_reports"][0]["title"], "精确报告1")
self.assertEqual(task["expected_reports"][0]["page_count"], 1)
+ self.assertEqual(task["device_serial"], "emulator-5554")
+ self.assertEqual(self.adb_calls, [
+ (["adb", "devices", "-l"], self.root, batch_executor.ADB_PREFLIGHT_TIMEOUT_SECONDS)
+ ])
with (batch_root / "batch_manifest.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
self.assertEqual([row["status"] for row in rows], ["SUCCESS"] * 3)
@@ -221,6 +248,94 @@
self.assertEqual(summary["files_read"], 2)
self.assertEqual(summary["stage_totals_ms"], {"ui_scan": 20, "detail": 28})
+ def test_explicit_and_auto_device_resolution(self):
+ multiple = (
+ b"List of devices attached\n"
+ b"emulator-5554\tdevice product:sdk\n"
+ b"emulator-5556\tdevice product:sdk\n"
+ )
+ explicit = batch_executor.resolve_device_serial(
+ "adb",
+ "emulator-5554",
+ cwd=self.root,
+ environment={},
+ runner=self.make_device_runner(multiple),
+ )
+ self.assertEqual(explicit, "emulator-5554")
+
+ value = batch_value("auto-single", 1)
+ value["runtime"]["device_serial"] = "auto"
+ terminal = self.run_batch(value)
+ self.assertEqual(terminal["status"], "SUCCESS")
+ task = json.loads(
+ (self.root / "ana-data" / "tmp" / "hibor-runs" / "auto-single" / "t" / "01.json")
+ .read_text(encoding="utf-8")
+ )
+ self.assertEqual(task["device_serial"], "emulator-5554")
+
+ def test_auto_zero_and_multiple_devices_fail_closed_before_output(self):
+ cases = {
+ "zero": b"List of devices attached\n\n",
+ "multiple": (
+ b"List of devices attached\n"
+ b"emulator-5554\tdevice\n"
+ b"emulator-5556\tdevice\n"
+ ),
+ }
+ for batch_id, stdout in cases.items():
+ with self.subTest(batch_id=batch_id):
+ value = batch_value(batch_id, 1)
+ value["runtime"]["device_serial"] = "auto"
+ path = self.root / f"{batch_id}.json"
+ path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
+ with self.assertRaises(batch_executor.BatchInputError):
+ batch_executor.execute_batch(
+ path,
+ project_root=self.root,
+ kernel_root=self.root,
+ python_executable=sys.executable,
+ kernel_entry=self.stub,
+ device_runner=self.make_device_runner(stdout),
+ )
+ self.assertFalse(
+ (self.root / "ana-data" / "tmp" / "hibor-runs" / batch_id).exists()
+ )
+
+ def test_unready_missing_and_failed_adb_preflight_fail_closed(self):
+ cases = (
+ (
+ "emulator-5554",
+ b"List of devices attached\nemulator-5554\toffline\n",
+ 0,
+ b"",
+ ),
+ (
+ "emulator-5554",
+ b"List of devices attached\nemulator-5556\tdevice\n",
+ 0,
+ b"",
+ ),
+ (
+ "auto",
+ b"List of devices attached\nemulator-5554\tunauthorized\n",
+ 0,
+ b"",
+ ),
+ ("auto", b"", 1, b"adb unavailable"),
+ )
+ for requested, stdout, returncode, stderr in cases:
+ with self.subTest(requested=requested, stdout=stdout, returncode=returncode):
+ with self.assertRaises(batch_executor.BatchInputError):
+ batch_executor.resolve_device_serial(
+ "adb",
+ requested,
+ cwd=self.root,
+ environment={},
+ runner=self.make_device_runner(
+ stdout, returncode=returncode, stderr=stderr
+ ),
+ )
+
if __name__ == "__main__":
unittest.main()
diff --git a/dev/project-dev/test/test_transcribe_media.py b/dev/project-dev/test/test_transcribe_media.py
index ba72522..d6304a4 100644
--- a/dev/project-dev/test/test_transcribe_media.py
+++ b/dev/project-dev/test/test_transcribe_media.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import os
import subprocess
import sys
import tempfile
@@ -138,11 +139,121 @@
if Path(command[0]).name.lower().startswith("ffmpeg")
)
self.assertIn("-n", ffmpeg_command)
+ self.assertEqual(
+ ffmpeg_command[ffmpeg_command.index("-threads") + 1],
+ str(transcribe_media.MEDIA_DECODE_THREADS),
+ )
+ self.assertLess(ffmpeg_command.index("-threads"), ffmpeg_command.index("-i"))
self.assertEqual(ffmpeg_command[ffmpeg_command.index("-map") + 1], "0:a:0")
self.assertEqual(ffmpeg_command[ffmpeg_command.index("-ac") + 1], "1")
self.assertEqual(ffmpeg_command[ffmpeg_command.index("-ar") + 1], "16000")
self.assertEqual(ffmpeg_command[ffmpeg_command.index("-c:a") + 1], "flac")
+ def test_default_process_runner_uses_below_normal_creation_flags(self) -> None:
+ completed = subprocess.CompletedProcess(["tool"], 0, "", "")
+ with patch.object(transcribe_media, "_is_windows", return_value=True), patch.object(
+ transcribe_media.subprocess, "run", return_value=completed
+ ) as run:
+ self.assertIs(transcribe_media._run_process(["tool"]), completed)
+ self.assertEqual(
+ run.call_args.kwargs["creationflags"],
+ getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0x00004000),
+ )
+
+ @unittest.skipUnless(os.name == "nt", "Windows named mutex and priority are required")
+ def test_named_mutex_blocks_second_process_and_restores_priority(self) -> None:
+ import ctypes
+
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32.GetCurrentProcess.restype = ctypes.c_void_p
+ kernel32.GetPriorityClass.argtypes = (ctypes.c_void_p,)
+ kernel32.GetPriorityClass.restype = ctypes.c_uint32
+ process_handle = kernel32.GetCurrentProcess()
+ original_priority = int(kernel32.GetPriorityClass(process_handle))
+ child_code = (
+ "import sys\n"
+ f"sys.path.insert(0, {str(PROJECT_DEV)!r})\n"
+ "import transcribe_media as module\n"
+ "try:\n"
+ " with module.media_host_guard():\n"
+ " pass\n"
+ "except module.MediaHostGuardError:\n"
+ " raise SystemExit(23)\n"
+ )
+ with transcribe_media.media_host_guard():
+ self.assertEqual(int(kernel32.GetPriorityClass(process_handle)), 0x00004000)
+ busy = subprocess.run([sys.executable, "-c", child_code], check=False)
+ self.assertEqual(busy.returncode, 23)
+ self.assertEqual(int(kernel32.GetPriorityClass(process_handle)), original_priority)
+ free = subprocess.run([sys.executable, "-c", child_code], check=False)
+ self.assertEqual(free.returncode, 0)
+
+ @unittest.skipUnless(os.name == "nt", "Windows named mutex is required")
+ def test_both_real_clis_reject_busy_before_path_or_external_actions(self) -> None:
+ scripts = (
+ (PROJECT_DEV / "transcribe_media.py", 1),
+ (PROJECT_DEV / "extract_ppt_slides.py", 2),
+ )
+ with transcribe_media.media_host_guard():
+ for script, expected_code in scripts:
+ with self.subTest(script=script.name):
+ result = subprocess.run(
+ [sys.executable, str(script), "definitely-missing-media.mp4"],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"},
+ check=False,
+ )
+ self.assertEqual(result.returncode, expected_code)
+ self.assertIn("已有重型媒体任务运行", result.stderr)
+ self.assertNotIn("视频文件不存在", result.stderr)
+ self.assertNotIn("输入视频不存在", result.stderr)
+
+ def test_win32_guard_failures_close_every_created_handle(self) -> None:
+ def kernel(**values):
+ defaults = {
+ "CreateMutexW": unittest.mock.Mock(return_value=101),
+ "WaitForSingleObject": unittest.mock.Mock(return_value=0),
+ "ReleaseMutex": unittest.mock.Mock(return_value=True),
+ "CloseHandle": unittest.mock.Mock(return_value=True),
+ "GetCurrentProcess": unittest.mock.Mock(return_value=202),
+ "GetPriorityClass": unittest.mock.Mock(return_value=0x20),
+ "SetPriorityClass": unittest.mock.Mock(return_value=True),
+ }
+ defaults.update(values)
+ return types.SimpleNamespace(**defaults)
+
+ scenarios = (
+ ("wait", kernel(WaitForSingleObject=unittest.mock.Mock(return_value=0x102))),
+ ("get", kernel(GetPriorityClass=unittest.mock.Mock(return_value=0))),
+ ("set", kernel(SetPriorityClass=unittest.mock.Mock(return_value=False))),
+ )
+ for name, fake in scenarios:
+ with self.subTest(name=name), patch.object(
+ transcribe_media, "_is_windows", return_value=True
+ ), patch.object(transcribe_media, "_windows_kernel32", return_value=fake), patch.object(
+ transcribe_media, "_win32_last_error", return_value=5
+ ):
+ with self.assertRaises(transcribe_media.MediaHostGuardError):
+ with transcribe_media.media_host_guard():
+ self.fail("guard must not yield")
+ fake.CloseHandle.assert_called_once_with(101)
+ if name == "wait":
+ fake.ReleaseMutex.assert_not_called()
+ else:
+ fake.ReleaseMutex.assert_called_once_with(101)
+
+ create_failed = kernel(CreateMutexW=unittest.mock.Mock(return_value=0))
+ with patch.object(transcribe_media, "_is_windows", return_value=True), patch.object(
+ transcribe_media, "_windows_kernel32", return_value=create_failed
+ ), patch.object(transcribe_media, "_win32_last_error", return_value=5):
+ with self.assertRaises(transcribe_media.MediaHostGuardError):
+ with transcribe_media.media_host_guard():
+ self.fail("guard must not yield")
+ create_failed.CloseHandle.assert_not_called()
+
def test_existing_output_is_not_overwritten(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
diff --git a/dev/project-dev/transcribe_media.py b/dev/project-dev/transcribe_media.py
index 1b065bd..ae7cc8e 100644
--- a/dev/project-dev/transcribe_media.py
+++ b/dev/project-dev/transcribe_media.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
+from contextlib import contextmanager
import json
import math
import os
@@ -21,10 +22,17 @@
SHORT_AUDIO_LIMIT_SECONDS = 1200.0
CHUNK_CORE_SECONDS = 1200.0
CHUNK_OVERLAP_SECONDS = 5.0
+MEDIA_DECODE_THREADS = 4
+MEDIA_FILTER_THREADS = 2
+MEDIA_MUTEX_NAME = r"Local\MBXMediaHeavyTaskV1"
class MediaTranscriptionError(RuntimeError):
"""A user-facing error that should stop the command without a traceback."""
+
+
+class MediaHostGuardError(RuntimeError):
+ """The shared Windows heavy-media guard could not be acquired safely."""
@dataclass(frozen=True)
@@ -78,6 +86,106 @@
ModelLoader = Callable[[], tuple[Any, RuntimeInfo]]
+def _is_windows() -> bool:
+ return os.name == "nt"
+
+
+def _subprocess_creation_flags() -> int:
+ if not _is_windows():
+ return 0
+ return int(getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0x00004000))
+
+
+def _windows_kernel32() -> Any:
+ import ctypes
+ from ctypes import wintypes
+
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32.CreateMutexW.argtypes = (ctypes.c_void_p, wintypes.BOOL, wintypes.LPCWSTR)
+ kernel32.CreateMutexW.restype = wintypes.HANDLE
+ kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
+ kernel32.WaitForSingleObject.restype = wintypes.DWORD
+ kernel32.ReleaseMutex.argtypes = (wintypes.HANDLE,)
+ kernel32.ReleaseMutex.restype = wintypes.BOOL
+ kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
+ kernel32.CloseHandle.restype = wintypes.BOOL
+ kernel32.GetCurrentProcess.restype = wintypes.HANDLE
+ kernel32.GetPriorityClass.argtypes = (wintypes.HANDLE,)
+ kernel32.GetPriorityClass.restype = wintypes.DWORD
+ kernel32.SetPriorityClass.argtypes = (wintypes.HANDLE, wintypes.DWORD)
+ kernel32.SetPriorityClass.restype = wintypes.BOOL
+ return kernel32
+
+
+def _win32_last_error() -> int:
+ import ctypes
+
+ return int(ctypes.get_last_error())
+
+
+@contextmanager
+def media_host_guard() -> Iterable[None]:
+ """Serialize heavy media CLIs and lower the current Windows process priority."""
+ if not _is_windows():
+ yield
+ return
+
+ kernel32 = _windows_kernel32()
+
+ wait_object_0 = 0x00000000
+ wait_abandoned = 0x00000080
+ wait_timeout = 0x00000102
+ below_normal_priority_class = 0x00004000
+ handle = kernel32.CreateMutexW(None, False, MEDIA_MUTEX_NAME)
+ if not handle:
+ raise MediaHostGuardError(
+ f"无法创建重型媒体互斥(Win32={_win32_last_error()})。"
+ )
+ acquired = False
+ process_handle = kernel32.GetCurrentProcess()
+ original_priority = 0
+ priority_lowered = False
+ try:
+ wait_result = int(kernel32.WaitForSingleObject(handle, 0))
+ if wait_result == wait_timeout:
+ raise MediaHostGuardError(
+ "已有重型媒体任务运行,拒绝并行启动;请等待其结束后重试。"
+ )
+ if wait_result not in (wait_object_0, wait_abandoned):
+ raise MediaHostGuardError(
+ f"无法获取重型媒体互斥(Win32 wait={wait_result})。"
+ )
+ acquired = True
+ original_priority = int(kernel32.GetPriorityClass(process_handle))
+ if original_priority == 0:
+ raise MediaHostGuardError(
+ f"无法读取当前进程优先级(Win32={_win32_last_error()})。"
+ )
+ if not kernel32.SetPriorityClass(process_handle, below_normal_priority_class):
+ raise MediaHostGuardError(
+ f"无法将当前进程设为低于正常优先级(Win32={_win32_last_error()})。"
+ )
+ priority_lowered = True
+ yield
+ finally:
+ if priority_lowered and not kernel32.SetPriorityClass(process_handle, original_priority):
+ print(
+ "警告:重型媒体任务结束后无法恢复原进程优先级"
+ f"(Win32={_win32_last_error()})。",
+ file=sys.stderr,
+ )
+ if acquired and not kernel32.ReleaseMutex(handle):
+ print(
+ f"警告:释放重型媒体互斥失败(Win32={_win32_last_error()})。",
+ file=sys.stderr,
+ )
+ if not kernel32.CloseHandle(handle):
+ print(
+ f"警告:关闭重型媒体互斥句柄失败(Win32={_win32_last_error()})。",
+ file=sys.stderr,
+ )
+
+
def _run_process(command: Sequence[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
list(command),
@@ -86,6 +194,7 @@
text=True,
encoding="utf-8",
errors="replace",
+ creationflags=_subprocess_creation_flags(),
)
@@ -182,6 +291,8 @@
"error",
"-nostdin",
"-n",
+ "-threads",
+ str(MEDIA_DECODE_THREADS),
"-i",
str(video),
"-map",
@@ -275,6 +386,8 @@
"-n",
"-ss",
f"{chunk.audio_start:.6f}",
+ "-threads",
+ str(MEDIA_DECODE_THREADS),
"-i",
str(full_audio),
"-t",
@@ -636,8 +749,9 @@
def main(argv: Sequence[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
try:
- outputs, runtime = transcribe_video(args.video, args.output, args.language)
- except MediaTranscriptionError as exc:
+ with media_host_guard():
+ outputs, runtime = transcribe_video(args.video, args.output, args.language)
+ except (MediaTranscriptionError, MediaHostGuardError) as exc:
print(f"错误:{exc}", file=sys.stderr)
return 1
except KeyboardInterrupt as exc:
--
Gitblit v1.9.3