From aa206a16798c130302507089e005b9160eafa134 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 7 Feb 2026 19:06:38 +0800 Subject: [PATCH] =?UTF-8?q?Add=20knowledge=20base:=20B=E7=AB=99=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E3=80=8A=E5=9C=A82000=E5=A4=9A=E7=B1=B3=E9=AB=98?= =?UTF-8?q?=E7=A9=BA=20=E5=BB=BA=E5=BA=A7"=E4=BC=9A=E9=A3=9E=E7=9A=84"?= =?UTF-8?q?=E5=8F=91=E7=94=B5=E7=AB=99=E3=80=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 knowledge-base/ 目录 - 保存央视新闻视频:在2000多米高空 建座"会飞的"发电站 - 完善 obsidian-kb skill:添加知乎 Playwright 提取器 - 更新依赖和文档 --- ...026-02-07_在2000多米高空 建座“会飞的”发电站.md | 27 + .../知乎-answer-2003427891925509507-.md | 16 + skills/obsidian-kb/=0.11.6 | 11 + skills/obsidian-kb/README.md | 80 ++ skills/obsidian-kb/SKILL.md | 55 ++ skills/obsidian-kb/config.yaml | 37 + skills/obsidian-kb/requirements.txt | 3 + .../src/sources/markdown_converter.py | 17 + skills/obsidian-kb/src/sources/zhihu.py | 32 +- .../src/sources/zhihu_playwright_extractor.py | 385 +++++++++ .../tests/test_zhihu_playwright.py | 736 ++++++++++++++++++ 11 files changed, 1397 insertions(+), 2 deletions(-) create mode 100644 knowledge-base/videos/bilibili/央视新闻/2026-02-07_在2000多米高空 建座“会飞的”发电站.md create mode 100644 knowledge-base/知乎-answer-2003427891925509507-.md create mode 100644 skills/obsidian-kb/=0.11.6 create mode 100644 skills/obsidian-kb/config.yaml create mode 100644 skills/obsidian-kb/src/sources/markdown_converter.py create mode 100644 skills/obsidian-kb/src/sources/zhihu_playwright_extractor.py create mode 100644 skills/obsidian-kb/tests/test_zhihu_playwright.py diff --git a/knowledge-base/videos/bilibili/央视新闻/2026-02-07_在2000多米高空 建座“会飞的”发电站.md b/knowledge-base/videos/bilibili/央视新闻/2026-02-07_在2000多米高空 建座“会飞的”发电站.md new file mode 100644 index 0000000..bb02e98 --- /dev/null +++ b/knowledge-base/videos/bilibili/央视新闻/2026-02-07_在2000多米高空 建座“会飞的”发电站.md @@ -0,0 +1,27 @@ +--- +title: 在2000多米高空 建座“会飞的”发电站 +source: bilibili +url: https://www.bilibili.com/video/BV1HszxBzEfm +author: 央视新闻 +duration: 19:54 +date: 2026-02-07 +tags: ["video", "bilibili", "未分类", "待处理", "作者:央视新闻"] +--- +# 在2000多米高空 建座“会飞的”发电站 + +## 摘要 + +无字幕内容,标题:在2000多米高空 建座“会飞的”发电站 + +## 关键要点 + +- 未分类 +- 待处理 +- 作者:央视新闻 + +*此视频无字幕,仅保存元数据* + +--- +- **来源**: [央视新闻 - 在2000多米高空 建座“会飞的”发电站](https://www.bilibili.com/video/BV1HszxBzEfm) +- **时长**: 19:54 +- **处理日期**: 2026-02-07 18:55:25 \ No newline at end of file diff --git a/knowledge-base/知乎-answer-2003427891925509507-.md b/knowledge-base/知乎-answer-2003427891925509507-.md new file mode 100644 index 0000000..2642ef4 --- /dev/null +++ b/knowledge-base/知乎-answer-2003427891925509507-.md @@ -0,0 +1,16 @@ +--- +title: +author: +source: zhihu +type: answer +url: https://www.zhihu.com/question/266309957/answer/2003427891925509507 +content_id: 2003427891925509507 +publish_time: +vote_up: 0 +created_at: 2026-02-07T18:45:53.179880 +--- + +# + +--- + diff --git a/skills/obsidian-kb/=0.11.6 b/skills/obsidian-kb/=0.11.6 new file mode 100644 index 0000000..01ea3e9 --- /dev/null +++ b/skills/obsidian-kb/=0.11.6 @@ -0,0 +1,11 @@ +Collecting markdownify + Using cached markdownify-1.2.2-py3-none-any.whl.metadata (9.9 kB) +Requirement already satisfied: beautifulsoup4<5,>=4.9 in ./.venv/lib/python3.12/site-packages (from markdownify) (4.14.3) +Collecting six<2,>=1.15 (from markdownify) + Using cached six-1.17.0-py2.py3-none-any.whl.metadata (1.7 kB) +Requirement already satisfied: soupsieve>=1.6.1 in ./.venv/lib/python3.12/site-packages (from beautifulsoup4<5,>=4.9->markdownify) (2.8.3) +Requirement already satisfied: typing-extensions>=4.0.0 in ./.venv/lib/python3.12/site-packages (from beautifulsoup4<5,>=4.9->markdownify) (4.15.0) +Using cached markdownify-1.2.2-py3-none-any.whl (15 kB) +Using cached six-1.17.0-py2.py3-none-any.whl (11 kB) +Installing collected packages: six, markdownify +Successfully installed markdownify-1.2.2 six-1.17.0 diff --git a/skills/obsidian-kb/README.md b/skills/obsidian-kb/README.md index 9804573..4c1df3a 100644 --- a/skills/obsidian-kb/README.md +++ b/skills/obsidian-kb/README.md @@ -6,6 +6,7 @@ - **多源内容聚合**: 支持 Bilibili、YouTube、知乎等平台 - **增强型 Bilibili 提取**: 使用 Playwright 绕过反爬检测,支持字幕和弹幕提取 +- **知乎内容提取**: 支持知乎回答和文章,完整 Markdown 格式保留,支持代码块、表格等 - **智能收件箱**: AI 质量评分,自动分类 - **长视频分段分析**: 30分钟+ 视频自动分段处理 - **无字幕处理**: 智能处理无字幕视频,基于元数据生成内容 @@ -197,6 +198,85 @@ obsidian-kb/ - **教程视频** (`≥ 30分钟`): `tutorial_video.md` - 包含详细分段、前置知识、常见坑点 - **文章**: `article.md` +## 知乎提取 + +知乎提取器支持回答和文章两种类型,使用 Playwright 绕过反爬虫保护,返回完整 Markdown 格式。 + +### 支持的内容类型 + +- **知乎回答**: `https://www.zhihu.com/question/xxx/answer/yyy` +- **知乎文章**: `https://zhuanlan.zhihu.com/p/xxx` +- **知乎问题**: `https://www.zhihu.com/question/xxx` + +### 输出格式 + +提取的内容包含以下信息: + +```python +{ + 'type': 'answer' | 'article' | 'question', + 'title': '标题', + 'author': '作者', + 'content': 'HTML 内容', + 'content_markdown': 'Markdown 内容', # 完整格式 + 'publish_time': '发布时间', + 'vote_up': 点赞数, + 'url': '原始 URL', + 'content_id': '内容 ID' +} +``` + +### Markdown 格式保留 + +知乎提取器保留完整的 Markdown 格式: + +- ✅ 标题(H1-H6) +- ✅ 代码块(支持语法高亮) +- ✅ 表格 +- ✅ 引用块 +- ✅ 列表(有序/无序) +- ✅ 粗体、斜体 +- ✅ 链接 + +### 配置 + +在 `config.yaml` 中配置知乎提取: + +```yaml +zhihu: + enabled: true + use_playwright: true # 使用 Playwright(推荐) + cookie_file: "" # 可选:Cookie 文件路径 + timeout: 30 # 超时时间(秒) + max_retries: 2 # 最大重试次数 +``` + +### 降级机制 + +当 Playwright 提取失败时,系统会自动降级到 HTTP 方案: + +- Playwright 未安装 → 使用 HTTP 请求 +- 网络超时 → 重试后降级 +- 403 错误 → 降级到 HTTP +- 运行时错误 → 降级到 HTTP + +### 安装依赖 + +知乎提取器需要以下依赖: + +```bash +# 使用虚拟环境(推荐) +cd obsidian-kb +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# 安装 Playwright 浏览器 +playwright install chromium +``` + +--- + ## 长视频分段策略 | 时长 | 策略 | 分段数 | diff --git a/skills/obsidian-kb/SKILL.md b/skills/obsidian-kb/SKILL.md index 51af706..20c7975 100644 --- a/skills/obsidian-kb/SKILL.md +++ b/skills/obsidian-kb/SKILL.md @@ -192,7 +192,11 @@ sources: api_key: '' # YouTube API Key zhihu: enabled: false + use_playwright: true # 使用 Playwright 绕过检测(推荐) cookie: '' # 知乎 Cookie (可选) + cookie_file: '' # Cookie 文件路径 (可选) + timeout: 30 # 超时时间(秒) + max_retries: 2 # 最大重试次数 # Bilibili 提取器配置 bilibili_extraction: @@ -272,6 +276,57 @@ else: content["subtitle_text"] = subtitle_text ``` +### ZhihuPlaywrightExtractor 类 + +ZhihuPlaywrightExtractor 提供了知乎内容的完整提取功能: + +**主要方法:** +- `extract(url)` - 提取知乎内容(支持回答、文章、问题) +- `identify_url_type(url)` - 识别 URL 类型 +- `extract_id(url)` - 提取内容 ID + +**特性:** +- 使用 Playwright 绕过知乎反爬虫保护 +- 支持完整 Markdown 格式(代码块、表格、引用等) +- 提取元数据:标题、作者、发布时间、点赞数 +- 智能降级机制(Playwright 失败 → HTTP) +- 每次提取独立启动浏览器,内存占用低 + +**降级逻辑:** + +当 Playwright 提取失败时: + +1. **ImportError** - Playwright 未安装 → 使用 HTTP 方案 +2. **TimeoutError** - 网络超时 → 重试后降级 +3. **403 Forbidden** - 触发反爬虫 → 降级到 HTTP +4. **RuntimeError** - 浏览器崩溃 → 降级到 HTTP + +```python +from sources.zhihu_playwright_extractor import ZhihuPlaywrightExtractor + +extractor = ZhihuPlaywrightExtractor() +data = extractor.extract("https://www.zhihu.com/question/266309957/answer/2003427891925509507") + +print(f"Title: {data['title']}") +print(f"Author: {data['author']}") +print(f"Content (Markdown): {data['content_markdown']}") +``` + +**返回数据结构:** +```python +{ + 'type': 'answer' | 'article' | 'question', + 'title': str, + 'author': str, + 'content': str, # HTML 格式 + 'content_markdown': str, # Markdown 格式 + 'publish_time': str, + 'vote_up': int, + 'url': str, + 'content_id': str +} +``` + ### 使用提取器 **直接使用提取器:** diff --git a/skills/obsidian-kb/config.yaml b/skills/obsidian-kb/config.yaml new file mode 100644 index 0000000..b356e29 --- /dev/null +++ b/skills/obsidian-kb/config.yaml @@ -0,0 +1,37 @@ +obsidian: + vault_path: /home/admin/.openclaw/workspace/knowledge-base + +git: + enabled: false + vault_path: /home/admin/.openclaw/workspace/knowledge-base + remote: origin + branch: main + auto_push: false + commit_message_template: "Add note: {title}" + +output: + organize_by: author + filename_format: "{date}_{title}.md" + auto_tags: true + default_tags: + - video + - bilibili + +sources: + bilibili: + enabled: true + extraction: + headless: true + timeout: 30 + +zhihu: + enabled: true + use_playwright: true + cookie_file: "" + timeout: 30 + max_retries: 2 + +chunking: + enabled: true + chunk_duration_seconds: 900 + long_video_threshold: 1800 diff --git a/skills/obsidian-kb/requirements.txt b/skills/obsidian-kb/requirements.txt index 894d164..995e6d2 100644 --- a/skills/obsidian-kb/requirements.txt +++ b/skills/obsidian-kb/requirements.txt @@ -15,6 +15,9 @@ undetected-playwright>=0.1.0 beautifulsoup4>=4.12.0 lxml>=4.9.0 +# Zhihu support +markdownify>=0.11.6 + # Testing pytest>=7.4.0 pytest-cov>=4.1.0 diff --git a/skills/obsidian-kb/src/sources/markdown_converter.py b/skills/obsidian-kb/src/sources/markdown_converter.py new file mode 100644 index 0000000..c0f912d --- /dev/null +++ b/skills/obsidian-kb/src/sources/markdown_converter.py @@ -0,0 +1,17 @@ +from markdownify import MarkdownConverter as _MarkdownConverter + +class MarkdownConverter: + """HTML 转 Markdown 转换器,保留完整格式""" + + def __init__(self): + self.converter = _MarkdownConverter( + heading_style="ATX", + bullets="*,-", + code_blocks=True, + tables=True, + strip=["script", "style", "nav", "footer", "aside"] + ) + + def convert(self, html: str) -> str: + """转换 HTML 为 Markdown""" + return self.converter.convert(html) diff --git a/skills/obsidian-kb/src/sources/zhihu.py b/skills/obsidian-kb/src/sources/zhihu.py index 6d80ba5..d60a554 100644 --- a/skills/obsidian-kb/src/sources/zhihu.py +++ b/skills/obsidian-kb/src/sources/zhihu.py @@ -51,5 +51,33 @@ class ZhihuSource(BaseSource): def get_transcript(self, content: Content) -> Optional[str]: """获取文章内容""" - # TODO: 实现文章抓取 - return None + try: + # 优先使用 Playwright 提取 + from .zhihu_playwright_extractor import ZhihuPlaywrightExtractor + + extractor = ZhihuPlaywrightExtractor() + data = extractor.extract(content.url) + + if data and data.get('content'): + # 更新元数据 + if data.get('title'): + content.title = data['title'] + if data.get('author'): + content.author = data['author'] + + # 返回 Markdown 内容 + return data['content'] + + except ImportError as e: + # Playwright 未安装 + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Playwright 未安装,使用 HTTP 方案: {e}") + except Exception as e: + # Playwright 提取失败 + import logging + logger = logging.getLogger(__name__) + logger.error(f"Playwright 提取失败: {e}") + + # 降级到原有 HTTP 方案(保留现有逻辑) + return self._fetch_with_http(content.url) diff --git a/skills/obsidian-kb/src/sources/zhihu_playwright_extractor.py b/skills/obsidian-kb/src/sources/zhihu_playwright_extractor.py new file mode 100644 index 0000000..2baf648 --- /dev/null +++ b/skills/obsidian-kb/src/sources/zhihu_playwright_extractor.py @@ -0,0 +1,385 @@ +""" +知乎 Playwright 内容提取器 +使用 Playwright 浏览器自动化工具抓取知乎内容 +""" + +import re +import sys +import os +from typing import Dict, Optional, Any +from urllib.parse import urlparse + +# 添加当前目录到 Python 路径以支持绝对导入 +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + +try: + from .markdown_converter import MarkdownConverter +except ImportError: + from markdown_converter import MarkdownConverter + + +class PlaywrightBrowser: + """Playwright 浏览器管理器""" + + def __init__(self, headless: bool = True, timeout: int = 30): + self.headless = headless + self.timeout = timeout * 1000 + self.browser = None + self.context = None + self.page = None + + def start(self): + """启动浏览器""" + from playwright.sync_api import sync_playwright + self.playwright = sync_playwright().start() + self.browser = self.playwright.chromium.launch(headless=self.headless) + self.context = self.browser.new_context( + 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' + ) + self.page = self.context.new_page() + self.page.set_default_timeout(self.timeout) + + def close(self): + """关闭浏览器""" + if self.page: + self.page.close() + if self.context: + self.context.close() + if self.browser: + self.browser.close() + if hasattr(self, 'playwright'): + self.playwright.stop() + + def goto(self, url: str, wait_until: str = "networkidle"): + """导航到指定URL""" + return self.page.goto(url, wait_until=wait_until) + + def wait_for_selector(self, selector: str, timeout: int = None): + """等待选择器出现""" + timeout_ms = timeout * 1000 if timeout else self.timeout + return self.page.wait_for_selector(selector, timeout=timeout_ms) + + def query_selector(self, selector: str): + """查询选择器""" + return self.page.query_selector(selector) + + def query_selector_all(self, selector: str): + """查询所有匹配的选择器""" + return self.page.query_selector_all(selector) + + def evaluate(self, script: str): + """执行 JavaScript 代码""" + return self.page.evaluate(script) + + +class ZhihuPlaywrightExtractor: + """知乎内容提取器 - 使用 Playwright""" + + URL_PATTERNS = { + 'answer': r'zhihu\.com/question/\d+/answer/(\d+)', + 'article': r'zhuanlan\.zhihu\.com/p/(\d+)', + 'question': r'zhihu\.com/question/(\d+)(?:$|/)', + } + + def __init__(self, headless: bool = True, timeout: int = 30): + """ + 初始化提取器 + + Args: + headless: 是否使用无头浏览器 + timeout: 超时时间(秒) + """ + self.browser = PlaywrightBrowser(headless=headless, timeout=timeout) + self.markdown_converter = MarkdownConverter() + + def identify_url_type(self, url: str) -> Optional[str]: + """ + 识别 URL 类型 + + Returns: + 'answer', 'article', 'question' 或 None + """ + for url_type, pattern in self.URL_PATTERNS.items(): + if re.search(pattern, url): + return url_type + return None + + def extract_id(self, url: str) -> Optional[str]: + """ + 从 URL 中提取内容 ID + + Returns: + 内容 ID 或 None + """ + url_type = self.identify_url_type(url) + if url_type: + pattern = self.URL_PATTERNS[url_type] + match = re.search(pattern, url) + if match: + return match.group(1) + return None + + def _extract_text_content(self, element) -> str: + """提取元素的文本内容""" + if element is None: + return '' + return element.inner_text() if hasattr(element, 'inner_text') else '' + + def _extract_html_content(self, element) -> str: + """提取元素的 HTML 内容""" + if element is None: + return '' + return element.inner_html() if hasattr(element, 'inner_html') else '' + + def _extract_answer_content(self) -> Dict[str, Any]: + """提取回答内容""" + result = { + 'type': 'answer', + 'title': '', + 'author': '', + 'content': '', + 'content_markdown': '', + 'publish_time': '', + 'vote_up': 0, + } + + # 打印页面标题用于调试 + page_title = self.browser.page.title() + print(f"页面标题: {page_title}") + + # 尝试保存页面快照用于调试 + try: + import os + debug_dir = '/home/admin/.openclaw/workspace/debug' + os.makedirs(debug_dir, exist_ok=True) + self.browser.page.screenshot(path=os.path.join(debug_dir, 'zhihu_screenshot.png')) + with open(os.path.join(debug_dir, 'zhihu_page.html'), 'w', encoding='utf-8') as f: + f.write(self.browser.page.content()) + print(f"页面快照已保存到 {debug_dir}") + except Exception as e: + print(f"保存页面快照时出错: {e}") + + # 尝试多个可能的选择器 + selectors = { + 'title': ['h1.QuestionHeader-title', 'h1', '.QuestionHeader-main'], + 'author': ['.AuthorInfo-name', '.AuthorInfo .Text', '[itemprop="name"]'], + 'content': ['.RichContent-inner', '.RichContent', '.List-itemText', '[data-zop-feedlist] article'], + 'time': ['.ContentItem-time', '[itemprop="dateCreated"]'], + 'vote': ['.VoteButton--up .VoteButton-label', '.VoteButton--up'] + } + + for selector in selectors['title']: + title_elem = self.browser.query_selector(selector) + if title_elem: + result['title'] = self._extract_text_content(title_elem) + print(f"找到标题(使用选择器 {selector}): {result['title'][:50]}...") + break + + for selector in selectors['author']: + author_elem = self.browser.query_selector(selector) + if author_elem: + result['author'] = self._extract_text_content(author_elem) + print(f"找到作者(使用选择器 {selector}): {result['author']}") + break + + for selector in selectors['content']: + content_elem = self.browser.query_selector(selector) + if content_elem: + html_content = self._extract_html_content(content_elem) + result['content'] = html_content + result['content_markdown'] = self.markdown_converter.convert(html_content) + print(f"找到内容(使用选择器 {selector}),长度: {len(result['content_markdown'])}") + break + + for selector in selectors['time']: + time_elem = self.browser.query_selector(selector) + if time_elem: + result['publish_time'] = time_elem.get_attribute('data-tooltip') or self._extract_text_content(time_elem) + break + + for selector in selectors['vote']: + vote_elem = self.browser.query_selector(selector) + if vote_elem: + vote_text = self._extract_text_content(vote_elem) + try: + result['vote_up'] = int(vote_text.replace(',', '')) + except (ValueError, AttributeError): + pass + break + + return result + + def _extract_article_content(self) -> Dict[str, Any]: + """提取文章内容""" + result = { + 'type': 'article', + 'title': '', + 'author': '', + 'content': '', + 'content_markdown': '', + 'publish_time': '', + 'vote_up': 0, + } + + # 提取文章标题 + title_elem = self.browser.query_selector('.Post-Title') + if title_elem: + result['title'] = self._extract_text_content(title_elem) + + # 提取作者信息 + author_elem = self.browser.query_selector('.AuthorInfo-name') + if author_elem: + result['author'] = self._extract_text_content(author_elem) + + # 提取文章内容 + content_elem = self.browser.query_selector('.Post-RichText') + if content_elem: + html_content = self._extract_html_content(content_elem) + result['content'] = html_content + result['content_markdown'] = self.markdown_converter.convert(html_content) + + # 提取发布时间 + time_elem = self.browser.query_selector('.ContentItem-time') + if time_elem: + result['publish_time'] = time_elem.get_attribute('data-tooltip') or self._extract_text_content(time_elem) + + # 提取点赞数 + vote_elem = self.browser.query_selector('.VoteButton--up .VoteButton-label') + if vote_elem: + vote_text = self._extract_text_content(vote_elem) + try: + result['vote_up'] = int(vote_text.replace(',', '')) + except (ValueError, AttributeError): + pass + + return result + + def _extract_question_content(self) -> Dict[str, Any]: + """提取问题内容(问题页本身)""" + result = { + 'type': 'question', + 'title': '', + 'author': '', + 'content': '', + 'content_markdown': '', + 'publish_time': '', + 'vote_up': 0, + } + + # 提取问题标题 + title_elem = self.browser.query_selector('h1.QuestionHeader-title') + if title_elem: + result['title'] = self._extract_text_content(title_elem) + + # 提取问题详情 + detail_elem = self.browser.query_selector('.QuestionHeader-detail') + if detail_elem: + html_content = self._extract_html_content(detail_elem) + result['content'] = html_content + result['content_markdown'] = self.markdown_converter.convert(html_content) + + # 问题的作者通常是提问者 + author_elem = self.browser.query_selector('.AuthorInfo-name') + if author_elem: + result['author'] = self._extract_text_content(author_elem) + + return result + + def extract(self, url: str) -> Optional[Dict[str, Any]]: + """ + 从 URL 提取知乎内容 + + Args: + url: 知乎 URL + + Returns: + 包含提取内容的字典,结构如下: + { + 'type': 'answer' | 'article' | 'question', + 'title': str, + 'author': str, + 'content': str (HTML), + 'content_markdown': str (Markdown), + 'publish_time': str, + 'vote_up': int, + } + 如果提取失败返回 None + """ + try: + # 识别 URL 类型 + url_type = self.identify_url_type(url) + if not url_type: + print(f"无法识别 URL 类型: {url}") + return None + + print(f"URL 类型: {url_type}") + print(f"启动浏览器...") + + # 启动浏览器 + self.browser.start() + + print(f"访问页面: {url}") + # 访问页面 + self.browser.goto(url, wait_until="domcontentloaded") + + print("等待页面加载...") + import time + time.sleep(3) # 等待 JavaScript 执行 + + # 尝试多个可能的选择器 + if url_type == 'answer': + selectors = ['.RichContent-inner', '.RichContent', '.List-itemText', '[data-zop-feedlist]'] + print(f"尝试查找选择器: {selectors}") + result = self._extract_answer_content() + elif url_type == 'article': + selectors = ['.Post-RichText', '.Post-Title'] + print(f"尝试查找选择器: {selectors}") + result = self._extract_article_content() + else: # question + selectors = ['.QuestionHeader-title', 'h1'] + print(f"尝试查找选择器: {selectors}") + result = self._extract_question_content() + + # 添加额外信息 + result['url'] = url + result['content_id'] = self.extract_id(url) + + print(f"提取成功! 标题: {result['title'][:50]}...") + return result + + except Exception as e: + print(f"提取知乎内容时出错: {e}") + import traceback + traceback.print_exc() + return None + finally: + # 关闭浏览器 + self.browser.close() + + def __enter__(self): + """上下文管理器入口""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """上下文管理器出口""" + self.browser.close() + return False + + +# 便捷函数 +def extract_zhihu_content(url: str, headless: bool = True, timeout: int = 30) -> Optional[Dict[str, Any]]: + """ + 便捷函数:提取知乎内容 + + Args: + url: 知乎 URL + headless: 是否使用无头浏览器 + timeout: 超时时间(秒) + + Returns: + 提取的内容字典 + """ + with ZhihuPlaywrightExtractor(headless=headless, timeout=timeout) as extractor: + return extractor.extract(url) diff --git a/skills/obsidian-kb/tests/test_zhihu_playwright.py b/skills/obsidian-kb/tests/test_zhihu_playwright.py new file mode 100644 index 0000000..73fc452 --- /dev/null +++ b/skills/obsidian-kb/tests/test_zhihu_playwright.py @@ -0,0 +1,736 @@ +"""知乎 Playwright Mock 测试""" + +import pytest +import sys +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock, Mock as MockObj + +# 添加路径 +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +# Mock playwright 模块 +mock_playwright = MockObj() +mock_playwright.sync_api = MockObj() +mock_playwright.sync_api.sync_playwright = MockObj(return_value=MockObj( + start=MockObj(return_value=MockObj( + chromium=MockObj( + launch=MockObj(return_value=MockObj( + new_context=MockObj(return_value=MockObj( + new_page=MockObj(return_value=MockObj( + goto=MockObj(), + wait_for_selector=MockObj(), + query_selector=MockObj(return_value=MockObj( + inner_text=MockObj(return_value='测试内容'), + inner_html=MockObj(return_value='

测试

'), + get_attribute=MockObj(return_value='2024-01-01') + )), + query_selector_all=MockObj() + )) + )) + )) + ) + )) +)) + +sys.modules['playwright'] = mock_playwright +sys.modules['playwright.sync_api'] = mock_playwright.sync_api +sys.modules['playwright.sync_api.sync_playwright'] = mock_playwright.sync_api.sync_playwright + + +class TestURLDetection: + """测试 URL 识别""" + + def test_zhihu_answer_url(self): + """测试知乎回答 URL 识别""" + try: + from sources.zhihu import ZhihuSource + + source = ZhihuSource() + url = "https://www.zhihu.com/question/123456/answer/789012" + + # 测试 can_handle + assert source.can_handle(url) is True + + # 测试 extract_id + content_id = source.extract_id(url) + assert content_id == "789012" + + print("✅ 知乎回答 URL 识别测试通过") + except ImportError as e: + pytest.skip(f"ZhihuSource 导入失败(BaseSource 不存在): {e}") + + def test_zhihu_article_url(self): + """测试知乎文章 URL 识别""" + try: + from sources.zhihu import ZhihuSource + + source = ZhihuSource() + url = "https://zhuanlan.zhihu.com/p/123456789" + + # 测试 can_handle + assert source.can_handle(url) is True + + # 测试 extract_id + content_id = source.extract_id(url) + assert content_id == "123456789" + + print("✅ 知乎文章 URL 识别测试通过") + except ImportError as e: + pytest.skip(f"ZhihuSource 导入失败(BaseSource 不存在): {e}") + + def test_zhihu_question_url(self): + """测试知乎问题 URL 识别""" + try: + from sources.zhihu import ZhihuSource + + source = ZhihuSource() + url = "https://www.zhihu.com/question/123456" + + # 测试 can_handle + assert source.can_handle(url) is True + + # 测试 extract_id + content_id = source.extract_id(url) + assert content_id == "123456" + + print("✅ 知乎问题 URL 识别测试通过") + except ImportError as e: + pytest.skip(f"ZhihuSource 导入失败(BaseSource 不存在): {e}") + + def test_non_zhihu_url(self): + """测试非知乎 URL 识别""" + try: + from sources.zhihu import ZhihuSource + + source = ZhihuSource() + url = "https://www.example.com/article/123" + + assert source.can_handle(url) is False + + print("✅ 非知乎 URL 识别测试通过") + except ImportError as e: + pytest.skip(f"ZhihuSource 导入失败(BaseSource 不存在): {e}") + + def test_url_pattern_direct(self): + """直接测试 URL 模式匹配""" + import re + + # 定义知乎 URL 模式 + patterns = [ + r'zhihu\.com/question/\d+/answer/(\d+)', + r'zhuanlan\.zhihu\.com/p/(\d+)', + r'zhihu\.com/question/(\d+)', + ] + + test_cases = [ + ("https://www.zhihu.com/question/123456/answer/789012", "789012"), + ("https://zhuanlan.zhihu.com/p/123456789", "123456789"), + ("https://www.zhihu.com/question/999888", "999888"), + ] + + for url, expected_id in test_cases: + extracted = None + for pattern in patterns: + match = re.search(pattern, url) + if match: + extracted = match.group(1) + break + + assert extracted == expected_id, f"URL {url} 应提取到 {expected_id},实际得到 {extracted}" + + print("✅ URL 模式匹配测试通过") + + +class TestPlaywrightBrowserMock: + """测试 Mock Playwright 浏览器""" + + def test_mock_playwright_browser_start(self): + """测试 Mock Playwright 浏览器启动""" + try: + from sources.zhihu_playwright_extractor import PlaywrightBrowser + + browser = PlaywrightBrowser() + browser.start() + + # 验证 mock 被调用 + assert mock_playwright.sync_api.sync_playwright.called + + print("✅ Mock Playwright 浏览器启动测试通过") + except ImportError as e: + pytest.skip(f"PlaywrightBrowser 导入失败: {e}") + + def test_mock_playwright_browser_close(self): + """测试 Mock Playwright 浏览器关闭""" + try: + from sources.zhihu_playwright_extractor import PlaywrightBrowser + + # 设置 mock 返回值 + mock_playwright.sync_api.sync_playwright.return_value.start.return_value.chromium.launch.return_value.new_context.return_value.new_page.return_value.close = Mock() + + browser = PlaywrightBrowser() + browser.browser = mock_playwright.sync_api.sync_playwright.return_value.start.return_value.chromium.launch.return_value.new_context.return_value.new_page.return_value + browser.context = browser.browser.new_context.return_value + browser.page = browser.browser.new_page.return_value + browser.playwright = mock_playwright.sync_api.sync_playwright.return_value + + browser.close() + + # 验证关闭被调用 + assert browser.page.close.called + + print("✅ Mock Playwright 浏览器关闭测试通过") + except ImportError as e: + pytest.skip(f"PlaywrightBrowser 导入失败: {e}") + + def test_mock_playwright_goto_and_query(self): + """测试 Mock Playwright 导航和查询""" + try: + from sources.zhihu_playwright_extractor import PlaywrightBrowser + + # 创建浏览器实例 + browser = PlaywrightBrowser() + browser.browser = mock_playwright.sync_api.sync_playwright.return_value.start.return_value.chromium.launch.return_value.new_context.return_value.new_page.return_value + browser.context = browser.browser.new_context.return_value + browser.page = browser.browser.new_page.return_value + + # 测试导航 + url = "https://www.zhihu.com/question/123456" + browser.goto(url) + assert browser.page.goto.called + + # 测试查询 + selector = "h1.QuestionHeader-title" + browser.query_selector(selector) + assert browser.page.query_selector.called + + # 测试查询所有 + browser.query_selector_all(selector) + assert browser.page.query_selector_all.called + + print("✅ Mock Playwright 导航和查询测试通过") + except ImportError as e: + pytest.skip(f"PlaywrightBrowser 导入失败: {e}") + + +class TestPlaywrightTimeoutHandling: + """测试 Playwright 超时处理""" + + def test_playwright_start_timeout(self): + """测试 Playwright 启动超时""" + try: + from sources.zhihu_playwright_extractor import PlaywrightBrowser + + # 设置 mock 抛出超时异常 + mock_playwright.sync_api.sync_playwright.side_effect = TimeoutError("Playwright 启动超时") + + browser = PlaywrightBrowser() + + # 测试启动超时 + with pytest.raises(TimeoutError): + browser.start() + + print("✅ Playwright 启动超时测试通过") + except ImportError as e: + pytest.skip(f"PlaywrightBrowser 导入失败: {e}") + + def test_playwright_goto_timeout(self): + """测试 Playwright 页面导航超时""" + try: + from sources.zhihu_playwright_extractor import PlaywrightBrowser + + # 设置 mock page goto 抛出超时异常 + mock_page = mock_playwright.sync_api.sync_playwright.return_value.start.return_value.chromium.launch.return_value.new_context.return_value.new_page.return_value + mock_page.goto.side_effect = TimeoutError("页面加载超时") + + browser = PlaywrightBrowser() + browser.browser = mock_page + browser.context = mock_page.new_context.return_value + browser.page = mock_page + + # 测试导航超时 + with pytest.raises(TimeoutError): + browser.goto("https://www.zhihu.com/question/123456") + + print("✅ Playwright 页面导航超时测试通过") + except ImportError as e: + pytest.skip(f"PlaywrightBrowser 导入失败: {e}") + + def test_playwright_wait_for_selector_timeout(self): + """测试 Playwright 等待选择器超时""" + try: + from sources.zhihu_playwright_extractor import PlaywrightBrowser + + # 设置 mock page wait_for_selector 抛出超时异常 + mock_page = mock_playwright.sync_api.sync_playwright.return_value.start.return_value.chromium.launch.return_value.new_context.return_value.new_page.return_value + mock_page.wait_for_selector.side_effect = TimeoutError("等待选择器超时") + + browser = PlaywrightBrowser() + browser.browser = mock_page + browser.context = mock_page.new_context.return_value + browser.page = mock_page + + # 测试等待选择器超时 + with pytest.raises(TimeoutError): + browser.wait_for_selector(".RichContent-inner") + + print("✅ Playwright 等待选择器超时测试通过") + except ImportError as e: + pytest.skip(f"PlaywrightBrowser 导入失败: {e}") + + +class TestPlaywrightForbiddenError: + """测试 Playwright 403 错误处理""" + + def test_playwright_goto_403_error(self): + """测试 Playwright 遇到 403 错误""" + try: + from sources.zhihu_playwright_extractor import PlaywrightBrowser + + # 创建 mock response + mock_response = Mock() + mock_response.status = 403 + mock_response.status_text = "Forbidden" + + mock_page = mock_playwright.sync_api.sync_playwright.return_value.start.return_value.chromium.launch.return_value.new_context.return_value.new_page.return_value + mock_page.goto.return_value = mock_response + + # 设置 close 方法避免后续错误 + mock_page.close = Mock() + mock_page.new_context.return_value.close = Mock() + mock_page.new_context.return_value.new_page.return_value.close = Mock() + + browser = PlaywrightBrowser() + browser.browser = mock_page + browser.context = mock_page.new_context.return_value + browser.page = mock_page + + # 测试导航到返回 403 的页面 + result = browser.goto("https://www.zhihu.com/question/123456") + + # 验证返回的响应状态 + assert result.status == 403 + assert result.status_text == "Forbidden" + + print("✅ Playwright 403 错误测试通过") + except ImportError as e: + pytest.skip(f"PlaywrightBrowser 导入失败: {e}") + + +class TestPlaywrightFallbackToHTTP: + """测试 Playwright 降级到 HTTP 的机制""" + + def test_playwright_timeout_fallback_to_http(self): + """测试 Playwright 超时降级到 HTTP""" + try: + from sources.zhihu import ZhihuSource + + # 设置 Playwright 导入失败 + with patch('sources.zhihu_playwright_extractor.ZhihuPlaywrightExtractor') as MockExtractor: + mock_extractor_instance = MagicMock() + mock_extractor_instance.extract.side_effect = TimeoutError("Playwright 启动超时") + MockExtractor.return_value = mock_extractor_instance + + source = ZhihuSource() + url = "https://www.zhihu.com/question/123456/answer/789012" + + # 测试 get_transcript 的降级逻辑 + with patch.object(source, '_fetch_with_http', return_value="HTTP 内容"): + transcript = source.get_transcript(source.fetch(url)) + + # 验证是否降级到 HTTP + assert transcript == "HTTP 内容" + + print("✅ Playwright 超时降级测试通过") + except ImportError as e: + pytest.skip(f"ZhihuSource 导入失败: {e}") + + def test_playwright_import_error_fallback(self): + """测试 Playwright 导入错误降级""" + try: + from sources.zhihu import ZhihuSource + + # 设置 Playwright 导入失败 + with patch('sources.zhihu_playwright_extractor.ZhihuPlaywrightExtractor', side_effect=ImportError("Playwright 未安装")): + source = ZhihuSource() + url = "https://www.zhihu.com/question/123456/answer/789012" + + # 测试 get_transcript 的降级逻辑 + with patch.object(source, '_fetch_with_http', return_value="HTTP 内容"): + transcript = source.get_transcript(source.fetch(url)) + + # 验证是否降级到 HTTP + assert transcript == "HTTP 内容" + + print("✅ Playwright 导入错误降级测试通过") + except ImportError as e: + pytest.skip(f"ZhihuSource 导入失败: {e}") + + def test_playwright_runtime_error_fallback(self): + """测试 Playwright 运行时错误降级""" + try: + from sources.zhihu import ZhihuSource + + # 设置 Playwright 提取器抛出运行时错误 + with patch('sources.zhihu_playwright_extractor.ZhihuPlaywrightExtractor') as MockExtractor: + mock_extractor_instance = MagicMock() + mock_extractor_instance.extract.side_effect = RuntimeError("浏览器崩溃") + MockExtractor.return_value = mock_extractor_instance + + source = ZhihuSource() + url = "https://www.zhihu.com/question/123456/answer/789012" + + # 测试 get_transcript 的降级逻辑 + with patch.object(source, '_fetch_with_http', return_value="HTTP 内容"): + transcript = source.get_transcript(source.fetch(url)) + + # 验证是否降级到 HTTP + assert transcript == "HTTP 内容" + + print("✅ Playwright 运行时错误降级测试通过") + except ImportError as e: + pytest.skip(f"ZhihuSource 导入失败: {e}") + + +class TestZhihuPlaywrightExtractorMock: + """测试 Mock ZhihuPlaywrightExtractor""" + + def test_extract_answer_success(self): + """测试成功提取回答""" + try: + from sources.zhihu_playwright_extractor import ZhihuPlaywrightExtractor + + # 设置 mock 元素 - 创建多个 mock 元素以匹配不同的选择器 + mock_title = Mock() + mock_title.inner_text.return_value = "测试问题" + + mock_author = Mock() + mock_author.inner_text.return_value = "测试作者" + + mock_content = Mock() + mock_content.inner_html.return_value = "

测试内容

" + + mock_time = Mock() + mock_time.get_attribute.return_value = "2024-01-01" + + mock_vote = Mock() + mock_vote.inner_text.return_value = "100" + + def mock_query_selector(selector): + if 'QuestionHeader-title' in selector: + return mock_title + elif 'AuthorInfo-name' in selector: + return mock_author + elif 'RichContent-inner' in selector: + return mock_content + elif 'ContentItem-time' in selector: + return mock_time + elif 'VoteButton--up' in selector: + return mock_vote + return None + + mock_page = mock_playwright.sync_api.sync_playwright.return_value.start.return_value.chromium.launch.return_value.new_context.return_value.new_page.return_value + mock_page.query_selector = mock_query_selector + + # 设置 close 方法避免后续错误 + mock_page.close = Mock() + mock_page.new_context.return_value.close = Mock() + mock_page.new_context.return_value.new_page.return_value.close = Mock() + + # 创建提取器并提取内容 + extractor = ZhihuPlaywrightExtractor() + url = "https://www.zhihu.com/question/123456/answer/789012" + result = extractor.extract(url) + + # 验证结果 + assert result is not None + assert result['type'] == 'answer' + assert result['title'] == '测试问题' + assert result['author'] == '测试作者' + assert result['content_id'] == '789012' + + print("✅ 成功提取回答测试通过") + except ImportError as e: + pytest.skip(f"ZhihuPlaywrightExtractor 导入失败: {e}") + + def test_extract_article_success(self): + """测试成功提取文章""" + try: + from sources.zhihu_playwright_extractor import ZhihuPlaywrightExtractor + + # 设置 mock 元素 - 创建多个 mock 元素以匹配不同的选择器 + mock_title = Mock() + mock_title.inner_text.return_value = "测试文章标题" + + mock_author = Mock() + mock_author.inner_text.return_value = "测试作者" + + mock_content = Mock() + mock_content.inner_html.return_value = "

测试文章内容

" + + mock_time = Mock() + mock_time.get_attribute.return_value = "2024-01-01" + + mock_vote = Mock() + mock_vote.inner_text.return_value = "200" + + def mock_query_selector(selector): + if 'Post-Title' in selector: + return mock_title + elif 'AuthorInfo-name' in selector: + return mock_author + elif 'Post-RichText' in selector: + return mock_content + elif 'ContentItem-time' in selector: + return mock_time + elif 'VoteButton--up' in selector: + return mock_vote + return None + + mock_page = mock_playwright.sync_api.sync_playwright.return_value.start.return_value.chromium.launch.return_value.new_context.return_value.new_page.return_value + mock_page.query_selector = mock_query_selector + + # 设置 close 方法避免后续错误 + mock_page.close = Mock() + mock_page.new_context.return_value.close = Mock() + mock_page.new_context.return_value.new_page.return_value.close = Mock() + + # 创建提取器并提取内容 + extractor = ZhihuPlaywrightExtractor() + url = "https://zhuanlan.zhihu.com/p/123456789" + result = extractor.extract(url) + + # 验证结果 + assert result is not None + assert result['type'] == 'article' + assert result['title'] == '测试文章标题' + assert result['content_id'] == '123456789' + + print("✅ 成功提取文章测试通过") + except ImportError as e: + pytest.skip(f"ZhihuPlaywrightExtractor 导入失败: {e}") + + +class TestMarkdownConverter: + """测试 Markdown 转换器""" + + def test_simple_html_conversion(self): + """测试简单 HTML 转 Markdown""" + try: + from sources.markdown_converter import MarkdownConverter + + converter = MarkdownConverter() + html = "

测试

内容

" + md = converter.convert(html) + + assert "# 测试" in md + assert "内容" in md + + print("✅ 简单 HTML 转换测试通过") + except ImportError as e: + pytest.skip(f"MarkdownConverter 未安装: {e}") + + def test_complex_html_conversion(self): + """测试复杂 HTML 转 Markdown""" + try: + from sources.markdown_converter import MarkdownConverter + + converter = MarkdownConverter() + html = """ +
+

标题

+

段落加粗和斜体

+ +
引用内容
+
+ """ + md = converter.convert(html) + + assert "## 标题" in md + assert "**加粗**" in md + assert "*斜体*" in md + assert "* 列表项 1" in md or "- 列表项 1" in md + assert ">" in md # 引用格式 + + print("✅ 复杂 HTML 转换测试通过") + except ImportError as e: + pytest.skip(f"MarkdownConverter 未安装: {e}") + + def test_code_blocks_conversion(self): + """测试代码块转换""" + try: + from sources.markdown_converter import MarkdownConverter + + converter = MarkdownConverter() + html = """ +
def hello():
+    print("Hello, World!")
+
+ """ + md = converter.convert(html) + + assert "```" in md or " def hello():" in md + + print("✅ 代码块转换测试通过") + except ImportError as e: + pytest.skip(f"MarkdownConverter 未安装: {e}") + + def test_script_and_style_stripping(self): + """测试移除 script 和 style 标签""" + try: + from sources.markdown_converter import MarkdownConverter + + converter = MarkdownConverter() + html = """ + + +

实际内容

+ """ + md = converter.convert(html) + + # markdownify 可能保留 script/style 内容,但至少应该有实际内容 + assert "实际内容" in md + + print("✅ Script 和 Style 移除测试通过") + except ImportError as e: + pytest.skip(f"MarkdownConverter 未安装: {e}") + + +class TestErrorHandling: + """测试错误处理""" + + def test_timeout_handling_mock(self): + """测试超时处理(Mock 版本)""" + # 模拟超时异常 + def mock_fetch_with_timeout(url, timeout=1): + """模拟带超时的请求""" + raise TimeoutError(f"Request to {url} timed out after {timeout}s") + + # 测试超时 + with pytest.raises(TimeoutError) as exc_info: + mock_fetch_with_timeout("https://www.zhihu.com", timeout=30) + + assert "timed out" in str(exc_info.value) + + print("✅ 超时处理测试通过") + + def test_timeout_handling_sync(self): + """测试超时处理(同步版本)""" + # 模拟一个可能超时的函数 + def mock_fetch_with_possible_timeout(url, should_timeout=False): + """模拟可能超时的请求""" + if should_timeout: + raise TimeoutError(f"Request to {url} timed out") + return "success" + + # 测试正常情况 + result = mock_fetch_with_possible_timeout("https://www.zhihu.com", should_timeout=False) + assert result == "success" + + # 测试超时情况 + with pytest.raises(TimeoutError): + mock_fetch_with_possible_timeout("https://www.zhihu.com", should_timeout=True) + + print("✅ 超时处理(同步)测试通过") + + def test_403_error_handling(self): + """测试 403 错误处理""" + # 模拟 403 响应 + class MockResponse: + def __init__(self, status_code): + self.status_code = status_code + self.url = "https://www.zhihu.com/question/123456" + + def raise_for_status(self): + if self.status_code == 403: + from requests.exceptions import HTTPError + raise HTTPError(f"403 Forbidden for {self.url}") + + # 测试 403 错误 + response = MockResponse(403) + with pytest.raises(Exception): # HTTPError + response.raise_for_status() + + print("✅ 403 错误处理测试通过") + + def test_file_permission_error(self): + """测试文件权限错误""" + try: + with pytest.raises(PermissionError): + open("/tmp/test.txt", "w") + except AssertionError: + # 在某些环境下可能没有权限错误,跳过 + pytest.skip("无法在当前环境下测试文件权限错误") + + print("✅ 文件权限错误测试通过") + + def test_network_error_handling(self): + """测试网络错误处理""" + # 模拟网络连接错误 + class MockNetworkError(Exception): + pass + + def mock_fetch_url(url): + if url.startswith("https://www.zhihu.com"): + raise MockNetworkError("Network unreachable") + return "success" + + # 测试网络错误 + with pytest.raises(MockNetworkError): + mock_fetch_url("https://www.zhihu.com/question/123456") + + print("✅ 网络错误处理测试通过") + + +class TestIntegration: + """集成测试""" + + def test_zhihu_source_content_type(self): + """测试内容类型识别""" + try: + from sources.zhihu import ZhihuSource + try: + from src.models import ContentType + except ImportError: + from models import ContentType + + source = ZhihuSource() + + # 测试文章类型 + article_url = "https://zhuanlan.zhihu.com/p/123456789" + article_content = source.fetch(article_url) + assert article_content is not None + assert article_content.content_type == ContentType.ARTICLE + + print("✅ 内容类型识别测试通过") + except ImportError as e: + pytest.skip(f"ZhihuSource 或 ContentType 导入失败: {e}") + + def test_url_pattern_matching(self): + """测试 URL 模式匹配""" + try: + from sources.zhihu import ZhihuSource + + source = ZhihuSource() + + # 测试所有 URL 模式 + test_urls = [ + ("https://www.zhihu.com/question/123456/answer/789012", "789012"), + ("https://zhuanlan.zhihu.com/p/123456789", "123456789"), + ("https://www.zhihu.com/question/999888", "999888"), + ] + + for url, expected_id in test_urls: + extracted_id = source.extract_id(url) + assert extracted_id == expected_id, f"Failed for {url}: got {extracted_id}, expected {expected_id}" + + print("✅ URL 模式匹配测试通过") + except ImportError as e: + pytest.skip(f"ZhihuSource 导入失败: {e}") + + +if __name__ == "__main__": + # 运行所有测试 + pytest.main([__file__, "-v"])