基于 DrissionPage 与 Xvfb 的分布式无头爬虫架构

基于 DrissionPage 与 Xvfb 的分布式无头爬虫架构(Ubuntu环境)


一、 整体架构概述

该脚本的设计目标是实现一个在无 GUI 环境(Linux)中运行的高可靠性网页采集器。整体架构采用了“控制流与日志流彻底分离”、“单线程严格物理超时熔断”、“多实例端口级精准隔离”以及“网络数据包拦截与 DOM 节点反向注入”等核心设计模式。

                    +------------------------------------+
                    |  CLI 命令行参数 / 文件列表 (argparse)  |
                    +-----------------+------------------+
                                      |
                                      v
                    +------------------------------------+
                    |  标准输出重定向 sys.stdout -> stderr  |
                    +-----------------+------------------+
                                      |
                                      v
                    +------------------------------------+
                    |  环境预检与 Chrome 端口清理 (lsof)    |
                    +-----------------+------------------+
                                      |
                                      v
                    +------------------------------------+
                    |    启动 Xvfb 虚拟显示服务 (端口)     |
                    +-----------------+------------------+
                                      |
                                      v
                    +------------------------------------+
                    | ChromiumOptions 配置 / WebPage 初始化|
                    +-----------------+------------------+
                                      |
                 +--------------------+--------------------+
                 |                                         |
                 v                                         v
        +------------------+                      +------------------+
        | 动态模式 (Mode d) |                      | 静态模式 (Mode s) |
        | 开启 Listen 监听  |                      | 跳转基准站 (Baidu)|
        | 拦截 API Response|                      | 组装 Header/Cookie|
        +--------+---------+                      +--------+---------+
                 |                                         |
                 +--------------------+--------------------+
                                      |
                                      v
                    +------------------------------------+
                    |  ThreadPoolExecutor 严格超时熔断   |
                    +-----------------+------------------+
                                      |
                                      v
                    +------------------------------------+
                    | 将 API 拦截 JSON 注入 DOM (#Crawler)|
                    +-----------------+------------------+
                                      |
                                      v
                    +------------------------------------+
                    | 原始 Stdout 纯净输出 (sys.__stdout__) |
                    +-----------------+------------------+
                                      |
                                      v
                    +------------------------------------+
                    |  Signal 捕获与 finally 资源优雅回收  |
                    +------------------------------------+


二、 核心技术亮点与设计模式分析

1. 标准输出与日志流的彻底隔离 (IO Cleanliness)

  • 实现机制:代码开篇即执行 sys.stdout = sys.stderr
  • 业务价值:在管道化(Piped)或父子进程调用的自动化运维场景中,常规的 print() 输出与日志(Logger)都会被强行推送到标准错误流(stderr)。只有在抓取成功获取到最终 HTML 时,才通过 sys.__stdout__.write(html + "\n") 将纯净的 HTML 写入原始标准输出。
  • 效果:上游调用方(如 Shell 脚本、Node.js 或 Go 服务)可以直接通过管道捕捉 stdout 获取纯净数据,完全不会混入任何日志干扰。

2. 基于线程池的严格物理超时熔断 (Anti-Deadlock)

  • 问题背景:在 Chromium 底层协议中,当代理失效或网络发生 TCP 挂起时,DrissionPage 传入的 timeout 参数可能因 Chromium 底层死锁而失效,导致爬虫卡死在 page.get() 步骤。
  • 解决方案:引入 ThreadPoolExecutor(max_workers=1) 全局单例线程池,在 _strict_get 方法中提交 page.get 任务:
future = self.executor.submit(self.page.get, url, **kwargs)
return future.result(timeout=self.timeout_scope + 2)

  • 熔断处理:若超过 timeout_scope + 2 秒未能返回,强行捕获 TimeoutError 并触发 self.page.stop_loading(),强制中断浏览器加载并释放卡死的后台线程。

3. 多实例隔离与进程级精准清理

  • 避免误杀:移除传统的 pkill -f chrome 粗暴清理方式,改用定向端口查找:
lsof -ti:<chrome_port> | xargs -I {} kill -9 {}

  • 并发友好:允许在同一台服务器上开启多个不同端口(如 9099、9100)的爬虫实例,互不干扰。同时通过 Xvfb 独立虚拟显示端口(display_port)实现完全隔离的无头渲染环境。

4. 系统信号监听与优雅退出 (Graceful Shutdown)

  • 通过 signal.signal() 注册 SIGINT (Ctrl+C) 和 SIGTERM (Kill 信号) 的监听函数 _signal_handler
  • 当接收到退出指令时,修改标志位 stop_requested = True 并抛出 SystemExit(0),强制打断当前正在遍历的 URL 队列,直达 finally 块释放 Chrome 实例与 Xvfb 进程,防止产生孤儿进程(Orphan Process)。

5. API 拦截与 DOM 反向注入 (Data Enrichment)

  • 在动态模式下,利用 DrissionPage 的 listen 监听网路数据包。
  • 当捕获到匹配 listen_stop_tag 标识符的异步响应(XHR/Fetch)时,调用 add_div2html 方法,通过注入 JavaScript 代码(run_js_loaded)在页面底部追加 <div id="Crawler"> 节点,并将拦截到的 JSON 结构数据以字符串方式挂载在该节点下。
  • 效果:将异步渲染的接口数据与前端渲染后的静态 DOM 一体化,最终合并为一个完整的 HTML 交付。

三、 核心类与方法拆解

全案围绕核心类 DrissionPageCrawler 展开,下表详述其内部方法及职能:

方法名称核心职能说明
__init__参数初始化,建立线程池,注册信号监听器,调用预检与清理,启动 Xvfb 服务。
_register_signals捕获系统的 SIGINTSIGTERM 信号,确保服务能被操作系统安全终止。
_signal_handler信号响应逻辑,标记退出状态并抛出 SystemExit 触发资源回收。
pre_check检查 Linux 系统中是否存在 google-chrome 依赖,无依赖则阻断抛错。
kill_chrome精准清理指定 chrome_port 端口上残留的 Chromium 进程。
clear_user_data清理并重置 Chrome 用户数据目录(user_data_path),确保 Session 隔离。
init_xvfb启动 Xvfb 虚拟显示服务(**依赖本地自定义工具类 InitXCFB** ⚠️)。
init_chrome_config构建 ChromiumOptions,配置代理、User-Agent、无头参数(--no-sandbox, --disable-gpu)及防检测配置。
init_page实例化 WebPage,并绑定全局加载和脚本超时设置。
_strict_get核心安全屏障。采用线程池包裹请求,实现硬性物理超时截断与 stop_loading 强制止损。
add_div2html运行 JS 将拦截到的网络请求 JSON、抓取时间戳等数据反向注入至 HTML 的 DOM 结构中。
analysis_listen遍历网络数据包(listen.steps),匹配特征 URL,完成数据抽取与注入。
get_html_d动态模式执行引擎:启动 Listen -> 发起严格请求 -> 执行页面 JS -> 提取网络包 -> 返回 HTML。
get_html_s静态模式执行引擎:先访问百度基准站建立基础环境 -> 切换模式装载 Headers/Cookies/Proxies -> 请求数据 -> 返回 HTML。
init_arguments解析并校验外部传入的批量 URL、代理、User-Agent、Headers、Cookies 等配置。
process主逻辑管道:执行生命周期流转、URL 循环抓取、数据输出及 finally 块终极资源清理。

四、 抓取模式深度对比 (Dynamic vs. Static)

脚本支持两种不同抓取模式,通过参数 -m / --mode 控制:

评估维度动态模式 (mode="d")静态模式 (mode="s")
适用目标重度 SPA 应用、需要完整渲染 DOM 且依赖异步 API 的网页。静态网页,或只需携带 Header/Cookie 即可直出内容的网页。
反爬对抗策略使用完整 Chrome 浏览器加载,注入真实 UA 与代理,直接执行 JS。先跳转至基准站(baidu.com)伪装真实访问轨迹,随后调用 change_mode(go=False) 切换为轻量静态请求。
数据补充方式开启 Chromium 网络监听,将捕捉到的 JSON 注入到 DOM 树底部。构造包含 Cookies、Headers 及代理的自定义静态 Request 任务。
系统开销较高(需持续维持 GUI 渲染与 JS 事件循环)。较低(减少二次渲染开销)。

五、 命令行参数与文件批量输入机制

__main__ 入口处提供了强大的参数解析能力,支持命令行直接传参和 .txt 文件批量导入混合使用:

参数映射与加载逻辑

  命令行单值/多值参数                             txt 文本文件输入 (一行一个)
  -U / --urls                                    -Uf / --urls_file
  -l / --listen                                  -lf / --listen_file
  -x / --proxy                                   -xf / --proxy_file
  -ua / --ua                                     -uaf / --ua_file
  -H / --headers (JSON字符串)                    -Hf / --headers_file (一行一个JSON)
  -c / --cookies (JSON字符串)                    -cf / --cookies_file (一行一个JSON)
                          \                        /
                           \                      /
                            v                    v
                        [ read_file_lines / parse_json ]
                                       |
                                       v
                           合并生成最终数组/字典供 process() 调用


六、 部署环境要求与依赖 Checklist

要使该脚本顺利运行,系统必须安装并配置以下组件:

1. 系统级依赖包 (Linux Standard Packages)

  • google-chrome-stable:谷歌浏览器内核。
  • xvfb:无显卡环境下的虚拟 Display 服务。
  • lsof:用于精准匹配端口并清理残留进程。

安装命令:

sudo apt-get update
sudo apt-get install -y google-chrome-stable xvfb lsof

2. Python 第三方依赖包

pip install DrissionPage urllib3

3. 项目内部自定义模块清单

注意:代码中引用了自定义模块 utilsbase,部署时需确保以下模块在 Python Path 中可被加载 ⚠️

  • base.Base:日志基类,提供 self.logger 示例。
  • utils.RunCMD:封装 Shell 命令执行的工具类。
  • utils.InitXCFB:负责启动和管理 Xvfb 虚拟显示的工具类。
  • utils.UtilSecret:提供 UUID 等生成算法。
  • utils.UtilTime:时间戳格式化工具。

七、 典型命令行使用示例

典型场景 1:动态模式抓取单个 URL 并监听特定的 API 接口

python3 crawler_by_dp.py \
  -m d \
  -p 9099 \
  -dp 99 \
  -t 20 \
  -U "https://example.com/data-list" \
  -l "api/v1/get_info"

典型场景 2:基于文本文件进行批量静态代理抓取

假设 urls.txt 存有目标 URL 列表,proxies.txt 存有代理列表:

python3 crawler_by_dp.py \
  -m s \
  -p 9100 \
  -dp 100 \
  -Uf ./urls.txt \
  -xf ./proxies.txt \
  -H '{"User-Agent": "Custom-App-Agent"}' \
  > output.html

提示:运行后,所有的调试 Log 将输出到终端 stderr,而抓取结果将通过重定向 > 干净地写入到 output.html 文件中。

# !/usr/bin/python3
# -*- coding:utf-8 -*-
"""
@author: JHC000abc@gmail.com
@file: crawler_by_dp.py
@time: 2026/7/31 23:37 
@desc: 
sudo apt-get update
sudo apt-get install -y xvfb lsof
"""
import json
import os
import random
import traceback
import argparse
import signal
import sys
import concurrent.futures
from DrissionPage import WebPage
from DrissionPage import ChromiumOptions
from utils import RunCMD, InitXCFB, UtilSecret, UtilTime
from base import Base
import urllib3

# 全局屏蔽 InsecureRequestWarning 警告
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# 【关键修改】:将当前进程的所有标准输出(print等)强制重定向到标准错误(stderr)
# 确保常规日志不会污染标准输出,只有最终的 HTML 才能进入 stdout 被调用方纯净获取
sys.stdout = sys.stderr


class DrissionPageCrawler:
    """

    """

    def __init__(self, save_folder="./htmls", timeout_scope=30, chrome_port=9099, user_data_path=None,
                 new_user_data_path=True, mode="d", display_port="99"):
        self.logger = Base(log_name="DrissionPageCrawler").logger
        self.save_folder = save_folder
        self.timeout_scope = timeout_scope
        self.chrome_port = chrome_port

        self.user_data_path = user_data_path
        self.new_user_data_path = new_user_data_path
        self.mode = mode
        self.display_port = display_port
        self.page = None

        # 全局初始化单例线程池,避免每次请求重复创建消耗资源
        self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)

        self.logger.info(
            f"初始化爬虫实例参数: 模式={self.mode}, 超时={self.timeout_scope}s, 端口={self.chrome_port}")

        # 注册系统 signal 信号监听机制
        self.stop_requested = False
        self._register_signals()

        self.pre_check()
        self.kill_chrome()

        self.xvfb_process = self.init_xvfb()

    def _register_signals(self):
        """
        注册系统 signal 信号监听器 (SIGINT / SIGTERM)
        """
        try:
            signal.signal(signal.SIGINT, self._signal_handler)
            signal.signal(signal.SIGTERM, self._signal_handler)
            self.logger.info("系统信号监听器 (SIGINT/SIGTERM) 注册成功")
        except Exception as e:
            self.logger.warning(f"信号注册失败: {e}")

    def _signal_handler(self, signum, frame):
        """
        signal 信号触发时的统一安全退出处理
        """
        sig_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else str(signum)
        self.logger.info(f"接收到系统 signal 信号: {sig_name} ({signum}),开始优雅退出并清理资源...")
        self.stop_requested = True
        # 抛出 SystemExit 强行打断当前阻塞流程,直达 process 的 finally 块释放资源
        raise SystemExit(0)

    def pre_check(self):
        """

        :return:
        """
        self.logger.info("开始执行系统环境预检...")
        cmd = "which google-chrome"
        self.chrome_path = RunCMD.run(cmd).strip()
        if "命令执行失败" in self.chrome_path:
            self.logger.error("未检测到 google-chrome,预检失败")
            raise Exception("请安装谷歌浏览器")
        self.logger.info(f"环境预检通过,Chrome 路径: {self.chrome_path}")

    def kill_chrome(self):
        """

        :return:
        """
        self.logger.info(f"尝试清理残留的 Chrome 进程 (端口: {self.chrome_port})...")
        # 【关键修改】:移除了 pkill -f chrome,防止在多实例并发抓取时误杀其他端口的浏览器,仅通过端口精准定向清理
        cmd = "lsof -ti:" + f"{self.chrome_port}" + " | xargs -I {} kill -9 {}"
        RunCMD.run(cmd)
        self.logger.info(f"端口 {self.chrome_port} 残留 Chrome 进程清理完毕")

    def clear_user_data(self, co: ChromiumOptions):
        """

        :param co:
        :return:
        """
        if self.user_data_path:
            self.logger.info(f"配置 Chrome 用户数据路径: {self.user_data_path}")
            if self.new_user_data_path:
                self.logger.info("正在清理并初始化新的用户数据空间...")
                cmd = f"rm -rf {self.user_data_path}"
                # 【关键修改】:移除了 asyncio.run,因为整个脚本已回归同步架构
                RunCMD.run(cmd)
                co.new_env(True)
            co.set_user_data_path(self.user_data_path)

    def init_xvfb(self):
        """

        :return:
        """
        self.logger.info(f"正在启动 Xvfb 虚拟显示服务 (端口: {self.display_port})...")
        return InitXCFB(f"{self.display_port}", self.chrome_port).xvfb_process

    def init_chrome_config(self):
        """

        :return:
        """
        self.logger.info("正在初始化 ChromiumOptions 配置...")
        co = ChromiumOptions()
        if self.chrome_path:
            co.set_browser_path(self.chrome_path)
        if self.user_agent:
            ua = random.choice(self.user_agent)
            self.logger.info(f"设置随机 User-Agent: {ua}")
            co.set_user_agent(ua)
        if self.chrome_port:
            co.set_local_port(self.chrome_port)
        # 插件 add_extension()

        if self.proxy:
            self.logger.info(f"配置浏览器代理: http://{self.proxy}")
            co.set_proxy(f"http://{self.proxy}")
        co.mute(True)
        co.incognito()
        co.set_argument('--guest')
        co.set_argument('--window-size', '800,600')
        co.set_argument('--no-sandbox')
        co.set_argument('--disable-gpu')
        co.set_argument('--disable-dev-shm-usage')
        co.set_argument('--remote-allow-origins=*')
        # 禁止所有弹出窗口
        co.set_pref(arg='profile.default_content_settings.popups', value='0')
        # 是否忽略证书错误
        co.ignore_certificate_errors()
        self.logger.info("ChromiumOptions 配置初始化完毕")
        return co

    def init_page(self, co: ChromiumOptions):
        """

        :param co:
        :return:
        """
        if not self.page:
            self.logger.info("正在创建 DrissionPage WebPage 实例...")
            self.page = WebPage(chromium_options=co)
            # 强制设定底层页面的全局加载和脚本注入超时,防止挂起死锁
            self.page.set.timeouts(page_load=self.timeout_scope, script=self.timeout_scope)
            self.logger.info(f"WebPage 实例创建成功,已设置全局超时: {self.timeout_scope}s")

    def _strict_get(self, url, **kwargs):
        """
        基于线程级别的严格超时熔断请求方法
        解决无代理时,底层 Chromium TCP死锁导致 DrissionPage 的 timeout 参数失效的问题。
        """
        kwargs["timeout"] = self.timeout_scope
        kwargs["retry"] = 0  # 必须禁用自动重试,防止翻倍产生冗长的等待时间

        # 直接复用全局实例化的线程池
        future = self.executor.submit(self.page.get, url, **kwargs)
        try:
            # 设定严格的物理超时时间(给原生超时机制多留 2 秒作为宽限期)
            return future.result(timeout=self.timeout_scope + 2)
        except concurrent.futures.TimeoutError:
            self.logger.warning(
                f"【严格熔断】底层请求在 {self.timeout_scope + 2} 秒内无响应,可能发生网络死锁,强行中止: {url}")
            try:
                # 强行中止浏览器当前加载任务,瞬间释放后台被卡死挂起的请求线程
                self.page.stop_loading()
            except Exception:
                pass
            return False
        except Exception as e:
            self.logger.error(f"页面请求发生内部异常: {e}")
            return False

    def add_div2html(self, url, index=0, resp={}, feature=""):
        """

        :param url:
        :param index:
        :param resp:
        :param feature:
        :return:
        """
        self.logger.info(f"将拦截到的第 {index} 个监听数据注入页面 HTML 中...")
        crawler_time = UtilTime.time2str(UtilTime.get_shanghai_time())
        resp.update({
            "crawler_time": crawler_time,
            "crawler_url": url,
            "feature": feature
        })
        self.page.run_js_loaded("""var container = document.getElementById('crawler');
                               if (!container) {
                                   container = document.createElement('div');
                                   container.id = 'Crawler';
                                   document.body.appendChild(container);
                               }
                               var div = document.createElement('div');
                               div.id = 'crawler-hook-""" + f"{index}" + """';
                               div.innerText = '""" + f"{json.dumps(resp, indent=None, ensure_ascii=False)}" + """';
                               container.appendChild(div);
                               var br = document.createElement('br');
                               container.appendChild(br);
                               """)

    def analysis_listen(self, url):
        """

        :param url:
        :return:
        """
        self.logger.info(f"开始分析网络请求监听数据:{self.listen_stop_tag},超时范围: {self.timeout_scope}s")
        index = 0
        set_listen_stop_tag = set(self.listen_stop_tag)
        record_set_listen_stop_tag = set()
        for packet in self.page.listen.steps(timeout=self.timeout_scope):
            if self.stop_requested:
                self.logger.info("检测到停止信号,中断请求监听分析")
                return
            feature_match = [i in packet.url for i in self.listen_stop_tag]
            if any(feature_match):
                self.logger.info(f"识别到达监听标签:{packet.url}")
                resp = packet.response.body
                index += 1
                resp = {"response": {} if not resp else resp}
                feature = self.listen_stop_tag[feature_match.index(True)]
                record_set_listen_stop_tag.add(feature)

                self.add_div2html(url, index, resp, feature)
                diff_set = set_listen_stop_tag - record_set_listen_stop_tag
                if index >= len(self.listen_stop_tag) and not diff_set:
                    self.logger.info("所有预期监听标签已全部捕获,结束当前分析任务")
                    return
        self.logger.info("请求监听分析环节结束或超时")
        self.add_div2html(url)

    def get_html_d(self, url):
        """

        :param url:
        :return:
        """
        self.logger.info(f"[动态模式-d] 开始处理目标 URL: {url}")
        if not self.listen_stop_tag or len(self.listen_stop_tag) <= 0:
            self.logger.info("未配置监听标签,直接访问并返回页面 HTML")
            is_success = self._strict_get(url)
            if not is_success:
                self.logger.warning(f"页面请求失败或超时熔断,避免产生脏数据,返回空: {url}")
                return ""
            self.add_div2html(url)
            return self.page.html

        self.logger.info("启动页面网络请求监听器...")
        self.page.listen.start()

        is_success = self._strict_get(url)
        if not is_success:
            self.logger.warning(f"页面请求失败或超时熔断,停止监听并返回空: {url}")
            self.page.listen.stop()
            return ""

        self.page.run_js_loaded("""console.log('页面加在完成');""")

        self.analysis_listen(url)

        self.page.listen.stop()
        self.logger.info("[动态模式-d] 处理完毕,返回带有注入数据的页面 HTML")
        return self.page.html

    def get_html_s(self, url):
        """

        :param url:
        :return:
        """
        self.logger.info(f"[静态模式-s] 开始处理目标 URL: {url}")
        base_url = "https://www.baidu.com"
        self.page.listen.start()
        if self.page.title != "百度一下,你就知道" or "www.baidu.com" not in self.page.url:
            self.logger.info(f"当前未在基础防屏蔽环境,正在跳转基准站: {base_url}")
            self._strict_get(base_url)
        self.page.run_js_loaded("""console.log('初始化页面加在完成');""")

        if not self.listen_stop_tag or len(self.listen_stop_tag) <= 0:
            self.logger.info("未配置监听标签,直接请求并返回目标 HTML")
            is_success = self._strict_get(url)
            if not is_success:
                self.logger.warning(f"页面请求失败或超时熔断,避免产生脏数据,返回空: {url}")
                return ""
            self.page.run_js_loaded("""console.log('页面加在完成');""")
            return self.page.html

        self.logger.info("切换页面模式,准备附带 headers/cookies 构造静态请求...")
        self.page.change_mode(go=False)

        req_kwargs = {}
        proxies = self.proxies
        if proxies:
            req_kwargs.update({"proxies": proxies})
            self.logger.info(f"装载请求代理: {proxies}")
        if self.cookies:
            cookies = random.choice(self.cookies)
            req_kwargs.update({"cookies": cookies})
            self.logger.info("装载请求 Cookies")
        if self.user_agent:
            random_ua = random.choice(self.user_agent)
            headers = {"User-Agent": random_ua}
            if self.headers:
                random_header = random.choice(self.headers)
                headers.update(random_header)
            req_kwargs.update({"headers": headers})
            self.logger.info("装载请求 Headers")
        if self.timeout_scope:
            req_kwargs.update({"timeout": self.timeout_scope})
        req_kwargs.update({"verify": False})

        is_success = self._strict_get(url, **req_kwargs)
        if not is_success:
            self.logger.warning(f"页面请求失败或超时熔断,停止监听并返回空: {url}")
            self.page.listen.stop()
            return ""

        self.analysis_listen(url)

        self.page.listen.stop()
        self.logger.info("[静态模式-s] 处理完毕,返回页面 HTML")
        return self.page.html

    def init_arguments(self, data: dict):
        """

        :param data:
        :return:
        """
        self.logger.info("开始解析装载外部传入的参数列表...")
        self.urls = data.get("urls", [])
        if not self.urls:
            self.logger.error("缺少必要参数: urls 为空")
            raise Exception("请传入urls")

        self.listen_stop_tag = data.get("listen_stop_tag", [])
        self.proxys = data.get("proxys", [])
        self.proxies = None
        self.proxy = None
        if self.proxys:
            self.proxy = random.choice(self.proxys)
            self.proxies = {'http': f'{self.proxy}', 'https': f'{self.proxy}'}

        self.user_agent = data.get("user_agent", [])
        self.headers = data.get("headers", [])
        self.cookies = data.get("cookies", [])
        self.logger.info(f"参数解析完毕: URL共 {len(self.urls)} 条, 监听标签 {len(self.listen_stop_tag)} 个")

    # 【关键修改】:移除了 async 声明,回归纯同步方法
    def process(self, *args, **kwargs):
        """

        :param args:
        :param kwargs:
        :return:
        """
        self.logger.info("============== 爬虫 Process 主任务流开始 ==============")
        self.init_arguments(kwargs)
        uid = UtilSecret.make_uuid4()
        self.logger.info(f"当前任务分配 UID: {uid}")
        try:
            self.pre_check()
            self.kill_chrome()
            co = self.init_chrome_config()
            self.clear_user_data(co)
            self.init_page(co)

            if self.cookies:
                self.logger.info("向页面实例注入随机 Cookies")
                self.page.set.cookies(random.choice(self.cookies))
            if self.headers:
                self.logger.info("向页面实例注入随机 Headers")
                self.page.set.headers(random.choice(self.headers))

            self.logger.info("准备开始遍历执行所有的 URL 任务...")
            for index, url in enumerate(self.urls, 1):
                if self.stop_requested:
                    self.logger.info(f"任务已被信号强行终止,停止处理后续 URL (第 {index} 个)")
                    break

                self.logger.info(f">>> 当前进度: [{index}/{len(self.urls)}] URL: {url}")
                if self.mode == "d":
                    html = self.get_html_d(url)
                elif self.mode == "s":
                    html = self.get_html_s(url)
                else:
                    self.logger.error(f"不支持的模式标识: {self.mode}")
                    raise Exception("请选择模式 只支持(s/d)")

                if not html:
                    self.logger.warning(f"获取的 HTML 为空,输出空字符串 (URL: {url})")
                    html = ""

                # 【关键修改】:抛弃本地文件写入,直接向最原始的系统标准输出打印 HTML 内容
                # 使用 sys.__stdout__ 绕开代码顶部的标准输出重定向拦截
                sys.__stdout__.write(html + "\n")
                sys.__stdout__.flush()

        except (KeyboardInterrupt, SystemExit):
            self.logger.info("捕获到中断/退出 signal 信号,正在完成收尾清理机制...")
        except Exception as e:
            traceback.print_exc()
            self.logger.error(f"执行期间出现未捕获异常:\n{traceback.format_exc()}")
        finally:
            self.logger.info("============== 爬虫任务结束,进入资源回收环节 ==============")
            if hasattr(self, 'executor') and self.executor:
                self.logger.info("销毁全局严格熔断线程池...")
                self.executor.shutdown(wait=False)

            if self.page:
                self.logger.info("关闭 WebPage 浏览器实例...")
                self.page.close()
            if self.xvfb_process:
                self.logger.info("终止 Xvfb 虚拟显示进程...")
                self.xvfb_process.terminate()
            self.kill_chrome()
            self.logger.info("所有资源回收完毕,程序退出。")


if __name__ == '__main__':
    def parse_json(value):
        """将JSON字符串转换为字典"""
        if isinstance(value, dict):
            return value
        try:
            return json.loads(value)
        except Exception as e:
            raise argparse.ArgumentTypeError(f"无效的JSON格式: {value}")


    def str2bool(v):
        """将命令行传入的字符串转换为布尔值"""
        if isinstance(v, bool):
            return v
        if v.lower() in ('yes', 'true', 't', 'y', '1'):
            return True
        elif v.lower() in ('no', 'false', 'f', 'n', '0'):
            return False
        else:
            raise argparse.ArgumentTypeError('期待布尔值输入 (如 true 或 false)。')


    def read_file_lines(file_path):
        """读取txt文件内容,按行返回非空字符串列表"""
        if not file_path:
            return []
        if not os.path.exists(file_path):
            raise argparse.ArgumentTypeError(f"找不到指定的参数文件: {file_path}")
        with open(file_path, "r", encoding="utf-8") as f:
            return [line.strip() for line in f if line.strip()]


    parser = argparse.ArgumentParser(description="DrissionPageCrawler 命令行参数配置")

    # Init 参数
    parser.add_argument("-o", "--out", dest="save_folder", type=str, default="./htmls",
                        help="[废弃] 保存目录已废弃,直接输出到Stdout")
    parser.add_argument("-t", "--timeout", dest="timeout_scope", type=int, default=30,
                        help="[基础] 超时时间范围/秒 (默认: 30)")
    parser.add_argument("-p", "--port", dest="chrome_port", type=int, default=9099,
                        help="[基础] Chrome 本地调试端口 (默认: 9099)")
    parser.add_argument("-u", "--user_data", dest="user_data_path", type=str, default=None,
                        help="[基础] Chrome 用户数据路径")
    parser.add_argument("-n", "--new_env", dest="new_user_data_path", type=str2bool, default=True,
                        help="[基础] 是否清理并使用新的用户数据路径 (默认: True)")
    parser.add_argument("-m", "--mode", dest="mode", type=str, default="d", choices=["d", "s"],
                        help="[基础] 运行模式:d(动态) 或 s(静态) (默认: d)")
    parser.add_argument("-dp", "--disp_port", dest="display_port", type=str, default="99",
                        help="[基础] Xvfb 显示端口 (默认: 99)")

    # URL 参数 (支持多个空格分隔传入 或 txt文件传入)
    parser.add_argument("-U", "--urls", dest="urls", nargs="*", default=[],
                        help="[URL] 目标URL列表,支持空格分隔传入多个")
    parser.add_argument("-Uf", "--urls_file", dest="urlsf", type=str, default=None,
                        help="[URL] 包含URL列表的txt文件路径,一行一个")

    # 监听标签参数
    parser.add_argument("-l", "--listen", dest="listen_stop_tag", nargs="*", default=[],
                        help="[监听] 监听停止标签列表,支持空格分隔传入多个")
    parser.add_argument("-lf", "--listen_file", dest="listen_stop_tagf", type=str, default=None,
                        help="[监听] 包含监听停止标签的txt文件路径,一行一个")

    parser.add_argument("-x", "--proxy", dest="proxys", type=str, default=None,
                        help="[网络] 代理地址(单个字符串,如 127.0.0.1:1080)")
    parser.add_argument("-xf", "--proxy_file", dest="proxysf", type=str, default=None,
                        help="[网络] 包含代理地址列表的txt文件路径,一行一个")

    parser.add_argument("-ua", "--ua", dest="user_agent", type=str, default=None, help="[网络] User-Agent(单个字符串)")
    parser.add_argument("-uaf", "--ua_file", dest="user_agentf", type=str, default=None,
                        help="[网络] 包含User-Agent列表的txt文件路径,一行一个")

    parser.add_argument("-H", "--headers", dest="headers", type=str, default=None,
                        help="[网络] Headers JSON字符串,如 '{\"Auth\":\"token\"}'")
    parser.add_argument("-Hf", "--headers_file", dest="headersf", type=str, default=None,
                        help="[网络] 包含Headers JSON字符串的txt文件路径,一行一个JSON")

    parser.add_argument("-c", "--cookies", dest="cookies", type=str, default=None,
                        help="[网络] Cookies JSON字符串,如 '{\"session\":\"1234\"}'")
    parser.add_argument("-cf", "--cookies_file", dest="cookiesf", type=str, default=None,
                        help="[网络] 包含Cookies JSON字符串的txt文件路径,一行一个JSON")

    args = parser.parse_args()

    # 处理 URLs
    urls = args.urls + read_file_lines(args.urlsf)

    # 处理 listen_stop_tag
    listen_stop_tag = args.listen_stop_tag + read_file_lines(args.listen_stop_tagf)

    # 处理 proxys
    proxys = ([args.proxys] if args.proxys else []) + read_file_lines(args.proxysf)

    # 处理 user_agent
    user_agent = ([args.user_agent] if args.user_agent else []) + read_file_lines(args.user_agentf)

    # 处理 headers
    headers = []
    if args.headers:
        headers.append(parse_json(args.headers))
    if args.headersf:
        headers.extend([parse_json(line) for line in read_file_lines(args.headersf)])

    # 处理 cookies
    cookies = []
    if args.cookies:
        cookies.append(parse_json(args.cookies))
    if args.cookiesf:
        cookies.extend([parse_json(line) for line in read_file_lines(args.cookiesf)])

    crawler = DrissionPageCrawler(
        save_folder=args.save_folder,
        timeout_scope=args.timeout_scope,
        chrome_port=args.chrome_port,
        user_data_path=args.user_data_path,
        new_user_data_path=args.new_user_data_path,
        mode=args.mode,
        display_port=args.display_port
    )

    try:
        # 【关键修改】:移除了 asyncio.run(),直接同步调用
        crawler.process(
            urls=urls,
            listen_stop_tag=listen_stop_tag,
            proxys=proxys,
            user_agent=user_agent,
            headers=headers,
            cookies=cookies
        )
    except (KeyboardInterrupt, SystemExit):
        # 同样输出到 stderr,以免干扰
        sys.stderr.write("\n[INFO] 接收到 signal 退出信号,程序已安全终止,资源清理完毕。\n")

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值