diff --git a/README.md b/README.md
index 30d986d..fb30dfd 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
目前能抓取小红书、抖音、快手、B站、微博、贴吧、知乎等平台的公开信息。
原理:利用[playwright](https://playwright.dev/)搭桥,保留登录成功后的上下文浏览器环境,通过执行JS表达式获取一些加密参数
-通过使用此方式,免去了复现核心加密JS代码,逆向难度大大降低
+通过使用此方式,免去了复现核心加密JS代码,逆向难度大大降低。
# 功能列表
| 平台 | 关键词搜索 | 指定帖子ID爬取 | 二级评论 | 指定创作者主页 | 登录态缓存 | IP代理池 | 生成评论词云图 |
@@ -52,36 +52,38 @@
# 安装部署方法
> 开源不易,希望大家可以Star一下MediaCrawler仓库!!!!十分感谢!!!
-## 创建并激活 python 虚拟环境
-> 如果是爬取抖音和知乎,需要提前安装nodejs环境,版本大于等于:`16`即可
-> 新增 [uv](https://github.com/astral-sh/uv) 来管理项目依赖,使用uv来替代python版本管理、pip进行依赖安装,更加方便快捷
- ```shell
- # 进入项目根目录
- cd MediaCrawler
-
- # 创建虚拟环境
- # 我的python版本是:3.9.6,requirements.txt中的库是基于这个版本的,如果是其他python版本,可能requirements.txt中的库不兼容,自行解决一下。
- python -m venv venv
-
- # macos & linux 激活虚拟环境
- source venv/bin/activate
+## 前置依赖
- # windows 激活虚拟环境
- venv\Scripts\activate
+### uv 安装
+> 在进行下一步操作之前, 请确保电脑上已经安装了uv,[uv安装地址](https://docs.astral.sh/uv/getting-started/installation)
+>
+> uv是否安装成功的验证, 终端输入命令:uv --version 如果正常显示版本好,那证明已经安装成功
+>
+> 强力安利 uv 给大家使用,简直是最强的python包管理工具
+>
- ```
+### nodejs安装
+项目依赖nodejs,安装地址:https://nodejs.org/en/download/
+> 如果要用python的原生venv来管理环境的话,可以参考: [原生环境管理文档](docs/原生环境管理文档.md)
-## 安装依赖库
+### python包安装
- ```shell
- pip install -r requirements.txt
- ```
+```shell
+# 进入项目目录
+cd MediaCrawler
-## 安装 playwright浏览器驱动
+# 使用 uv sync 命令来保证python版本和相关依赖包的一致性
+uv sync
+```
- ```shell
- playwright install
- ```
+### 浏览器驱动安装
+```shell
+# 安装浏览器驱动
+playwright install
+```
+> MediaCrawler目前已经支持使用playwright连接你本地的Chrome浏览器了,一些因为Webdriver导致的问题迎刃而解了。
+>
+> 目前开放了 xhs 和 dy 这两个使用 cdp 的方式连接本地浏览器,如有需要,查看config/base_config.py中的配置项。
## 运行爬虫程序
@@ -90,16 +92,16 @@
### 一些其他支持项,也可以在config/base_config.py查看功能,写的有中文注释
# 从配置文件中读取关键词搜索相关的帖子并爬取帖子信息与评论
- python main.py --platform xhs --lt qrcode --type search
+ uv run main.py --platform xhs --lt qrcode --type search
# 从配置文件中读取指定的帖子ID列表获取指定帖子的信息与评论信息
- python main.py --platform xhs --lt qrcode --type detail
+ uv run main.py --platform xhs --lt qrcode --type detail
# 打开对应APP扫二维码登录
# 其他平台爬虫使用示例,执行下面的命令查看
- python main.py --help
- ```
+ uv run main.py --help
+ ```
## 数据保存
- 支持关系型数据库Mysql中保存(需要提前创建数据库)
@@ -107,7 +109,9 @@
- 支持保存到csv中(data/目录下)
- 支持保存到json中(data/目录下)
-
+# 项目微信交流群
+[加入微信交流群](https://nanmicoder.github.io/MediaCrawler/%E5%BE%AE%E4%BF%A1%E4%BA%A4%E6%B5%81%E7%BE%A4.html)
+
# 其他常见问题可以查看在线文档
>
@@ -120,10 +124,7 @@
[作者的知识付费栏目介绍](https://nanmicoder.github.io/MediaCrawler/%E7%9F%A5%E8%AF%86%E4%BB%98%E8%B4%B9%E4%BB%8B%E7%BB%8D.html)
-# 项目微信交流群
-[加入微信交流群](https://nanmicoder.github.io/MediaCrawler/%E5%BE%AE%E4%BF%A1%E4%BA%A4%E6%B5%81%E7%BE%A4.html)
-
# 感谢下列Sponsors对本仓库赞助支持
diff --git a/base/base_crawler.py b/base/base_crawler.py
index 6812b39..56d13a5 100644
--- a/base/base_crawler.py
+++ b/base/base_crawler.py
@@ -12,7 +12,7 @@
from abc import ABC, abstractmethod
from typing import Dict, Optional
-from playwright.async_api import BrowserContext, BrowserType
+from playwright.async_api import BrowserContext, BrowserType, Playwright
class AbstractCrawler(ABC):
@@ -43,6 +43,19 @@ class AbstractCrawler(ABC):
"""
pass
+ async def launch_browser_with_cdp(self, playwright: Playwright, playwright_proxy: Optional[Dict],
+ user_agent: Optional[str], headless: bool = True) -> BrowserContext:
+ """
+ 使用CDP模式启动浏览器(可选实现)
+ :param playwright: playwright实例
+ :param playwright_proxy: playwright代理配置
+ :param user_agent: 用户代理
+ :param headless: 无头模式
+ :return: 浏览器上下文
+ """
+ # 默认实现:回退到标准模式
+ return await self.launch_browser(playwright.chromium, playwright_proxy, user_agent, headless)
+
class AbstractLogin(ABC):
@abstractmethod
diff --git a/config/base_config.py b/config/base_config.py
index 102e567..ec0ad43 100644
--- a/config/base_config.py
+++ b/config/base_config.py
@@ -45,6 +45,33 @@ HEADLESS = False
# 是否保存登录状态
SAVE_LOGIN_STATE = True
+# ==================== CDP (Chrome DevTools Protocol) 配置 ====================
+# 是否启用CDP模式 - 使用用户现有的Chrome/Edge浏览器进行爬取,提供更好的反检测能力
+# 启用后将自动检测并启动用户的Chrome/Edge浏览器,通过CDP协议进行控制
+# 这种方式使用真实的浏览器环境,包括用户的扩展、Cookie和设置,大大降低被检测的风险
+ENABLE_CDP_MODE = False
+
+# CDP调试端口,用于与浏览器通信
+# 如果端口被占用,系统会自动尝试下一个可用端口
+CDP_DEBUG_PORT = 9222
+
+# 自定义浏览器路径(可选)
+# 如果为空,系统会自动检测Chrome/Edge的安装路径
+# Windows示例: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
+# macOS示例: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+CUSTOM_BROWSER_PATH = ""
+
+# CDP模式下是否启用无头模式
+# 注意:即使设置为True,某些反检测功能在无头模式下可能效果不佳
+CDP_HEADLESS = False
+
+# 浏览器启动超时时间(秒)
+BROWSER_LAUNCH_TIMEOUT = 30
+
+# 是否在程序结束时自动关闭浏览器
+# 设置为False可以保持浏览器运行,便于调试
+AUTO_CLOSE_BROWSER = True
+
# 数据保存类型选项配置,支持三种类型:csv、db、json, 最好保存到DB,有排重的功能。
SAVE_DATA_OPTION = "json" # csv or db or json
diff --git a/docs/CDP模式使用指南.md b/docs/CDP模式使用指南.md
new file mode 100644
index 0000000..541cbc3
--- /dev/null
+++ b/docs/CDP模式使用指南.md
@@ -0,0 +1,246 @@
+# CDP模式使用指南
+
+## 概述
+
+CDP(Chrome DevTools Protocol)模式是一种高级的反检测爬虫技术,通过控制用户现有的Chrome/Edge浏览器来进行网页爬取。与传统的Playwright自动化相比,CDP模式具有以下优势:
+
+### 🎯 主要优势
+
+1. **真实浏览器环境**: 使用用户实际安装的浏览器,包含所有扩展、插件和个人设置
+2. **更好的反检测能力**: 浏览器指纹更加真实,难以被网站检测为自动化工具
+3. **保留用户状态**: 自动继承用户的登录状态、Cookie和浏览历史
+4. **扩展支持**: 可以利用用户安装的广告拦截器、代理扩展等工具
+5. **更自然的行为**: 浏览器行为模式更接近真实用户
+
+## 快速开始
+
+### 1. 启用CDP模式
+
+在 `config/base_config.py` 中设置:
+
+```python
+# 启用CDP模式
+ENABLE_CDP_MODE = True
+
+# CDP调试端口(可选,默认9222)
+CDP_DEBUG_PORT = 9222
+
+# 是否在无头模式下运行(建议设为False以获得最佳反检测效果)
+CDP_HEADLESS = False
+
+# 程序结束时是否自动关闭浏览器
+AUTO_CLOSE_BROWSER = True
+```
+
+### 2. 运行测试
+
+```bash
+# 运行CDP功能测试
+python examples/cdp_example.py
+
+# 运行小红书爬虫(CDP模式)
+python main.py
+```
+
+## 配置选项详解
+
+### 基础配置
+
+| 配置项 | 类型 | 默认值 | 说明 |
+|--------|------|--------|------|
+| `ENABLE_CDP_MODE` | bool | False | 是否启用CDP模式 |
+| `CDP_DEBUG_PORT` | int | 9222 | CDP调试端口 |
+| `CDP_HEADLESS` | bool | False | CDP模式下的无头模式 |
+| `AUTO_CLOSE_BROWSER` | bool | True | 程序结束时是否关闭浏览器 |
+
+### 高级配置
+
+| 配置项 | 类型 | 默认值 | 说明 |
+|--------|------|--------|------|
+| `CUSTOM_BROWSER_PATH` | str | "" | 自定义浏览器路径 |
+| `BROWSER_LAUNCH_TIMEOUT` | int | 30 | 浏览器启动超时时间(秒) |
+
+### 自定义浏览器路径
+
+如果系统自动检测失败,可以手动指定浏览器路径:
+
+```python
+# Windows示例
+CUSTOM_BROWSER_PATH = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
+
+# macOS示例
+CUSTOM_BROWSER_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+
+# Linux示例
+CUSTOM_BROWSER_PATH = "/usr/bin/google-chrome"
+```
+
+## 支持的浏览器
+
+### Windows
+- Google Chrome (稳定版、Beta、Dev、Canary)
+- Microsoft Edge (稳定版、Beta、Dev、Canary)
+
+### macOS
+- Google Chrome (稳定版、Beta、Dev、Canary)
+- Microsoft Edge (稳定版、Beta、Dev、Canary)
+
+### Linux
+- Google Chrome / Chromium
+- Microsoft Edge
+
+## 使用示例
+
+### 基本使用
+
+```python
+import asyncio
+from playwright.async_api import async_playwright
+from tools.cdp_browser import CDPBrowserManager
+
+async def main():
+ cdp_manager = CDPBrowserManager()
+
+ async with async_playwright() as playwright:
+ # 启动CDP浏览器
+ browser_context = await cdp_manager.launch_and_connect(
+ playwright=playwright,
+ user_agent="自定义User-Agent",
+ headless=False
+ )
+
+ # 创建页面并访问网站
+ page = await browser_context.new_page()
+ await page.goto("https://example.com")
+
+ # 执行爬取操作...
+
+ # 清理资源
+ await cdp_manager.cleanup()
+
+asyncio.run(main())
+```
+
+### 在爬虫中使用
+
+CDP模式已集成到所有平台爬虫中,只需启用配置即可:
+
+```python
+# 在config/base_config.py中
+ENABLE_CDP_MODE = True
+
+# 然后正常运行爬虫
+python main.py
+```
+
+## 故障排除
+
+### 常见问题
+
+#### 1. 浏览器检测失败
+**错误**: `未找到可用的浏览器`
+
+**解决方案**:
+- 确保已安装Chrome或Edge浏览器
+- 检查浏览器是否在标准路径下
+- 使用`CUSTOM_BROWSER_PATH`指定浏览器路径
+
+#### 2. 端口被占用
+**错误**: `无法找到可用的端口`
+
+**解决方案**:
+- 关闭其他使用调试端口的程序
+- 修改`CDP_DEBUG_PORT`为其他端口
+- 系统会自动尝试下一个可用端口
+
+#### 3. 浏览器启动超时
+**错误**: `浏览器在30秒内未能启动`
+
+**解决方案**:
+- 增加`BROWSER_LAUNCH_TIMEOUT`值
+- 检查系统资源是否充足
+- 尝试关闭其他占用资源的程序
+
+#### 4. CDP连接失败
+**错误**: `CDP连接失败`
+
+**解决方案**:
+- 检查防火墙设置
+- 确保localhost访问正常
+- 尝试重启浏览器
+
+### 调试技巧
+
+#### 1. 启用详细日志
+```python
+import logging
+logging.basicConfig(level=logging.DEBUG)
+```
+
+#### 2. 手动测试CDP连接
+```bash
+# 手动启动Chrome
+chrome --remote-debugging-port=9222
+
+# 访问调试页面
+curl http://localhost:9222/json
+```
+
+#### 3. 检查浏览器进程
+```bash
+# Windows
+tasklist | findstr chrome
+
+# macOS/Linux
+ps aux | grep chrome
+```
+
+## 最佳实践
+
+### 1. 反检测优化
+- 保持`CDP_HEADLESS = False`以获得最佳反检测效果
+- 使用真实的User-Agent字符串
+- 避免过于频繁的请求
+
+### 2. 性能优化
+- 合理设置`AUTO_CLOSE_BROWSER`
+- 复用浏览器实例而不是频繁重启
+- 监控内存使用情况
+
+### 3. 安全考虑
+- 不要在生产环境中保存敏感Cookie
+- 定期清理浏览器数据
+- 注意用户隐私保护
+
+### 4. 兼容性
+- 测试不同浏览器版本的兼容性
+- 准备回退方案(标准Playwright模式)
+- 监控目标网站的反爬策略变化
+
+## 技术原理
+
+CDP模式的工作原理:
+
+1. **浏览器检测**: 自动扫描系统中的Chrome/Edge安装路径
+2. **进程启动**: 使用`--remote-debugging-port`参数启动浏览器
+3. **CDP连接**: 通过WebSocket连接到浏览器的调试接口
+4. **Playwright集成**: 使用`connectOverCDP`方法接管浏览器控制
+5. **上下文管理**: 创建或复用浏览器上下文进行操作
+
+这种方式绕过了传统WebDriver的检测机制,提供了更加隐蔽的自动化能力。
+
+## 更新日志
+
+### v1.0.0
+- 初始版本发布
+- 支持Windows和macOS的Chrome/Edge检测
+- 集成到所有平台爬虫
+- 提供完整的配置选项和错误处理
+
+## 贡献
+
+欢迎提交Issue和Pull Request来改进CDP模式功能。
+
+## 许可证
+
+本功能遵循项目的整体许可证条款,仅供学习和研究使用。
diff --git a/docs/原生环境管理文档.md b/docs/原生环境管理文档.md
new file mode 100644
index 0000000..08b981a
--- /dev/null
+++ b/docs/原生环境管理文档.md
@@ -0,0 +1,52 @@
+## 使用python原生venv管理依赖(不推荐了)
+
+## 创建并激活 python 虚拟环境
+> 如果是爬取抖音和知乎,需要提前安装nodejs环境,版本大于等于:`16`即可
+> 新增 [uv](https://github.com/astral-sh/uv) 来管理项目依赖,使用uv来替代python版本管理、pip进行依赖安装,更加方便快捷
+ ```shell
+ # 进入项目根目录
+ cd MediaCrawler
+
+ # 创建虚拟环境
+ # 我的python版本是:3.9.6,requirements.txt中的库是基于这个版本的,如果是其他python版本,可能requirements.txt中的库不兼容,自行解决一下。
+ python -m venv venv
+
+ # macos & linux 激活虚拟环境
+ source venv/bin/activate
+
+ # windows 激活虚拟环境
+ venv\Scripts\activate
+
+ ```
+
+## 安装依赖库
+
+ ```shell
+ pip install -r requirements.txt
+ ```
+
+## 查看配置文件
+
+## 安装 playwright浏览器驱动 (非必需)
+
+ ```shell
+ playwright install
+ ```
+
+## 运行爬虫程序
+
+ ```shell
+ ### 项目默认是没有开启评论爬取模式,如需评论请在config/base_config.py中的 ENABLE_GET_COMMENTS 变量修改
+ ### 一些其他支持项,也可以在config/base_config.py查看功能,写的有中文注释
+
+ # 从配置文件中读取关键词搜索相关的帖子并爬取帖子信息与评论
+ python main.py --platform xhs --lt qrcode --type search
+
+ # 从配置文件中读取指定的帖子ID列表获取指定帖子的信息与评论信息
+ python main.py --platform xhs --lt qrcode --type detail
+
+ # 打开对应APP扫二维码登录
+
+ # 其他平台爬虫使用示例,执行下面的命令查看
+ python main.py --help
+ ```
\ No newline at end of file
diff --git a/main.py b/main.py
index 1560f88..7292701 100644
--- a/main.py
+++ b/main.py
@@ -11,6 +11,7 @@
import asyncio
import sys
+from typing import Optional
import cmd_arg
import config
@@ -43,8 +44,8 @@ class CrawlerFactory:
raise ValueError("Invalid Media Platform Currently only supported xhs or dy or ks or bili ...")
return crawler_class()
-
async def main():
+
# parse cmd
await cmd_arg.parse_cmd()
diff --git a/media_platform/douyin/core.py b/media_platform/douyin/core.py
index 78c2a90..05027b7 100644
--- a/media_platform/douyin/core.py
+++ b/media_platform/douyin/core.py
@@ -15,7 +15,7 @@ import random
from asyncio import Task
from typing import Any, Dict, List, Optional, Tuple
-from playwright.async_api import (BrowserContext, BrowserType, Page,
+from playwright.async_api import (BrowserContext, BrowserType, Page, Playwright,
async_playwright)
import config
@@ -23,6 +23,7 @@ from base.base_crawler import AbstractCrawler
from proxy.proxy_ip_pool import IpInfoModel, create_ip_pool
from store import douyin as douyin_store
from tools import utils
+from tools.cdp_browser import CDPBrowserManager
from var import crawler_type_var, source_keyword_var
from .client import DOUYINClient
@@ -35,9 +36,11 @@ class DouYinCrawler(AbstractCrawler):
context_page: Page
dy_client: DOUYINClient
browser_context: BrowserContext
+ cdp_manager: Optional[CDPBrowserManager]
def __init__(self) -> None:
self.index_url = "https://www.douyin.com"
+ self.cdp_manager = None
async def start(self) -> None:
playwright_proxy_format, httpx_proxy_format = None, None
@@ -47,14 +50,23 @@ class DouYinCrawler(AbstractCrawler):
playwright_proxy_format, httpx_proxy_format = self.format_proxy_info(ip_proxy_info)
async with async_playwright() as playwright:
- # Launch a browser context.
- chromium = playwright.chromium
- self.browser_context = await self.launch_browser(
- chromium,
- None,
- user_agent=None,
- headless=config.HEADLESS
- )
+ # 根据配置选择启动模式
+ if config.ENABLE_CDP_MODE:
+ utils.logger.info("[DouYinCrawler] 使用CDP模式启动浏览器")
+ self.browser_context = await self.launch_browser_with_cdp(
+ playwright, playwright_proxy_format, None,
+ headless=config.CDP_HEADLESS
+ )
+ else:
+ utils.logger.info("[DouYinCrawler] 使用标准模式启动浏览器")
+ # Launch a browser context.
+ chromium = playwright.chromium
+ self.browser_context = await self.launch_browser(
+ chromium,
+ playwright_proxy_format,
+ user_agent=None,
+ headless=config.HEADLESS
+ )
# stealth.min.js is a js script to prevent the website from detecting the crawler.
await self.browser_context.add_init_script(path="libs/stealth.min.js")
self.context_page = await self.browser_context.new_page()
@@ -282,7 +294,41 @@ class DouYinCrawler(AbstractCrawler):
)
return browser_context
+ async def launch_browser_with_cdp(self, playwright: Playwright, playwright_proxy: Optional[Dict],
+ user_agent: Optional[str], headless: bool = True) -> BrowserContext:
+ """
+ 使用CDP模式启动浏览器
+ """
+ try:
+ self.cdp_manager = CDPBrowserManager()
+ browser_context = await self.cdp_manager.launch_and_connect(
+ playwright=playwright,
+ playwright_proxy=playwright_proxy,
+ user_agent=user_agent,
+ headless=headless
+ )
+
+ # 添加反检测脚本
+ await self.cdp_manager.add_stealth_script()
+
+ # 显示浏览器信息
+ browser_info = await self.cdp_manager.get_browser_info()
+ utils.logger.info(f"[DouYinCrawler] CDP浏览器信息: {browser_info}")
+
+ return browser_context
+
+ except Exception as e:
+ utils.logger.error(f"[DouYinCrawler] CDP模式启动失败,回退到标准模式: {e}")
+ # 回退到标准模式
+ chromium = playwright.chromium
+ return await self.launch_browser(chromium, playwright_proxy, user_agent, headless)
+
async def close(self) -> None:
"""Close browser context"""
- await self.browser_context.close()
+ # 如果使用CDP模式,需要特殊处理
+ if self.cdp_manager:
+ await self.cdp_manager.cleanup()
+ self.cdp_manager = None
+ else:
+ await self.browser_context.close()
utils.logger.info("[DouYinCrawler.close] Browser context closed ...")
diff --git a/media_platform/xhs/core.py b/media_platform/xhs/core.py
index 532273b..df65092 100644
--- a/media_platform/xhs/core.py
+++ b/media_platform/xhs/core.py
@@ -16,7 +16,7 @@ import time
from asyncio import Task
from typing import Dict, List, Optional, Tuple
-from playwright.async_api import BrowserContext, BrowserType, Page, async_playwright
+from playwright.async_api import BrowserContext, BrowserType, Page, Playwright, async_playwright
from tenacity import RetryError
import config
@@ -26,6 +26,7 @@ from model.m_xiaohongshu import NoteUrlInfo
from proxy.proxy_ip_pool import IpInfoModel, create_ip_pool
from store import xhs as xhs_store
from tools import utils
+from tools.cdp_browser import CDPBrowserManager
from var import crawler_type_var, source_keyword_var
from .client import XiaoHongShuClient
@@ -39,11 +40,13 @@ class XiaoHongShuCrawler(AbstractCrawler):
context_page: Page
xhs_client: XiaoHongShuClient
browser_context: BrowserContext
+ cdp_manager: Optional[CDPBrowserManager]
def __init__(self) -> None:
self.index_url = "https://www.xiaohongshu.com"
# self.user_agent = utils.get_user_agent()
self.user_agent = config.UA if config.UA else "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
+ self.cdp_manager = None
async def start(self) -> None:
playwright_proxy_format, httpx_proxy_format = None, None
@@ -57,11 +60,20 @@ class XiaoHongShuCrawler(AbstractCrawler):
)
async with async_playwright() as playwright:
- # Launch a browser context.
- chromium = playwright.chromium
- self.browser_context = await self.launch_browser(
- chromium, None, self.user_agent, headless=config.HEADLESS
- )
+ # 根据配置选择启动模式
+ if config.ENABLE_CDP_MODE:
+ utils.logger.info("[XiaoHongShuCrawler] 使用CDP模式启动浏览器")
+ self.browser_context = await self.launch_browser_with_cdp(
+ playwright, playwright_proxy_format, self.user_agent,
+ headless=config.CDP_HEADLESS
+ )
+ else:
+ utils.logger.info("[XiaoHongShuCrawler] 使用标准模式启动浏览器")
+ # Launch a browser context.
+ chromium = playwright.chromium
+ self.browser_context = await self.launch_browser(
+ chromium, playwright_proxy_format, self.user_agent, headless=config.HEADLESS
+ )
# stealth.min.js is a js script to prevent the website from detecting the crawler.
await self.browser_context.add_init_script(path="libs/stealth.min.js")
# add a cookie attribute webId to avoid the appearance of a sliding captcha on the webpage
@@ -292,6 +304,7 @@ class XiaoHongShuCrawler(AbstractCrawler):
else:
crawl_interval = random.uniform(1, config.CRAWLER_MAX_SLEEP_SEC)
try:
+ utils.logger.info(f"[get_note_detail_async_task] Begin get note detail, note_id: {note_id}")
# 尝试直接获取网页版笔记详情,携带cookie
note_detail_from_html: Optional[Dict] = (
await self.xhs_client.get_note_by_id_from_html(
@@ -449,9 +462,40 @@ class XiaoHongShuCrawler(AbstractCrawler):
)
return browser_context
+ async def launch_browser_with_cdp(self, playwright: Playwright, playwright_proxy: Optional[Dict],
+ user_agent: Optional[str], headless: bool = True) -> BrowserContext:
+ """
+ 使用CDP模式启动浏览器
+ """
+ try:
+ self.cdp_manager = CDPBrowserManager()
+ browser_context = await self.cdp_manager.launch_and_connect(
+ playwright=playwright,
+ playwright_proxy=playwright_proxy,
+ user_agent=user_agent,
+ headless=headless
+ )
+
+ # 显示浏览器信息
+ browser_info = await self.cdp_manager.get_browser_info()
+ utils.logger.info(f"[XiaoHongShuCrawler] CDP浏览器信息: {browser_info}")
+
+ return browser_context
+
+ except Exception as e:
+ utils.logger.error(f"[XiaoHongShuCrawler] CDP模式启动失败,回退到标准模式: {e}")
+ # 回退到标准模式
+ chromium = playwright.chromium
+ return await self.launch_browser(chromium, playwright_proxy, user_agent, headless)
+
async def close(self):
"""Close browser context"""
- await self.browser_context.close()
+ # 如果使用CDP模式,需要特殊处理
+ if self.cdp_manager:
+ await self.cdp_manager.cleanup()
+ self.cdp_manager = None
+ else:
+ await self.browser_context.close()
utils.logger.info("[XiaoHongShuCrawler.close] Browser context closed ...")
async def get_notice_media(self, note_detail: Dict):
diff --git a/tools/browser_launcher.py b/tools/browser_launcher.py
new file mode 100644
index 0000000..0483e17
--- /dev/null
+++ b/tools/browser_launcher.py
@@ -0,0 +1,243 @@
+# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
+# 1. 不得用于任何商业用途。
+# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
+# 3. 不得进行大规模爬取或对平台造成运营干扰。
+# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
+# 5. 不得用于任何非法或不当的用途。
+#
+# 详细许可条款请参阅项目根目录下的LICENSE文件。
+# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
+
+
+import os
+import platform
+import subprocess
+import time
+import socket
+from typing import Optional, List, Tuple
+import asyncio
+from pathlib import Path
+
+from tools import utils
+
+
+class BrowserLauncher:
+ """
+ 浏览器启动器,用于检测和启动用户的Chrome/Edge浏览器
+ 支持Windows和macOS系统
+ """
+
+ def __init__(self):
+ self.system = platform.system()
+ self.browser_process = None
+ self.debug_port = None
+
+ def detect_browser_paths(self) -> List[str]:
+ """
+ 检测系统中可用的浏览器路径
+ 返回按优先级排序的浏览器路径列表
+ """
+ paths = []
+
+ if self.system == "Windows":
+ # Windows下的常见Chrome/Edge安装路径
+ possible_paths = [
+ # Chrome路径
+ os.path.expandvars(r"%PROGRAMFILES%\Google\Chrome\Application\chrome.exe"),
+ os.path.expandvars(r"%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe"),
+ os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
+ # Edge路径
+ os.path.expandvars(r"%PROGRAMFILES%\Microsoft\Edge\Application\msedge.exe"),
+ os.path.expandvars(r"%PROGRAMFILES(X86)%\Microsoft\Edge\Application\msedge.exe"),
+ # Chrome Beta/Dev/Canary
+ os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome Beta\Application\chrome.exe"),
+ os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome Dev\Application\chrome.exe"),
+ os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome SxS\Application\chrome.exe"),
+ ]
+ elif self.system == "Darwin": # macOS
+ # macOS下的常见Chrome/Edge安装路径
+ possible_paths = [
+ # Chrome路径
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
+ "/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
+ # Edge路径
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+ "/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
+ "/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
+ "/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
+ ]
+ else:
+ # Linux等其他系统
+ possible_paths = [
+ "/usr/bin/google-chrome",
+ "/usr/bin/google-chrome-stable",
+ "/usr/bin/google-chrome-beta",
+ "/usr/bin/google-chrome-unstable",
+ "/usr/bin/chromium-browser",
+ "/usr/bin/chromium",
+ "/snap/bin/chromium",
+ "/usr/bin/microsoft-edge",
+ "/usr/bin/microsoft-edge-stable",
+ "/usr/bin/microsoft-edge-beta",
+ "/usr/bin/microsoft-edge-dev",
+ ]
+
+ # 检查路径是否存在且可执行
+ for path in possible_paths:
+ if os.path.isfile(path) and os.access(path, os.X_OK):
+ paths.append(path)
+
+ return paths
+
+ def find_available_port(self, start_port: int = 9222) -> int:
+ """
+ 查找可用的端口
+ """
+ port = start_port
+ while port < start_port + 100: # 最多尝试100个端口
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(('localhost', port))
+ return port
+ except OSError:
+ port += 1
+
+ raise RuntimeError(f"无法找到可用的端口,已尝试 {start_port} 到 {port-1}")
+
+ def launch_browser(self, browser_path: str, debug_port: int, headless: bool = False,
+ user_data_dir: Optional[str] = None) -> subprocess.Popen:
+ """
+ 启动浏览器进程
+ """
+ # 基本启动参数
+ args = [
+ browser_path,
+ f"--remote-debugging-port={debug_port}",
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-background-timer-throttling",
+ "--disable-backgrounding-occluded-windows",
+ "--disable-renderer-backgrounding",
+ "--disable-features=TranslateUI",
+ "--disable-ipc-flooding-protection",
+ "--disable-hang-monitor",
+ "--disable-prompt-on-repost",
+ "--disable-sync",
+ "--disable-web-security", # 可能有助于某些网站的访问
+ "--disable-features=VizDisplayCompositor",
+ "--disable-extensions-except", # 保留用户扩展
+ "--load-extension", # 允许加载扩展
+ ]
+
+ # 无头模式
+ if headless:
+ args.extend([
+ "--headless",
+ "--disable-gpu",
+ "--no-sandbox",
+ ])
+
+ # 用户数据目录
+ if user_data_dir:
+ args.append(f"--user-data-dir={user_data_dir}")
+
+ utils.logger.info(f"[BrowserLauncher] 启动浏览器: {browser_path}")
+ utils.logger.info(f"[BrowserLauncher] 调试端口: {debug_port}")
+ utils.logger.info(f"[BrowserLauncher] 无头模式: {headless}")
+
+ try:
+ # 在Windows上,使用CREATE_NEW_PROCESS_GROUP避免Ctrl+C影响子进程
+ if self.system == "Windows":
+ process = subprocess.Popen(
+ args,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
+ )
+ else:
+ process = subprocess.Popen(
+ args,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ preexec_fn=os.setsid # 创建新的进程组
+ )
+
+ return process
+
+ except Exception as e:
+ utils.logger.error(f"[BrowserLauncher] 启动浏览器失败: {e}")
+ raise
+
+ def wait_for_browser_ready(self, debug_port: int, timeout: int = 30) -> bool:
+ """
+ 等待浏览器准备就绪
+ """
+ utils.logger.info(f"[BrowserLauncher] 等待浏览器在端口 {debug_port} 上准备就绪...")
+
+ start_time = time.time()
+ while time.time() - start_time < timeout:
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(1)
+ result = s.connect_ex(('localhost', debug_port))
+ if result == 0:
+ utils.logger.info(f"[BrowserLauncher] 浏览器已在端口 {debug_port} 上准备就绪")
+ return True
+ except Exception:
+ pass
+
+ time.sleep(0.5)
+
+ utils.logger.error(f"[BrowserLauncher] 浏览器在 {timeout} 秒内未能准备就绪")
+ return False
+
+ def get_browser_info(self, browser_path: str) -> Tuple[str, str]:
+ """
+ 获取浏览器信息(名称和版本)
+ """
+ try:
+ if "chrome" in browser_path.lower():
+ name = "Google Chrome"
+ elif "edge" in browser_path.lower() or "msedge" in browser_path.lower():
+ name = "Microsoft Edge"
+ elif "chromium" in browser_path.lower():
+ name = "Chromium"
+ else:
+ name = "Unknown Browser"
+
+ # 尝试获取版本信息
+ try:
+ result = subprocess.run([browser_path, "--version"],
+ capture_output=True, text=True, timeout=5)
+ version = result.stdout.strip() if result.stdout else "Unknown Version"
+ except:
+ version = "Unknown Version"
+
+ return name, version
+
+ except Exception:
+ return "Unknown Browser", "Unknown Version"
+
+ def cleanup(self):
+ """
+ 清理资源,关闭浏览器进程
+ """
+ if self.browser_process:
+ try:
+ utils.logger.info("[BrowserLauncher] 正在关闭浏览器进程...")
+
+ if self.system == "Windows":
+ # Windows下使用taskkill强制终止进程树
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(self.browser_process.pid)],
+ capture_output=True)
+ else:
+ # Unix系统下终止进程组
+ os.killpg(os.getpgid(self.browser_process.pid), 9)
+
+ self.browser_process = None
+ utils.logger.info("[BrowserLauncher] 浏览器进程已关闭")
+
+ except Exception as e:
+ utils.logger.warning(f"[BrowserLauncher] 关闭浏览器进程时出错: {e}")
diff --git a/tools/cdp_browser.py b/tools/cdp_browser.py
new file mode 100644
index 0000000..9b8142f
--- /dev/null
+++ b/tools/cdp_browser.py
@@ -0,0 +1,266 @@
+# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
+# 1. 不得用于任何商业用途。
+# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
+# 3. 不得进行大规模爬取或对平台造成运营干扰。
+# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
+# 5. 不得用于任何非法或不当的用途。
+#
+# 详细许可条款请参阅项目根目录下的LICENSE文件。
+# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
+
+
+import os
+import asyncio
+from typing import Optional, Dict, Any
+from playwright.async_api import Browser, BrowserContext, Playwright
+
+import config
+from tools.browser_launcher import BrowserLauncher
+from tools import utils
+
+
+class CDPBrowserManager:
+ """
+ CDP浏览器管理器,负责启动和管理通过CDP连接的浏览器
+ """
+
+ def __init__(self):
+ self.launcher = BrowserLauncher()
+ self.browser: Optional[Browser] = None
+ self.browser_context: Optional[BrowserContext] = None
+ self.debug_port: Optional[int] = None
+
+ async def launch_and_connect(self, playwright: Playwright,
+ playwright_proxy: Optional[Dict] = None,
+ user_agent: Optional[str] = None,
+ headless: bool = False) -> BrowserContext:
+ """
+ 启动浏览器并通过CDP连接
+ """
+ try:
+ # 1. 检测浏览器路径
+ browser_path = await self._get_browser_path()
+
+ # 2. 获取可用端口
+ self.debug_port = self.launcher.find_available_port(config.CDP_DEBUG_PORT)
+
+ # 3. 启动浏览器
+ await self._launch_browser(browser_path, headless)
+
+ # 4. 通过CDP连接
+ await self._connect_via_cdp(playwright)
+
+ # 5. 创建浏览器上下文
+ browser_context = await self._create_browser_context(
+ playwright_proxy, user_agent
+ )
+
+ self.browser_context = browser_context
+ return browser_context
+
+ except Exception as e:
+ utils.logger.error(f"[CDPBrowserManager] CDP浏览器启动失败: {e}")
+ await self.cleanup()
+ raise
+
+ async def _get_browser_path(self) -> str:
+ """
+ 获取浏览器路径
+ """
+ # 优先使用用户自定义路径
+ if config.CUSTOM_BROWSER_PATH and os.path.isfile(config.CUSTOM_BROWSER_PATH):
+ utils.logger.info(f"[CDPBrowserManager] 使用自定义浏览器路径: {config.CUSTOM_BROWSER_PATH}")
+ return config.CUSTOM_BROWSER_PATH
+
+ # 自动检测浏览器路径
+ browser_paths = self.launcher.detect_browser_paths()
+
+ if not browser_paths:
+ raise RuntimeError(
+ "未找到可用的浏览器。请确保已安装Chrome或Edge浏览器,"
+ "或在配置文件中设置CUSTOM_BROWSER_PATH指定浏览器路径。"
+ )
+
+ browser_path = browser_paths[0] # 使用第一个找到的浏览器
+ browser_name, browser_version = self.launcher.get_browser_info(browser_path)
+
+ utils.logger.info(f"[CDPBrowserManager] 检测到浏览器: {browser_name} ({browser_version})")
+ utils.logger.info(f"[CDPBrowserManager] 浏览器路径: {browser_path}")
+
+ return browser_path
+
+ async def _launch_browser(self, browser_path: str, headless: bool):
+ """
+ 启动浏览器进程
+ """
+ # 设置用户数据目录(如果启用了保存登录状态)
+ user_data_dir = None
+ if config.SAVE_LOGIN_STATE:
+ user_data_dir = os.path.join(
+ os.getcwd(), "browser_data",
+ f"cdp_{config.USER_DATA_DIR % config.PLATFORM}"
+ )
+ os.makedirs(user_data_dir, exist_ok=True)
+ utils.logger.info(f"[CDPBrowserManager] 用户数据目录: {user_data_dir}")
+
+ # 启动浏览器
+ self.launcher.browser_process = self.launcher.launch_browser(
+ browser_path=browser_path,
+ debug_port=self.debug_port,
+ headless=headless,
+ user_data_dir=user_data_dir
+ )
+
+ # 等待浏览器准备就绪
+ if not self.launcher.wait_for_browser_ready(
+ self.debug_port, config.BROWSER_LAUNCH_TIMEOUT
+ ):
+ raise RuntimeError(f"浏览器在 {config.BROWSER_LAUNCH_TIMEOUT} 秒内未能启动")
+
+ async def _connect_via_cdp(self, playwright: Playwright):
+ """
+ 通过CDP连接到浏览器
+ """
+ cdp_url = f"http://localhost:{self.debug_port}"
+ utils.logger.info(f"[CDPBrowserManager] 正在通过CDP连接到浏览器: {cdp_url}")
+
+ try:
+ # 使用Playwright的connectOverCDP方法连接
+ self.browser = await playwright.chromium.connect_over_cdp(cdp_url)
+
+ if self.browser.is_connected():
+ utils.logger.info("[CDPBrowserManager] 成功连接到浏览器")
+ utils.logger.info(f"[CDPBrowserManager] 浏览器上下文数量: {len(self.browser.contexts)}")
+ else:
+ raise RuntimeError("CDP连接失败")
+
+ except Exception as e:
+ utils.logger.error(f"[CDPBrowserManager] CDP连接失败: {e}")
+ raise
+
+ async def _create_browser_context(self, playwright_proxy: Optional[Dict] = None,
+ user_agent: Optional[str] = None) -> BrowserContext:
+ """
+ 创建或获取浏览器上下文
+ """
+ if not self.browser:
+ raise RuntimeError("浏览器未连接")
+
+ # 获取现有上下文或创建新的上下文
+ contexts = self.browser.contexts
+
+ if contexts:
+ # 使用现有的第一个上下文
+ browser_context = contexts[0]
+ utils.logger.info("[CDPBrowserManager] 使用现有的浏览器上下文")
+ else:
+ # 创建新的上下文
+ context_options = {
+ "viewport": {"width": 1920, "height": 1080},
+ "accept_downloads": True,
+ }
+
+ # 设置用户代理
+ if user_agent:
+ context_options["user_agent"] = user_agent
+ utils.logger.info(f"[CDPBrowserManager] 设置用户代理: {user_agent}")
+
+ # 注意:CDP模式下代理设置可能不生效,因为浏览器已经启动
+ if playwright_proxy:
+ utils.logger.warning(
+ "[CDPBrowserManager] 警告: CDP模式下代理设置可能不生效,"
+ "建议在浏览器启动前配置系统代理或浏览器代理扩展"
+ )
+
+ browser_context = await self.browser.new_context(**context_options)
+ utils.logger.info("[CDPBrowserManager] 创建新的浏览器上下文")
+
+ return browser_context
+
+ async def add_stealth_script(self, script_path: str = "libs/stealth.min.js"):
+ """
+ 添加反检测脚本
+ """
+ if self.browser_context and os.path.exists(script_path):
+ try:
+ await self.browser_context.add_init_script(path=script_path)
+ utils.logger.info(f"[CDPBrowserManager] 已添加反检测脚本: {script_path}")
+ except Exception as e:
+ utils.logger.warning(f"[CDPBrowserManager] 添加反检测脚本失败: {e}")
+
+ async def add_cookies(self, cookies: list):
+ """
+ 添加Cookie
+ """
+ if self.browser_context:
+ try:
+ await self.browser_context.add_cookies(cookies)
+ utils.logger.info(f"[CDPBrowserManager] 已添加 {len(cookies)} 个Cookie")
+ except Exception as e:
+ utils.logger.warning(f"[CDPBrowserManager] 添加Cookie失败: {e}")
+
+ async def get_cookies(self) -> list:
+ """
+ 获取当前Cookie
+ """
+ if self.browser_context:
+ try:
+ cookies = await self.browser_context.cookies()
+ return cookies
+ except Exception as e:
+ utils.logger.warning(f"[CDPBrowserManager] 获取Cookie失败: {e}")
+ return []
+ return []
+
+ async def cleanup(self):
+ """
+ 清理资源
+ """
+ try:
+ # 关闭浏览器上下文
+ if self.browser_context:
+ await self.browser_context.close()
+ self.browser_context = None
+ utils.logger.info("[CDPBrowserManager] 浏览器上下文已关闭")
+
+ # 断开浏览器连接
+ if self.browser:
+ await self.browser.close()
+ self.browser = None
+ utils.logger.info("[CDPBrowserManager] 浏览器连接已断开")
+
+ # 关闭浏览器进程(如果配置为自动关闭)
+ if config.AUTO_CLOSE_BROWSER:
+ self.launcher.cleanup()
+ else:
+ utils.logger.info("[CDPBrowserManager] 浏览器进程保持运行(AUTO_CLOSE_BROWSER=False)")
+
+ except Exception as e:
+ utils.logger.error(f"[CDPBrowserManager] 清理资源时出错: {e}")
+
+ def is_connected(self) -> bool:
+ """
+ 检查是否已连接到浏览器
+ """
+ return self.browser is not None and self.browser.is_connected()
+
+ async def get_browser_info(self) -> Dict[str, Any]:
+ """
+ 获取浏览器信息
+ """
+ if not self.browser:
+ return {}
+
+ try:
+ version = self.browser.version
+ contexts_count = len(self.browser.contexts)
+
+ return {
+ "version": version,
+ "contexts_count": contexts_count,
+ "debug_port": self.debug_port,
+ "is_connected": self.is_connected()
+ }
+ except Exception as e:
+ utils.logger.warning(f"[CDPBrowserManager] 获取浏览器信息失败: {e}")
+ return {}