ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python爬虫+可视化:构建漫画数据采集与分析系统

Python爬虫+可视化:构建漫画数据采集与分析系统

前阵子朋友让我帮忙研究一部漫画的更新规律,我想手动一页页翻网站记录信息,不仅慢还容易漏。后来我直接用Python写了一个漫画爬取与可视化分析系统,把采集、存储、分析、展示串成一条完整链路。说人话就是:写爬虫把目标网站上公开的漫画基础信息抓进数据库,再用图表把更新趋势、热度排行、分类分布变成一眼能看懂的曲线和柱状图。这个项目很适合已经掌握Python基础语法、想找一个小而全的项目练手的朋友,十几个小时就能跑通,能覆盖网络请求、页面解析、数据规范化、接口设计、前端图表渲染这些常见能力。

我最早想的是架一个“自动追更提醒”,后来发现光提醒还不够,我对“这部作品到底火在哪”“作者更新频率有没有规律”“哪个分类容易出现好评作品”这些信息更感兴趣。于是演进成了现在的系统:爬虫负责采集,SQLite负责存数据,Flask提供数据接口,前端用ECharts渲染图表。整个技术栈都是最常见的组合,没有任何花哨的框架,但跑通之后你会对“数据从哪来、怎么存、怎么用”有非常直观的理解。

1. 项目整体设计与技术选型

1.1 写代码前先想清楚要什么

很多人上手爬虫就急着写 requests,结果抓到一堆 HTML 不知道怎么处理。我这次先把需求拆成了四个问题:

  • 数据源在哪:目标漫画平台有哪些公开列表页、详情页,能拿到哪些字段。
  • 存什么数据:漫画名称、作者、分类、状态、章节数、热度数值、最后更新时间。
  • 分析什么:更新趋势、分类占比、热度排行、作者产出频率。
  • 怎么展示:网页图表、离线报告,还是简单打印表格。

把这些问题写下来之后,整个系统的数据流就清晰了:网页 → 解析 → 清洗 → 入库 → API → 图表。后面每一步其实都是在顺着这条数据流填代码。

1.2 技术选型:为什么是这几件套

当时我列了一张对比表,考虑过几个组合:

选型候选方案最终选择理由
采集框架requests+BeautifulSoup 或 Scrapyrequests+BeautifulSoup数据量不大,单机任务,Scrapy 的并发和中间件在这个规模下属于过度设计
存储SQLite 或 MySQLSQLite个人项目零配置,单文件备份方便,几千条漫画数据完全够用
后端接口Flask 或 DjangoFlask只需要提供几个 JSON 接口,Flask 轻量足够
图表渲染ECharts 或 MatplotlibECharts前端图表交互性好,鼠标悬停能看数值,适合给非技术朋友展示

我用过一个最笨的版本的方案是把数据抓下来后,用 Matplotlib 画几张静态图存成 PNG,发给朋友看。看起来很“程序员”,但问题是一有交互需求就要重复跑脚本。改成 Flask + ECharts 之后,直接开网页就能看,数据刷新也很方便。

选 SQLite 还有一个重要原因:这个项目本质上是一个“数据量很小但链路很完整”的学习型系统,没必要为它起 MySQL 服务。如果以后真想上线,迁移也很简单,查询语句基本不用变,把数据库连接层换掉就行。

1.3 模块划分与现实收益

整个系统分成了四个模块:

  • 采集器:给目标网站发请求,拿到 HTML。
  • 解析器:从 HTML 里提取需要的信息,变成干净的数据对象。
  • 存储层:把数据写入 SQLite。
  • 展示层:Flask 读取数据库并提供 API,前端图表消费这些 API。

模块分离最大的好处是“改一个点不用动全部代码”。比如后来目标网站改版了,页面结构变了,我只需要改解析器,其他三个模块动都不用动。又比如我想换个数据源,只要新数据源也能提供相同字段,采集器替换之后,存储和展示完全不受影响。这种设计习惯放到大型项目里也是一样的逻辑。

2. 数据采集层核心细节拆解

2.1 请求伪装与频率控制

采集层是直接被网络环境打磨的地方,百分之八十的坑都出在“请求被拒”和“拿到空数据”。

请求被拒最常见的原因是缺 User-Agent 或者请求太频繁。目标网站虽然不一定有严格的反爬,但一个空 UA 的 Python 脚本请求,很容易被安全策略拦下。我在采集器里维护了一个 UA 池,每次请求随机挑选一个,模拟不同浏览器的访问。

另外一个很关键的细节是 Referer。有些目标站会校验请求来源,如果 Referer 为空或来源不对,会重定向到验证页。我第一次跑采集的时候,有一批详情页返回了 302,排查半天发现就是 Referer 的问题。补上之后一切正常。

请求频率这块我用的是随机延时,每次请求之间等待 1 到 3 秒,取随机值而不是固定值。固定值容易被识别成机器行为,随机值更接近人的操作节奏。重试逻辑也做了指数退避:第一次失败等 2 秒重试,第二次等 4 秒,最多重试 3 次。这个策略也是接口调用中的常见模式,爬网页同样适用。

2.2 页面解析与字段提取

拿到 HTML 之后,用 BeautifulSoup 配合 CSS 选择器提取字段。相比正则表达式,CSS 选择器读起来更接近前端开发习惯,维护友好。

我遇到一个比较实际的坑是“字段明明在页面上,但解析出来是空的”。后来发现因为页面上有多个元素满足同一个选择器,比如作者信息出现了两次,一次在页头,一次在页脚,我抓的是那个空内容的元素。解决办法是先把候选元素都打印出来,用 enumerate 看索引,再精确定位有用信息。调试页面上有没有数据、数据长什么样,临时用print(soup.prettify()[:3000])看前面一部分就行,别一上来就写完整解析逻辑。

数据清洗同样不能省。我清洗的项目包括:空格和换行符去除、热度字段里的“万”单位转换(把“12.3万”转成 123000)、章节数字段去重、评分字段缺失时的默认值。清洗结果存到一个标准的数据字典里,再交给存储层,这样可以保证数据库里不会出现格式混乱的值。

2.3 增量抓取与去重策略

这个项目不是跑一次就完事,而是要长期追踪数据变化,所以增量更新是重点。我设计了一个比较简单的机制:每个漫画都有一个唯一标识,抓取时如果这个标识已经在数据库里,就只更新“最后更新时间”和“最新章节数”,不重复新增记录。

SQL 层面我给漫画表加了一个唯一索引,再配合 INSERT OR REPLACE 或者先查后插的逻辑,这样就能避免重复数据。因为 SQLite 支持 UPSERT 语法,我可以把写到数据库的操作写成一条语句:

INSERT INTO comic (title, author, category, status, chapters, views, rating, last_update) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(title) DO UPDATE SET chapters = excluded.chapters, views = excluded.views, rating = excluded.rating, last_update = excluded.last_update;

增量抓取的判断依据是“最后更新时间”。如果数据库里的最后更新时间比网站上的时间新,说明已经抓过,可以跳过。这样可以避免每次重复抓取整个站点,减少对目标服务器的压力。

2.4 图片、附件与防盗链策略

漫画网站的图片通常有防盗链机制。如果直接下载图片,会收到 403 或者一张占位图。解决方式是在请求图片时,带上目标网站的 Referer。

headers = { "Referer": "https://demo-manga.example.com", "User-Agent": random.choice(USER_AGENTS), } resp = requests.get(image_url, headers=headers, timeout=10)

这个项目里我并没有把图片全部下载下来,因为磁盘和带宽都会很快撑不住。我的策略是:分析阶段只记录封面 URL 和图片数量,不实际下载。如果你确实要做本地存档,也建议只挑热门作品下载,并且控制下载频率,不要一次性把所有图片拉下来。

提示:抓取图片时请务必考虑目标站点的压力和版权问题,本地存档仅限个人学习使用,不要二次分发。

3. 数据存储与可视化分析实现

3.1 数据表设计:决定后面分析的天花板

数据库表结构决定了你能做哪些分析。我一开始只设计了五个字段,到后面发现没法回答“作者最近更新频率”这种问题,又回头补字段,很麻烦。现在这张表的字段如下:

CREATE TABLE comic ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL UNIQUE, author TEXT, category TEXT, status TEXT, chapters INTEGER, views INTEGER, rating REAL, last_update TEXT, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_comic_category ON comic(category); CREATE INDEX idx_comic_last_update ON comic(last_update);

字段含义:category 是漫画分类,比如“热血”“日常”“科幻”;status 是连载状态;views 是热度值;rating 是评分;last_update 是最后更新时间。crawl_time 记录这条数据是哪次爬虫任务采集的,方便追溯。

索引是我后来加的。分类索引让“按分类统计”的查询快很多,时间索引让“按更新日期排序”的查询快很多。数据量只有几千行的时可能感觉不明显,但这是好习惯,防止数据量大了之后查询越来越慢。

3.2 Flask API:让数据能说话

后端不需要做复杂的页面渲染,只要提供几个 JSON 接口。我设计了三个核心接口:

from flask import Flask, jsonify import sqlite3 app = Flask(__name__) def query_db(sql, params=()): conn = sqlite3.connect("manga_data.db") conn.row_factory = sqlite3.Row cur = conn.execute(sql, params) rows = cur.fetchall() conn.close() return [dict(row) for row in rows] @app.route("/api/overview") def overview(): return jsonify(query_db(""" SELECT category, COUNT(*) AS count, AVG(rating) AS avg_rating FROM comic GROUP BY category """)) @app.route("/api/trend") def trend(): return jsonify(query_db(""" SELECT last_update, COUNT(*) AS count FROM comic WHERE last_update IS NOT NULL GROUP BY last_update ORDER BY last_update """)) @app.route("/api/top") def top(): return jsonify(query_db(""" SELECT title, author, views, rating FROM comic ORDER BY views DESC LIMIT 20 """))

设计接口的时候,我特别注意“统计逻辑放在 SQL 里还是 Python 里”这个问题。其实像分类统计、平均值这些聚合操作,SQL 写起来非常简洁,把复杂逻辑交给数据库,后端代码就只需要做查询和返回。别把几千条数据拉到 Python 里再循环统计,性能和代码可读性都不好。

3.3 前端图表:从数据到一眼看懂

前端页面用纯 HTML + JavaScript + ECharts 写成,不引入重量级框架。页面结构是左侧分类占比饼图,右侧热度排行柱状图,下面更新趋势折线图。

核心代码很简单:

fetch('/api/overview') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('pieChart')); chart.setOption({ tooltip: { trigger: 'item' }, legend: { bottom: 0 }, series: [{ type: 'pie', radius: ['35%', '70%'], itemStyle: { borderRadius: 6 }, data: data.map(item => ({ name: item.category, value: item.count })) }] }); });

前端调 API 的时候有一个小坑:后端返回的字段名和前端取用的字段名必须完全一致。比如我后端 SQL 里写了AVG(rating) AS avg_rating,前端就要用item.avg_rating。如果返回的是avgRating这种驼峰命名,前端也要对应改成驼峰。调试的时候先打开浏览器开发者工具的 Network 面板,看一眼接口返回的 JSON 长什么样,再写前端逻辑,能省很多事。

ECharts 的折线图我还加了 dataZoom 组件,方便缩放查看某个时间段内的更新量变化:

dataZoom: [ { type: 'inside', start: 0, end: 100 }, { type: 'slider', start: 0, end: 100 } ]

这个组件在数据量变大之后非常实用,不用重新请求接口,直接在前端拖拽就能看到不同区间的曲线。

3.4 分析维度的选择

我在做可视化时发现,图表不是越多越好,要能回答实际问题。这个系统我保留了四个分析维度:

  • 分类分布:饼图展示不同分类下的作品数量,用于观察平台的内容结构偏向。
  • 热度排行:柱状图展示热门作品,按热度值排序,可以直接看出头部内容的集中程度。
  • 更新趋势:折线图展示每日更新数量变化,能反映平台更新节奏和作者的活跃程度。
  • 作者产出:表格展示每个作者的作品数和平均章节数,用于对比不同作者的产能。

我见过很多项目把图表做得花里胡哨,但看的人不知道重点在哪里。我的经验是:每一个图表都要对应一个实际的使用场景。比如更新趋势图,不是为了好看,而是为了回答“这部漫画什么时候更新最频繁、有没有固定规律”。你在设计图表之前先问自己这个问题,答案自然就有了。

4. 完整实操流程与核心代码

4.1 环境准备

这个项目需要用到的依赖非常少:

pip install requests beautifulsoup4 flask

然后创建一个测试网站,目标域名我用的是https://demo-manga.example.com。如果你不想直接抓真实网站,可以先用一个公开的测试接口或者自建一个简单的静态页面练手,把流程跑通再换到目标网站。

数据库只需要创建一个文件,用一个 Python 脚本执行建表语句:

import sqlite3 conn = sqlite3.connect("manga_data.db") conn.execute(""" CREATE TABLE IF NOT EXISTS comic ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL UNIQUE, author TEXT, category TEXT, status TEXT, chapters INTEGER, views INTEGER, rating REAL, last_update TEXT, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); """) conn.commit() conn.close()

4.2 核心爬虫代码

我把爬虫封装成了一个类,方便后续扩展。核心思路是:请求列表页 → 提取每部漫画的详情页 URL → 请求详情页 → 解析字段。

import random import time import requests from bs4 import BeautifulSoup 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/537.36 ...", ] class MangaSpider: def __init__(self): self.session = requests.Session() self.base_url = "https://demo-manga.example.com" def make_headers(self): return { "User-Agent": random.choice(USER_AGENTS), "Referer": self.base_url, "Accept-Language": "zh-CN,zh;q=0.9", } def fetch(self, path): url = self.base_url + path for attempt in range(3): try: resp = self.session.get(url, headers=self.make_headers(), timeout=10) resp.raise_for_status() return resp except requests.RequestException as err: print(f"[尝试 {attempt + 1}] 请求失败: {err}") time.sleep(2 ** attempt) return None def parse_list(self, html): soup = BeautifulSoup(html, "html.parser") items = [] for node in soup.select(".comic-item"): title_node = node.select_one(".title a") detail_url = title_node.get("href", "") title = title_node.text.strip() items.append({"title": title, "detail_path": detail_url}) return items def parse_detail(self, html): soup = BeautifulSoup(html, "html.parser") author_text = soup.select_one(".author") category_text = soup.select_one(".category") status_text = soup.select_one(".status") chapter_text = soup.select_one(".chapters") views_text = soup.select_one(".views") rating_text = soup.select_one(".rating") update_text = soup.select_one(".last-update") return { "author": author_text.text.strip() if author_text else None, "category": category_text.text.strip() if category_text else None, "status": status_text.text.strip() if status_text else None, "chapters": int(chapter_text.text.replace("话", "").strip()) if chapter_text else 0, "views": self.parse_views(views_text), "rating": float(rating_text.text.strip()) if rating_text else 0.0, "last_update": update_text.text.strip() if update_text else None, } def parse_views(self, node): if not node: return 0 text = node.text.strip() if "万" in text: return int(float(text.replace("万", "")) * 10000) return int(text.replace(",", ""))

很多初学者会纠结“用什么选择器”,我的建议是:先在浏览器开发者工具里右键复制 selector,自己再看一遍改短一点。选择器太长了容易失效,ECharts 也一样,选择器要尽量稳定,不要依赖层级很深的路径。

4.3 入库与 API

爬虫解析完数据后,交给一个独立的save_to_db函数处理。这里用到了 SQLite 的 UPSERT:

def save_to_db(items): conn = sqlite3.connect("manga_data.db") conn.execute("PRAGMA journal_mode=WAL;") sql = """ INSERT INTO comic (title, author, category, status, chapters, views, rating, last_update) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(title) DO UPDATE SET chapters = excluded.chapters, views = excluded.views, rating = excluded.rating, last_update = excluded.last_update; """ conn.executemany(sql, items) conn.commit() conn.close()

执行批量写入时,务必用executemany而不是在 Python 循环里逐条execute。几十条数据时区别不明显,数据量上千后差距非常明显。PRAGMA journal_mode=WAL是让数据库并发读写更顺畅,后续爬虫边写、前端边读不会锁死。

Flask API 的完整逻辑前面已经展示过,运行之后访问http://127.0.0.1:5000/api/overview就能看到 JSON 数据。这一步输出 JSON 时,建议在 Flask app 里加一句app.json.ensure_ascii = False,否则中文会被转成\uXXXX,调试不太方便。

4.4 ECharts 面板

前端文件index.html核心结构如下:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> <style> .chart { width: 100%; height: 360px; margin-bottom: 20px; } </style> </head> <body> <div id="trendChart" class="chart"></div> <div id="categoryChart" class="chart"></div> <div id="topChart" class="chart"></div> <script src="app.js"></script> </body> </html>

app.js 里分别 fetch 三个接口,渲染三张图。写入数据时需要注意,ECharts 的数据格式一般是{ name: '热血', value: 12 },所以前端要将后端返回的数据做成这个结构,不能直接把整个数组塞进去。你可以在浏览器的 console 里打印一下接口返回,对照着写映射逻辑。

4.5 定时更新与运行维护

手动触发采集器总不是长久之计,我加了定时任务支持。最原始的方式是直接用系统的计划任务,在命令行里运行:

python crawl.py

你也可以在代码里用schedule库做进程内定时,但更省事的方案是系统计划任务。目标数据每天跑一次,放在凌晨三点左右,避免影响目标平台正常访问。运行日志我存到了crawler.log:

python crawl.py >> crawler.log 2>&1

日志里要记录时间、URL、状态码、耗时,出问题时扫一眼日志就能定位到哪个环节出了问题,这个习惯后来救了我很多次。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

我整理了实际项目中遇到过的常见问题,做成速查表:

问题现象可能原因解决思路
请求返回 403UA 或 Referer 缺失,或请求频率过高换 UA,补 Referer,增加随机延时
HTML 拿到了但没有数据页面是异步 JS 加载在开发者工具里找 XHR 接口,直接请求 JSON
中文乱码requests 自动猜测编码失败用 resp.apparent_encoding,或强制指定页面的 charset
提示需要登录目标内容在登录态下才可见分析登录接口,或者手动登录后导出 Cookie 给脚本用
图片下载返回 403防盗链机制图片请求头里带上原网页 Referer
数据库里有重复记录缺少唯一约束或去重逻辑加唯一索引,使用 UPSERT
数据库写入越来越慢没有索引,或写库锁等待加索引,开启 WAL 模式
图表渲染空白接口字段和前端映射不一致先在 Network 面板确认返回的 JSON 结构

5.2 三个印象最深的坑

第一个坑是“页面长什么样子”。我一开始用列表页的标题作为唯一标识,发现有些标题在列表页被截断,和详情页的完整标题不一致,导致入库时重复或错位。后来统一以详情页的标题为准,列表页只用来拿详情页 URL。这类问题很难通过打印调试发现,因为它不是报错,而是数据本身不准。做爬虫项目一定要建立“数据质量”意识,不能只看有没有报错。

第二个坑是“看着是静态页面,实际是异步加载”。我抓的目标站有一个排序功能,点击后 URL 没变,列表却刷新了。我盯着 HTML 代码找了半天也没找到新列表的数据,后来打开开发者工具,刷新页面,在 Network 面板里看到一个返回 JSON 的接口,才发现页面是用 JS 异步渲染的。直接请求那个接口,数据干净又完整,比解析 HTML 还方便。从此我遇到页面就先去 Network 面板看有没有直接的 JSON 数据接口,能走 API 就不解析 HTML。

第三个坑是“SQLite 锁等待”。爬虫边入库边有前端页面在查询时,偶尔出现database is locked。解决方式很简单:连接数据库时设置timeout=10,并开启 WAL 模式。我一开始没做这个设置,老是被锁坑到睡不着,改完以后就没再出现过。

6. 合规使用与学习边界

这部分必须单独说,而且要认真说。抓取漫画数据这件事本身有天然的版权敏感性,我在做这个项目的过程中始终保持几个原则:只抓取公开可见的信息,不碰任何需要付费解锁的内容或用技术越过访问限制;严格遵循目标网站的访问频率要求,不给对方服务器增加压力;抓到的数据仅用于个人学习和分析,不用于任何商业用途,也不做二次分发。

对初学者我建议,练手阶段先用公开的测试接口或者自建一个静态页面,完全掌握流程之后,再考虑真实目标网站。真实场景下,先把目标站点的条款和服务协议看一遍。做一个尊重数据来源的爬虫,比技术本身更重要。

系统设计上也可以直接留出“白名单”机制:只有确认过协议和版权情况的数据源才允许加入配置,否则爬虫直接拒绝启动。这个机制我在后面的版本里加上了,避免自己哪天头脑一热配置了一个不该访问的站点。

写在最后

我个人实际操作中最深的体会是:爬虫代码本身不难,难的是让整条链路稳定地跑起来。网站改版、字段缺失、请求超时、数据重复,每个坑都需要临场解决。而且我发现调整一个细节,比如随机延时范围,对采集成功率的影响非常明显。踩过几次坑后我才明白,做任何采集项目都要先想清楚“这是什么类型的网站、它有哪些限制、我该怎么尊重它的规则”。

还有一个小技巧是,采集器每个环节都要留足够清晰的日志,格式包含时间、URL、状态码、耗时,出问题后扫一眼就能定位到哪个环节挂了。这套系统后来我扩展了一下,加入定时任务每天自动抓取最新数据,早上打开电脑就能看到昨晚的更新曲线,追更再也没有漏过。如果你想练手,建议从分类统计和更新趋势这两个维度开始,做成最小闭环,再逐渐叠加功能,你会看到这个系统一点点长出你想要的样子。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进