欢迎光临
我们一直在努力

从爬虫到推荐:构建一个Python新闻推荐平台实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

简介:这是基于Python实现的新闻推荐平台完整项目,涵盖爬虫采集与推荐算法两大核心模块。爬虫部分面向新浪新闻,实现了URL收集、请求网页、解析内容、数据存储的完整链路,可自动抓取页面标题、正文、图片及视频链接,并保留原始排版;推荐模块融合权重衰减、标签推荐、区域推荐和热点推荐,帮助开发者理解从原始数据到个性化排序的全过程。压缩包共2000个文件,其中1607个Python脚本构成项目主体,涵盖爬虫、解析、存储与推荐逻辑;131个HTML页面、115个JavaScript脚本、20个Vue组件及CSS等前端资源用于展示新闻列表与推荐结果,另有少量C/C扩展与配置文件辅助底层运算和集成,整体约144.59MB,目录按功能划分,便于查阅。已有184人学习浏览,适合具备一定Python基础、准备课程设计或毕业设计的读者,可作为新闻聚合、内容采集、推荐策略设计等方向的完整工程样例。

1. 新闻推荐平台是什么:爬虫、算法与这套方案能解决的问题

拿到“Python网络爬虫与推荐算法新闻推荐平台.zip”这个包的人,第一反应通常是解压、找 README、试跑,然后卡在“数据从哪来”和“推荐结果为什么不靠谱”两个问题上。这个标题本质上把三个独立的技术栈拼成了一条完整链路:网络爬虫负责把新闻数据抓下来,推荐算法负责从海量内容里筛出用户当前最想看的几条,新闻推荐平台则是把这两者包成一个能对外提供服务的产品外壳。它解决的问题很具体:你不想用第三方新闻 API(要 key、有配额、内容还不一定能定制),想自己掌控数据源、复现推荐逻辑,或者需要一个能写进简历的 Python 综合项目。

这套方案适合三类人:一是想练完整项目的新手,能同时接触到 requests 爬虫、SQLite 存储、scikit-learn 和 Flask 接口;二是需要内部新闻聚合工具的从业者,比如做舆情监控或竞品分析的场景;三是想在推荐算法上做实验的研究型开发者,可以用自己的数据验证基于内容推荐和协同过滤的差别。下面按“采集 → 算法 → 平台 → 避坑 → 验证”的顺序把这条链路拆开讲。

2. 新闻爬虫模块:多源采集、正文清洗与入库的正确姿势

2.1 环境准备:用轻量 requests 而不是一上来上 Scrapy

先明确边界:新闻推荐平台的核心价值在推荐算法和产品闭环,爬虫只是数据供给。所以我不建议一上来就上 Scrapy——它有自己的项目结构、中间件机制和调试方式,对于这个量级的新闻采集任务属于杀鸡用牛刀。常见做法是用 requests + BeautifulSoup + lxml 解决列表页和详情页,等后面真的需要分布式采集或遵守 robots 协议做全站抓取时,再把采集层重构成 Scrapy 也不迟。

环境准备按最小集合来,装错了后面全是坑:

# Python 3.8+ 环境,建议先建虚拟环境
python -m venv venv
source venv/bin/activate # Windows 下用 venv\\Scripts\\activate

pip install requests beautifulsoup4 lxml jieba scikit-learn flask

参数说明:requests 负责 HTTP 请求,BeautifulSoup 负责解析 HTML,lxml 是 bs4 的解析引擎,比默认的 html.parser 快而且容错性更好;jieba 是中文分词库,推荐算法那一步要用;scikit-learn 提供 TF-IDF 向量化和余弦相似度计算;flask 用来把推荐结果包成 HTTP 接口。解压 zip 包后如果发现里面有 requirements.txt,直接 pip install -r requirements.txt 即可,没有就按上面这份装,够用。

这里额外提一个环境细节:有虚拟环境后,PyCharm 里记得把解释器指到 venv 里的 python,VSCode 则用 Python: Select Interpreter 选到同一路径。很多人报了 ModuleNotFoundError 或者 no module named 'bs4' ,百分之八十是解释器和 pip 不在同一个环境里,属于最典型的 Python 环境配置翻车点。

2.2 列表页与正文页:RSS 兜底 + HTML 详情解析双通道

新闻站点的页面结构差异很大,但我一般不直接去解析每个站的 HTML,而是优先接 RSS 源。RSS 是站点主动发布的、结构化的内容出口,稳定性远高于直接解析 HTML 页面,而且不会被当成爬虫封掉。RSS 拿标题、链接、发布时间、摘要;拿不到正文时,再回退到详情页解析。

import requests
import feedparser
from bs4 import BeautifulSoup
from datetime import datetime

HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}

def parse_rss_feed(feed_url: str, source_name: str) -> list:
"""从 RSS 源解析新闻条目,返回结构化字典列表"""
articles = []
resp = requests.get(feed_url, headers=HEADERS, timeout=10)
resp.encoding = resp.apparent_encoding or "utf-8"
feed = feedparser.parse(resp.content)

for entry in feed.entries:
article = {
"source": source_name,
"title": entry.get("title", "").strip(),
"link": entry.get("link", ""),
"summary": entry.get("summary", "").strip(),
"published": normalize_time(entry.get("published") or entry.get("pubDate")),
}
articles.append(article)
return articles

def normalize_time(raw_time: str) -> str:
"""把 RSS 里的各种时间格式统一成 yyyy-MM-dd HH:mm:ss"""
if not raw_time:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
dt = datetime.strptime(raw_time, "%a, %d %b %Y %H:%M:%S %z")
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

逻辑说明:feedparser 不是 requests 系列的标准库,需要 pip install feedparser ,它是处理 RSS 最省心的库。代码里先请求 RSS 地址,用 resp.apparent_encoding 猜测编码是因为部分站点会在 Content-Type 里漏掉 charset; feedparser.parse 直接吃二进制内容,兼容 utf-8 和 gbk。 normalize_time 存在的意义是新闻时间格式五花八门,有的带时区、有的带星期缩写,不统一的话后面按时间过滤和排序会直接出错。

参数说明: timeout=10 是必须的,爬虫最怕某个源挂了导致整个采集线程卡死;HEADERS 里的 UA 是给自己一块“免死金牌”,虽然不能完全模拟真实浏览器,但能绕过相当一部分仅检查 UA 的防护。注意 feedparser.parse 对发布时间字段敏感,如果 entry 里没有 published 字段,有些源会用 updated ,建议解析失败时再补一次 entry.get("updated") 。

2.3 HTML 详情页正文抽取:我一般不用通用抽取器

RSS 能拿到标题和摘要,但推荐算法需要正文来算 TF-IDF,摘要长度远远不够。所以第二条通道是请求详情页,把正文从 HTML 里剥出来。通用正文抽取器这类方案(比如基于文本密度统计的算法)在门户网站上效果时好时坏,我一般用更朴素的办法——针对具体站点配 CSS 选择器。

def fetch_article_content(link: str) -> str:
"""抓取新闻详情页正文,返回纯文本"""
try:
resp = requests.get(link, headers=HEADERS, timeout=10)
resp.encoding = resp.apparent_encoding or "utf-8"
soup = BeautifulSoup(resp.text, "lxml")
except Exception as e:
return ""

# 常见新闻站点正文容器:div.article、div.content、article#content
for selector in ["div.article-content", "div.article", "div.content", "article"]:
node = soup.select_one(selector)
if node:
# 移除脚本、样式、广告位
for tag in node.select("script, style, .advertisement, .recommend"):
tag.decompose()
text = node.get_text(separator="\\n", strip=True)
if len(text) > 200:
return text
return ""

逻辑说明: select_one 按优先级依次尝试常见选择器,命中的节点如果正文长度大于 200 字才认定有效,避免抓到标题下面的导语就当正文。 decompose() 把 script、style、广告位节点直接从 DOM 里摘掉,比 extract() 更彻底。 get_text(separator="\\n", strip=True) 的效果是每段一行,去掉首尾空白,比直接拿全部文本更适合后续 jieba 分词——分段按行统计文本密度也更自然。

参数说明:选择器列表要按站点实际情况调整。比如有的站点正文装在 div#js_content ,有的在 div.article-detail ,接入新站点时先用浏览器开发者工具看一两个页面才能确定,这属于爬虫开发里最常规的“人工巡检”过程。对这个平台,接入 2 到 3 个站足够跑通推荐逻辑;站点越多,选择器适配的工作量越大,收益反而递减。

2.4 数据入库:SQLite 表结构与去重策略

新闻数据落库我首选 SQLite,没上 MySQL 是因为平台是单机应用,不需要并发写库,推荐算法读库也都在同一进程内。SQLite 零配置文件、一个文件就能迁移备份,对个人项目和实验场景完全够了。表结构设计有三个关键点:发布时间单独存标准格式、正文和摘要分开存、新闻链接做唯一索引。

import sqlite3

DB_PATH = "news.db"

def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
link TEXT UNIQUE NOT NULL,
summary TEXT,
content TEXT,
source TEXT,
published_at TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_published_at ON articles(published_at)
""")
conn.commit()
conn.close()

def insert_articles(articles):
"""批量入库,重复链接直接跳过"""
conn = sqlite3.connect(DB_PATH)
sql = """
INSERT OR IGNORE INTO articles (title, link, summary, content, source, published_at)
VALUES (?, ?, ?, ?, ?, ?)
"""
conn.executemany(sql, [
(a["title"], a["link"], a["summary"], a.get("content", ""), a["source"], a["published"])
for a in articles
])
conn.commit()
conn.close()

逻辑说明: link TEXT UNIQUE 是天然去重键。新闻站点的链接通常带稳定 ID,比如 /news/123456.html ,同一篇新闻即使被多个频道重复抓取,链接也一样, INSERT OR IGNORE 遇到重复链接就静默跳过。id 是自增主键,推荐算法里用 id 作为唯一标识,避免用 URL 当主键导致空间浪费和索引变慢。

参数说明: published_at 建普通索引是为了后面按时间过滤旧闻、按时间排序热门新闻。实际业务里你会经常执行这种查询: SELECT * FROM articles WHERE published_at > datetime('now', '-3 days') ,没有索引的话几千条数据可能无所谓,等积累到几万条,全表扫描的延迟就会出现在接口耗时里。 created_at 用数据库默认值,记录入库时间,排查问题时能看出是采集延迟还是推荐程序处理延迟。

3. 推荐算法选型:基于内容 + 物品协同过滤怎么落地

3.1 中文分词与文本向量化:TF-IDF 的正确打开方式

新闻推荐不同于商品推荐,新闻的时效性强、物品生命周期只有几小时到几天,用户与新闻的交互矩阵天然稀疏。因此主推荐策略选基于内容的推荐:把每篇新闻变成向量,用余弦相似度找“和你读过的新闻最像”的其他新闻。这需要先解决中文文本向量化的问题。

直接对中文新闻文本调用 TfidfVectorizer 会得到一堆单字词或者奇怪的组合,因为英文按空格分词这个先验条件在中文里不成立。所以要做两步:先用 jieba 分词把句子切成词序列,再把词序列交给 TfidfVectorizer 的预分词接口。

import jieba
from sklearn.feature_extraction.text import TfidfVectorizer

def tokenize(text: str) -> list:
"""jieba 分词,去掉单字和空白"""
return [w for w in jieba.lcut(text) if len(w.strip()) > 1]

def build_tfidf_matrix(articles):
"""
articles: [{"id": 1, "title": "xxx", "content": "xxx"}, …]
返回 (matrix, id_list)
"""
texts = [f"{a['title']}。{a['content']}" for a in articles]

# analyzer 指向预分词函数,token_pattern 改为 None
vectorizer = TfidfVectorizer(
analyzer=lambda text: tokenize(text),
token_pattern=None,
max_features=10000,
sublinear_tf=True,
)
matrix = vectorizer.fit_transform(texts)
ids = [a["id"] for a in articles]
return matrix, ids, vectorizer

逻辑说明: analyzer=lambda text: tokenize(text) 让 TfidfVectorizer 不再做默认的英文分词,而是直接使用 jieba 的结果; token_pattern=None 是为了禁用内部的正则分词逻辑,否则 jieba 分好的词还会被重新切一遍,这是网上很多中文 TF-IDF 代码悄悄翻车的地方。标题和正文拼接时加个句号分隔,避免标题末字和正文首字被错误组合。

参数说明: max_features=10000 限制特征维度,保留词频最高的前一万个词。新闻语料里大量低频词是噪声,保留它们会拉长向量、增加计算量,对效果没有帮助。 sublinear_tf=True 表示对词频取 1 + log(tf) ,让高频词的优势不要过大,在新闻场景下比原始 TF 更平稳。这一步产出的矩阵就是你给推荐算法的“特征输入”,矩阵质量决定了后续相似度计算的可靠性,值得多花时间在清洗上——正文里有页面版权声明或“责任编辑”字样的话,建议在分词前就把这类模板文本过滤掉。

3.2 基于内容的推荐:给每篇新闻算“相似新闻”

向量造好之后,推荐思路就清晰了:用户读了一篇新闻 A,就把和 A 余弦相似度最高的 N 篇推荐给他。计算余弦相似度有两种方式:两两计算得到相似度矩阵,或者用 linear_kernel 直接算稀疏矩阵间的内积。注意新闻库是不断增长的,每次新抓一批新闻就全量重算矩阵不划算,常见做法是定时全量重算并缓存结果。

from sklearn.metrics.pairwise import cosine_similarity

def compute_similarity_matrix(matrix):
"""返回 (n_samples, n_samples) 的稀疏相似度矩阵"""
return cosine_similarity(matrix, matrix)

def recommend_by_content(article_id: int, sim_matrix, ids, top_k=10):
"""基于内容的推荐:根据文章 id 返回相似文章 id 列表"""
# ids 是 list,先找到文章在矩阵里的行号
idx = ids.index(article_id)
# sim_matrix[idx] 是该文章对所有文章的相似度
sim_scores = list(enumerate(sim_matrix[idx]))
# 去掉自己,按相似度降序
sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True)
sim_scores = [s for s in sim_scores if s[0] != idx]
top_items = sim_scores[:top_k]
return [(ids[i], round(score, 4)) for i, score in top_items]

逻辑说明: sim_matrix[idx] 拿到第 idx 篇新闻到所有新闻的相似度数组;排序时排除自己(某篇文章和它自己的相似度永远是 1.0,不做排除的话推荐结果第一位永远是自己);二元组里的 score 保留 4 位小数,方便调参时人工判断阈值。这套代码的问题在于每次都线性找 ids.index(article_id) ,几千篇新闻时尚可接受,超过一两万篇建议改成 id_to_index 字典映射。

参数说明: top_k 是推荐条数,首页推荐位一般取 10 到 20 条,取 10 的意义是首屏能完整展示且决定推荐位价值的是前 3 条,多给的候补位置用于过滤掉太老或与最新新闻重复的条目。这个函数返回的是推荐候选集,还没做时间过滤和热度加权,建议不要直接暴露给前端。

3.3 物品协同过滤与热度融合:解决冷启动和同质化

只靠基于内容推荐有一个明显缺点:相似新闻内容会越来越同质,用户看完一条体育新闻,推荐的全是同一场比赛的报道,体验非常差。所以平台里还要加一个物品协同过滤的轻量实现,让“喜欢 A 的用户也喜欢 B”的信息参与排序。不过新闻站点的用户行为数据很少,我一般用简化版:用点阅行为的共现次数计算物品相似度。

import collections

def build_item_cf_matrix(user_click_log: list):
"""
user_click_log: [(user_id, article_id), …]
返回 item_sim: dict[article_id] = [(other_article_id, sim_score), …]
"""
# 1. 统计每个用户点过哪些文章
user_items = collections.defaultdict(set)
for user_id, article_id in user_click_log:
user_items[user_id].add(article_id)

# 2. 统计两篇文章被同一用户点击的次数
co_occur = collections.defaultdict(int)
for user_id, items in user_items.items():
items = list(items)
for i in range(len(items)):
for j in range(i + 1, len(items)):
a, b = items[i], items[j]
co_occur[(a, b)] += 1
co_occur[(b, a)] += 1

# 3. 按共现次数排序,得到每个 item 的物品相似候选
item_sim = collections.defaultdict(list)
for (a, b), count in co_occur.items():
item_sim[a].append((b, count))
for a in item_sim:
item_sim[a].sort(key=lambda x: x[1], reverse=True)
return item_sim

逻辑说明:这是最朴素的 item-based CF 实现,没有做余弦归一化。在新闻场景下,共现次数本身比归一化后的分值更有解释性:两个条目被同一个人点过 3 次,基本可以认定内容相关。之所以不加分母归一化,是因为新闻数据里热门条目天然有更高的共现机会,我希望热度这个信息保留在分值里,作为一种隐性的流行度信号。

参数说明: user_click_log 在这个项目里需要自己构造。常见做法是在平台前端埋一个点击接口,用户点哪条新闻就记录 (user_id, article_id, timestamp) ,存进另一张 click_log 表。没有点击数据时,可以拿同一篇文章在相似度矩阵里“点击”这类行为做模拟,效果稍差但能演示整条链路。这条代码的时间复杂度是 O(用户数 × 每用户点击数²),用户行为多的话要改成 Spark 或者至少用 numpy 矩阵运算,单机脚本只适合实验阶段。

3.4 三个必调参数:相似度阈值、时间衰减与候选集大小

推荐不是算完相似度就结束了,实际效果好坏往往取决于最后那层业务规则。我会在推荐候选中做三件事:过滤相似度低于阈值的弱相关新闻、用发布时间做指数衰减、去重后保留位置靠前的条目。

import math

def final_ranking(candidate_ids, sim_matrix, ids, article_info, time_now, top_k=10):
"""
candidate_ids: 从协同过滤和内容推荐合并来的候选集
article_info: dict[article_id] = {"published_at": datetime, "like_count": int}
"""
scored = []
for aid in candidate_ids:
if aid not in article_info:
continue
sim = 0
try:
idx = ids.index(aid)
sim = sim_matrix[idx].max() # 候选与该用户阅读历史的最高相似度
except ValueError:
pass
if sim < 0.3:
continue # 弱相关直接淘汰

# 时间衰减: 新闻每过 24 小时权重减半
age_hours = max((time_now – article_info[aid]["published_at"]).total_seconds() / 3600, 0)
time_weight = math.pow(0.5, age_hours / 24)

# 最终得分 = 0.6 * 相似度 + 0.3 * 热度 + 0.1 * 时间新鲜度
hot = math.log1p(article_info[aid].get("like_count", 0))
score = 0.6 * sim + 0.3 * hot + 0.1 * time_weight
scored.append((aid, score))

scored.sort(key=lambda x: x[1], reverse=True)
return [aid for aid, _ in scored[:top_k]]

逻辑说明:这三个参数是推荐系统落地时真正的“手感”所在。 sim < 0.3 是相似度筛选阈值,太严候选不够,太松推荐列表里会出现无关内容; time_weight 用半衰期 24 小时控制旧闻权重,新闻和电影不同,三天前的新闻基本只有历史参考价值;最终得分里相似度占 0.6 说明平台明确以相关性为主,热度只做平滑。

参数说明: math.log1p 对 0 点击的新闻友好,log(1+0)=0 避免负无穷。热度字段需要后端额外维护,没有真实的点赞数据时可以退化为用点击量代替,再不行就统一置 1,等于放弃热度项。这套公式写死在代码里不是好习惯,建议把 0.6/0.3/0.1 和相似度阈值抽成配置文件,方便后续做 A/B 对比时调参。

4. 推荐接口与平台串联:用 Flask 把算法变成可调用的服务

4.1 平台数据流:从数据库到推荐结果只隔一个函数

爬虫完成采集、算法完成训练之后,下一步是把它们串成能对外服务的平台。整个数据流是这样的:采集脚本定期把新闻写入 SQLite → 推荐模块定时读取文章表,构建 TF-IDF 矩阵和相似度矩阵 → Flask 启动时加载矩阵到内存,收到请求后查询用户的历史点击,调用推荐函数返回结果。矩阵不落盘,每次启动重新构建,所以平台启动前会自动检查数据库是否为空,空了就提示先跑采集。

# app.py
import sqlite3
from flask import Flask, request, jsonify
import os

app = Flask(__name__)
DB_PATH = "news.db"

def load_articles():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT id, title, content, published_at FROM articles").fetchall()
conn.close()
return [dict(r) for r in rows]

逻辑说明: row_factory = sqlite3.Row 让查出来的行支持按列名取值,否则 dict(r) 只会拿到索引到值的映射,写起来非常别扭。 load_articles 是平台和数据库之间唯一的接口,推荐矩阵、接口查询都从这里拿数据。平台启动前建议做一次空库检查,否则 Flask 接口能起来但请求推荐时矩阵是空的,报错信息对新手不友好。

4.2 推荐接口实现:/api/hot 与 /api/recommend

平台暴露两个接口就够用: /api/hot 返回最近 24 小时的热门新闻,给冷启动用户兜底; /api/recommend 根据用户 ID 返回个性化推荐列表。冷启动在这里特别重要——平台刚上线时用户没有点击记录,纯推荐算法输出为空,必须有一个热门榜顶着首屏。

@app.route("/api/recommend")
def recommend():
user_id = request.args.get("user_id", default="default_user")
limit = int(request.args.get("limit", default=10))

conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
# 读取用户最近点过的文章 id
clicks = conn.execute(
"SELECT article_id FROM click_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 5",
(user_id,)
).fetchall()
conn.close()

if not clicks:
# 冷启动:返回热门新闻
return jsonify({"user_id": user_id, "strategy": "hot", "items": get_hot_news(limit)})

# 有历史点击:取最近一篇做内容推荐,再和协同过滤候选合并
last_article_id = clicks[0]["article_id"]
content_items = recommend_by_content(last_article_id, SIM_MATRIX, ARTICLE_IDS, top_k=20)
cf_items = get_cf_items(last_article_id, ITEM_SIM, top_k=20)

merged = list(dict.fromkeys([aid for aid, _ in content_items] + cf_items))
final_items = final_ranking(merged, SIM_MATRIX, ARTICLE_IDS, ARTICLE_INFO, time_now, top_k=limit)
return jsonify({"user_id": user_id, "strategy": "hybrid", "items": final_items})

逻辑说明: clicks 查最近 5 条点击记录,取最近一篇作为“种子文章”。为什么不取全部 5 篇?因为代码里 recommend_by_content 只接受一个 article_id;取全部文章做加权是更优解,但会让代码复杂度上升很多。原型阶段取最近一篇效果已足够,后续优化方向是把 5 篇文章的相似度加权平均。合并候选集时用 list(dict.fromkeys(…)) 保持顺序去重,确保内容推荐的结果排在前头。

参数说明: limit 参数让前端可以按需调整条数; strategy 字段返回 hot 或 hybrid ,方便前端打点,判断用户走的是不是冷启动策略。推荐接口要做的防御是: user_id 允许缺省,但 limit 必须限制最大值,否则有人传 100000 会把响应体撑爆。建议加一行 limit = min(limit, 50) 。

4.3 定时重算与结果缓存:不要每次请求都重建矩阵

矩阵计算是重活,尤其是 TF-IDF 构建和相似度计算,几千篇新闻全量重算需要数十秒。如果每个请求都实时算,接口延迟直接不可用。正确做法是矩阵常驻内存,定时任务周期性重建:采集脚本每次写入新新闻后触发一次增量训练,或每天凌晨跑一次全量重算。

from apscheduler.schedulers.blocking import BlockingScheduler

def scheduled_rebuild():
print("开始重建推荐矩阵…")
rebuild_similarity_matrix()
print("矩阵重建完成")

# 每 6 小时重建一次
scheduler = BlockingScheduler()
scheduler.add_job(scheduled_rebuild, "interval", hours=6)
scheduler.start()

逻辑说明: rebuild_similarity_matrix 封装前面第 3 章的矩阵构建流程,重算完把全局变量 SIM_MATRIX 、 ARTICLE_IDS 替换掉。这里的关键是矩阵重建必须原子性——先算新的,算完再赋值,不能让接口在赋值过程中读到半个矩阵。APScheduler 在这里是独立进程运行,和 Flask 并行,互不阻塞。

参数说明: interval 触发器按固定间隔执行,6 小时对于新闻平台偏长,2 到 3 小时更合适;对实验环境 6 小时也能接受,反正新闻数据不是分钟级更新的。如果不想引入 APScheduler,用操作系统的 cron 也能实现,区别是前者跨平台且能记录调度日志,后者更轻量。Linux 下用 crontab -e 写一行 0 */3 * * * cd /path/to/project && python rebuild.py 即可达到同样效果。

4.4 一个最简单的可视化界面:验证平台跑没跑通

命令行接口看不出推荐效果,我会在平台里加一个单页 HTML 做可视化验证。不引前端框架,简单到只有一张表格和两个按钮——一个显示热门新闻,一个根据用户 ID 显示推荐列表。之所以强调“最简单”,是因为这个界面的目的是让推荐效果可被观察,不是做产品。

<!– templates/index.html –>
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>新闻推荐平台</title>
</head>
<body>
<h1>新闻推荐平台</h1>
<input id="user_id" value="u_001" placeholder="输入用户ID">
<button onclick="loadRecommend()">获取推荐</button>
<div id="result"></div>
<script>
async function loadRecommend() {
const uid = document.getElementById("user_id").value;
const resp = await fetch(`/api/recommend?user_id=${uid}&limit=10`);
const data = await resp.json();
document.getElementById("result").innerText = JSON.stringify(data, null, 2);
}
</script>
</body>
</html>

逻辑说明:页面加载后默认填一个测试用户 ID,点击按钮调 /api/recommend ,把 JSON 结果直接渲染到页面上。不需要样式美化,核心目的是肉眼确认推荐结果里有没有过滤掉旧闻、有没有和热门榜明显重叠。把结果里每条新闻的标题和发布时间展示出来,比调接口看 JSON 直观得多。

这个界面也回答了“Python 爬虫可视化界面”这条需求路径——可视化不一定非要用 pyecharts 或 ECharts 画炫酷图表,对推荐平台来说,把“推荐了哪些文章”这一个信息讲清楚,就完成了验证任务。等后期要展示算法效果对比,再考虑接 echarts 折线图也不迟。

5. 常见问题与避坑:编码、反爬、冷启动和高频翻车点

5.1 解压 zip 包后中文乱码或文件报错:伪加密与编码问题

现象:从网上下载的这个 zip 包,在 Linux 上用 unzip news_platform.zip 解压后,文件名里的中文全部变成乱码;或者解压到一半提示 CRC 错误。Windows 上用自带解压工具正常,但换到 7-Zip 又提示损坏。

原因分两种。一种是 zip 包在 Windows 上压缩时用了 GBK 编码记录文件名,而 Linux 默认按 UTF-8 解码,导致中文名乱码;另一种是包体被人为设置过“伪加密”——文件名目录里把加密标志位置 1,实际数据并没有加密,解压工具检测到加密标志就直接拒绝或要求密码。

解决:Linux 下解压先用 unzip -O gbk filename.zip 指定编码(具体写法视 unzip 版本而定),用 7-Zip 的话执行 7z x filename.zip 通常能自动识别。伪加密更简单,判断依据是:压缩包有密码但你根本没设过密码,这时用 7-Zip 打开能预览文件名却解不出来,可以直接用 7z x -p123 随便填个密码尝试——很多伪加密包的“密码”只存在于目录项,数据本身是明文的,给任意密码都能解开。注意这只是修复“文件能用”层面的问题,你仍然需要自行确认压缩包内容信誉是否可靠再运行里面的代码。

5.2 爬虫被限流或返回验证码:UA、请求速度与重试

现象:爬虫刚跑起来前几百条正常,继续抓几百条之后,响应 403,或者返回的 HTML 里出现“访问过于频繁”的字样。某个新闻源单独每天抓两三次没问题,一旦缩短到分钟级就触发风控。

原因:新闻站点普遍有基础的访问频率限制,单一 UA 高频请求尤其是主要特征。requests 默认 UA 是 python-requests/x.x.x ,这个指纹太好认了,服务器看到基本直接拒绝。

解决:把请求头里的 UA 替换成常见浏览器字符串,并在两次请求之间加随机延时。更稳妥的做法是构建一个 UA 池,每次请求随机选一个;再配合 time.sleep(random.uniform(1.0, 3.0)) 控制频率。如果对方校验严格,还得升级到维护会话 Cookie 或者接代理池,但这个项目不需要——新闻 RSS 源本身对请求频率就不敏感,真正容易触发限制的是 HTML 详情页抓取,详情页每次请求间隔不低于 2 秒比较安全。

5.3 正文抽取抓到评论区或广告:选择器失效与正文长度判断

现象:某天开始,推荐算法跑出来的相似新闻里突然出现大量“广告位招租”或“网友评论精选”,一看数据库,正文内容混入了页面侧边栏。

原因:新闻站点改版。页面结构一变,之前配置的 CSS 选择器可能指向了新加的侧边栏容器,或者原本的正文容器里嵌入了推荐模块的 DOM 节点。这类改动通常发生在新闻详情页的模板升级时,没有预兆。

解决:给正文抽取加两层保险。第一层是长度校验,正文小于 200 字视为抽取失败,进入候选选择器轮流尝试;第二层是关键词黑名单, node.select(".advertisement, .recommend, .comment-list") 里的 decompose() 操作要覆盖所有已知的广告容器类名。接新站点时必须人工抽查至少 10 篇文章的抽取结果,确认没有混入非正文内容再正式采集。

5.4 新用户没有点击记录导致推荐列表全空:冷启动的处理

现象:接口返回 "items": [] ,前端页面空空如也。后台日志显示 clicks 查询结果为 0,推荐函数直接走到了空分支。

原因:基于内容的推荐需要“种子文章”,种子来自用户点击历史。新用户没有任何行为,推荐函数没有入口,自然返回空列表。这属于推荐系统教科书里典型的冷启动问题,不是代码 bug。

解决:在接口层做策略降级——用户无点击记录时返回最近 24 小时热门新闻,热门榜的排序指标用“点击量 + 新鲜度”综合分。实现上给 click_log 表预置几条默认行为也可以,但我更建议保留真实的降级逻辑,因为生产环境永远会有新用户进来,需要靠这个兜底策略撑过前几次访问。

5.5 旧闻反复出现在推荐列表:时间衰减没生效

现象:数据库里存了 30 天前的新闻,推荐结果里还能看到一周前的旧闻,用户点进去发现是“旧闻回顾”。

原因:推荐排序只看相似度,没有把发布时间纳入得分。相似度是静态属性,不会随时间变化,所以旧文章只要内容和用户历史相似,就会一直被推荐上来。

解决:在最终排序函数里加时间衰减项,半衰期设为 24 小时,超过 3 天的新闻权重降到接近零。另外在数据库查询层加一道硬过滤: WHERE published_at > datetime('now', '-3 days') ,把超过 3 天的新闻直接从候选集里剔除。两道闸门一起工作,排序项负责“让旧闻靠后”,查询条件负责“让旧闻根本没资格参与排序”。

6. 离线评测与增量更新:给推荐结果一个可信的验证方案

推荐算法最容易被质疑的一点是“效果好不好”。我习惯在平台上线前先做离线评测,把历史点击数据按时间切分——前 80% 做训练,后 20% 做测试——然后验证系统给测试用户推荐的新闻里,有多少篇是他真正点击过的。这个指标虽然离真实用户体验还差一层,但至少能让调参有据可依。

import random
from sklearn.metrics import precision_score # 实际用不到,演示计算逻辑

def evaluate_recommendation(click_log, recommend_func, k=10):
"""
click_log: [(user_id, article_id, timestamp), …]
recommend_func: (user_id, k) -> [article_id, …]
返回 precision@k
"""
# 按用户分组,每个用户保留最后一次点击做校验
user_clicks = {}
for user_id, article_id, ts in click_log:
user_clicks.setdefault(user_id, []).append((article_id, ts))

hit = 0
total = 0
for user_id, clicks in user_clicks.items():
if len(clicks) < 2:
continue
clicks.sort(key=lambda x: x[1])
last_article_id = clicks[-1][0]
# 用最后一次点击之前的数据去推荐,看推荐结果里有没有最后一次点击的这篇
recommended = recommend_func(user_id, k)
if last_article_id in recommended:
hit += 1
total += 1
return hit / max(total, 1)

逻辑说明: precision@k 的意思是推荐列表前 k 条里命中测试点击的占比。这段代码的划分方法很朴素:每个用户最后一次点击作为“标准答案”,只看推荐列表里有没有这一篇。这种评测方式低估了真实效果——用户没点击不代表不想看——但它实现最快、解释成本最低,足够用来对比不同参数下的推荐效果。

增量更新方面,我习惯固定一个节奏:每天凌晨全量重建矩阵,实时接口只读缓存。全量重建包括重新拉取新闻、重算 TF-IDF、重算相似度矩阵,整个过程控制在 10 分钟内,不阻塞在线服务。如果新闻采集频率加密到每小时一批,可以把全量重建也改成每小时执行,但单机版平台不建议低于 1 小时间隔——矩阵越大重建越慢,投入产出不划算。

最后说一个我的习惯:每次调整相似度阈值或时间衰减系数,都把旧的推荐结果导出一份 JSON 存档,再跑三天新的,人工对比差异。推荐算法里很多指标失真过,调参前后靠直觉判断是不可靠的,只有留痕对比才能积累出手感。离线评测指标再漂亮,也要拿真实用户点出的数据来验证。希望这套从采集到评测的链路能帮你在新闻推荐平台上少走弯路——数据源抓好、相似度算准、旧闻压得住,这个项目就立住了。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

赞(0)
未经允许不得转载:171主机测评 » 从爬虫到推荐:构建一个Python新闻推荐平台实战指南
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址