简介:这是一套面向数据采集与社交媒体分析场景的Python爬虫工具,基于selenium实现Facebook视频数据的多线程抓取。使用者只需提供关键词,程序即可自动打开网页搜索,并依次读取对应视频的标题、地址、发布日期、播放量、点赞数、评论数、分享数、商品链接bit.ly点击量、是否有去逛逛及视频时长等字段,结果按关键词分别写入Excel表格,便于后续整理与统计。资源包共384个文件,以308个py源码为主,辅以pyd、exe、dll等运行依赖,以及txt、xml、html、bat等配置与说明文件,整体约33.47MB,解压后可直接查看脚本结构与依赖组成。目前已有4126人学习下载,适合具备一定Python基础、希望研究多线程采集与浏览器自动化实现思路的开发者参考,可从中了解字段提取、数据落表与依赖打包的完整组织方式。
1. 从一次数据采集翻车说起:这套多线程爬虫到底能干什么
去年帮某高校实验室做社交舆情分析,需要批量抓取公开主页的帖子标题、发布时间和互动数。单线程 Selenium 跑了一晚上,进度条才爬到 12%,中途还因为元素加载超时崩了三次。后来换成多线程调度加显式等待,同样的量四十分钟跑完,稳定性也上来了。这套「Facebook 多线程爬虫(python+selenium)」就是干这个的——用 Python 驱动 Selenium 模拟真实浏览器行为,再通过线程池把多个采集任务并行化,适合需要抓取动态渲染页面、又不想被反爬机制轻易识别的小规模数据采集场景。它不碰登录态、不碰私密数据,只针对公开可见的页面内容做结构化提取。如果你手头有几百到几千条公开帖子的采集需求,又不想上重型分布式框架,这套东西刚好卡在「够用」和「不复杂」之间。
2. 环境搭建与核心依赖:把 Selenium 多线程跑起来需要哪些零件
2.1 Python 侧依赖清单与版本边界
这套爬虫的 Python 侧依赖不复杂,但版本卡得比较死。Selenium 4.x 之后 API 变动不小,网上很多老教程用的find_element_by_xpath在 4.x 里已经废弃,得换成find_element(By.XPATH, ...)。我一般会锁在 Selenium 4.15 以上、Python 3.9 到 3.11 之间,再高的版本没实测过,不敢打包票。
# 建议在虚拟环境里装,避免污染全局 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install selenium==4.15.0 pip install webdriver-manager==4.0.1 pip install requests==2.31.0webdriver-manager这个包值得单独说一句。以前用 Selenium 最烦的就是浏览器驱动版本对不上,Chrome 一自动更新,脚本就报SessionNotCreatedException。webdriver-manager会在运行时自动拉取匹配当前浏览器版本的驱动,省掉手动下载解压配 PATH 的步骤。不过它首次运行要联网下载,内网环境得提前把驱动缓存好。
2.2 浏览器驱动的初始化参数怎么设
驱动初始化是整个爬虫里最容易被忽视、又最影响稳定性的环节。默认配置下,Selenium 会加载图片、CSS、弹窗,这些对抓文本数据来说全是浪费。我一般会挂一组ChromeOptions把非必要资源全掐掉。
from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager def build_driver(headless=True): opts = Options() if headless: opts.add_argument("--headless=new") # 无头模式,服务器上跑必开 opts.add_argument("--disable-gpu") # 无头下关 GPU 加速,防部分环境崩溃 opts.add_argument("--no-sandbox") # 容器环境常见报错,加这个绕开 opts.add_argument("--disable-dev-shm-usage") # 共享内存不足时的保命参数 opts.add_argument("--blink-settings=imagesEnabled=false") # 不加载图片,省带宽 opts.add_argument("--window-size=1920,1080") # 固定窗口,避免响应式布局导致元素错位 opts.add_argument( "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) service = Service(ChromeDriverManager().install()) return webdriver.Chrome(service=service, options=opts)这里几个参数值得展开。--headless=new是 Chrome 112 之后的新无头模式,比老的--headless更接近真实浏览器行为,有些页面在老无头下渲染不全,换新模式就好了。--disable-dev-shm-usage在 Docker 里几乎是必加项,不然跑一会儿就报DevToolsActivePort file doesn't exist。user-agent建议固定一个主流版本,别用 Selenium 默认的那串,默认 UA 里带HeadlessChrome字样,等于主动告诉对方「我是脚本」。
2.3 多线程模型的选择:ThreadPoolExecutor 还是手动管理
多线程这块,我踩过的坑比调通的路还多。最早用threading.Thread手动起线程,结果线程数一多就互相抢资源,浏览器实例开太多直接把内存打满。后来换成concurrent.futures.ThreadPoolExecutor,用固定大小的线程池控制并发,稳得多。
from concurrent.futures import ThreadPoolExecutor, as_completed def crawl_batch(urls, max_workers=4): results = [] with ThreadPoolExecutor(max_workers=max_workers) as pool: future_map = {pool.submit(crawl_single, url): url for url in urls} for future in as_completed(future_map): url = future_map[future] try: data = future.result(timeout=60) # 单个任务超时 60 秒 results.append(data) except Exception as e: print(f"[FAIL] {url} -> {e}") return resultsmax_workers这个值不是越大越好。每个线程背后是一个独立的浏览器实例,一个 Chrome 进程吃 200MB 到 400MB 内存很正常。4 核 8G 的机器,开到 4 个并发基本是上限,再往上就是拿稳定性换速度。我一般会先跑 10 条测试数据,观察内存曲线,再决定最终并发数。future.result(timeout=60)里的超时也得设,不然某个页面卡住会把整个批次拖死。
3. 页面元素定位与数据提取:动态渲染下怎么稳定拿到字段
3.1 显式等待替代 sleep 的实操写法
新手最容易犯的错就是time.sleep(5)满天飞。页面加载快的时候白等,加载慢的时候又不够。正确做法是用WebDriverWait配合expected_conditions,让脚本等「条件满足」而不是等「时间过去」。
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.common.exceptions import TimeoutException def wait_for_posts(driver, timeout=20): try: WebDriverWait(driver, timeout).until( EC.presence_of_all_elements_located( (By.CSS_SELECTOR, "div[role='article']") ) ) except TimeoutException: print("帖子容器未在预期时间内出现,可能页面结构变了或加载失败") return False return Truepresence_of_all_elements_located等的是元素出现在 DOM 里,不保证可见。如果要等可见,换成visibility_of_all_elements_located。这两个的区别在抓懒加载内容时特别关键——有些帖子容器先渲染出来但内容是空的,用 presence 会拿到空壳,用 visibility 更稳。超时时间我一般设 20 秒,再长说明网络或页面本身有问题,硬等没意义。
3.2 帖子字段的提取逻辑与容错
定位到容器之后,逐个字段提取。这里的关键是每个字段都要有独立的容错,不能一个字段找不到就整个任务崩掉。
def extract_post(card): def safe_text(parent, css): try: return parent.find_element(By.CSS_SELECTOR, css).text.strip() except Exception: return "" # 字段缺失返回空串,不抛异常 return { "title": safe_text(card, "h3, strong"), "content": safe_text(card, "div[dir='auto']"), "time": safe_text(card, "abbr, time, span[aria-label]"), "likes": safe_text(card, "span[aria-label*='like'], span[aria-label*='赞']"), }safe_text这个包装函数是我从一次线上事故里学来的。当时有个页面的时间字段用了abbr标签,另一个页面用time,还有一个直接是span带aria-label。如果写死一个选择器,换一批数据就全军覆没。用逗号分隔多个选择器,Selenium 会返回第一个匹配到的,配合 try/except 兜底,字段缺失就返回空串,后续清洗阶段再统一处理。这样单个字段的失败不会传染到整条记录。
3.3 翻页与滚动加载的处理策略
动态页面的翻页有两种:一种是点「下一页」按钮,一种是往下滚动自动加载。两种都得处理,而且得设终止条件,不然脚本会无限滚下去。
def scroll_until_stable(driver, max_scroll=30, pause=1.5): last_height = driver.execute_script("return document.body.scrollHeight") for i in range(max_scroll): driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(pause) # 这里必须等,给加载留时间 new_height = driver.execute_script("return document.body.scrollHeight") if new_height == last_height: break # 高度不再变化,说明到底了 last_height = new_heightmax_scroll是硬性上限,防止页面有「无限加载」设计时脚本跑飞。pause给 1.5 秒是经验值,太短了内容还没渲染出来,太长了浪费时间。判断终止用「滚动高度是否变化」比判断「有没有加载图标」可靠,因为加载图标的 CSS 类名经常变,高度变化是物理事实。
4. 避坑与排查:多线程 Selenium 最容易翻车的五个地方
4.1 现象:脚本跑几分钟后报StaleElementReferenceException
原因:页面在你拿到元素引用之后又重新渲染了,原来的引用失效。多线程环境下,滚动、翻页、动态加载都会触发 DOM 重绘,元素引用说没就没。
解决:不要在循环外一次性拿所有元素引用。改成「每次操作前重新定位」,或者把元素的关键属性(比如文本、href)先提取出来存成普通变量,后续操作基于变量而不是元素对象。如果必须持有引用,在操作前加一次WebDriverWait重新确认元素存在。
4.2 现象:并发开到 6 以上,程序被系统杀掉或报内存错误
原因:每个线程一个浏览器实例,Chrome 本身内存占用就大,加上页面资源缓存,几个实例就能吃掉几个 G。
解决:把max_workers压到 3 到 4,并且在每个任务结束后显式调用driver.quit()释放实例。如果任务量大,改成「分批创建驱动」——每批处理完就销毁,下一批重新建,而不是全程复用。另外--blink-settings=imagesEnabled=false和--disable-dev-shm-usage这两个参数能明显降低单实例内存。
4.3 现象:本地跑得好好的,放到服务器上就报DevToolsActivePort file doesn't exist
原因:服务器环境缺少 Chrome 运行所需的依赖库,或者/dev/shm分区太小。这是容器化部署的经典坑。
解决:先确认 Chrome 能独立启动(命令行直接跑google-chrome --version),再检查/dev/shm大小(df -h /dev/shm)。如果小于 64MB,加--disable-dev-shm-usage参数让 Chrome 用磁盘代替共享内存。还不行就装libnss3、libgconf-2-4这类系统库,具体缺哪个看报错日志。
4.4 现象:抓到的数据大量为空,但页面手动打开明明有内容
原因:无头模式下某些页面走了不同的渲染分支,或者user-agent被识别导致返回了精简版页面。
解决:先关掉无头模式跑一遍对比,确认是不是无头导致。如果是,换--headless=new模式。同时检查user-agent是否带了Headless字样,换成正常浏览器 UA。还有一种可能是元素在 iframe 里,需要先driver.switch_to.frame()切进去再定位。
4.5 现象:线程之间数据串了,A 任务的字段出现在 B 任务的结果里
原因:多个线程共享了同一个全局变量或同一个 driver 实例。Selenium 的 driver 不是线程安全的,一个实例同时被多个线程操作必然出乱子。
解决:每个线程独立创建自己的 driver 实例,任务函数内部完成「建驱动 → 抓数据 → 关驱动」的完整闭环,不依赖外部传入的 driver。共享的只有只读的配置参数,可变状态一律线程内私有。
5. 进阶技巧:把采集结果落成结构化数据并做基础校验
数据抓回来只是第一步,落库前的清洗和校验决定了这份数据能不能用。我一般会在爬虫和存储之间加一层轻量校验,把明显异常的记录拦下来。
import re from datetime import datetime def normalize_record(raw): """把原始字段清洗成统一格式,异常记录打标不丢弃""" record = dict(raw) record["content"] = re.sub(r"\s+", " ", record.get("content", "")).strip() record["likes_num"] = parse_count(record.get("likes", "")) # 时间字段尝试多种格式,解析失败标记为 unknown time_str = record.get("time", "") record["parsed_time"] = try_parse_time(time_str) # 内容为空且标题也为空的,标记为无效 record["valid"] = bool(record["content"] or record["title"]) return record def parse_count(text): """把 '1.2K' '3,456' 这类计数转成整数""" if not text: return 0 text = text.replace(",", "").strip().upper() m = re.match(r"([\d.]+)\s*([KMB]?)", text) if not m: return 0 num, unit = float(m.group(1)), m.group(2) return int(num * {"": 1, "K": 1_000, "M": 1_000_000, "B": 1_000_000_000}[unit]) def try_parse_time(text): for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%b %d, %Y"): try: return datetime.strptime(text, fmt).isoformat() except (ValueError, TypeError): continue return "unknown"parse_count处理的是互动数那种「1.2K」「3.4M」的缩写格式,直接存字符串后续没法做数值分析。try_parse_time用多格式尝试的方式兼容不同页面写法,解析不了就标unknown而不是丢弃,保留原始信息供人工复核。valid字段是个软标记,空内容记录不删,但下游分析时可以按需过滤。
落库这块,小规模数据我习惯先写 CSV 再导入,比直接连数据库省事,出问题也好回溯。
import csv def save_to_csv(records, path="output.csv"): if not records: return fields = ["title", "content", "parsed_time", "likes_num", "valid"] with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") writer.writeheader() writer.writerows(records)encoding="utf-8-sig"是为了 Excel 打开不乱码,这个细节坑过不少人。extrasaction="ignore"让字典里多余的键不报错,方便后续加字段。
最后说个验证习惯。每次跑完一批,我会随机抽 10 条记录,手动打开对应页面比对字段。听起来笨,但这是唯一能确认「脚本没在自欺欺人」的办法。有次抽检发现时间字段全抓成了页面底部的版权年份,原因是选择器命中了页脚而不是帖子本身——这种错误光看日志根本发现不了。从那以后我每次改完选择器,都强制走一遍抽检流程。希望帮到你。
本文还有配套的精品资源,点击获取