做一个网盘搜索引擎

前言

两年前笔者做过一个当时非常有成就感的小型搜索引擎,但回过头一看,全忘完了🙃,为了复习一下以前的知识,前面写过一篇文章总结概括了一下搜索引擎的原理。但作为程序员,光是理论肯定是不够的,所以得实践一下。以前的文章搜索引擎说实话没啥用,所以这次为了让项目更加有意思一点,决定做的是一个网盘类的搜索引擎,也算是有点用处。

笔者将整个搜索引擎简化了很多,甚至比两年前做的那个小型搜索引擎还要简单,但也算是一个微型搜索引擎了,帮助笔者实践了部分知识。

项目演示

做一个网盘搜索引擎

做一个网盘搜索引擎

线上演示地址:pan.justin3go.com

至文章发布3个月内该地址应该都可以访问。服务器暂时买了3个月的放在那里,3个月之后看是否还有空闲的服务器,否则应该就会下线该服务。

技术概览

让我们还是先看看之前这篇文章的一个架构图:

做一个网盘搜索引擎

然后具体来说笔者使用了如下技术栈:

  1. 爬虫框架:Scrapy
  2. 索引存储/搜索:ElasticSearch
  3. 后端服务:NestJS
  4. 前端服务:VueJS
  5. 部署:Docker

爬虫

本章不会介绍Scrapy的使用,只会大致讲讲笔者的思路,框架使用请见Scrapy官网

  1. 找一些种子网站,比如知乎、贴吧中网盘资源分享的圈子
  2. 爬取该网页,文章类搜索引擎就是识别标题、正文之类的,而网盘类搜索引擎更加简单,直接使用正则表达式识别域名就可以了,比如阿里云盘的正则就是aliyundrive.com/s/[A-Za-z0-9]{11}
  3. 将提取的内容做处理并索引到ElasticSearch之中
  4. 提取该网页的所有外链,并过滤,比如资源链接(图片、视频、音乐)之类,继续爬取这些外链,重复该步骤

这里笔者将爬行深度限制为了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部分:

ts

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部分:

html

<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部分

html

<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部分

scss

<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笔者都没删除。

阅读剩余
THE END