知乎提取器:从 Playwright 迁移到 DrissionPage 方案

- 使用 DrissionPage 替代 Playwright
- 实现 Cookie 管理和反爬绕过
- 支持回答/文章/问题三种内容类型
- 灵活配置(文件/环境变量/配置文件)
- 更新文档和配置文件
This commit is contained in:
OpenClaw Agent
2026-02-08 05:56:27 +08:00
parent aa206a1679
commit a69225ff2b
8 changed files with 665 additions and 79 deletions
+31
View File
@@ -296,6 +296,37 @@ class Config:
"""Get bilibili extraction timeout in seconds."""
return self.get('sources.bilibili.extraction.timeout', 30)
# Zhihu DrissionPage configuration
@property
def zhihu_enabled(self) -> bool:
"""知乎是否启用"""
return self.get('sources.zhihu.enabled', False)
@property
def zhihu_use_drissionpage(self) -> bool:
"""知乎是否使用 DrissionPage"""
return self.get('sources.zhihu.use_drissionpage', True)
@property
def zhihu_cookie_file(self) -> Optional[str]:
"""知乎 Cookie 文件路径"""
return self.get('sources.zhihu.cookie_file')
@property
def zhihu_cookie_str(self) -> Optional[str]:
"""知乎 Cookie 字符串"""
return self.get('sources.zhihu.cookie_str')
@property
def zhihu_timeout(self) -> int:
"""知乎提取超时时间(秒)"""
return self.get('sources.zhihu.timeout', 30)
@property
def zhihu_headless(self) -> bool:
"""知乎是否使用无头浏览器"""
return self.get('sources.zhihu.headless', True)
def ensure_dirs(self) -> None:
"""Ensure required directories exist."""
self.vault_path.mkdir(parents=True, exist_ok=True)
+13 -9
View File
@@ -10,45 +10,49 @@ from models import Content
class SourceAdapter(ABC):
"""内容源适配器基类"""
@abstractmethod
def fetch_new(self, since: datetime, **kwargs) -> List[Content]:
"""
获取新内容
Args:
since: 起始时间,只获取该时间之后的内容
**kwargs: 额外的参数
Returns:
List[Content]: 新内容列表
"""
pass
@abstractmethod
def extract_content(self, url: str, **kwargs) -> Optional[Content]:
"""
从URL提取内容
Args:
url: 内容URL
**kwargs: 额外的参数
Returns:
Optional[Content]: 内容对象,如果提取失败返回None
"""
pass
@abstractmethod
def get_transcript(self, content: Content, **kwargs) -> Optional[str]:
"""
获取字幕/文本
Args:
content: 内容对象
**kwargs: 额外的参数
Returns:
Optional[str]: 字幕文本(SRT格式),如果没有字幕返回None
"""
pass
# 为向后兼容,提供 BaseSource 别名
BaseSource = SourceAdapter
+19 -11
View File
@@ -5,6 +5,14 @@ from typing import Optional
from .base import BaseSource
from ..models import Content, ContentType
try:
from .zhihu_drission_extractor import ZhihuDrissionExtractor
except ImportError:
# DrissionPage 未安装
import logging
logger = logging.getLogger(__name__)
logger.warning(f"DrissionPage 未安装,无法提取知乎内容")
class ZhihuSource(BaseSource):
"""知乎文章/回答源"""
@@ -52,13 +60,16 @@ class ZhihuSource(BaseSource):
def get_transcript(self, content: Content) -> Optional[str]:
"""获取文章内容"""
try:
# 优先使用 Playwright 提取
from .zhihu_playwright_extractor import ZhihuPlaywrightExtractor
# 使用 DrissionPage 提取器
extractor = ZhihuDrissionExtractor(
headless=self.config.get('headless', True),
timeout=self.config.get('timeout', 30),
config=self.config
)
extractor = ZhihuPlaywrightExtractor()
data = extractor.extract(content.url)
if data and data.get('content'):
if data and data.get('content_markdown'):
# 更新元数据
if data.get('title'):
content.title = data['title']
@@ -66,18 +77,15 @@ class ZhihuSource(BaseSource):
content.author = data['author']
# 返回 Markdown 内容
return data['content']
return data['content_markdown']
except ImportError as e:
# Playwright 未安装
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Playwright 未安装,使用 HTTP 方案: {e}")
logger.error(f"DrissionPage 导入失败: {e}")
except Exception as e:
# Playwright 提取失败
import logging
logger = logging.getLogger(__name__)
logger.error(f"Playwright 提取失败: {e}")
logger.error(f"DrissionPage 提取失败: {e}")
# 降级到原有 HTTP 方案(保留现有逻辑)
return self._fetch_with_http(content.url)
return None
@@ -0,0 +1,491 @@
"""
知乎 DrissionPage 内容提取器
使用 DrissionPage 浏览器自动化工具抓取知乎内容
"""
import re
import json
import sys
import os
from typing import Dict, Optional, Any, List
from pathlib import Path
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 DrissionBrowser:
"""DrissionPage 浏览器管理器"""
def __init__(self, headless: bool = True, timeout: int = 30):
self.headless = headless
self.timeout = timeout
self.page = None
def start(self):
"""启动浏览器"""
from DrissionPage import ChromiumOptions, ChromiumPage
options = ChromiumOptions()
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')
self.page = ChromiumPage(options)
self.page.set.timeouts(base=self.timeout)
def close(self):
"""关闭浏览器"""
if self.page:
self.page.quit()
self.page = None
def goto(self, url: str):
"""导航到指定 URL"""
self.page.get(url)
def wait_for_selector(self, selector: str, timeout: int = None):
"""等待选择器出现"""
timeout = timeout or self.timeout
self.page.wait.ele_displayed(selector, timeout=timeout)
def query_selector(self, selector: str):
"""查询选择器"""
return self.page.ele(selector)
def query_selector_all(self, selector: str):
"""查询所有匹配的选择器"""
return self.page.eles(selector)
def evaluate(self, script: str):
"""执行 JavaScript 代码"""
return self.page.run_js(script)
def get_page_title(self):
"""获取页面标题"""
if self.page:
return self.page.title
return ''
def get_page_content(self):
"""获取页面 HTML 内容"""
if self.page:
return self.page.html
return ''
def screenshot(self, path: str):
"""截图"""
if self.page:
self.page.get_screenshot(path=path)
class ZhihuDrissionExtractor:
"""知乎 DrissionPage 提取器"""
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, config: Optional[Dict] = None):
"""
初始化提取器
Args:
headless: 是否使用无头浏览器(默认 True)
timeout: 超时时间(秒,默认 30)
config: 配置字典,用于加载 Cookie
"""
self.headless = headless
self.timeout = timeout
self.config = config or {}
# 支持从 config 中获取 headless 和 timeout
if self.config.get('headless') is not None:
self.headless = self.config.get('headless')
if self.config.get('timeout') is not None:
self.timeout = self.config.get('timeout')
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'
)
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.text if hasattr(element, 'text') else ''
def _extract_html_content(self, element) -> str:
"""提取元素的 HTML 内容"""
if element is None:
return ''
return element.html if hasattr(element, 'html') else ''
def _extract_attribute(self, selector: str, attribute: str) -> str:
"""提取元素的属性"""
try:
element = self.browser.query_selector(selector)
if element:
return element.attr(attribute) or ''
except Exception as e:
print(f"提取属性时出错(选择器: {selector}, 属性: {attribute}): {e}")
return ''
def _load_cookies(self) -> List[Dict]:
"""
加载 Cookie,优先级:配置 > 环境 > 文件
Returns:
Cookie 列表
"""
cookies = []
# 1. 尝试从配置加载(优先级最高)
if self.config.get('zhihu.cookie_str'):
try:
cookies = json.loads(self.config.get('zhihu.cookie_str'))
print(f"从配置加载 Cookie,共 {len(cookies)} 个")
except json.JSONDecodeError as e:
print(f"配置中的 Cookie JSON 格式错误: {e}")
# 2. 尝试从环境变量加载(优先级中等)
elif os.getenv('ZHIHU_COOKIE'):
try:
cookies = json.loads(os.getenv('ZHIHU_COOKIE'))
print(f"从环境变量加载 Cookie,共 {len(cookies)} 个")
except json.JSONDecodeError as e:
print(f"环境变量中的 Cookie JSON 格式错误: {e}")
# 3. 尝试从文件加载(优先级最低)
else:
cookie_file = self.config.get('zhihu.cookie_file', self.cookie_file)
if cookie_file and Path(cookie_file).exists():
try:
with open(cookie_file, 'r', encoding='utf-8') as f:
cookies = json.load(f)
print(f"从文件加载 Cookie ({cookie_file}),共 {len(cookies)} 个")
except (json.JSONDecodeError, FileNotFoundError, IOError) as e:
print(f"从文件加载 Cookie 失败: {e}")
else:
print("未找到 Cookie 配置,将使用未登录状态访问知乎")
return cookies
def _apply_cookies(self, cookies: List[Dict]):
"""
应用 Cookie 到浏览器页面
Args:
cookies: Cookie 列表
"""
if not cookies:
return
try:
for cookie in cookies:
if self.browser and self.browser.page:
self.browser.page.set.cookies.add(cookie)
print(f"成功应用 {len(cookies)} 个 Cookie")
except Exception as e:
print(f"应用 Cookie 时出错: {e}")
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.get_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.screenshot(os.path.join(debug_dir, 'zhihu_drission_screenshot.png'))
with open(os.path.join(debug_dir, 'zhihu_drission_page.html'), 'w', encoding='utf-8') as f:
f.write(self.browser.get_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.attr('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.attr('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,
'url': str,
'content_id': str,
}
如果提取失败返回 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}")
# 加载 Cookie
cookies = self._load_cookies()
if cookies:
print(f"加载了 {len(cookies)} 个 Cookie")
print(f"启动浏览器...")
# 启动浏览器
self.browser.start()
# 应用 Cookie
if cookies and self.browser.page:
self._apply_cookies(cookies)
print(f"访问页面: {url}")
# 访问页面
self.browser.goto(url)
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, config=None) -> Optional[Dict[str, Any]]:
"""
便捷函数:提取知乎内容
Args:
url: 知乎 URL
config: 配置字典
Returns:
提取的内容字典
"""
with ZhihuDrissionExtractor(config=config) as extractor:
return extractor.extract(url)