ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python图书推荐系统实战:从爬虫采集到协同过滤与可视化

2026/9/7 16:12:14 拓冰建站 浏览量
Python图书推荐系统实战:从爬虫采集到协同过滤与可视化 做图书推荐系统这类项目我前前后后帮人梳理过不少版本。网上搜“python爬虫”、“大数据”、“数据可视化”出来的案例很多但大多数是零散的技术片段真正能同时把爬虫采集、协同过滤算法、后台服务和可视化展示串起来并且跑出一个完整推荐效果的项目反而不多。这篇就把这套“图书推荐系统”从头到尾的拆解思路、实现细节和踩坑记录整理出来给那些正准备做推荐系统或者毕业设计选了类似题目的人一个可以直接参考的路线。项目本身不复杂但模块多数据要从网上抓抓完要清洗清洗完要做用户对图书的评分矩阵再用协同过滤算相似度最后把“猜你喜欢”和统计图表放到同一个分析页面里。这个链路很适合用来理解推荐系统最朴素的原理也是实现成本最低的一类推荐架构。适合刚学完Python基础、对pandas有一点了解但是不清楚推荐系统项目该从哪里下手的同学。1. 先拆需求这个推荐系统到底要做什么1.1 模块边界不要含糊先画出数据流我在动手之前习惯先不写代码而是把系统拆成几块数据从哪来、数据长什么样、怎么存、算法跑完输出什么、页面要展示什么。这套图书推荐系统最关键的五个模块分别是爬虫采集、数据清洗与存储、协同过滤推荐引擎、Web后端接口、数据可视化展示。模块之间的关系是单向的爬虫负责把图书信息、用户评分抓下来清洗模块把脏数据、重复数据去掉再整理成“用户ID、图书ID、评分、时间”这样的标准结构推荐引擎读取评分数据计算图书之间的相似度或者用户之间的相似度给指定用户生成TopN书单后端把推荐结果和统计数据通过接口抛给前端可视化页面负责把图书热度、评分分布、推荐结果这些内容画出来。整个链路只要某个环节数据格式没对齐后面全乱所以第一步先想清楚数据流转比写代码重要得多。1.2 为什么我最终选择Python全家桶来做这种综合项目用Python几乎是必然的选择倒不是别的语言不行而是生态太顺手。爬虫阶段有requests、Scrapy、BeautifulSoup这类成熟库几行代码就能把页面抓下来清洗阶段有pandas的DataFrame去重、透视、缺失处理全部有现成方法算法阶段就算不用现成的surprise库自己用numpy写余弦相似度也就十几行可视化阶段如果不想纯手写前端Flask加ECharts的组合能极大降低开发成本。如果换成Java或者C做爬虫、数据分析和算法这三件事需要分别引入大量额外框架工作量完全不在一个级别。而且这一类推荐系统的核心是验证算法链路不是比拼极端性能Python的执行效率在这个数据量级下也完全够用。图书数据哪怕是几万条评分单机跑算法都是秒级完成根本不需要上分布式系统。1.3 “大数据”在项目里到底体现在哪里项目标题带着“大数据”三个字很多人就爱纠结要不要上Hadoop、Spark其实没必要。这个项目的“大数据”更多是指全链路的数据工程思维数据来自多个渠道、字段碎片化、需要从海量网页里抽取有效信息再用统一格式存储。单个文件可能只有几MB不需要分布式存储但你需要用处理大数据的思路去写清洗代码保证数据量扩大时依然能跑得通。我自己的经验是先把单机版做出来等流程稳定后再把清洗和相似度计算替换成PySpark版本也不迟。这类项目里最忌讳的就是第一版就堆了一堆技术名词结果爬虫都还没抓到数据。正确路线是先让项目跑通一条最小闭环再去谈扩展。2. 爬虫采集设计从零攒出一份图书评分数据集2.1 数据源和数据字段怎么定推荐系统的核心输入是“用户对物品的历史行为”放到图书场景就是用户评分。所以爬虫的采集目标要考虑两个层面一是图书本身的特征数据二是用户行为数据。书名、作者、出版社、出版年份、分类标签、封面、ISBN这些属于图书特征用户ID、评分、评论时间、评分内容属于用户行为。如果只爬图书详情页没有评分数据协同过滤就无从谈起。我之前常用的做法是先确定一个图书站点或者公开图书社区抓取一个分类榜单下的图书列表页再进入每本图书的详情页提取基本信息同时把热门评论区的用户评分一并抓下来。这样就能得到一张较完整的图书评分数据表避免自己手动构造数据推荐结果也更有说服力。爬虫的产出不要直接扔给算法而是保留两层原始数据第一层是页面解析后的明细CSV第二层是统一入库后的MySQL表。这样就算后面解析逻辑改了原始明细还在不至于重头再爬一遍。2.2 一个足够清晰的请求和解析代码骨架写爬虫最怕贪多求全一上来就想抓动态页面、搞验证码识别。对图书推荐系统来说很多站的列表页和详情页都是服务端渲染直接用requests加BeautifulSoup就能搞定。下面给一个最常用的骨架结构上参考了requests提取列表、详情页二次解析的套路。import time import random import requests from bs4 import BeautifulSoup import pandas as pd HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } def get_html(url): resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text def parse_book_list(html): soup BeautifulSoup(html, lxml) book_urls [] for item in soup.select(.book-item a.title): book_url item.get(href) if book_url: book_urls.append(book_url) return list(set(book_urls)) def parse_book_detail(html): soup BeautifulSoup(html, lxml) title soup.select_one(h1).get_text(stripTrue) author soup.select_one(.author) rating soup.select_one(.rating-value) isbn soup.select_one(.isbn) return { title: title, author: author.get_text(stripTrue) if author else None, rating: float(rating.get_text(stripTrue)) if rating else None, isbn: isbn.get_text(stripTrue) if isbn else None } def crawl_books(start_url, max_pages10): results [] for page in range(1, max_pages 1): list_url f{start_url}?page{page} html get_html(list_url) for book_url in parse_book_list(html): try: detail_html get_html(book_url) info parse_book_detail(detail_html) info[book_url] book_url results.append(info) except Exception as e: print(f解析失败: {book_url}, 错误: {e}) time.sleep(random.uniform(0.5, 1.5)) # 休息一段时间再抓下一页 time.sleep(random.uniform(2, 5)) return pd.DataFrame(results) if __name__ __main__: df crawl_books(https://example.com/books, max_pages5) df.to_csv(books_raw.csv, indexFalse, encodingutf-8-sig)代码里两个细节要注意一是resp.encoding resp.apparent_encoding如果不做这一步中文页面很容易解析出一堆乱码二是每个请求之间必须用time.sleep(random.uniform(...))做延时哪怕目标站点没有明显风控这也是最基本的礼貌既是给自己减少被封风险也是照顾对方服务器压力。2.3 爬虫越到后面越折磨人经验值体现在抗风险上爬虫不是把代码写对就完事真正让人头疼的是运行过程中的各种意外。列表页翻到一半IP被限制详情页偶尔返回超时甚至某个字段结构变化导致解析直接报错这些我都遇到过。我的建议是第一版爬虫一定要带断点续抓能力。具体做法是记录已经成功抓取过的URL放到一个集合或文件里每次重新运行前加载一次遇到重复URL直接跳过。另一个建议是单独写一个异常处理函数把每一次失败请求的URL、异常类型、堆栈信息写入日志文件。这样就算程序跑挂了你也能定位到是哪个环节出了问题而不是看着黑框框干瞪眼。还有一类场景是目标站点数据通过JavaScript异步加载你直接requests拿到的是空壳页面。这个时候有两个选择第一是分析XHR接口找到真正的数据接口直接请求第二是用Selenium这类浏览器模拟工具。对图书推荐系统来说优先选第一种因为第二种效率低还要额外维护浏览器驱动。实际做的时候打开浏览器的开发者工具切到Network面板刷新页面找一个返回JSON的请求直接拿那个地址当接口用即可。当然需要注意爬虫合规问题尽量遵守目标网站的Robots协议数据只用于学习研究不要大规模抓取个人隐私数据也不要用爬来的数据做商业用途。3. 预处理和评分矩阵构建算法落地前的关键步骤3.1 清洗规则不是越复杂越好先处理好几类典型脏数据爬下来的数据百分之百是脏的。常见情况包括书名和作者字段包含换行或多余空格、同一本图书在不同页面里的ISBN格式不一致、用户在多个页面留下重复评分、字段缺失严重导致图书详情不全。清洗阶段做的不是玄学操作而是按照下面的优先级来处理。第一步是去重。我一般以“用户ID加图书ID”作为唯一键如果同一对组合里有多条评分只保留时间戳最新的一条或者直接取平均值。第二步是处理缺失作者、书名、评分这三列如果缺失直接丢弃这类记录因为推荐算法既需要知道物品是谁也需要知道评分是多少封面、出版社这类非核心字段缺失可以先留着不影响建模。第三步是统一字段类型评分全部转成float用户ID和图书ID统一转成字符串避免后面merge的时候因为类型不一致丢失数据。3.2 评分数据不等于评分矩阵透视表帮你理顺结构协同过滤算法的输入是一张行为矩阵术语叫user-item矩阵。行是用户列是图书单元格里存放用户给这本书的评分。爬虫抓下来的评分表是长表每行是一条行为记录所以必须先通过pandas的透视功能把它变形为宽表。import pandas as pd ratings pd.read_csv(ratings_clean.csv) # 如果同一个用户对同一本书有重复评分先去重再透视 ratings ratings.drop_duplicates(subset[user_id, book_id], keeplast) user_item_matrix ratings.pivot_table( indexuser_id, columnsbook_id, valuesrating, aggfuncmean )透视之后产生大量NaN这是正常现象因为大部分用户只读过几十本书他不可能给几千本书评分。NaN在后续相似度计算里不能直接当成0处理否则会把“没读过”和“打0分”混为一谈。如果你选择用余弦相似度做计算那才需要把矩阵里的NaN填充为0因为余弦公式只看向量里对应维度的情况但如果你用皮尔逊相关系数pandas的corr方法会自动忽略NaN不需要额外填充。3.3 图书推荐场景里的长尾和稀疏问题图书数据的特点说好听点是长尾丰富说难听点就是马太效应非常明显。头部几百本热门书拿到了绝大多数评分剩下几十万本图书可能只有零星几条记录。这种数据直接丢进协同过滤算法有两个问题一是热门书容易被推荐给所有人个性化程度低二是那些评分记录极少的物品相似度计算结果非常不稳定可能因为一两条评分就与某本书高度相似。所以在预处理阶段我通常会加一道过滤逻辑比如只保留“被至少5个用户评分过”的图书以及“评价过至少10本图书”的用户。这道操作能显著降低矩阵的稀疏度推荐效果反而比把所有冷门数据都塞进算法要好。这里有个取舍如果项目特别强调长尾挖掘可以保留少量冷门数据用于冷启动补充但核心推荐结果还是应该用过滤后的数据来计算。4. 核心算法实现协同过滤怎么在图书场景落地4.1 选UserCF还是ItemCF项目场景说了算协同过滤最常被问到的就是该用User-based还是Item-based。很多讲解都会列公式但没有说清楚项目里到底该偏向哪边。其实方向很明确更看重用户和用户之间有相似的阅读口味就选UserCF更看重图书和图书之间的内容相近关系比如喜欢A书的人群也喜欢B书就选ItemCF。在图书推荐场景我倾向于优先使用Item-based协同过滤。理由有三点第一用户兴趣会随时间和身份发生变化一个学生工作之后看书的类型可能有明显转向但书籍本身的属性相对稳定第二图书数量虽然很大但图书与图书之间的相似度变化频率远低于用户与用户之间的关系可以离线计算、每日更新一次模型工程上更好维护第三ItemCF给出的推荐解释直观“因为你读了《人类简史》所以推荐《未来简史》”用户一看就懂这在展示系统里很重要。如果非要用UserCF适合的场景类似豆瓣小组、同一个学校阅读社群这种用户连接感更强的产品但项目里要让相似用户有足够的互动数据才跑得出效果否则推荐结果很泛。4.2 相似度计算方式不能只看公式还得看数据分布相似度计算方式选择上最常用的是余弦相似度和皮尔逊相关系数。余弦相似度的公式很简洁就是两个向量的夹角的余弦值皮尔逊相关系数则先对每个用户的评分做了中心化处理也就是减去该用户的平均打分然后再算余弦。放在图书场景里不同用户的评分尺度可能差很多。有人习惯打4到5分有人给书比较严格普遍打2到3分。如果直接用余弦相似度评分严格和评分宽松的两类用户很难被识别为相似但用皮尔逊相关系数每个人都有自己不同的“打分基准线”中心化之后比较的是评分趋势而不是绝对分数结果会更合理。我自己的做法是ItemCF、UserCF分别用皮尔逊和余弦都试一遍然后用一个小测试集算一下精确率。如果两类差异不大优先选皮尔逊因为它对用户评分尺度的鲁棒性更好。虽然系统能在Scikit-learn或pandas里几行跑出来你还是得知道背后在算什么毕竟面试答辩时别人一问你为什么要做中心化答不上来就很尴尬。4.3 手写一个基于物品相似度的推荐流程为了更清楚解释ItemCF是如何工作的我贴一段不使用高级算法库的代码用pandas加numpy手写推荐逻辑。import pandas as pd import numpy as np def compute_item_similarity(user_item_matrix): # 用皮尔逊相关系数计算物品图书之间的相似度 item_sim user_item_matrix.corr(methodpearson) return item_sim def recommend_by_item(user_item_matrix, item_sim_matrix, target_user, top_n10): # 找到该用户已经评过分的书 user_ratings user_item_matrix.loc[target_user].dropna() if len(user_ratings) 0: return None # 计算候选图书得分用目标用户已读书的评分加权累加相似度 scores {} for book in user_ratings.index: rating user_ratings[book] sim_series item_sim_matrix[book].dropna() for candidate, sim in sim_series.items(): if candidate in user_ratings.index: continue scores[candidate] scores.get(candidate, 0) sim * rating if not scores: return None # 归一化并取TopN ranked pd.Series(scores).sort_values(ascendingFalse) seen set(user_ratings.index) rec_books [ book_id for book_id in ranked.index if book_id not in seen ][:top_n] return rec_books # 示例使用 item_sim compute_item_similarity(user_item_matrix) result recommend_by_item(user_item_matrix, item_sim, user_1001)这段权重累加其实就是一个最朴素的预测打分用户没读过的书它的推荐分等于所有用户已读书的书评分与该书相似度的加权和。这个逻辑很简单也会有一些冗余比如热门书权重被重复抬高但作为第一版算法足够。如果想让分数更平滑可以在加权和前面除以一个“相似度总和”作为归一化因子这样计算出来的数值更接近用户自身评分尺度。4.4 别忘了留一部分数据做效果评测推荐系统做完不是能出几个推荐结果就算完成。你需要拿出一个小测试集看看模型的推荐到底准不准。常用做法是把用户的历史评分按比例随机分成训练集和测试集比如80%训练、20%测试接着用训练集生成推荐最后检查测试集里用户真实读过的书有多少出现在推荐结果里这就是命中率也就是TopN推荐指标里的召回率/精确率。细节上注意随机划分时要以“用户-图书”为单位进行不能把所有某一本书的记录都放进同一份数据集否则同一本热门书只出现在训练集或只出现在测试集评估结果会失真。项目里最简单可以计算平均命中率例如随机抽100个用户每个用户取他测试集里评分最高的3本书看系统返回的前10本推荐有没有覆盖到。命中率超过10%就算链路是正常的低于这个值就先检查清洗逻辑和相似度计算而不是急着调算法。5. 数据可视化分析系统前端大屏背后是数据聚合5.1 页面需要的不是所有图表而是能说明问题的图可视化模块最容易被做成“为了堆图而堆图”一打开页面全是饼图、折线图、地图闪烁但问起来每张图想说明什么答不上来。我的建议是围绕“别人看这个系统能得出什么结论”来设计图。图书推荐系统的展示区建议固定做五个内容块。全部图书评分总量和活跃用户数用简单的统计卡展示直观交代数据量。评分分布直方图揭示用户打分习惯通常你会看到中高分段扎堆这也是为什么前面说原始评分需要中心化处理。图书热度Top10用横向柱状图一目了然哪些书拿走了流量。分类占比图用饼图或环形图看图书类型分布。推荐结果区放在页面最核心的位置给指定用户生成书单并展示推荐理由。最后可以加一张散点图横轴是图书被评分的数量纵轴是平均分用来揭示哪些书属于“小众高分”。可视化不是简单把维度拖进图表每个图都要能支撑一个分析结论这样答辩或者做汇报的时候才有内容讲。5.2 Flask加ECharts这套方案为什么顺手开发可视化系统的选型因人而异但Flask加ECharts是我个人最愿意推荐给Python开发者的组合。Flask足够轻量几行代码就能把数据接口挂出来ECharts是前端图表库自身支持异步加载对动态数据的更新很友好。整套流程是后端通过SQL或pandas把聚合结果算好返回JSON给前端前端用ECharts的option配置渲染图表。用Flask写后端关键是不要在前端页面里直接写大数据查询的SQL。比如热度Top10要查询所有评分表再聚合统计如果这个操作放在前端每次刷新都执行数据库压力会非常大。正确做法是后端提供一个缓存机制或者把这类统计结果预计算成一张summary表前端调用接口时直接返回汇总结果。一个典型接口代码形如from flask import Flask, jsonify import pandas as pd app Flask(__name__) ratings pd.read_csv(ratings_clean.csv) books pd.read_csv(books_clean.csv) app.route(/api/top_books) def top_books(): top ( ratings.groupby(book_id)[rating] .agg([count, mean]) .sort_values(count, ascendingFalse) .head(10) .reset_index() ) top top.merge(books, onbook_id, howleft) result top.to_dict(orientrecords) return jsonify({code: 0, data: result}) if __name__ __main__: app.run(debugTrue)前端为了拿到jsonify返回的中文不乱码需要保证响应头里编码是UTF-8。ECharts的请求一般用fetch或axios如果直接用jQuery老接口注意加dataType: jsonp还可能遇到跨域问题本地开发阶段可以用Flask-CORS提前把跨域放开。5.3 页面展示推荐结果是整条链路的出口可视化模块除了图表统计很重要的功能是展示推荐结果。这里要注意一个细节推荐结果展示不能只给书名最好再带上一句可解释文案。用户过来看的是结果但你给他呈现推荐理由他能理解为什么这本书会出现在列表里感受完全不同。在实现上后端返回推荐书单时同时携带每条推荐对应的相似图书前端渲染成“你读过《XXX》所以推荐你《YYY》”。这需要算法返回阶段就保留相似度依据。如果算法一次性返回10本书前端很难渲染理由所以我一般在推荐函数里让每条记录携带相似来源列表每次只展示前三条理由。这个小改动对系统完成度提升非常明显也不是很难写。关于部署Flask内置的开发服务器只适合本地调试真正放在服务器或者演示环境里建议用gunicorn或者uwsgi来启动然后再配Nginx反向代理别让开发服务器直接暴露到公网。6. 常见问题排查与实操避坑记录6.1 爬虫程序运行后什么都没有只显示Process finished with exit code 0这是我见过这个问题新手问得最多的pycharm点运行控制台干净得像没发生过事情。大多数情况是代码根本没有执行到爬虫主流程。先检查是否加了if __name__ __main__:。如果忘了写代码只是定义了函数没有调用程序跑完自然什么都打印不出来。再检查是不是入口函数放在了错误的模块里。比如从Scrapy的某个spider里复制代码到普通Python文件但文件里没有触发crawl的命令。还有一种情况是代码逻辑在跑只是被反爬拦截了请求返回的是空白页或者验证页面而解析代码没有校验就静默跳过最后输出空列表。排查方法很简单在拿到HTML后先打印一段前200个字符看看内容里有没有关键字给每个请求加上日志输出例如打印当前页面的URL和状态码。不要一开始就怀疑代码逻辑先确认数据有没有过来。6.2 协同过滤结果全是NaN或者推荐列表为空算出来的相似度矩阵NaN遍布大概率出在评分矩阵过于稀疏或者某本书的评分只有一条记录皮尔逊公式分母出现零。这时你检查一下这一列的方差是否为0方差的含义是这本书的所有用户给的分完全一样此时无法计算相关性返回NaN是正常现象不应该填充成0。推荐列表为空则是另一个问题你的用户历史行为太少或者目标用户所有读过的书都没有任何可关联的其它书籍。解决办法是降低过滤门槛或者为这些用户使用热门榜兜底系统至少要把“大家都在看”推荐出来空列表在演示环境里非常尴尬。6.3 中文乱码导致清洗和展示双重出错pandas读取CSV时中文乱码问题几乎都出在CSV编码不一致上。你爬虫保存的时候用了几个不同编码utf-8、encodingutf-8-sig、gbk读取时没有指定统一编码就会出现全乱。处理规则很简单所有中间文件统一用utf-8-sig保存pandas读写时显式传参encodingutf-8-sig。数据库层面建表时都用 utf8mb4 字符集排序规则用 utf8mb4_unicode_ci这样中文不会出现问号替换。前端页面出现乱码可能是Flask返回JSON时没有设置app.config[JSON_AS_ASCII] FalseFlask默认会把中文转成\u开头的内容虽然浏览器会解析但一旦接口被截断转义就可能显示异常需要手动关闭ASCII转义。6.4 页面打开很慢接口几秒才响应可视化页面打开慢多数不是算法问题而是统计查询没有做预聚合。每次加载页面实时去扫描几十万行评分数据做groupby不管数据库还是pandas都会吃力。处理手段是给统计类接口开一个轻量缓存Python里可以用functools.lru_cache或者直接用一个定时任务把统计结果写到JSON文件里页面只读文件。经过预聚合优化之后页面响应基本能降到百毫秒级。如果数据量真的上了百万行再把pandas换成DuckDB或直接到数据库里跑SQL聚合大多数情况下完全不需要Spark。这个项目里要搞清楚瓶颈在哪而不是盲目引入大数据组件。6.5 一个解决相似度计算内存暴涨的小教训有个同学把图书数万本书两两相似度直接全量计算生成了一个上万乘上万的大矩阵跑的时候内存立刻爆掉走人。这里需要讲的点是图书推荐正确做法不是计算所有图书两两之间的相似度而是只对出现在同一批用户评分记录里的图书两两计算。换句话说建立倒排索引先给每个用户找出他读过的书集合然后只对这些书涉及的组合算相似度能大幅减少无用计算。大量实战场景里真正有用户共同评分的图书组合只占全部组合的很少一部分。7. 写在最后的实操经验和扩展建议这个项目做完一遍你基本就理解了推荐系统从数据到评估的完整闭环。但我更想强调一句整个链路里最难的不是算法不是爬虫而是数据质量意识。如果爬下来的评分数据本身质量差再厉害的模型也推荐不出东西。我第一次做的时候忽略了评分分布不均的问题只算了一版余弦相似度就上线结果推荐出来的书清一色全是高分畅销品用户画像完全没有体现。后来重新分析数据做了评分中心化和长尾截断推荐列表才算有了区分度。一个小技巧是把项目里的关键环节做成可视化检查点比如每步清洗之后打印出数据量变化图数据从几万条降到几百条时能一眼看出清洗逻辑是否误杀。这个习惯在我之后做数据分析项目里帮了大忙。最后留个扩展思路图书协同过滤跑通后你可以试试给推荐加一个时间衰减因子让较新的评分对推荐影响更大或者把图书的分类标签做成内容特征跟用户历史评分拼成一个混合推荐模型。这些方向比重新折腾一个项目有意思得多也更有延续性。希望这篇能让你少走点弯路在“快速复现”和“理解原理”之间找到自己的平衡点。