简介:一套基于Scrapy、ElasticSearch和Django的小型全文搜索引擎毕业设计项目方案,适合计算机专业毕设、课设以及想掌握爬虫与搜索联动开发的初学者。内容覆盖需求分析、技术选型、爬虫抓取策略、索引映射设计、Django后端路由与视图等关键环节,可辅助快速搭建可运行的搜索原型,理解从网页抓取到索引检索再到前端展示的完整链路。包内共2000个文件,以Python脚本(1605个py)为主,配合HTML页面、JavaScript/CSS前端样式、C头文件及少量配置文档,压缩包约88.15MB,结构清晰便于按模块查阅,目前已有136人学习下载。对于需要系统梳理搜索引擎类毕设方案或动手复现全流程的学生,是一份实用且完整的参考资料。
1. 基于Scrapy+ElasticSearch+Django的小型全文搜索引擎:毕设这么做才不翻车
毕业设计选“基于Scrapy+ElasticSearch+Django的小型全文搜索引擎”这个题,通常意味着你要用Scrapy把某个网站的数据抓下来,清洗后送进ElasticSearch,再通过Django把搜索结果渲染成网页。方向本身不冷门,但很多人做到一半才发现难点不在爬虫,而在三个组件怎么衔接顺畅、如何让中文搜索出得了结果。这篇文章按我自己的落地顺序讲:先给爬虫定数据格式,再定ES索引结构,最后把Django的搜索页接上,并给出完整避坑清单。适合有Python基础、但没完整跑过搜索型项目的同学;不是教你怎么写论文,而是照着做能通、能演示、能答辩的一套最小可行方案。
2. 先把数据喂进来:Scrapy爬虫的选型与最小可运行项目
2.1 为什么是Scrapy而不是requests+BeautifulSoup:三个理由
做小型全文搜索引擎,很多人第一反应是requests + BeautifulSoup,写起来轻快,但等你要爬几百上千个页面时就难受了。Scrapy的优势在于调度、去重、并发、重试、日志这些能力是自带的,天然就是一个“能上量的骨架”。换requests方案,每一个并发、去重、失败重试的细节都要自己实现,代码量至少翻一倍。另一个更关键的点:Scrapy的Item是结构化的,写好的item怎么清洗、怎么落库、怎么推给ElasticSearch,都能在pipeline里按顺序处理。后面接Django或ES时,你手里是一份确定字段的字典,而不是一串散落的选择器代码。
第三个理由和毕业设计答辩相关。Scrapy项目的目录结构天然就是“扩展点清晰”的:spiders放采集逻辑,pipelines放清洗与存储,middlewares放下载中间件,settings做全局配置。评委问“你的系统架构是什么”,你直接按这个目录讲就行。requests方案往往是脚本式组织,边界模糊,讲不清楚。
2.2 一个能跑的爬虫骨架:settings、items、pipeline怎么写
先用scrapy startproject建立一个项目,目录结构大致是这样:
search_engine/ scrapy.cfg search_engine/ settings.py items.py pipelines.py spiders/ news_spider.pyitems.py里先定义你的数据模型。这里建议为搜索场景直接设计字段,而不是什么都抓。我一般会先限定四五个核心字段,后续加字段是便宜的事。
# items.py import scrapy class NewsItem(scrapy.Item): url = scrapy.Field() # 唯一标识,也是ES里的_id title = scrapy.Field() # 标题字段,需要分词 content = scrapy.Field() # 正文字段,需要分词 author = scrapy.Field() # 作者,keyword类型 publish_time = scrapy.Field() # 发布时间,date类型 source = scrapy.Field() # 来源站点逻辑说明:url做唯一标识是为了防止重复数据进入ES;title和content是主要的全文检索字段,中文分词会作用在它们上面;author、publish_time、source这类字段在搜索里更多用于过滤,分词意义不大。写Spider时把网页解析结果填充到这个Item里。
settings.py里要注意几个配置项,直接影响爬取量和稳定性:
# settings.py 关键配置 BOT_NAME = "search_engine" ROBOTSTXT_OBEY = True # 毕设建议遵守robots,别给答辩惹麻烦 CONCURRENT_REQUESTS = 8 DOWNLOAD_DELAY = 1.0 # 单位秒,保持礼貌爬取 ITEM_PIPELINES = { "search_engine.pipelines.ElasticsearchPipeline": 300, }参数说明:CONCURRENT_REQUESTS控制在8,DOWNLOAD_DELAY设为1秒,对一个小型站点每秒最多8个请求,已经足够支撑毕设的数据量;同时降低被封IP的概率。ITEM_PIPELINES里的数字300表示执行优先级,数字越小越先执行。你可以在同一条pipeline里先做清洗再入库,也可以拆成多个pipeline按顺序处理。
pipeline是数据进ES的关键入口。下面是常见的写法:
# pipelines.py from elasticsearch import Elasticsearch class ElasticsearchPipeline: def open_spider(self, spider): # 连接ES,注意设置超时,避免爬虫卡在连接上 self.es = Elasticsearch(["http://localhost:9200"], timeout=30) def process_item(self, item, spider): # 这里可以做字段清洗 if not item.get("title"): raise DropItem(f"缺少标题,丢弃: {item.get('url')}") # 使用url做文档ID,天然去重 self.es.index(index="news", id=item["url"], body=dict(item)) return item逻辑说明:open_spider在爬虫启动时初始化ES连接,process_item对每一条item做清洗并写入ES。用url作为文档ID,同一个页面重复被抓取时ES会直接覆盖,不会产生重复文档。字段清洗放在pipeline里而不是spider里,是为了让spider只关注网页解析,职责更干净。
2.3 动态渲染页面怎么办:Scrapy集成Playwright处理iframe的基本姿势
毕设选题常见的是新闻、招聘、商品这类站点,很多都用了动态渲染,页面里还有iframe嵌入内容。Scrapy默认的下载器拿不到JavaScript渲染后的DOM,自然解析不到数据。常见做法是给Scrapy接入Playwright中间件,让Chrome内核去执行页面脚本,再把最终的HTML交给spider解析。
安装playwright并下载浏览器后,settings.py里这样开:
# settings.py 启用Playwright DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } PLAYWRIGHT_LAUNCH_OPTIONS = { "headless": True, }spider里声明某个请求需要动态渲染:
# news_spider.py 片段 def parse_list(self, response): for url in response.css("a::attr(href)").getall(): yield scrapy.Request( url, meta={"playwright": True}, # 开启动态渲染 callback=self.parse_detail ) def parse_detail(self, response): # 此时response.body已经是渲染后的HTML # 如果是iframe,需要先切换到对应frame frame = response.css("iframe::attr(src)").get() if frame: # 用一个无头请求去抓iframe真实地址 yield scrapy.Request(frame, callback=self.parse_frame)逻辑说明:meta里的playwright标记只对单个请求生效,不会影响其他普通请求的抓取速度。iframe场景的常见处理是:先拿到iframe的src,再单独发一个请求,因为iframe内容是独立文档。如果你发现iframe里的数据还是空的,大概率是iframe本身又依赖了二次请求,这种情况属于少数,毕设里用上述方式一般就够用了。
3. 数据落库与中文分词:ElasticSearch的索引设计与Query DSL
3.1 Windows上启动ElasticSearch:JDK、内存和启动报错排查
毕设环境大多是Windows,ES启动比Linux多几个经典的坑。先确认版本匹配:ES 7.x自带JDK,不需要额外装;ES 8.x也是。但你的电脑上如果有其他Java应用依赖JDK 8,要注意ES 7.10以上不支持Java 8,最好直接用ES 7.17.x或8.x自带的JDK。下载zip解压后,进入bin目录双击elasticsearch.bat。如果你看到一闪而过的窗口,去命令行手动跑:
cd elasticsearch-7.17.10/bin elasticsearch.bat常见启动失败像这样:报“future versions of Elasticsearch will require Java 11”。ES 7.17默认用自带JDK,但如果你设置了JAVA_HOME指向旧版本,它会优先用你指定的JDK。解决方式是临时清空JAVA_HOME再启动:
set JAVA_HOME= elasticsearch.bat还有内存不足的报错。ES启动默认JVM堆是1GB,如果你的机器内存紧张,在config/jvm.options里改小:
-Xms512m -Xmx512m参数说明:Xms和Xmx相等是ES官方推荐的做法,避免运行中动态扩容引发GC停顿。毕设级数据量512MB足够;如果你的文档数到几十万,再升到1GB。启动后访问http://localhost:9200,能看到包含cluster_name和tagline的JSON就说明成了。windows上另一个常见坑是路径里有中文或空格,ES会启动失败或数据目录异常,务必把zip解压到纯英文路径。
3.2 索引mapping怎么定:ik分词、字段类型和中文搜索效果
索引设计是搜索引擎的核心。ES默认的标准分词器对中文是按字切的,搜索“苹果”时会把“苹”和“果”拆开,结果不理想。中文搜索的常见做法是装ElasticSearch的ik分词插件,支持最细粒度和智能切词两种分词模式。安装方式是把ik插件zip解压到ES的plugins目录下,然后重启ES。版本必须和ES主版本一致,用错版本会报illegalArgumentException。
创建一个带ik分词的索引,同时指定mapping:
curl -X PUT "http://localhost:9200/news" -H "Content-Type: application/json" -d' { "settings": { "analysis": { "analyzer": { "ik_analyzer": { "type": "custom", "tokenizer": "ik_max_word" } } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word" }, "content": { "type": "text", "analyzer": "ik_max_word" }, "author": { "type": "keyword" }, "publish_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }, "url": { "type": "keyword" }, "source": { "type": "keyword" } } } }'逻辑说明:title和content是全文检索的核心,用ik_max_word做最大切分,能切出尽可能多的词元,召回率高;author和url这些字段用keyword,因为你要的是精确匹配;publish_time设为date类型,是为了后续能按时间排序。如果你发现某些行业词切得不对,比如“Transformer”被拆成“Transformer”一个词还行,但“BERT”经常被ik拆成“B”“ERT”,这时要自定义词典,后面会在避坑部分专门讲。
3.3 Query DSL详解:match、multi_match与bool组合查询
数据进去了,查询怎么写,是另一个容易卡住的点。DSL是ES的查询语法,用JSON表示。最简单的全文检索用match:
{ "query": { "match": { "content": "人工智能" } } }match会对查询词做同样的分词处理,然后去倒排索引里匹配。毕设里我更推荐用multi_match,同时搜索标题和正文,相关性更高:
{ "query": { "multi_match": { "query": "人工智能", "fields": ["title^3", "content"] } } }逻辑说明:title^3表示标题字段的权重乘以3,匹配到标题的文档会排到前面。如果你想加上过滤条件,比如只看某一来源,就要用bool查询把过滤和全文检索组合起来。
{ "query": { "bool": { "must": [ { "multi_match": { "query": "人工智能", "fields": ["title^3", "content"] } } ], "filter": [ { "term": { "source": "新华网" } } ] } }, "from": 0, "size": 10, "sort": [ { "publish_time": { "order": "desc" } } ], "highlight": { "fields": { "title": {}, "content": {} } } }参数说明:filter里的term是精确匹配,不参与评分,速度快;from和size做分页;sort按发布时间倒序;highlight指定要高亮的字段,ES会返回带em标签的片段,前端直接渲染就能看到红字效果。这个DSL基本覆盖了毕设演示时90%的查询场景。调试DSL可以直接在Kibana的Dev Tools里,或者用curl跑,不要写一行代码调一次。
4. 让搜索在网页上出来:Django的搜索视图与数据同步策略
4.1 Django创建app与ORM连接:搜索项目里Django到底负责什么
在这个三国杀架构里,Django的主要职责是:接收用户搜索词,构造ES查询,把结果渲染成网页。它不需要存数据,因为ES本身就是数据源;也不需要直接操作数据库。很多同学一上来就在Django里建models、跑migrate,然后发现数据进不去,这是没想清楚架构。Django负责的是Web层,ES负责存储与检索引擎,Scrapy负责数据采集,三者通过HTTP协议沟通。
创建app的命令如下:
django-admin startproject mysite cd mysite python manage.py startapp search然后在mysite/settings.py里把search加进INSTALLED_APPS。写一个简单的搜索页面,只需要一个视图、一个模板、一个路由。视图里通过ES客户端发起查询,把结果传给模板渲染。
4.2 搜索视图怎么写:分页、高亮、排序一次到位
直接看代码。用elasticsearch-py库来连ES,视图函数里执行查询:
# search/views.py from django.shortcuts import render from elasticsearch import Elasticsearch es = Elasticsearch(["http://localhost:9200"], timeout=30) def search_view(request): q = request.GET.get("q", "").strip() page = int(request.GET.get("page", 1)) page_size = 10 if not q: return render(request, "search/index.html") body = { "query": { "multi_match": { "query": q, "fields": ["title^3", "content"] } }, "from": (page - 1) * page_size, "size": page_size, "highlight": { "fields": {"title": {}, "content": {}} } } # 只在有排序需求时加sort,默认按评分排序 resp = es.search(index="news", body=body) return render(request, "search/results.html", { "results": resp["hits"]["hits"], "q": q, "total": resp["hits"]["total"]["value"], "page": page, })逻辑说明:from和size组合实现分页;highlight的字段声明和DSL里保持一致,这样返回的_hit里会多出一个highlight字段;total用于前端显示总共找到多少条。一个常见问题是默认排序是按相关性得分,如果你想要最新优先,就在body里加上sort。
模板里取出高亮片段:
{% for hit in results %} <h3> {% if hit.highlight.title %} {{ hit.highlight.title.0|safe }} {% else %} {{ hit._source.title }} {% endif %} </h3> <p> {% if hit.highlight.content %} {{ hit.highlight.content.0|safe }} {% else %} {{ hit._source.content|truncatechars:150 }} {% endif %} </p> <a href="{{ hit._source.url }}">原文链接</a> {% endfor %}说明:highlight返回的片段里包含标签,模板里用safe过滤器放行;如果不加safe,页面上会直接显示em标签源码。content如果没有匹配到高亮片段,就用truncatechars截断前150字作为摘要。这套写法做出来的搜索页,观感上已经有搜索引擎的样子了。
4.3 数据同步与删除对象:Django和ES的一致性怎么维护
小型系统最常见的数据同步方式,是让Scrapy的pipeline同时写数据库和ES,或只写ES。毕设里如果只用ES,就不需要同步。但有一个场景必须处理:当你重跑爬虫时,旧数据可能已经在ES里了,不清理就会重复或残留。常见做法是在爬虫启动前先删掉对应索引,再重建索引:
# pipelines.py 中 open_spider 里做重建 def open_spider(self, spider): if self.es.indices.exists(index="news"): self.es.indices.delete(index="news") # 这里调用上一章创建mapping的代码,建立新索引逻辑说明:删除再重建是最粗暴但最可靠的方式,数据量在万级时重建只需几秒。如果你不想全量重建,可以在Django里写一个删除视图,比如管理员手动删除某条过期数据时,同时从ES删除。Django删除对象本身用ORM很简单,问题是ES里对应文档也要删:
# 删除ES文档示例 def delete_news(request, doc_id): es.delete(index="news", id=doc_id) return redirect("/search/")注意:这个doc_id就是之前pipeline写入时用的url字段。如果你在Scrapy里没用url做id,这里就要维护一个外部队列映射,徒增复杂度。所以设计阶段给每条数据一个稳定ID,是后续所有操作的地基。
5. 避坑指南:这几个坑我当年全踩过,你在答辩前绕开
5.1 现象:数据明明写进去了,搜索却查不到
原因分析:ES写入是近实时的,默认refresh间隔是1秒。你写入文档后立刻查,可能查不到。另一个更隐蔽的问题是分页太深:如果你用from和size翻到10000条以后,ES会报错,因为默认的max_result_window是10000。很多毕设数据量不会超过这个数,但是批量导入时可能误触发。
解决方式:测试时手动触发刷新curl -X POST "http://localhost:9200/news/_refresh";分页超过10000条时改用search_after,但毕设里建议直接把size限制在10000内,或者加大max_result_window。我一般只用前10页结果做演示,够用了。
5.2 现象:爬虫跑完了,ES里还是空的
原因分析:最常见是pipeline没启用。Scrapy的pipeline写在代码里还不够,必须在settings.py的ITEM_PIPELINES里注册,否则process_item永远不会被调用。另一个可能是item出现异常被丢弃了,而你又没看日志。
解决方式:先看Scrapy日志里是否有“Item dropped”或异常堆栈;把pipeline里对外的网络请求用try/except包起来并打日志。记住一个原则:先跑进ES一条,再去跑完整爬虫;黑盒出问题就先缩小范围到单条数据。
5.3 现象:Django页面响应慢,搜索请求要好几秒
原因分析:首当其冲的是ES连接初始化问题。每次请求都新建Elasticsearch客户端,握手开销几毫秒到几十毫秒不等,但高并发下特别明显。更常见的是你在视图里做了多余工作,比如把全部结果都取回再在Python里过滤,而不是把过滤条件放进DSL。
解决方式:把ES客户端做成模块级单例,Django进程内复用;查询参数尽量在DSL里完成,别用Python做后置过滤。如果页面还要显示摘要,考虑在pipeline阶段把摘要截好存进ES,省掉每次查询时的截断动作。尺寸不大的项目做到这两点,接口响应能回到百毫秒级。
5.4 现象:中文搜索“Transformer”查不到东西
原因分析:ik分词器默认词典是通用中文词库,对专业术语和英文缩写支持不好。“Transformer”会被拆成“Transform”“er”或者干脆分不出来。自定义词典是常见做法,在ik/config目录下新建一个词典文件,比如main.dic里加一行“transformer”,然后在IKAnalyzer.cfg.xml里引入这个词典,重启ES才生效。
解决方式:把行业术语、人名、专有名词慢慢累积进自定义词典;爬虫数据量不大时,也可以把分词器改成ik_smart做粗粒度切分,效果不一定差。词典生效后,用curl -X POST "http://localhost:9200/news/_analyze"传一段文本,就能看到切词结果,这招我每次都用来验证分词状态。
5.5 现象:重启ES后索引丢了
原因分析:ES的数据目录默认在解压目录的data文件夹里。如果你为了“干净”把解压目录删了,或者在不同版本ES之间拷贝data目录,索引基本就废了。还有一种情况是ES没有正常退出,数据目录里的translog损坏,启动时直接报index.must_be_specified这类错误。
解决方式:毕设阶段最简单的策略是保留重建脚本,索引丢了就重跑一次。把创建mapping的curl脚本存下来,把Scrapy爬虫的启动命令存下来,整个数据流程能在十几分钟内重建完毕。这不丢人,反而显得你的方案可工程化、可恢复性强。生产级的调备份策略不在毕设范围内。
6. 让毕设真正能演示:验证方法、数据规模与一个加分技巧
毕设演示最怕的是临场翻车。我建议你在答辩前定量验证三个东西:数据量是否覆盖了搜索意图、搜索响应时间是否稳定、高频词搜索结果是否合理。数据量在1万条以上、搜索响应低于500毫秒、搜索结果第一页包含你预先准备的关键词,这三个指标达标,演示就很稳。验证响应时间可以用Django的调试工具栏,也可以直接在浏览器F12里看网络耗时。
一个加分技巧是加入搜索建议词或热门搜索排行。在Django里写一个中间件,把每次搜索词记到ES里单独一个索引,或者存到SQLite表里,然后在前端页面显示“大家都在搜”。这个功能不需要改动搜索引擎本身,但展示出来会让评委觉得你考虑了产品完整性。我当年就是把搜索日志做了个词频统计,然后在前端渲染成热词标签,点击标签直接触发搜索,效果比干巴巴的一个搜索框好很多。
另一个我自己习惯用的验证思路是:故意输入一个错别字或者不完整的短语,看搜索能不能兜底。小型引擎的容错能力普遍弱,遇到查不到的结果页要友好提示,而不只是空白页。你可以在视图里加一个判断,命中数为0时给出相近的搜索建议。这个细节在答辩时非常加分,因为它说明了你知道搜索引擎不是万能的,而且你做的是产品而不是demo。
总的来说,这个毕设项目的核心不是单点技术多深,而是你能否三言两语讲清楚“数据从哪儿来、怎么变成可检索的结构、用户怎么查”。把三条链路打通并且稳定复现,比追求花哨的算法更实用。希望帮到你。
本文还有配套的精品资源,点击获取