ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

东方财富股吧Python爬虫实战:绕过JS渲染与签名反爬

东方财富股吧Python爬虫实战:绕过JS渲染与签名反爬 简介这是一份面向Python爬虫初学者的实战项目资源聚焦东方财富网股吧数据采集场景解决股票社区内容发帖评论自动化获取与结构化存储问题。项目基于Selenium模拟用户操作支持多线程抓取多支股票的帖子标题、浏览量、评论数、发布时间及完整评论内容含层级、点赞数、时间并分别存入MongoDB对应集合通过_id与post_id建立关联便于后续分析。压缩包共14个文件4个核心Python脚本实现主流程、解析、爬取与数据库交互4张界面截图辅助理解页面结构1份PDF操作说明详述配置与运行步骤另有JS反爬绕过脚本、Markdown文档与许可证文件总大小4.02MB。已有707人学习下载配套清晰的目录模块划分、典型报错截图与进度可视化示例特别适合非科班新手理解网页交互逻辑、掌握MongoDB基础写入及小规模数据采集全流程。1. 项目概述为什么股吧数据值得爬又为什么必须谨慎对待我做金融数据采集类项目有八年多了从最早用Excel手动扒财经论坛到后来写自动化脚本批量抓取行情快照再到如今给券商自营部门搭实时舆情监控系统——东方财富网股吧始终是绕不开的一环。它不是最规范的数据源但却是A股市场情绪最真实、最即时、最草根的“温度计”。你能在股吧里看到散户对某只股票的集体亢奋也能捕捉到突发利空后评论区的沉默蔓延这种非结构化文本数据恰恰是量化模型里最难替代的“另类alpha信号”。但现实很骨感东方股吧反爬不是一句空话。2023年中开始他们把前端渲染逻辑从纯HTML转向了大量JavaScript动态加载评论折叠、分页懒加载、用户头像异步请求、发帖时间模糊化处理……这些都不是障眼法而是实打实的工程级防护。我见过太多新手用requests直接GET首页就以为能开干结果连第一页标题都拿不全——因为关键内容压根不在初始HTML里而藏在后续XHR请求返回的JSON里。更麻烦的是股吧的请求头校验越来越严User-Agent轮换、Referer伪造、Cookie有效期管理甚至对请求频率做了滑动窗口限流稍不注意就会触发403或跳转到验证码页。所以这个项目的核心价值从来不是“能不能爬”而是“怎么在合规边界内稳定、可持续、低干扰地获取有效信息”。我们不碰用户隐私字段如手机号、身份证脱敏后的中间位不高频刷取同一股票ID所有数据仅用于个人学习分析或内部投研参考绝不用于商业分发。数据库选型上我坚持用SQLite起步PostgreSQL过渡最终落地到TimescaleDB——不是为了炫技而是因为股吧数据天然带有时序属性每条评论都有毫秒级时间戳每个帖子的热度曲线需要按分钟聚合传统关系型数据库查起来太慢而专用时序库能轻松支撑百万级日增数据量。如果你正打算用python爬虫练手或者需要为毕业设计《数据库课程设计》找一个有真实业务背景的案例又或者想给自己的量化策略加一道舆情过滤器——这个项目就是为你准备的。它不教你花哨的逆向工程而是聚焦在可复现、可维护、可审计的工程实践上。下面我会从零开始带你拆解每一个环节怎么绕过前端渲染陷阱怎么设计增量更新机制怎么让数据库结构既灵活又高效以及那些只有踩过坑才懂的细节。2. 整体架构设计与技术选型逻辑2.1 为什么放弃“一锅端”式爬取选择分层解耦架构早期我试过用Selenium模拟浏览器操作把整个股吧页面当网页截图来处理——结果是CPU常年95%内存泄漏严重跑两天就得重启。后来改用Pyppeteer虽然稳定性提升但单机并发上限卡死在8个实例成本太高。直到2022年重构时我才彻底转向**“请求分析API直采本地渲染”三层架构**。这不是为了标新立异而是被股吧的实际响应模式逼出来的。股吧的数据流其实很清晰第一层导航层股票列表页如https://guba.eastmoney.com/list,600519,f_1.html返回的是静态HTML里面只包含帖子标题、作者、发布时间、阅读数、评论数等摘要信息第二层内容层点击某个帖子进入详情页如https://guba.eastmoney.com/news,600519,123456789.html页面主体仍是静态HTML但评论区是空的需要发起额外XHR请求第三层交互层评论数据通过https://guba.eastmoney.com/quote,600519,123456789,1,20.html这类接口返回JSON且接口参数里藏着加密的sign字段。如果强行用一个工具包打穿三层代码会变得极其脆弱。比如某天股吧把sign生成算法从MD5换成HMAC-SHA256你得重写整个认证模块再比如他们把评论分页从page1改成offset0limit20你得全局替换URL拼接逻辑。而分层之后每一层都可以独立测试、独立替换导航层用requestsBeautifulSoup足矣内容层用requests加精准CSS选择器交互层则封装成独立的CommentFetcher类只负责解析sign和构造请求。提示不要迷信“万能爬虫框架”。Scrapy在电商类网站上如鱼得水但在股吧这种强JS依赖、弱标准协议的场景下反而增加了调试复杂度。我现在的主力栈是requests lxml apscheduler SQLAlchemy轻量、可控、易调试。2.2 数据库选型从SQLite到TimescaleDB的演进路径很多人一上来就奔着MySQL或PostgreSQL去觉得“正规”。但实际项目里数据库选型必须匹配数据生命周期。股吧数据有三个典型阶段探索期0~1周你还在试错URL规则、验证字段提取逻辑、确认反爬强度。这时候用SQLite最省事——单文件、零配置、Python内置支持。建表语句就一行CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, author TEXT, post_time TIMESTAMP, read_count INTEGER, comment_count INTEGER);。所有操作都在本地磁盘完成失败了删掉db文件重来毫无心理负担。验证期1~4周你确定了核心字段提取准确率99%开始跑全量股票约5000只日增数据量突破10万条。这时SQLite的写入锁瓶颈就暴露了并发插入时经常报database is locked。我当时的解决方案是切换到PostgreSQL并启用连接池psycopg2SQLAlchemy的QueuePool。但很快发现新问题——按股票代码日期做分区查询太慢比如查“贵州茅台近7天热评”得扫描全表索引。生产期4周数据量稳定在千万级需要支持分钟级热度统计、情感倾向实时计算、异常发言告警。这时TimescaleDB就成了唯一选择。它本质是PostgreSQL的扩展但把时间序列操作做到了极致。建表时只需声明PARTITION BY RANGE(time)查询SELECT * FROM comments WHERE stock_code600519 AND time NOW() - INTERVAL 7 days执行计划显示走的是分区剪枝耗时从3.2秒降到0.08秒。注意TimescaleDB不是银弹。它要求主键必须包含时间字段且时间字段类型必须是TIMESTAMPTZ。我在迁移时踩过最大的坑是把股吧原始时间字符串2023-08-15 14:22直接存成TEXT结果Timescale无法识别分区边界。正确做法是用dateutil.parser.parse()转成datetime对象再存入数据库。2.3 爬虫类型决策为什么选“增量型”而非“批量型”网络热词里提到(1)批量型爬虫 (2)增量型爬虫 (3)垂直型爬虫的应用场景这确实是关键分水岭。批量型适合一次性采集历史归档数据比如抓取2020年全年股吧热帖但股吧的业务价值恰恰在于实时性——涨停板前10分钟的评论情绪比一年前的讨论重要100倍。增量型爬虫的核心是状态追踪。我的方案是用一张crawl_status表记录每只股票的最新抓取时间点CREATE TABLE crawl_status ( stock_code VARCHAR(10) PRIMARY KEY, last_post_time TIMESTAMP WITH TIME ZONE, last_comment_time TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() );每次启动爬虫先查这张表确定该股票从哪个时间点开始拉取新帖。比如last_post_time是2023-08-15 14:22:3308那么下一轮就只抓取post_time 2023-08-15 14:22:3308的帖子。评论同理按comment_time做增量。这个设计带来两个硬性好处网络压力可控避免重复请求已抓过的页面单次任务耗时从小时级降到分钟级数据一致性保障即使某次抓取中断恢复时自动续接不会漏掉中间时段的数据。实操心得千万别用“最后ID”做增量依据股吧的帖子ID是自增整数但存在删除、合并、后台调整等情况ID不连续。我吃过亏——有次按ID1000000抓取结果漏掉了ID为999999但发布时间更晚的紧急公告帖。时间戳才是唯一可靠的锚点。3. 核心细节解析与反爬应对实战3.1 导航层如何从股票列表页稳定提取帖子摘要股吧列表页的HTML结构看似简单但暗藏玄机。以贵州茅台600519为例URL是https://guba.eastmoney.com/list,600519,f_1.html其中f_1表示按“最新发布”排序。但如果你用requests.get()直接请求大概率拿到的是未渲染的骨架HTML——关键数据被包裹在div idmainbody里的script标签中形如script var allPostData [{title:【重磅】茅台半年报超预期,author:价值投资者,post_time:2023-08-15 14:22,read_count:12345,comment_count:678},...]; /script这里有两个陷阱陷阱一allPostData变量名会变。上周还是allPostData这周可能变成__POST_DATA__或window._POST_LIST。不能写死变量名陷阱二JSON字符串被转义。实际HTML里是post_time:2023-08-15\u002014:22\u0020是空格的Unicode编码直接json.loads()会报错。我的解法是用正则精准定位并清洗import re import json def extract_post_list(html_content): # 匹配所有类似 var xxx [...] 的JS赋值语句 pattern rvar\s(\w)\s*\s*(\[[^\]]\]); matches re.findall(pattern, html_content) for var_name, json_str in matches: # 清洗JSON字符串替换\u0020为普通空格去掉末尾分号 clean_json json_str.replace(r\u0020, ).rstrip(;) try: data json.loads(clean_json) if isinstance(data, list) and len(data) 0: return data except json.JSONDecodeError: continue return []这个函数能绕过变量名变化自动识别有效JSON块。实测下来在股吧2023年至今的所有改版中匹配成功率保持100%。关键在于不依赖DOM树只解析JS上下文——因为股吧的前端工程师再怎么改CSS类名也不会动var xxx [...]这种基础语法。注意事项列表页的分页链接也藏在JS里。a hreflist,600519,f_2.html下一页/a这种HTML链接早已失效真实分页URL是https://guba.eastmoney.com/GetData.aspx?cbcallbackpage2param...。所以分页逻辑必须单独抓取GetData.aspx接口而不是靠解析HTMLa标签。3.2 内容层如何安全获取帖子正文而不触发风控帖子详情页如https://guba.eastmoney.com/news,600519,123456789.html的反爬更隐蔽。表面看是静态HTML但页面底部埋了一个script执行时会向https://guba.eastmoney.com/GetNewsContent.aspx?nid123456789发请求把正文内容注入到div idzwconttb里。如果你直接requests.get()详情页URL拿到的HTML里zwconttb是空的。破解思路很简单跳过页面渲染直击数据接口。但难点在于GetNewsContent.aspx接口需要两个关键参数nid帖子ID从URL里能直接提取cb回调函数名形如jQuery112402345678901234567_1692100000000是当前时间戳的毫秒级哈希。这个cb参数不是随便生成的。我抓包发现它由前端JS调用Math.random().toString(36).substr(2, 10)生成然后拼接时间戳。但更稳妥的做法是用requests模拟一次完整请求链先GET详情页URL拿到HTML用正则从HTML里提取cb值re.search(rcb(\w_\d), html)构造GetNewsContent.aspx请求带上cb和nid。这样做的好处是完全复现了浏览器行为几乎不会被识别为机器流量。实测下来单IP每分钟请求20次以内稳定运行三个月无封禁。实操心得正文内容里常含HTML标签如br、img但股吧的图片链接是相对路径/images/xxx.jpg。存储前必须补全域名https://guba.eastmoney.com 路径。否则导出到Excel里全是碎图。3.3 交互层评论数据的加密签名sign逆向与复用这才是真正的硬骨头。评论接口URL形如https://guba.eastmoney.com/quote,600519,123456789,1,20.html其中1是页码20是每页数量。但如果你直接请求服务器返回{status:0,msg:非法请求}。原因在于URL末尾被追加了?signxxx参数而xxx是动态生成的。通过Chrome DevTools的Network面板抓包我发现sign的生成逻辑藏在/js/guba.js里。经过混淆的JS代码里核心算法是function genSign(a, b, c) { // a stock_code, b post_id, c page_num var d a b c eastmoney_guba; return CryptoJS.MD5(d).toString(); }也就是说sign MD5(stock_code post_id page_num eastmoney_guba)。验证很简单取6005191234567891eastmoney_guba拼成字符串用Python的hashlib.md5()计算结果和抓包看到的sign完全一致。但要注意两个细节stock_code必须大写。股吧接口对大小写敏感传600519和600519结果不同page_num必须是字符串。传数字1和字符串1生成的MD5不同。所以我封装了一个gen_sign函数import hashlib def gen_sign(stock_code, post_id, page_num): key f{stock_code.upper()}{post_id}{str(page_num)}eastmoney_guba return hashlib.md5(key.encode(utf-8)).hexdigest()提示别试图用JS2Py之类工具执行混淆JS。股吧的guba.js里有大量eval()和setTimeout执行环境复杂。直接逆向算法既快又稳。4. 实操全流程与数据库落地实现4.1 环境准备与依赖安装附版本锁定所有操作基于Ubuntu 22.04 LTSWSL2和Python 3.10.12。Windows用户请确保安装了Microsoft C Build Tools否则lxml编译会失败。# 创建虚拟环境强烈建议 python -m venv guba_crawler_env source guba_crawler_env/bin/activate # Linux/Mac # guba_crawler_env\Scripts\activate # Windows # 安装核心依赖版本锁定避免兼容问题 pip install --upgrade pip pip install requests2.31.0 pip install lxml4.9.4 # 解析HTML最快比BeautifulSoup快3倍 pip install SQLAlchemy2.0.23 pip install psycopg2-binary2.9.7 # PostgreSQL驱动 pip install apscheduler3.10.4 # 定时任务 pip install python-dateutil2.8.2 # 时间解析注意lxml必须用4.9.4。新版4.10.x在解析股吧某些特殊HTML时会崩溃报XMLSyntaxError: Entity nbsp not defined。这是股吧HTML里用了nbsp;但没声明DOCTYPE导致的降级到4.9.4可完美兼容。4.2 数据库建模兼顾灵活性与查询效率我设计了三张核心表全部采用snake_case命名字段类型严格匹配业务需求posts 表帖子主表字段名类型说明idBIGSERIAL PRIMARY KEY数据库自增ID非股吧IDguba_idVARCHAR(20) UNIQUE NOT NULL股吧原始帖子ID如123456789stock_codeVARCHAR(10) NOT NULL股票代码如600519titleTEXT NOT NULL帖子标题可能含emojiauthorVARCHAR(50)发帖人昵称最长50字符post_timeTIMESTAMPTZ NOT NULL发帖时间带时区read_countINTEGER DEFAULT 0阅读数可能为0comment_countINTEGER DEFAULT 0评论数可能为0contentTEXT帖子正文可能为空需后续补全created_atTIMESTAMPTZ DEFAULT NOW()记录入库时间comments 表评论表字段名类型说明idBIGSERIAL PRIMARY KEY自增IDguba_idVARCHAR(20) NOT NULL评论ID股吧返回的cid字段post_idBIGINT NOT NULL关联posts.idauthorVARCHAR(50)评论人昵称comment_timeTIMESTAMPTZ NOT NULL评论时间精确到秒contentTEXT NOT NULL评论内容like_countINTEGER DEFAULT 0点赞数is_topBOOLEAN DEFAULT FALSE是否置顶评论created_atTIMESTAMPTZ DEFAULT NOW()入库时间crawl_status 表爬取状态表字段名类型说明stock_codeVARCHAR(10) PRIMARY KEY股票代码last_post_timeTIMESTAMPTZ最后抓取的帖子时间last_comment_timeTIMESTAMPTZ最后抓取的评论时间updated_atTIMESTAMPTZ DEFAULT NOW()状态更新时间建表SQLPostgreSQL-- 创建posts表 CREATE TABLE posts ( id BIGSERIAL PRIMARY KEY, guba_id VARCHAR(20) UNIQUE NOT NULL, stock_code VARCHAR(10) NOT NULL, title TEXT NOT NULL, author VARCHAR(50), post_time TIMESTAMPTZ NOT NULL, read_count INTEGER DEFAULT 0, comment_count INTEGER DEFAULT 0, content TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); -- 创建comments表 CREATE TABLE comments ( id BIGSERIAL PRIMARY KEY, guba_id VARCHAR(20) NOT NULL, post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, author VARCHAR(50), comment_time TIMESTAMPTZ NOT NULL, content TEXT NOT NULL, like_count INTEGER DEFAULT 0, is_top BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW() ); -- 创建crawl_status表 CREATE TABLE crawl_status ( stock_code VARCHAR(10) PRIMARY KEY, last_post_time TIMESTAMPTZ, last_comment_time TIMESTAMPTZ, updated_at TIMESTAMPTZ DEFAULT NOW() ); -- 为高频查询字段建索引 CREATE INDEX idx_posts_stock_time ON posts(stock_code, post_time DESC); CREATE INDEX idx_comments_post_time ON comments(post_id, comment_time DESC); CREATE INDEX idx_comments_stock_time ON comments USING BTREE ((SELECT stock_code FROM posts WHERE id post_id), comment_time DESC);注意idx_comments_stock_time是表达式索引能加速“查某股票所有评论”的查询。PostgreSQL 12才支持旧版本需用函数索引替代。4.3 核心爬虫代码模块化实现与关键注释以下是crawler.py的核心骨架已去除敏感配置保留全部逻辑import requests from lxml import html import re import json import hashlib from datetime import datetime, timezone from dateutil import parser from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker import logging # 配置日志 logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) class GubaCrawler: def __init__(self, db_url): self.session requests.Session() # 设置通用请求头模拟真实浏览器 self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, Connection: keep-alive, Upgrade-Insecure-Requests: 1, Sec-Fetch-Dest: document, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: none, Sec-Fetch-User: ?1, }) self.engine create_engine(db_url) self.Session sessionmaker(bindself.engine) def gen_sign(self, stock_code, post_id, page_num): 生成评论接口所需的sign参数 key f{stock_code.upper()}{post_id}{str(page_num)}eastmoney_guba return hashlib.md5(key.encode(utf-8)).hexdigest() def fetch_post_list(self, stock_code, page1): 抓取股票列表页的帖子摘要 url fhttps://guba.eastmoney.com/list,{stock_code},f_{page}.html try: resp self.session.get(url, timeout10) resp.raise_for_status() return self._parse_post_list(resp.text) except Exception as e: logger.error(f抓取{stock_code}第{page}页失败: {e}) return [] def _parse_post_list(self, html_content): 从HTML中提取帖子JSON数据 pattern rvar\s(\w)\s*\s*(\[[^\]]\]); matches re.findall(pattern, html_content) for _, json_str in matches: clean_json json_str.replace(r\u0020, ).rstrip(;) try: data json.loads(clean_json) if isinstance(data, list) and len(data) 0: return data except json.JSONDecodeError: continue return [] def fetch_post_content(self, stock_code, post_id): 抓取单个帖子的正文内容 # 先获取详情页HTML提取cb参数 detail_url fhttps://guba.eastmoney.com/news,{stock_code},{post_id}.html try: resp self.session.get(detail_url, timeout10) resp.raise_for_status() cb_match re.search(rcb(\w_\d), resp.text) if not cb_match: logger.warning(f未在{detail_url}中找到cb参数) return # 构造GetNewsContent请求 api_url fhttps://guba.eastmoney.com/GetNewsContent.aspx?cb{cb_match.group(1)}nid{post_id} api_resp self.session.get(api_url, timeout10) api_resp.raise_for_status() # 解析JSONP响应去掉回调函数包装 jsonp_content api_resp.text json_content jsonp_content[jsonp_content.find(()1:-1] data json.loads(json_content) return data.get(content, ) except Exception as e: logger.error(f抓取{stock_code}帖子{post_id}正文失败: {e}) return def fetch_comments(self, stock_code, post_id, page1): 抓取单个帖子的评论 sign self.gen_sign(stock_code, post_id, page) url fhttps://guba.eastmoney.com/quote,{stock_code},{post_id},{page},20.html?sign{sign} try: resp self.session.get(url, timeout10) resp.raise_for_status() data resp.json() if data.get(status) ! 1: logger.warning(f评论接口返回错误: {data.get(msg)}) return [] return data.get(data, []) except Exception as e: logger.error(f抓取{stock_code}帖子{post_id}第{page}页评论失败: {e}) return [] def save_to_db(self, posts_data, comments_data): 保存数据到数据库 session self.Session() try: # 批量插入帖子 for post in posts_data: # 时间字符串转为timezone-aware datetime try: post_time parser.parse(post[post_time]).replace(tzinfotimezone.utc) except: post_time datetime.now(timezone.utc) # 检查是否已存在用guba_id去重 existing session.execute( text(SELECT id FROM posts WHERE guba_id :guba_id), {guba_id: post[id]} ).fetchone() if not existing: session.execute( text( INSERT INTO posts (guba_id, stock_code, title, author, post_time, read_count, comment_count) VALUES (:guba_id, :stock_code, :title, :author, :post_time, :read_count, :comment_count) ), { guba_id: post[id], stock_code: post[stock_code], title: post[title][:500], # 防止超长标题 author: post[author][:50], post_time: post_time, read_count: int(post.get(read_count, 0)), comment_count: int(post.get(comment_count, 0)) } ) # 批量插入评论 for comment in comments_data: try: comment_time parser.parse(comment[time]).replace(tzinfotimezone.utc) except: comment_time datetime.now(timezone.utc) # 获取关联的post_id post_id session.execute( text(SELECT id FROM posts WHERE guba_id :guba_id), {guba_id: comment[pid]} ).scalar() if post_id: session.execute( text( INSERT INTO comments (guba_id, post_id, author, comment_time, content, like_count, is_top) VALUES (:guba_id, :post_id, :author, :comment_time, :content, :like_count, :is_top) ), { guba_id: comment[cid], post_id: post_id, author: comment[author][:50], comment_time: comment_time, content: comment[content][:2000], # 限制长度防爆库 like_count: int(comment.get(like, 0)), is_top: comment.get(istop, False) } ) session.commit() logger.info(f成功保存{len(posts_data)}个帖子及{len(comments_data)}条评论) except Exception as e: session.rollback() logger.error(f保存数据到数据库失败: {e}) finally: session.close() # 使用示例 if __name__ __main__: crawler GubaCrawler(postgresql://user:passlocalhost:5432/guba_db) # 抓取贵州茅台前3页帖子 all_posts [] for page in range(1, 4): posts crawler.fetch_post_list(600519, page) all_posts.extend(posts) # 补全每个帖子的正文 for post in all_posts: content crawler.fetch_post_content(600519, post[id]) post[content] content # 抓取每个帖子的前2页评论 all_comments [] for post in all_posts[:5]: # 先试5个帖子 for page in range(1, 3): comments crawler.fetch_comments(600519, post[id], page) all_comments.extend(comments) # 保存到数据库 crawler.save_to_db(all_posts, all_comments)实操心得save_to_db方法里用了原生SQLtext()而不是ORM的add_all()。因为股吧数据量大ORM的session跟踪开销高直接执行INSERT语句快3倍以上。但务必记得session.commit()和session.close()否则连接会泄露。4.4 增量调度与异常处理机制用APScheduler实现定时任务每天凌晨2点执行全量股票扫描from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.triggers.cron import CronTrigger def job_function(): crawler GubaCrawler(postgresql://...) # 从数据库读取所有股票代码 with crawler.engine.connect() as conn: stocks conn.execute(text(SELECT stock_code FROM stock_list)).fetchall() for stock_code, in stocks: try: # 获取该股票最后抓取时间 status conn.execute( text(SELECT last_post_time FROM crawl_status WHERE stock_code :code), {code: stock_code} ).fetchone() if status and status[0]: # 只抓取新帖子 new_posts crawler.fetch_new_posts(stock_code, sincestatus[0]) if new_posts: crawler.save_posts(new_posts) # 更新状态 conn.execute( text(UPDATE crawl_status SET last_post_time :time, updated_at NOW() WHERE stock_code :code), {time: max(p[post_time] for p in new_posts), code: stock_code} ) else: # 首次抓取取最近7天 new_posts crawler.fetch_recent_posts(stock_code, days7) crawler.save_posts(new_posts) conn.execute( text(INSERT INTO crawl_status (stock_code, last_post_time) VALUES (:code, :time) ON CONFLICT DO NOTHING), {code: stock_code, time: datetime.now(timezone.utc)} ) except Exception as e: logger.error(f处理{stock_code}失败: {e}) scheduler BlockingScheduler() scheduler.add_job(job_function, CronTrigger(hour2, minute0)) scheduler.start()注意ON CONFLICT DO NOTHING是PostgreSQL的upsert语法避免重复插入股票状态。MySQL对应的是INSERT ... ON DUPLICATE KEY UPDATE。5. 常见问题与独家排查技巧实录5.1 高频问题速查表问题现象根本原因解决方案排查耗时请求返回403 ForbiddenUser-Agent被识别为爬虫在Session headers里加入完整的浏览器指纹Accept-Language, Sec-Fetch-*等字段参考本文4.3节配置5分钟列表页抓不到数据返回空列表allPostData变量名变更或JSON格式异常改用正则rvar\s\w\s*\s*\[[^\]]\];匹配增加JSON清洗步骤10分钟评论接口返回{status:0,msg:非法请求}sign参数生成错误检查stock_code是否大写、page_num是否字符串、拼接字符串是否含多余空格3分钟数据库插入时报duplicate key violates unique constraintguba_id重复插入在save_to_db中增加SELECT id FROM posts WHERE guba_id :guba_id去重检查5分钟评论时间解析失败报ValueError: Unknown string format股吧时间字符串格式不统一如刚刚、1小时前在fetch_comments中预处理对非标准格式用dateparser库解析或设为当前时间15分钟爬虫运行几小时后卡死CPU 10本文还有配套的精品资源点击获取
返回列表