Python全栈数据采集分析与可视化平台:爬虫、Flask与手写识别实战
简介基于Python的全栈式数据采集分析与可视化平台是一套面向教学演示与实战开发的完整资源包整合网络爬虫抓取、多源异构数据存储、交互式前端展示与深度学习手写数字识别模型适合高校学生、教师及数据应用开发者用于课程设计、毕设参考或快速搭建原型。压缩包共含2002个文件、约89.64MB其中1323个Markdown文档承载说明与实验笔记550个JavaScript文件支撑前端交互15个Python脚本实现爬虫与模型推理另有JSON配置、CSS样式和SQL脚本协同完成数据层与展示层搭建。已有73人学习资料内附赠配套文档与前端样式库可直接对照目录结构理解从数据采集、存储到可视化、模型识别的完整链路。借助这套资源读者既能掌握爬虫、数据库、前端可视化与深度学习建模等关键技能也能节省从零搭建环境的时间聚焦业务逻辑与教学演示。1. 全栈式数据采集分析与可视化平台包里不只是爬虫和手写数字模型如果你同时带项目或者自己在补全栈技能手头最缺的往往不是一个算法而是一条能从数据源头一路跑到页面展示的完整链路。这个基于Python的全栈式数据采集分析与可视化平台核心价值恰恰在这里网络爬虫数据抓取、多源异构数据存储管理、交互式前端展示界面、深度学习手写数字识别模型被装进同一个工程技术栈以Scrapy为主线从爬虫产出数据、数据落库、接口封装到前端图表展示和手写数字识别每一步都能在本地跑通。它不是面向生产环境的高并发系统而是为教学演示与实战开发准备的一套可复现骨架适合刚开始接触全栈数据项目的学生也适合需要快速搭建演示Demo的从业者。2. 爬虫层Scrapy工程结构、数据字段与下载策略下载完压缩包先别急着跑scrapy crawl把工程目录和Item结构看明白后面能省掉大量返工。这份资源里的爬虫部分遵循Scrapy标准工程布局对熟悉框架的人来说可直接扩展对刚入门的人来说也是很好的阅读样本。2.1 工程骨架与Item定义先想清楚要爬什么字段打开工程先看items.py。爬虫爬到的每条记录本质是一个字典Item就是给这个字典规定好字段名和类型的「合同」。字段没定义清楚后面Pipeline、入库、前端接口全都会跟着乱。import scrapy class DemoItem(scrapy.Item): title scrapy.Field() # 标题 url scrapy.Field() # 原始链接入库时做唯一约束 author scrapy.Field() # 作者 publish_time scrapy.Field() # 发布时间入库前转成datetime content scrapy.Field() # 正文或摘要 source_site scrapy.Field() # 来源站点标识多源数据靠它区分这个Item定义了六类信息。title和url是核心字段url在后续建表时要加UNIQUE约束用来去重source_site是故意留出来的因为平台强调「多源异构」等数据来自三四个站点时没有这个字段就无法回答「哪条数据是哪个站点来的」。publish_time不直接用字符串入库后面Pipeline里会统一解析成datetime这是异构数据治理的第一步。再看爬虫本体压缩包里一般有一个或多个Spider文件核心逻辑集中在parse()方法里。用CSS选择器抽取页面元素是Scrapy里最直观的写法import scrapy from demo_spider.items import DemoItem class DemoSiteSpider(scrapy.Spider): name demo_site # 启动时用 scrapy crawl demo_site start_urls [https://example-site.local/list] def parse(self, response): # 每张卡片是一条记录 for card in response.css(div.card): item DemoItem() item[title] card.css(h2 a::text).get() item[url] response.urljoin(card.css(h2 a::attr(href)).get()) item[author] card.css(.author::text).get() item[publish_time] card.css(.time::text).get() item[content] card.css(.content::text).get() item[source_site] demo_site yield item这里的核心逻辑分三步先定位每条记录的容器节点再通过CSS选择器从容器里抽出具体字段最后用yield把Item交给Pipeline。response.urljoin()很关键它能把页面里的相对链接比如/detail/123拼成完整URL避免入库后拿去访问时报错。.get()只取第一个匹配结果.getall()返回所有匹配结果的列表抓列表页时用前者居多。参数说明name是爬虫唯一标识命令行启动时要用start_urls只负责第一批请求后续翻页需要自己在parse()里yield scrapy.Request(next_url)手动跟进。如果目标站点改了页面结构优先调整选择器而不是重写整个Spider。2.2 settings与中间件下载延迟、请求头、去重和日志Scrapy的settings.py是整个爬虫运行策略的集中控制点。这套平台面向教学演示配置走的是「稳」而不是「快」宁可爬得慢一点也别把对方站点压出问题。下面这些配置一般直接能用BOT_NAME demo_spider DOWNLOAD_DELAY 2.0 # 同一域名下两个请求之间的间隔单位秒 CONCURRENT_REQUESTS 4 # 全局最大并发请求数 USER_AGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ROBOTSTXT_OBEY True # 遵守robots协议演示项目建议开着 ITEM_PIPELINES { demo_spider.pipelines.CleanPipeline: 300, demo_spider.pipelines.SQLitePipeline: 400, } LOG_LEVEL INFODOWNLOAD_DELAY和CONCURRENT_REQUESTS是调整爬取压力最常用的两个旋钮。真实数据采集场景里2秒延迟、4个并发是比较保守的组合如果目标是本地测试站点可以把延迟调到0.5、并发调到8速度会明显提升。ROBOTSTXT_OBEY在演示阶段保持True一方面培养合规采集习惯另一方面避免学生拿外网站点测试时造成不必要的访问压力。ITEM_PIPELINES里字典的值是执行顺序数字小的先跑所以CleanPipeline负责清洗SQLitePipeline负责入库先后关系很明确。UA池也一并放在这里。很多站点会拦掉默认UA所以这份资源里一般会配一个随机UA中间件# middlewares.py import random USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36, ] class RandomUserAgentMiddleware: def process_request(self, request, spider): request.headers[User-Agent] random.choice(USER_AGENTS) return None同时在settings.py里通过DOWNLOADER_MIDDLEWARES注册这个中间件。逻辑说明process_request在每个请求发出前执行random.choice从列表里随机挑一个UA覆写请求头避免所有请求都带同一个标识。注意中间件和Pipeline的区别——中间件作用于请求/响应链路Pipeline作用于Item落地链路两者职责别混。调试阶段常见做法是用scrapy shell https://example-site.local/list进交互环境随手敲response.css(...)验证选择器是否正确。这比反复跑完整爬虫看日志高效得多尤其是页面结构变化时几乎成了排查问题的首选手段。3. 存储层多源异构数据的入库流程与去重治理爬虫产出的是Raw Data能不能变成平台可用的数据资产取决于存储设计。标题里「多源异构」四个字不是摆设这一层的核心矛盾是数据来源五花八门格式千差万别如何统一收口。3.1 存储选型SQLite为主、文件目录为辅的策略教学演示项目不适合一上来就部署MySQL集群但要体现「多源异构」的工程设计思路。这套平台采用的方案是分层存储结构化字段进SQLite原始文件进文件系统中间JSON保留在导出目录里。数据类别存储位置选择理由结构化记录标题/作者/时间/正文等data/demo.dbSQLite零配置、单文件、方便分发演示图片、网页快照等二进制files/images、files/html数据库只存路径避免SQLite体积膨胀爬虫原始响应JSONexports/raw保留最原始数据方便回溯和重新清洗SQLite在这类教学项目里几乎是默认答案不需要额外安装数据库服务一个文件就是一个库学生拷走整个工程目录就能跑。但代价是并发写能力弱这个坑后面专门讲。图片和正文快照就不建议塞进SQLite了一方面数据库文件会迅速膨胀另一方面这类数据很少需要按字段查询保留文件路径反而更灵活。工程目录里通常会预留这样的数据组织方式data/ demo.db # 结构化数据文章表、来源表 exports/ raw/ # 爬虫抓回的原始JSON json/ # 清洗后导出的中间JSON files/ images/ # 图片按source_site分目录存放exports/json是给「不会SQL的人」准备的出口。教学演示时经常需要把数据导出给其他同学分析统一JSON格式比直接丢一个SQLite文件更友好。多源数据在写入SQLite之前先在这个目录里过一次手也方便排查清洗逻辑的问题。3.2 清洗与入库类型转换、去重和事务控制Pipeline是数据入库前的最后一站。我在查看这套资源的Pipeline实现时最关心的就是三件事脏数据怎么拦、重复数据怎么去、批量写怎么不锁库。一个合格的SQLitePipeline大概长这样import sqlite3 from datetime import datetime from scrapy.exceptions import DropItem def parse_time(value): # 兼容 2024-01-05 12:30:00 和 2024/01/05 两种常见格式 for fmt in (%Y-%m-%d %H:%M:%S, %Y/%m/%d, %Y-%m-%d): try: return datetime.strptime(value.strip(), fmt) except (ValueError, AttributeError): continue return None class SQLitePipeline: def open_spider(self, spider): self.conn sqlite3.connect(data/demo.db) self.conn.execute( CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, url TEXT UNIQUE, author TEXT, publish_time DATETIME, content TEXT, source_site TEXT ) ) def process_item(self, item, spider): # title为空直接丢弃不污染数据表 if not item.get(title): raise DropItem(fMissing title: {item}) self.conn.execute( INSERT OR IGNORE INTO articles (title, url, author, publish_time, content, source_site) VALUES (?, ?, ?, ?, ?, ?), (item[title], item[url], item.get(author), parse_time(item.get(publish_time)), item.get(content), item[source_site]) ) self.conn.commit() return item def close_spider(self, spider): self.conn.close()这段代码里有几个设计点值得抄进自己的项目。url TEXT UNIQUE是数据库层面的去重约束配合INSERT OR IGNORE重复URL直接跳过不需要在Python里做一次if url in seen的判断。parse_time做多格式兼容不同站点的发布时间格式几乎不可能统一解析失败返回None保留NULL而不是写入脏字符串。DropItem的调用位置在插入前title为空就丢弃避免前端拿到空标题的尴尬。提交策略需要注意这里每条Item都commit()在低数据量教学场景中没问题但爬虫跑几千条时SQLite会越来越慢。更优做法是攒够一定条数或每隔几秒批量提交一次既能提速又能减少锁冲突。压缩包里一般保留简单写法便于阅读生产环境要自己改成批量提交。对于手工数据和API数据这类平台通常还会附带一个导入脚本把JSON/CSV里已有的数据合并进同一个SQLite库import json, sqlite3 def import_json_export(path): conn sqlite3.connect(data/demo.db) with open(path, r, encodingutf-8) as f: records json.load(f) for r in records: conn.execute( INSERT OR IGNORE INTO articles (title, url, author, publish_time, content, source_site) VALUES (?, ?, ?, ?, ?, ?), (r[title], r[url], r.get(author), parse_time(r.get(publish_time)), r.get(content), r.get(source_site, manual)) ) conn.commit() conn.close() print(fimported {len(records)} records)参数说明path指向前一步导出目录里的JSON文件source_site缺省为manual这样手工导入的数据和爬虫数据能在前端看板里区分开。这里体现的多源异构核心思路是不同来源在入口处清洗在表结构上统一在查询时通过source_site字段回溯来源。4. 展示层与手写数字识别接口封装、前端看板和CNN模型这一章是平台最有「演示效果」的部分。爬虫抓回来的数据通过Flask暴露成接口前端用图表呈现手写数字模型同样封装成HTTP接口前端画板调用后返回识别结果。整个模块的逻辑链是数据表 → Flask接口 → 前端图表模型文件 → 推理函数 → Flask接口 → 前端画板。4.1 Flask数据接口把数据库和模型统一变成APIFlask在这类工程里承担的是轻量后端角色。它的优势是简单一个文件就能把数据查询和模型推理都包进来。平台中典型的app.py核心路由有两个from flask import Flask, jsonify, request import sqlite3 import torch from model import Net app Flask(__name__) model Net() model.load_state_dict(torch.load(model/model.pt, map_locationcpu)) model.eval() app.route(/api/articles) def articles(): conn sqlite3.connect(data/demo.db) rows conn.execute( SELECT id, title, author, publish_time, source_site FROM articles ORDER BY id DESC LIMIT 50 ).fetchall() cols [id, title, author, publish_time, source_site] return jsonify([dict(zip(cols, row)) for row in rows]) app.route(/api/predict, methods[POST]) def predict(): payload request.get_json() pixels payload[pixels] # 前端传回的28x28灰度列表 tensor torch.tensor(pixels, dtypetorch.float32).view(1, 1, 28, 28) with torch.no_grad(): logits model(tensor) pred logits.argmax(dim1).item() prob torch.softmax(logits, dim1).max().item() return jsonify({digit: pred, confidence: round(prob, 4)}) if __name__ __main__: app.run(host127.0.0.1, port5000, debugTrue)路由设计的要点在pixels的数据形状转换。前端canvas是280×280的绘制区域传给后端前要先缩放到28×28并归一化后端拿到后view(1, 1, 28, 28)变成模型要求的(batch, channel, height, width)。模型文件在服务启动时加载一次放在路由外面避免每次请求都重新读盘——这是推理服务最基本的性能习惯。map_locationcpu让模型在无GPU的机器上也能跑教学环境绝大多数是CPU。如果你要对接其他前端页面跨域是个绕不开的问题。Flask默认不允许跨域请求前端页面如果跑在不同端口浏览器会拦截响应。常见做法是装flask-cors然后初始化时加一句CORS(app)。这类平台的前后端通常都在本地演示不配置CORS的话页面经常是「接口有数据、浏览器全报错」的诡异状态。4.2 交互式前端图表轮询与手写画板前端文件一般是一个静态HTML加ECharts、原生JavaScript不引入前端脚手架目的是降低阅读门槛。核心功能有两个定时刷新数据图表、canvas手写数字识别。canvas idboard width280 height280/canvas button idpredictBtn识别/button div idresult识别结果/div script // 每5秒拉一次最新文章数据更新来源分布柱状图 async function refreshChart() { const res await fetch(/api/articles); const list await res.json(); const stats {}; list.forEach(item { stats[item.source_site] (stats[item.source_site] || 0) 1; }); // 用stats更新ECharts的series.data } // 把canvas内容缩小成28x28的灰度数组 function getPixels() { const canvas document.getElementById(board); const ctx canvas.getContext(2d); const imgData ctx.getImageData(0, 0, 280, 280); const resized new Array(28 * 28); // 遍历280x280像素按区块平均降采样到28x28 for (let i 0; i 28; i) { for (let j 0; j 28; j) { let sum 0; for (let dy 0; dy 10; dy) { for (let dx 0; dx 10; dx) { const idx ((i * 10 dy) * 280 (j * 10 dx)) * 4; sum imgData.data[idx]; // 取R通道灰度下RGB相等 } } resized[i * 28 j] sum / 100 / 255; // 归一化到0~1 } } return resized; } /scriptrefreshChart的轮询逻辑是教学场景里常见做法每5秒调用一次接口数据变了图表自己跟着变免去手动刷新页面。相比WebSocket轮询牺牲实时性换来了实现简单对演示平台来说足够。getPixels是整个手写识别前端里最容易出错的地方——它把280×280画板按10×10区块平均降采样到28×28然后除以255做归一化。前端画板若是黑底白字这里取到的R通道值分布和白底黑字完全相反模型预测结果会天差地别这一点在避坑章节还会重点讲。4.3 CNN手写数字模型网络结构、训练配置与落盘手写数字识别模型基于PyTorch用的是一组经典CNN结构。这个网络在MNIST这类数据集上是「基本功」级别的存在参数量不大CPU上几十秒就能完成一轮训练非常适合教学演示。import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, 3, padding1) # 输入1通道输出32通道 self.conv2 nn.Conv2d(32, 64, 3, padding1) # 32 - 64 self.pool nn.MaxPool2d(2) # 2x2最大池化 self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) # 10个数字类别 def forward(self, x): x self.pool(F.relu(self.conv1(x))) # 28 - 14 x self.pool(F.relu(self.conv2(x))) # 14 - 7 x x.view(x.size(0), -1) # 展平64*7*7 x F.relu(self.fc1(x)) return self.fc2(x)网络结构解析两个卷积层各接一个ReLU和2×2最大池化28×28输入经过两次池化变成7×7通道数从1涨到64展平后经过两个全连接层输出10类得分。padding1保证卷积不改变特征图尺寸所以尺寸变化完全由池化层控制。训练部分一般配套一份train.pyfrom torch import optim import torch model Net() optimizer optim.Adam(model.parameters(), lr0.001) loss_fn nn.CrossEntropyLoss() # 训练5个epochbatch_size64 for epoch in range(5): for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss loss_fn(outputs, labels) loss.backward() optimizer.step() print(fepoch {epoch1} finished) torch.save(model.state_dict(), model/model.pt)训练参数有三个值得留意lr0.001是Adam在MNIST这种简单任务上的惯例起点改到0.01容易震荡、改到0.0001收敛过慢5个epoch在CPU上也就几分钟验证集准确率一般在98%以上再增大epoch收益变低教学场景不需要刷点batch_size64在显存或内存有限的机器上比较稳妥调大能略微加速但会明显吃内存。model.state_dict()只保存权重不保存网络结构所以推理时Net()和load_state_dict必须成对出现。5. 避坑与常见问题三条链路各自的翻车现场这类多模块项目跑通一次不难难的是换了数据源、换了机器、换了Python版本之后还能稳定跑。下面是我拆解这套平台时整理的踩坑记录每一条都是真实项目里出现过的现象按「现象 → 原因 → 解决」列出。5.1 爬虫与存储侧问题现象一启动scrapy crawl demo_site后一个请求都不发日志停留在抓取robots.txt最后整个爬虫直接退出。原因ROBOTSTXT_OBEY True时Scrapy会先请求目标站点的robots.txt。测试站点如果没配置robots或者对非浏览器UA返回403爬虫会认为「不允许抓取」而拒绝发送后续请求。解决本地演示环境把ROBOTSTXT_OBEY改成False或者给测试站点补一个允许抓取的robots.txt。正式采集时再根据目标站点策略决定是否开启不要用同一套配置通吃所有场景。现象二爬虫跑到一半Pipeline报sqlite3.OperationalError: database is locked。原因SQLite同一时刻只允许一个写者。多个爬虫进程并发跑或者Pipeline里频繁 commit 导致写锁冲突。解决先检查是否有多个爬虫进程在同时写同一个数据库文件教学场景强烈建议一次只跑一个爬虫再把Pipeline改成批量提交攒满50条或每隔3秒提交一次减少锁竞争。如果数据量真的上来了就该换MySQL或PostgreSQL。现象三入库后打开数据库中文全部是乱码或者报UnicodeEncodeError。原因目标站点响应编码不是UTF-8常见于GBK老站点Scrapy默认按UTF-8解码解码失败就产生乱码。解决在Spider里对response显式指定编码常见做法是先打印response.encoding确认再设置response.encoding gbk然后重新抽取字段。这个位置属于「设一次就再也不会碰到」的坑但第一次碰上确实恼火。现象四爬虫正常跑完日志显示item_scraped_count: 0数据表里干干净净一行都没有。原因页面结构变了CSS选择器没匹配到任何元素。目标站点改版、加了登录墙、接口改成动态渲染都会触发这个问题。解决用scrapy shell把页面拉下来确认当前结构重新写选择器。另外可以看一眼DOWNLOADER_MIDDLEWARES里是否加了处理JS渲染的中间件——没有的话动态加载内容自然抓不到。5.2 前端与模型侧问题现象五接口直接访问有正常JSON浏览器前端页面里图表一直空白控制台报CORS错误。原因Flask服务跑在5000端口前端页面跑在另一个端口或直接用file协议打开跨域请求被浏览器拦截。即使两个服务都在本机不同端口之间也属于跨域。解决安装flask-cors在app里加CORS(app, resources{r/api/*: {origins: *}})或者让Flask直接托管静态页面把前端HTML放进templates或static浏览器和接口同源彻底绕开跨域。现象六模型训练时验证集准确率98%但前端画板里写数字识别结果一塌糊涂甚至稳定预测成同一个数。原因几乎都是预处理不一致。训练时MNIST数据是白底黑字、28×28、数值归一化到0-1前端canvas传过来的可能是黑底白字或者没有归一化或者图像缩放方式跟训练集不一样。模型的输入分布变了输出自然崩。解决把前端预处理统一成训练时的标准——灰度图、反色如果训练集是白底黑字、缩放28×28、逐像素除以255。这个坑最隐蔽的地方在于缩放算法280降采样到28简单跳跃取点和区块平均取点的结果不同推荐用和MNIST转换一致的均值降采样也就是前面getPixels里的写法。6. 进阶把四条链路串起来做端到端验证多模块项目最怕「单模块都正常、一连起来就完蛋」。拿到这个压缩包后我强烈建议你按下面的顺序跑一遍最小闭环任何一步卡住问题范围立刻缩小到具体模块不用满工程排查。第一步验证爬虫链路。在工程根目录执行scrapy crawl demo_site跑完后用SQL查一下数据落库情况sqlite3 data/demo.db SELECT source_site, count(*) FROM articles GROUP BY source_site;看到每个来源站点都有记录说明爬虫、Pipeline、SQLite这一侧通了。如果source_site字段全是空回去查Item里有没有给每个Spider赋值——这是新手最容易漏的一步。第二步验证接口链路。启动Flask服务另开终端python app.py curl http://127.0.0.1:5000/api/articles返回JSON数组就说明数据库到接口通了。再验证模型接口curl -X POST http://127.0.0.1:5000/api/predict \ -H Content-Type: application/json \ -d {pixels: [0, 0, ...]}这里pixels数组我用省略号略写了实际传的时候要传满28×28共784个数。第三步验证前端展示。浏览器打开看板页图表有数据、画板能识别整个闭环就通了。我一般会用一张手写的数字5来做基准测试——如果前端画板把5断成3或9优先怀疑预处理不一致而不是模型训练不够。验证完成后后续扩展方向也很清晰爬虫层可以加Cron定时调度做增量抓取存储层可以把SQLite平滑替换成MongoDB体现真正的多源异构模型层还能换ResNet变体或者加入数据增强提升鲁棒性前端可以引入WebSocket替代5秒轮询。这套工程的模块边界比较干净每个部分都是可替换的这正是它适合实战开发的原因。回头看我自己第一次拆这种多模块工程时图省事跳过了端到端验证直接闷头改模型结构结果改完发现前端图表一直是空的——折腾了一下午最后定位到是CORS没配。从那以后我每次拿到这类平台包都强制先跑一遍从爬虫到前端的最小闭环再往里加功能。顺序对了排错成本能降一个数量级。希望这个习惯也能帮到你少走这一步弯路。本文还有配套的精品资源点击获取