ARTICLE DETAIL

资讯详情

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

图书销售排行预测评分网站实战:Python爬虫+Flask+Vue+Django全栈开发

图书销售排行预测评分网站实战:Python爬虫+Flask+Vue+Django全栈开发 做这个项目的时候我其实酝酿了很久。图书销售排行预测评分网站说白了就是把爬虫、Web后端、前端展示、算法评分串成一条完整的数据流水线。市面上讲爬虫的教程很多讲Flask和Vue的也不少但真正能把“爬数据 → 清洗入库 → 预测评分 → 可视化展示”这条链路完整走通还能落地部署的项目并不多见。这篇文章就从我的实战视角把我做这个 python-flask 图书销售排行预测评分网站时的完整思路、技术选型理由、核心代码设计和踩坑经历都摊开来说希望对正在做类似项目的朋友有实际帮助。1. 项目定位与整体架构设计先说清楚这个项目到底做了什么。图书销售排行预测评分网站核心功能有三个维度一是自动采集电商平台或公开书库的图书销售数据也就是爬虫部分二是基于历史销量、评分、评论数等特征用一套评分模型对图书未来的销售表现做预测排行三是把预测结果通过 Web 界面可视化呈现给用户同时支持关键词搜索、图书详情查看、评分对比。整个技术栈围绕 python、flask、爬虫、vue、django 这几个关键词展开但这里需要说明一下 Django 在项目里的真实位置——我把它用了两种方式处理一种是用 Django 的 ORM 模型设计思路来指导 Flask 侧的数据库模型另一种是最终部署时用 Django 写了一个简单的管理后台做数据看板。主服务还是以 Flask 为主因为 Flask 轻量灵活适合这种中大型单页应用的后端接口服务。1.1 系统总体架构整个项目分成四层数据采集层、业务处理层、服务接口层、前端展示层。数据采集层跑的是自研的爬虫程序用 requests 加 BeautifulSoup 抓取公开的图书榜单页面再用 pandas 做数据清洗。业务处理层用 Flask 封装了核心算法逻辑包括评分预测模型、排行榜计算、用户评分记录。服务接口层提供 RESTful API涵盖图书列表、榜单排行、搜索、预测评分、用户注册登录等。前端展示层我用的是 Vue 3 加 Element Plus通过 axios 调用后端接口渲染图书卡片、排行表格、评分雷达图这些可视化组件。如果画一张数据流向图就是爬虫脚本 → 原始数据 CSV → 清洗入库MySQL → Flask 接口读取 → 评分模型计算 → Vue 前端渲染。这条链路里最重要的是数据格式的统一。我爬下来的字段包括书名、作者、出版社、出版日期、定价、当前售价、月销量、累计评论数、好评率、榜单类型、收录时间。这些字段后面要同时喂给预测模型和展示页面所以字段名在设计爬虫时就要想好不然后面清洗阶段会非常痛苦。1.2 为什么选 Flask 而不是直接用 Django很多朋友会问既然热词里同时出现了 Flask 和 Django为什么不全用 Django我的理由很简单Flask 的微内核更适合接口优先的开发模式。预测评分网站的前端是 Vue 单页应用前后端完全分离Flask 只需要专心返回 JSON 数据不需要渲染模板。Django 自带模板引擎和 Admin 后台在这些场景下反而是多余的重量。但 Django 在这个项目中也没有白费。我最终用 Django 单独写了一个内部管理后台用来做数据质量监控——比如爬虫抓取了多少条数据、预测评分分布情况、用户反馈列表。这个后台只有管理员能访问和主站 Flask 服务分开跑在不同端口通过 Nginx 做反向代理。这样做的好处是职责清晰Flask 管 C 端用户体验Django 管 B 端运营管理。热词里提到的 “django streaminghttpresponse 参数 content_type 和 content-disposition”我也在后台的 CSV 导出功能中用到了后面细说。2. 爬虫模块从页面抓取到数据落地的完整链路爬虫是数据源头这个模块的质量直接决定预测模型和前端展示的效果。我做爬虫的第一原则是遵守 robots 协议和目标网站的合理访问频率控制请求间隔在 1 到 2 秒单次任务总量不超过 3000 条。这个项目主要用于学习和研究场景所有数据仅用于排行分析展示不涉及商用和隐私信息。2.1 爬虫代码设计与关键函数我先把爬虫核心代码拆出来看主要分三个函数fetch_html负责请求页面并返回结构化 HTMLparse_books负责解析图书列表页save_to_excel负责把解析结果落盘。下面是完整代码import time import random import requests from bs4 import BeautifulSoup import pandas as pd from urllib.parse import urljoin HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } BASE_URL https://example-book-list.com/rank def fetch_html(url, retry3): 请求页面并返回 BeautifulSoup 对象带重试机制 for attempt in range(retry): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: resp.encoding resp.apparent_encoding return BeautifulSoup(resp.text, html.parser) else: print(f[警告] 状态码异常: {resp.status_code}, URL: {url}) except requests.RequestException as e: print(f[错误] 第 {attempt 1} 次请求失败: {e}) time.sleep(2) return None def parse_books(html, category): 从榜单页提取图书字段 books [] if html is None: return books items html.select(div.book-item) for item in items: try: title_tag item.select_one(a.book-title) author_tag item.select_one(span.book-author) price_tag item.select_one(span.book-price) sales_tag item.select_one(span.book-sales) comment_tag item.select_one(span.book-comments) rating_tag item.select_one(span.book-rating) book { 书名: title_tag.get_text(stripTrue) if title_tag else 未知, 作者: author_tag.get_text(stripTrue) if author_tag else 未知, 价格: float(price_tag.get_text(stripTrue).replace(¥, )) if price_tag else 0.0, 月销量: int(sales_tag.get_text(stripTrue).replace(,, )) if sales_tag else 0, 评论数: int(comment_tag.get_text(stripTrue).replace(,, )) if comment_tag else 0, 好评率: float(rating_tag.get_text(stripTrue).replace(%, )) if rating_tag else 0.0, 榜单类型: category, 收录时间: time.strftime(%Y-%m-%d %H:%M:%S), } books.append(book) except (AttributeError, ValueError) as e: print(f[解析跳过] 一条数据解析异常: {e}) continue return books def crawl_rank_pages(categories, pages_per_category5): 批量爬取多个榜单页 all_books [] for category in categories: for page in range(1, pages_per_category 1): url f{BASE_URL}?category{category}page{page} html fetch_html(url) books parse_books(html, category) all_books.extend(books) print(f[完成] 榜单 {category} 第 {page} 页累计 {len(books)} 条) time.sleep(random.uniform(1.0, 2.0)) # 控制访问频率 return all_books def save_to_excel(books, filenamebooks_data.xlsx): 数据清洗并保存为 Excel方便后续导入数据库 df pd.DataFrame(books) df.drop_duplicates(subset[书名, 作者], keepfirst, inplaceTrue) df.fillna({好评率: 0, 评论数: 0}, inplaceTrue) df.to_excel(filename, indexFalse, engineopenpyxl) print(f[保存] 共 {len(df)} 条数据写入 {filename}) return df if __name__ __main__: categories [文学, 科技, 历史, 经济管理] data crawl_rank_pages(categories, pages_per_category3) if data: df save_to_excel(data) print(df.head())这段代码看起来简单但有几个细节是实际跑下来才发现的坑。第一是resp.encoding resp.apparent_encoding这一步不能省很多排行榜页面没有在响应头里明确 charset用默认编码解析中文书名会乱码。第二是解析时一定要做异常捕获因为页面结构偶尔会调整某个字段缺失会导致整条数据被丢弃但如果不打印日志你根本不知道是哪一步出了问题。第三是random.uniform(1.0, 2.0)的随机延时比固定延时更接近人类浏览行为对目标服务器更友好也更不容易触发反爬升级。2.2 反爬策略与合规边界爬虫写多了你会形成本能反应第一件事就是看目标网站的 robots.txt。我这次抓的是公开图书榜站点没有明确禁止爬取但我依然做了三件事限制每秒请求数不超过 1 次、设置 User-Agent 标明来源、只在白天时段运行爬虫任务避开高峰。这三条在合规层面不一定能完全免责但它体现的是一个开发者的基本职业操守。做爬虫项目的底线是不碰个人隐私数据、不抓需要登录才能看的付费内容、不绕过验证码和加密参数、不对目标站点造成访问压力。那些因为爬虫入狱的案例绝大多数是非法获取个人信息或者恶意攻击服务器和我们做入门级项目完全不是一回事。但这个问题值得每个初学者在写第一行爬虫代码之前就想清楚——工具本身没有原罪使用工具的目的和边界才是关键。2.3 数据清洗与入库爬下来的原始数据不能直接用尤其是销量数字里可能带着逗号、好评率可能是字符串类型、价格可能混着单位。我用 pandas 做了三步清洗去重、类型转换、异常值过滤。去重键选择“书名 作者”组合而不是单独书名是因为重名书太多比如《活着》有多个版本、精装和平装同时上榜。类型转换放到parse_books里做了一部分save_to_excel里再做一次兜底。异常值过滤规则是价格小于 1 元的、评论数为负数的、好评率超过 100% 的数据直接剔除。这些脏数据一旦混进预测模型的训练集后面所有结果都会失真。清洗完的数据最终要导入 MySQL。我是先用 pandas 的to_sql快速导入后续增量更新用自己写的 INSERT 语句。具体入库代码我放在后面 Flask 部分一起讲。3. Flask 后端核心API 接口、预测模型与 Django 管理台后端是整个项目的中枢既要处理爬虫入库的数据又要运行评分预测算法还要给 Vue 前端提供稳定接口。我用 Flask 写了一个app.py主程序配合蓝图模块化管理。为了避免文件膨胀路由按业务拆分为book_routes.py、auth_routes.py、rank_routes.py三个模块再在主程序里注册。3.1 Flask 应用初始化与数据库连接数据库我用 MySQL 8.0连接方式用的是pymysql加SQLAlchemyORM。热词里提到的 django ORM 思路在这里派上了用场——我先像设计 Django 模型一样规划了Book和PredictionResult两张核心表再翻译成 SQLAlchemy 模型。from flask import Flask, jsonify, request from flask_cors import CORS from flask_sqlalchemy import SQLAlchemy app Flask(__name__) CORS(app, resources{r/api/*: {origins: *}}) # 数据库配置密码部分用环境变量读取避免明文泄露 import os DB_USER os.getenv(DB_USER, root) DB_PASSWORD os.getenv(DB_PASSWORD, your_password) DB_HOST os.getenv(DB_HOST, 127.0.0.1) DB_NAME os.getenv(DB_NAME, book_rank) app.config[SQLALCHEMY_DATABASE_URI] ( fmysqlpymysql://{DB_USER}:{DB_PASSWORD}{DB_HOST}:3306/{DB_NAME}?charsetutf8mb4 ) app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False app.config[JSON_AS_ASCII] False # 保证返回中文不转义 db SQLAlchemy(app) class Book(db.Model): __tablename__ books id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(200), nullableFalse) author db.Column(db.String(100)) publisher db.Column(db.String(200)) publish_date db.Column(db.String(50)) price db.Column(db.Float) monthly_sales db.Column(db.Integer) comment_count db.Column(db.Integer) rating db.Column(db.Float) category db.Column(db.String(50)) crawled_at db.Column(db.String(50)) class PredictionResult(db.Model): __tablename__ prediction_results id db.Column(db.Integer, primary_keyTrue) book_id db.Column(db.Integer, db.ForeignKey(books.id)) predict_score db.Column(db.Float) trend db.Column(db.String(20)) predict_rank db.Column(db.Integer) updated_at db.Column(db.String(50)) with app.app_context(): db.create_all()这段模型设计的要点是JSON_AS_ASCII False不设这个参数Flask 返回的 JSON 里所有中文都会变成\uXXXX前端拿到正常显示没问题但你在调试接口时看一屏的 Unicode 转义会非常崩溃。另外CORS是前后端分离项目必须处理的不然 Vue 页面在 5173 端口开发模式下访问 Flask 的 5000 端口接口会被浏览器拦截。这里我为了方便测试直接用了*生产环境建议限定具体域名。3.2 数据入库与增量更新策略爬虫程序把清洗好的数据保存为 Excel 后还需要一个脚本把 Excel 内容同步到 MySQL。这里我用了一个独立的import_data.py直接从 pandas DataFrame 遍历写入。import pandas as pd from app import app, db, Book def import_books_from_excel(filepath): df pd.read_excel(filepath, engineopenpyxl) with app.app_context(): for _, row in df.iterrows(): # 先检查是否已存在避免重复入库 exists Book.query.filter_by(titlerow[书名], authorrow[作者]).first() if exists: # 更新销量和评分保留首次爬取时间 exists.monthly_sales int(row[月销量]) exists.rating float(row[好评率]) exists.comment_count int(row[评论数]) else: book Book( titlerow[书名], authorrow[作者], pricefloat(row[价格]), monthly_salesint(row[月销量]), comment_countint(row[评论数]), ratingfloat(row[好评率]), categoryrow[榜单类型], crawled_atrow[收录时间], ) db.session.add(book) db.session.commit() print(f[入库完成] 共处理 {len(df)} 条记录)这里有一个我对增量更新的理解图书销售数据是随时间变化的同一本书今天爬的月销量和下周爬的月销量显然不同。所以我采用“存在即更新”的策略用书名和作者做联合去重键重复出现的书只更新销量、评论数和好评率不重复插入新记录。这样做的好处是数据库不会膨胀历史趋势数据可以通过crawled_at字段的多次记录来分析。但要注意crawled_at在更新时我故意没有覆盖这样如果后续要做时间序列分析就能追踪同一本书在不同时间点的销量变化——这是预测模型非常宝贵的训练素材。3.3 评分预测算法实现评分预测是项目最核心的部分。我要回答的核心问题是根据一本书当前的销量、评论数、价格、好评率这些特征预测它未来的销售表现会处于什么水平。这里没有用复杂的深度学习模型而是采用了一个可解释性强、适合中小数据量的加权评分模型。我的算法思路是这样的综合分 月销量得分 × 40% 评论数得分 × 25% 好评率得分 × 25% 价格系数修正 × 10%。每个指标先做 Min-Max 归一化到 0 到 100 分再按权重加权。import numpy as np def normalize_minmax(series): 最小最大归一化到 0-100 min_val series.min() max_val series.max() if max_val min_val: return pd.Series([50.0] * len(series)) return (series - min_val) / (max_val - min_val) * 100 def predict_scores(book_list): 输入图书列表返回带预测分和排名的结果 df pd.DataFrame(book_list) if df.empty: return [] sales_score normalize_minmax(df[monthly_sales]) comment_score normalize_minmax(df[comment_count]) rating_score df[rating] # 好评率本来就是百分制不需要再归一化 # 价格修正因子价格在 10-100 元之间的图书更符合大众消费区间适当加分 price_factor np.where( (df[price] 10) (df[price] 100), 1.0, 0.9 ) total_score ( sales_score * 0.4 comment_score * 0.25 rating_score * 0.25 price_factor * 10 ) df[predict_score] total_score.round(2) df[trend] np.select( [df[monthly_sales] df[monthly_sales].mean()], [上升], default平稳 ) df.sort_values(predict_score, ascendingFalse, inplaceTrue) df.reset_index(dropTrue, inplaceTrue) df[predict_rank] df.index 1 return df.to_dict(orientrecords)关于价格修正因子我想多说两句。实际跑数据时发现定价过高比如超过 200 元的精装套书和定价过低比如 5 元以下的促销书的销量波动极大直接不修正会把排行榜结果带偏。所以我把价格作为调节因子而不是硬指标价格在 10 到 100 元大众区间的书获得满分权重区间外的乘 0.9相当于给出温和惩罚。这个设计不算精妙但胜在简单有效、易于解释。如果你对预测精度有更高要求可以考虑换成 LightGBM 或 XGBoost 回归模型但数据量没有上万条之前复杂的模型反而容易过拟合加权评分反而是最稳的选择。3.4 API 路由设计后端路由我设计了 6 个核心接口获取榜单列表、获取图书详情、搜索图书、提交用户评分、预测结果查询、导出 CSV。全部走/api/前缀返回统一的 JSON 格式。 app.route(/api/books, methods[GET]) def get_books(): 分页获取图书列表支持按分类和排序参数 page request.args.get(page, 1, typeint) per_page request.args.get(per_page, 20, typeint) category request.args.get(category, ) sort_by request.args.get(sort_by, predict_score) query Book.query if category: query query.filter_by(categorycategory) if sort_by in [monthly_sales, rating, price, predict_score]: if sort_by predict_score: # 关联预测结果表联合排序 query query.outerjoin(PredictionResult).order_by(PredictionResult.predict_score.desc()) else: query query.order_by(getattr(Book, sort_by).desc()) pagination query.paginate(pagepage, per_pageper_page, error_outFalse) items [] for book in pagination.items: pred PredictionResult.query.filter_by(book_idbook.id).first() items.append({ id: book.id, title: book.title, author: book.author, price: book.price, monthly_sales: book.monthly_sales, rating: book.rating, category: book.category, predict_score: pred.predict_score if pred else None, trend: pred.trend if pred else 新书, predict_rank: pred.predict_rank if pred else None, }) return jsonify({ code: 0, data: { items: items, total: pagination.total, page: page, per_page: per_page, } })前端积分榜的默认排序就是用预测分倒序配合分页参数Vue 页面只需要把 page 和 per_page 传给后端就能拿到对应页的数据。接口返回的数据结构统一包了一层code和data这是一种很常见的约定便于前端统一处理错误状态。这里我踩过的坑是paginate方法在 Flask-SQLAlchemy 3.x 版本中默认error_outTrue如果请求的页码超出了总页数会抛 404 错误。前端如果再优雅地处理一下倒还好如果没处理用户随便点一下页码就直接白屏。所以我把它设为False超出范围时返回空列表这个细节值得注意。4. Vue 前端搭建从页面结构到数据展示前端我用 Vue 3 加 Vite 构建UI 组件库选择了 Element Plus图表可视化用 ECharts。整个前端部分没有用 Vuex 或 Pinia因为项目的数据流足够简单——榜单、详情、搜索三个页面之间不需要共享复杂状态组件内自己维护数据就够了。4.1 前端项目结构与路由先看 Vite 创建项目的命令和目录结构npm create vitelatest book-rank-web -- --template vue cd book-rank-web npm install npm install element-plus axios echarts vue-router4前端路由配置有两个主要页面首页是图书排行看板展示预测分 Top 排行、销量趋势图和筛选条件搜索页支持按书名、作者、分类检索搜索结果以卡片形式展示。路由配置如下// src/router/index.js import { createRouter, createWebHistory } from vue-router; const routes [ { path: /, name: Dashboard, component: () import(../views/Dashboard.vue), }, { path: /search, name: Search, component: () import(../views/Search.vue), }, { path: /book/:id, name: BookDetail, component: () import(../views/BookDetail.vue), }, ]; const router createRouter({ history: createWebHistory(), routes, }); export default router;之所以用动态导入() import是为了让路由变成懒加载。如果在前端入口直接 import 所有页面组件首屏就会一次性加载全部 JS 包图书榜单页本身就有大量图片和数据体积会非常大。懒加载把每个页面拆成独立的 JS chunk只有访问对应路由时才加载这是前端性能优化的基本操作。热词里提到的 “vue播放m3u8” 和这个项目没有直接关系但如果你日后要在详情页给图书做视频导读用 video.js 加载 m3u8 地址就行Vue 侧的集成思路和普通视频组件没有本质区别。4.2 核心组件排行看板与图表可视化排行看板是用户进入网站首先看到的界面我用 Element Plus 的el-table渲染排行榜配合 ECharts 绘制销量趋势图。核心封装了一个fetchBooks函数调用前面 Flask 的/api/books接口// src/api/book.js import axios from axios; const apiClient axios.create({ baseURL: http://127.0.0.1:5000/api, timeout: 10000, }); export function fetchBooks(params {}) { return apiClient.get(/books, { params }); } export function fetchBookDetail(id) { return apiClient.get(/books/${id}); } export function searchBooks(keyword) { return apiClient.get(/books/search, { params: { q: keyword } }); }然后排行榜组件的核心 script 部分// src/views/Dashboard.vue script setup import { ref, onMounted } from vue; import { fetchBooks } from ../api/book; const bookList ref([]); const total ref(0); const currentPage ref(1); const loading ref(false); async function loadBooks(page 1) { loading.value true; try { const resp await fetchBooks({ page, per_page: 20, sort_by: predict_score }); bookList.value resp.data.data.items; total.value resp.data.data.total; currentPage.value page; } catch (e) { console.error(加载图书列表失败:, e); } finally { loading.value false; } } onMounted(() loadBooks(1)); /script模板部分我用了el-table展示榜单同时在大屏上叠加一个 ECharts 的饼图展示各分类图书占比。这里有个代码技巧ECharts 的图表需要在 DOM 渲染完成后初始化所以要在onMounted之后调用echarts.init。我之前踩过坑在created生命周期里初始化图表结果容器还没挂载拿到的是undefined控制台直接报错。正确做法是用nextTick或者直接在onMounted里调用。4.3 Vue 与 Flask 联调时的跨域问题和开发代理开发阶段Flask 跑在 5000 端口Vite 跑在 5173 端口跨域是必须处理的问题。我用了两种方案配合后端 Flask 侧已经加了flask_cors前端 Vite 再配一层代理避免浏览器直接发出跨域请求。Vite 配置如下// vite.config.js import { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:5000, changeOrigin: true, }, }, }, });配置代理的好处是前端代码里可以直接写/api/books而不必写完整的http://127.0.0.1:5000/api/books后面部署到生产环境时只需要把 Nginx 的代理规则从开发代理迁移到服务器代理前端代码完全不用改。这个细节很多人忽视导致项目上线时四处找 API 地址替换非常痛苦。5. 部署上线与 Django 管理后台的配合项目开发完不能只在本地跑部署上线是真正考验项目工程化水平的一环。我这次的部署环境是阿里云轻量服务器系统 Ubuntu 22.04配置 2 核 4G。热词里提到 “python django windows10 waitressnginx 部署”说明很多人关注 Windows 环境的部署方式但我实际项目跑在 Linux 服务器上部署链路是 Gunicorn Nginx MySQL。5.1 Flask 生产部署Gunicorn 与 NginxFlask 自带的开发服务器werkzeug在调试时很好用但它并发能力弱、容易阻塞生产环境必须换 Gunicorn。我用的启动命令是gunicorn -w 3 -b 127.0.0.1:5001 wsgi:app-w 3表示启动 3 个 worker 进程-b绑定内网地址外面的请求统一由 Nginx 转发进来。为什么不直接把 Gunicorn 绑到公网 80 端口因为 Nginx 在这一层还能做静态文件缓存、SSL 终止、日志切割如果让 Gunicorn 直接暴露到公网这些功能全都要自己实现得不偿失。Nginx 的核心配置如下server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/book-rank-web/dist; index index.html; # 前端路由 history 模式配置 location / { try_files $uri $uri/ /index.html; } # 后端 API 反向代理 location /api/ { proxy_pass http://127.0.0.1:5001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 7d; add_header Cache-Control public, no-transform; } }这段配置里最关键的是try_files $uri $uri/ /index.html。Vue Router 用的是 history 模式它不像 hash 模式那样在 URL 里带#而是依赖浏览器的 History API。用户直接访问https://你的域名/book/123时Nginx 要根据请求路径去找对应的静态文件找不到就回退到index.html让 Vue Router 自己去解析路由。没有这一行配置刷新页面就是 404。这个坑几乎是所有 SPA 部署的必经之路。5.2 Django 管理后台的职责与 CSV 导出主站用 Flask 跑那 Django 管理后台怎么部署我的做法是把它作为独立应用跑在 5002 端口Nginx 配置一个/admin/前缀转发过去。管理后台主要是给自己人用的界面不需要花哨Django 自带的 Admin 界面就够用了。我在 Django 里创建了一个BookData模型和 Flask 侧的表结构对应然后注册到 Admin。这里就用到热词里的 “django streaminghttpresponse 参数 content_type 和 content-disposition” 了。我在管理后台加了一个导出全部预测结果 CSV 的功能数据量大概几万条时直接用HttpResponse一次性返回会给服务器造成很大内存压力甚至超时。StreamingHttpResponse 可以边生成边输出把 CSV 内容以流式方式推给浏览器。import csv from django.http import StreamingHttpResponse def export_books_csv(request): 流式导出图书数据 CSV def generate_csv(): # 先写表头 header [书名, 作者, 分类, 价格, 月销量, 好评率, 预测分, 排名] yield \ufeff ,.join(header) \n # \ufeff 是 BOM确保 Excel 打开不乱码 books BookData.objects.all().values_list( title, author, category, price, monthly_sales, rating, predict_score, predict_rank ) for book in books: yield ,.join(str(item) for item in book) \n response StreamingHttpResponse(generate_csv(), content_typetext/csv) # Content-Disposition 里带文件名浏览器才会触发下载 response[Content-Disposition] attachment; filenamebook_predictions.csv return responsecontent_type告诉浏览器这是一个 CSV 文件Content-Disposition里的attachment参数触发下载而不是在浏览器里打开filename指定下载的文件名。这两个参数是 HTTP 响应头中最容易搞混但又最基础的部分。很多初学者只设置content_type结果浏览器打开了一堆乱码而不是下载文件就是因为漏了Content-Disposition。这里还有一个细节CSV 文件开头加了一个\ufeffBOM 头因为 Excel 默认用 ANSI 编码打开 CSV没有 BOM 的话中文会乱码加上 BOM 才能让 Excel 正确识别 UTF-8 编码。5.3 数据库备份与定时爬虫任务生产环境的数据安全不能靠运气我写了一个简单的定时备份脚本用 crontab 每天早上 3 点执行0 3 * * * mysqldump -u root -ppassword book_rank /backup/book_rank_$(date \%Y\%m\%d).sql爬虫也需要定时更新我直接用系统 crontab 每天凌晨跑一遍增量爬虫脚本把最新一期的销售数据更新到数据库再触发预测评分重新计算。整个数据链路形成闭环爬虫取数 → 数据清洗 → 入库更新 → 预测模型重算 → 前端排行刷新。site-packages环境打包部署这些常规操作就不展开说了核心逻辑是把依赖固定到requirements.txt用虚拟环境隔离。6. 从开发到上线踩过的五个有代表性的坑任何一个真实项目都是踩坑踩出来的这部分我把这次项目里最典型的五个问题复盘出来每个都对应明显的报错现象或逻辑错误希望对你有前车之鉴。6.1 Flask 模型中文编码问题第一次跑通 Flask 接口时返回的 JSON 里中文全是\u4e66\u7c4d形式的 Unicode 转义。前端能正常解析但用命令行工具调试时根本没法读。这个问题的根因是 Flask 的JSON_AS_ASCII默认是 True它会把非 ASCII 字符全部转义。解决方法是设置app.config[JSON_AS_ASCII] False同时确保数据库连接串里写了charsetutf8mb4否则 MySQL 侧数据就已经乱码了后端设置再对也没用。6.2 SQLAlchemy 分页超出范围报 404前面提过paginate的error_out参数这里再说一下背后的原因。Flask-SQLAlchemy 的paginate方法默认会对超出页数的请求抛出 404。这在普通文章列表里没问题但图书排行榜的预测分是动态计算的数据量可能随时变化。用户停留在第 10 页管理员后台更新数据后总页数变成了 8 页用户再点下一页就报错。所以分页接口必须设置error_outFalse返回空列表让前端提示“没有更多数据了”。6.3 前端 ECharts 容器尺寸为 0ECharts 图表在页面上渲染不出来控制台也没有报错只是看到一片空白。排查后发现是图表组件的容器 div 在初始化时高度为 0。原因是我用百分比高度height: 100%但父容器没有设置具体高度浏览器不知道 100% 是多少。解决办法有两种给容器设置固定的像素高度比如height: 400px或者在初始化前用getBoundingClientRect()动态获取容器高度。我最终选择了固定高度方案排行榜页面的大屏图表用固定 400px 是最稳妥的。这个坑很常见做 Vue ECharts 的朋友基本都会碰到一次。6.4 Vite 构建后路由刷新 404Vue 项目执行npm run build后扔到 Nginx 上首页能打开但点击进入图书详情页后再刷新页面就直接 404。这个坑和前面 Nginx 配置里的try_files是同一个问题。Vue Router 的 history 模式需要服务器把所有请求都回退到index.html让前端路由接管。除了 Nginx 配置也可以在项目里改用 hash 模式URL 变成/#/book/123但 URL 不美观我最后还是坚持用 history 模式加 Nginx 配置。如果你用的服务器是 Apache对应配置是.htaccess里的RewriteRule原理相同。6.5 一次性导入上万条数据超时第一次用to_sql导入爬虫抓到的 5000 条数据时直接抛了超时异常。原因是 pandas 的to_sql默认是一条一条地写5000 条数据在 MySQL 上要跑很久。后来改成批量插入用 SQLAlchemy 的bulk_insert_mappings速度快了几十倍from sqlalchemy.dialects.mysql import insert def bulk_import_books(books): 批量插入图书数据 with app.app_context(): if not books: return stmt insert(Book).values(books) # 使用 INSERT IGNORE 忽略重复主键 stmt stmt.prefix_with(IGNORE) db.session.execute(stmt) db.session.commit()这个函数把 5000 条数据分成一次事务提交MySQL 的INSERT IGNORE会跳过重复的主键配合我们之前“书名作者”的去重逻辑既能保证数据不重复又能保证批量插入性能。7. 部署后如何监控数据质量与用户反馈项目上线后不能撒手不管。我在 Django 后台里加了一个数据质量看板统计每天的爬虫抓取条数、入库成功条数、预测计算耗时和异常数据比例。这个看板不需要花哨图表Django Admin 自带的列表页加几个 filter 就够用了。关键指标是入库成功率和预测分覆盖率。如果某天爬虫改版入库成功率大幅下降后台立刻能看到。用户反馈功能是我非常看重的模块。我在 Vue 前端加了一个评分和评论入口用户可以对某本书的预测分是否合理进行反馈。接口设计为/api/feedback提交后写入 MySQL 的feedback表。这些数据积累到一定量后可以反过来校验预测模型的准确性。比如每本书的预测分和用户实际评分对比如果偏差持续超过 20 分说明模型某个特征权重偏了就要重新调参。这就是一个简易的“模型迭代闭环”。目前这个项目的预测模型还没有做自动更新还是靠手动跑重算脚本。后续如果要升级我打算把 Flask 侧加一个定时任务每天凌晨拉取爬虫新数据后自动重算预测分重算完成后清理 Redis 缓存前端排行榜自动刷新。这样就会变成一个真正“活”的图书销售预测网站。如果你也要做类似项目或者正在纠结 Flask 和 Django 怎么选、爬虫数据怎么建模、Vue 和 Flask 怎么对接希望这篇实操记录能给你一个具体参考。代码不复杂但每一行都是在真实跑通后总结出来的关键是掌握整体的数据流设计思维。照着这条链路走下来你也能做出一套从数据采集到展示预测的完整全栈系统。
返回列表