Selenium 3.141.0与Chrome 109环境搭建及B站数据爬取实战
1. 项目概述与核心挑战最近在做一个数据分析项目需要批量获取B站热门视频的播放量、点赞、投币这些核心数据。一开始想着用Requests库配合API或者直接解析页面不就完了但实际操作下来发现B站这类现代SPA单页应用网站大量数据是通过JavaScript动态加载的直接发HTTP请求拿到的HTML源码里关键数据全是空的。这时候就得请出我们的老朋友——Selenium了。Selenium 3.141.0是一个经典的版本稳定且社区资源丰富而Chrome 109虽然已经不是最新版但在很多自动化环境中依然被广泛使用因为它与特定版本的ChromeDriver匹配关系明确避免了新版浏览器可能带来的未知兼容性问题。这个组合听起来很“复古”但恰恰是这种经过时间考验的搭配在爬虫项目中能提供最稳定的运行环境。毕竟爬虫脚本追求的不是追新而是稳定、可复现。这个项目的核心就是利用Selenium模拟真实用户操作浏览器完整地加载出B站热门页面比如“每周必看”、“全站排行榜”的所有JavaScript渲染内容然后从中精准地定位并提取我们需要的结构化数据。听起来简单但里面坑多得能绊倒一头大象。从驱动版本匹配、页面元素加载等待到反爬虫策略的应对每一步都可能让你调试到怀疑人生。接下来我就结合这次实战把从环境搭建到数据抓取再到最后优雅退出的完整流程以及我踩过的每一个坑和填坑方法毫无保留地分享给你。2. 环境搭建与版本匹配的“玄学”万事开头难对于Selenium爬虫来说这个“难”十有八九出在环境搭建上。版本不匹配是导致WebDriverException、浏览器无法启动、或者页面元素定位失败的最常见原因。我们必须像配钥匙一样精确匹配Chrome浏览器、ChromeDriver驱动和Selenium库这三个核心组件的版本。2.1 核心组件版本锁定与获取我选择的组合是Chrome浏览器 109.0.5414.120搭配ChromeDriver 109.0.5414.74并通过pip安装Selenium 3.141.0。为什么是109因为这个版本在各大镜像站和归档站点都容易找到且其生命周期内的最后一个ChromeDriver版本是确定的避免了“最新版”带来的不确定性。注意绝对不要在你的脚本中尝试绕过或提及任何网络访问限制的工具或方法。我们的所有操作都基于合法、公开可访问的浏览器和驱动从官方或可信的镜像站下载。第一步安装指定版本的Chrome浏览器。对于Windows用户直接搜索“Chrome 109离线安装包”通常能找到.exe文件。对于macOS或Linux用户可以去像https://www.slimjet.com/chrome/google-chrome-old-version.php这样的第三方站点查找历史版本下载链接。下载后正常安装即可。第二步获取对应版本的ChromeDriver。这是最关键的一步。ChromeDriver的主版本号必须与Chrome浏览器的主版本号完全一致。对于Chrome 109我们必须使用ChromeDriver 109.x.x.x。访问ChromeDriver的官方归档站点https://chromedriver.storage.googleapis.com/index.html。在页面列表中寻找109.0.5414.74/这个目录版本号可能略有微小差异但主版本109必须匹配。根据你的操作系统下载对应的文件如chromedriver_win32.zip用于Windows。解压后你会得到一个名为chromedriverWindows下为chromedriver.exe的可执行文件。第三步安装Selenium库。在命令行中执行以下命令指定版本安装pip install selenium3.141.02.2 驱动路径配置与启动验证下载好的chromedriver需要被Selenium找到。有两种常见方式方式一添加到系统PATH环境变量。这是最一劳永逸的方法。将chromedriver.exe所在的目录路径例如C:\WebDriver\bin添加到系统的PATH变量中。这样你在代码中初始化时就不需要指定路径了。方式二在代码中指定绝对路径。更推荐在项目中使用这种方式因为它更明确避免了因系统环境不同导致的问题。from selenium import webdriver # 指定chromedriver的绝对路径 driver_path rC:\你的路径\chromedriver.exe # Windows示例 # driver_path /Users/你的路径/chromedriver # macOS/Linux示例 driver webdriver.Chrome(executable_pathdriver_path) # Selenium 3.x 写法在Selenium 4.x中Service类被引入来管理驱动生命周期但我们的Selenium 3.141.0仍然使用executable_path参数。启动验证脚本from selenium import webdriver import time driver webdriver.Chrome(executable_pathdriver_path) # 替换为你的路径 driver.get(https://www.bing.com) # 使用一个稳定的网站测试 time.sleep(3) print(driver.title) # 应该能打印出Bing的标题 driver.quit()如果浏览器成功弹出并打开了Bing页面恭喜你最艰难的一步已经跨过去了。如果报错请再次核验浏览器和驱动的主版本号是否均为109。2.3 虚拟环境与依赖管理强烈建议为这个项目创建一个独立的Python虚拟环境如使用venv或conda。这能确保项目依赖的纯净性避免与其他项目的包版本冲突。# 创建虚拟环境 python -m venv bilibili_selenium_env # 激活虚拟环境 # Windows: bilibili_selenium_env\Scripts\activate # macOS/Linux: source bilibili_selenium_env/bin/activate # 在激活的环境内安装selenium pip install selenium3.141.0这样你的selenium 3.141.0就只服务于当前项目了。3. 页面导航与智能等待策略环境搞定我们开始写爬虫逻辑。第一个要攻克的难点就是等。等页面加载等元素出现等动态数据渲染完成。很多新手写的爬虫之所以不稳定十次运行有八次失败问题都出在“等”的策略上。3.1 显式等待爬虫稳定的基石绝对不要使用time.sleep(固定秒数)这种“硬等待”这是最糟糕的做法效率低下且不可靠。网络慢的时候等不够网络快的时候白浪费时间。Selenium提供了强大的“显式等待”Explicit Wait机制。from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC # 初始化驱动 driver webdriver.Chrome(executable_pathdriver_path) wait WebDriverWait(driver, 10) # 创建一个最多等待10秒的等待对象 # 访问B站热门页面 driver.get(https://www.bilibili.com/v/popular/weekly?spm_id_from333.1007.0.0) try: # 等待直到“每周必看”的标题区域出现证明页面主体已加载 title_element wait.until( EC.presence_of_element_located((By.CLASS_NAME, weekly-title)) ) print(页面主框架加载成功) except TimeoutException: print(等待页面标题超时可能页面结构已变化或网络问题) driver.quit() exit()这里的关键是WebDriverWait配合expected_conditions。EC.presence_of_element_located表示等待某个元素出现在DOM树中但不一定可见或可交互。对于需要点击的元素应使用EC.element_to_be_clickable。3.2 应对无限滚动的动态加载B站的热门列表如全站排行榜很多都是滚动到底部自动加载更多。用Selenium处理这种场景核心思路是模拟用户滚动行为并判断新内容是否已加载。from selenium.webdriver.common.keys import Keys import time # 假设我们已经进入了排行榜页面 last_height driver.execute_script(return document.body.scrollHeight) video_items [] while True: # 1. 滚动到底部 driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) # 等待新内容加载 time.sleep(2) # 这里可以用一个简短的固定等待因为动态加载时间不确定 # 2. 计算新的页面高度 new_height driver.execute_script(return document.body.scrollHeight) # 3. 收集当前已加载的所有视频卡片元素 current_items driver.find_elements(By.CSS_SELECTOR, .video-card) # 选择器需根据实际页面调整 video_items list(set(video_items current_items)) # 去重 print(f已加载视频数: {len(video_items)}) # 4. 判断是否滚动到了底部高度不再变化 if new_height last_height: print(已滚动到页面底部停止加载。) break last_height new_height # 安全措施防止无限循环比如最多加载10次 if len(video_items) 200: # 假设我们最多抓取200个 print(已达到最大抓取数量限制。) break这个循环会不断将页面滚动到底部直到页面高度不再增加意味着没有新内容了或者达到我们设定的抓取上限。注意这里的.video-card选择器只是一个示例你需要用浏览器的开发者工具F12去检查B站实际使用的CSS类名。3.3 关键元素定位与反爬虫应对B站的页面结构可能会调整所以元素定位器如ID、Class Name、XPath不能写死。最可靠的方法是使用相对稳定且具有语义化的选择器。使用开发者工具DevTools定位元素在Chrome中打开B站页面按F12。点击左上角的箭头图标然后在页面上点击你想抓取的数据比如视频标题。在Elements面板中右键点击高亮的代码行选择Copy-Copy selector或Copy XPath。示例提取一个视频卡片的信息假设我们通过上述滚动加载获得了视频卡片元素的列表video_items。for index, item in enumerate(video_items[:10]): # 先处理前10个做测试 try: # 标题通常在一个a标签里类名可能包含‘title’ # 使用find_element在item这个WebElement下查找而不是全局driver title_elem item.find_element(By.CSS_SELECTOR, a.title, .bili-video-card__info--tit) title title_elem.text.strip() link title_elem.get_attribute(href) # 播放量通常在一个包含‘play’或‘观看’文本的span里 # 更健壮的方式是查找特定的图标或类名 play_count_elem item.find_element(By.XPATH, .//span[contains(class, play) or contains(text(), 万)]) play_count play_count_elem.text # UP主类似逻辑 up_elem item.find_element(By.CSS_SELECTOR, .up-name, .bili-video-card__info--author) up_name up_elem.text print(f{index1}. 标题{title}) print(f 链接{link}) print(f 播放{play_count}) print(f UP主{up_name}) print(- * 50) except Exception as e: print(f提取第{index1}个视频信息时出错{e}) continue # 跳过这个出错的卡片继续下一个反爬虫应对小技巧随机延迟在循环处理每个视频卡片之间加入随机等待时间time.sleep(random.uniform(1, 3))模拟人类阅读速度。User-Agent虽然Selenium用的是真实浏览器但有些网站会检测WebDriver特征。可以通过options.add_argument(--disable-blink-featuresAutomationControlled)等实验性参数来尝试隐藏。不过对于B站常规操作通常足够。避免高频访问不要设计成不间断的循环请求。抓取一个列表后适当休息一段时间。4. 数据提取的实战代码与解析上面我们已经看到了如何定位单个元素。现在让我们构建一个完整的函数来解析B站热门页面以“全站排行榜”为例的一个视频卡片并提取尽可能多的字段。4.1 构建健壮的数据提取函数B站的页面结构并非一成不变我们需要一个能容忍一定变化、并备有后备方案的提取逻辑。def extract_video_data(video_card_element): 从一个视频卡片WebElement中提取结构化数据。 参数: video_card_element: Selenium WebElement对象代表一个视频卡片。 返回: 一个包含视频信息的字典。如果某个字段提取失败则记为None。 data { title: None, url: None, play_count: None, danmaku_count: None, # 弹幕数 like_count: None, # 点赞数 coin_count: None, # 投币数 favorite_count: None,# 收藏数 up_name: None, up_url: None, duration: None, # 视频时长 pub_time: None # 发布时间相对时间 } try: # 1. 标题和链接 - 通常是最稳定的 title_selectors [ a.title, .bili-video-card__info--tit a, [class*title] a ] data[title], data[url] _extract_by_selectors(video_card_element, title_selectors, attrhref) # 2. 播放量 play_selectors [ span[class*play], span:contains(播放), .bili-video-card__stats--item:first-child ] data[play_count] _extract_by_selectors(video_card_element, play_selectors) # 3. 弹幕数 danmaku_selectors [ span[class*danmu], span:contains(弹幕), .bili-video-card__stats--item:nth-child(2) ] data[danmaku_count] _extract_by_selectors(video_card_element, danmaku_selectors) # 4. UP主信息 up_selectors [ .up-name a, .bili-video-card__info--author a, [class*author] a ] data[up_name], data[up_url] _extract_by_selectors(video_card_element, up_selectors, attrhref) # 5. 时长 (通常在封面图上的一个标签) duration_selectors [ .bili-video-card__info--duration, [class*duration], .length ] data[duration] _extract_by_selectors(video_card_element, duration_selectors) # 6. 点赞、投币、收藏 (可能在鼠标悬停时才显示或需要进入详情页) # 这里以在列表页能直接抓取到的为例如果抓不到可以标记为需要深度抓取 # 这些数据有时在data-*属性里可以用get_attribute like_elem video_card_element.find_elements(By.CSS_SELECTOR, [class*like]) if like_elem: data[like_count] like_elem[0].text or like_elem[0].get_attribute(innerText) # 投币、收藏类似... except Exception as e: print(f提取视频卡片数据时发生错误: {e}) # 可以选择记录日志而不是让整个程序崩溃 return data def _extract_by_selectors(element, selector_list, attrNone): 辅助函数尝试用多个选择器从元素中提取文本或属性。 参数: element: 父WebElement。 selector_list: 选择器字符串列表。 attr: 需要提取的属性名如href为None则提取文本。 返回: 如果attr不为None返回(文本, 属性值)否则只返回文本。 如果所有选择器都失败返回(None, None)或None。 for selector in selector_list: try: target_elem element.find_element(By.CSS_SELECTOR, selector) if attr: return (target_elem.text.strip(), target_elem.get_attribute(attr).strip()) else: return target_elem.text.strip() except: continue if attr: return (None, None) else: return None这个extract_video_data函数尝试用多个可能的选择器去定位每个数据字段提高了代码的容错性。_extract_by_selectors辅助函数让这种“尝试链”变得简洁。4.2 主循环与数据存储现在我们将页面滚动、元素查找和数据提取串联起来并把结果保存下来。import json from selenium.common.exceptions import TimeoutException, NoSuchElementException import random def scrape_bilibili_hot_rank(url, max_videos50, scroll_pause_time2): 主爬取函数。 参数: url: 要爬取的B站排行榜/热门页面URL。 max_videos: 最大抓取视频数量。 scroll_pause_time: 每次滚动后等待新内容加载的时间秒。 driver webdriver.Chrome(executable_pathdriver_path) driver.get(url) all_video_data [] try: # 等待页面初始内容加载 wait WebDriverWait(driver, 15) wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, .video-card, .rank-item))) last_height driver.execute_script(return document.documentElement.scrollHeight) collected_ids set() # 用于根据URL或ID去重 while len(all_video_data) max_videos: # 1. 滚动 driver.execute_script(window.scrollTo(0, document.documentElement.scrollHeight);) time.sleep(scroll_pause_time random.uniform(0, 1)) # 增加随机性 # 2. 查找当前视图中的所有视频卡片 # 使用更通用的选择器根据实际情况调整 card_selectors [.video-card, .rank-item, .bili-video-card] video_cards [] for selector in card_selectors: video_cards driver.find_elements(By.CSS_SELECTOR, selector) if video_cards: break # 3. 提取新卡片的数据 for card in video_cards: try: # 生成一个唯一标识符例如基于链接 link_elem card.find_elements(By.CSS_SELECTOR, a[href*/video/]) if link_elem: video_url link_elem[0].get_attribute(href) video_id video_url.split(/)[-1] if video/ in video_url else video_url else: continue # 没有有效链接跳过 if video_id not in collected_ids: collected_ids.add(video_id) video_info extract_video_data(card) video_info[id] video_id if video_info[title]: # 只保存有标题的有效数据 all_video_data.append(video_info) print(f已采集: {video_info[title]}) # 达到数量限制则退出 if len(all_video_data) max_videos: break except NoSuchElementException: continue except Exception as e: print(f处理卡片时出错: {e}) continue # 4. 检查是否已滚动到底部 new_height driver.execute_script(return document.documentElement.scrollHeight) if new_height last_height: print(已滚动到底部无法加载更多内容。) break last_height new_height # 5. 防止被ban每处理一些数据后稍作休息 if len(all_video_data) % 10 0: time.sleep(random.uniform(2, 5)) except TimeoutException: print(页面加载超时。) except Exception as e: print(f爬取过程中发生未知错误: {e}) finally: # 无论如何最后都要关闭浏览器 driver.quit() return all_video_data # 执行爬取 if __name__ __main__: # B站全站排行榜日榜 hot_url https://www.bilibili.com/v/popular/rank/all video_data_list scrape_bilibili_hot_rank(hot_url, max_videos30) # 保存为JSON文件 with open(bilibili_hot_videos.json, w, encodingutf-8) as f: json.dump(video_data_list, f, ensure_asciiFalse, indent2) print(f爬取完成共获得 {len(video_data_list)} 条视频数据。已保存至 bilibili_hot_videos.json)4.3 数据清洗与格式化从网页直接抓取的数据往往包含多余的空格、换行符或中文单位如“万”。在存储前进行清洗会让后续分析更方便。def clean_video_data(raw_data_list): 清洗和格式化爬取的原始数据。 cleaned_list [] for item in raw_data_list: cleaned item.copy() # 清洗播放量将“1.2万”转换为12000 play_str cleaned.get(play_count, ) if play_str: cleaned[play_count_clean] _parse_count_string(play_str) # 清洗时长将“12:34”转换为秒数754 duration_str cleaned.get(duration, ) if duration_str and : in duration_str: parts duration_str.split(:) if len(parts) 2: mins, secs parts cleaned[duration_seconds] int(mins) * 60 int(secs) elif len(parts) 3: hours, mins, secs parts cleaned[duration_seconds] int(hours)*3600 int(mins)*60 int(secs) # 去除标题等文本字段的首尾空白字符 for text_field in [title, up_name]: if cleaned.get(text_field): cleaned[text_field] cleaned[text_field].strip() cleaned_list.append(cleaned) return cleaned_list def _parse_count_string(count_str): 将中文计数字符串转换为整数。 例如: “1.2万” - 12000, “305” - 305 try: if 万 in count_str: num float(count_str.replace(万, )) return int(num * 10000) elif 亿 in count_str: num float(count_str.replace(亿, )) return int(num * 100000000) else: # 移除逗号等分隔符 return int(count_str.replace(,, )) except: return None # 在保存前调用清洗函数 cleaned_data clean_video_data(video_data_list)这样你最终得到的cleaned_data就是一个更干净、更易于分析的结构化数据列表了。5. 常见问题排查与实战避坑指南即使代码写得再严谨在实际运行中你还是会遇到各种各样的问题。下面是我在多次爬取B站数据过程中总结出来的“坑位图”和填坑方法。5.1 驱动与浏览器相关问题问题1SessionNotCreatedException: Message: session not created: This version of ChromeDriver only supports Chrome version XX原因ChromeDriver版本与已安装的Chrome浏览器版本不匹配。这是最常见的问题。解决在浏览器地址栏输入chrome://version/查看你的Chrome主要版本号如109.0.5414.120。访问ChromeDriver归档站下载与之主版本号完全相同的驱动如109.x.x.x。确保代码中webdriver.Chrome()指向的是新下载的驱动路径。问题2浏览器闪退或启动后立即关闭原因脚本执行完毕或发生未捕获的异常导致driver.quit()被调用。也可能是驱动与浏览器不兼容的另一种表现。解决在代码最后或finally块中检查driver.quit()的调用时机确保你希望在浏览器中手动查看时暂时注释掉这行。在初始化驱动时添加options.add_experimental_option(detach, True)可以让浏览器在脚本结束后保持打开状态但需注意资源释放。使用try...except...finally结构确保异常时也能正确退出避免残留进程。问题3WebDriverException: Message: unknown error: cannot find Chrome binary原因Selenium找不到Chrome浏览器的安装位置。解决通过options.binary_location指定Chrome可执行文件的绝对路径。from selenium.webdriver.chrome.options import Options chrome_options Options() # 指定你的Chrome浏览器程序路径 chrome_options.binary_location rC:\Program Files\Google\Chrome\Application\chrome.exe driver webdriver.Chrome(executable_pathdriver_path, optionschrome_options)5.2 页面元素定位与交互问题问题4NoSuchElementException或TimeoutException原因元素选择器失效页面改版、页面未完全加载、元素在iframe内、或元素是动态生成的。解决优先使用显式等待这是根本解决方法。确保在操作元素前它已经出现并可交互。更新选择器定期检查页面结构。使用相对路径的CSS选择器或XPath避免依赖绝对路径和易变的ID。检查iframe如果元素在iframe里必须先使用driver.switch_to.frame(frame_element)切换到对应的iframe内才能操作。备用选择器链如我们之前代码所示准备多个可能的选择器逐个尝试。尝试JS直接获取对于极难定位的元素可以尝试用driver.execute_script()执行JavaScript来获取数据。问题5页面无限滚动加载不停陷入死循环原因滚动触发了新的加载但页面高度计算逻辑有误或者有“加载更多”按钮需要点击。解决设置明确的退出条件除了判断高度是否变化还要加上最大滚动次数或最大收集数量的限制。检查是否有“点击加载”按钮有些页面是点击按钮加载不是无限滚动。代码需要识别并点击这个按钮。try: load_more_button driver.find_element(By.CSS_SELECTOR, .load-more-btn) load_more_button.click() time.sleep(2) # 等待新内容加载 except NoSuchElementException: # 没有更多按钮可能是无限滚动或已加载完毕 pass调试输出在滚动循环中加入打印语句输出当前高度和已收集项目数便于观察逻辑是否正确。5.3 反爬虫策略与稳定性优化问题6访问频率稍高就出现验证码或请求被拒绝原因服务器检测到自动化脚本行为。解决降低请求频率在关键操作如翻页、滚动之间加入随机延迟time.sleep(random.uniform(2, 5))。模拟人类行为除了延迟还可以随机滚动页面中间位置模拟阅读。使用更“人性化”的选项chrome_options Options() # 禁用自动化控制标志部分环境有效 chrome_options.add_experimental_option(excludeSwitches, [enable-automation]) chrome_options.add_experimental_option(useAutomationExtension, False) # 可以尝试添加常规的用户代理但Selenium本身会暴露WebDriver # chrome_options.add_argument(user-agentMozilla/5.0 ...) driver webdriver.Chrome(optionschrome_options, executable_pathdriver_path) # 执行CDP命令覆盖navigator.webdriver属性Chrome 79 driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); })核心保持低调。对于公开数据单账号、低频率的抓取通常不会触发强力反爬。如果需求量巨大需要考虑分布式、代理IP等更复杂的方案但这超出了基础指南的范围且必须严格遵守网站的服务条款。问题7脚本运行一段时间后内存占用越来越高最终卡死原因Selenium驱动的浏览器实例未正确清理或者页面内容累积过多。解决确保driver.quit()被调用使用try...finally块。定期清理页面对于超长滚动页面可以考虑定期执行driver.execute_script(document.body.innerHTML ;)来清空DOM但注意这会清掉所有元素需在数据已提取后操作。分批次抓取不要试图一次抓取成千上万条数据。分成多个小任务每次抓取几百条后重启浏览器。使用无头模式Headless无头模式通常消耗资源更少。添加chrome_options.add_argument(--headless)即可。但在调试阶段建议使用有头模式便于观察。5.4 数据提取与处理问题问题8提取到的文本是空的或者包含乱码原因元素可能隐藏display: none或visibility: hidden或者文本由伪元素生成或者编码问题。解决检查元素可见性使用EC.visibility_of_element_located等待条件。尝试获取innerText或textContent属性element.get_attribute(innerText)有时比element.text更可靠。处理动态文本有些数据可能在属性里如>