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:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"registry": "https://clawhub.ai",
|
||||
"slug": "find-skills",
|
||||
"installedVersion": "0.1.0",
|
||||
"installedAt": 1770607549810
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn77ajmmqw3cgnc3ay1x3e0ccd805hsw",
|
||||
"slug": "find-skills",
|
||||
"version": "0.1.0",
|
||||
"publishedAt": 1769698710765
|
||||
}
|
||||
Executable
+30
@@ -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 兼容性问题。"
|
||||
@@ -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
|
||||
@@ -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 提取器提供知乎内容的完整提取功能:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user