Update obsidian-kb skill and add find-skills

- obsidian-kb: Add OCR processors (easyocr, tesseract)
- obsidian-kb: Fix subtitle detection with test files
- obsidian-kb: Add cookie verification script
- Add find-skills skill for discovering capabilities
- Add video processing scripts
- Update clawhub lock file
This commit is contained in:
OpenClaw Agent
2026-02-09 11:53:42 +08:00
parent d66eade77e
commit e0a2b323d9
33 changed files with 3555 additions and 36 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "find-skills",
"installedVersion": "0.1.0",
"installedAt": 1770607549810
}
+133
View File
@@ -0,0 +1,133 @@
---
name: find-skills
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
---
# Find Skills
This skill helps you discover and install skills from the open agent skills ecosystem.
## When to Use This Skill
Use this skill when the user:
- Asks "how do I do X" where X might be a common task with an existing skill
- Says "find a skill for X" or "is there a skill for X"
- Asks "can you do X" where X is a specialized capability
- Expresses interest in extending agent capabilities
- Wants to search for tools, templates, or workflows
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
## What is the Skills CLI?
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
**Key commands:**
- `npx skills find [query]` - Search for skills interactively or by keyword
- `npx skills add <package>` - Install a skill from GitHub or other sources
- `npx skills check` - Check for skill updates
- `npx skills update` - Update all installed skills
**Browse skills at:** https://skills.sh/
## How to Help Users Find Skills
### Step 1: Understand What They Need
When a user asks for help with something, identify:
1. The domain (e.g., React, testing, design, deployment)
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
3. Whether this is a common enough task that a skill likely exists
### Step 2: Search for Skills
Run the find command with a relevant query:
```bash
npx skills find [query]
```
For example:
- User asks "how do I make my React app faster?" → `npx skills find react performance`
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
- User asks "I need to create a changelog" → `npx skills find changelog`
The command will return results like:
```
Install with npx skills add <owner/repo@skill>
vercel-labs/agent-skills@vercel-react-best-practices
└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices
```
### Step 3: Present Options to the User
When you find relevant skills, present them to the user with:
1. The skill name and what it does
2. The install command they can run
3. A link to learn more at skills.sh
Example response:
```
I found a skill that might help! The "vercel-react-best-practices" skill provides
React and Next.js performance optimization guidelines from Vercel Engineering.
To install it:
npx skills add vercel-labs/agent-skills@vercel-react-best-practices
Learn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices
```
### Step 4: Offer to Install
If the user wants to proceed, you can install the skill for them:
```bash
npx skills add <owner/repo@skill> -g -y
```
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
## Common Skill Categories
When searching, consider these common categories:
| Category | Example Queries |
| --------------- | ---------------------------------------- |
| Web Development | react, nextjs, typescript, css, tailwind |
| Testing | testing, jest, playwright, e2e |
| DevOps | deploy, docker, kubernetes, ci-cd |
| Documentation | docs, readme, changelog, api-docs |
| Code Quality | review, lint, refactor, best-practices |
| Design | ui, ux, design-system, accessibility |
| Productivity | workflow, automation, git |
## Tips for Effective Searches
1. **Use specific keywords**: "react testing" is better than just "testing"
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
## When No Skills Are Found
If no relevant skills exist:
1. Acknowledge that no existing skill was found
2. Offer to help with the task directly using your general capabilities
3. Suggest the user could create their own skill with `npx skills init`
Example:
```
I searched for skills related to "xyz" but didn't find any matches.
I can still help you with this task directly! Would you like me to proceed?
If this is something you do often, you could create your own skill:
npx skills init my-xyz-skill
```
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn77ajmmqw3cgnc3ay1x3e0ccd805hsw",
"slug": "find-skills",
"version": "0.1.0",
"publishedAt": 1769698710765
}
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# 禁用 oneDNN 后端,解决 PaddleOCR 兼容性问题
echo "禁用 oneDNN..."
# 方法1:环境变量(临时)
export FLAGS_use_mkldnn=0
echo "✅ 环境变量已设置: FLAGS_use_mkldnn=0"
# 方法2:写入 ~/.bashrc(永久)
if ! grep -q "FLAGS_use_mkldnn=0" ~/.bashrc; then
echo "" >> ~/.bashrc
echo "# 禁用 PaddleOCR 的 oneDNN 后端(解决兼容性问题)" >> ~/.bashrc
echo "export FLAGS_use_mkldnn=0" >> ~/.bashrc
echo "✅ 已写入 ~/.bashrc"
else
echo "ℹ️ ~/.bashrc 中已存在此配置"
fi
echo ""
echo "验证配置..."
if [ "$FLAGS_use_mkldnn" = "0" ]; then
echo "✅ 当前会话: FLAGS_use_mkldnn=$FLAGS_use_mkldnn"
else
echo "⚠️ 当前会话: FLAGS_use_mkldnn=${FLAGS_use_mkldnn:-未设置}"
echo "💡 提示: 运行 'source ~/.bashrc' 使配置生效"
fi
echo ""
echo "完成!现在 PaddleOCR 将使用 CPU 原生计算,避免 oneDNN 兼容性问题。"
+42
View File
@@ -0,0 +1,42 @@
Collecting DrissionPage
Using cached DrissionPage-4.1.1.2-py3-none-any.whl.metadata (4.7 kB)
Requirement already satisfied: lxml in ./.venv/lib/python3.12/site-packages (from DrissionPage) (6.0.2)
Requirement already satisfied: requests in ./.venv/lib/python3.12/site-packages (from DrissionPage) (2.32.5)
Collecting cssselect (from DrissionPage)
Using cached cssselect-1.4.0-py3-none-any.whl.metadata (2.4 kB)
Collecting DownloadKit>=2.0.7 (from DrissionPage)
Using cached DownloadKit-2.0.7-py3-none-any.whl.metadata (1.7 kB)
Collecting websocket-client (from DrissionPage)
Using cached websocket_client-1.9.0-py3-none-any.whl.metadata (8.3 kB)
Requirement already satisfied: click in ./.venv/lib/python3.12/site-packages (from DrissionPage) (8.3.1)
Collecting tldextract>=3.4.4 (from DrissionPage)
Using cached tldextract-5.3.1-py3-none-any.whl.metadata (7.3 kB)
Collecting psutil (from DrissionPage)
Using cached psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl.metadata (22 kB)
Collecting DataRecorder>=3.4.11 (from DownloadKit>=2.0.7->DrissionPage)
Using cached DataRecorder-3.6.2-py3-none-any.whl.metadata (4.5 kB)
Requirement already satisfied: idna in ./.venv/lib/python3.12/site-packages (from tldextract>=3.4.4->DrissionPage) (3.11)
Collecting requests-file>=1.4 (from tldextract>=3.4.4->DrissionPage)
Using cached requests_file-3.0.1-py2.py3-none-any.whl.metadata (1.7 kB)
Collecting filelock>=3.0.8 (from tldextract>=3.4.4->DrissionPage)
Using cached filelock-3.20.3-py3-none-any.whl.metadata (2.1 kB)
Requirement already satisfied: charset_normalizer<4,>=2 in ./.venv/lib/python3.12/site-packages (from requests->DrissionPage) (3.4.4)
Requirement already satisfied: urllib3<3,>=1.21.1 in ./.venv/lib/python3.12/site-packages (from requests->DrissionPage) (2.6.3)
Requirement already satisfied: certifi>=2017.4.17 in ./.venv/lib/python3.12/site-packages (from requests->DrissionPage) (2026.1.4)
Collecting openpyxl (from DataRecorder>=3.4.11->DownloadKit>=2.0.7->DrissionPage)
Using cached openpyxl-3.1.5-py2.py3-none-any.whl.metadata (2.5 kB)
Collecting et-xmlfile (from openpyxl->DataRecorder>=3.4.11->DownloadKit>=2.0.7->DrissionPage)
Using cached et_xmlfile-2.0.0-py3-none-any.whl.metadata (2.7 kB)
Using cached DrissionPage-4.1.1.2-py3-none-any.whl (257 kB)
Using cached DownloadKit-2.0.7-py3-none-any.whl (21 kB)
Using cached tldextract-5.3.1-py3-none-any.whl (105 kB)
Using cached cssselect-1.4.0-py3-none-any.whl (18 kB)
Using cached psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl (155 kB)
Using cached websocket_client-1.9.0-py3-none-any.whl (82 kB)
Using cached DataRecorder-3.6.2-py3-none-any.whl (37 kB)
Using cached filelock-3.20.3-py3-none-any.whl (16 kB)
Using cached requests_file-3.0.1-py2.py3-none-any.whl (4.5 kB)
Using cached openpyxl-3.1.5-py2.py3-none-any.whl (250 kB)
Using cached et_xmlfile-2.0.0-py3-none-any.whl (18 kB)
Installing collected packages: websocket-client, psutil, filelock, et-xmlfile, cssselect, requests-file, openpyxl, tldextract, DataRecorder, DownloadKit, DrissionPage
Successfully installed DataRecorder-3.6.2 DownloadKit-2.0.7 DrissionPage-4.1.1.2 cssselect-1.4.0 et-xmlfile-2.0.0 filelock-3.20.3 openpyxl-3.1.5 psutil-7.2.2 requests-file-3.0.1 tldextract-5.3.1 websocket-client-1.9.0
+143
View File
@@ -208,6 +208,51 @@ bilibili_extraction:
enable_danmaku: false # 启用弹幕提取
timeout_seconds: 30 # 超时时间
max_retries: 3 # 最大重试次数
# 硬字幕提取配置 (OCR 方式)
hard_subtitle:
# 是否启用硬字幕提取功能
enabled: true
# OCR 引擎选择
# 可选值: "paddleocr" (推荐), "tesseract"
engine: "paddleocr"
# 字幕区域检测配置
detection:
# 默认字幕区域: "bottom" (底部), "full" (全屏), "auto" (自动检测)
default_region: "bottom"
# 当默认区域未检测到字幕时,是否自动回退到全屏模式
fallback_auto: true
# 是否缓存字幕区域检测结果(提升处理速度)
cache_region: true
# OCR 识别配置
ocr:
# 置信度阈值: 低于此值的识别结果将被过滤
confidence_threshold: 0.7
# 批处理大小: 每次处理的帧数(影响内存使用和速度)
batch_size: 32
# 最大并发工作线程数
max_workers: 4
# 视频帧提取配置
video:
# 抽帧间隔 (秒): 每隔多少秒抽取一帧进行识别
interval_seconds: 5
# 最大抽取帧数: 防止处理过长视频导致资源耗尽
max_frames: 500
# 流式处理模式: 逐步读取视频帧,避免一次性加载整个视频
stream_mode: true
# 后处理配置
postprocess:
# 是否启用去重: 去除连续相同的字幕文本
deduplicate: true
# 最小文本长度: 少于此长度的识别结果将被视为噪声
min_length: 2
# 是否过滤噪声: 使用规则过滤明显错误的识别结果
filter_noise: true
```
### 环境变量
@@ -319,6 +364,104 @@ else:
content["subtitle_text"] = subtitle_text
```
### 硬字幕提取功能
当视频没有内置字幕时,硬字幕提取功能可以通过 OCR(光学字符识别)技术从视频画面中识别字幕文本。
**功能介绍:**
- 自动检测和识别视频画面中的字幕文本
- 支持多种 OCR 引擎(PaddleOCR、Tesseract)
- 智能字幕区域检测(底部、全屏、自动)
- 自动去重和噪声过滤
- 支持流式处理,降低内存占用
**使用场景:**
- 视频没有提供 CC 字幕或 AI 字幕
- 字幕嵌入在视频画面中(硬编码字幕)
- 需要提取非标准格式的字幕
**配置说明:**
```yaml
hard_subtitle:
# 是否启用硬字幕提取功能
enabled: true
# OCR 引擎选择
# 可选值: "paddleocr" (推荐), "tesseract"
engine: "paddleocr"
# 字幕区域检测配置
detection:
# 默认字幕区域: "bottom" (底部), "full" (全屏), "auto" (自动检测)
default_region: "bottom"
# 当默认区域未检测到字幕时,是否自动回退到全屏模式
fallback_auto: true
# 是否缓存字幕区域检测结果(提升处理速度)
cache_region: true
# OCR 识别配置
ocr:
# 置信度阈值: 低于此值的识别结果将被过滤
confidence_threshold: 0.7
# 批处理大小: 每次处理的帧数(影响内存使用和速度)
batch_size: 32
# 最大并发工作线程数
max_workers: 4
# 视频帧提取配置
video:
# 抽帧间隔 (秒): 每隔多少秒抽取一帧进行识别
interval_seconds: 5
# 最大抽取帧数: 防止处理过长视频导致资源耗尽
max_frames: 500
# 流式处理模式: 逐步读取视频帧,避免一次性加载整个视频
stream_mode: true
# 后处理配置
postprocess:
# 是否启用去重: 去除连续相同的字幕文本
deduplicate: true
# 最小文本长度: 少于此长度的识别结果将被视为噪声
min_length: 2
# 是否过滤噪声: 使用规则过滤明显错误的识别结果
filter_noise: true
```
**使用示例:**
```python
from obsidian_kb.extractors.bilibili import BilibiliExtractor
# 创建提取器实例(自动启用硬字幕提取)
extractor = BilibiliExtractor(
use_playwright=True,
headless=True
)
# 当视频没有内置字幕时,会自动尝试硬字幕提取
subtitle = extractor.extract_subtitle("https://www.bilibili.com/video/BV1xx411c7mD")
print(f"识别到的字幕: {subtitle}")
```
**注意事项:**
1. **性能考虑**:硬字幕提取需要较高的计算资源,建议在配置合理的机器上运行
2. **识别准确度**:OCR 识别准确度受字幕字体、颜色、背景等因素影响,可能存在误差
3. **处理时间**:硬字幕提取比软字幕提取慢,对于长视频需要较长时间处理
4. **依赖安装**:使用前需要安装 OCR 引擎依赖:
- PaddleOCR: `pip install paddleocr paddlepaddle`
- Tesseract: 需要安装 Tesseract OCR 引擎和语言包
5. **网络视频**:流式处理需要稳定的网络连接
6. **字幕格式**:不同视频的字幕样式差异较大,可能需要调整配置参数以获得最佳效果
**最佳实践:**
- 对于短视频(<30分钟),可以减小 `interval_seconds` 以提高识别精度
- 对于长视频,增加 `interval_seconds` 和减少 `max_frames` 以平衡速度和质量
- 如果字幕位置固定在底部,使用 `default_region: "bottom"` 可以提升速度
- 首次使用建议先测试一段视频,根据结果调整配置参数
### ZhihuDrissionExtractor 类
知乎 DrissionPage 提取器提供知乎内容的完整提取功能:
+91
View File
@@ -0,0 +1,91 @@
# Obsidian KB - 待办事项
## 📌 当前任务:解决硬字幕 OCR 问题
### ✅ 已完成
1. [x] 分析硬字幕提取问题
- 发现 oneDNN 兼容性问题
- 发现 PaddleOCR 参数错误
2. [x] 禁用 oneDNN
- 设置环境变量 FLAGS_use_mkldnn=0
- 写入 ~/.bashrc
3. [x] 修复 PaddleOCR 参数
- 更新 `use_angle_cls` → `use_textline_orientation`
- 移除不支持的 `show_log` 参数
4. [x] 安装 EasyOCR
- 安装 EasyOCR 1.7.2
- 测试 EasyOCR 初始化成功
5. [x] 创建 EasyOCR 处理器
- 新文件:`src/extractors/easyocr_processor.py`
- 支持简体中文识别
- 避免所有 oneDNN 兼容性问题
### ⏳ 待完成
- [ ] 1. 修改硬字幕提取器,支持双 OCR 引擎
- 在 `hard_subtitle_extractor.py` 中添加引擎选择逻辑
- 根据 `config.hard_subtitle.engine` 参数选择使用 PaddleOCR 或 EasyOCR
- 保持向后兼容性
- [ ] 2. 更新配置文件
- 添加 `hard_subtitle.engine` 配置项(`paddleocr` | `easyocr`)
- 默认值设为 `easyocr`(更稳定)
- 更新配置文档
- [ ] 3. 实际测试 EasyOCR 字幕提取
- 使用真实视频测试硬字幕提取
- 对比 EasyOCR 和 PaddleOCR 的识别效果
- 测试处理速度和准确度
- [ ] 4. 性能优化(可选)
- 优化帧提取逻辑(当前只提取 4 帧有问题)
- 调整抽帧间隔和最大帧数
- 考虑多线程/多进程加速
- [ ] 5. 文档更新
- 更新 SKILL.md 中的硬字幕提取说明
- 添加 OCR 引擎选择指南
- 记录 EasyOCR 优势和限制
---
## 📋 备注
### 问题记录
- **PaddleOCR oneDNN 问题**:即使设置环境变量,仍出现兼容性错误
```
(Unimplemented) ConvertPirAttribute2RuntimeAttribute not support
[pir::ArrayAttribute<pir::DoubleAttribute>]
```
- **帧提取异常**:12:48的视频只提取了4帧(应该150+帧)
### EasyOCR 优势
- ✅ 不依赖 PaddlePaddle
- ✅ 安装简单
- ✅ 无底层框架兼容性问题
- ✅ 支持 70+ 种语言
- ✅ 活跃维护
### EasyOCR 限制
- ⚠️ 中文准确度略低于 PaddleOCR
- ⚠️ 首次初始化需要下载模型(约 40MB)
- ⚠️ 处理速度可能略慢
---
## 🔗 相关文件
- `src/extractors/ocr_processor.py` - PaddleOCR 处理器
- `src/extractors/easyocr_processor.py` - EasyOCR 处理器(新建)
- `src/extractors/hard_subtitle_extractor.py` - 硬字幕提取器
- `src/extractors/bilibili_extractor.py` - B站提取器(调用硬字幕提取)
- `.disable_onednn.sh` - 禁用 oneDNN 脚本
---
创建时间:2026-02-09 08:02
最后更新:2026-02-09 08:02
+99 -28
View File
@@ -1,38 +1,109 @@
obsidian:
vault_path: /home/admin/.openclaw/workspace/knowledge-base
# Obsidian KB 配置文件
# 优先级: ./config.yaml > ~/.obsidian-kb/config.yaml > ~/.config/obsidian-kb/config.yaml
git:
enabled: false
vault_path: /home/admin/.openclaw/workspace/knowledge-base
remote: origin
branch: main
auto_push: false
commit_message_template: "Add note: {title}"
# AI 配置
ai:
provider: openai # AI 提供商
model: gpt-4 # 模型名称
api_key: sk-... # API Key (建议使用环境变量: OPENAI_API_KEY)
# 存储配置
storage:
inbox_dir: inbox # 收件箱目录
topics_dir: topics # 主题目录
data_dir: data # 数据目录
# 输出配置
output:
organize_by: author
filename_format: "{date}_{title}.md"
auto_tags: true
default_tags:
- video
- bilibili
format: markdown # 输出格式
encoding: utf-8 # 文件编码
include_timestamp: true # 包含时间戳
include_source: true # 包含源链接
# Git 同步配置
git:
enabled: true # 启用 Git 同步
vault_path: ~/ObsidianVault # Obsidian 知识库路径
remote: origin # 远程仓库名
branch: main # 分支名
auto_commit: true # 自动提交
commit_message_template: "Update from obsidian-kb: {count} items"
# 分段配置
chunking:
enabled: true # 启用长视频分段
chunk_duration_seconds: 900 # 分段时长 (15分钟)
max_chunk_duration_seconds: 1800 # 最大分段时长 (30分钟)
long_video_threshold: 1800 # 长视频阈值 (30分钟)
# 数据源配置
sources:
bilibili:
enabled: true
extraction:
headless: true
timeout: 30
cookie: '' # Bilibili Cookie (可选,环境变量: BILIBILI_COOKIE)
youtube:
enabled: false
api_key: '' # YouTube API Key
zhihu:
enabled: true
use_drissionpage: true # 使用 DrissionPage 绕过检测(推荐)
cookie_file: ~/.obsidian-kb/zhihu_cookies.json # Cookie 文件路径
cookie_str: "" # 可选:直接在配置中填入 Cookie JSON (环境变量: ZHIHU_COOKIE)
timeout: 30
headless: true
zhihu:
enabled: true
use_drissionpage: true
cookie_file: ~/.obsidian-kb/zhihu_cookies.json
cookie_str: "" # 可选:直接在配置中填入
timeout: 30
headless: true
# Bilibili 提取器配置
bilibili_extraction:
use_playwright: true # 使用 Playwright 绕过检测
headless: true # 无头模式
cookie_file: '' # Cookie 文件路径 (可选)
enable_subtitle: true # 启用字幕提取
subtitle_language: zh-CN # 字幕语言
enable_danmaku: false # 启用弹幕提取
timeout_seconds: 30 # 超时时间
max_retries: 3 # 最大重试次数
chunking:
# 硬字幕提取配置 (OCR 方式)
hard_subtitle:
# 是否启用硬字幕提取功能
enabled: true
chunk_duration_seconds: 900
long_video_threshold: 1800
# OCR 引擎选择
# 可选值: "paddleocr" (推荐), "tesseract"
engine: "paddleocr"
# 字幕区域检测配置
detection:
# 默认字幕区域: "bottom" (底部), "full" (全屏), "auto" (自动检测)
default_region: "bottom"
# 当默认区域未检测到字幕时,是否自动回退到全屏模式
fallback_auto: true
# 是否缓存字幕区域检测结果(提升处理速度)
cache_region: true
# OCR 识别配置
ocr:
# 置信度阈值: 低于此值的识别结果将被过滤
confidence_threshold: 0.7
# 批处理大小: 每次处理的帧数(影响内存使用和速度)
batch_size: 32
# 最大并发工作线程数
max_workers: 4
# 视频帧提取配置
video:
# 抽帧间隔 (秒): 每隔多少秒抽取一帧进行识别
interval_seconds: 5
# 最大抽取帧数: 防止处理过长视频导致资源耗尽
max_frames: 500
# 流式处理模式: 逐步读取视频帧,避免一次性加载整个视频
stream_mode: true
# 后处理配置
postprocess:
# 是否启用去重: 去除连续相同的字幕文本
deduplicate: true
# 最小文本长度: 少于此长度的识别结果将被视为噪声
min_length: 2
# 是否过滤噪声: 使用规则过滤明显错误的识别结果
filter_noise: true
+3
View File
@@ -14,6 +14,9 @@ anthropic>=0.7.0
# undetected-playwright>=0.1.0
beautifulsoup4>=4.12.0
lxml>=4.9.0
paddlepaddle>=2.5.0
paddleocr>=2.7.0
opencv-python>=4.8.0
# Zhihu support
DrissionPage>=4.0.0
@@ -9,11 +9,14 @@ B站视频内容提取器
import asyncio
import json
import logging
import re
from datetime import datetime
from typing import Dict, Any, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
from playwright.async_api import async_playwright
from bs4 import BeautifulSoup
@@ -0,0 +1,355 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OCR 文字识别处理器(EasyOCR 版本)
使用 EasyOCR 实现视频帧文字识别功能,作为 PaddleOCR 的替代方案
主要优势:不依赖 PaddlePaddle,避免 oneDNN 兼容性问题
"""
import re
import numpy as np
from typing import List, Dict, Any, Tuple
import logging
logger = logging.getLogger(__name__)
class EasyOCRProcessor:
"""
EasyOCR 文字识别处理器
使用 EasyOCR 从视频帧中识别文字,支持批量识别和结果过滤
相比 PaddleOCR 的优势:
- 不依赖 PaddlePaddle,避免 oneDNN 兼容性问题
- 安装更简单,依赖更少
- 支持多种语言(70+ 种)
"""
# 相似度阈值:用于判断两个文本是否相似(基于编辑距离)
SIMILARITY_THRESHOLD = 0.85
# 置信度阈值
CONFIDENCE_THRESHOLD = 0.7
# 最小字幕长度(字符数)
MIN_SUBTITLE_LENGTH = 2
def __init__(self, lang: str = 'ch_sim', use_gpu: bool = False, detail: int = 1):
"""
初始化 OCR 处理器
Args:
lang: 语言支持,'ch_sim' 为简体中文,'ch_tra' 为繁体中文
use_gpu: 是否使用 GPU 加速
detail: 识别结果详细程度(0=只返回文本,1=返回文本+坐标+置信度)
"""
self.lang = lang
self.use_gpu = use_gpu
self.detail = detail
# 初始化 EasyOCR 实例
self._ocr = None
self._initialize_ocr()
def _initialize_ocr(self):
"""初始化 EasyOCR 实例"""
try:
from easyocr import Reader
self._ocr = Reader(
[self.lang],
gpu=self.use_gpu,
verbose=False # 不显示详细日志
)
logger.info("EasyOCR 初始化成功")
except Exception as e:
logger.error(f"EasyOCR 初始化失败: {e}")
raise RuntimeError(f"无法初始化 EasyOCR: {e}")
def _initialize_ocr(self):
"""初始化 EasyOCR 实例"""
try:
from easyocr import Reader
self._ocr = Reader(
[self.lang],
gpu=self.use_gpu,
verbose=False # 不显示详细日志
)
logger.info("EasyOCR 初始化成功")
except Exception as e:
logger.error(f"EasyOCR 初始化失败: {e}")
raise RuntimeError(f"无法初始化 EasyOCR: {e}")
def recognize_frames(self, frames: List[Tuple[np.ndarray, float]]) -> List[Dict[str, Any]]:
"""
批量识别帧中的文字
Args:
frames: 帧列表,每个元素为 (图像数组, 时间戳) 元组
- 图像数组:numpy.ndarray,形状为 (H, W, 3) 的 RGB 图像
- 时间戳:float,单位为秒
Returns:
List[Dict[str, Any]]: 识别结果列表,每个结果包含:
- time: float, 时间戳(秒)
- text: str, 识别的文本
- confidence: float, 置信度 (0-1)
"""
if not frames:
logger.warning("输入帧列表为空")
return []
results = []
for image, timestamp in frames:
try:
# 调用 EasyOCR 进行识别
# EasyOCR 返回格式: [[[x1, y1], [x2, y2], [x3, y3], [x4, y4]], (text, confidence, ...)]
# 根据 detail 参数,返回格式不同:
# detail=0: 返回 [text1, text2, ...]
# detail=1: 返回 [[bbox, (text, confidence)], ...]
ocr_result = self._ocr.readtext(image, detail=self.detail)
if ocr_result:
if self.detail == 0:
# 简单模式:只返回文本列表
for i, text in enumerate(ocr_result):
if text:
results.append({
'time': timestamp,
'text': str(text),
'confidence': 1.0 # 简单模式下没有置信度
})
else:
# 详细模式:返回边界框+文本+置信度
for item in ocr_result:
if len(item) >= 2:
bbox = item[0]
text_info = item[1]
if len(text_info) >= 2:
text, confidence = text_info[0], text_info[1]
# 跳过空文本
if not text:
continue
# 处理置信度:可能是数字或特殊字符
try:
if isinstance(confidence, str):
# 如果是 '?' 或其他特殊字符,使用默认置信度
conf_float = 0.5
else:
conf_float = float(confidence)
except (ValueError, TypeError):
conf_float = 0.5
results.append({
'time': timestamp,
'text': text,
'confidence': conf_float
})
except Exception as e:
logger.warning(f"帧识别失败 (时间戳 {timestamp}s): {e}")
continue
logger.info(f"原始识别结果数量: {len(results)}")
# 过滤和清洗结果
filtered_results = self._filter_and_clean(results)
logger.info(f"过滤后结果数量: {len(filtered_results)}")
return filtered_results
def _filter_and_clean(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
过滤和清洗识别结果
Args:
results: 原始识别结果列表
Returns:
List[Dict[str, Any]]: 过滤后的结果列表
"""
if not results:
return []
# 步骤 1: 按置信度过滤
filtered = self._filter_by_confidence(results)
logger.debug(f"置信度过滤后: {len(filtered)}")
# 步骤 2: 过滤噪声(纯符号、单字符等)
filtered = self._filter_noise(filtered)
logger.debug(f"噪声过滤后: {len(filtered)}")
# 步骤 3: 去除重复或相似的文本
filtered = self._remove_duplicates(filtered)
logger.debug(f"去重后: {len(filtered)}")
# 步骤 4: 按时间戳排序
filtered.sort(key=lambda x: x['time'])
return filtered
def _filter_by_confidence(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
按置信度过滤结果
Args:
results: 识别结果列表
Returns:
List[Dict[str, Any]]: 高置信度结果
"""
return [r for r in results if r['confidence'] >= self.CONFIDENCE_THRESHOLD]
def _filter_noise(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
过滤噪声结果
过滤条件:
- 文本长度小于最小长度
- 纯符号或空白字符
Args:
results: 识别结果列表
Returns:
List[Dict[str, Any]]: 过滤后的结果
"""
filtered = []
for result in results:
text = result['text'].strip()
# 过滤过短的文本
if len(text) < self.MIN_SUBTITLE_LENGTH:
continue
# 过滤纯符号或空白字符
if self._is_noise(text):
continue
filtered.append(result)
return filtered
def _is_noise(self, text: str) -> bool:
"""
判断文本是否为噪声
Args:
text: 要判断的文本
Returns:
bool: 如果是噪声返回 True
"""
# 使用正则判断是否只包含符号和空白
try:
# 移除标点符号后检查是否为空
# 先替换常见的中文标点
cleaned = re.sub(r'[^\w\u4e00-\u9fff]', '', text)
return len(cleaned) == 0
except:
# 如果正则出错,保守处理,不算噪声
return False
def _remove_duplicates(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
去除重复或相似的文本
基于编辑距离(Levenshtein distance)判断文本相似度
Args:
results: 识别结果列表(假设已按时间戳排序)
Returns:
List[Dict[str, Any]]: 去重后的结果
"""
if not results:
return []
deduped = [results[0]]
for current in results[1:]:
last = deduped[-1]
# 计算相似度
similarity = self._calculate_similarity(current['text'], last['text'])
# 如果相似度低于阈值,则保留
if similarity < self.SIMILARITY_THRESHOLD:
deduped.append(current)
else:
# 相似文本:保留置信度更高的那个
if current['confidence'] > last['confidence']:
deduped[-1] = current
return deduped
def _calculate_similarity(self, text1: str, text2: str) -> float:
"""
计算两个文本的相似度(基于归一化编辑距离)
Args:
text1: 文本1
text2: 文本2
Returns:
float: 相似度 (0-1),1 表示完全相同
"""
if text1 == text2:
return 1.0
len1, len2 = len(text1), len(text2)
if len1 == 0 or len2 == 0:
return 0.0
# 动态规划计算编辑距离
dp = np.zeros((len1 + 1, len2 + 1))
for i in range(len1 + 1):
dp[i, 0] = i
for j in range(len2 + 1):
dp[0, j] = j
for i in range(1, len1 + 1):
for j in range(1, len2 + 1):
if text1[i-1] == text2[j-1]:
dp[i, j] = dp[i-1, j-1]
else:
dp[i, j] = min(
dp[i-1, j] + 1, # 删除
dp[i, j-1] + 1, # 插入
dp[i-1, j-1] + 1 # 替换
)
edit_distance = dp[len1, len2]
max_len = max(len1, len2)
# 归一化相似度:1 - (编辑距离 / 最大长度)
similarity = 1.0 - (edit_distance / max_len)
return similarity
def get_ocr_info(self) -> Dict[str, Any]:
"""
获取 OCR 处理器信息
Returns:
Dict[str, Any]: OCR 配置信息
"""
return {
'engine': 'easyocr',
'lang': self.lang,
'use_gpu': self.use_gpu,
'detail': self.detail,
'confidence_threshold': self.CONFIDENCE_THRESHOLD,
'similarity_threshold': self.SIMILARITY_THRESHOLD,
'min_subtitle_length': self.MIN_SUBTITLE_LENGTH
}
@@ -0,0 +1,309 @@
"""
OCR 文字识别处理器
使用 PaddleOCR 实现视频帧文字识别功能,主要用于提取硬字幕
"""
import os
import re
import numpy as np
from typing import List, Dict, Any, Tuple
from paddleocr import PaddleOCR
import logging
logger = logging.getLogger(__name__)
class OCRProcessor:
"""
OCR 文字识别处理器
使用 PaddleOCR 从视频帧中识别文字,支持批量识别和结果过滤
"""
# 相似度阈值:用于判断两个文本是否相似(基于编辑距离)
SIMILARITY_THRESHOLD = 0.85
# 置信度阈值
CONFIDENCE_THRESHOLD = 0.7
# 最小字幕长度(字符数)
MIN_SUBTITLE_LENGTH = 2
# 注意:Python re 模块不支持 \p{P} 等属性类,在 _is_noise 方法中使用自定义逻辑
def __init__(self, use_textline_orientation: bool = True, lang: str = 'ch',
use_gpu: bool = False):
"""
初始化 OCR 处理器
Args:
use_textline_orientation: 是否使用文本行方向分类器(PaddleOCR 3.x 参数名)
lang: 语言支持,'ch' 为中英文混合
use_gpu: 是否使用 GPU 加速(通过环境变量设置,如需启用请先设置 CUDA_VISIBLE_DEVICES)
"""
self.use_textline_orientation = use_textline_orientation
self.lang = lang
self.use_gpu = use_gpu
# 初始化 PaddleOCR 实例
self._ocr = None
self._initialize_ocr()
def _initialize_ocr(self):
"""初始化 PaddleOCR 实例"""
try:
# 强制禁用 oneDNN 和 MKL
os.environ['FLAGS_use_mkldnn'] = '0'
os.environ['MKL_SERVICE_FORCE_INTEL'] = '1'
os.environ['OMP_NUM_THREADS'] = '1'
os.environ['PADDLE_ON_INFER'] = '1'
# PaddleOCR 3.x 版本参数
self._ocr = PaddleOCR(
use_textline_orientation=self.use_textline_orientation,
lang=self.lang
)
logger.info("PaddleOCR 初始化成功(oneDNN 已禁用)")
except Exception as e:
logger.error(f"PaddleOCR 初始化失败: {e}")
raise RuntimeError(f"无法初始化 PaddleOCR: {e}")
def recognize_frames(self, frames: List[Tuple[np.ndarray, float]]) -> List[Dict[str, Any]]:
"""
批量识别帧中的文字
Args:
frames: 帧列表,每个元素为 (图像数组, 时间戳) 元组
- 图像数组:numpy.ndarray,形状为 (H, W, 3) 的 RGB 图像
- 时间戳:float,单位为秒
Returns:
List[Dict[str, Any]]: 识别结果列表,每个结果包含:
- time: float, 时间戳(秒)
- text: str, 识别的文本
- confidence: float, 置信度 (0-1)
"""
if not frames:
logger.warning("输入帧列表为空")
return []
results = []
for image, timestamp in frames:
try:
# 调用 PaddleOCR 进行识别
# 注意:PaddleOCR 3.x 版本的 ocr() 方法参数已简化
ocr_result = self._ocr.ocr(image)
# PaddleOCR 返回格式: [[[box], (text, confidence)], ...]
if ocr_result and ocr_result[0]:
for line in ocr_result[0]:
box, (text, confidence) = line
if text and confidence:
results.append({
'time': timestamp,
'text': text,
'confidence': float(confidence)
})
except Exception as e:
logger.warning(f"帧识别失败 (时间戳 {timestamp}s): {e}")
continue
logger.info(f"原始识别结果数量: {len(results)}")
# 过滤和清洗结果
filtered_results = self._filter_and_clean(results)
logger.info(f"过滤后结果数量: {len(filtered_results)}")
return filtered_results
def _filter_and_clean(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
过滤和清洗识别结果
Args:
results: 原始识别结果列表
Returns:
List[Dict[str, Any]]: 过滤后的结果列表
"""
if not results:
return []
# 步骤 1: 按置信度过滤
filtered = self._filter_by_confidence(results)
logger.debug(f"置信度过滤后: {len(filtered)}")
# 步骤 2: 过滤噪声(纯符号、单字符等)
filtered = self._filter_noise(filtered)
logger.debug(f"噪声过滤后: {len(filtered)}")
# 步骤 3: 去除重复或相似的文本
filtered = self._remove_duplicates(filtered)
logger.debug(f"去重后: {len(filtered)}")
# 步骤 4: 按时间戳排序
filtered.sort(key=lambda x: x['time'])
return filtered
def _filter_by_confidence(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
按置信度过滤结果
Args:
results: 识别结果列表
Returns:
List[Dict[str, Any]]: 高置信度结果
"""
return [r for r in results if r['confidence'] >= self.CONFIDENCE_THRESHOLD]
def _filter_noise(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
过滤噪声结果
过滤条件:
- 文本长度小于最小长度
- 纯符号或空白字符
Args:
results: 识别结果列表
Returns:
List[Dict[str, Any]]: 过滤后的结果
"""
filtered = []
for result in results:
text = result['text'].strip()
# 过滤过短的文本
if len(text) < self.MIN_SUBTITLE_LENGTH:
continue
# 过滤纯符号或空白字符
if self._is_noise(text):
continue
filtered.append(result)
return filtered
def _is_noise(self, text: str) -> bool:
"""
判断文本是否为噪声
Args:
text: 要判断的文本
Returns:
bool: 如果是噪声返回 True
"""
# 使用正则判断是否只包含符号和空白
# 注意:这里用简单的正则,更复杂的判断可以后续优化
try:
# 移除标点符号后检查是否为空
# 先替换常见的中文标点
cleaned = re.sub(r'[^\w\u4e00-\u9fff]', '', text)
return len(cleaned) == 0
except:
# 如果正则出错,保守处理,不算噪声
return False
def _remove_duplicates(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
去除重复或相似的文本
基于编辑距离(Levenshtein distance)判断文本相似度
Args:
results: 识别结果列表(假设已按时间戳排序)
Returns:
List[Dict[str, Any]]: 去重后的结果
"""
if not results:
return []
deduped = [results[0]]
for current in results[1:]:
last = deduped[-1]
# 计算相似度
similarity = self._calculate_similarity(current['text'], last['text'])
# 如果相似度低于阈值,则保留
if similarity < self.SIMILARITY_THRESHOLD:
deduped.append(current)
else:
# 相似文本:保留置信度更高的那个
if current['confidence'] > last['confidence']:
deduped[-1] = current
return deduped
def _calculate_similarity(self, text1: str, text2: str) -> float:
"""
计算两个文本的相似度(基于归一化编辑距离)
Args:
text1: 文本1
text2: 文本2
Returns:
float: 相似度 (0-1),1 表示完全相同
"""
if text1 == text2:
return 1.0
len1, len2 = len(text1), len(text2)
if len1 == 0 or len2 == 0:
return 0.0
# 动态规划计算编辑距离
dp = np.zeros((len1 + 1, len2 + 1))
for i in range(len1 + 1):
dp[i, 0] = i
for j in range(len2 + 1):
dp[0, j] = j
for i in range(1, len1 + 1):
for j in range(1, len2 + 1):
if text1[i-1] == text2[j-1]:
dp[i, j] = dp[i-1, j-1]
else:
dp[i, j] = min(
dp[i-1, j] + 1, # 删除
dp[i, j-1] + 1, # 插入
dp[i-1, j-1] + 1 # 替换
)
edit_distance = dp[len1, len2]
max_len = max(len1, len2)
# 归一化相似度:1 - (编辑距离 / 最大长度)
similarity = 1.0 - (edit_distance / max_len)
return similarity
def get_ocr_info(self) -> Dict[str, Any]:
"""
获取 OCR 处理器信息
Returns:
Dict[str, Any]: OCR 配置信息
"""
return {
'use_textline_orientation': self.use_textline_orientation,
'lang': self.lang,
'use_gpu': self.use_gpu,
'confidence_threshold': self.CONFIDENCE_THRESHOLD,
'similarity_threshold': self.SIMILARITY_THRESHOLD,
'min_subtitle_length': self.MIN_SUBTITLE_LENGTH
}
@@ -35,6 +35,10 @@ class DrissionBrowser:
from DrissionPage import ChromiumOptions, ChromiumPage
options = ChromiumOptions()
# 设置浏览器路径(服务器环境需要)
options.set_browser_path('/usr/bin/chromium-browser')
if self.headless:
options.headless()
options.set_user_agent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36')
@@ -116,10 +120,17 @@ class ZhihuDrissionExtractor:
self.browser = DrissionBrowser(headless=self.headless, timeout=self.timeout)
self.markdown_converter = MarkdownConverter()
self.cookie_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'data', 'cookies', 'zhihu.json'
)
# 从配置中读取 Cookie 文件路径,支持环境变量展开
# 直接读取 cookie_file(因为 self.config 已经是 zhihu 配置)
cookie_file = self.config.get('cookie_file')
if cookie_file:
self.cookie_file = os.path.expanduser(cookie_file)
else:
# 默认路径
self.cookie_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'data', 'cookies', 'zhihu.json'
)
def identify_url_type(self, url: str) -> Optional[str]:
"""
@@ -180,9 +191,11 @@ class ZhihuDrissionExtractor:
cookies = []
# 1. 尝试从配置加载(优先级最高)
if self.config.get('zhihu.cookie_str'):
# 支持扁平和嵌套配置结构
cookie_str = self.config.get('cookie_str')
if cookie_str:
try:
cookies = json.loads(self.config.get('zhihu.cookie_str'))
cookies = json.loads(cookie_str)
print(f"从配置加载 Cookie,共 {len(cookies)} 个")
except json.JSONDecodeError as e:
print(f"配置中的 Cookie JSON 格式错误: {e}")
@@ -197,7 +210,16 @@ class ZhihuDrissionExtractor:
# 3. 尝试从文件加载(优先级最低)
else:
cookie_file = self.config.get('zhihu.cookie_file', self.cookie_file)
# 直接从配置中读取 cookie_file(因为 self.config 已经是 zhihu 配置)
cookie_file = self.config.get('cookie_file')
if not cookie_file:
# 如果没有配置,使用默认路径
cookie_file = self.cookie_file
else:
# 展开环境变量(如 ~)
cookie_file = os.path.expanduser(cookie_file)
if cookie_file and Path(cookie_file).exists():
try:
with open(cookie_file, 'r', encoding='utf-8') as f:
@@ -206,7 +228,8 @@ class ZhihuDrissionExtractor:
except (json.JSONDecodeError, FileNotFoundError, IOError) as e:
print(f"从文件加载 Cookie 失败: {e}")
else:
print("未找到 Cookie 配置,将使用未登录状态访问知乎")
print(f"未找到 Cookie 配置,将使用未登录状态访问知乎")
print(f" 期望路径: {cookie_file}")
return cookies
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
字幕检测器测试脚本
验证:
1. 预设区域定义
2. 自动字幕区域检测
3. 缓存机制
"""
import sys
import os
import cv2
import numpy as np
# 添加项目路径
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, project_root)
from src.extractors.subtitle_detector import SubtitleDetector
def create_test_frame_with_text(width=1280, height=720):
"""
创建包含模拟字幕的测试帧
Args:
width: 帧宽度
height: 帧高度
Returns:
numpy.ndarray: 测试帧
"""
# 创建黑色背景
frame = np.zeros((height, width, 3), dtype=np.uint8)
# 添加一些噪声背景
noise = np.random.randint(0, 50, (height, width, 3), dtype=np.uint8)
frame = cv2.add(frame, noise)
# 在底部添加白色文字(模拟B站字幕)
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 1.0
thickness = 2
# 添加多行字幕
texts = [
"这是测试字幕内容",
"B站硬字幕提取测试",
"00:01:23 第一行字幕",
"00:01:25 第二行字幕"
]
y_start = int(height * 0.85)
line_height = 35
for i, text in enumerate(texts):
y = y_start + i * line_height
x = int(width * 0.15)
# 绘制文字阴影(黑色)
cv2.putText(frame, text, (x + 2, y + 2), font, font_scale, (0, 0, 0), thickness + 2)
# 绘制白色文字
cv2.putText(frame, text, (x, y), font, font_scale, (255, 255, 255), thickness)
return frame
def test_preset_regions():
"""测试1: 预设区域定义"""
print("\n" + "="*60)
print("测试1: 预设区域定义")
print("="*60)
detector = SubtitleDetector()
frame_width, frame_height = 1280, 720
# 测试所有预设区域
for region_name in [detector.REGION_BOTTOM, detector.REGION_CENTER, detector.REGION_TOP]:
try:
x, y, width, height = detector.get_preset_region(region_name, frame_height, frame_width)
print(f"\n✓ 区域 '{region_name}':")
print(f" 坐标: x={x}, y={y}")
print(f" 尺寸: width={width}, height={height}")
print(f" 相对位置: top={y/frame_height:.2f}, bottom={(y+height)/frame_height:.2f}")
except Exception as e:
print(f"✗ 区域 '{region_name}' 失败: {e}")
# 测试无效区域名称
try:
detector.get_preset_region("invalid", frame_height, frame_width)
print("\n✗ 错误: 应该抛出异常但没有")
except ValueError as e:
print(f"\n✓ 无效区域名称正确抛出异常: {e}")
print("\n测试1完成!")
def test_auto_detection():
"""测试2: 自动字幕区域检测"""
print("\n" + "="*60)
print("测试2: 自动字幕区域检测")
print("="*60)
detector = SubtitleDetector()
# 创建测试帧(带字幕)
print("\n创建测试帧...")
test_frame = create_test_frame_with_text()
height, width = test_frame.shape[:2]
print(f"帧尺寸: {width}x{height}")
# 保存测试帧用于可视化
test_frame_path = "/tmp/test_subtitle_frame.png"
cv2.imwrite(test_frame_path, test_frame)
print(f"✓ 测试帧已保存到: {test_frame_path}")
# 执行自动检测
print("\n执行自动检测...")
try:
x, y, w, h = detector.auto_detect_subtitle_region(test_frame, video_id=None, use_cache=False)
print(f"✓ 检测到字幕区域:")
print(f" 坐标: x={x}, y={y}")
print(f" 尺寸: width={w}, height={h}")
print(f" 相对位置: top={y/height:.2f}, bottom={(y+h)/height:.2f}")
# 在测试帧上绘制检测框
frame_with_box = test_frame.copy()
cv2.rectangle(frame_with_box, (x, y), (x+w, y+h), (0, 255, 0), 2)
# 保存结果
result_path = "/tmp/test_subtitle_detected.png"
cv2.imwrite(result_path, frame_with_box)
print(f"✓ 检测结果已保存到: {result_path}")
# 验证检测区域是否合理(应该在底部)
region_bottom = (y + h) / height
if region_bottom > 0.7: # 底部30%
print(f"✓ 检测区域位置合理(底部)")
else:
print(f"! 警告: 检测区域可能不在底部 (bottom={region_bottom:.2f})")
except Exception as e:
print(f"✗ 自动检测失败: {e}")
import traceback
traceback.print_exc()
print("\n测试2完成!")
def test_cache_mechanism():
"""测试3: 缓存机制"""
print("\n" + "="*60)
print("测试3: 缓存机制")
print("="*60)
detector = SubtitleDetector(cache_size=3) # 小缓存便于测试
# 创建测试帧
test_frame = create_test_frame_with_text()
# 测试1: 首次检测并缓存
print("\n1. 首次检测并缓存...")
video_id1 = "BV1xx411c7mD"
region1 = detector.auto_detect_subtitle_region(test_frame, video_id=video_id1, use_cache=True)
print(f"✓ 检测完成,区域: {region1}")
print(f"✓ 缓存大小: {detector.get_cache_size()}")
# 测试2: 再次检测同一视频(应使用缓存)
print("\n2. 再次检测同一视频(应使用缓存)...")
region2 = detector.auto_detect_subtitle_region(test_frame, video_id=video_id1, use_cache=True)
print(f"✓ 缓存结果: {region2}")
if region1 == region2:
print(f"✓ 缓存命中,结果一致")
else:
print(f"✗ 警告: 缓存结果不一致")
# 测试3: 检测不同视频
print("\n3. 检测不同视频...")
video_id2 = "BV1yy411c7mE"
region3 = detector.auto_detect_subtitle_region(test_frame, video_id=video_id2, use_cache=True)
print(f"✓ 新视频检测完成,区域: {region3}")
print(f"✓ 缓存大小: {detector.get_cache_size()}")
# 测试4: 查看所有缓存
print("\n4. 查看所有缓存...")
cached = detector.get_cached_regions()
print(f"✓ 缓存内容:")
for vid, reg in cached.items():
print(f" {vid}: {reg}")
# 测试5: 清空指定视频缓存
print("\n5. 清空指定视频缓存...")
detector.clear_cache(video_id=video_id1)
print(f"✓ 已删除 {video_id1}")
print(f"✓ 缓存大小: {detector.get_cache_size()}")
# 测试6: 清空所有缓存
print("\n6. 清空所有缓存...")
detector.clear_cache()
print(f"✓ 缓存已清空")
print(f"✓ 缓存大小: {detector.get_cache_size()}")
# 测试7: 缓存容量限制(LRU)
print("\n7. 测试缓存容量限制(LRU)...")
for i in range(5):
vid = f"BV{i:012d}"
detector.auto_detect_subtitle_region(test_frame, video_id=vid, use_cache=True)
print(f" 添加视频 {vid},缓存大小: {detector.get_cache_size()}")
cached = detector.get_cached_regions()
print(f"✓ 缓存中有 {len(cached)} 个视频(应为最大容量3)")
print(f"✓ 保留的视频: {list(cached.keys())}")
print("\n测试3完成!")
def test_integration():
"""测试4: 集成测试"""
print("\n" + "="*60)
print("测试4: 集成测试")
print("="*60)
detector = SubtitleDetector()
test_frame = create_test_frame_with_text()
height, width = test_frame.shape[:2]
print("\n模拟B站视频字幕检测流程...")
# 步骤1: 尝试自动检测
print("\n步骤1: 自动检测字幕区域")
video_id = "BV1xx411c7mD"
region = detector.auto_detect_subtitle_region(test_frame, video_id=video_id)
print(f"✓ 自动检测结果: {region}")
# 步骤2: 后续帧使用缓存
print("\n步骤2: 后续帧使用缓存")
for i in range(3):
cached_region = detector.auto_detect_subtitle_region(test_frame, video_id=video_id)
if cached_region == region:
print(f"✓ 帧 {i+1}: 使用缓存,结果一致")
else:
print(f"✗ 帧 {i+1}: 缓存结果异常")
# 步骤3: 对比预设区域
print("\n步骤3: 对比预设区域")
bottom_region = detector.get_preset_region(detector.REGION_BOTTOM, height, width)
print(f" 检测区域: y={region[1]}, height={region[3]}")
print(f" 底部预设: y={bottom_region[1]}, height={bottom_region[3]}")
# 步骤4: 提取字幕区域
print("\n步骤4: 提取字幕区域用于OCR")
x, y, w, h = region
subtitle_roi = test_frame[y:y+h, x:x+w]
subtitle_path = "/tmp/subtitle_roi.png"
cv2.imwrite(subtitle_path, subtitle_roi)
print(f"✓ 字幕ROI已保存到: {subtitle_path}")
print(f" ROI尺寸: {w}x{h}")
print("\n测试4完成!")
def main():
"""主测试函数"""
print("\n" + "="*60)
print("字幕检测器完整测试")
print("="*60)
try:
# 运行所有测试
test_preset_regions()
test_auto_detection()
test_cache_mechanism()
test_integration()
print("\n" + "="*60)
print("所有测试完成!")
print("="*60)
print("\n生成的测试文件:")
print(" - /tmp/test_subtitle_frame.png (原始测试帧)")
print(" - /tmp/test_subtitle_detected.png (带检测框的结果)")
print(" - /tmp/subtitle_roi.png (提取的字幕区域)")
except Exception as e:
print(f"\n✗ 测试失败: {e}")
import traceback
traceback.print_exc()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""
验证知乎 Cookie 配置
"""
import json
import os
import sys
# 添加 src 目录到 Python 路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from sources.zhihu_drission_extractor import ZhihuDrissionExtractor
def verify_cookie_config():
"""验证 Cookie 配置"""
print("=" * 60)
print("知乎 Cookie 配置验证")
print("=" * 60)
print()
# 1. 检查 Cookie 文件路径
cookie_file = os.path.expanduser('~/.obsidian-kb/zhihu_cookies.json')
print(f"1. Cookie 文件路径")
print(f" {cookie_file}")
if os.path.exists(cookie_file):
print(f" ✅ 文件存在")
else:
print(f" ❌ 文件不存在")
print(f" 请上传 Cookie 文件到此路径")
print()
return False
print()
# 2. 检查 Cookie 文件格式
print(f"2. Cookie 文件格式")
try:
with open(cookie_file, 'r', encoding='utf-8') as f:
cookies = json.load(f)
print(f" ✅ JSON 格式正确")
print(f" Cookie 数量: {len(cookies)}")
except json.JSONDecodeError as e:
print(f" ❌ JSON 格式错误: {e}")
return False
except Exception as e:
print(f" ❌ 读取失败: {e}")
return False
print()
# 3. 检查关键 Cookie
print(f"3. 关键 Cookie 检查")
cookie_names = [c['name'] for c in cookies]
required_cookies = ['z_c0', 'd_c0']
for cookie_name in required_cookies:
if cookie_name in cookie_names:
cookie = next(c for c in cookies if c['name'] == cookie_name)
value_preview = cookie['value'][:10] + '...' if len(cookie['value']) > 10 else cookie['value']
print(f" ✅ {cookie_name}: {value_preview}")
else:
print(f" ⚠️ {cookie_name}: 未找到(可能影响登录状态)")
print()
# 4. 检查配置文件
print(f"4. 配置文件检查")
config_file = os.path.join(os.path.dirname(__file__), 'config.yaml')
if os.path.exists(config_file):
print(f" ✅ config.yaml 存在")
with open(config_file, 'r') as f:
content = f.read()
if 'zhihu_cookies.json' in content:
print(f" ✅ Cookie 路径已配置")
else:
print(f" ⚠️ Cookie 路径未找到(可能是使用了其他配置)")
else:
print(f" ❌ config.yaml 不存在")
print()
# 5. 测试提取器初始化
print(f"5. 提取器初始化测试")
try:
# 加载配置文件并传递给提取器
import yaml
with open(config_file, 'r', encoding='utf-8') as f:
full_config = yaml.safe_load(f)
print(f" 配置文件: {config_file}")
print(f" zhihu 配置: {full_config.get('zhihu', {})}")
# 提取 zhihu 配置并传递
zhihu_config = full_config.get('zhihu', {})
extractor = ZhihuDrissionExtractor(config=zhihu_config)
print(f" ✅ ZhihuDrissionExtractor 初始化成功")
# 测试 Cookie 加载
loaded_cookies = extractor._load_cookies()
print(f" ✅ 成功加载 {len(loaded_cookies)} 个 Cookie")
if len(loaded_cookies) == 0:
print(f" ⚠️ 未加载到 Cookie,将使用未登录状态访问")
print(f" 这可能导致提取失败或内容不完整")
except Exception as e:
print(f" ❌ 初始化失败: {e}")
return False
print()
print("=" * 60)
print("✅ 配置验证完成!")
print("=" * 60)
print()
# 6. 下一步建议
print("下一步:")
print("1. 如果 Cookie 文件格式正确,可以直接开始提取")
print("2. 建议先测试一个简单的知乎回答链接")
print()
print("示例命令:")
print(" python3 -c \"from src.sources.zhihu_drission_extractor import ZhihuDrissionExtractor; print(ZhihuDrissionExtractor().extract('https://www.zhihu.com/question/266309957/answer/2003427891925509507')['title'])\"")
return True
if __name__ == '__main__':
success = verify_cookie_config()
sys.exit(0 if success else 1)