最近在整理网文数据的时候,发现很多人卡在同一个环节:榜单数据拿到了,但不知道怎么系统地管理、更新和展示。单纯写个脚本跑一遍只能拿到一次性结果,等下次榜单刷新又要手动跑,数据一多还会遇到重复、字段错乱、格式不一致的问题。我自己的做法是用 Django 作为整个数据提取项目的底座,把请求、解析、入库、展示串成一条线,顺便把 Top500 小说数据做成一个可持续维护的小系统。
这篇文章就完整记录这个项目的设计与实现过程,覆盖从起点中文网榜单页面分析到 Django 模型设计、爬虫代码编写、定时调度、后台管理和 CSV 导出的全部环节。适合正在学 Python 数据提取的读者,也适合想在 Django 里集成爬虫逻辑、但不想引入 Scrapy 等重型框架的开发者参考。
在开始之前先说清楚:项目目标是提取起点中文网 Top500 小说榜单的基本信息,包括排名、书名、作者、分类、字数、状态和简介,并把数据持久化到本地数据库,方便后续查询和分析。整个项目基于 Python 3 和 Django 实现,不依赖复杂组件。
1. 提取目标拆解:起点Top500榜单到底有哪些数据
1.1 榜单页面的结构与数据来源确认
动笔写代码之前,先花时间把页面结构看清楚。起点中文网的排名体系比较多,有热度榜、月票榜、推荐榜等,Top500 一般指榜单前 500 名。我这次处理的是热门小说排名页面,每页展示 50 条左右,500 条数据大概要翻 10 页。
这里有个关键判断:页面上的数据是服务端直接渲染在 HTML 里,还是前端通过异步接口动态加载。打开浏览器开发者工具的 Elements 面板,如果在页面上看到的小说排名、书名、作者都直接出现在 HTML 源代码中,那就是第一种,用 requests 抓 HTML 后直接解析即可。如果页面显示数据,但 HTML 源码里找不到,就需要去 Network 面板看 XHR 请求,通常能找到返回 JSON 数据的接口。
起点这类页面的榜单数据,通常是由页面接口动态返回的,但也有服务端渲染的部分。我的建议是不要凭空假设,抓一次页面把响应内容打印前两千个字符,看看里面有没有书名和作者信息,再定解析方案。这一步做对了,后面能省很多反复调试的时间。
1.2 数据结构反推:页面字段与存储字段的映射
页面上的每条榜单信息,通常包含排名、书名、作者、分类、简介、字数、连载状态、详情页链接这几个核心字段。这些字段基本都能直接映射到表结构里。
在设计阶段就应该考虑清楚:哪些字段是必须的,哪些字段是可有可无的。我的经验是,排名、书名、作者、分类、详情页链接是必选,字数、状态、简介属于加分项。简介虽然有长有短,但对后续做文本分析很有价值,建议存下。详情页链接还可以作为唯一标识的一部分,因为同一本书通常只有一个详情页地址。
1.3 频率、访问规范与请求头准备
做公开数据抓取,最忌讳的就是高频率短时间冲击对方服务器。Top500 榜单一天抓一次完全足够,榜单更新频率远低于你的想象。抓取时每页之间随机 sleep 1~3 秒,不要用固定间隔,更不要并发开十几个线程去抢。这个项目的数据量只有 500 条,串行完全够用。
请求头至少要带上 User-Agent 和 Accept-Language,默认的 Python-requests UA 很容易被拦截。User-Agent 用浏览器的标准字符串,Referer 设置成榜单页本身的地址,这样整个请求看起来更像正常访问。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:为什么用Django承载数据提取逻辑
2.1 Django、Scrapy与纯脚本的边界
很多人一听说爬虫就想到 Scrapy,但 Scrapy 适合的是大规模、分布式、需要中间件和管道机制的爬取场景。这个项目的数据量有限,反而需要的是数据管理、展示 API、后台维护这些能力,这些恰好是 Django 的强项。
如果用纯脚本,requests 跑完得到一堆字典,存 CSV 或 JSON 文件,也能完成任务,但后期做增量更新、字段校验、去重统计会非常别扭。Django 提供的 ORM、迁移机制、Admin 后台可以让数据的整个生命周期都处于可控状态。一句话总结:Scrapy 解决的是“怎么爬到更多数据”,Django 解决的是“爬到的数据怎么管理好”。
2.2 项目结构与App拆分
项目结构上,我建议把爬虫逻辑独立成一个模块,不要写在 view 或 model 里。
code复制novel_top500/
├── manage.py
├── config/
│ ├── settings.py
│ └── urls.py
├── novels/
│ ├── models.py
│ ├── views.py
│ ├── admin.py
│ ├── management/
│ │ └── commands/
│ │ └── fetch_top500.py
│ └── spiders/
│ ├── qidian.py
│ └── parser.py
spiders 目录放抓取和解析逻辑,management/commands 放 Django 命令入口,这样既能用 python manage.py fetch_top500 手动触发,也能接入 crontab 定时执行。不要把 requests 代码直接写到 view 里,否则页面刷新一次就触发一次抓取,非常容易被对方服务器拉黑。
2.3 依赖版本与虚拟环境
Django 版本我用的 4.x,Python 3.10 以上。requests、BeautifulSoup4、lxml 是解析侧的核心依赖。建议在项目根目录准备 requirements.txt,方便部署时一次性安装。
虚拟环境一定要用,隔离不同项目的依赖版本。Django 的版本差异对模型定义和 admin 写法多少有点影响,固定版本能少踩很多坑。
3. 数据模型设计:Top500榜单的字段取舍与约束
3.1 模型字段设计
模型设计是整个项目最关键的一环。字段太少,后续分析不够用;字段太宽,抓取和校验成本上升。我最终确定的模型如下:
python复制from django.db import models
class Novel(models.Model):
STATUS_CHOICES = (
("ongoing", "连载中"),
("finished", "已完结"),
)
rank = models.PositiveIntegerField("排名")
title = models.CharField("书名", max_length=255)
author = models.CharField("作者", max_length=100)
category = models.CharField("分类", max_length=50, blank=True)
intro = models.TextField("简介", blank=True)
word_count = models.PositiveIntegerField("字数", default=0)
status = models.CharField("状态", max_length=20, choices=STATUS_CHOICES, default="ongoing")
detail_url = models.URLField("详情页链接", max_length=500)
crawl_date = models.DateField("抓取日期", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
ordering = ["crawl_date", "rank"]
constraints = [
models.UniqueConstraint(
fields=["crawl_date", "rank"],
name="unique_crawl_rank"
),
models.UniqueConstraint(
fields=["crawl_date", "title", "author"],
name="unique_crawl_book"
),
]
def __str__(self):
return f"{self.crawl_date}-{self.rank}-{self.title}"
3.2 为什么保留crawl_date而不是只存最新榜单
这里有一个设计上的取舍。如果只需要当前 Top500,用书名加作者作为唯一键,每次抓取时更新排名即可,表里永远只有 500 条。但如果想在后期分析榜单变化趋势,比如哪本书在上升、哪本书掉出前 500,就必须记录每次抓取的时间点。
我选择保留 crawl_date,每次抓取生成一条独立记录。这样表会越来越大,如果你每天抓一次,一年就是 18 万条。18 万条对 MySQL 来说毫无压力,对 SQLite 也扛得住,所以这个设计是划算的。代价是查询时总是带上 crawl_date 条件。
3.3 数据约束与幂等性保障
唯一约束是保证数据不重复的最后防线。按日期加排名做唯一键,能防止同一天同一排名出现两条数据;按日期加书名加作者做唯一键,能防止同一本书在同一天被插入两次。实际抓取时,即使页面短暂返回到相同内容,入库时也会因为约束而失败或更新,不会出现脏数据。
迁移命令执行一次,约束就会生效。之后的写入逻辑直接用 update_or_create 或者配合 get_or_create 处理。
4. 爬虫核心实现:从请求、解析到入库
4.1 请求层:用Session复用连接并处理异常
请求层的代码要简单可靠。requests.Session 会复用 TCP 连接,连续翻页时性能比每次新建连接好,也能统一携带请求头。
python复制import time
import requests
from requests.adapters import HTTPAdapter
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.5",
}
def build_session():
session = requests.Session()
session.headers.update(HEADERS)
adapter = HTTPAdapter(pool_connections=5, pool_maxsize=5, max_retries=2)
session.mount("https://", adapter)
return session
每次请求必须设置超时,不然某个页面挂起,整个命令会卡在那里。超时后重试,重试之间 sleep 一段时间,避免连续失败导致的死循环。
4.2 解析层:BeautifulSoup与lxml的配合
解析用 BeautifulSoup + lxml 解析器足够。lxml 比 html.parser 快,页面结构小的情况下差距不明显,但用 lxml 更稳妥。
python复制from bs4 import BeautifulSoup
def parse_page(html):
soup = BeautifulSoup(html, "lxml")
novels = []
for item in soup.select("li.book-item"):
rank_el = item.select_one(".rank")
title_el = item.select_one(".book-title a")
author_el = item.select_one(".author")
category_el = item.select_one(".category")
intro_el = item.select_one(".intro")
word_el = item.select_one(".word-count")
status_el = item.select_one(".status")
novels.append({
"rank": int(rank_el.get_text(strip=True)) if rank_el else 0,
"title": title_el.get_text(strip=True) if title_el else "",
"author": author_el.get_text(strip=True) if author_el else "",
"category": category_el.get_text(strip=True) if category_el else "",
"intro": intro_el.get_text(strip=True) if intro_el else "",
"word_count": parse_word_count(word_el.get_text(strip=True)) if word_el else 0,
"status": "finished" if status_el and "完结" in status_el.get_text() else "ongoing",
"detail_url": title_el["href"] if title_el and title_el.has_attr("href") else "",
})
return novels
选择器必须根据实际页面结构调整,上面这段是示例。关键技巧是:所有字段都做空值兜底,get_text(strip=True) 会去掉首尾空白,避免入库时出现一堆空格。
4.3 字数转换:一种常见的脏数据形态
榜单页面上字数常常显示为“12.3万”这样的格式,直接存字符串会让后续排序和统计很难受。我在解析层做了专门的转换函数:
python复制def parse_word_count(text):
if not text:
return 0
text = text.replace(",", "").strip()
if "万" in text:
try:
return int(float(text.replace("万", "")) * 10000)
except ValueError:
return 0
try:
return int(text)
except ValueError:
return 0
这个函数把“12.3万”转成 123000,把纯数字字符串转成 int。转换失败时返回 0,不让脏数据撑破程序。
4.4 入库逻辑:update_or_create的幂等处理
数据入库用 Django ORM 的 update_or_create,这是整个设计里幂等性的核心。无论命令重复执行几次,最终表里不会出现重复记录。
python复制from django.utils import timezone
from novels.models import Novel
def save_novels(novels, crawl_date=None):
crawl_date = crawl_date or timezone.localdate()
created_count = 0
updated_count = 0
for item in novels:
defaults = {
"author": item["author"],
"category": item["category"],
"intro": item["intro"],
"word_count": item["word_count"],
"status": item["status"],
"detail_url": item["detail_url"],
}
obj, created = Novel.objects.update_or_create(
crawl_date=crawl_date,
rank=item["rank"],
defaults=defaults,
)
if created:
created_count += 1
else:
updated_count += 1
return created_count, updated_count
这里默认用“日期 + 排名”作为匹配条件。如果页面排名和实际数据对不上,可能会把一本书的信息覆盖到另一本书的排名上。更稳妥的方案是用“日期 + 书名 + 作者”作为匹配条件,排名仅作为展示字段。这个取舍要根据你自己对数据源可信度的判断。
5. 调度与自动化:手动命令、crontab与Celery的取舍
5.1 用Django管理命令封装爬虫入口
把爬虫入口写成 Django 管理命令后,执行方式和原生 Django 命令一致,部署和手动触发都很方便。
python复制from django.core.management.base import BaseCommand, CommandError
from novels.spiders.qidian import fetch_top500
from novels.models import Novel
class Command(BaseCommand):
help = "抓取起点中文网 Top500 小说榜单"
def add_arguments(self, parser):
parser.add_argument("--page", type=int, default=10, help="要抓取的页数")
parser.add_argument("--sleep", type=float, default=1.5, help="每页间隔秒数")
def handle(self, *args, **options):
self.stdout.write("start fetching top500...")
try:
result = fetch_top500(pages=options["page"], sleep_interval=options["sleep"])
except Exception as exc:
raise CommandError(f"fetch failed: {exc}")
self.stdout.write(self.style.SUCCESS(f"done, created={result['created']}, updated={result['updated']}, errors={result['errors']}"))
管理命令的正确用法是返回结构化结果,不要在里面直接 print 一大堆调试信息。最终输出到日志文件里,方便排查。
5.2 Linux下用crontab定时执行
这个项目一天抓一次,完全没必要上 Celery。Celery 的价值在于任务队列、依赖调度和异步处理,Top500 这种低频、单一、串行的任务,crontab 是最合适的。
在 Linux 上配置定时任务,先进入虚拟环境,再执行命令:
code复制0 3 * * * cd /opt/novel_top500 && /opt/.venv/bin/python manage.py fetch_top500 --page 10 >> /var/log/top500.log 2>&1
这里有个容易踩坑的地方:crontab 的环境变量和交互 shell 不一样,python 命令不一定指向虚拟环境的解释器。一定要写绝对路径,尤其是在系统里同时装 Python 2、Python 3 或 pyenv 的环境。
5.3 MySQL命令行导出数据的小工具位
做过爬虫数据管理后,很多人会问怎么把库里的数据导出给运营或其他人。除了 Django 后台的 CSV 导出,也可以直接用 MySQL 命令行导出单表数据。
比如要导出 novels 表全部数据到文本文件,在 Linux 命令行下可以写成:
code复制mysql -u用户名 -p密码 -h主机 数据库名 -B -e "SELECT * FROM novels_novel;" > /tmp/novels.txt
-B 参数让结果以制表符分隔,方便后续处理。生产环境不建议把密码直接写在命令行里,写入配置文件或者用环境变量更安全。Django 在 settings 里配置数据库连接,同样注意不要把密码硬编码提交到版本仓库。
6. 展示与导出:后台管理、查询接口和CSV
6.1 用Admin后台管理原始数据
Django Admin 对这种数据管理场景帮助很大。全量抓取 500 条数据后,直接在后台查看、筛选、搜索、编辑,比查数据库直观得多。
注册模型时把列表字段、搜索字段、筛选字段都配置好,避免打开后台看到一个全是对象的空列表。
python复制from django.contrib import admin
from .models import Novel
@admin.register(Novel)
class NovelAdmin(admin.ModelAdmin):
list_display = ["crawl_date", "rank", "title", "author", "category", "word_count", "status", "updated_at"]
list_filter = ["crawl_date", "category", "status"]
search_fields = ["title", "author"]
list_per_page = 100
readonly_fields = ["crawl_date", "updated_at"]
6.2 提供JSON查询接口
如果数据要供前端或其他人使用,可以写一个轻量 JSON 接口。只读接口用 Django REST Framework 显得重,直接用 JsonResponse 就够。
python复制import json
from django.http import JsonResponse
from .models import Novel
def top500_api(request):
date = request.GET.get("date")
qs = Novel.objects.all()
if date:
qs = qs.filter(crawl_date=date)
items = list(qs.values("rank", "title", "author", "category", "word_count", "status"))
return JsonResponse({"code": 0, "data": items}, json_dumps_params={"ensure_ascii": False})
ensure_ascii=False 是中文输出必须的参数,否则接口返回的全是 \uXXXX 转义字符,前端拿到后显示成乱码。
6.3 CSV导出与中文乱码
CSV 导出同样要注意中文编码。用标准库 csv 写文件,打开文件时必须指定 encoding="utf-8-sig"。utf-8 和 utf-8-sig 的区别在于,后者会写入 BOM 头,Excel 打开时才能正确识别 UTF-8 编码的中文。如果直接用 utf-8 编码,Excel 默认用 GBK 解码,中文会乱码。
下面代码是一个简单的 CSV 导出视图:
python复制import csv
from django.http import HttpResponse
def export_csv(request):
response = HttpResponse(content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = "attachment; filename=top500.csv"
writer = csv.writer(response)
writer.writerow(["排名", "书名", "作者", "分类", "字数", "状态"])
qs = Novel.objects.order_by("crawl_date", "rank")
for novel in qs:
writer.writerow([novel.rank, novel.title, novel.author, novel.category, novel.word_count, novel.status])
return response
7. 排错记录:抓取过程中最常遇到的四个问题
7.1 403响应:从默认UA到完整浏览器请求头
第一次跑通代码时最容易遇到的就是 403。我的经验是,requests 默认的 python-requests/x.x.x 太容易识别,被服务器拦截后直接返回 403 或一段空 HTML。解决方法是把请求头替换成完整浏览器头,User-Agent、Accept、Accept-Language、Referer 都带上。
如果加了请求头仍然 403,检查频率是否过高。连续请求间隔低于 1 秒时,封禁概率会明显上升。
7.2 页面结构调整:选择器失效
爬虫最怕的是页面改版。昨天还能正确解析的 li.book-item,今天可能就变成了 div.book-item 或 li.book_unit。我排查这个问题时,通常先在浏览器里打开页面,找到目标元素,用开发者工具确认当前的选择器,然后打印 html[:5000] 和解析结果做对比。
更稳妥的做法是解析时通过多套选择器兜底,例如 soup.select_one(".book-title a, .book_name a, a[class*=book]"),这样单一选择器失效时还能靠其他选择器撑住。
7.3 编码问题:乱码的根因
网页编码判断错误也会导致解析不出来。requests 根据响应头推断编码,但某些页面响应头没有 charset,默认使用 ISO-8859-1 解析,中文自然乱码。遇到这种情况可以强制设置 resp.encoding = "utf-8" 后再解析,或者用 resp.apparent_encoding 让 requests 自动猜测。
不过 apparent_encoding 依赖 chardet 库,对短文本判断不一定准确。我的做法是优先看响应头,再看页面 meta 标签,最后才交给自动猜测。
7.4 数据库字段过窄:数据截断
Django 的 CharField(max_length=255) 对书名足够,但对某些书的完整标题加副标题可能接近上限。如果某个字段被截断,建议在入库前跑一遍长度检查,把超长内容截断或改用 TextField。这个坑在测试数据量小的时候完全看不出来,数据量大后才暴露。
出现 Data too long 错误时,不要只在 ORM 层排查,先查表结构再查代码。
最后说一点个人体会
这类“提取 + 管理”的项目,最大的坑不是爬虫本身,而是没有从一开始做好数据模型和幂等设计。Top500 榜单抓取一次很简单,但能持续稳定运行一年,靠的是约束建得对、命令可重跑、日志有痕。另一个体会是,工具选型要匹配规模:500 条数据不需要 Scrapy,也不需要 Redis 队列,一个 Django 命令加 crontab 就能干净利落地解决问题。
如果你是第一次做类似项目,建议先只抓一页数据,把模型、入库、导出整条链路跑通,再扩大到 10 页全量抓取。这样出错时排查范围小,也不会因为早期设计问题反复返工。后续如果数据量增长,可以在这个架构上增加分页自动识别、异常告警、历史榜单趋势分析,基础都不会动。
