ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

新闻爬虫与推荐系统实战:从数据采集到个性化推荐的全流程构建

新闻爬虫与推荐系统实战:从数据采集到个性化推荐的全流程构建 简介这份以毕业论文形式呈现的资料聚焦基于Python的新闻爬取与推荐系统适合计算机及相关专业学生完成毕业设计、课程设计或技术调研时参考。文档完整覆盖选题背景、技术选型、可行性分析、系统设计到实现全过程技术栈涉及Python、Flask、MySQL前台功能包括用户登录注册、新闻搜索、分类查看、个性化推荐、词云与收藏后台包含新闻、用户、留言管理等模块。资源为doc格式共1个文件压缩包大小约3.14MB结构清晰便于直接查阅和编辑。目前已有98人学习内容兼顾方案完整性与实现细节摘要、目录、英文摘要等模块齐全可作为理解爬虫采集、个性化推荐算法和Web开发整合应用的入门参考。1. 新闻爬取推荐系统先搭一个不糊弄人的最小闭环做新闻爬取与推荐系统最大的坑不是爬虫也不是推荐算法而是两者之间的数据链路。爬虫抓下来的标题、时间、正文如果不做清洗和去重推荐模型就是在垃圾堆里找金子反过来如果推荐只依赖TF-IDF不接用户行为冷了场就全推热门新闻谁都看得出这是糊弄。这个标题要解决的是用Python把新闻列表页、详情页变成结构化数据落库后做分词和向量化再基于内容和用户行为产生推荐结果并暴露成接口。适合正在做课程设计、准备毕设或者想把爬虫、数据清洗、推荐三个技能串起来的人。2. 用requests和BeautifulSoup把新闻列表变成结构化数据一个可用的新闻爬取模块不是把HTML拿回来就结束。列表页翻页、详情页解析、请求头设置、响应编码、抓取节奏、增量去重这些细节都会直接影响后面的推荐效果。为什么第一版不用Scrapy对单站点、小批量、要快速看到结果的项目requests加BeautifulSoup更直观报错容易定位等真需要分布式和中间件再迁到Scrapy不迟。2.1 爬虫主流程列表翻页、请求头、响应编码与超时先写一个列表页抓取函数循环请求新闻站点列表页把每条新闻的标题和链接解析出来。import time import requests from bs4 import BeautifulSoup BASE_URL https://news.example.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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, } def fetch_list(page): resp requests.get(BASE_URL, params{page: page}, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text def parse_list(html): soup BeautifulSoup(html, html.parser) items [] for a in soup.select(a[href*/detail/]): title a.get_text(stripTrue) href a.get(href) if not href: continue if len(title) 8: continue if not href.startswith(http): href https://news.example.com href items.append({title: title, url: href}) return items代码里几个地方不是随手写的。params{page: page}让requests自己拼查询串比字符串拼接更安全也避免中文和特殊符号未转义。timeout10是必须的不加这个参数站点响应拖住时爬虫可能一直挂在那里。raise_for_status()会把4xx、5xx抛成异常防止把错误页当正常结果解析。resp.encoding resp.apparent_encoding能解决一部分中文乱码为什么只说一部分后面避坑章节细说。select(a[href*/detail/])是CSS选择器选中href包含/detail/的所有链接可以去掉导航、标签、相关推荐里的大量噪声。标题长度过滤是为了避开图片链接和无文本的A标签。相对地址必须补全成绝对URL否则存库后没法再请求详情页也没法做URL去重。如果要抓前5页循环里的节奏控制要明显for page in range(1, 6): html fetch_list(page) items parse_list(html) print(fpage {page}: {len(items)} 条) time.sleep(1.0) # 每页间隔至少1秒不要顶着站点打sleep(1.0)不是玄学。很多新闻站对访问频率敏感连续短时间请求几十次会触发验证页或临时拦截。个人项目抓取量本来就不大把每秒请求数控制在1到2次足够。在动手爬之前还要先确认站点robots内容个人练手尽量优先使用公开RSS或官方接口如果只能抓HTML就把抓取量控制在很低水平只保存标题和摘要字段不做全文再分发。“能爬”不代表“该爬”这个边界要自己心里有数。2.2 详情页解析正文、发布时间和来源字段有了列表里的URL下一步抓详情页。不同站点详情页结构差异最大最稳妥的策略不是写一个超级复杂的CSS表达式而是先定位正文容器再提取段落文本。session requests.Session() session.headers.update(HEADERS) def fetch_detail(session, url): resp session.get(url, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, html.parser) title_node soup.select_one(h1.article-title) time_node soup.select_one(time.article-time) source_node soup.select_one(span.source-name, a.source-name) content_node soup.select_one(div.article-content) if content_node is None: return None paragraphs content_node.select(p) content \n.join(p.get_text(stripTrue) for p in paragraphs) if len(content) 50: return None return { title: title_node.get_text(stripTrue) if title_node else , time_raw: time_node.get_text(stripTrue) if time_node else , source: source_node.get_text(stripTrue) if source_node else , content: content[:8000], }这里使用同一个requests.Session对象可以复用连接和Cookies比每次requests.get多省一些握手开销。div.article-content里的p通常是正文段落视频新闻或图文新闻的正文可能不完整所以加了长度过滤低于50字大概率是空壳页面。time.article-time是HTML5语义标签比span.article-time更规范有的站点用pubDate、date之类class抓不到就打开浏览器开发者工具重新看DOM不要死磕一个选择器。发布时间格式五花八门直接存原始字符串推荐结果里“时间最近”这个条件就失效。我习惯把常见中文时间格式统一转换。from datetime import datetime, timedelta import re def normalize_time(raw): if not raw: return None raw raw.strip() now datetime.now() m re.search(r今天\s*(\d{1,2}):(\d{2}), raw) if m: return now.replace(hourint(m.group(1)), minuteint(m.group(2)), second0, microsecond0) m re.search(r昨天\s*(\d{1,2}):(\d{2}), raw) if m: day now - timedelta(days1) return day.replace(hourint(m.group(1)), minuteint(m.group(2)), second0, microsecond0) m re.search(r(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})[日]?\s*(\d{1,2}):(\d{2}), raw) if m: year, month, day, hour, minute map(int, m.groups()) try: return datetime(year, month, day, hour, minute) except ValueError: return None return None这个函数只处理“今天、昨天、完整时间”三种情况。它有个边界如果站点给的是2024-01-02T10:30:00Z这种ISO带时区格式需要单独处理。为什么不直接用dateparser对单个站点来说写一个覆盖目标站点所有格式的小函数比引入一个猜测型依赖更可控推荐系统只需要时间能排序、能统一存储不需要处理全世界所有日历格式。2.3 增量去重用URL哈希和标题双重校验重复抓取是新闻爬虫最隐蔽的问题。列表页可能同一页出现多条重复链接也可能上次抓完下次列表又展示了旧新闻。如果不做去重后面推荐模型会反复推荐同一篇。import hashlib import sqlite3 def article_hash(url): clean_url url.split(?)[0].split(#)[0] return hashlib.sha256(clean_url.encode(utf-8)).hexdigest()[:16] def save_if_new(conn, article): url_hash article_hash(article[url]) cursor conn.execute( SELECT id FROM news WHERE url_hash ? OR title ?, (url_hash, article[title]), ) if cursor.fetchone(): return False conn.execute( INSERT INTO news(url_hash, title, content, source, published_at) VALUES (?,?,?,?,?), (url_hash, article[title], article[content], article.get(source, ), article.get(published_at)), ) conn.commit() return True去重先走URL哈希再用精确标题兜底。URL处理时把查询参数和锚点去掉因为站点的跟踪参数、时间戳会造成同一正文被不同链接抓到。标题兜底用于同一篇文章被不同URL转载的情况但只对完全相同的标题有效标题加了“独家”就会失效。如果要求更高可以用正文前200字的MD5做第三重校验代价是入库前要多算一次摘要哈希。列表和详情抓取都可能有瞬时失败连接超时、DNS错误、SSL证书异常都会让整段循环中断。更健壮的做法是单条抓取放到try/except里失败后记录日志继续跑让爬虫具备断点续抓能力避免断了一次就要重跑全量。爬虫模块把列表、详情、去重三个环节串起来已经具备把一批新闻变成干净数据的能力。但干净数据只是地基要让推荐系统有输入下一步必须做分词和向量化。3. 新闻数据落库清洗、存储与素材加工3.1 用SQLite做第一版存储表结构与索引很多教程一上来就上MySQL但做毕设和本地项目SQLite完全够用。新闻数据每天几百条SQLite单文件、零运维、不需要额外服务还能用标准SQL查询等并发真上来了再迁MySQL这套表结构可以复用。CREATE TABLE IF NOT EXISTS news ( id INTEGER PRIMARY KEY AUTOINCREMENT, url_hash TEXT UNIQUE, title TEXT, content TEXT, source TEXT, category TEXT, published_at TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_news_category ON news(category); CREATE INDEX IF NOT EXISTS idx_news_published_at ON news(published_at);url_hash上的唯一约束是入库去重的地基。published_at用TEXT存格式化时间而不是整数时间戳SQL查询时可读排序只要格式统一也没有问题。created_at直接用SQLite默认时间后面做数据量分析和冷启动统计会用到。用户行为表是推荐系统不可缺少的部分没有用户行为推荐就退化成热门榜单。我一般建两张表CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL ); CREATE TABLE IF NOT EXISTS user_behavior ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, news_id INTEGER NOT NULL, behavior TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, news_id, behavior) );behavior字段存view、collect、like三类。UNIQUE(user_id, news_id, behavior)防止用户对同一篇新闻重复点赞造成重复行为。不要给行为表加太多字段先保核心后续想扩展可以加stay_seconds用来判断用户是否真的看完了。SQLite在事务和并发上不如MySQL但单机项目足够。可以用.backup命令做冷备份新闻文本压缩率高放到网盘就能迁移。后面想接数据分析pandas.read_sql_query直接读SQLite比连MySQL还省事。3.2 jieba分词和停用词中文新闻推荐的地基推荐系统不能直接拿中文字符串去计算相似度。中文没有天然空格必须分词。jieba是中文项目最常用的分词库对新闻语料基本够用。import jieba STOP_WORDS set() with open(stopwords.txt, encodingutf-8) as f: STOP_WORDS {line.strip() for line in f if line.strip() and not line.startswith(#)} def tokenize(text): return [ word for word in jieba.cut(text) if word.strip() and word not in STOP_WORDS and len(word) 1 ]停用词表不能直接拿网上下载的一份通用表就完事。新闻语料里“记者”“报道”“编辑”“来源”这些词本身没有推荐价值但通用停用词表不一定收录。适合的做法是跑完第一批语料后把词频Top200里和主题无关的词加进去。分词时过滤单字词能明显减少噪声单字的“的、了、在”靠停用词表也能清但有些单字在特定领域有意义比如财经和地产里的“股”“房”业务上要不要保留得看推荐场景。我这里用len(word) 1一刀切是为了向量化特征更干净。如果希望切词更贴合项目可以在程序启动时加载自定义词典例如jieba.load_userdict(domain_dict.txt)文件每行一个词可以带词频和词性。比如写一行苹果公司 100 n分词时“苹果公司”就不会被拆成“苹果”和“公司”。对于“新能源汽车”“Mate60Pro”这类品牌词自定义词典几乎是最有效的补救手段。3.3 TF-IDF向量化与相似度计算第一版推荐模型分词后的正文要变成可计算的形式最常见就是TF-IDF向量化加余弦相似度。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity tfidf TfidfVectorizer( tokenizertokenize, token_patternNone, min_df1, max_df0.8, ngram_range(1, 2), ) matrix tfidf.fit_transform(news_texts) # news_texts [标题 正文, ...]按news.id排序 sim_matrix cosine_similarity(matrix)token_patternNone必须设置。sklearn的TfidfVectorizer默认token_pattern按英文词划分对中文默认会按字符切结果完全不可用。ngram_range(1, 2)可以把“新能源汽车”这类短语纳入特征提升同主题新闻的相似度。max_df0.8过滤掉出现在80%以上文本中的高频词避免“新闻”“中国”这类词主导向量。cosine_similarity(matrix)得到N篇新闻之间的相似度矩阵全量计算是N平方量级几千条没问题到几万条后内存会吃紧。到时候可以改用NearestNeighbors做近似搜索或者分批计算而不是一次性生成完整矩阵。TF-IDF矩阵不需要每次推荐都重算。常见做法是每天凌晨把新增新闻加入语料重算全量矩阵几千篇文档两秒左右算完。重算时要注意news_texts列表的顺序必须和news表主键id顺序一致最好同时保存一份id_map列表否则相似度矩阵的下标对不回数据库主键。4. 推荐引擎设计从相似度打分到行为加权4.1 基于内容推荐用户看过哪篇就把相似新闻排前面新闻推荐常见有两派做法基于内容推荐和协同过滤。基于内容推荐不依赖大量用户行为用户刚点开一篇就能根据文本相似度推荐同主题新闻冷启动相对好处理协同过滤需要用户行为矩阵行为少的时候矩阵太稀疏算出来的结果还不如热门榜。所以第一版我用内容推荐为主等行为数据积累到一定量再叠加一路协同过滤做加权融合。基于内容推荐的核心是找出用户最近看过的新闻然后看这些新闻在向量空间里的近邻近邻就是候选。def topk_similar(news_id, sim_matrix, k30): sims sim_matrix[news_id] top_ids sims.argsort()[::-1] result [] for i in top_ids: if i news_id or sims[i] 0: continue result.append((int(i), float(sims[i]))) if len(result) k: break return resultargsort()[::-1]得到从大到小的下标排除自身再取前K个。相似度阈值0是为了防御未来把向量换成Embedding后可能出现负分数的情况。K一般取20到50太小候选不够太大会掺入很多低相关的长尾新闻。4.2 行为加权浏览、收藏、点赞不应该等权用户点开新闻不代表喜欢可能只是标题吸引人但收藏和点赞一定是主动反馈。给所有行为相同权重会造成标题党反复被推上来。常见做法是给不同行为不同权重再做时间衰减。BEHAVIOR_WEIGHT { view: 1.0, collect: 3.0, like: 5.0, } def time_decay(days): return 0.9 ** max(days, 0)权重数字不是拍脑袋。收藏和点赞的置信度远高于一次普通浏览。time_decay按天衰减10天前的浏览只贡献约0.35的权重让推荐更能跟随兴趣变化。调参时可以在验证集上试几组0.85到0.95的底数看最终点击表现不必追求理论最优。用户行为日志的写入要简洁在项目入口处记一下即可def log_behavior(conn, user_id, news_id, behavior): conn.execute( INSERT OR IGNORE INTO user_behavior(user_id, news_id, behavior) VALUES (?,?,?), (user_id, news_id, behavior), ) conn.commit()INSERT OR IGNORE配合UNIQUE(user_id, news_id, behavior)可以保证重复点击不会越攒越多。这个日志同时是推荐模型的输入数据脏了后面所有动作都偏。4.3 推荐输出排除已读、合并内容分与行为分把内容和行为权重合并的推荐函数逻辑可以写成这样def fetch_viewed_ids(conn, user_id): rows conn.execute( SELECT DISTINCT news_id FROM user_behavior WHERE user_id? AND behaviorview, (user_id,), ).fetchall() return {row[0] for row in rows} def generate_recommendations(conn, user_id, topn10): viewed fetch_viewed_ids(conn, user_id) if not viewed: return fetch_hot_news(conn, topn) score {} source_map {} for news_id in viewed: behavior conn.execute( SELECT behavior FROM user_behavior WHERE user_id? AND news_id?, (user_id, news_id), ).fetchone() weight BEHAVIOR_WEIGHT.get(behavior[0] if behavior else view, 1.0) days 0 # 实际应从created_at计算这里省略 weight weight * time_decay(days) similar_items topk_similar(news_id, sim_matrix, k30) for cand_id, sim in similar_items: if cand_id in viewed: continue score[cand_id] score.get(cand_id, 0) sim * weight source_map.setdefault(cand_id, []).append(news_id) ranked sorted(score.items(), keylambda x: x[1], reverseTrue) return [(nid, round(score, 4), source_map.get(nid, [])) for nid, _ in ranked[:topn]]这里的关键在于用户看过10篇新闻每篇展开30个近邻候选会重叠出现累积分越高的排越前。排除viewed是为了不把用户刚看过的新闻推回去。冷启动用户没有行为记录直接返回热门榜比强行用空模型推荐更不容易翻车。举个例子用户看过《公司发布新款车型》和《智能座舱体验评测》两篇新闻的Top10里都包含《公司发布电动车平台》那么《公司发布电动车平台》得到两道相似分的累加排在最前面而另一篇《公司财报解读》只被第一路用低相似度带出来排序靠后。这种“多路累积”会让推荐结果更集中也更好解释。如果用户量过千实时遍历全部行为做候选举会慢。常见做法是每天凌晨把每个用户的前100条推荐结果预计算到recommend_cache表接口直接查缓存。预计算表字段就是user_id, news_id, score, source_ids推荐接口读出来直接返回。这样响应时间能控制在几十毫秒后续也能扛住更大查询量。5. 新闻爬取与推荐里的5条避坑记录从乱码到冷启动5.1 反爬误伤请求频率和User-Agent之间怎么平衡现象列表页抓前3页正常第4页开始返回验证页面或空列表有的站点不直接拦截而是开始返回403状态码。原因站点有基于IP和会话的访问频率限制短时间密集请求触发了风控只用一个User-Agent容易被识别为脚本客户端缺少浏览器常见请求头也让特征更明显。解决先确认是不是反爬拦截把resp.status_code和resp.text[:80]写进日志问题立刻能定位。然后调整两点一是把请求间隔从0.1秒提升到1秒以上并用time.sleep(random.uniform(1.2, 2.3))制造不规律节奏二是准备一个5到10个浏览器UA的轮换列表。对个人项目更好的策略是优先使用站点提供的RSS或官方接口公开页面抓取频率控制在极低水平。不要因为遇到拦截就尝试更激进的手段新闻数据时效性强今天爬不到明天再爬仍然有意义。5.2 编码乱码apparent_encoding不一定可靠现象resp.apparent_encoding对部分页面返回gb2312页面实际是GBK生僻字变成“锟斤拷”有的页面Meta声明UTF-8响应头却给charsetgb2312强制UTF-8时乱码。原因apparent_encoding依赖内容猜测页面较长时可能猜错响应头、Meta声明、实际字符集三者不一致是新闻老站点的常态。解决打开浏览器开发者工具看Network响应头确定真实编码然后按优先级写一个函数先用响应头charset再用Meta charset两者都没有或冲突时才用apparent_encoding。最后对正文抽样检查是否包含\ufffd替换字符。更省事的是如果站点统一UTF-8直接resp.encoding utf-8不要每次都猜。乱码问题不能靠肉眼觉得“好像正常”就放过它会污染整个语料库推荐出来的东西也会莫名跑偏。5.3 正文提取翻车段落、图片说明和推广卡片混在一起现象正文解析出来是一长串没有段落的文本或者夹杂着编辑头像、图片说明、推广卡片有的页面正文分布在多个div.g-content节点只取p会丢一半内容。原因新闻发布系统的模板不统一正文容器不一定叫article-content子节点也不一定都是p。不同栏目可能使用不同模板这也是同一站点不同页面解析结果不一致的根源。解决写解析器前先用浏览器开发者工具检查正文容器的直接子节点。我一般写一个extract_text_block函数遍历容器子节点只提取p, div, h2, h3等标签跳过script, aside, .ad和figure最后把连续空白压缩成单个空格。图片说明用figure标签排除避免每张配图说明都进正文推广卡片通常有ad相关class用属性过滤掉。这个小函数比任何通用正文抽取库都可控。5.4 分词不准导致推荐跑偏停用词和n-gram要一起调现象推荐结果里经常出现两篇只是都含“中国”和“发展”的新闻主题完全不同却被排在一起“iPhone”被切成“iphone”“Mate60”被切成“Mate”和“60”。原因默认词典缺少新闻领域词和品牌词分词碎片化停用词表没有过滤新闻语料里大量出现的通用词还有可能是标题和正文拼接后标题关键词被高频正文词稀释了。解决项目里维护一份自定义词典像“新能源汽车”“Mate60Pro”“苹果公司”这类特征词在启动时jieba.add_word或load_userdict注入停用词表从自己语料的词频统计中迭代不要只靠第三方通用表。分词完成后抽几个样本打印token序列先肉眼确认切分结果再进TF-IDF。别等推荐结果差了才回头查分词这一步是后面所有相似度计算的表不准半毛钱不能省。5.5 冷启动与数据稀疏没有行为就没有个性化现象新用户打开推荐页返回的列表和未登录首页一样全是热门内容新入库新闻浏览量低几乎不会进入候选老新闻越推越靠前形成马太效应。原因基于内容的推荐需要用户行为做锚点行为表为空就生不成个性化结果相似度矩阵只考虑文本不考虑时间老新闻靠相似度一直霸榜。解决冷启动阶段用热度排序兜底可以是hot_score 0.5*log(view_count1) 0.3*time_score 0.2*source_rank保证新用户有内容可看。新入库新闻没有浏览量给最近24小时内入库的新闻一个时间加成。用户行为记录超过3条后再切到内容推荐新闻库低于500篇时先别急着上模型把热门榜做扎实。文本推荐的基础是数据量数据不够时算法调不出花来。6. 让推荐结果可解释打分溯源与离线验证线上推荐系统最容易挨骂的就是黑匣子用户不知道为什么看到这条新闻开发者也说不清推荐是怎么算出来的。我习惯让推荐接口返回带理由的结构化数据除了news_id和score还要带来源锚点列表列出这个候选是被用户看过的哪几篇新闻推荐出来的以及对应相似度。{ news_id: 1024, score: 2.153, 推荐理由: [ {source_news_id: 985, source_title: 新能源汽车市场再迎变局, similarity: 0.62}, {source_news_id: 1001, source_title: 动力电池新规解读, similarity: 0.48} ] }这条数据既给前端展示也给开发排查。如果推荐结果里出现莫名其妙的内容翻source_ids就能定位是哪篇锚点带出来的再去看那篇新闻的正文和分词结果问题往往出在停用词、自定义词典或分类字段上。离线验证也不用等系统完全上线。最省事的做法是准备一批用户历史浏览序列按时间切成前70%和后30%用前段做推荐看后段新闻有多少命中Top10命中率能判断算法是否比随机榜强。更好一点的方法是把用户最近3条浏览作为输入人工给Top10推荐结果标注“相关/不相关”统计相关度达到七成左右再考虑部署。每次改完分词参数或行为权重就用同样的验证集重跑一遍。我现在的习惯是每次调完一个模块先找出5篇新闻的相似度Top10肉眼看一遍结果不只看准确率数字。文本推荐这个方向数据干净比算法花哨重要得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表