做一个网盘搜索引擎
前言
两年前笔者做过一个当时非常有成就感的小型搜索引擎,但回过头一看,全忘完了?,为了复习一下以前的知识,前面写过一篇文章总结概括了一下搜索引擎的原理。但作为程序员,光是理论肯定是不够的,所以得实践一下。以前的文章搜索引擎说实话没啥用,所以这次为了让项目更加有意思一点,决定做的是一个网盘类的搜索引擎,也算是有点用处。
笔者将整个搜索引擎简化了很多,甚至比两年前做的那个小型搜索引擎还要简单,但也算是一个微型搜索引擎了,帮助笔者实践了部分知识。
项目演示
至文章发布3个月内该地址应该都可以访问。服务器暂时买了3个月的放在那里,3个月之后看是否还有空闲的服务器,否则应该就会下线该服务。
技术概览
让我们还是先看看之前这篇文章的一个架构图:
然后具体来说笔者使用了如下技术栈:
- 爬虫框架:Scrapy
- 索引存储/搜索:ElasticSearch
- 后端服务:NestJS
- 前端服务:VueJS
- 部署:Docker
爬虫
本章不会介绍Scrapy的使用,只会大致讲讲笔者的思路,框架使用请见Scrapy官网
- 找一些种子网站,比如知乎、贴吧中网盘资源分享的圈子
- 爬取该网页,文章类搜索引擎就是识别标题、正文之类的,而网盘类搜索引擎更加简单,直接使用正则表达式识别域名就可以了,比如阿里云盘的正则就是
aliyundrive.com/s/[A-Za-z0-9]{11}
- 将提取的内容做处理并索引到ElasticSearch之中
- 提取该网页的所有外链,并过滤,比如资源链接(图片、视频、音乐)之类,继续爬取这些外链,重复该步骤
这里笔者将爬行深度限制为了10,因为一般离导航网站越远,越不重要,就节省资源,不爬取了。
python代码就不贴了,笔者已经很久没写过python代码了,这次写python代码基本上都是说说思路,然后GPT帮助笔者写的,反正感觉挺烂的,但总算Debug能力还在,把这堆代码跑起来了。
最后部署就用了node的pm2守护这个爬虫进程,放在服务器上一直运行就行了。
索引
部署方面使用Docker进行部署
部署流程笔者基本上都是参考的这篇文章,整体下来也比较简单,就不再重新部署演示了。
需要注意的是,不要为了贪图方便,不设置IP白名单,笔者就是因为家里网络属于动态IP,为了本地开发方便,就没有单独设置IP白名单,结果爬取了3周的数据全部被删除了,而且还不长记性,被删除了2次:
MD,我的Elasticsearch的索引全被删除了,之前想着没人知道我的IP地… #掘金沸点#
https://juejin.cn/pin/7262761740524372029
太猖狂了,删我ES数据库 – #掘金沸点#
https://juejin.cn/pin/7264921613552058402
所以这里要么使用iptables
工具进行限制,或者直接使用云厂商的安全组策略,仅允许应用服务器的IP访问。
应用服务
最后就是应用服务了,这些就轻车熟路了。
后端部分
为了简单,笔者这里后端就只写了两个接口,一个是搜索的接口,一个是搜索建议的接口,就是输入前半截,提示后半截这种。
如下是NestJS中service
部分:
import { Injectable } from '@nestjs/common';
import { ElasticsearchService } from '@nestjs/elasticsearch';
import { SearchDto } from './dto/search.dto';
import { SuggestDto } from './dto/suggest.dto';
@Injectable()
export class SearchService {
constructor(private readonly elasticsearchService: ElasticsearchService) {}
async search(data: SearchDto) {
const { pageNo, pageSize, query } = data;
const esRes = await this.elasticsearchService.search({
index: 'pan',
body: {
from: (pageNo - 1) * pageSize, // 从哪里开始
size: pageSize, // 查询条数
query: {
match: {
title: query, // 搜索查询到的内容
},
},
highlight: {
pre_tags: ["<span class='highlight'>"],
post_tags: ['</span>'],
fields: {
title: {},
},
fragment_size: 40,
},
},
});
// 组装参数
const finalRes = {
took: esRes.body.took,
total: esRes.body.hits.total.value,
data: esRes.body.hits?.hits.map((item: any) => ({
title: item._source.title,
pan_url: item._source.pan_url,
extract_code: item._source.extract_code,
highlight: item.highlight,
})),
};
return finalRes;
}
async suggest(data: SuggestDto) {
const { input } = data;
const esRes = await this.elasticsearchService.search({
index: 'pan',
suggest_field: 'suggest',
suggest_mode: 'always',
suggest_size: 20,
suggest_text: input,
});
return esRes.body.suggest;
}
}
非常少,后端服务基本上就只写上面这几行代码,啥用户系统、安全限制都没做。没必要,只是为了演示,先做主要功能,如果有需要再迭代就行了,多半也不会继续开发了。
前端部分
整个前端页面就只有一个页面,也非常简单,稍微麻烦一点的就是兼容了一下移动端显示,UI库使用的是腾讯的UI库-TDesign,感觉还挺好看的,其他就没啥特别的了,如下是那个主页面的具体代码:
template
部分:
<template>
<header>
<div class="title">阿里云盘搜索神器</div>
<div class="slogan">
互联网具有天然的资源共享性,本小站不是资源的生产者,不存储任何资源,仅仅只是互联网的搬运工
</div>
</header>
<main>
<div class="search-input">
<t-auto-complete
v-model="searchValue"
:options="options"
placeholder="请输入关键词搜索"
size="large"
highlight-keyword
filterable
class="t-demo-autocomplete__search"
@change="loadSuggest"
@enter="searchData"
@select="searchData"
>
<template v-if="searchValue" #suffix>
<CloseCircleFilledIcon class="t-input__suffix-clear" @click="searchValue = ''" />
</template>
<template #suffixIcon>
<t-button shape="square" size="large" @click="searchData"><SearchIcon /></t-button>
</template>
</t-auto-complete>
</div>
<t-loading size="small" :loading="loading" show-overlay style="width: 100%; height: 100%">
<div class="list-container">
<t-list :split="true">
<t-list-item v-for="(item, index) in searchResult" :key="index">
<t-list-item-meta :description="`提取码: ${item.extractCode || '--'}`">
<template #title>
<div class="result-title" v-html="item.highlight.title[0]"></div>
</template>
</t-list-item-meta>
<template #action>
<t-link
theme="primary"
hover="#000000"
style="margin-left: 16px"
@click="copyCode(item.extractCode)"
>
复制提取码
</t-link>
<t-link
theme="primary"
hover="#000000"
style="margin-left: 16px"
@click="openAdDialog(item.pan_url)"
>
跳转
</t-link>
</template>
</t-list-item>
</t-list>
<div v-if="!searchResult.length" class="empty-container">
<div class="empty">
<!-- <WalletIcon size="80px" /> -->
<img src="@/assets/empty.png" alt="" style="width: 240px; opacity: 0.5" />
<div class="text">请输入搜索或暂无数据</div>
</div>
</div>
</div>
</t-loading>
<div v-if="page.total" class="pagination-container">
<t-pagination
v-model="page.NO"
v-model:pageSize="page.size"
:total="page.total"
:showPreviousAndNextBtn="false"
page-ellipsis-mode="both-ends"
@page-size-change="onPageSizeChange"
@current-change="onCurrentChange"
/>
</div>
</main>
<footer></footer>
<t-dialog
v-model:visible="visible"
header="请认真阅读以下声明"
confirmBtn="同意声明并跳转"
@confirm="jumpLink"
@close="closeAdDialog"
>
<p class="content">
1.
本站链接为程序自动收集自互联网,不储存、复制、传播、控制编辑任何网盘文件,不提供下载服务,链接跳转至官方网盘,文件的有效性和安全性需要您自行判断。
</p>
<p class="content">
2. 本站坚决杜绝一切违规不良信息,如您发现任何涉嫌违规的网盘信息,请立即向<a
href="https://terms.alicdn.com/legal-agreement/terms/suit_bu1_dingtalk/suit_bu1_dingtalk202103181300_11832.html"
>网盘官方网站</a
>举报
</p>
<p class="content">
3. 本站是笔者在线作品演示网站,所有服务仅供学习交流使用,搜索引擎技术细节可以访问笔者的<a
href="https://justin3go.com"
>个人博客</a
>查找。
</p>
</t-dialog>
</template>
script
部分
<script setup lang="ts">
import { reactive, ref, type Ref } from 'vue'
import { SearchIcon, CloseCircleFilledIcon } from 'tdesign-icons-vue-next'
import { MessagePlugin } from 'tdesign-vue-next'
import searchApi from '@/service/apis/search'
import useClipboard from 'vue-clipboard3'
const searchValue = ref('')
const options = ref([])
interface ISearchResultItem {
title: string
pan_url: string
extractCode: string
highlight: {
title: string[]
}
}
const searchResult: Ref<ISearchResultItem[]> = ref([])
const page = reactive({
total: 0,
NO: 1,
size: 10
})
const loading = ref(false)
async function loadData() {
if (loading.value) return
loading.value = true
const data = await searchApi.search({
pageNo: page.NO,
pageSize: page.size,
query: searchValue.value
})
searchResult.value = data?.data?.data || []
page.total = data?.data?.total || 0
loading.value = false
}
function resetPage() {
page.total = 0
page.NO = 1
}
async function searchData() {
resetPage()
loadData()
}
const timer = ref()
function loadSuggest() {
clearTimeout(timer.value)
timer.value = setTimeout(async () => {
const data = await searchApi.suggest({
input: searchValue.value
})
const resOptions = data?.data?.suggest[0]?.options
// eslint-disable-next-line no-control-regex
options.value = resOptions?.map((item: any) => item.text.replace(/u001f/g, '')) || []
}, 200)
}
function onPageSizeChange(size: number) {
page.size = size
loadData()
}
function onCurrentChange(index: number) {
page.NO = index
loadData()
MessagePlugin.success(`转到第${index}页`)
}
async function copyCode(code: string) {
if (!code) {
MessagePlugin.info('无需提取码')
}
const { toClipboard } = useClipboard()
await toClipboard(code)
MessagePlugin.success('复制成功')
}
const visible = ref(false)
const curPanUrl = ref('')
function openAdDialog(url: string) {
visible.value = true
curPanUrl.value = url
}
function closeAdDialog() {
visible.value = false
}
function jumpLink() {
let url = curPanUrl.value
if (!url.startsWith('https://')) url = 'https://' + url
window.open(url, '_blank')
}
</script>
css
部分
<style lang="scss" scoped>
header {
margin-top: 20px;
padding: 0 100px;
.title {
font-size: 32px;
font-weight: 900;
text-align: center;
}
.slogan {
text-align: center;
color: #888888;
margin-top: 10px;
max-width: 80vw;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.slogan::before {
content: '“';
font-size: 40px;
vertical-align: text-bottom;
line-height: 20px;
}
.slogan::after {
content: '”';
font-size: 40px;
vertical-align: text-top;
/* line-height: 20px; */
}
/* 移动端样式适配 */
@media (max-width: 1024px) {
padding: 0;
.slogan {
max-width: 100vw;
}
}
}
:deep(.t-demo-autocomplete__search .t-input) {
padding-right: 0;
}
:deep(.t-demo-auto-complete__base .t-button svg) {
font-size: 20px;
}
main {
position: relative;
top: 0;
.search-input {
margin: 0 20%;
width: 60%;
}
.list-container {
margin: 20px 10% 50px 10%;
width: 80%;
height: calc(100vh - 250px);
overflow-y: scroll;
.empty-container {
color: #bfbfbf;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.empty {
.text {
width: 240px;
text-align: center;
}
}
}
}
.list-container::-webkit-scrollbar {
width: 8px;
}
.list-container::-webkit-scrollbar-track {
/* background: rgb(239, 239, 239); */
border-radius: 2px;
}
.list-container::-webkit-scrollbar-thumb {
background: #bfbfbf;
border-radius: 10px;
}
.list-container::-webkit-scrollbar-thumb:hover {
background: #a7a7a7;
}
.pagination-container {
position: absolute;
bottom: -50px;
margin: 0 5%;
width: 90%;
background-color: #ffffff;
}
/* 移动端样式适配 */
@media (max-width: 1024px) {
padding: 0 20px;
.search-input {
margin: auto;
width: calc(100vw - 40px);
}
.list-container {
margin: 20px auto 50px auto;
width: calc(100vw - 40px);
}
.pagination-container {
width: calc(100% - 40px);
margin: auto;
}
}
}
:deep(.highlight) {
/* color: rgb(172, 141, 0); */
color: rgb(138, 138, 230);
/* font-weight: bolder; */
}
:deep(.t-dialog) {
max-width: 360px;
}
</style>
还有一些其他零碎的代码就不贴了,主要页面就是上述部分。
最后
整体开发下来一个感受就是:由于人为简化了很多,所以每一部分,比如前端、后端、爬虫、部署之类的单独看都非常简单,但整个流程走下来却是较为复杂的,比如遇到BUG排查就会较为耗时。
项目应该不会开源,也没开源的必要,毕竟代码没怎么整理,比如前端项目用脚手架创建的AboutView.vue
笔者都没删除。