Python爬虫与推荐算法实战:新闻推荐平台工程化落地 简介这是一套面向Python爬虫与推荐算法学习者的实战项目包适合具备Python基础、希望打通数据采集到个性化推荐全流程的开发者与在校学生。项目以新浪新闻为数据源爬虫部分完整覆盖URL队列构建、HTTP请求、HTML解析与数据存储等环节可抓取标题、正文、图片及视频链接并保留原始排版同时涉及robots协议、访问频率控制与User-Agent模拟等反爬应对思路推荐部分则实现权重衰减、标签推荐、区域推荐与热点推荐四类策略便于理解推荐系统的召回与排序逻辑。压缩包共约2000个文件以py源码为主体辅以html、js、vue前端页面及css样式另有c、h等底层文件与txt、md、json、xml配置说明整体约144.59MB目录结构清晰。目前已有184人学习下载适合作为课程设计、毕业设计或推荐算法入门练手参考。1. 从一份新闻推荐平台压缩包说起爬虫、算法与工程化怎么串起来很多人第一次看到「Python网络爬虫与推荐算法新闻推荐平台」这类项目第一反应是「不就是爬点新闻再推给用户吗」。真动手才发现爬虫要处理反爬、去重、增量更新推荐算法要面对冷启动、实时性、特征稀疏最后还要把两者用一套数据流串起来任何一环掉链子整个平台就是个跑不起来的空壳。这个标题背后其实是一条完整的工程链路用 Python 网络爬虫把新闻数据持续拉回来清洗入库再用推荐算法把内容分发给不同用户中间还夹着调度、存储、接口和评估。它适合两类人一类是刚学完 Python 基础、想找一个能写进简历的完整项目练手另一类是做后端或数据方向、需要快速搭一套内容分发原型验证想法。下面我按自己搭过几套类似系统的顺序把选型、代码、参数和踩过的坑讲清楚你照着能跑通一个最小可用版本也能看清哪些地方别硬抄。2. 新闻爬虫的工程化从请求到入库的完整链路2.1 为什么不用 requests BeautifulSoup 一把梭新手最常写的爬虫是requests.get加BeautifulSoup解析几十行就能抓一个列表页。但新闻平台和抓单个网页是两回事新闻站点通常有列表页分页、详情页正文、发布时间、来源、分类还要考虑增量抓取——今天抓过的文章明天不能重复入库。一把梭的写法在数据量上到几千条后就会暴露问题没有去重、没有断点续爬、没有失败重试脚本一崩就得从头再来。我一般会把爬虫拆成三层调度层负责生成待抓 URL 队列抓取层负责请求和解析存储层负责去重和落库。调度层用简单的 Redis 集合或者 SQLite 表记录已抓 URL 和待抓 URL抓取层用requests.Session复用连接存储层在入库前用文章 URL 的哈希做唯一索引。这样即使中途挂掉重启后也能从队列里继续不会重复劳动。2.2 最小可运行的新闻列表加详情爬虫下面这段代码是一个可复现的最小爬虫骨架抓取一个新闻列表页解析出详情链接再进入详情页提取标题、正文和时间最后写入 SQLite。实际站点结构不同选择器要按目标页面调整。import hashlib import sqlite3 import time from urllib.parse import urljoin import requests from bs4 import BeautifulSoup # 目标站点列表页和详情页的选择器按实际页面改 LIST_URL https://example-news-site.com/list 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 init_db(pathnews.db): conn sqlite3.connect(path) conn.execute( CREATE TABLE IF NOT EXISTS articles ( url_hash TEXT PRIMARY KEY, url TEXT NOT NULL, title TEXT, content TEXT, publish_time TEXT, source TEXT ) ) conn.commit() return conn def url_md5(url: str) - str: return hashlib.md5(url.encode(utf-8)).hexdigest() def fetch(session, url): # 超时和重试是必须的新闻站点经常慢响应 for attempt in range(3): try: resp session.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text except requests.RequestException: time.sleep(1 attempt) return None def parse_list(html, base_url): soup BeautifulSoup(html, html.parser) links [] for a in soup.select(div.list-item a.title): href a.get(href) if href: links.append(urljoin(base_url, href)) return links def parse_detail(html): soup BeautifulSoup(html, html.parser) title soup.select_one(h1.article-title) content soup.select_one(div.article-content) pub soup.select_one(span.publish-time) return { title: title.get_text(stripTrue) if title else , content: content.get_text(stripTrue) if content else , publish_time: pub.get_text(stripTrue) if pub else , } def save(conn, url, data): h url_md5(url) try: conn.execute( INSERT INTO articles (url_hash, url, title, content, publish_time, source) VALUES (?, ?, ?, ?, ?, ?), (h, url, data[title], data[content], data[publish_time], example) ) conn.commit() return True except sqlite3.IntegrityError: # 主键冲突说明已经抓过直接跳过 return False def main(): conn init_db() session requests.Session() list_html fetch(session, LIST_URL) if not list_html: print(列表页抓取失败) return detail_urls parse_list(list_html, LIST_URL) print(f待抓详情页 {len(detail_urls)} 条) for url in detail_urls: html fetch(session, url) if not html: continue data parse_detail(html) if save(conn, url, data): print(入库:, data[title][:30]) time.sleep(0.5) # 控制频率别把人家站点打挂 if __name__ __main__: main()逻辑上分四步初始化数据库建唯一索引抓列表页解析详情链接逐条抓详情并解析字段用 URL 的 MD5 做主键去重入库。参数上重点看三个地方timeout10是单次请求超时新闻站点响应慢可以调到 15time.sleep(0.5)是请求间隔公开站点建议不低于 0.3 秒有反爬的站点要配合代理池和随机 UArange(3)是重试次数超过三次还失败就放弃这条避免卡死整个队列。2.3 增量抓取和去重的三个关键参数增量抓取的核心是「怎么知道哪些抓过」。常见做法有三种用 URL 哈希做主键、用文章标题加发布时间做联合唯一索引、用布隆过滤器在内存里判重。小规模用 SQLite 主键冲突最简单几万条以内性能足够上到百万级再考虑布隆过滤器加 Redis。去重之外还有两个参数容易被忽略。一是列表页翻页的终止条件不能无限翻通常用「连续三页没有新链接」或者「发布时间早于上次抓取时间」来停。二是正文提取的兜底策略很多新闻详情页正文容器 class 会变我一般会同时匹配两到三个选择器取文本最长的那个作为正文避免抓到空内容还入库。提示抓取前先看目标站点的 robots.txt 和服务条款控制频率别给人家造成压力。这条不是客套是避免你的 IP 被封、项目半路夭折的实际操作。3. 推荐算法选型从协同过滤到内容特征的落地取舍3.1 新闻推荐为什么不能直接套电商那套电商推荐和新闻推荐最大的差别在时效性。一件商品的热度能持续几周一条新闻的黄金生命周期可能只有几小时。这意味着电商里好用的矩阵分解、深度协同过滤直接搬到新闻场景会严重滞后——模型还没更新完新闻已经没人看了。新闻推荐更依赖内容特征和实时行为标题关键词、分类、发布时间、用户最近几次点击这些信号比历史共现矩阵更管用。所以我的选型顺序通常是先上基于内容的推荐TF-IDF 或词向量算文章相似度再叠加基于物品的协同过滤看相似文章被同一批用户点击的情况最后如果有实时行为流再加一个热度衰减的召回通道。冷启动阶段内容特征能兜底有行为数据后协同过滤补精度热度通道保证新文章有机会曝光。3.2 用 TF-IDF 加余弦相似度做内容召回下面这段代码用 scikit-learn 的 TF-IDF 把新闻标题和正文转成向量然后算文章之间的相似度给用户推荐和他刚看过文章最像的内容。这是冷启动阶段最稳的召回方式不需要用户历史行为。import sqlite3 import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def load_articles(db_pathnews.db): conn sqlite3.connect(db_path) rows conn.execute(SELECT url_hash, title, content FROM articles).fetchall() conn.close() return rows def tokenize(text): # 中文需要分词jieba 是最常用的轻量方案 return .join(jieba.cut(text)) def build_similarity(rows): hashes [r[0] for r in rows] corpus [tokenize((r[1] or ) (r[2] or )[:500]) for r in rows] # max_features 控制词表大小太大内存吃不消 vectorizer TfidfVectorizer(max_features5000, min_df2, max_df0.8) tfidf vectorizer.fit_transform(corpus) sim cosine_similarity(tfidf) return hashes, sim def recommend(hashes, sim, read_hash, top_k10): if read_hash not in hashes: return [] idx hashes.index(read_hash) scores list(enumerate(sim[idx])) scores.sort(keylambda x: x[1], reverseTrue) # 跳过自己取前 top_k return [(hashes[i], round(s, 4)) for i, s in scores[1:top_k 1]] if __name__ __main__: rows load_articles() if len(rows) 2: print(文章太少先多抓点数据) else: hashes, sim build_similarity(rows) result recommend(hashes, sim, hashes[0]) for h, score in result: print(h[:8], score)逻辑是先把每篇文章的标题和正文前 500 字拼起来分词用 TF-IDF 转成稀疏向量再算两两余弦相似度。参数上max_features5000限制词表规模新闻语料几万篇时够用内存紧张可以降到 3000min_df2过滤只出现一次的词减少噪声max_df0.8过滤掉超过 80% 文章都有的词比如「记者」「报道」这类高频无意义词。top_k10是返回条数实际接口里还会叠加时间衰减越新的文章加权越高。3.3 协同过滤和热度通道怎么叠加内容召回解决「相似」协同过滤解决「同类用户还看了什么」。用surprise或implicit库可以快速搭一个基于物品的协同过滤输入是用户-文章点击矩阵。新闻场景下这个矩阵非常稀疏所以物品相似度比用户相似度更稳。热度通道则简单粗暴按发布时间做指数衰减score base_score * exp(-lambda * hours_since_publish)lambda取 0.05 到 0.1 之间让半天前的新闻权重降到一半左右。三路召回后做加权融合权重可以先用经验值内容 0.4、协同 0.4、热度 0.2上线后用点击率做 A/B 调。别一上来就上深度学习排序新闻推荐的数据量和迭代速度往往撑不起复杂模型先把召回和融合做扎实效果提升比换模型明显。4. 把爬虫和推荐串成平台存储、接口与调度4.1 数据表设计和字段取舍爬虫和推荐之间的桥梁是数据表。最小可用版本三张表就够articles存文章users存用户behaviors存点击、阅读时长等行为。articles表除了标题正文一定要留publish_time和category推荐排序和分类召回都靠它们。behaviors表用(user_id, article_id, timestamp)做联合索引查用户最近行为时走索引才快。字段类型上正文用 TEXT 没问题但别在列表查询里SELECT *把正文也拉出来列表接口只取标题、时间、分类和摘要。摘要可以在爬虫入库时截取正文前 100 字存成单独字段省得每次查询都处理大文本。4.2 用 FastAPI 暴露推荐接口接口层用 FastAPI 写最省事自带文档和异步支持。下面是一个推荐接口的最小实现接收用户 ID 和最近阅读的文章 ID返回推荐列表。from fastapi import FastAPI, Query from pydantic import BaseModel import sqlite3 app FastAPI() class RecommendItem(BaseModel): article_id: str title: str score: float def get_recent_read(user_id: str, limit: int 5): conn sqlite3.connect(news.db) rows conn.execute( SELECT article_id FROM behaviors WHERE user_id? ORDER BY timestamp DESC LIMIT ?, (user_id, limit) ).fetchall() conn.close() return [r[0] for r in rows] app.get(/recommend, response_modellist[RecommendItem]) def recommend(user_id: str Query(...), top_k: int Query(10, ge1, le50)): recent get_recent_read(user_id) if not recent: # 冷启动走热度通道这里简化为按时间倒序 conn sqlite3.connect(news.db) rows conn.execute( SELECT url_hash, title FROM articles ORDER BY publish_time DESC LIMIT ?, (top_k,) ).fetchall() conn.close() return [RecommendItem(article_idr[0], titler[1], score0.5) for r in rows] # 有历史行为时走内容召回实际项目里这里调第 3 章的相似度逻辑 return [RecommendItem(article_idrecent[0], title示例, score0.9)]接口逻辑分两条路没有历史行为的用户走热度冷启动直接按发布时间倒序返回有行为的用户走召回融合。参数上top_k限制在 1 到 50防止调用方传个 10000 把服务拖垮。实际部署时相似度矩阵要预计算好放内存或 Redis不能在每次请求里现算否则响应时间会从毫秒级涨到秒级。4.3 调度爬虫多久跑一次模型多久更新一次调度频率取决于新闻更新速度。一般新闻站点列表页几分钟就有新内容爬虫可以 10 到 15 分钟跑一次增量推荐模型的相似度矩阵不需要每次爬虫后都重算文章量变化不大时每小时更新一次足够。用户行为数据实时写入但召回融合可以接受分钟级延迟。用APScheduler或者系统 crontab 都能做。关键是给爬虫任务加锁避免上一次还没跑完下一次又启动导致重复抓取和数据库锁冲突。我一般用文件锁或者 Redis 的SETNX做互斥任务开始前抢锁结束后释放抢不到就跳过本次。5. 避坑与排查那些让平台跑不起来的细节5.1 爬虫被反爬拦截返回 403 或验证码页现象是请求返回 403或者拿到的是验证码页面而不是新闻内容。原因通常是 UA 固定、请求频率过高、或者缺少必要的 Cookie。解决方法是轮换 User-Agent、把请求间隔调大到 1 秒以上、必要时用代理池分散 IP。判断是否被拦看返回内容里有没有「验证」「访问过于频繁」这类关键词有就暂停该站点抓取别硬刚。5.2 中文分词后 TF-IDF 矩阵内存爆掉现象是文章到几万篇后fit_transform直接吃满内存或者报 MemoryError。原因是词表太大稀疏矩阵维度失控。解决办法是限制max_features同时用min_df过滤低频词正文只取前 500 字参与向量化。如果还不行改用HashingVectorizer它不需要维护词表内存占用固定代价是失去可解释性。5.3 推荐结果全是旧新闻新文章没曝光现象是用户反馈推荐列表里都是几天前的内容。原因是热度衰减没做或者内容召回的相似度矩阵更新太慢新文章还没进矩阵。解决办法是加一路按发布时间倒序的热度召回给新文章固定配额比如每次推荐结果里至少两条是 24 小时内的。同时把相似度矩阵更新频率提到每小时一次。5.4 数据库锁冲突导致爬虫和接口互相阻塞现象是爬虫写入时接口查询超时或者爬虫报database is locked。原因是 SQLite 默认的写锁会阻塞读。解决办法是爬虫批量写入时用事务减少锁持有时间接口查询和爬虫写入分离到不同连接数据量再大就换 PostgreSQL 或 MySQL。小项目用 SQLite 的 WAL 模式也能缓解执行PRAGMA journal_modeWAL即可。5.5 接口响应慢相似度现算拖垮服务现象是推荐接口 P99 响应时间超过 1 秒。原因是在请求里实时计算余弦相似度。解决办法是离线预计算好文章相似度矩阵存成字典或写入 Redis接口只做查表和排序。矩阵大时只存每个文章的前 50 个最近邻不要存全量。6. 进阶技巧用行为反馈闭环把推荐效果调起来平台跑通之后真正决定推荐质量的是反馈闭环。我一般会在接口返回推荐结果时带上一个request_id前端把用户点击、停留时长、是否分享回传写入behaviors表。然后用这些数据做两件事一是统计每路召回的点击率动态调融合权重二是把点击过的文章作为新的种子扩展内容召回的相似度计算。具体做法是每天跑一次离线评估脚本按request_id关联曝光和点击算出每路召回的 CTR。如果内容召回的 CTR 明显高于协同就把权重从 0.4 调到 0.5反之亦然。这个调整不需要重训模型改配置就行迭代速度很快。另一个技巧是给新用户做引导选择。冷启动阶段纯热度推荐点击率通常很低让用户注册时选三五个感兴趣的分类用分类过滤加内容相似度做首屏推荐点击率能翻倍。这个改动很小但效果立竿见影。验证推荐效果别只看点击率还要看多样性。如果推荐列表全是同一类新闻用户很快会腻。我一般会加一个简单的打散逻辑同一分类最多连续出现两条超过就换下一类。代码上就是在排序后做一次重排几行就能实现。最后说个我自己的习惯每次改完召回或权重先在小流量上跑一天看点击率和人均阅读篇数两个指标都涨了再全量。别一次改多个变量否则出了问题根本不知道是哪个改动导致的。这套东西我踩过的最大坑就是一开始贪多同时上了协同过滤和深度学习排序结果数据量不够模型全是噪声还不如老老实实做内容召回加热度。希望帮到你。本文还有配套的精品资源点击获取