简介:面向Python爬虫学习者的留言板数据采集实战代码,以Selenium模拟浏览器操作结合multiprocessing多进程并行抓取,重点解决登录态维持、AJAX动态加载与反爬绕过等场景。代码包含WebDriver初始化、表单模拟登录、元素等待、留言内容解析、多进程任务分配及CSV落盘等核心模块,适合想在真实站点上练习动态网页爬虫与自动化测试的开发者。压缩包约1.8MB,文件清单暂未在平台展示,主体为Python脚本及少量配置文件;已有589人学习/浏览。通过阅读该项目,读者可掌握Selenium与multiprocessing的配合方式,理解翻页、延迟、异常重试等细节,并以此为基础迁移到其他留言板或评论采集任务。
1. 从“留言板留言”到“可复现代码”:这套 Python 资源到底值不值得拆
做留言板数据采集的人大概率都撞过同一个墙:页面源码里能看到留言总数,可正文内容全是 JavaScript 异步加载出来的,直接用 requests 拉 HTML 什么都拿不到。这篇文章要拆的资源,就是一套基于 Python 的「留言板留言爬虫」,重点是 Selenium 模拟真实浏览器去操作页面,再靠多进程把采集速度拉起来,整体打包成一个多进程版的项目。它解决三类诉求:能不能稳定抓到动态加载的留言、能不能在登录态下持续跑、以及多进程并行时怎么保证数据不丢不乱。适合刚接触 Selenium 的人照着抄作业,也适合想把单进程爬虫改成多进程的开发者参考。下面是完整拆解。
2. 拆解资源结构:Selenium 模拟浏览器与多进程并行的配合逻辑
2.1 为什么留言板场景绕不开 Selenium
先明确一点,这类资源之所以选 Selenium 而不是 requests,核心原因只有一个:目标站点的留言列表不走服务端渲染。常见的留言板系统里,列表页首次返回的 HTML 只包含一个壳,真正的留言内容由后面的 Ajax 接口分批返回,触发方式可能是滚动页面、点击“查看更多”,也可能是翻页参数藏在 JavaScript 变量里。用 requests 去模拟这些请求不是不行,但每次都要逆向接口签名和参数,而且目标站点一旦改了接口格式,整个脚本就废了。
Selenium 的思路是直接在浏览器内核里跑页面,等 DOM 渲染完再提取,和真人操作几乎一致。副作用是每个浏览器实例要占几百 MB 内存,跑多个并发就会吃紧,这正是资源里引入多进程的原因。我一般会先确认目标页面的网络请求里是否有 JSON 接口,如果有且不加密,requests 反而是更省资源的方案;只有当接口被加密、或者页面交互过于复杂才上 Selenium。这套资源选择 Selenium,说明设计者默认用户面对的是后者。
判断依据其实很直观:打开浏览器开发者工具,切到 Network 面板,刷新列表页。如果 XHR 请求里能看到返回留言 JSON 的接口,并且接口没有签名参数,那 requests 完全够用;如果接口响应是加密数组、或者所有数据都内联在一个 JS 变量里,那老老实实用 Selenium。留言板这类场景还有一个特殊点:很多站点会对每页数量做服务端限制,但“加载更多”按钮是靠前端累加偏移量触发的,这类交互逻辑用 requests 模拟容易漏参数,Selenium 点按钮则天然契合。
2.2 资源目录结构与各文件职责
拿到压缩包解压后,核心文件一般是下面这张表。建议先按这张表确认每个文件的角色,避免后面跑的时候连入口都找不到。
| 文件 | 职责 | 运行阶段 |
|---|---|---|
| main.py | 进程池入口,负责调度 | 主动运行 |
| login.py | 登录并序列化 cookies | 首次运行 |
| worker.py | 单个进程内的抓取循环 | 由 main 调度 |
| parse.py | 留言内容解析与清洗 | 被 worker 调用 |
| config.py | URL、选择器、进程数等参数 | 公共配置 |
| requirements.txt | 依赖清单 | 安装阶段 |
config.py 是这类资源里最值得先读的文件。进程数、目标 URL、XPath 选择器、超时时间通常都集中在这里。多进程版的爬虫最忌讳把参数写死在各个 worker 里,后期想调并发数就得满文件翻代码。我拿到这类资源的第一件事永远是打开 config.py 通读一遍,确认三个东西:入口 URL 是不是目标站点、选择器是否匹配当前页面结构、进程数和超时时间是否跟机器配置匹配。
还有一点容易被忽略:压缩包里一般不会内置对应版本的 ChromeDriver,驱动需要按本机 Chrome 版本自己下载,README 里如果没写清楚版本匹配关系,后面大概率会踩第 5 章里那个“找不到浏览器二进制”的坑。文件清单里如果有 README.md,先读它,里面一般记录了作者实测时的 Python 版本和浏览器版本,这两个信息比任何注释都值钱。
2.3 多进程并行模型:进程池与浏览器实例分配
这套资源的多进程模型不是用线程池并发切任务,而是用 multiprocessing 直接拉起多个进程,每个进程维护一个独立的 WebDriver 实例。这个选择是合理的,因为 WebDriver 操作的是浏览器进程,线程之间共享同一个 driver 时会出现页面窗口互相干扰的问题;每个进程各自开一个浏览器,反而隔离得干净。线程模型在爬虫场景的另一个痛点是 GIL,虽然 Selenium 的等待时间大部分花在 IO 上,但多线程下 driver 的 execute 调用并不总是线程安全,与其在共享状态上做各种加锁,不如直接进程隔离。
进程池骨架大致长这样:
import multiprocessing from worker import run_worker def main(): # 进程数建议设置为 2~4,视机器内存而定 pool = multiprocessing.Pool(processes=3) page_urls = build_url_list() # 生成待抓取的入口 URL for url in page_urls: pool.apply_async(run_worker, args=(url,)) pool.close() pool.join()逻辑说明:这里用 apply_async 把每个页面 URL 作为独立任务丢给进程池,worker 内部自己去控制当前页内“加载更多”的点击逻辑。进程数的选择看内存而不是 CPU,每个浏览器实例峰值占用约 300MB,3 个进程加上系统开销,8GB 内存的机器跑起来已经有点吃紧。参数上注意 Pool 的 processes 不要超过 4,否则页面频繁切换会触发目标站点的访问频率检测。
其实还有一种更省内存的做法是每个 worker 里复用同一个 driver 循环处理多条 URL,而不是每条 URL 新开一个浏览器进程,但资源作者选的是进程池,好处是某个页面崩了只影响一个 worker,坏处是频繁创建和销毁浏览器实例会拖慢整体速度。实际跑的时候我倾向于把每批 URL 控制在 20~50 条,让一个 worker 一个 driver 连续处理完再退出,既保留进程隔离的容错性,又避免反复启动浏览器的开销。
每个 worker 内部的 driver 生命周期也需要约定:初始化阶段完成浏览器 option 设置、加载 cookies、预访问目标域名;运行阶段循环抓取;结束阶段必须 driver.quit() 释放内存。很多人在多进程爬虫里忘了 quit,导致一轮跑完内存不降,跑几轮就被系统 OOM killer 杀掉,这是进程池模式最典型的资源泄漏点。
3. 跑通第一轮抓取:环境准备、登录态保持与首轮循环
3.1 环境准备:虚拟环境、依赖与驱动
拿到资源后第一步不是直接跑 main.py,而是先把环境拉齐。这套项目依赖的库集中在 requests、selenium、lxml,外加 pandas 用于导出。建议用虚拟环境隔离,避免和本机其他项目打架。
python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install --upgrade pip pip install -r requirements.txt参数说明:requirements.txt 里一般没有锁定 ChromeDriver,因为它属于二进制文件,不通过 pip 分发。安装完成后需要到浏览器设置里查看 Chrome 版本号,然后去对应渠道下载同主版本的 ChromeDriver,把可执行文件放到 PATH 或项目根目录。这一步不做,后面启动时大概率会收到 “WebDriverException: unknown error: cannot find Chrome binary” 之类的报错。
注意:chromedriver 不随 pip 分发,必须和本机 Chrome 主版本完全一致,否则启动阶段就会报版本不匹配错误。
我一般会在装机后顺手写一个 driver 路径检测函数,启动时自动检查 chromedriver 是否可用,不可用就直接提示,省得程序跑了几分钟才暴露问题。
import shutil from selenium import webdriver from selenium.webdriver.chrome.options import Options def init_driver(): driver_path = shutil.which("chromedriver") if not driver_path: raise RuntimeError("未找到 chromedriver,请先下载并加入 PATH") options = Options() # 无头模式只适合调试,正式抓取建议保留界面 # options.add_argument("--headless=new") options.add_argument("--disable-gpu") options.add_argument("--window-size=1400,900") return webdriver.Chrome(executable_path=driver_path, options=options)逻辑说明:shutil.which 的作用是在 PATH 里查找可执行文件,找不到直接抛异常,把问题暴露在启动阶段而不是运行中间。disable-gpu 和无头参数主要用于服务器环境;留言板交互场景里有“加载更多”按钮,无头模式偶尔会出现在视口外的元素不可点击问题,所以我一般建议保留界面跑,至少在验证阶段不要开无头。
3.2 登录态保持:cookies 序列化与复用
留言板站点通常要求登录后才能查看完整留言或避免频繁的验证码。手动每次登录不现实,这套资源采用的做法是首次手动登录一次,然后把 cookies 落盘,之后每次启动直接加载。关键在于选择正确的存储字段:Selenium 的 get_cookies() 返回的是完整结构,重新 add_cookie 时有些字段会失效,常见处理是只保留 name、value、domain、path 四项。
import json from selenium import webdriver def save_cookies(driver, path="cookies.json"): cookies = driver.get_cookies() # 只保留核心字段,避免 session 类字段被持久化后过期 slim = [ {"name": c["name"], "value": c["value"], "domain": c.get("domain"), "path": c.get("path", "/")} for c in cookies ] with open(path, "w", encoding="utf-8") as f: json.dump(slim, f, ensure_ascii=False, indent=2) def load_cookies(driver, path="cookies.json"): with open(path, "r", encoding="utf-8") as f: cookies = json.load(f) for c in cookies: driver.add_cookie(c)逻辑说明:save_cookies 里把 cookies 裁剪成四个字段,是因为很多站点会种下 HttpOnly 或 Secure 标记的会话字段,直接从 JSON 反序列化回去时,如果字段缺失或过期会直接被浏览器忽略,裁剪反而更稳。参数 domain 必须保留,否则 add_cookie 会因为域名不匹配而静默失败。加载 cookies 前先 get 一次目标站点域名,让浏览器处于该域名下,否则 add_cookie 同样无效。
实际操作时,我建议把手动登录这一步骤留在独立脚本里跑:启动浏览器打开站点、人工输入账号密码、登录成功后按任意键触发 save_cookies。不要试图在 worker 里做登录,因为多进程环境下多个浏览器实例同时登同一个账号,很容易触发站点风控,把账号给顶下线。
3.3 首轮抓取主循环:从入口 URL 到留言列表
做完登录态准备,进入正式抓取循环。这套资源的主循环思路是:先打开列表页,定位留言容器,循环点击“加载更多”直到按钮消失或达到预设上限,再逐个提取留言块。
import time from selenium.webdriver.common.by import By from parse import extract_comment def run_worker(url, max_loads=10): driver = init_driver() driver.get(url) # 等待首屏留言内容渲染完成 wait_until_visible(driver, "css", ".comment-item", timeout=15) for _ in range(max_loads): try: load_more_btn = driver.find_element(By.CSS_SELECTOR, "button.load-more") if not load_more_btn.is_enabled(): break load_more_btn.click() time.sleep(2) # 等新内容加载 except Exception: # 找不到按钮说明没有更多留言,正常退出 break items = driver.find_elements(By.CSS_SELECTOR, ".comment-item") result = [extract_comment(item) for item in items] driver.quit() return result逻辑说明:max_loads 参数是用来防呆的,万一按钮一直存在,循环也会在设定次数后退出,避免死循环拖死进程。time.sleep(2) 是简单粗暴的等待策略,但对留言板这类轻量页面足够用;如果追求稳定,可以改成 WebDriverWait 配合 expected_conditions 等待新留言数增加。参数上建议根据目标站点的加载速度调整 sleep 秒数,调太小会点到未生成的事件绑定,调太大影响整体效率。
多进程场景下,每个 worker 自己 init_driver,用完就 driver.quit(),不要把 driver 实例跨进程传递,这是这个资源在多进程改造里比较重要的约束。如果你把这段代码改到自己项目里,还要注意 extract_comment 接收的是 WebElement 而不是整个 driver,这样解析逻辑和浏览器控制逻辑才能解耦,后面目标站点改版时只需要动 parse.py。
4. 把留言解析成结构化数据:选择器设计、清洗与结果汇总
4.1 页面结构分析与选择器设计
留言板的页面结构表面上各有差异,但抽出共性后无非三种元素:留言容器、留言内容字段、加载更多按钮。这套资源把选择器全部收敛到 config.py,而不是散落在代码各处,后期站点改版时只需要改配置。选择器选型上,优先用 CSS,其次 XPath,原因在于 CSS 选择器可读性好且 Selenium 原生支持快。
XPath 也不是完全不用。当留言块的位置依赖文本内容判断时,比如需要找到“管理员回复”所在的区块,XPath 的 contains(text()) 能力就派上用场;但 XPath 一旦写复杂,页面微调就会失效,而且可读性差。我的原则是能用 CSS class 定位就不用 XPath,只有 class 不稳定或需要按文本过滤时才用 XPath 补充。
# config.py 配置示例 SELECTORS = { "comment_item": "div.comment-item", "author_field": ".comment-author .name", "content_field": ".comment-content", "time_field": ".comment-time", "load_more_btn": "button.load-more", "next_page_btn": "a.next-page", }参数说明:这里 comment_item 是整个留言块的根元素,其余字段都基于根元素往下查找。写选择器时要注意,某些模板类站点喜欢把评论区分为“留言”和“回复”两种区块,回复区块的类名通常是 reply-item,如果只抓 comment-item 会漏掉一部分数据。资源的 config 如果只配了顶层选择器,拿回来后建议自己加上 reply-item 一起抓。
还有一个容易踩的点:不少留言板的“加载更多”按钮有两种形态,一种是 button,一种是带 href 的 a 标签。如果 config 里只配了 button.load-more,遇到 a 标签版本就会找不到元素。我一般会在配置里同时维护两套候选选择器,定位时逐一尝试,这样站点做小改动时不至于立刻停摆。
4.2 数据清洗:过滤广告、空白与重复留言
留言板数据比想象中脏。常见污染包括:纯表情回复、系统自动顶帖、被屏蔽的占位内容、以及同一用户短时间内刷屏的重复留言。清洗阶段要把这些噪声过滤掉,否则后续做分析时数据质量完全没法用。
import re # 已见过的留言指纹,用于进程内去重 seen_signatures = set() def normalize_text(text): # 先统一换行和空白,再压缩成连续字符串 t = re.sub(r"\s+", "", text) t = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", t) return t def is_noise(comment): sig = normalize_text(comment["content"]) if len(sig) < 4: return True if sig in ("顶", "路过", "沙发", "赞"): return True # 短时间重复刷屏过滤,同指纹内容只在单个进程内保留一次 if sig in seen_signatures: return True seen_signatures.add(sig) return False逻辑说明:normalize_text 先把所有空白和标点去掉,得到一个压缩后的指纹。is_noise 里 len(sig) < 4 判断是为了过滤纯表情或单个字符的灌水;seen_signatures 做进程内去重。多进程环境下注意:这个集合只在当前进程有效,跨进程去重需要在 main 里汇总时再做一次,或者用 multiprocessing.Manager 维护共享集合,后者有锁开销,量小时不推荐。
清洗之后的字段建议统一为 content、author、time、source_url 四个键,方便后续拼成 DataFrame 或直接落入 SQLite。时间字段也要处理:留言板显示的时间经常是“刚刚”“5分钟前”“昨天 14:32”这类相对时间,直接存下来完全没法排序。常见做法是在爬虫运行那一刻把相对时间换算成绝对时间,换算函数里注意时区,目标站点如果是国内站点,直接用本地时间即可。
4.3 多进程结果汇总:避免数据交错
每个 worker 返回的是留言列表,main 进程需要把这些列表合并。最容易犯的错是让多个进程直接写同一个 CSV,结果就是行内容互相穿插,数据整个废掉。正确的做法是 worker 只返回数据,由主进程统一写入。
import csv from multiprocessing import Pool def collect_results(pool_result_iter): rows = [] for result in pool_result_iter: if result: rows.extend(result) # 统一排序,按时间字段倒序 rows.sort(key=lambda x: x["time"], reverse=True) write_csv(rows) def write_csv(rows): with open("messages.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=["author", "content", "time", "source_url"]) writer.writeheader() writer.writerows(rows)逻辑说明:encoding 用 utf-8-sig 是因为很多下游工具(特别是 Excel)直接打开 CSV 时对无 BOM 的 UTF-8 会乱码,带 BOM 后兼容性明显改善。参数 source_url 来自 worker 里记录的当前页面 URL,这条字段在后续增量抓取和排查数据来源时非常有价值,建议不要省略。
如果数据量超过几万行,CSV 就不是最优载体了。数据量大的场景,我一般建议直接写 SQLite,避免频繁打开关闭文件句柄,也方便做增量更新。写入数据库时把增量去重放到 SQL 层面,用 content 字段的 hash 建唯一索引,这样即使日志没记录到,库里也不会出现重复行。
5. 避坑与排查:留言板爬虫最常见的 5 个翻车现场
5.1 启动即报 WebDriverException,找不到浏览器二进制
现象:运行 main.py 后马上报错,提示 cannot find Chrome binary 或 chromedriver 版本不匹配。 原因:资源包内置的驱动版本是作者本机的 Chrome 版本,换一台机器后版本对不上;或者 chromedriver 没有加入 PATH。 解决:先执行 chrome://version 查看浏览器版本,下载对应版本的驱动;把驱动放在项目根目录,并在代码里用 executable_path 显式指定路径。验证驱动版本有没有配对,最直接的方法是打开命令行手动执行 chromedriver --version,看输出的版本号和你本机 Chrome 主版本是否一致。从那以后我每次拿到新电脑都会先跑一遍 driver 检查再跑主程序。
5.2 登录态失效,抓回来全是登录页 HTML
现象:日志显示页面加载成功,但解析结果全是空列表,查看截图发现停在登录页。 原因:cookies.json 过期失效,或者 add_cookie 时没有先访问目标域名,cookies 未被浏览器接受。 解决:删除 cookies.json 重新手动登录一次;检查 load_cookies 里是否有先 driver.get(目标域名) 的操作。建议在 worker 里加一道校验:每页解析后若留言数为 0,自动跳转登录页检测,命中则终止该 worker,避免无意义地刷空页面。快速判断 cookies 是否被浏览器接受,可以在加载后打印当前页面的 cookie 列表,如果比 cookies.json 少很多,说明 add_cookie 静默失败了。
5.3 “加载更多”按钮点击无效或点击报错
现象:按钮明明存在,click() 却报 element not clickable,或点击后无新内容。 原因:按钮被固定在页面底部的悬浮层遮挡,或者按钮在视口外,WebDriver 默认不会自动滚动。 解决:先执行 driver.execute_script("arguments[0].scrollIntoView();", btn) 再点击;如果仍不稳定,改为发送 Keys.PAGE_DOWN 后重试。这套资源如果只写死了 click,建议自己补上滚动逻辑。还有一种情况是按钮点击后数据异步加载有延迟,点击完立刻找新留言当然找不到,至少要加一个 WebDriverWait 等待留言总数变化,等新留言数大于旧留言数再继续循环。
5.4 多进程写同一个 CSV 导致数据行交错
现象:CSV 中同一行内容来自不同留言,明显错位。 原因:多个 worker 进程同时打开同一个文件句柄写入,操作系统层面没有原子保护。 解决:worker 内不写文件,只通过返回链表传数据,main 进程统一落盘。如果一定要在 worker 内写,至少给每个进程分配独立文件,最后再合并。这个问题的隐蔽之处在于错误不是必现的,进程少、数据量小时可能恰好没交错,一旦进程数上来就必炸。排查时优先看 CSV 文件行数和实际抓取总数是否一致,不一致基本就是并发写入导致的行覆盖或错位。
5.5 页面元素选择器失效,抓取量断崖下跌
现象:昨天还能抓到完整数据,今天只能抓到一半,日志无报错。 原因:目标站点改版,留言块类名从 comment-item 变成 message-item,旧选择器命中不到。 解决:抓取前先打印前几个元素的 HTML 片段,确认选择器;把选择器集中到 config.py 后直接改配置,不用动爬虫主体。日常维护时每两天抽查一次采样页,把页面结构变化记录在案,能省不少排查时间。如果是偶发性的抓到一半,排除改版后还要考虑是不是某几个页面加载慢导致部分留言块没渲染出来,这种情况可以对比失败 URL 的日志,统一重试就行。
6. 从“能跑”到“跑得稳”:增量抓取与异常自恢复的技巧
留言板数据不是静态的,用户会持续追加新留言。如果每次全量重抓,一方面浪费带宽,另一方面也容易触发站点频率限制。增量抓取的思路是维护一个已抓取留言指纹集合,每次只处理新增内容。
常见做法是把已抓取的留言 ID 或内容指纹存到一个本地文件,每次抓取后更新。判断新留言时,只需要在解析后对比指纹集合即可。
import hashlib import os def load_fingerprints(path="seen.txt"): if not os.path.exists(path): return set() with open(path, "r", encoding="utf-8") as f: return set(line.strip() for line in f) def is_duplicate(comment, fingerprint_path="seen.txt"): fp = hashlib.md5(comment["content"].encode("utf-8")).hexdigest() if fp in load_fingerprints(fingerprint_path): return True with open(fingerprint_path, "a", encoding="utf-8") as f: f.write(fp + "\n") return False逻辑说明:用内容 md5 做指纹,比用 ID 更稳,因为部分留言板的 ID 是服务端生成的随机数,翻页顺序不稳定,但内容指纹天然具备唯一性,除非用户复制粘贴重复留言,否则不会误判。增量模式下,worker 可以先从列表页尾部抓取新留言,抓完即止,不必等“加载更多”全部跑完。
异常自恢复方面,我一般会在 worker 的循环外面包一层重试机制,收集爬虫访问目标入口时出现的超时、元素超时等异常,捕捉后按固定间隔重试两次,第三次仍然失败就放弃该 worker,但不影响主进程继续调度其他任务。这套思路在留言板这种低频变化且反爬不太严重的场景下足够用。
至于验证码,如果目标站点只是偶尔弹验证码,建议直接放弃当前 worker 稍后重跑,不要硬着头皮去接打码平台,毕竟留言板场景的数据时效性要求不高,错峰重跑成本更低,把请求节奏压低比接任何反爬都管用。从那以后我每次给类似资源写“稳定版”时,都会强制走一遍“先增量、再重试、最后限速”这三个步骤,实测下来抓取成功率明显提升。希望这套拆解对你有帮助。
本文还有配套的精品资源,点击获取