Python爬虫实战:用requests、lxml与SQLite构建课程数据采集分析闭环

发布时间:2026/10/12 3:45:37
Python爬虫实战:用requests、lxml与SQLite构建课程数据采集分析闭环 在线课程平台的课程数据其实是一座很少被认真对待的金矿。我最初只是想搞清楚某个方向上到底哪些课值得学结果发现平台只给你看前几页的精选推荐搜索排序也有自己的商业逻辑真正客观的全貌被藏得严严实实。后来我花了两个周末用 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;q0.9, } def fetch_list_page(direction, page): url fhttps://example-course-platform.com/courses/{direction}?page{page} resp requests.get(url, headersHEADERS, timeout10) 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[classcourse-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 itemsparse_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 fhttps://example-course-platform.com/course/{course_id} resp requests.get(detail_url, headersHEADERS, timeout10) soup BeautifulSoup(resp.text, html.parser) item {} # 讲师从信息标签内提取 instructor_tag soup.select_one(.instructor-name) item[instructor] instructor_tag.get_text(stripTrue) if instructor_tag else # 课程时长通常以12小时34分格式展示 duration_tag soup.select_one(.duration) item[course_hours] parse_duration(duration_tag.get_text(stripTrue)) # 课程价格优先取优惠价缺失则取原价 price_tag soup.select_one(.price-now) or soup.select_one(.price-origin) item[price] float(price_tag.get_text(stripTrue).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_retries3, backoff1.5): for attempt in range(max_retries): try: resp session.get(url, timeout10) 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, ascendingFalse) print(direction_groups)聚合结果会很有意思。我当时跑出来的数据发现IT方向课程数量最多但平均评分反而是五大方向里偏低的语言学习方向课程数量少平均评分却最高。这说明供给多的地方竞争激烈低分课程拉低了均值而供给少的方向反而都是精挑细选的课程。接下来看评分的分布情况# 评分直方图分布判断是否存在虚高 df[rating_round] df[rating].round(1) rating_dist df.groupby(rating_round).size().reset_index(namecount) 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]], oncourse_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, ascendingFalse).head(10)第二步标记高增长课程。增长量高不等于增长快要看增长率。比如一个10万人的课程增长1万和一个1000人的课程增长800后者才是现象级课程。所以我会同时保留两个榜单绝对增长榜和相对增长榜。第三步把增长数据与课程详情关联起来分析高增长课程哪些特征更常见# 关联详情信息 course_detail df[[course_id, course_name, direction, instructor, price, course_hours]] result top_growth.merge(course_detail, oncourse_id, howleft) result.to_csv(top_growth_courses.csv, indexFalse, encodingutf-8-sig)encodingutf-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分钟内完成全部方向的一轮采集对个人研究足够了。如果你要采集的体量更大我的建议也是在限速前提下逐步增加并发而不是一开始就上高并发方案。稳定的采集节奏远比一次性跑得快重要。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询