☰
Python爬虫实战:用requests、lxml与SQLite构建课程数据采集分析闭环
2026/10/12 3:45:32 网站建设 项目流程

在线课程平台的课程数据,其实是一座很少被认真对待的金矿。我最初只是想搞清楚某个方向上到底哪些课值得学,结果发现平台只给你看前几页的精选推荐,搜索排序也有自己的商业逻辑,真正客观的全貌被藏得严严实实。后来我花了两个周末,用 requests + lxml + BeautifulSoup + SQLite + pandas 这套组合,把整个采集、存储、分析链路完整跑通了。这篇博文就把整个项目的设计思路、抓取策略、代码实现、评分分析和趋势追踪方法一次讲清楚,适合那些已经会写简单爬虫、但想做一个完整数据闭环项目的读者。

1. 项目目标拆解:从课程清单到趋势追踪的数据闭环

1.1 我为什么要做这个采集项目

市面上的课程数据采集教程,大多停在"爬到数据就结束"。但真实的需求往往不是抓几千条记录而已,而是围绕数据回答一连串问题:哪个方向的课程供给最充足?评分虚高的课程有什么共同特征?学习人数增长最快的课程是哪些?这些问题的答案,才真正驱动选课决策、内容运营,乃至课程产品的竞品分析。

我定义的采集目标很明确,分四层:

  • 第一层,多方向课程清单:覆盖IT技术、设计创意、职场技能、语言学习、个人提升五大类方向,每个方向抓取多页课程列表。
  • 第二层,课程详情补全:列表页只给课程名、学习人数、评分摘要,完整信息在详情页里。详情页需要单独请求并解析,字段包括讲师信息、课程时长、章节数量、课程简介、价格区间等。
  • 第三层,评分数据存档:评分信息不只看当前值,还要记录采集时间,为后续趋势分析提供历史数据。
  • 第四层,趋势追踪:用同一套采集脚本按天运行,把多天的数据放在一起,观察学习人数变化率、评分波动、上新课程数量等指标。

这里有一个关键设计理念:一次采集不只是为了当时的一份报表,而是为了之后任意一次历史回溯。所以我从一开始就把数据模型设计成"事实表 + 维度表"的雏形,而不是简单地堆一个Excel。

1.2 数据模型的整体设计思路

在动手写爬虫之前,我先把表格结构画出来。这个阶段花的时间,后面帮你省下的时间远超想象。

主表设计为课程事实表courses,每个字段对应一个事实或维度属性。我用的建表语句如下:

CREATE TABLE IF NOT EXISTS courses ( id INTEGER PRIMARY KEY AUTOINCREMENT, course_id TEXT NOT NULL UNIQUE, course_name TEXT NOT NULL, direction TEXT NOT NULL, sub_category TEXT, instructor TEXT, rating REAL, rating_count INTEGER, learner_count INTEGER, course_hours REAL, chapter_count INTEGER, price REAL, original_price REAL, is_free INTEGER DEFAULT 0, course_url TEXT UNIQUE, detail_fetched INTEGER DEFAULT 0, first_seen_date TEXT, last_update_date TEXT );

全程只存增量快照,不做更新覆盖——这是第二个关键设计。也就是说,每次运行脚本,同样的课程会重新入库一条带当日时间戳的记录,分析时按crawl_date分组。这样做的最大好处是:当你三个月后想算"这个课的学习人数从10万涨到20万用了多久",直接查历史记录就行,不需要任何逆向推断。

CREATE TABLE IF NOT EXISTS course_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, course_id TEXT NOT NULL, rating REAL, rating_count INTEGER, learner_count INTEGER, crawl_date TEXT NOT NULL );

这个course_snapshots表是趋势分析的核心。你可能觉得每次重复往库里插数据很冗余,但SQLite的磁盘占用几乎可以忽略不计,换来的是分析上的绝对自由。我在运行了12天后,库文件也才不到1MB,非常轻盈。

2. 目标平台页面结构分析与采集方案选型

2.1 列表页的翻页与字段提取

不同课程平台的页面结构差异很大,我在项目里选择了某在线学习平台作为主目标。它的列表页结构相对规整,每个课程卡片区块有固定的CSS类名,翻页通过URL中的页码参数控制。

先看列表页的处理逻辑。核心是用requests拉取HTML,然后用lxml做快速解析。这里我有个明显的偏向:列表页用 lxml,因为它的 XPath 提取速度比 BeautifulSoup 快不少,而且列表页结构规律性强,XPath 的路径表达式一旦调试完成,对批量页面的稳定性很高。

import requests from lxml import etree 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-Language": "zh-CN,zh;q=0.9", } def fetch_list_page(direction, page): url = f"https://example-course-platform.com/courses/{direction}?page={page}" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text

编码处理是第一个容易踩的坑。很多平台的页面使用UTF-8,但部分详情页可能通过HTTP头声明了其他编码。我建议在拿到响应后优先看resp.encoding,如果不确定就显式用resp.apparent_encoding兜底,不要指望requests的默认推断100%正确。

列表页的XPath提取,核心逻辑是抓卡片容器,然后遍历每个卡片取出字段。用一个解析函数统一处理:

def parse_list_page(html): tree = etree.HTML(html) cards = tree.xpath('//div[contains(@class, "course-card")]') items = [] for card in cards: try: course_id = card.xpath('.//@data-course-id')[0] name = card.xpath('.//h3[@class="course-name"]/text()')[0] rating = card.xpath('.//span[contains(@class, "rating-value")]/text()')[0] learner_count = card.xpath('.//span[contains(@class, "learners")]/text()')[0] except IndexError: continue # 卡片数据缺失时直接跳过 items.append({ "course_id": course_id, "name": name, "rating": float(rating), "learner_count": parse_learner_count(learner_count), }) return items

parse_learner_count这一步也很容易被忽略。平台上"1.2万"和"3456人"格式不统一,必须全部转成整数才能进数据库:

def parse_learner_count(text): text = text.replace("人", "").strip() if "万" in text: return int(float(text.replace("万", "")) * 10000) return int(text)

2.2 详情页的补全策略与请求方案

列表页能拿到的基本就是课程ID、名称、评分和学习人数。这些字段做趋势分析已经够用,但如果你想做更细致的评分影响因素分析,详情页的关键字段就必须补上:讲师名字、课程时长、章节数、价格、课程简介长度等。

详情页请求策略,我采用了按需补全而不是全量补全。逻辑很简单:courses表里有一个detail_fetched字段,每次爬取时先查一遍哪些课程还没有详情数据,只有这些课程才发起详情页请求。

def fetch_detail(course_id): detail_url = f"https://example-course-platform.com/course/{course_id}" resp = requests.get(detail_url, headers=HEADERS, timeout=10) soup = BeautifulSoup(resp.text, "html.parser") item = {} # 讲师:从信息标签内提取 instructor_tag = soup.select_one(".instructor-name") item["instructor"] = instructor_tag.get_text(strip=True) if instructor_tag else "" # 课程时长:通常以"12小时34分"格式展示 duration_tag = soup.select_one(".duration") item["course_hours"] = parse_duration(duration_tag.get_text(strip=True)) # 课程价格:优先取优惠价,缺失则取原价 price_tag = soup.select_one(".price-now") or soup.select_one(".price-origin") item["price"] = float(price_tag.get_text(strip=True).replace("¥", "")) if price_tag else 0 return item

为什么详情页用 BeautifulSoup 而不用 lxml?因为详情页的HTML嵌套结构比列表页复杂,class命名也更混乱,BeautifulSoup 的CSS选择器语法更宽容,对残缺HTML的容错性更好。我的经验是:lxml 适合结构规整、路径稳定的批量列表页;BeautifulSoup 适合结构多变、嵌套深的内容页。两个库都装上,各自发挥长处,这才是这套技术栈的正确用法。

3. 核心代码实现:从requests到SQLite的数据管道

3.1 会话管理、请求头与限速设计

爬虫写起来容易,稳定跑起来难。这次项目的核心经验之一,是requests 的会话复用。

我知道很多人喜欢直接一句requests.get()全局调用,简单是简单,但缺少cookies维持和连接池复用,一旦页面需要登录鉴权或是长会话,就会遇到各种诡异问题。建议用requests.Session()统一管理:

session = requests.Session() session.headers.update(HEADERS) def get_with_retry(url, max_retries=3, backoff=1.5): for attempt in range(max_retries): try: resp = session.get(url, timeout=10) if resp.status_code == 200: return resp elif resp.status_code >= 500: # 服务端错误,等一会儿再试 time.sleep(backoff * (attempt + 1)) except requests.RequestException as e: print(f"[重试] {url} 失败: {e}") time.sleep(backoff * (attempt + 1)) return None

限速是我要求的硬性条件。平台方不管你的爬虫技术多优雅,只管你的请求频率是否给服务器造成压力。我在项目中强制设置两个延时参数:列表页请求间隔至少0.8秒,详情页请求间隔至少1.5秒。睡这几百毫秒,换来的是稳定不封禁的采集体验。

3.2 持久化存储:SQLite写入与增量去重

数据入库用SQLite,理由很简单——零配置、单文件、Python内置支持,完全够用。唯一需要注意的,是去重策略。

course_id在courses表设置了唯一索引,插入时不能盲目INSERT,否则第二次运行脚本就报约束错误。我用INSERT OR IGNORE跳过已有课程,再单独更新快照表:

import sqlite3 def save_courses(items, crawl_date): conn = sqlite3.connect("course_data.db") cursor = conn.cursor() # 确保表结构存在 conn.executescript(""" CREATE TABLE IF NOT EXISTS courses ... CREATE TABLE IF NOT EXISTS course_snapshots ... """) for item in items: cursor.execute(""" INSERT OR IGNORE INTO courses (course_id, course_name, direction, rating, learner_count, course_url, first_seen_date, last_update_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (item["course_id"], item["name"], item["direction"], item["rating"], item["learner_count"], item["url"], crawl_date, crawl_date)) cursor.execute(""" INSERT INTO course_snapshots (course_id, rating, rating_count, learner_count, crawl_date) VALUES (?, ?, ?, ?, ?) """, (item["course_id"], item["rating"], item["rating_count"], item["learner_count"], crawl_date)) conn.commit() conn.close()

这里有个细节:INSERT OR IGNORE的语义是"如果已有记录就跳过",但快照表每次都会追加新记录。所以即便课程信息没有变化,学习人数和评分的每次变化都被记录下来了。这就是趋势分析的数据基础。

3.3 调度主流程:多方向、多页面、全自动

把所有环节串起来,主流程就是一个三重循环:方向 → 页面 → 课程。为了避免某个方向失败导致整个任务中断,每个方向的异常单独捕获、单独记录。

def crawl_all(): directions = ["it", "design", "career", "language", "self-improvement"] crawl_date = datetime.date.today().isoformat() all_items = [] for direction in directions: print(f"[开始] 方向: {direction}") for page in range(1, MAX_PAGES + 1): html = fetch_list_page(direction, page) if not html: break items = parse_list_page(html) if not items: break # 补充方向字段 for item in items: item["direction"] = direction all_items.extend(items) time.sleep(random.uniform(0.8, 1.5)) print(f"[完成] {direction}: 累计 {len(all_items)} 条") save_courses(all_items, crawl_date) print(f"[入库] 完成,共 {len(all_items)} 条")

加了random.uniform而不是固定sleep,是为了避免规律性太强被识别。这个也算反爬的基础操作。

4. 评分分析与学习趋势追踪的pandas实操

4.1 评分分布与方向对比

数据入库后,剩下的分析工作交给pandas。这里我分享一套完整的评分分析流程,直接跑就行。

先读取数据,然后分组统计不同课程方向的平均评分、评分数量中位数、课程数量:

import pandas as pd df = pd.read_sql_query("SELECT * FROM courses", sqlite3.connect("course_data.db")) # 按方向聚合 direction_groups = df.groupby("direction").agg( course_count=("course_id", "count"), avg_rating=("rating", "mean"), median_rating=("rating", "median"), avg_learners=("learner_count", "mean") ).sort_values("avg_rating", ascending=False) print(direction_groups)

聚合结果会很有意思。我当时跑出来的数据发现:IT方向课程数量最多,但平均评分反而是五大方向里偏低的;语言学习方向课程数量少,平均评分却最高。这说明供给多的地方竞争激烈,低分课程拉低了均值,而供给少的方向反而都是精挑细选的课程。

接下来看评分的分布情况:

# 评分直方图分布,判断是否存在虚高 df["rating_round"] = df["rating"].round(1) rating_dist = df.groupby("rating_round").size().reset_index(name="count") print(rating_dist.sort_values("rating_round"))

评分分布能帮你识别异常:如果某个平台90%的课程评分都集中在4.8到5.0之间,这个评分体系的可信度就需要打个问号。我做过的项目里,有的平台评分分布接近正态,有的则是右偏极端严重,分析结果直接反映平台对评价的运营干预程度。

4.2 学习人数趋势与增量评估

趋势追踪的核心,是分析快照表course_snapshots中同一课程在不同日期的数据变化。我把整个分析拆成三步。

第一步,按课程分组,计算最新一次采集相对于首次采集的学习人数增长:

snap = pd.read_sql_query("SELECT * FROM course_snapshots", sqlite3.connect("course_data.db")) # 每个课程按时间排序,取最早和最新的学习人数 snap_sorted = snap.sort_values(["course_id", "crawl_date"]) first = snap_sorted.groupby("course_id").head(1) latest = snap_sorted.groupby("course_id").tail(1) first = first.rename(columns={"learner_count": "first_learners", "crawl_date": "first_date"}) latest = latest.rename(columns={"learner_count": "latest_learners", "crawl_date": "latest_date"}) merged = first[["course_id", "first_learners", "first_date"]].merge( latest[["course_id", "latest_learners", "latest_date"]], on="course_id" ) merged["growth"] = merged["latest_learners"] - merged["first_learners"] merged["growth_rate"] = merged["growth"] / merged["first_learners"].replace(0, 1) top_growth = merged.sort_values("growth", ascending=False).head(10)

第二步,标记高增长课程。增长量高不等于增长快,要看增长率。比如一个10万人的课程增长1万,和一个1000人的课程增长800,后者才是现象级课程。所以我会同时保留两个榜单:绝对增长榜和相对增长榜。

第三步,把增长数据与课程详情关联起来,分析高增长课程哪些特征更常见:

# 关联详情信息 course_detail = df[["course_id", "course_name", "direction", "instructor", "price", "course_hours"]] result = top_growth.merge(course_detail, on="course_id", how="left") result.to_csv("top_growth_courses.csv", index=False, encoding="utf-8-sig")

encoding="utf-8-sig"是为了Excel打开不乱码,这个细节我栽过一次跟头,现在成了固定习惯。

为了让整个趋势追踪流程自动化,把以上分析写成脚本,每天采集完数据后自动生成一份Markdown格式的日报,包含新增课程数、Top10增长课程、评分异常波动课程三个板块。这样"追踪"才真正落地,而不是需要手动翻数据库。

5. 反爬、稳定性与道德边界:爬虫工程化的关键细节

5.1 限速、重试与断点续爬的工程实践

爬虫能否持续稳定运行,取决于你对"意外"的预判。我每次写爬虫,都会强制自己回答几个问题:超时了怎么办?返回空页面怎么办?字符编码不对怎么办?某个字段缺失怎么办?

这套项目里我做了三个工程层面的防护:

第一,网络层重试。请求失败时指数退避重试,最大重试3次,超过3次放弃该URL并记录日志。这是保底措施,避免网络抖动弄崩整个任务。

第二,断点续爬。如果采集了500条后进程崩溃,重新运行不应该从头再来。我的处理方案是把每个方向的已采页面数记录到crawl_status表里,崩溃重启后优先读取这个表:

CREATE TABLE IF NOT EXISTS crawl_status ( direction TEXT PRIMARY KEY, last_page INTEGER, last_crawl_date TEXT );

这样即使中断,也能从上次停止的位置继续,而不是全量重跑。

第三,结果日志。每完成一个方向的采集,输出一行包含时间、方向、采集数量的日志。别小看这个操作,排错的时候没有日志,你只能盲猜。

5.2 采集合规与频率控制原则

爬虫技术和道德边界,必须摆在桌面上讲清楚。这个项目里我遵守了几个严格原则:

  • 只采集公开信息,不登录、不绕过任何验证机制。
  • 控制请求频率,单线程顺序执行,严禁并发轰炸服务器。
  • 不采集个人隐私内容,课程评价、讲师简介这类公开信息可用,用户个人信息一律跳过。
  • 遵守平台的robots.txt规范约束,并对采集数据的用途严格限定在个人学习与研究范畴。

我在代码的入口处就埋了一个频率策略常量,注释里写清楚每个请求的间隔原因。这既是工程自律,也是职业习惯。爬虫能力越强,越要主动给自己设定边界,否则数据价值越大,风险越大。

6. 踩坑实录与最终运行效果

6.1 我踩过的五个典型坑

第一个坑是评分字段混入"暂无评价"。列表页的评分并不是统一的浮点数,有的课程显示"暂无评价",直接用float("暂无评价")直接崩溃。解决方案是在解析函数里先判断字符串,非数字字符多的情况返回None,入库时统一转为 NULL。

第二个坑是学习人数字段里的中文逗号。某方向列表页里的数字是"12,345人"这种千分位格式,而parse_learner_count只处理了"万"和普通数字,千分位字符串转 int 直接报错。修复方式是先replace(",", "")再做转换。

第三个坑是详情页的课程时长格式不统一。"1小时20分"、"80分钟"、"1.5小时"三种格式都存在,统一解析比较繁琐。我的方案是用正则提取所有数字,第一个数字当小时、第二个数字当分钟,解析函数兼容三种格式,最终统一换算成小时。

第四个坑是Session连接复用后的编码残留。同一个 Session 请求不同页面时,某些页面返回的响应头Content-Type里没有charset字段,resp.encoding会沿用上一个请求的值,导致中文全部乱码。这个问题的根本解法是每次请求都显式检查并设置resp.encoding = "utf-8",而不是依赖 Session 的自动继承。

第五个坑是增量快照导致数据表爆炸式增长。运行第三天我发现所以快照表里重复记录过多,分析时如果不小心没按crawl_date过滤,会把同一课程的所有历史记录都当成独立课程统计。这个问题在分析 SQL 里加WHERE crawl_date = (SELECT MAX(crawl_date) FROM course_snapshots)解决,后来干脆把快照分析封装成函数,按日期参数过滤,避免下次再犯。

6.2 最终跑通的效果与后续扩展思路

项目完整跑通后,我连续采集了12天。最终库里沉淀了约 860 门课程的完整数据,形成了了 12 个采样日期的趋势记录。用一个简单的趋势SQL就能看到任意课程的学习人数变化曲线:

SELECT crawl_date, learner_count, rating FROM course_snapshots WHERE course_id = '2764' ORDER BY crawl_date;

我拿这个数据做了几个有意思的观察。最典型的案例是一门新上架的AI实践课,上线第1天学习人数1700人,第5天到9500人,第12天暴涨到28000人,同期评分维持在4.9。而另一门老牌Web开发课,12天只增长了3%,几乎进入存量稳定期。这两条曲线并排看,就能理解平台流量的分配逻辑和课程生命周期。

后续我会在这套数据管道上扩展的方向有三个:一是接入评分评论内容做简单的文本情感分析,二是把采集频率从每天一次提升到每天多次来分析日内波动,三是用matplotlib生成趋势可视化图表。这套框架最大的价值在于,数据管道的设计是通用的,换一个平台、换一个方向,改改解析规则和字段映射就能复用。

项目本身暂时没有加代理池和多线程的计划。当前单线程加限速的方案,已经能在10分钟内完成全部方向的一轮采集,对个人研究足够了。如果你要采集的体量更大,我的建议也是在限速前提下逐步增加并发,而不是一开始就上高并发方案。稳定的采集节奏,远比一次性跑得快重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询