资讯动态

Django股票分红数据爬虫与展示系统实战指南

发布时间:2026/10/27 7:48:47 来源:尧图企业网站定制
简介这是一份基于Django框架的股票分红数据爬虫与展示系统完整项目源码面向个人投资者、金融研究人员以及正在学习Django和爬虫技术的开发者。系统从东方财富网站分页抓取股票分红数据通过随机User-Agent和访问延迟模拟真实用户有效规避反爬限制数据经爬虫清洗后借助Django ORM写入数据库默认配置为SQL Server并提供查询、导出与图表展示服务。压缩包共2000个文件、约71.85MB其中1627个py文件覆盖爬虫、模型与视图逻辑156个html模板渲染页面87个js与15个css支撑前端交互和样式另有少量c/h源码及相关配置文件便于二次开发与问题排查。目前已有261人学习/下载。学习该项目可掌握分页数据采集、数据库建模、基于模板的可视化实现等环节适合作为Django全栈与爬虫入门的综合实战参考。1. 把 Django 股票分红数据爬虫和展示系统拆开先跑起来再谈细节拿到这个基于Django框架的股票分红数据爬虫和展示系统.zip之后我第一反应不是去看 README而是先确认一件事这套 Django 工程到底是把爬虫和 Web 展示揉在一个项目里还是拆成了两个独立服务。解压之后答案很清楚——是前者一个 Django 工程里同时承担了 requests 爬虫采集、模型层存储、视图层查询和模板渲染这对大多数做个人投研工具或者中小团队数据组件的场景来说恰恰是性价比最高的结构。你不用维护两台机器、两套部署也不需要在爬虫程序和数据库之间单独架一层 APIDjango 自带的 ORM 和 Admin 就能把“抓到数据”和“看到数据”串起来。适合谁用一类是想把分红公告从散落的网页里变成结构化表格的量化初学者另一类是已经会用 Django 写 CRUD 但没碰过爬虫的 Web 开发两类人都能在这套工程里找到可以直接抄的部分。2. 工程拆解爬虫、存储、展示三层在 Django 项目里怎么组织2.1 目录结构与请求链路从 startproject 到 urls 再到模板解压 zip 之后先别急着runserver第一步是确认目录划分。常见做法是标准的 Django 工程布局一个 project 根目录里面挂多个 app。这套系统的 app 拆分一般会按照职责边界走——datasource管爬虫任务和抓取日志stock管分红数据模型dashboard管展示和统计。这样拆的好处是爬虫调度和 Web 查询在逻辑上解耦但数据库是共享的改爬虫字段不会影响页面渲染代码。# 工程内 app 划分示意 django-admin startproject dividend_project . python manage.py startapp datasource python manage.py startapp stock python manage.py startapp dashboard这里的dividend_project是工程配置目录settings.py、urls.py都在里面三个 app 各自维护自己的models.py、views.py。请求链路是标准的 Django 流程浏览器请求 →dividend_project/urls.py做路由分发 → 各 app 的views.py执行 ORM 查询 → 渲染templates下的 HTML 模板。爬虫链路则是另一条线datasource下的爬虫脚本直接访问财经站点 → 解析 HTML → 写入stock模型的表里完全不经过视图层。要点在于settings.py里要把三个 app 都注册进INSTALLED_APPS同时配置好数据库连接。这套工程默认用的是 SQLite对个人爬虫场景完全够用但如果你打算跑全市场几千只股票的历史分红建议改成 MySQL后面第三章的批量写入逻辑不变只是连接串要换。2.2 数据表设计分红记录表是核心爬虫日志表不能省这个系统的核心表只有三张但设计得好不好直接决定后续查询和增量更新省不省力。第一张是股票基础信息表存代码、简称、交易所第二张是分红记录表存每股派息、送转股、股权登记日、除权除息日第三张是爬虫任务日志表记录每次抓取的起始时间、成功条数、失败条数。日志表很容易被新手省略但它是排查问题的重要抓手——没有日志你根本不知道上一次抓取是没跑还是跑了但解析全失败了。# stock/models.py 核心模型 from django.db import models class StockInfo(models.Model): code models.CharField(max_length10, uniqueTrue, verbose_name股票代码) name models.CharField(max_length50, verbose_name股票简称) exchange models.CharField(max_length10, verbose_name交易所) created_at models.DateTimeField(auto_now_addTrue) class DividendRecord(models.Model): stock models.ForeignKey(StockInfo, on_deletemodels.CASCADE) announce_date models.DateField(verbose_name公告日期) dividend_year models.CharField(max_length10, verbose_name分红年度) cash_dividend models.DecimalField(max_digits8, decimal_places2, verbose_name每股派息/元) bonus_share models.DecimalField(max_digits5, decimal_places2, default0, verbose_name每股送转/股) record_date models.DateField(nullTrue, verbose_name股权登记日) ex_date models.DateField(nullTrue, verbose_name除权除息日) class Meta: constraints [ models.UniqueConstraint( fields[stock, dividend_year, announce_date], nameunique_dividend_per_year ) ]上面这段有两点值得细说。第一cash_dividend用DecimalField而不是FloatField这是分红数据必须守住的底线——浮动点数在累加和比较时会出幺蛾子具体翻车案例放在第五章讲。第二UniqueConstraint把stock dividend_year announce_date锁成唯一组合这是增量更新的基础同一只股票同一年的分红公告如果公告日期相同说明是同一条记录后续update_or_create就靠它判断是插入还是更新。2.3 依赖锁定requirements.txt 里哪些版本必须钉死爬虫项目最怕的不是 Django 升级而是解析库和请求库的隐式行为变化。lxml的 XPath 求值规则在不同大版本下对非法 HTML 的容错不一致requests的默认超时行为也改过。我的习惯是把这个工程能跑通过的版本组合写进requirements.txt不做这种开放性约束直接钉死到小版本。Django4.2.7 requests2.31.0 lxml4.9.3 pandas2.1.4Django 4.2 是 LTS 版本补丁更新到 2026 年个人项目选它比追 5.0 稳妥requests2.31 修复了多个安全漏洞lxml4.9.x 对 XPath 的中文文本匹配表现稳定。装完依赖之后先用python manage.py migrate把三张表建出来再跑python manage.py createsuperuser创建管理员账号进入 Django Admin 后台确认模型能正常读写。到这里整个工程骨架已经通了接下来才是重头戏——爬虫模块怎么写。3. 爬虫模块实现requests XPath 抓分红公告的完整套路3.1 为什么不选 Scrapy轻量场景下 requests 更好使这套工程用requestslxml做爬虫而不是直接上 Scrapy是经过取舍的。Scrapy 的爬虫引擎、调度器、中间件体系很完善但它是一个独立的异步框架和 Django 的 ORM 集成要额外配置DjangoItem和pipeline对只有几个目标页面的场景来说学习成本和配置成本反而更高。而requests是同步请求单线程循环逐页抓取在页面量不大几百个公告页的情况下足够快代码也更直观——发请求、拿响应、解析、入库四步走完每一步都可以单独调试。# datasource/spider.py 核心抓取流程 import requests from lxml import etree from stock.models import StockInfo, DividendRecord HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, } def fetch_dividend_page(stock_code: str, page_url: str): 抓取单只股票的分红公告列表页返回解析后的记录列表 resp requests.get(page_url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding # 处理中文站点编码 tree etree.HTML(resp.text) records [] rows tree.xpath(//table[classdividend-table]/tbody/tr) for row in rows: record { announce_date: row.xpath(./td[1]/text())[0].strip(), dividend_year: row.xpath(./td[2]/text())[0].strip(), cash_per_share: row.xpath(./td[3]/text())[0].strip(), bonus_share: row.xpath(./td[4]/text())[0].strip(), record_date: row.xpath(./td[5]/text())[0].strip(), ex_date: row.xpath(./td[6]/text())[0].strip(), } records.append(record) return records这段代码的逻辑分三层先构造带User-Agent的请求头模拟真实浏览器访问然后通过resp.apparent_encoding覆盖响应头里的编码声明解决中文站点的 GBK/UTF-8 识别问题最后用 XPath 定位表格行。timeout10是经验值太短可能误伤慢站点太长会让单次任务在批量抓取时卡死。resp.raise_for_status()保证 HTTP 4xx/5xx 时直接抛异常而不是拿一个错误页面去解析这点对批量任务尤其重要——失败要尽早暴露而不是静默吞掉。3.2 XPath 解析细节contains、normalize-space 和 text() 的坑XPath 写得好不好决定了解析代码的健壮性。直接取text()在页面结构稳定的前提下没问题但现实中的财经公告页经常有大量空白字符和换行。我的习惯是固定组合normalize-space()去首尾空白 判断空值兜底。如果目标单元格里的文本是多个节点拼接的text()只取第一个文本节点这时要用string(...)把整个子树的文本拼起来。# 更健壮的 XPath 写法示例 def parse_cell(row, xpath_expr: str) - str: 从行节点提取文本去空白后返回 nodes row.xpath(xpath_expr) if not nodes: return # string() 比 text() 更稳能拿到子节点内的全部文本 value row.xpath(fstring({xpath_expr.strip(/)})) or return .join(value.split())这里的参数说明xpath_expr是传入的单元格定位表达式比如./td[3]string()函数会把该节点及其所有后代节点的文本拼接起来配合 .join(value.split())做归一化即使页面里混着换行和多个空格也能得到干净字符串。实际抓取时字段缺失很常见——某些年份没有送转股对应单元格就是空所以parse_cell要对空节点返回空字符串而不是直接索引[0]导致IndexError。3.3 入库策略update_or_create 做增量delete 与撤回同步数据解析出来之后入库逻辑是整个爬虫模块里最容易出错的一环。新手习惯先filter(...).exists()再决定 insert 还是 update但更省事的方案是 Django ORM 自带的update_or_create——它内部就是先查再改只是把两步封装成一个原子操作省去了一次代码往返也天然规避了并发场景下的竞态问题。# datasource/save_records.py 增量写入逻辑 from django.db import transaction from stock.models import StockInfo, DividendRecord def save_dividend_records(stock_obj: StockInfo, records: list) - int: 把解析后的记录列表增量写入数据库返回新增/更新的条数 saved_count 0 with transaction.atomic(): for item in records: _, created DividendRecord.objects.update_or_create( stockstock_obj, dividend_yearitem[dividend_year], announce_dateitem[announce_date], defaults{ cash_dividend: item[cash_per_share], bonus_share: item[bonus_share], record_date: item[record_date], ex_date: item[ex_date], } ) if created: saved_count 1 return saved_count代码逻辑说明update_or_create的第一个参数组合stock dividend_year announce_date是查询条件对应模型里的唯一约束defaults字典里的字段是“如果记录已存在则更新这些值”。返回值created是布尔型真代表插入了新记录假代表更新了既有记录。用transaction.atomic()包一层是为了防止批量写入中途失败导致部分提交——爬虫任务最怕数据库落到半新半旧的脏状态。有一个额外的场景必须考虑公司发布了更正公告原来的分红方案被撤销此时远端页面不再展示这条记录但本地库里还留着。单纯依赖update_or_create无法处理这种情况。常见做法是抓取完当前页面后把本地库中“该股票该年度在远端已不存在的记录”找出来删除。这就是热词里常说的“django 执行查询-删除对象”的典型应用# 在事务里先对比再清理 existing_keys set( DividendRecord.objects.filter(stockstock_obj) .values_list(dividend_year, announce_date) ) remote_keys {(r[dividend_year], r[announce_date]) for r in records} to_delete existing_keys - remote_keys if to_delete: DividendRecord.objects.filter(stockstock_obj).delete() # 需按字段精确匹配后再删这段代码里的delete()是整体删除的示意实际生产中需要先构造 Q 对象精确匹配再删避免误删。思路是把本地已有的分红年度和公告日期组合做成一个集合和远端解析出来的集合做差集差集部分就是已被撤回的公告。4. 展示层与查询把分红数据变成能筛选、能统计的页面4.1 列表页筛选Q 对象组合年份、派息区间和关键词爬虫把数据写进库里只是第一步系统价值在展示层才真正体现出来。普通的分红列表页不难写难的是让用户按“某一年 每股派息多少以上 股票名称含什么关键词”这种组合条件去筛。Django 的filter链式调用可以解决但条件个数是动态的需要用Q对象把可选条件拼起来。// 此段为错误示例正确实现见下方 Python 代码上面那行是混淆项真实实现用 Python# dashboard/views.py 列表页筛选逻辑 from django.db.models import Q from django.shortcuts import render from stock.models import DividendRecord def dividend_list(request): qs DividendRecord.objects.select_related(stock).all().order_by(-announce_date) keyword request.GET.get(keyword, ).strip() year request.GET.get(year, ).strip() min_cash request.GET.get(min_cash, ).strip() if keyword: qs qs.filter(Q(stock__name__icontainskeyword) | Q(stock__code__icontainskeyword)) if year: qs qs.filter(dividend_yearyear) if min_cash: qs qs.filter(cash_dividend__gtemin_cash) return render(request, dashboard/list.html, {records: qs[:200]})逻辑说明select_related(stock)是 Django 的查询优化手段因为列表页每一行都要展示股票名称如果不做预查询每条记录都会额外发一次 SQL 去查股票表200 条记录就是 200 次查询页面会肉眼可见地慢。Q对象里的stock__name__icontains是跨模型字段查询双下划线表示沿着外键关系向后找。__gte是大于等于运算符min_cash参数直接来自 URL 的 query string。实战经验模板里渲染这个筛选结果时给每个字段加一个readonly的查询参数回显否则用户点完筛选之后表单框里的条件没了体验很割裂。另外分页用 Django 内置的Paginator不要自己切片切片只适合调试。4.2 统计视图用 aggregate 按年度聚合喂给 ECharts分红数据的另一个高频需求是统计视图某只股票连续十年的每股派息走势或者全市场某年分红总额排行。这类数据在 Django 里用aggregate和annotate实现前者返回汇总值后者给每条记录加字段。下面的例子按年度聚合分红总额并组装成前端图表组件需要的 JSON 结构。# dashboard/views.py 年度分红统计 from django.db.models import Sum from django.http import JsonResponse from stock.models import DividendRecord def dividend_stats(request): stats ( DividendRecord.objects.values(dividend_year) .annotate(total_cashSum(cash_dividend)) .order_by(dividend_year) ) data { years: [s[dividend_year] for s in stats], total_cash: [float(s[total_cash]) for s in stats], } return JsonResponse(data)这段的要点在values(dividend_year)加annotate(total_cashSum(cash_dividend))的组合它在 SQL 层面对应的是SELECT dividend_year, SUM(cash_dividend) FROM app_dividendrecord GROUP BY dividend_year把全市场分红记录按年度分组求和。转成float是因为Decimal类型在 JSON 序列化时会报错这是新手经常踩的坑。前端模板里用 ECharts 的柱状图或者折线图接收这两个数组即可。这里选 ECharts 的原因很简单它对折线图、柱状图、联动缩放的支持成熟而且只需要在前端模板里通过script标签从本地静态文件引入不需要后端额外渲染图表Django 只负责吐 JSON 数据。注意不要把图表库依赖 CDN——内网环境或者站点离线时图表会整个白屏把echarts.min.js下载到项目的static/js目录下更靠谱。4.3 定时任务三种让爬虫自动跑的方式展示系统做好之后下一步是怎么让数据更新自动化。三种常见做法第一种是cron直接调python manage.py runscript需要安装django-extensions支持脚本入口第二种是写一个自定义 management command配合 Linux crontab 执行第三种是上 Celery beat适合要并发抓取多只股票的大场景。这个工程规模用第二种最合适——代码量小调试直观不用额外起 worker 进程。# datasource/management/commands/fetch_dividends.py from django.core.management.base import BaseCommand from datasource.spider import fetch_all_stocks_dividend class Command(BaseCommand): help 抓取全市场股票分红公告增量写入数据库 def add_arguments(self, parser): parser.add_argument(--symbol, typestr, default, help指定股票代码留空则抓取全部) def handle(self, *args, **options): symbol options[symbol] saved fetch_all_stocks_dividend(symbolsymbol) self.stdout.write(self.style.SUCCESS(f本次成功写入 {saved} 条记录))这个 management command 的入口类名Command是 Django 的约定handle()是执行体--symbol参数可以手动指定单只股票以便调试默认留空时抓全部。写好之后在 Linux 环境下给 crontab 加一条# 每天早上 7 点抓一次分红公告 0 7 * * * cd /opt/dividend_project /usr/local/bin/python manage.py fetch_dividends /var/log/dividend_spider.log 21crontab 里的会把爬虫输出追加到日志文件21把错误流也汇入同一个文件。定时任务上线后建议头一周每天去翻一次日志确认写入条数和遇到反爬时的重试次数一周跑稳之后再逐步加大频率。5. 避坑列表股票数据爬虫和 Django 展示的常见翻车现场5.1 反爬与请求频率页面突然 431 或返回验证码现象批量抓取前几个页面一切正常抓到第一百个左右时requests 的响应里突然报 431或者返回的 HTML 变成验证码页面解析出来的记录全是空。原因目标站点在单位时间内的请求频率超过了阈值服务端直接拒绝连接。requests 库默认的识别特征很明显——同一个 User-Agent、固定间隔、无 Cookie 的连续请求站点判断是脚本很容易。解决给请求头增加随机浏览器标识在每次请求之间用time.sleep(random.uniform(1, 3))打乱节奏同时增加失败重试机制收到 431 或 403 后先退避 30 秒再重试。重试次数要设上限否则单次任务会卡死。另一个有效做法是启用 requests.Session 保持连接让站点看到的是同一条 HTTP 连接上的连续请求而不是每次都新开 TLS 握手。5.2 日期解析公告里各种格式混在一个字段里现象解析record_date字段时上一行是2024年06月20日下一行变成2024-6-20再往下还有2024/06/20。datetime.strptime用固定格式去解析遇到不匹配的直接抛ValueError中断整个任务。原因财经门户的数据源不是统一录入的人工编辑的公告从上到下格式随心所欲。股票分红数据的日期字段结构极不稳定这是该类目特有的坑。解决不要自己维护格式映射表直接用dateutil.parser.parse()做智能解析它能识别中英文多种日期格式。解析之前先做一次正则归一化把年/月/日中的汉字统一替换成分隔符能进一步提高命中率。解析失败时返回None而不是抛异常分红记录就算日期缺失也至少能把金额存下来。5.3 小数精度FloatField 存分红数据算出 0.30000000000000004现象某只股票每股派息 0.3 元用 FloatField 存储展示时页面上显示 0.30000000000000004聚合求和时误差被放大排名榜的顺序变得不可信。原因浮点数在二进制里的表示精度限制0.3 无法被二进制精确表示尾数误差在多次运算后逐渐放大。很多教程代码图省事用 FloatField 存价格这在展示系统里不可接受。解决所有金额、比例、股数类字段一律用DecimalField精确到小数点后 4 位以上。Django 的DecimalField在数据库层面对应DECIMAL类型存储的是字符串形式的精确值不经过浮点转换。前端展示前可以用str()或format(value, .2f)统一保留两位。5.4 静态文件改了不生效模板和 CSS 被缓存卡死现象修改了样式文件刷新页面看不出任何变化以为 Django 没有重新加载模板但实际上模板引擎早就改了问题出在浏览器缓存或者 Django 开发模式下的STATICFILES_DIRS配置错误。原因开发模式下 Django 提供静态文件服务的逻辑依赖DEBUGTrue且要在settings.py里正确配置STATICFILES_DIRS。如果配置里少了一层路径Django 会自动去找 app 目录下的static文件夹找不到就返回 404浏览器拿不到新文件就一直用缓存里的旧样式。解决每次改完 CSS 或者 JS在模板引用里加一个版本号参数比如style.css?v20240115同时确认settings.py里STATICFILES_DIRS指向的是项目根目录下的static文件夹static内部保持css、js、img的子目录结构模板里用{% load static %}配合{% static css/style.css %}引用。5.5 展示系统的接口被校外脚本盯上分页接口被抓成数据源现象列表页的分页接口被外部脚本高频请求服务端日志里出现大量来自同一 IP 的访问记录页面响应变慢。原因很多刚做完 Django 系统的人把精力放在爬虫的反爬对抗上忘了自己的展示系统也是一个数据接口同样会被别人的爬虫盯上。如果你的 Django 服务是公网可达的分页接口就是一个裸奔的数据源。解决展示层接口要加三层基础防护。第一层对 GET 类接口做轻量校验绑定会话的合法跳转来源第二层接口层做限流Django 中可以用django-ratelimit库按 IP 限制每分钟请求数第三层分页接口改为 POST 提交或者加一次性令牌参数把普通脚本的难度抬高。记住这句话你做爬虫时多讨厌的验证码自己的网站就该多认真地防爬。注意这里全程说的是自建系统对外防护与任何网络访问工具无关。6. 验证与进阶从爬虫到决策面板的一次完整走查6.1 数据完整性验证脚本把脏数据挡在展示之前定时任务跑了一周之后不能只看“写入成功几条”要写一个独立的验证脚本在 Django shell 上下文里检查数据质量。我习惯把它做成 management command每天跑完爬虫后自动执行一次发现异常直接发告警。# datasource/management/commands/check_data.py from django.core.management.base import BaseCommand from stock.models import DividendRecord from django.db.models import Count class Command(BaseCommand): help 校验分红数据完整性输出异常统计 def handle(self, *args, **options): # 异常1: 每股派息为负数 negative DividendRecord.objects.filter(cash_dividend__lt0).count() # 异常2: 除权日早于公告日 date_conflict DividendRecord.objects.filter(ex_date__ltmodels.F(announce_date)).count() # 异常3: 同一股票同一年度出现多条分红 duplicated ( DividendRecord.objects.values(stock, dividend_year) .annotate(cntCount(id)) .filter(cnt__gt1).count() ) self.stdout.write(f负派息: {negative} 条, 日期冲突: {date_conflict} 条, 年度重复: {duplicated} 组)这里的models.F(announce_date)是数据库字段间的比较语法SQL 层面对应的是WHERE ex_date announce_dateDjango 会在数据库端完成比较而不是把数据拉到 Python 内存里大数据量下性能差很多。Count(id)配合values(stock, dividend_year)做分组统计是查重复最常用的口径。6.2 一张分红记录从抓到展示的完整走查最后把全链路串一遍验证系统不是“看起来可用”。手动指定某只股票重新抓取然后按时间顺序确认四个环节爬虫日志里对应任务是否成功返回 200数据库DividendRecord表中的记录是否被更新或新增列表页按年份筛选能否看到该记录统计接口的年度总额是否跟着变了。这一步我吃过亏曾经修过解析逻辑自测的时候只验证了数据库层忘了模板层用了缓存结果数据是新的页面显示的还是旧的导致“系统坏了”的错误判断。从那以后每次改完爬虫或者改完模板我都会强制走一遍这条链路从请求发起到页面响应全程确认一遍——程序员的第六感不可靠链路验证才可信。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑