
1. 项目缘起为什么选择爬取股吧评论做量化分析的朋友都知道情绪因子是A股市场一个非常有意思的观测维度。散户的情绪波动往往能提前反映在股吧、论坛的讨论热度与情感倾向上。东方财富的股吧作为国内流量最大、用户最活跃的股票讨论社区其评论区蕴藏着海量的、未经加工的散户情绪数据。这些数据对于构建市场情绪指数、进行事件驱动型策略研究甚至是对冲高频交易中的噪音都有着不可估量的价值。然而股吧的页面结构复杂评论数据加载方式多样有直接渲染的也有滚动加载和点击“查看更多”异步加载的并且东方财富作为大型财经平台其反爬机制也相当完善。单纯用requests库去模拟HTTP请求你会发现自己很快就被403拒之门外或者拿到的HTML里根本没有评论内容。这就是为什么这个项目最终选择了Selenium作为核心工具。它不是一个“轻量级”的爬虫方案但它能最真实地模拟人类浏览器的行为完美应对动态渲染和复杂交互是攻克此类反爬严密的“堡垒型”网站最可靠的“重武器”。我这次的目标很明确不是简单地抓取一页评论而是要构建一个稳定、可配置、能应对反爬、并且能将数据规整存储的爬虫系统。整个过程涉及环境搭建、页面分析、元素定位策略、反爬应对、数据清洗和持久化等多个环节。下面我就把这次实战中的核心思路、踩过的坑以及总结的经验毫无保留地分享出来。2. 战前准备Selenium环境搭建与核心配置工欲善其事必先利其器。用Selenium第一步就是把环境配通。这里面的坑从浏览器驱动版本匹配到网络环境配置一个比一个“经典”。2.1 浏览器与驱动版本锁死的艺术很多人环境跑不起来十有八九是栽在了Chrome浏览器和chromedriver的版本不匹配上。它们俩必须是大版本号一致。我的经验是先确定你本地Chrome的版本再去下载对应版本的chromedriver。查看Chrome版本打开Chrome在地址栏输入chrome://settings/help回车后就能看到版本号比如版本 128.0.6613.138正式版本。下载对应驱动访问https://chromedriver.chromium.org/或国内镜像站。找到与你Chrome大版本号如128一致的chromedriver版本下载。如果你的Chrome是最新版而官网还未提供对应驱动可以尝试下载版本号最接近的通常是低一两个小版本有时也能工作但最稳妥的是暂时将Chrome回退一个稳定版本。放置与配置下载的chromedriver.exe可以放在任意目录但需要将该目录添加到系统的PATH环境变量中。更简单的做法是在Python代码里指定它的绝对路径。一个更“无痛”的解决方案是使用webdriver-manager库。它能自动检测你的浏览器版本并下载匹配的驱动堪称环境搭建的“救星”。pip install webdriver-manager然后在代码中这样使用from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice)2.2 绕过常见陷阱urllib3版本冲突与网络环境在安装依赖时你可能会遇到这样一个报错selenium 4.5.0 requires urllib3[socks]~1.26, but you have urllib3 2.6.3。这是因为Selenium旧版本对urllib3有特定要求。现在的解决方案很简单安装最新版的Selenium。新版本已经解决了这个依赖冲突。pip install selenium --upgrade # 通常升级到4.x的最新版即可另一个至关重要的准备是网络环境。东方财富等国内网站对海外的IP访问有时会施加更严格的限制或出现加载不全的问题。为了保证爬虫的稳定性和速度务必在国内网络环境下运行你的脚本。使用公司、家庭或国内云服务器的网络能避免很多意想不到的加载失败和验证码问题。2.3 驱动初始化给浏览器加上“隐身斗篷”直接启动的浏览器实例很容易被网站识别为自动化脚本。我们需要进行一些配置来让它看起来更像个“真人”。from selenium import webdriver from selenium.webdriver.chrome.options import Options def create_stealth_driver(): chrome_options Options() # 核心隐身配置 chrome_options.add_argument(--disable-blink-featuresAutomationControlled) chrome_options.add_experimental_option(excludeSwitches, [enable-automation]) chrome_options.add_experimental_option(useAutomationExtension, False) # 实用配置 chrome_options.add_argument(--no-sandbox) # 解决Linux下的一些权限问题 chrome_options.add_argument(--disable-dev-shm-usage) # 解决共享内存问题 chrome_options.add_argument(--disable-gpu) # 某些虚拟环境需要 # chrome_options.add_argument(--headless) # 无头模式不显示浏览器窗口。调试阶段建议先注释掉方便观察。 # 反反爬修改navigator.webdriver属性 driver webdriver.Chrome(optionschrome_options) driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); }) return driver注意调试阶段强烈建议不要开启--headless无头模式。亲眼看着浏览器操作能帮你快速定位是脚本逻辑问题还是页面元素没加载出来。等脚本完全稳定后再开启无头模式用于生产环境。3. 页面攻坚战分析股吧评论的加载逻辑与元素定位环境配好浏览器能跑起来了接下来就是真正的战斗找到评论数据在哪里以及如何把它“掏出来”。东方财富股吧的评论页面是一个经典的动态渲染分段加载案例。3.1 页面结构剖析不止一个“评论区”首先你需要明确目标URL的格式。通常一个股票帖子的页面链接类似于https://guba.eastmoney.com/news,{股票代码},{帖子id}.html打开这样一个页面你会发现评论呈现是分区的主楼评论帖子正文下方的第一波评论通常是直接渲染在HTML中的。“查看更多回复”针对某一条评论的次级回复需要点击“查看更多回复”或“展开*条回复”才会加载。分页帖子热度高时评论会分页显示需要点击页码或“下一页”。我们的策略需要分层处理先抓取所有直接展示的评论然后递归地处理“查看更多回复”最后处理分页。3.2 元素定位策略XPath与CSS Selector的抉择Selenium提供了多种定位元素的方法id,name,class_name,tag_name,link_text,partial_link_text,xpath,css_selector。对于股吧这种class名可能随机化、结构复杂的页面XPath和CSS Selector是最强大的武器。CSS Selector通常更简洁阅读起来更直观性能稍好。适合基于id、class、层级关系的定位。例如div.reply_item查找所有class包含reply_item的div元素。XPath功能更强大可以基于文本内容、元素顺序、复杂逻辑关系进行定位。在需要定位“包含特定文字”的按钮如“查看更多回复”时几乎是唯一选择。例如//span[contains(text(), ‘查看更多回复’)]查找文本中包含“查看更多回复”的span元素。我的经验是优先使用CSS Selector进行常规元素抓取因为它更稳定当需要基于可变文本或复杂路径定位时果断使用XPath。在浏览器开发者工具中你可以直接右键元素选择“Copy” - “Copy XPath”或“Copy selector”但这只是一个起点通常需要根据页面结构进行优化和调整使其更健壮less brittle。3.3 等待的艺术显式等待是稳定性的基石这是Selenium爬虫成败的关键。绝对不能使用time.sleep(固定秒数)这种“硬等待”。网络延迟、页面加载速度不确定固定等待要么浪费时间要么导致元素未加载就执行操作而报错。必须使用显式等待Explicit Wait。它告诉Selenium在最多N秒内持续检查某个条件是否成立如元素是否出现、是否可点击一旦成立就立即执行后续操作。from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By # 等待“查看更多回复”按钮出现并可点击 try: more_button WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.XPATH, //span[contains(text(), 查看更多回复)])) ) more_button.click() except Exception as e: print(未找到‘查看更多回复’按钮或等待超时, e) # 这里可以记录日志然后继续处理下一条评论而不是让整个脚本崩溃对于整个评论列表的加载可以使用等待某个代表列表加载完成的元素出现# 等待评论列表容器加载出来 comment_list_present EC.presence_of_element_located((By.CSS_SELECTOR, div.comment_list_container)) WebDriverWait(driver, 15).until(comment_list_present)4. 核心爬取逻辑实现递归展开与数据提取有了前面的铺垫我们可以构建核心的爬取函数了。这个函数需要具备递归展开子回复和翻页的能力。4.1 定义数据模型与主抓取函数首先定义我们要抓取的数据字段。一条评论通常包括comment_id: 评论唯一标识可从元素id或>import time from dataclasses import dataclass from typing import Optional dataclass class Comment: comment_id: str user_name: str content: str publish_time: str like_count: int reply_count: int parent_id: Optional[str] None post_id: Optional[str] None def fetch_post_comments(driver, post_url): 抓取单个帖子的所有评论 driver.get(post_url) time.sleep(2) # 初始页面加载可配合显式等待优化 all_comments [] current_page 1 while True: print(f正在抓取第{current_page}页评论...) # 1. 抓取当前页所有顶层评论 page_comments extract_comments_from_current_page(driver) all_comments.extend(page_comments) # 2. 对当前页每条评论尝试展开并抓取其子回复 for comment in page_comments: if comment.reply_count 0: # 如果有回复 sub_comments expand_and_fetch_replies(driver, comment.comment_id) all_comments.extend(sub_comments) # 3. 尝试翻到下一页 try: next_button WebDriverWait(driver, 5).until( EC.element_to_be_clickable((By.CSS_SELECTOR, a.pagination_next:not(.disabled))) ) next_button.click() # 等待新页面评论加载 WebDriverWait(driver, 10).until( EC.staleness_of(page_comments[0].web_element) # 等待旧元素失效 ) time.sleep(1) # 短暂稳定等待 current_page 1 except Exception: print(已是最后一页或翻页失败停止翻页。) break return all_comments4.2 递归展开子回复函数这是技术难点。需要定位到具体评论的“展开”按钮点击后等待子回复加载再提取数据并且子回复可能还有更深层的回复。def expand_and_fetch_replies(driver, parent_comment_id): 展开并抓取指定父评论下的所有子回复 sub_comments [] # 构建定位父评论和其展开按钮的XPath # 假设父评论有一个data-comment-id属性 parent_comment_xpath f//div[data-comment-id{parent_comment_id}] try: # 查找该父评论下的“查看更多回复”按钮 more_reply_btn driver.find_element(By.XPATH, f{parent_comment_xpath}//span[contains(text(), 查看更多回复)]) driver.execute_script(arguments[0].click();, more_reply_btn) # 使用JS点击有时更可靠 time.sleep(1.5) # 等待回复加载可优化为显式等待 # 定位加载出来的子回复列表容器 reply_list_xpath f{parent_comment_xpath}/following-sibling::div[contains(class, reply_list)] WebDriverWait(driver, 8).until( EC.presence_of_element_located((By.XPATH, reply_list_xpath)) ) # 提取子回复 reply_elements driver.find_elements(By.XPATH, f{reply_list_xpath}//div[contains(class, reply_item)]) for elem in reply_elements: comment_data parse_comment_element(elem, parent_comment_id) if comment_data: sub_comments.append(comment_data) # 递归如果子回复本身也有回复继续展开 if comment_data.reply_count 0: grand_sub expand_and_fetch_replies(driver, comment_data.comment_id) sub_comments.extend(grand_sub) except Exception as e: print(f展开父评论 {parent_comment_id} 的回复时出错: {e}) # 记录错误但不要终止整个流程 return sub_comments4.3 数据解析与清洗函数parse_comment_element函数负责从单个评论HTML元素中提取结构化数据。这里需要仔细研究股吧页面的实际HTML结构。def parse_comment_element(comment_element, parent_idNone): 从单个评论元素中解析数据 try: # 这些选择器需要根据股吧实际HTML结构调整 # 这里只是示例你必须用开发者工具自己分析 user_elem comment_element.find_element(By.CSS_SELECTOR, .user_name a) user_name user_elem.text.strip() content_elem comment_element.find_element(By.CSS_SELECTOR, .comment_content) content content_elem.text.strip() time_elem comment_element.find_element(By.CSS_SELECTOR, .publish_time) publish_time time_elem.get_attribute(title) or time_elem.text.strip() # 有时时间在title里 like_elem comment_element.find_element(By.CSS_SELECTOR, .like_count) like_count int(like_elem.text) if like_elem.text.isdigit() else 0 # 回复数可能藏在“共X条回复”的文本里 reply_text_elem comment_element.find_element(By.CSS_SELECTOR, .reply_text) reply_text reply_text_elem.text import re reply_count_match re.search(r共(\d)条回复, reply_text) reply_count int(reply_count_match.group(1)) if reply_count_match else 0 # 获取评论ID - 通常从元素id或data-*属性中获取 comment_id comment_element.get_attribute(data-comment-id) or comment_element.get_attribute(id) if not comment_id: # 如果都没有可以自己生成一个唯一标识比如结合用户和时间戳 comment_id f{user_name}_{publish_time} return Comment( comment_idcomment_id, user_nameuser_name, contentcontent, publish_timepublish_time, like_countlike_count, reply_countreply_count, parent_idparent_id ) except Exception as e: print(f解析评论元素时出错: {e}) # 可以在这里记录日志或者返回None return None5. 反爬对抗与稳定性优化东方财富不会坐视数据被轻易抓取。除了动态加载还可能遇到IP限制、验证码、行为检测等。5.1 请求节奏控制与随机化即使使用Selenium模拟真人过于密集的操作也会触发风控。随机等待时间在关键操作点击、翻页之间插入随机延时。import random def random_delay(min_s1, max_s3): time.sleep(random.uniform(min_s, max_s)) # 在点击后使用 more_button.click() random_delay(1, 2.5)滚动页面在抓取前随机滚动页面模拟阅读行为。def random_scroll(driver): scroll_height driver.execute_script(return document.body.scrollHeight) scroll_to random.randint(200, scroll_height // 2) driver.execute_script(fwindow.scrollTo(0, {scroll_to});) time.sleep(random.uniform(0.5, 1.5))5.2 应对验证码与登录态验证码如果遇到验证码弹窗最简单的策略是暂停脚本手动处理。可以设置一个超时等待检测验证码元素是否出现如果出现则time.sleep(60)给你手动输入的时间。对于复杂项目可以考虑集成打码平台API。登录态股吧大部分内容无需登录但某些高频操作或查看更多历史评论可能需要。你可以先用Selenium手动登录一次然后使用driver.get_cookies()获取cookies并保存。后续脚本运行时通过driver.add_cookie()加载cookies恢复登录态。注意cookies有有效期。5.3 异常处理与断点续爬爬虫必须健壮。网络波动、元素定位失败、页面结构微调都可能导致脚本中断。全面的Try-Except对每一个可能失败的操作查找元素、点击、获取文本进行异常捕获记录错误日志并尽可能让程序继续运行或优雅降级。数据持久化不要等所有数据抓完再存。应该每抓取一页或一批评论就立即追加存储到文件如JSON行格式jsonlines或数据库。这样即使脚本中途崩溃已抓取的数据也不会丢失。状态记录记录已成功抓取的帖子ID、页码。重启脚本时可以先读取这个状态文件跳过已抓取的部分实现断点续爬。import json import os STATE_FILE crawl_state.json def load_state(): if os.path.exists(STATE_FILE): with open(STATE_FILE, r, encodingutf-8) as f: return json.load(f) return {last_post_id: None, completed_pages: {}} def save_state(state): with open(STATE_FILE, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) # 在主循环中使用 state load_state() if post_id ! state.get(last_post_id): # 开始抓取新帖子 pass6. 数据存储、分析与后续思路抓取不是终点让数据产生价值才是。6.1 存储方案选择JSON / CSV适合中小规模数据、快速原型验证。使用jsonlines格式每行一个JSON对象便于追加。SQLite轻量级数据库无需安装服务器适合单机爬虫项目。便于进行复杂的查询和去重。MongoDB文档型数据库非常适合存储非结构化的评论数据字段增减灵活。MySQL/PostgreSQL关系型数据库如果数据需要与其他结构化数据如股票行情关联分析这是更好的选择。这里给出一个SQLite存储的示例import sqlite3 import pandas as pd def init_db(db_pathguba_comments.db): conn sqlite3.connect(db_path) c conn.cursor() c.execute( CREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, comment_id TEXT UNIQUE, post_id TEXT, parent_id TEXT, user_name TEXT, content TEXT, publish_time TEXT, like_count INTEGER, reply_count INTEGER, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() conn.close() def save_to_db(comment_list, db_pathguba_comments.db): conn sqlite3.connect(db_path) for comment in comment_list: try: conn.execute( INSERT OR IGNORE INTO comments (comment_id, post_id, parent_id, user_name, content, publish_time, like_count, reply_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?) , (comment.comment_id, comment.post_id, comment.parent_id, comment.user_name, comment.content, comment.publish_time, comment.like_count, comment.reply_count)) except sqlite3.IntegrityError: # 重复数据忽略或更新 pass conn.commit() conn.close()6.2 简单的数据分析示例数据存好后可以用pandas进行快速分析。import pandas as pd import matplotlib.pyplot as plt # 从数据库读取 conn sqlite3.connect(guba_comments.db) df pd.read_sql_query(SELECT * FROM comments WHERE post_id目标帖子ID, conn) conn.close() # 1. 评论时间分布 df[publish_time] pd.to_datetime(df[publish_time]) df[hour] df[publish_time].dt.hour hourly_counts df[hour].value_counts().sort_index() hourly_counts.plot(kindbar, title评论时间分布按小时) plt.show() # 2. 活跃用户TOP10 active_users df[user_name].value_counts().head(10) print(最活跃用户) print(active_users) # 3. 评论情感倾向简单关键词法 positive_words [看好, 上涨, 牛, 支持, 利好] negative_words [看空, 下跌, 跑, 利空, 垃圾] def simple_sentiment(text): text str(text) pos_score sum(1 for word in positive_words if word in text) neg_score sum(1 for word in negative_words if word in text) return pos_score - neg_score df[sentiment] df[content].apply(simple_sentiment) sentiment_dist df[sentiment].value_counts() print(情感分布) print(sentiment_dist)6.3 项目扩展与优化方向分布式爬虫使用Scrapy-Redis或Celery框架将任务分发到多台机器或多个浏览器实例大幅提升抓取效率。代理IP池集成当单IP访问频率过高被限制时需要集成代理IP。Selenium可以通过--proxy-server命令行参数或ChromeOptions配置代理。更智能的反反爬使用undetected-chromedriver等更高级的库来进一步隐藏自动化特征。模拟更真实的鼠标移动轨迹如PyAutoGUI。评论情感深度分析接入NLP模型如SnowNLP、BERT进行更准确的情感分析而不是简单的关键词匹配。实时监控与预警将爬虫部署为定时任务监控特定股票或关键词的评论情绪变化当情绪指数剧烈波动时触发预警。7. 实战中踩过的坑与心得最后分享几个只有真正动手做过才会深刻体会的教训。坑一XPath/CSS Selector 过于脆弱。股吧前端的class名可能会随着版本更新而改变甚至包含随机哈希值。不要依赖完整的class名使用contains函数进行部分匹配。例如用div[class*reply_item]而不是div.reply_item_abc123。同时尽量使用相对路径和更具语义的标签组合来定位减少对单一属性的依赖。坑二忽略了iframe。有时评论框或部分内容可能嵌套在iframe中。如果无论如何都找不到元素检查一下页面里是否有iframe。需要用driver.switch_to.frame(frame_element)切换到对应的iframe内才能操作其中的元素操作完记得用driver.switch_to.default_content()切回来。坑三内存泄漏与浏览器僵死。长时间运行Selenium爬虫尤其是打开大量页面而不关闭会导致Chrome进程内存占用越来越高。我的解决方案是每处理完50-100个帖子就完全重启一次浏览器driver.quit()然后重新create_stealth_driver()。虽然会损失一些时间但保证了长期运行的稳定性。心得一日志系统至关重要。不要只用print。使用Python的logging模块将不同级别的信息INFO、WARNING、ERROR输出到文件和控制台。记录下每个帖子的抓取状态、遇到的异常、跳过的条目。当脚本在半夜运行时这些日志是你第二天排查问题的唯一依据。心得二先爬“小样”再铺开。不要一开始就写一个抓取全站评论的宏大框架。先针对一个具体的帖子URL把单页评论抓取、展开回复、翻页这个完整链路跑通并保存下数据。这个“小样”脚本稳定后再往外扩展加入帖子列表遍历、异常处理、状态管理等功能。步步为营能节省大量调试时间。心得三尊重robots.txt与法律边界。在爬取前务必查看https://guba.eastmoney.com/robots.txt。虽然robots.txt没有法律强制力但它表明了网站运营者的态度。控制你的爬取频率避免对目标网站服务器造成明显压力。数据的用途应限于个人学习与研究切勿用于商业牟利或侵害他人权益这是每个技术从业者应有的底线。