简介这是一份面向爬虫与搜索引擎入门学习者的实战项目资源围绕百度新闻场景构建可运行的搜索系统。项目从百家号文章页出发通过请求头伪装规避反爬并借助已爬标题与URL集合避免重复抓取逐步扩展待爬队列完整记录标题、链接、正文与下级URL。数据处理环节在DataFrame中完成子链接筛选与反向归属分析构建节点连通图同时用TF-IDF做文本相似度去重删除重复度超过0.9的内容并尝试TextRank4Sentence与知乎TextSummary进行摘要提取最后建立倒排索引支撑检索。资源包共8个文件以3个Python脚本为核心辅以CSV数据、停用词表、说明文档及示意图整体约298KB结构紧凑便于直接运行与二次修改。目前已有547人学习适合希望打通爬虫、去重、摘要与索引全流程的读者参考。1. 从一篇百家号文章开始这个爬虫项目到底能搜出什么假设你在百度百家号上随手点开一篇文章它的 URL 长这样http://baijiahao.baidu.com/s?id1685579912006379153。现在把这条链接丢给一个脚本它会顺着页面里的下级链接一路爬下去把每篇文章的标题、链接、正文全部存下来再对这些文本做去重、摘要和倒排索引——最后你输入一个关键词它能把相关新闻按相关度排好返回给你。这就是「基于爬虫的百度新闻搜索引擎」干的事。它适合两类人一类是想搞明白搜索引擎底层链路爬取→清洗→建索引→检索的学生或转行者另一类是手里有垂直站点、想快速搭一个站内搜索原型的开发者。项目不大但麻雀虽小五脏俱全从spider.py到ZAIYAO.py再到main_t.py每个文件都对应搜索链路里一个真实环节。下面我按「能干什么→怎么跑→怎么改」的顺序把这个包拆开讲一遍。2. 项目结构与技术选型六个文件撑起一条搜索链路2.1 目录里每个文件负责什么拿到压缩包解压后根目录下能看到这些文件文件/目录作用spider.py爬虫主程序负责请求、解析、去重、落盘ZAIYAO.py摘要提取模块基于 TextRank 做句子级摘要main_t.py检索入口加载索引并响应查询PageRanks.csv页面权重数据用于结果排序cn_stopwords.txt中文停用词表分词和 TF-IDF 都要用title.png/0.jpg演示截图或资源图README.md运行说明这个划分很清晰爬取、处理、检索三层分离。spider.py只管把数据拿回来ZAIYAO.py只管从正文里抽摘要main_t.py负责把索引读进来做查询。你二次开发时想换爬取源只动spider.py想换摘要算法只动ZAIYAO.py互不干扰。2.2 技术栈为什么是 requests jieba TextRank项目没有用 Scrapy 这类重型框架而是走「requests BeautifulSoup pandas」的轻量路线。常见做法是请求用requests配自定义请求头解析用BeautifulSoup或lxml中间数据用pandas.DataFrame承载因为后面去重、建图、建索引都要按列操作DataFrame 比纯字典方便得多。中文处理这块分词用jieba去重和检索用 TF-IDF摘要用textrank4zh里的TextRank4Sentence。作者在正文里也提到「尝试使用 textrank4zh 库中的 TextRank4Sentence 进行摘要提取以及知乎开源的 TextSummary 类」说明摘要环节做过方案对比最后落在 TextRank 系。停用词表cn_stopwords.txt是标配分词后过滤掉「的、了、在」这类词TF-IDF 的权重才有意义。提示如果你本地没有textrank4zh摘要模块会直接报 ImportError。装依赖时别只装 requests 和 pandas把jieba、textrank4zh、beautifulsoup4、lxml一起装上。2.3 数据流一条 URL 进来一条索引出去整条链路可以拆成五步从种子 URL 出发请求页面解析出标题、正文、下级 URL用「已爬标题集合 已爬 URL 集合」做去重防止重复爬取把新发现的 URL 放进待爬集合循环直到队列空或达到上限对爬下来的正文做 TF-IDF 相似度去重删掉重复度大于 0.9 的文本在 DataFrame 里根据子链接关系构建节点连通图再建倒排索引供main_t.py查询。这五步里第 2 步和第 4 步是两套不同的去重前者防「同一个 URL 被爬两次」后者防「不同 URL 但内容几乎一样」。很多人只做前者结果索引里塞了一堆转载稿检索出来全是重复结果这是血泪经验。3. 爬虫核心实现请求头、去重集合与 DataFrame 落盘3.1 请求头与反爬别让第一个请求就被拦spider.py开头第一件事就是设置请求头。百度系页面在没有 User-Agent 的情况下大概率返回验证页或 403所以这一步不能省import requests from bs4 import BeautifulSoup 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(url, timeout10): try: resp requests.get(url, headersHEADERS, timeouttimeout) resp.encoding resp.apparent_encoding # 防止中文乱码 if resp.status_code 200: return resp.text except requests.RequestException as e: print(f[fetch error] {url} - {e}) return None这里有两个参数值得说timeout10是防止某个页面卡死拖垮整个爬取循环resp.apparent_encoding是让 requests 自己猜编码百家号页面有时声明 UTF-8 实际是 GBK不设这行正文会变乱码。请求头里的Accept-Language不是必须但加上更接近真实浏览器。3.2 去重集合已爬标题 已爬 URL 双保险爬虫最怕两件事重复爬同一个页面、爬进死循环。项目用两个集合解决crawled_titles set() # 已爬取标题集合 crawled_urls set() # 已爬取 url 集合 pending_urls set() # 待爬取集合 def is_duplicate(title, url): if url in crawled_urls: return True if title and title in crawled_titles: return True return False def mark_crawled(title, url): crawled_urls.add(url) if title: crawled_titles.add(title)为什么标题也要去重因为同一篇新闻可能挂在多个 URL 下带不同参数URL 不同但标题一样。只按 URL 去重会漏掉这类重复。pending_urls用 set 而不是 list是因为 set 的in操作是 O(1)待爬队列几千条时差别很明显。3.3 用 DataFrame 记录标题、链接、正文和下级 URL每爬完一个页面就把结构化数据追加到 DataFrame 里。正文里明确写了「记录爬取网页的标题、链接、正文内容用于文本摘要及检索、下级 urls」对应四列import pandas as pd records [] def save_page(title, url, content, sub_urls): records.append({ title: title, url: url, content: content, sub_urls: sub_urls, # list后续建图用 }) # 爬取循环结束后统一落盘 df pd.DataFrame(records) df.to_csv(pages.csv, indexFalse, encodingutf-8-sig)sub_urls存成 list 而不是字符串是为了后面建连通图时能直接遍历。落盘用utf-8-sig是为了 Excel 打开不乱码如果你只用 pandas 读用utf-8也行。这一步是整个项目的「数据底座」后面去重、摘要、索引全从这张表出发。3.4 爬取主循环BFS 还是 DFS项目用的是广度优先BFS思路从种子 URL 开始把当前页面的所有下级 URL 加进待爬集合再逐个处理。BFS 的好处是能优先覆盖「离种子近」的页面对新闻站点来说同一栏目下的文章相关性更高先爬完这一层再往外扩索引质量更可控。def crawl(seed_url, max_pages200): pending_urls.add(seed_url) count 0 while pending_urls and count max_pages: url pending_urls.pop() html fetch(url) if not html: continue title, content, sub_urls parse_page(html) if is_duplicate(title, url): continue mark_crawled(title, url) save_page(title, url, content, sub_urls) for sub in sub_urls: if sub not in crawled_urls: pending_urls.add(sub) count 1max_pages是必须设的否则顺着链接能爬到天荒地老。parse_page需要你自己按目标站点的 DOM 结构写百家号正文一般在div的特定 class 下用 BeautifulSoup 的select定位即可。注意pending_urls.pop()是随机弹出一个如果你想要严格 BFS换成pop(0)或collections.deque。4. 文本处理与索引构建TF-IDF 去重、连通图与倒排表4.1 TF-IDF 相似度去重删掉重复度大于 0.9 的文本爬下来的文章里转载、洗稿、同一事件的多篇报道内容高度相似。项目用 TF-IDF 向量算余弦相似度把大于 0.9 的删掉。正文里也提到「这里的操作是删除所有实际上应该保留一篇」——这是个已知的粗糙点二次开发时可以改成保留第一篇。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def dedup_by_tfidf(texts, threshold0.9): vectorizer TfidfVectorizer(stop_wordsload_stopwords(cn_stopwords.txt)) tfidf_matrix vectorizer.fit_transform(texts) sim cosine_similarity(tfidf_matrix) keep [] dropped set() for i in range(len(texts)): if i in dropped: continue keep.append(i) for j in range(i 1, len(texts)): if sim[i][j] threshold: dropped.add(j) return keepload_stopwords读的就是cn_stopwords.txt每行一个词。threshold0.9是经验值调低到 0.8 会误删同主题但不同角度的文章调高到 0.95 又删不干净。cosine_similarity返回的是 N×N 矩阵文章上千篇时内存会吃紧常见做法是分批算或改用 MinHash。4.2 连通图构建从子链接关系反推节点连接正文里描述了两步操作一步筛选子链接只保留在当前已爬文件里有的二步根据一条 URL 反向搜索它出现在哪些 URL 的子链接中从而构建节点连通图。这本质是在建一张有向图节点是文章边是「A 页面链接到 B 页面」。def build_graph(df): url_set set(df[url]) graph {u: [] for u in url_set} for _, row in df.iterrows(): for sub in row[sub_urls]: if sub in url_set: # 只保留已爬到的子链接 graph[row[url]].append(sub) return graph def reverse_lookup(graph, target): # 反向搜索哪些页面链接到了 target return [u for u, subs in graph.items() if target in subs]url_set那层过滤很关键子链接里大量是指向站外或未爬取的页面不过滤的话图里全是悬空节点。reverse_lookup返回的是入链页面列表这个信息可以喂给 PageRank 算权重——项目里的PageRanks.csv大概率就是这么来的。有了权重检索排序时就能把「被很多页面链接的文章」排前面。4.3 摘要提取TextRank4Sentence 怎么用ZAIYAO.py负责从正文里抽摘要。项目用的是textrank4zh的TextRank4Sentence它对句子建图用 PageRank 迭代算每句的重要性取 top-N 句拼成摘要from textrank4zh import TextRank4Sentence def extract_summary(text, num_sentences3): tr4s TextRank4Sentence() tr4s.analyze(texttext, lowerTrue, sourceall_filters) sentences tr4s.get_key_sentences(numnum_sentences) return .join([s.sentence for s in sentences])lowerTrue对中文没影响但保留着不碍事。sourceall_filters表示用全部过滤规则包括停用词和词性过滤。num_sentences3是摘要句数新闻类文章 3 句基本能覆盖核心信息。注意 TextRank 对长文本效果更好正文太短比如只有标题时抽出来的摘要会很奇怪调用前最好判一下长度。4.4 倒排索引从「文档→词」翻转为「词→文档」正文最后提到「建立倒排索引其实就是对所有文章……」虽然话没说完但倒排索引的逻辑是固定的对每篇文章分词记录每个词出现在哪些文档里查询时直接按词查文档列表。from collections import defaultdict import jieba def build_inverted_index(df): index defaultdict(set) for doc_id, row in df.iterrows(): words jieba.lcut(row[content]) words [w for w in words if w not in stopwords and len(w) 1] for w in words: index[w].add(doc_id) return index def search(query, index, df, top_k10): q_words [w for w in jieba.lcut(query) if w not in stopwords] scores defaultdict(float) for w in q_words: for doc_id in index.get(w, []): scores[doc_id] 1.0 / len(q_words) ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [(df.loc[i, title], df.loc[i, url]) for i, _ in ranked[:top_k]]index用defaultdict(set)set 自动去重同一个词在同一篇文档里出现多次只记一次。search里的打分是简化版命中词越多分越高实际项目里应该乘上 TF-IDF 权重和 PageRank 权重。len(w) 1过滤掉单字中文单字噪声太大。这套索引结构简单但够用几千篇文档查询是毫秒级。5. 避坑与常见问题跑不起来时先查这几条5.1 请求返回 403 或验证页现象fetch拿回来的 HTML 里没有正文全是「安全验证」字样。原因请求头太简单或者请求频率太高被限流。解决补全User-Agent、Referer、Accept等头并在循环里加time.sleep(1)到time.sleep(3)的随机延迟。别用固定间隔固定间隔反而容易被识别。5.2 中文正文乱码现象存下来的content列全是\xe7\x99\xbe这种转义。原因requests 默认按 HTTP 头里的 charset 解码但部分页面声明与实际不符。解决在fetch里加resp.encoding resp.apparent_encoding或者手动指定resp.encoding utf-8。落盘时用encodingutf-8-sig。5.3 TF-IDF 去重把好文章也删了现象去重后剩下的文章数量远少于预期且主题单一。原因threshold0.9对短文本太敏感两篇不同角度的短新闻 TF-IDF 相似度也可能超过 0.9。解决对短于 200 字的文本跳过去重或者把阈值提到 0.95再或者改用「标题相似度 正文相似度」双条件判断。5.4 倒排索引查询无结果现象search返回空列表但索引里明明有数据。原因查询词分词后全被停用词过滤了或者索引里的词和查询词大小写/全半角不一致。解决查询前对 query 做同样的分词和停用词过滤并统一做strip()和全角转半角处理。调试时先打印q_words看看还剩什么。5.5 爬取循环停不下来现象脚本跑了半小时还在爬pending_urls不见减少。原因新闻站点的「相关推荐」「热门标签」会不断产生新链接形成近似无限图。解决设max_pages上限同时对 URL 做域名过滤只保留目标域名下的链接站外链接直接丢弃。6. 二次开发换数据源、加权重、把检索做成接口这个项目最值得改的地方有三个爬取源、排序权重、检索入口。下面按改动成本从低到高说。换爬取源是最容易上手的。spider.py里的parse_page是唯一和站点 DOM 绑定的函数你只要改这个函数里的选择器就能把种子从百家号换成任意新闻站。比如目标站正文在article标签里就写soup.select_one(article).get_text()。改完先跑 10 个页面确认标题、正文、子链接三列都有值再放开max_pages。加排序权重是提升检索质量的关键。现在的search只按命中词数打分你可以把PageRanks.csv读进来给每个 doc_id 乘一个权重系数def load_pageranks(pathPageRanks.csv): pr pd.read_csv(path) return dict(zip(pr[url], pr[score])) def search_weighted(query, index, df, pagerank, top_k10): q_words [w for w in jieba.lcut(query) if w not in stopwords] scores defaultdict(float) for w in q_words: for doc_id in index.get(w, []): url df.loc[doc_id, url] scores[doc_id] (1.0 / len(q_words)) * pagerank.get(url, 1.0) ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [(df.loc[i, title], df.loc[i, url]) for i, _ in ranked[:top_k]]pagerank.get(url, 1.0)里的默认值 1.0 是给没有权重数据的页面兜底避免 KeyError。权重系数怎么定我一般先用 PageRank 原始值归一化到 0.5 到 2.0 之间再乘上去避免权重过大盖过文本相关性。把检索做成接口是最后一步。main_t.py现在大概率是命令行交互你可以用 Flask 包一层from flask import Flask, request, jsonify app Flask(__name__) index build_inverted_index(df) pagerank load_pageranks() app.route(/search) def api_search(): q request.args.get(q, ) results search_weighted(q, index, df, pagerank) return jsonify([{title: t, url: u} for t, u in results]) if __name__ __main__: app.run(port5000)这样前端或其他服务就能通过GET /search?q关键词拿到 JSON 结果。注意索引构建放在启动时做一次别每次请求都重建否则查询延迟会从毫秒级掉到秒级。验证改动是否有效我习惯用一组固定查询词跑回归改之前先记下 top-10 结果改之后再跑一遍对比排序变化是否符合预期。如果加了 PageRank 权重后权威页面的排名反而下降说明权重系数或归一化方式有问题回退再调。从那以后我每次改检索逻辑都强制走一遍「固定查询词 改动前后 top-10 对比」的流程不靠感觉判断效果。希望这个拆解能帮你把这份源码跑起来也帮你找到自己想改的那个点。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?