简介:基于Python+Selenium的多线程Facebook视频爬虫工具,面向爬虫学习者、数据运营及社媒分析人员,用于批量采集Facebook视频公开数据。用户只需在配置中指定关键词,程序即可自动打开网页搜索并滚动加载,对每个关键词下的所有视频依次抓取标题、地址、发布时间、播放量、点赞数、评论数、分享数、视频商品bit.ly链接点击量、是否带‘去逛逛’入口以及视频时长,最终为每个关键词单独生成一份Excel表格,便于后续筛选和统计。整个zip包包含384个文件,体积约33.47MB;核心为308个py脚本,同时配备27个pyd编译模块、12个exe可执行文件及8个dll动态库,并包含虚拟环境配置、运行说明等辅助文件,解压后即可直接运行或二次开发。目前已有4121人学习下载。该工具把浏览器自动化、多线程任务调度、数据解析与持久化整合成一套可复用的采集方案,既能直接用于业务数据积累,也可作为Selenium大型爬虫项目的拆解参考,帮助理解多页面采集中的文件组织、依赖打包和异常处理思路。 上个月接了个需求,要抓一批Facebook公开主页的帖子数据,包括正文、发布时间、点赞评论数。一开始我想着用requests加BeautifulSoup,毕竟这套组合用了好多年,轻量又顺手。可真把页面拉下来一解析就傻眼了——HTML里空空荡荡,核心数据全是JavaScript异步渲染出来的,直接请求拿到的只是一个loading壳。再回头去抠XHR接口、分析加密参数,时间成本直接失控。于是我把技术栈切成了python + selenium,顺手用多线程把采集效率拉上去,最终稳定跑到每小时几千条帖子的吞吐量。这篇就从选型思路、架构设计、核心代码、踩坑记录四个层面拆一下这套方案,给打算用selenium做动态页面爬虫的朋友一个参考。
先说好,这套方案只面向公开主页的公开信息采集,不该碰的私密数据不要去碰。技术本身是中性的,用在哪、怎么用,心里得有数。
1. 为什么偏要用Selenium:动态渲染页面的抓取逻辑起点
1.1 静态请求与动态渲染之间隔着一整套JS执行链
用requests直接请求Facebook的页面,拿到的HTML其实非常“干净”,干净到没什么实际内容。帖子、时间线、点赞数这些数据,全都是浏览器在本地执行完JS脚本之后才拼出来的。这就导致一个很尴尬的局面:静态请求拿到的是空壳,真正想看的内容在DOM里,但DOM是JS运行的结果,requests拿不到。
有朋友会说,那去分析XHR接口,直接请求接口不就行了?理论上可以,但现实里这类接口一般会有签名参数、加密header,可能还绑定了浏览器指纹。你要先抓包、再逆向、再模拟,整个过程费时费力,而且页面一改版可能就白做了。Selenium的思路完全反过来——我不逆向你的JS,我让真正的浏览器去执行你的JS,执行完了我直接取渲染后的DOM。它拿到的页面和你在浏览器里看到的完全一致,这也是它在动态页面面前最核心的价值。
1.2 Selenium方案的取舍边界:费资源但省时间
Selenium的代价也很明显,就是重。每次启动一个完整的Chrome实例,内存轻轻松松吃掉几百MB;打开一个页面要等所有资源加载完,几秒起步。这意味着它不适合海量URL的批量抓取场景,如果目标站有干净的JSON接口,requests永远是更优解。
但Facebook这类高度动态化、渲染路径复杂的页面,selenium是“以资源换开发时间”的合理选择。我算过一笔账:用requests去逆向接口,光摸清参数签名算法可能就要一两周;用selenium,当天就能跑通一个页面。社交平台的页面结构还频繁改版,selenium基于真实用户视角定位元素,改版后调整成本通常比重新逆向接口低不少。这个取舍,我觉得值得。
2. 多线程架构设计:任务分发、去重与结果回传
2.1 ThreadPoolExecutor比裸threading好在哪
Python多线程写起来不难,但裸threading容易把代码写成灾难。你自己要管线程生命周期、封装任务循环、处理异常传播,写着写着就乱了。concurrent.futures.ThreadPoolExecutor把这些都封装好了,提交任务、回收结果都是一行代码的事,还能通过max_workers精确控制并发度。
不过在爬虫这个场景,我实际用下来反而更推荐“队列+常驻worker”的模型,而不是单纯往线程池里submit。原因很简单:每个worker对应一个WebDriver实例,如果任务执行完就销毁线程,driver也得跟着重建,初始化代价太高。常驻worker模型下,driver在启动时初始化一次,之后循环从任务队列里取任务执行,整个生命周期只属于这一个线程,既高效又安全。
2.2 队列当任务总线:上游只管生产,下游只管消费
架构上我用两个Queue。url_queue承载待抓取URL,result_queue承载解析好的结果。dispatcher负责把URL塞进url_queue,worker从里面取,解析完把结果放进result_queue。这样生产者和消费者完全解耦,哪怕某个worker执行特别久,也不会阻塞其他任务。
这个模型说白了就是“传送带模式”——dispatcher相当于仓库管理员,只管往传送带上放料;worker相当于打包员,各干各的。谁快谁慢互不影响,任务积压只体现在队列长度上,排查问题非常直观。两个Queue的职责也很清晰,绝不混用,这样可以避免数据结构上的混乱。
2.3 去重和写库:锁不是银弹但要会用
多线程里最怕共享资源竞争。URL去重我用一个set加一把lock:worker取到一个URL后,先加锁检查是否已经抓过,没抓过就加入集合并继续执行。如果不加锁,两个线程同时判断同一个URL,就会重复抓取。这个操作虽然简单,但漏掉它一定会出事。
写结果也一样。多个worker同时写同一个CSV文件,行会穿插甚至损坏,系统调用层不会帮你保证顺序。解决方案有两个:要么所有写盘操作加同一把lock串行化,要么只让一个writer线程从result_queue里取数据写盘,其他线程只负责放结果。我选了第二种,因为writer线程天然只有一个,写盘路径上不存在竞争,代码干净,不容易写错。
3. 核心代码实现:线程池、页面交互与数据解析
3.1 环境准备:Python、Selenium与ChromeDriver版本匹配
先列一下依赖:Python 3.8以上,selenium 4.x,webdriver-manager。这里必须提醒一句,Chrome和ChromeDriver的版本必须严格对应,否则driver启动直接报错。以前我手动下ChromeDriver,被版本地狱折磨过,后来换成webdriver-manager,它会根据本机Chrome版本自动匹配下载对应的驱动,省掉了很多麻烦。
from concurrent.futures import ThreadPoolExecutor from queue import Queue, Empty import threading import random import time import csv from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.chrome.options import Options3.2 初始化WebDriver:一个线程一个实例是底线
worker启动第一件事就是创建自己的driver。这里最关键的是每个线程必须用独立的user-data-dir。Chrome进程对profile目录有文件锁,多个driver共用同一个目录,第二个启动时就会报“directory already in use”之类的错误。所以登录好的profile要先复制几份,每个线程指向自己那份。
def create_driver(profile_dir): options = Options() options.add_argument(f"--user-data-dir={profile_dir}") options.add_argument("--disable-blink-features=AutomationControlled") options.add_argument("--no-sandbox") options.add_argument("--window-size=1400,900") driver = webdriver.Chrome(options=options) driver.set_page_load_timeout(15) return driverThreadPoolExecutor里线程名是唯一的,我用它来生成profile目录名,保证不冲突。
3.3 页面交互:滚动、等待、解析三段式
一次完整的页面获取分三步。driver.get进入页面,然后循环滚动到底部触发懒加载,最后等关键节点出现再解析。Facebook的帖子列表是边滚边追加的,一次滚动拿不完,所以要设定最大滚动次数,控制采集深度。
def fetch_page(driver, url, max_scrolls=8): driver.get(url) for i in range(max_scrolls): driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(random.uniform(2, 5)) if i % 3 == 0: driver.execute_script("window.scrollTo(0, document.body.scrollHeight * 0.8);") wait = WebDriverWait(driver, 10) posts = wait.until(EC.presence_of_all_elements_located( (By.XPATH, "//div[contains(@data-pagelet, 'FeedUnit')]"))) return [parse_post(post) for post in posts]解析时从帖子容器里逐项抽取正文、时间、点赞数、评论数。这里要提醒:XPath选择器一定以实际页面结构为准,页面改版后需要重新定位,写死的东西早晚要维护。
3.4 伪人类操作:随机延时与鼠标轨迹
自动化检测判断bot,看的往往不是单一特征,而是行为统计概率。访问频率恒定、滚动速度恒定、鼠标一直在同一个位置,很容易被标记。我的做法很朴素:每次滚动之间加随机延时,范围放宽到2到7秒;滚动距离不总是拉到底部,偶尔只滚一半;鼠标偶尔用ActionChains随机移动一段距离。这些操作成本极低,但能明显降低触发风控的概率。爬虫不是越快越好,像人一样“磨蹭”一点,反而能跑更久。
4. 踩过的坑:登录态、WebDriver实例与等待策略的连锁反应
4.1 登录态复用:用user-data-dir省掉每次扫码
未登录状态虽然能看公开主页,但能拿到的字段有限,动态加载策略也不一样。为了让采集稳定,我选择让driver带着登录态启动。做法是:先手动登录一次Facebook,找到user-data-dir目录,复制几份给各个线程用。worker启动后直接就是登录态,不需要在自动化脚本里处理密码和验证码。
这里有个细节:复制profile目录之前,必须确认浏览器已经完全退出。否则目录里留有锁文件,复制出来是坏的,启动时各种诡异报错。
4.2 多个driver抢一个用户目录会直接崩
第一次上线时我图省事,让所有线程指向同一个user-data-dir,结果第二个driver启动直接报错,后面启动的会话ID全是null,整个采集任务直接瘫了。原因就是Chrome的profile目录有文件锁,两个进程同时打开必然冲突。后来改成每个线程用独立的目录,启动前从登录好的profile复制一份,这个问题才算根治。不管并发多小,这个隔离一定要做。
4.3 等待策略叠加:隐式+显式让超时翻倍
Selenium里隐式等待和显式等待是两套独立的机制。如果先设置了implicitly_wait(10),又在某个find_element上套了WebDriverWait(..., 10),元素出现前查找会先走隐式等待的10秒,再走显式等待的10秒,极端情况下单个查找能卡几十秒,整个任务就被拖死了。
我的做法很干脆:implicitly_wait设置为0,全用显式等待,每个关键节点单独设超时时间。显式等待目标明确,还可以针对不同元素设置不同超时,比如滚动等待我放宽到15秒,普通按钮只给5秒。这类细节叠加起来,对整体稳定性的提升非常明显。
5. 稳定性与效率的平衡:限速、重试与监控
5.1 并发数不是越大越好
我一开始直接上了8个worker,结果采集速率反而没有线性提升,页面开始频繁要求登录验证。后来把并发降到5,配合随机延时,反而稳定跑了一周没出问题。给目标站点留点余地,爬虫才跑得久。另外每个worker背后就是一个完整的Chrome实例,内存和CPU开销摆在那里,8个实例差不多吃掉16G内存,机器配置不够的话,还没爬就被自己拖垮了。
5.2 失败重试要区分场景:网络超时和页面结构变化是两回事
爬虫一定会遇到异常,关键是分类处理。网络抖动导致driver.get超时,这种临时性问题重试大概率能过,我一般重试2到3次,每次间隔随机时长。但页面结构变化导致元素找不到,这种结构性异常重试一百次也是白搭。所以我单独把NoSuchElementException这类异常接住,直接把URL记录到异常列表,跑完统一人工核对。两类异常混在一起处理,很容易让任务卡死在无意义的循环里。
5.3 队列积压、采集进度和异常可视化
跑批量任务最怕黑盒,跑了一天不知道进展,出了问题也不知道从哪查。我在每次任务完成时用日志打印三个指标:url_queue剩余量、result_queue长度、成功数和失败数。url_queue清零说明任务接近尾声;result_queue持续增长说明writer线程可能卡住了;成功失败比例突变,说明页面结构或风控策略变了。一个简单的日志函数就能让问题浮出水面,比事后翻栈容易太多。
def log_stats(stats, url_queue, result_queue): print( f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] " f"url_queue={url_queue.qsize()} " f"result_queue={result_queue.qsize()} " f"success={stats['success']} fail={stats['fail']}" )这套统计逻辑跑起来之后,任务状态一目了然,什么问题都藏不住。
最后再说一个我自己的经验:不要一上来就追求多线程。先用单线程把一个页面的抓取流程完整跑通,确认选择器、等待条件、数据清洗都正常了,再套多线程架构。多线程放大的是确定性,单线程跑不通的东西,多线程只会把队列积压变成灾难。我第一版犯的最蠢的错误就是没先跑单线程,结果排查问题的同时还得排查并发问题,双重折磨。后来老老实实按单线程验证、多线程扩容这个节奏走,整个项目顺利了很多。
本文还有配套的精品资源,点击获取