Baike.dev
All toolsAI codingTrendingOpen sourceNewsSubmit
Log in
< Back to tools
D

DIYSearchEngine

> 编程语言
Open source

Go 开发的开源互联网搜索引擎,附教程《自己动手开发互联网搜索引擎》

701 stars0 likes0 views
WebsiteGitHub

About

Go 开发的开源互联网搜索引擎,附教程《自己动手开发互联网搜索引擎》

Go 开发的开源互联网搜索引擎

DIYSearchEngine 是一个能够高速采集海量互联网数据的开源搜索引擎,采用 Go 语言开发。

想运行本项目请拉到项目底部,有教程。

《两万字教你自己动手开发互联网搜索引擎》

写在前面

本文是一篇教你做“合法搜索引擎”的文章,一切都符合《网络安全法》和 robots 业界规范的要求,如果你被公司要求爬一些上了反扒措施的网站,我个人建议你马上离职,我已经知道了好几起全公司数百人被一锅端的事件。

《爬乙己》

搜索引擎圈的格局,是和别处不同的:只需稍作一番查考,便能获取一篇又一篇八股文,篇篇都是爬虫、索引、排序三板斧。可是这三板斧到底该怎么用代码写出来,却被作者们故意保持沉默,大抵可能确实是抄来的罢。

从我年方二十,便开始在新浪云计算店担任一名伙计,老板告诉我,我长相过于天真,无法应对那些难缠的云计算客户。这些客户时刻都要求我们的服务在线,每当出现故障,不到十秒钟电话就会纷至沓来,比我们的监控系统还要迅捷。所以过了几天,掌柜又说我干不了这事。幸亏云商店那边要人,无须辞退,便改为专管云商店运营的一种无聊职务了。

我从此便整天的坐在电话后面,专管我的职务。虽然只需要挨骂道歉,损失一些尊严,但总觉得有些无聊。掌柜是一副凶脸孔,主顾也没有好声气,教人活泼不得;只有在午饭后,众人一起散步时闲谈起搜索引擎,才能感受到几许欢笑,因此至今仍深刻铭记在心。

由于谷歌被戏称为“哥”,本镇居民就为当地的搜索引擎取了一个绰号,叫作度娘。

度娘一出现,所有人都笑了起来,有的叫到,“度娘,你昨天又加法律法规词了!”他不回答,对后台说,“温两个热搜,要一碟文库豆”,说着便排出九枚广告。我们又故意的高声嚷道,“你一定又骗了人家的钱了!”度娘睁大眼睛说,“你怎么这样凭空污人清白……”“什么清白?我前天亲眼见你卖了莆田系广告,第一屏全是。”度娘便涨红了脸,额上的青筋条条绽出,争辩道,“广告不能算偷……流量!……互联网广告的事,能算偷么?”接连便是难懂的话,什么“免费使用”,什么“CPM”之类,引得众人都哄笑起来:店内外充满了快活的空气。

本文目标

三板斧文章遍地都是,但是真的自己开发出来搜索引擎的人却少之又少,其实,开发一个搜索引擎没那么难,数据量也没有你想象的那么大,倒排索引也没有字面上看着那么炫酷,BM25 算法也没有它的表达式看起来那么夸张,只给几个人用的话也没多少计算压力。

突破自己心灵的枷锁,只靠自己就可以开发一个私有的互联网搜索引擎!

本文是一篇“跟我做”文章,只要你一步一步跟着我做,最后就可以得到一个可以运行的互联网搜索引擎。本文的后端语言采用 Golang,内存数据库采用 Redis,字典存储采用 MySQL,不用费尽心思地研究进程间通信,也不用绞尽脑汁地解决多线程和线程安全问题,也不用自己在磁盘上手搓 B+ 树致密排列,站着就把钱挣了。

目录

把大象装进冰箱,只需要三步:

  1. 编写高性能爬虫,从互联网上爬取网页
  2. 使用倒排索引技术,将网页拆分成字典
  3. 使用 BM25 算法,返回搜索结果

第一步,编写高性能爬虫,从互联网上爬取网页

Golang 的协程使得它特别适合拿来开发高性能爬虫,只要利用外部 Redis 做好“协程间通信”,你有多少 CPU 核心 go 都可以吃完,而且代码写起来还特别简单,进程和线程都不需要自己管理。当然,协程功能强大,代码简略,这就导致它的 debug 成本很高:我在写协程代码的时候感觉自己像在炼丹,修改一个字符就可以让程序从龟速提升到十万倍,简直比操控 ChatGPT 还神奇。

在编写爬虫之前,我们需要知道从互联网上爬取内容需要遵纪守法,并遵守robots.txt,否则,可能就要进去和前辈们切磋爬虫技术了。robots.txt 的具体规范大家可以自行搜索,下面跟着我开搞。

新建 go 项目我就不演示了,不会的可以问一下 ChatGPT~

爬虫工作流程

我们先设计一个可以落地的爬虫工作流程。

1. 设计一个 UA

首先我们要给自己的爬虫设定一个 UA,尽量采用较新的 PC 浏览器的 UA 加以改造,加入我们自己的 spider 名称,我的项目叫“Enterprise Search Engine” 简称 ESE,所以我设定的 UA 是 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4280.67 Safari/537.36 ESESpider/1.0,你们可以自己设定。

需要注意的是,部分网站会屏蔽非头部搜索引擎的爬虫,这个需要你们转动聪明的小脑袋瓜自己解决哦。

2. 选择一个爬虫工具库

我选择的是 PuerkitoBio/goquery,它支持自定义 UA 爬取,并可以对爬到的 HTML 页面进行解析,进而得到对我们的搜索引擎十分重要的页面标题、超链接等。

3. 设计数据库

爬虫的数据库倒是特别简单,一个表即可。这个表里面存着页面的 URL 和爬来的标题以及网页文字内容。

…

4. 给爷爬!

爬虫有一个极好的特性:自我增殖。每一个网页里,基本都带有其他网页的链接,这样我们就可以道生一,一生二,二生三,三生万物了。

此时,我们只需要找一个导航网站,手动把该网站的链接插入到数据库里,爬虫就可以开始运作了。各位可以自行挑选可口的页面链接服用。

我们正式进入实操阶段,以下都是可以运行的代码片段,代码逻辑在注释里面讲解。

我采用joho/godotenv来提供.env配置文件读取的能力,你需要提前准备好一个.env文件,并在里面填写好可以使用的 MySQL 数据库信息,具体可以参考项目中的.env.example文件。

…

我已经事先将 hao123 的链接插入了 pages 表,所以我运行go build -o ese *.go && ./ese命令之后,得到了如下信息:

My name id enterprise-search-engine!
加载.env : /root/enterprise-search-engine/.env
APP_ENV: local
[[{1  0 https://www.hao123.com       0 0 2001-01-01 00:00:00 +0800 CST 2001-01-01 08:00:00 +0800 CST 0001-01-01 00:00:00 +0000 UTC}]]

《递龟》

上面的代码中,我们第一次用到了递龟递归:自己调用自己。

5. 合法合规:遵守 robots.txt 规范

我选择用temoto/robotstxt这个库来探查我们的爬虫是否被允许爬取某个 URL,使用一张单独的表来存储每个域名的 robots 规则,并在 Redis 中建立缓存,每次爬取 URL 之前,先进行一次匹配,匹配成功后再爬,保证合法合规。

制造真正的生产级爬虫

《怎样画马》

有了前面这个理论上可以运行的简单爬虫,下面我们就要给这匹马补充亿点细节了:生产环境中,爬虫性能优化是最重要的工作。

从某种程度上来说,搜索引擎的优劣并不取决于搜索算法的优劣,因为算法作为一种“特定问题的简便算法”,一家商业公司比别家强的程度很有限,搜索引擎的真正优劣在于哪家能够以最快的速度索引到互联网上层出不穷的新页面和已经更新过内容的旧页面,在于哪家能够识别哪个网页是价值最高的网页。

识别网页价值方面,李彦宏起家的搜索专利,以及谷歌大名鼎鼎的 PageRank 都拥有异曲同工之妙。但本文的重点不在这个领域,而在于技术实现。让我们回到爬虫性能优化,为什么性能优化如此重要呢?我们构建的是互联网搜索引擎,需要爬海量的数据,因此我们的爬虫需要足够高效:中文互联网有 400 万个网站,3500 亿个网页,哪怕只爬千分之一,3.5 亿个网页也不是开玩笑的,如果只是单线程阻塞地爬,消耗的时间恐怕要以年为单位了。

爬虫性能优化,我们首先需要规划一下硬件。

硬件要求

首先计算磁盘空间,假设一个页面 20KB,在不进行压缩的情况下,一亿个页面就需要 20 * 100000000 / 1024 / 1024 / 1024 = 1.86TB 的磁盘空间,而我们打算使用 MySQL 来存储页面文本,需要的空间会更大一点。

我的爬虫花费了 2 个月的时间,爬到了大约 1 亿个 URL,其中 3600 万个爬到了页面的 HTML 文本存在了数据库里,共消耗了超过 600GB 的磁盘空间。

除了硬性的磁盘空间,剩下的就是尽量多的 CPU 核心数和内存了:CPU 拿来并发爬网页,内存拿来支撑海量协程的消耗,外加用 Redis 为爬虫提速。爬虫阶段对内存的要求还不大,但在后面第二步拆分字典的时候,大内存容量的 Redis 将成为提速利器。

所以,我们对硬件的需求是这样的:一台核心数尽量多的物理机拿来跑我们的 ese 二进制程序,外加高性能数据库(例如16核64GB内存,NVME磁盘),你能搞到多少台数据库就准备多少台,就算你搞到了 65536 台数据库,也能跑满,理论上我们可以无限分库分表。能这么搞是因为网页数据具有离散性,相互之间的关系在爬虫和字典阶段还不存在,在查询阶段才比较重要。

顺着这个思路,有人可能就会想,我用 KV 数据库例如 MongoDB 来存怎么样呢?当然是很好的,但是MongoDB 不适合干的事情实在是太多啦,所以你依然需要 Redis 和 MySQL 的支持,如果你需要爬取更大规模的网页,可以把 MongoDB 用起来,利用进一步推高系统复杂度的方式获得一个显著的性能提升。

下面我们开始进行软件优化,我只讲述关键步骤,各位有什么不明白的地方可以参考项目代码。

重用 HTTP 客户端以防止内存泄露

这个点看起来很小,但当你瞬间并发数十万协程的时候,每个协程 1MB 的内存浪费累积起来都是巨大的,很容易造成 OOM。

我们在 tools 文件夹下创建curl.go工具类,专门用来存放全局 client 和 curl 工具函数:

package tools

import ... //省略,具体可以参考项目代码

// 全局重用 client 对象,4 秒超时,不跟随 301 302 跳转
var client = req.C().SetTimeout(time.Second * 4).SetRedirectPolicy(req.NoRedirectPolicy())

// 返回 document 对象和状态码
func Curl(page models.Page, ch chan int) (*goquery.Document, int) {
  ... //省略,具体可以参考项目代码
}

基础知识储备:goroutine 协程

我默认你已经了解 go 协程是什么了,它就是一个看起来像魔法的东西。在这里我提供一个理解协程的小诀窍:每个协程在进入磁盘、网络等“只需要后台等待”的任务之后,会把当前 CPU 核心(可以理解成一个图灵机)的指令指针 goto 到下一个协程的起始。

需要注意的是,协程是一种特殊的并发形式,你在并发函数内调用的函数必须都支持并发调用,类似于传统的“线程安全”,如果你一不小心写了不安全的代码,轻则卡顿,重则 crash。

一次取出一批需要爬的 URL,使用协程并发爬

协程代码实操来啦!

…

go // 我们使用一个 Hash 来存储 URL 是否存在的状态 statusHashMapKey := "ese_spider_status_exist" statusExist := db.Rdb.HExists(db.Ctx, statusHashMapKey, _url).Val() // 若 HashMap 中不存在,则查询或插入数据库 if !statusExist { ··· 代码省略,不存在则创建这行 page,存在则更新信息 ··· // 无论是否新插入了数据,都将 _url 入 HashMap db.Rdb.HSet(db.Ctx, statusHashMapKey, _url, 1).Err() }


这段代码看似简单,实测非常好用,唯一的问题就是不能运行太长时间,隔一段时间得清空一次,因为随着时间的流逝,局部性会越来越差。

细心的小伙伴可能已经发现了,既然爬取状态已经用 Redis 来承载了,那还需要区分 pages 和 status 表吗?需要,因为 Redis 也不是全能的,它的基础数据依然是来自 MySQL 的。目前这个架构类似于复杂的三级火箭,看起来提升没那么大,但这小小的提速可能就能让你爬三亿个网页的时间从 3 个月缩减到 1 个月,是非常值的。

另外,如果通过扫描 256 张表中 craw_time 字段的方式来统计“过去 N 分钟爬了多少个 URL、有效页面多少个、因为爬虫压力而略过的页面多少个、网络错误的多少个、多次网络错误后不再重复爬取的多少个”的数据,还是太慢了,也太消耗资源了,这些统计信息也需要使用 Redis 来记录:

```go
// 过去一分钟爬到了多少个页面的 HTML
allStatusKey := "ese_spider_all_status_in_minute_" + strconv.Itoa(int(time.Now().Unix())/60)
// 计数器加 1
db.Rdb.IncrBy(db.Ctx, allStatusKey, 1).Err()
// 续命 1 小时
db.Rdb.Expire(db.Ctx, allStatusKey, time.Hour).Err()

// 过去一分钟从新爬到的 HTML 里面提取出了多少个新的待爬 URL
newStatusKey := "ese_spider_new_status_in_minute_" + strconv.Itoa(int(time.Now().Unix())/60)
// 计数器加 1
db.Rdb.IncrBy(db.Ctx, newStatusKey, 1).Err()
// 续命 1 小时
db.Rdb.Expire(db.Ctx, newStatusKey, time.Hour).Err()

…

go
dbdb0, _ := _db0.DB()
dbdb0.SetMaxIdleConns(1)
dbdb0.SetMaxOpenConns(100)
dbdb0.SetConnMaxLifetime(time.Hour)

…

text
110,85,1,195653,7101-66,111,1,195653,7101-

你问为什么我不找个常见的词?因为随便一个常见的词,它的字典长度都是以 MB 为单位的,根本没法放出来...

还有一个牛逼的词,最小完美哈希,可以用来排布字典数据,加快搜索速度,感兴趣的同学可以自行学习

生成倒排索引数据

理解了倒排索引是什么以后,我们就可以着手把我们爬到的 HTML 处理成倒排索引了。

我使用yanyiwu/gojieba这个库来调用结巴分词,按照以下步骤对我爬到的每一个 HTML 文本进行分词并归类:

  1. 分词,然后循环处理这些词:
  2. 统计词频:这个词在该 HTML 中出现的次数
  3. 记录下这个词在该 HTML 中每一次出现的位置,从 0 开始算
  4. 计算该 HTML 的总长度,搜索算法需要
  5. 按照一定格式,组装成倒排索引值,形式如下:
// 分表的顺序,例如 0f 转为十进制为 15
strconv.Itoa(i) + "," +
// pages.id 该 URL 的主键 ID
strconv.Itoa(int(pages.ID)) + "," +
// 词频:这个词在该 HTML 中出现的次数
strconv.Itoa(v.count) + "," +
// 该 HTML 的总长度,BM25 算法需要
strconv.Itoa(textLength) + "," +
// 这个词出现的每一个位置,用逗号隔开,可能有多个
strings.Join(v.positions, ",") +
// 不同 page 之间的间隔符
"-"

我们按照这个规则,把所有的 HTML 进行倒排索引,并且把生成的索引值拼接在一起,存入 MySQL 即可。

使用协程 + Redis 大幅提升词典生成速度

不知道大家感受到了没有,词典的生成是一个比爬虫高几个数量级的 CPU 消耗大户,一个 HTML 动辄几千个词,如果你要对数亿个 HTML 进行倒排索引,需要的计算量是非常惊人的。我爬到了 3600 万个页面,但是只处理了不到 800 万个页面的倒排索引,因为我的计算资源也有限...

并且,把词典的内容存到 MySQL 里难度也很大,因为一些常见词的倒排索引会巨长,例如“没有”这个词,真的是到处都有它。那该怎么做性能优化呢?还是我们的老朋友,协程和 Redis。

协程分词

两个 HTML 的分词工作之间完全没有交集,非常适合拿协程来跑。

但是,MySQL 举手了:我顶不住。所以协程的好朋友 Redis 也来了。

使用 Redis 做为词典数据的中转站

我们在 Redis 中针对每一个词生成一个 List,把倒排出来的索引插入到尾部:

db.Rdb10.RPush(db.Ctx, word, appendSrting)

使用协程从 Redis 搬运数据到 MySQL 中

你没看错,这个地方也需要使用协程,因为数据量实在是太大了,一个线程循环跑会非常慢。经过我的不断尝试,我发现每次转移 2000 个词,对 Redis 的负载比较能够接受,E5-V4 的 CPU 单核能够跑满,带宽大概 400Mbps。

从 Redis 到 MySQL 的高性能搬运方法如下:

  1. 随机获取一个 key
  2. 判断该 key 的长度,只有大于等于 2 的进入下一步
  3. 把最后一个索引值留下,前面的元素一个一个LPop(弹出头部)出来,拼接在一起
  4. 汇集一批 2000 个随机词的结果,append 到数据库该词现有索引值的后面

有了协程和 Redis 的协助,分词加倒排索引的速度快了起来,但是如果你选择一个词一个词地 append 值,你会发现 MySQL 又双叒叕变的超慢,又要优化 MySQL 了!

事务的妙用:MySQL 高速批量插入

由于需要往磁盘里写东西,所以只要是一个一个 update,怎么优化都会很慢,那有没有一次性 update 多行数据的方法呢?有!那就是事务:

tx.Exec(`START TRANSACTION`)

// 需要批量执行的 update 语句
for w, s := range needUpdate {
  tx.Exec(`UPDATE word_dics SET positions = concat(ifnull(positions,''), ?) where name = ?`, s, w)
}

tx.Exec(`COMMIT`)

这么操作,字典写入速度一下子起来了。但是,每次执行 2000 条 update 语句对磁盘的要求非常高,我进行这个操作的时候,可以把磁盘写入速度瞬间提升到 1.5GB/S,如果你的数据库存储不够快,可以减少语句数量。

世界的参差:无意义的词

这个世界上的东西并不都是有用的,一个 HTML 中的字符也是如此。

首先,一般不建议索引整个 HTML,而是把他用 DOM 库处理一下,提取出文本内容,再进行索引。

其次,即便是你已经过滤掉了所有的 html 标签、css、js 代码等,还是有些词频繁地出现:它们出现的频率如此的高,以至于反而失去了作为搜索词的价值。这个时候,我们就需要把他们狠狠地拉黑,不处理他们的倒排索引。我使用的黑名单词如下,表名为word_black_list,只有两个字段 id、word,需要的自取:

INSERT INTO `word_black_list` (`id`, `word`)
VALUES
	(1, 'px'),
	(2, '20'),
	(3, '('),
	(4, ')'),
	(5, ','),
	(6, '.'),
	(7, '-'),
	(8, '/'),
	(9, ':'),
	(10, 'var'),
	(11, '的'),
	(12, 'com'),
	(13, ';'),
	(14, '['),
	(15, ']'),
	(16, '{'),
	(17, '}'),
	(18, '\''),
	(19, '\"'),
	(20, '_'),
	(21, '?'),
	(22, 'function'),
	(23, 'document'),
	(24, '|'),
	(25, '='),
	(26, 'html'),
	(27, '内容'),
	(28, '0'),
	(29, '1'),
	(30, '3'),
	(31, 'https'),
	(32, 'http'),
	(33, '2'),
	(34, '!'),
	(35, 'window'),
	(36, 'if'),
	(37, '“'),
	(38, '”'),
	(39, '。'),
	(40, 'src'),
	(41, '中'),
	(42, '了'),
	(43, '6'),
	(44, '。'),
	(45, ''),
	(47, '联系'),
	(48, '号'),
	(49, 'getElementsByTagName'),
	(50, '5'),
	(51, '、'),
	(52, 'script'),
	(53, 'js');

…

go
// 页面总数
db.DbInstance0.Raw("select count(*) from pages_0f where dic_done = 1").Scan(&N)
N *= 256

// 字典的值中`-`出现的次数
NQi := len(partsArr)

// 得出相关性权重
IDF := math.Log10((float64(N-NQi) + 0.5) / (float64(NQi) + 0.5))

某个词和包含它的某个页面的“相关性得分”

这个表达式看起来是不是很复杂,但是它的复杂度是为了处理查询语句里面某一个关键词出现了多次的情况,例如“八百标兵奔北坡,炮兵并排北边跑。炮兵怕把标兵碰,标兵怕碰炮兵炮。”,“炮兵”这个词出现了 3 次。为了能快速实现一个能用的搜索引擎,我们放弃支持这种情况,然后这个看起来就刺激的表达式就可以简化成下面这种形式:

需要注意的是,这里面的大写的 K 依然是上面那个略微复杂的样式。我们取 k1 为 2,b 为 0.75,页面(文档)平均长度我自己跑了一个,13214,你们可以用我这个数,也可以自己跑一个用。

我的代码实现如下:

// 使用 - 切分后的值,为此页面的字典值,形式为:
// 110,85,1,195653,7101
ints := strings.Split(p, ",")

// 这个词在这个页面中出现总次数
Fi, err := strconv.Atoi(ints[2])
// 这个页面的长度
Dj, _ := strconv.Atoi(ints[3])

k1 := 2.0
b := 0.75

// 页面平均长度
avgDocLength := 13214.0

// 得到相关性得分
RQiDj := (float64(Fi) * (k1 + 1)) / (float64(Fi) + k1*(1-b+b*(float64(Dj)/avgDocLength)))

怎么样,是不是比你想象的简单?

检验搜索结果

我在我搞的“翰哥搜索”页面上搜了一下“BM25 算法的数学描述”,结果如下:

我搜索“住范儿”,结果如下:

第一个就是我们官网,可以说相当精准了。

看起来效果还不错,要知道这只是在 784 万的网页中搜索的结果哦,如果你有足够的服务器资源,能搞定三亿个页面的爬取、索引和查询的话,效果肯定更加的好。

如何继续提升搜索准确性?

目前我们的简化版 BM25 算法的搜索结果已经达到能用的水平了,还能继续提升搜索准确性吗?还可以:

  1. 本文全部是基于分词做的字典,你可以再做一份基于单字的,然后把单字的搜索结果排序和分词的搜索结果进行结合,搜索结果可以更准。
  2. 相似的原理,打造更加合理、更加丰富的分词方式,构造不同倾向的词典,可以提升特定领域的搜索结果表现,例如医学领域、代码领域等。
  3. 打造你自己的 PageRank 技术,从 URL 之间关系的角度,给单个 URL 的价值进行打分,并将这个价值分数放进搜索结果的排序参数之中。
  4. 引入 proximity 相似性计算,不仅考虑精确匹配的关键词,还要考虑到含义相近的关键词的搜索结果。
  5. 特殊查询的处理:修正用户可能的输入错误,处理中文独特的“拼音匹配”需求等。

参考资料

  1. 【NLP】非监督文本匹配算法——BM25 https://zhuanlan.zhihu.com/p/499906089
  2. 《自制搜索引擎》—— [日]山田浩之、末永匡

文章结束了,你学废了吗?欢迎到下列位置留下你的评论:

  1. Github:https://github.com/johnlui/DIY-Search-Engine
  2. 博客:https://pphc.lvwenhan.com/tech-epic/2023/diy-search-engine 【全文完】

本项目运行方法

首先,给自己准备一杯咖啡。

  1. 把本项目下载到本地
  2. 编译:go build -o ese *.go
  3. 修改配置文件:cp .env.example .env,然后把里面的数据库和 Redis 配置改成你的
  4. 执行./ese art init创建数据库
  5. 手动插入一个真实的 URL 到 pages_00 表中,只需要填充 url 和 host 两个字段
  6. 执行./ese或./ese all,静待好事发生 ☕️

如果需要按角色独立部署,可以使用下面的命令:

  1. Web 查询服务:./ese serve
  2. 爬虫 worker:./ese crawler
  3. 后台调度器:./ese scheduler
  4. 字典转移 worker:./ese indexer

常用吞吐参数可以直接在.env中调整:CRAWL_BATCH、CRAWL_WORKERS、PREPARE_BATCH、INDEX_PAGES_PER_SHARD、INDEX_TRANSFER_BATCH、INDEX_TRANSFER_WORD_DEPTH。

过一段时间,等字典数据表word_dics里面填充了数据之后,打开http://127.0.0.1:10086,尝试搜一下吧!

更多项目运行信息,请见 wiki

网页直接阅读:https://pphc.lvwenhan.com/tech-epic/2023/diy-search-engine

作者信息:

  1. 姓名:吕文翰
  2. GitHub:[johnlui](https://gi

Issues· 0 open

View all issuesOpen on GitHub

No open issues yet, or sync has not completed.

> Tags

Go

No comments yet. Be the first to share.

> Details

PublishedAug 1, 2026
UpdatedSep 17, 2026
Category编程语言
PricingOpen source

> Related tools

T
TypeScript
JavaScript 的超集,为前端与全栈提供静态类型
P
Python
通用编程语言,广泛用于 Web、数据与 AI
G
Go
Google 推出的简洁高效系统语言