├── .env.example
├── .gitignore
├── Dockerfile
├── README.md
├── art.go
├── controllers
├── search.go
└── status.go
├── cron.go
├── db
└── db.go
├── dict
├── hmm_model.utf8
├── idf.utf8
├── init.go
├── jieba.dict.utf8
├── stop_words.utf8
└── user.dict.utf8
├── docker-compose.yaml
├── go.mod
├── go.sum
├── main.go
├── models
├── host_count.go
├── page.go
├── status.go
└── word_dic.go
├── tools
├── curl.go
├── debug.go
├── number.go
└── string.go
└── views
├── index.tpl
└── search.tpl
/.env.example:
--------------------------------------------------------------------------------
1 | APP_ENV=local
2 | APP_DEBUG=true
3 | PORT=10086
4 |
5 | DB_HOST0=127.0.0.1
6 | DB_PORT0=3306
7 | DB_DATABASE0=test
8 | DB_USERNAME0=root
9 | DB_PASSWORD0=
10 |
11 | DB_HOST_DIC=127.0.0.1
12 | DB_PORT_DIC=3306
13 | DB_DATABASE_DIC=test
14 | DB_USERNAME_DIC=root
15 | DB_PASSWORD_DIC=
16 |
17 | REDIS_HOST=127.0.0.1
18 | REDIS_PORT=6379
19 | REDIS_PASSWORD=
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ese
2 | .env
3 | gorm-log.txt
4 | tmp
5 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # 使用 golang 官方镜像作为基础镜像
2 | FROM golang:latest AS builder
3 |
4 | # 设置工作目录
5 | WORKDIR /app
6 |
7 | # 拷贝项目文件到工作目录
8 | COPY . .
9 |
10 | # 编译项目
11 | RUN go build -o ese *.go
12 |
13 | # 使用 Alpine Linux 作为基础镜像
14 | FROM alpine:latest
15 |
16 | # 设置工作目录
17 | WORKDIR /app
18 |
19 | # 从前一个镜像中拷贝编译好的可执行文件到当前镜像
20 | COPY --from=builder /app/ese .
21 |
22 | # 拷贝配置文件
23 | COPY .env.example .env
24 |
25 | # 替换配置文件中的数据库和 Redis 配置
26 |
27 | # 初始化数据库
28 | RUN ./ese art init
29 |
30 | # 手动插入一个真实的 URL 到 pages_00 表中
31 |
32 | # 暴露端口
33 | EXPOSE 8080
34 |
35 | # 启动应用
36 | CMD ["./ese"]
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Go 开发的开源互联网搜索引擎
2 |
3 |
4 | DIYSearchEngine 是一个能够高速采集海量互联网数据的开源搜索引擎,采用 Go 语言开发。
5 |
6 | > #### 想运行本项目请拉到项目底部,有[教程](#本项目运行方法)。
7 |
8 |
9 |
10 |
《两万字教你自己动手开发互联网搜索引擎》
11 |
12 | ## 写在前面
13 |
14 | 本文是一篇教你做“合法搜索引擎”的文章,一切都符合《网络安全法》和 robots 业界规范的要求,如果你被公司要求爬一些上了反扒措施的网站,我个人建议你马上离职,我已经知道了好几起全公司数百人被一锅端的事件。
15 |
16 | ## 《爬乙己》
17 |
18 | 搜索引擎圈的格局,是和别处不同的:只需稍作一番查考,便能获取一篇又一篇八股文,篇篇都是爬虫、索引、排序三板斧。可是这三板斧到底该怎么用代码写出来,却被作者们故意保持沉默,大抵可能确实是抄来的罢。
19 |
20 | 从我年方二十,便开始在新浪云计算店担任一名伙计,老板告诉我,我长相过于天真,无法应对那些难缠的云计算客户。这些客户时刻都要求我们的服务在线,每当出现故障,不到十秒钟电话就会纷至沓来,比我们的监控系统还要迅捷。所以过了几天,掌柜又说我干不了这事。幸亏云商店那边要人,无须辞退,便改为专管云商店运营的一种无聊职务了。
21 |
22 | 我从此便整天的坐在电话后面,专管我的职务。虽然只需要挨骂道歉,损失一些尊严,但总觉得有些无聊。掌柜是一副凶脸孔,主顾也没有好声气,教人活泼不得;只有在午饭后,众人一起散步时闲谈起搜索引擎,才能感受到几许欢笑,因此至今仍深刻铭记在心。
23 |
24 | 由于谷歌被戏称为“哥”,本镇居民就为当地的搜索引擎取了一个绰号,叫作度娘。
25 |
26 | 度娘一出现,所有人都笑了起来,有的叫到,“度娘,你昨天又加法律法规词了!”他不回答,对后台说,“温两个热搜,要一碟文库豆”,说着便排出九枚广告。我们又故意的高声嚷道,“你一定又骗了人家的钱了!”度娘睁大眼睛说,“你怎么这样凭空污人清白……”“什么清白?我前天亲眼见你卖了莆田系广告,第一屏全是。”度娘便涨红了脸,额上的青筋条条绽出,争辩道,“广告不能算偷……流量!……互联网广告的事,能算偷么?”接连便是难懂的话,什么“免费使用”,什么“CPM”之类,引得众人都哄笑起来:店内外充满了快活的空气。
27 |
28 | ## 本文目标
29 |
30 | 三板斧文章遍地都是,但是真的自己开发出来搜索引擎的人却少之又少,其实,开发一个搜索引擎没那么难,数据量也没有你想象的那么大,倒排索引也没有字面上看着那么炫酷,BM25 算法也没有它的表达式看起来那么夸张,只给几个人用的话也没多少计算压力。
31 |
32 | 突破自己心灵的枷锁,只靠自己就可以开发一个私有的互联网搜索引擎!
33 |
34 | 本文是一篇“跟我做”文章,只要你一步一步跟着我做,最后就可以得到一个可以运行的互联网搜索引擎。本文的后端语言采用 Golang,内存数据库采用 Redis,字典存储采用 MySQL,不用费尽心思地研究进程间通信,也不用绞尽脑汁地解决多线程和线程安全问题,也不用自己在磁盘上手搓 B+ 树致密排列,站着就把钱挣了。
35 |
36 | ## 目录
37 |
38 | 把大象装进冰箱,只需要三步:
39 |
40 | 1. 编写高性能爬虫,从互联网上爬取网页
41 | 2. 使用倒排索引技术,将网页拆分成字典
42 | 3. 使用 BM25 算法,返回搜索结果
43 |
44 | ## 第一步,编写高性能爬虫,从互联网上爬取网页
45 |
46 | Golang 的协程使得它特别适合拿来开发高性能爬虫,只要利用外部 Redis 做好“协程间通信”,你有多少 CPU 核心 go 都可以吃完,而且代码写起来还特别简单,进程和线程都不需要自己管理。当然,协程功能强大,代码简略,这就导致它的 debug 成本很高:我在写协程代码的时候感觉自己像在炼丹,修改一个字符就可以让程序从龟速提升到十万倍,简直比操控 ChatGPT 还神奇。
47 |
48 | 在编写爬虫之前,我们需要知道从互联网上爬取内容需要遵纪守法,并遵守`robots.txt`,否则,可能就要进去和前辈们切磋爬虫技术了。robots.txt 的具体规范大家可以自行搜索,下面跟着我开搞。
49 |
50 | 新建 go 项目我就不演示了,不会的可以问一下 ChatGPT~
51 |
52 | ### 爬虫工作流程
53 |
54 | 我们先设计一个可以落地的爬虫工作流程。
55 |
56 | #### 1. 设计一个 UA
57 |
58 | 首先我们要给自己的爬虫设定一个 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`,你们可以自己设定。
59 |
60 | 需要注意的是,部分网站会屏蔽非头部搜索引擎的爬虫,这个需要你们转动聪明的小脑袋瓜自己解决哦。
61 |
62 | #### 2. 选择一个爬虫工具库
63 |
64 | 我选择的是 [PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery),它支持自定义 UA 爬取,并可以对爬到的 HTML 页面进行解析,进而得到对我们的搜索引擎十分重要的页面标题、超链接等。
65 |
66 | #### 3. 设计数据库
67 |
68 | 爬虫的数据库倒是特别简单,一个表即可。这个表里面存着页面的 URL 和爬来的标题以及网页文字内容。
69 |
70 | ```sql
71 | CREATE TABLE `pages` (
72 | `id` int unsigned NOT NULL AUTO_INCREMENT,
73 | `url` varchar(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '网页链接',
74 | `host` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '域名',
75 | `dic_done` tinyint DEFAULT '0' COMMENT '已拆分进词典',
76 | `craw_done` tinyint NOT NULL DEFAULT '0' COMMENT '已爬',
77 | `craw_time` timestamp NOT NULL DEFAULT '2001-01-01 00:00:00' COMMENT '爬取时刻',
78 | `origin_title` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '上级页面超链接文字',
79 | `referrer_id` int NOT NULL DEFAULT '0' COMMENT '上级页面ID',
80 | `scheme` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'http/https',
81 | `domain1` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '一级域名后缀',
82 | `domain2` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '二级域名后缀',
83 | `path` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'URL 路径',
84 | `query` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'URL 查询参数',
85 | `title` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '页面标题',
86 | `text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '页面文字',
87 | `created_at` timestamp NOT NULL DEFAULT '2001-01-01 08:00:00' COMMENT '插入时间',
88 | PRIMARY KEY (`id`)
89 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
90 | ```
91 |
92 | #### 4. 给爷爬!
93 |
94 | 爬虫有一个极好的特性:自我增殖。每一个网页里,基本都带有其他网页的链接,这样我们就可以道生一,一生二,二生三,三生万物了。
95 |
96 | 此时,我们只需要找一个导航网站,手动把该网站的链接插入到数据库里,爬虫就可以开始运作了。各位可以自行挑选可口的页面链接服用。
97 |
98 | 我们正式进入实操阶段,以下都是可以运行的代码片段,代码逻辑在注释里面讲解。
99 |
100 | 我采用`joho/godotenv`来提供`.env`配置文件读取的能力,你需要提前准备好一个`.env`文件,并在里面填写好可以使用的 MySQL 数据库信息,具体可以参考项目中的`.env.example`文件。
101 |
102 | ```go
103 | func main() {
104 | fmt.Println("My name id enterprise-search-engine!")
105 |
106 | // 加载 .env
107 | initENV() // 该函数的具体实现可以参考项目代码
108 |
109 | // 开始爬
110 | nextStep(time.Now())
111 |
112 | // 阻塞,不跑爬虫时用于阻塞主线程
113 | select {}
114 | }
115 |
116 | // 循环爬
117 | func nextStep(startTime time.Time) {
118 | // 初始化 gorm 数据库
119 | dsn0 := os.Getenv("DB_USERNAME0") + ":" +
120 | os.Getenv("DB_PASSWORD0") + "@(" +
121 | os.Getenv("DB_HOST0") + ":" +
122 | os.Getenv("DB_PORT0") + ")/" +
123 | os.Getenv("DB_DATABASE0") + "?charset=utf8mb4&parseTime=True&loc=Local"
124 | gormConfig := gorm.Config{}
125 | db0, _ := gorm.Open(mysql.Open(dsn0), &gormConfig)
126 |
127 | // 从数据库里取出本轮需要爬的 100 条 URL
128 | var pagesArray []models.Page
129 | db0.Table("pages").
130 | Where("craw_done", 0).
131 | Order("id").Limit(100).Find(&pagesArray)
132 |
133 | tools.DD(pagesArray) // 打印结果
134 |
135 | // 限于篇幅,下面用文字描述
136 | 1. 循环展开 pagesArray
137 | 2. 针对每一个 page,使用 curl 工具类获取网页文本
138 | 3. 解析网页文本,提取出标题和页面中含有的超链接
139 | 4. 将标题、一级域名后缀、URL 路径、插入时间等信息补充完全,更新到这一行数据上
140 | 5. 将页面上的超链接插入 pages 表,我们的网页库第一次扩充了!
141 |
142 |
143 | fmt.Println("跑完一轮", time.Now().Unix()-startTime.Unix(), "秒")
144 |
145 | nextStep(time.Now()) // 紧接着跑下一条
146 | }
147 | ```
148 |
149 | 
150 |
151 | 我已经事先将 hao123 的链接插入了 pages 表,所以我运行`go build -o ese *.go && ./ese`命令之后,得到了如下信息:
152 |
153 | ```ruby
154 | My name id enterprise-search-engine!
155 | 加载.env : /root/enterprise-search-engine/.env
156 | APP_ENV: local
157 | [[{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}]]
158 | ```
159 |
160 | 
161 | 《递龟》
162 |
163 | 上面的代码中,我们第一次用到了~~递龟~~递归:自己调用自己。
164 |
165 | #### 5. 合法合规:遵守 robots.txt 规范
166 |
167 | 我选择用`temoto/robotstxt`这个库来探查我们的爬虫是否被允许爬取某个 URL,使用一张单独的表来存储每个域名的 robots 规则,并在 Redis 中建立缓存,每次爬取 URL 之前,先进行一次匹配,匹配成功后再爬,保证合法合规。
168 |
169 | ### 制造真正的生产级爬虫
170 |
171 | 
172 | 《怎样画马》
173 |
174 | 有了前面这个理论上可以运行的简单爬虫,下面我们就要给这匹马补充亿点细节了:生产环境中,爬虫性能优化是最重要的工作。
175 |
176 | 从某种程度上来说,搜索引擎的优劣并不取决于搜索算法的优劣,因为算法作为一种“特定问题的简便算法”,一家商业公司比别家强的程度很有限,搜索引擎的真正优劣在于哪家能够以最快的速度索引到互联网上层出不穷的新页面和已经更新过内容的旧页面,在于哪家能够识别哪个网页是价值最高的网页。
177 |
178 | 识别网页价值方面,李彦宏起家的搜索专利,以及谷歌大名鼎鼎的 PageRank 都拥有异曲同工之妙。但本文的重点不在这个领域,而在于技术实现。让我们回到爬虫性能优化,为什么性能优化如此重要呢?我们构建的是互联网搜索引擎,需要爬海量的数据,因此我们的爬虫需要足够高效:中文互联网有 400 万个网站,3500 亿个网页,哪怕只爬千分之一,3.5 亿个网页也不是开玩笑的,如果只是单线程阻塞地爬,消耗的时间恐怕要以年为单位了。
179 |
180 | 爬虫性能优化,我们首先需要规划一下硬件。
181 |
182 | #### 硬件要求
183 |
184 | 首先计算磁盘空间,假设一个页面 20KB,在不进行压缩的情况下,一亿个页面就需要 `20 * 100000000 / 1024 / 1024 / 1024 = 1.86TB` 的磁盘空间,而我们打算使用 MySQL 来存储页面文本,需要的空间会更大一点。
185 |
186 | 我的爬虫花费了 2 个月的时间,爬到了大约 1 亿个 URL,其中 3600 万个爬到了页面的 HTML 文本存在了数据库里,共消耗了超过 600GB 的磁盘空间。
187 |
188 | 除了硬性的磁盘空间,剩下的就是尽量多的 CPU 核心数和内存了:CPU 拿来并发爬网页,内存拿来支撑海量协程的消耗,外加用 Redis 为爬虫提速。爬虫阶段对内存的要求还不大,但在后面第二步拆分字典的时候,大内存容量的 Redis 将成为提速利器。
189 |
190 | 所以,我们对硬件的需求是这样的:一台核心数尽量多的物理机拿来跑我们的 ese 二进制程序,外加高性能数据库(例如16核64GB内存,NVME磁盘),你能搞到多少台数据库就准备多少台,就算你搞到了 65536 台数据库,也能跑满,理论上我们可以无限分库分表。能这么搞是因为网页数据具有离散性,相互之间的关系在爬虫和字典阶段还不存在,在查询阶段才比较重要。
191 |
192 | 顺着这个思路,有人可能就会想,我用 KV 数据库例如 MongoDB 来存怎么样呢?当然是很好的,但是MongoDB 不适合干的事情实在是太多啦,所以你依然需要 Redis 和 MySQL 的支持,如果你需要爬取更大规模的网页,可以把 MongoDB 用起来,利用进一步推高系统复杂度的方式获得一个显著的性能提升。
193 |
194 | 下面我们开始进行软件优化,我只讲述关键步骤,各位有什么不明白的地方可以参考项目代码。
195 |
196 | #### 重用 HTTP 客户端以防止内存泄露
197 |
198 | 这个点看起来很小,但当你瞬间并发数十万协程的时候,每个协程 1MB 的内存浪费累积起来都是巨大的,很容易造成 OOM。
199 |
200 | 我们在 tools 文件夹下创建`curl.go`工具类,专门用来存放[全局 client](https://req.cool/zh/docs/tutorial/best-practices/#%e9%87%8d%e7%94%a8-client) 和 curl 工具函数:
201 |
202 | ```go
203 | package tools
204 |
205 | import ... //省略,具体可以参考项目代码
206 |
207 | // 全局重用 client 对象,4 秒超时,不跟随 301 302 跳转
208 | var client = req.C().SetTimeout(time.Second * 4).SetRedirectPolicy(req.NoRedirectPolicy())
209 |
210 | // 返回 document 对象和状态码
211 | func Curl(page models.Page, ch chan int) (*goquery.Document, int) {
212 | ... //省略,具体可以参考项目代码
213 | }
214 | ```
215 |
216 | #### 基础知识储备:goroutine 协程
217 |
218 | 我默认你已经了解 go 协程是什么了,它就是一个看起来像魔法的东西。在这里我提供一个理解协程的小诀窍:每个协程在进入磁盘、网络等“只需要后台等待”的任务之后,会把当前 CPU 核心(可以理解成一个图灵机)的指令指针 goto 到下一个协程的起始。
219 |
220 | 需要注意的是,协程是一种特殊的并发形式,你在并发函数内调用的函数必须都支持并发调用,类似于传统的“线程安全”,如果你一不小心写了不安全的代码,轻则卡顿,重则 crash。
221 |
222 | #### 一次取出一批需要爬的 URL,使用协程并发爬
223 |
224 | 协程代码实操来啦!
225 |
226 | ```go
227 | // tools.DD(pagesArray) // 打印结果
228 |
229 | // 创建 channel 数组
230 | chs := make([]chan int, len(pagesArray))
231 | // 展开 pagesArray 数组
232 | for k, v := range pagesArray {
233 | // 存储 channel 指针
234 | chs[k] = make(chan int)
235 | // 阿瓦达啃大瓜!!
236 | go craw(v, chs[k], k)
237 | }
238 |
239 | // 注意,下面的代码不可省略,否则你上面 go 出来的那些协程会瞬间退出
240 | var results = make(map[int]int)
241 | for _, ch := range chs {
242 | // 神之一手,收集来自协程的返回数据,并 hold 主线程不瞬间退出
243 | r := <-ch
244 |
245 | _, prs := results[r]
246 | if prs {
247 | results[r] += 1
248 | } else {
249 | results[r] = 1
250 | }
251 | }
252 | // 当代码执行到这里的时候,说明所有的协程都已经返回数据了
253 |
254 | fmt.Println("跑完一轮", time.Now().Unix()-startTime.Unix(), "秒")
255 | ```
256 |
257 | `craw`函数协程化:
258 |
259 | ```go
260 |
261 | // 真的爬,存储标题,内容,以及子链接
262 | func craw(status models.Page, ch chan int, index int) {
263 | // 调用 CURL 工具类爬到网页
264 | doc, chVal := tools.Curl(status, ch)
265 |
266 | // 对 doc 的处理在这里省略
267 |
268 | // 最重要的一步,向 chennel 发送 int 值,该动作是协程结束的标志
269 | ch <- chVal
270 | return
271 | }
272 | ```
273 |
274 | 协程优化做完了,CPU 被吃满了,接下来数据库要成为瓶颈了。
275 |
276 | ### MySQL 性能优化
277 |
278 | 做到这里,在做普通业务逻辑的时候非常快的 MySQL 已经是整个系统中最慢的一环了:pages 表一天就要增加几百万行,MySQL 会以肉眼可见的速度慢下来。我们要对 MySQL 做性能优化。
279 |
280 | #### 何以解忧,唯有索引
281 |
282 | 首先,收益最大的肯定是加索引,这句话适用于 99% 的场景。
283 |
284 | 在你磁盘容量够用的情况下,加索引通常可以获得数百倍到数万倍的性能提升。我们先给 url 加个索引,因为我们每爬到一个 URL 都要查一下它是否已经在表里面存在了,这个动作的频率是非常高的,如果我们最终爬到了一亿个页面,那这个对比动作至少会做百亿次。
285 |
286 | #### 部分场景下很好用的分库分表
287 |
288 | 非常幸运,爬虫场景和分库分表非常契合:只要我们能根据 URL 将数据均匀地分散开,不同的 URL 之间是没有多少关系的。那我们该怎么将数据分散开呢?使用散列值!
289 |
290 | 每一个 URL 在 MD5 之后,都会得到一个形如`698d51a19d8a121ce581499d7b701668`的 32 位长度的 16 进制数。而这些数字在概率上是均等的,所以理论上我们可以将数亿个 URL 均匀分布在多个库的多个表里。下面问题来了,该怎么分呢?
291 |
292 | #### 只有一台数据库,应该分表吗?
293 |
294 | 如果你看过我的[《高并发的哲学原理(八)-- 将 InnoDB 剥的一丝不挂:B+ 树与 Buffer Pool
295 | 》](https://lvwenhan.com/tech-epic/506.html)的话,就会明白,只要你能接受分表的逻辑代价,那在任何大数据量场景下分表都是有明显收益的,因为随着表容量的增加,那棵 16KB 页块组成的 B+ 树的复杂度增加是超线性的,用牛逼的话说就是:二阶导数持续大于 0。此外,缓存也会失效,你的 MySQL 运行速度会降低到一个令人发指的水平。
296 |
297 | 所以,即便你只有一台数据库,那也应该分表。如果你的磁盘是 NVME,我觉得单机拿出 MD5 的前两位数字,分出来 16 x 16 = 256 个表是比较不错的。
298 |
299 | 当然,如果你能搞到 16 台数据库服务器,那拿出第一位 16 进制数字选定物理服务器,再用二三位数字给每台机器分 256 个表也是极好的。
300 |
301 | #### 我的真实硬件和分表逻辑
302 |
303 | 由于我司比较节俭~~贫穷~~,机房的服务器都是二手的,实在是拿不出高性能的 NVME 服务器,于是我找 IT 借了两台 ThinkBook 14 寸笔记本装上了 CentOS Stream 9:
304 |
305 | 1. 把内存扩充到最大,形成了 8GB 板载 + 32GB 内存条一共 40GB 的奇葩配置
306 | 2. CPU 是 AMD Ryzen 5 5600U,虽然是低压版的 CPU,只有六核十二线程,但是也比 Intel 的渣渣 CPU 快多了(Intel:牙膏真的挤完了,一滴都没有了)
307 | 3. 磁盘就用自带的 500GB NVME,实测读写速度能跑到 3GB/2GB,十分够用
308 |
309 | 由于单台机器只有 6 核,我就各给他们分了 128 个表,在每次要执行 SQL 之前,我会先用 URL 作为参数获取一下它对应的数据库服务器和表名。表名获取逻辑如下:
310 |
311 | 1. 计算此 URL 的 MD5 散列值
312 | 2. 取前两位十六进制数字
313 | 3. 拼接成类似`pages_0f`样子的表名
314 |
315 | ```go
316 | tableName := table + "_" + tools.GetMD5Hash(url)[0:2]
317 | ```
318 |
319 | ### 爬虫数据流和架构优化
320 |
321 | 上面我们已经使用协程把 CPU 全部利用起来了,又使用分库分表技术把数据库硬件全部利用起来了,但是如果你这个时候直接用上面的代码开始跑,会发现速度还是不够快:因为某些工作 MySQL 还是不擅长做。
322 |
323 | 此时,我们就需要对数据流和架构做出优化了。
324 |
325 | #### 拆分仓库表和状态表
326 |
327 | 原始的 pages 表有 16 个字段,在我们爬的过程中,只用得到五个:`id` `url` `host` `craw_done` `craw_time`。而看过我上面的 InnoDB 文章的小伙伴还知道,在页面 HTML 被填充进`text`字段之后,pages 表的 16KB 页块会出现频繁的调整和指针的乱飞,对 InnoDB 的“局部性”性能涡轮的施展非常不利,会造成 buffer pool 的频繁失效。
328 |
329 | 所以,为了爬的更快,为 pages 表打造一个性能更强的“影子”就十分重要。于是,我为`pages_0f`表打造了只包含上面五个字段的`status_0f`兄弟表,数据从 pages 表里面复制而来,承担一些频繁读写任务:
330 |
331 | 1. 检查 URL 是否已经在库,即如果以前别的页面上已经出现了这个 URL 了,本次就不需要再入库了
332 | 2. 找出下一批需要爬的页面,即`craw_done=0`的 URL
333 | 3. craw_time 承担日志的作用,用于统计过去一段时间的爬虫效率
334 |
335 | 除了这些高频操作,存储页面 HTML 和标题等信息的低频操作是可以直接入`paqes_0f`仓库表的。
336 |
337 | #### 实时读取 URL 改为后台定时读取
338 |
339 | 随着单表数据量的逐渐提升,每一轮开始时从数据库里面批量读出需要爬的 URL 成了一个相对耗时的操作,即便每张表只需要 500ms,那轮询 256 张表总耗时也达到了 128 秒之多,这是无法接受的,所以这个流程也需要异步化。你问为什么不异步同时读取 256 张表?因为 MySQL 最宝贵的就是连接数,这样会让连接数直接爆掉,大家都别玩了,关于连接数我们下面还会有提及。
340 |
341 | 我们把流程调整一下:每 20 秒从 status 表中搜罗一批需要爬的 URL 放进 Redis 中积累起来,爬的时候直接从 Redis 中读一批。这么做是为了把每一秒的时间都利用起来,尽力填满协程爬虫的胃口。
342 |
343 | ```go
344 | // 在 main() 中注册定时任务
345 | c := cron.New(cron.WithSeconds())
346 | // 每 20 秒执行一次 prepareStatusesBackground 函数
347 | c.AddFunc("*/20 * * * * *", prepareStatusesBackground)
348 | go c.Start()
349 |
350 | // prepareStatusesBackground 函数中,使用 LPush 向有序列表的头部插入 URL
351 | for _, v := range _statusArray {
352 | taskBytes, _ := json.Marshal(v)
353 | db.Rdb.LPush(db.Ctx, "need_craw_list", taskBytes)
354 | }
355 |
356 | // 每一轮都使用 RPop 从有序列表的尾部读取需要爬的 URL
357 | var statusArr []models.Status
358 | maxNumber := 1 // 放大倍数,控制每一批的 URL 数量
359 | for i := 0; i < 256*maxNumber; i++ {
360 | jsonString := db.Rdb.RPop(db.Ctx, "need_craw_list").Val()
361 | var _status models.Status
362 | err := json.Unmarshal([]byte(jsonString), &_status)
363 | if err != nil {
364 | continue
365 | }
366 | statusArr = append(statusArr, _status)
367 | }
368 | ```
369 |
370 | #### 十分重要的爬虫压力管控
371 |
372 | 过去十年,中国互联网每次有搜索引擎新秀崛起,我都要被新爬虫 DDOS 一遍,想想就气。这帮大厂的菜鸟程序员,以为随便一个网站都能承受住 2000 QPS,实际上互联网上 99.9% 网站的极限 QPS 到不了 100,超过 10 都够呛。对了,如果有 YisouSpider 的人看到本文,请回去推动一下你们的爬虫优化,虽然你们的爬虫不会持续高速爬取,但是你们在每分钟的第一秒并发 10 个请求的方法更像是 DDOS,对系统的危害更大...
373 |
374 | 我们要像谷歌那样,做一个压力均匀的文明爬虫,这就需要我们把每一个域名的爬虫频率都记录下来,并实时进行调整。我基于 Redis 和每个 URL 的 host 做了一个计数器,在每次真的要爬某个 URL 之前,调用一次检测函数,看是否对单个域名的爬虫压力过大。
375 |
376 | 此外,由于我们的 craw 函数是协程调用的,此时 Redis 就显得更为重要了:它能提供宝贵的“线程安全数据读写”功能,如果你也是`sync.Map`的受害者,我相信你一定懂我😭
377 |
378 | > #### 我认为,单线程的 Redis 是 go 协程最佳的伙伴,就像 PHP 和 MySQL 那样。
379 |
380 | 具体代码我就不放了,有需要的同学可以自己去看项目代码哦。
381 |
382 | #### 疯狂使用 Redis 加速频繁重复的数据库调用
383 |
384 | 我们使用协程高速爬到数据了,下一步就是存储这些数据。这个操作看起来很简单,更新一下原来那一行,再插入 N 行新数据不就行了吗,其实不行,还有一个关键步骤需要使用 Redis 来加速:新爬到的 URL 是否已经在数据库里存在了。这个操作看起来简单,但在我们解决了上面这些性能问题以后,庞大的数量就成了这一步最大的问题,每一次查询会越来越慢,查询字数还特别多,这谁顶得住。
385 |
386 | 如果我们拿 Redis 来存 URL,岂不是需要把所有 URL 都存入 Redis 吗,这内存需求也太大了。这个时候,我们的老朋友,`局部性`又出现了:由于我们的爬虫是按照顺序爬的,那“朋友的朋友也是朋友”的概率是很大的,所以我们只要在 Redis 里记录一下某条 URL 是否存在,那之后一段时间,这个信息被查到的概率也很大:
387 |
388 | ```go
389 | // 我们使用一个 Hash 来存储 URL 是否存在的状态
390 | statusHashMapKey := "ese_spider_status_exist"
391 | statusExist := db.Rdb.HExists(db.Ctx, statusHashMapKey, _url).Val()
392 | // 若 HashMap 中不存在,则查询或插入数据库
393 | if !statusExist {
394 | ··· 代码省略,不存在则创建这行 page,存在则更新信息 ···
395 | // 无论是否新插入了数据,都将 _url 入 HashMap
396 | db.Rdb.HSet(db.Ctx, statusHashMapKey, _url, 1).Err()
397 | }
398 | ```
399 |
400 | 这段代码看似简单,实测非常好用,唯一的问题就是不能运行太长时间,隔一段时间得清空一次,因为随着时间的流逝,局部性会越来越差。
401 |
402 | 细心的小伙伴可能已经发现了,既然爬取状态已经用 Redis 来承载了,那还需要区分 pages 和 status 表吗?需要,因为 Redis 也不是全能的,它的基础数据依然是来自 MySQL 的。目前这个架构类似于复杂的三级火箭,看起来提升没那么大,但这小小的提速可能就能让你爬三亿个网页的时间从 3 个月缩减到 1 个月,是非常值的。
403 |
404 | 另外,如果通过扫描 256 张表中 craw_time 字段的方式来统计“过去 N 分钟爬了多少个 URL、有效页面多少个、因为爬虫压力而略过的页面多少个、网络错误的多少个、多次网络错误后不再重复爬取的多少个”的数据,还是太慢了,也太消耗资源了,这些统计信息也需要使用 Redis 来记录:
405 |
406 | ```go
407 | // 过去一分钟爬到了多少个页面的 HTML
408 | allStatusKey := "ese_spider_all_status_in_minute_" + strconv.Itoa(int(time.Now().Unix())/60)
409 | // 计数器加 1
410 | db.Rdb.IncrBy(db.Ctx, allStatusKey, 1).Err()
411 | // 续命 1 小时
412 | db.Rdb.Expire(db.Ctx, allStatusKey, time.Hour).Err()
413 |
414 | // 过去一分钟从新爬到的 HTML 里面提取出了多少个新的待爬 URL
415 | newStatusKey := "ese_spider_new_status_in_minute_" + strconv.Itoa(int(time.Now().Unix())/60)
416 | // 计数器加 1
417 | db.Rdb.IncrBy(db.Ctx, newStatusKey, 1).Err()
418 | // 续命 1 小时
419 | db.Rdb.Expire(db.Ctx, newStatusKey, time.Hour).Err()
420 | ```
421 |
422 | ### 生产爬虫遇到的其他问题
423 |
424 | 在我们不断提高爬虫速度的过程中,爬虫的复杂度也在持续上升,我们会遇到玩具爬虫遇不到的很多问题,接下来我分享一下我的处理经验。
425 |
426 | #### 抑制暴增的数据库连接数
427 |
428 | 在协程这个大杀器的协助之下,我们可以轻易写出超高并行的代码,把 CPU 全部吃完,但是,并行的协程多了以后,数据库的连接数压力也开始暴增。MySQL 默认的最大连接数只有 151,根据我的实际体验,哪怕是一个协程一个连接,我们这个爬虫也可以轻易把连接数干到数万,这个数字太大了,即便是最新的 CPU 加上 DDR5 内存,受制于 MySQL 算法的限制,在连接数达到这个级别以后,处理海量连接数所需要的时间也越来越多。这个情况和[《高并发的哲学原理(二)-- Apache 的性能瓶颈与 Nginx 的性能优势》](https://lvwenhan.com/tech-epic/500.html)一文中描述的 Apache 的 prefork 模式比较像。好消息是,最近版本的 MySQL 8 针对连接数匹配算法做了优化,大幅提升了大量连接数下的性能。
429 |
430 | 除了协程之外,分库分表对连接数的的暴增也负有不可推卸的责任。为了提升单条 SQL 的性能,我们给单台数据库服务器分了 256 张表,这种情况下,以前的一个连接+一条 SQL 的状态会突然增加到 256 个连接和 256 条 SQL,如果我们不加以限制的话,可以说协程+分表一启动,你就一定会收到海量的`Too many connections`报错。我的解决方法是,在 gorm 初始化的时候,给他设定一个“单线程最大连接数”:
431 |
432 | ```go
433 | dbdb0, _ := _db0.DB()
434 | dbdb0.SetMaxIdleConns(1)
435 | dbdb0.SetMaxOpenConns(100)
436 | dbdb0.SetConnMaxLifetime(time.Hour)
437 | ```
438 |
439 | 根据我的经验,100 个够用了,再大的话,你的 TCP 端口就要不够用了。
440 |
441 | #### 域名黑名单
442 |
443 | 我们都知道,内容农场是一种专门钻搜索引擎空子的垃圾内容生产者,爬虫很难判断哪些网站是内容农场,但是人一点进去就能判断出来。而这些域名的内部链接做的又特别好,这就导致我们需要手动给一些恶心的内容农场域名加黑名单。我们把爬到的每个域名下的 URL 数量统计一下,搞一个动态的排名,就能很容易发现头部的内容农场域名了。
444 |
445 | #### 复杂的失败处理策略
446 |
447 | > 生产代码和教学代码最大的区别就是成吨的错误处理!—— John·Lui(作者自己)
448 |
449 | 如果你真的要搞一个涵盖数亿页面的可以用的搜索引擎,你会碰到各种各样的奇葩失败,这些失败都需要拿出特别的处理策略,下面我分享一下我遇到过的问题和我的处理策略。
450 |
451 | 1. 单页面超时非常重要:如果你想尽可能地在一段时间内爬到尽量多的页面的话,缩短你 curl 的超时时间非常重要,经过摸索,我把这个时间设定到了 4 秒,既能爬到绝大多数网页,也不会浪费时间在一些根本就无法响应的 URL 上。
452 | 2. 单个 URL 错误达到一定数量以后,需要直接拉黑,不然一段时间后,你的爬虫整天就只爬那些被无数次爬取失败的 URL 上啦,一个新页面也爬不到。这个次数我设定的是 3 次。
453 | 3. 如果某个 URL 返回的 HTML 无法被解析,果断放弃,没必要花费额外资源重新爬。
454 | 4. 由于我们的数据流已经是三级火箭形态,所以在各种地方加上“动态锁”就很必要,因为很多时候我们需要手动让其他级火箭发动机暂停运行,手动检修某一级发动机。我一般拿 MySQL 来做这件事,创建一个名为`kvstores`的表,只有 key value 两个字段,需要的时候我会手动修改特定 key 对应的 value 值,让某一级发动机暂停一下。
455 | 5. 由于 curl 的结果具有不确定性,务必需要保证任何情况下,都要给 channel 返回信号量,不然你的整个应用会直接卡死。
456 | 6. 一个页面内经常会有同一个超链接重复出现,在内存里保存已经见过的 URL 并跳过重复值可以显著节约时间。
457 | 7. 我建了一个 MySQL 表来存储我手动插入的黑名单域名,这个非常好用,可以在爬虫持续运行的时候随时“止损”,停止对黑名单域名的新增爬取。
458 |
459 | 至此,我们的爬虫终于构建完成了。
460 |
461 | ### 爬虫运行架构图
462 |
463 | 现在我们的爬虫运行架构图应该是下面这样的:
464 |
465 | 
466 |
467 | 爬虫搞完了,让我们进入第二大部分。
468 |
469 | ## 第二步,使用倒排索引生成字典
470 |
471 | 那个~~男人~~一听就很牛逼的词出现了:倒排索引。
472 |
473 | 对于没搞过倒排索引的人来说,这个词听起来和“生态化反”一样牛逼,其实它非常简单,简单程度堪比 HTTP 协议。
474 |
475 | ### 倒排索引到底是什么
476 |
477 | 下面这个例子可以解释倒排索引是个什么东西:
478 |
479 | 1. 我们有一个表 titles,含有两个字段,ID 和 text,假设这个表有 100 行数据,其中第一行 text 为“爬虫工作流程”,第二行为“制造真正的生产级爬虫”
480 | 2. 我们对这两行文本进行分词,第一行可以得到“爬虫”、“工作”、“流程”三个词,第二行可以得到“制造”、“真正的”、“生产级”、“爬虫”四个词
481 | 3. 我们把顺序颠倒过来,以词为 key,以①`titles.id` ②`,` ③`这个词在 text 中的位置` 这三个元素拼接在一起为一个`值`,不同 text 生成的`值`之间以 - 作为间隔,对数据进行“反向索引”,可以得到:
482 | 1. 爬虫: 1,0-2,8
483 | 2. 工作:1,2
484 | 3. 流程:1,4
485 | 4. 制造:2,0
486 | 5. 真正的:2,2
487 | 6. 生产级:2,5
488 |
489 | 倒排索引完成了!就是这么简单。说白了,就是把所有内容都分词出来,再反向给每个词标记出“他出现在哪个文本的哪个位置”,没了,就是这么简单。下面是我生成的字典中,“辰玺”这个词的字典值:
490 |
491 | ```text
492 | 110,85,1,195653,7101-66,111,1,195653,7101-
493 | ```
494 |
495 | 你问为什么我不找个常见的词?因为随便一个常见的词,它的字典长度都是以 MB 为单位的,根本没法放出来...
496 |
497 | #### 还有一个牛逼的词,最小完美哈希,可以用来排布字典数据,加快搜索速度,感兴趣的同学可以自行学习
498 |
499 | ### 生成倒排索引数据
500 |
501 | 理解了倒排索引是什么以后,我们就可以着手把我们爬到的 HTML 处理成倒排索引了。
502 |
503 | 我使用`yanyiwu/gojieba`这个库来调用结巴分词,按照以下步骤对我爬到的每一个 HTML 文本进行分词并归类:
504 |
505 | 1. 分词,然后循环处理这些词:
506 | 2. 统计词频:这个词在该 HTML 中出现的次数
507 | 3. 记录下这个词在该 HTML 中每一次出现的位置,从 0 开始算
508 | 4. 计算该 HTML 的总长度,搜索算法需要
509 | 5. 按照一定格式,组装成倒排索引值,形式如下:
510 |
511 | ```go
512 | // 分表的顺序,例如 0f 转为十进制为 15
513 | strconv.Itoa(i) + "," +
514 | // pages.id 该 URL 的主键 ID
515 | strconv.Itoa(int(pages.ID)) + "," +
516 | // 词频:这个词在该 HTML 中出现的次数
517 | strconv.Itoa(v.count) + "," +
518 | // 该 HTML 的总长度,BM25 算法需要
519 | strconv.Itoa(textLength) + "," +
520 | // 这个词出现的每一个位置,用逗号隔开,可能有多个
521 | strings.Join(v.positions, ",") +
522 | // 不同 page 之间的间隔符
523 | "-"
524 | ```
525 |
526 | 我们按照这个规则,把所有的 HTML 进行倒排索引,并且把生成的索引值拼接在一起,存入 MySQL 即可。
527 |
528 | ### 使用协程 + Redis 大幅提升词典生成速度
529 |
530 | 不知道大家感受到了没有,词典的生成是一个比爬虫高几个数量级的 CPU 消耗大户,一个 HTML 动辄几千个词,如果你要对数亿个 HTML 进行倒排索引,需要的计算量是非常惊人的。我爬到了 3600 万个页面,但是只处理了不到 800 万个页面的倒排索引,因为我的计算资源也有限...
531 |
532 | 并且,把词典的内容存到 MySQL 里难度也很大,因为一些常见词的倒排索引会巨长,例如“没有”这个词,真的是到处都有它。那该怎么做性能优化呢?还是我们的老朋友,协程和 Redis。
533 |
534 | #### 协程分词
535 |
536 | 两个 HTML 的分词工作之间完全没有交集,非常适合拿协程来跑。
537 |
538 | 但是,MySQL 举手了:我顶不住。所以协程的好朋友 Redis 也来了。
539 |
540 | #### 使用 Redis 做为词典数据的中转站
541 |
542 | 我们在 Redis 中针对每一个词生成一个 List,把倒排出来的索引插入到尾部:
543 |
544 | ```go
545 | db.Rdb10.RPush(db.Ctx, word, appendSrting)
546 | ```
547 |
548 | #### 使用协程从 Redis 搬运数据到 MySQL 中
549 |
550 | 你没看错,这个地方也需要使用协程,因为数据量实在是太大了,一个线程循环跑会非常慢。经过我的不断尝试,我发现每次转移 2000 个词,对 Redis 的负载比较能够接受,E5-V4 的 CPU 单核能够跑满,带宽大概 400Mbps。
551 |
552 | 从 Redis 到 MySQL 的高性能搬运方法如下:
553 |
554 | 1. 随机获取一个 key
555 | 2. 判断该 key 的长度,只有大于等于 2 的进入下一步
556 | 3. 把最后一个索引值留下,前面的元素一个一个`LPop`(弹出头部)出来,拼接在一起
557 | 4. 汇集一批 2000 个随机词的结果,append 到数据库该词现有索引值的后面
558 |
559 | 有了协程和 Redis 的协助,分词加倒排索引的速度快了起来,但是如果你选择一个词一个词地 append 值,你会发现 MySQL 又双叒叕变的超慢,又要优化 MySQL 了!🙊
560 |
561 | ### 事务的妙用:MySQL 高速批量插入
562 |
563 | 由于需要往磁盘里写东西,所以只要是一个一个 update,怎么优化都会很慢,那有没有一次性 update 多行数据的方法呢?有!那就是事务:
564 |
565 | ```go
566 | tx.Exec(`START TRANSACTION`)
567 |
568 | // 需要批量执行的 update 语句
569 | for w, s := range needUpdate {
570 | tx.Exec(`UPDATE word_dics SET positions = concat(ifnull(positions,''), ?) where name = ?`, s, w)
571 | }
572 |
573 | tx.Exec(`COMMIT`)
574 | ```
575 |
576 | 这么操作,字典写入速度一下子起来了。但是,每次执行 2000 条 update 语句对磁盘的要求非常高,我进行这个操作的时候,可以把磁盘写入速度瞬间提升到 1.5GB/S,如果你的数据库存储不够快,可以减少语句数量。
577 |
578 | ### 世界的参差:无意义的词
579 |
580 | 这个世界上的东西并不都是有用的,一个 HTML 中的字符也是如此。
581 |
582 | 首先,一般不建议索引整个 HTML,而是把他用 DOM 库处理一下,提取出文本内容,再进行索引。
583 |
584 | 其次,即便是你已经过滤掉了所有的 html 标签、css、js 代码等,还是有些词频繁地出现:它们出现的频率如此的高,以至于反而失去了作为搜索词的价值。这个时候,我们就需要把他们狠狠地拉黑,不处理他们的倒排索引。我使用的黑名单词如下,表名为`word_black_list`,只有两个字段 id、word,需要的自取:
585 |
586 | ```ruby
587 | INSERT INTO `word_black_list` (`id`, `word`)
588 | VALUES
589 | (1, 'px'),
590 | (2, '20'),
591 | (3, '('),
592 | (4, ')'),
593 | (5, ','),
594 | (6, '.'),
595 | (7, '-'),
596 | (8, '/'),
597 | (9, ':'),
598 | (10, 'var'),
599 | (11, '的'),
600 | (12, 'com'),
601 | (13, ';'),
602 | (14, '['),
603 | (15, ']'),
604 | (16, '{'),
605 | (17, '}'),
606 | (18, '\''),
607 | (19, '\"'),
608 | (20, '_'),
609 | (21, '?'),
610 | (22, 'function'),
611 | (23, 'document'),
612 | (24, '|'),
613 | (25, '='),
614 | (26, 'html'),
615 | (27, '内容'),
616 | (28, '0'),
617 | (29, '1'),
618 | (30, '3'),
619 | (31, 'https'),
620 | (32, 'http'),
621 | (33, '2'),
622 | (34, '!'),
623 | (35, 'window'),
624 | (36, 'if'),
625 | (37, '“'),
626 | (38, '”'),
627 | (39, '。'),
628 | (40, 'src'),
629 | (41, '中'),
630 | (42, '了'),
631 | (43, '6'),
632 | (44, '。'),
633 | (45, '<'),
634 | (46, '>'),
635 | (47, '联系'),
636 | (48, '号'),
637 | (49, 'getElementsByTagName'),
638 | (50, '5'),
639 | (51, '、'),
640 | (52, 'script'),
641 | (53, 'js');
642 | ```
643 |
644 | 至此,字典的处理告一段落,下面让我们一起 Just 搜 it!
645 |
646 | ## 第三步,使用 BM25 算法给出搜索结果
647 |
648 | 网上关于 BM25 算法的文章是不是看起来都有点懵?别担心,看完下面这段文字,我保证你能自己写出来这个算法的具体实现,这种有具体文档的工作是最好做的了,比前面的性能优化简单多了。
649 |
650 | ### 简单介绍一下 BM25 算法
651 |
652 | BM25 算法是现代搜索引擎的基础,它可以很好地反映一个词和一堆文本的相关性。它拥有不少独特的设计思想,我们下面会详细解释。
653 |
654 | 这个算法第一次被生产系统使用是在 1980 年代的伦敦城市大学,在一个名为 Okapi 的信息检索系统中被实现出来,而原型算法来自 1970 年代 Stephen E. Robertson、Karen Spärck Jones 和他们的同伴开发的概率检索框架。所以这个算法也叫 Okapi BM25,这里的 BM 代表的是`best matching`(最佳匹配),非常实在,和比亚迪的“美梦成真”有的一拼(Build Your Dreams)😂
655 |
656 | ### 详细讲解 BM25 算法数学表达式的含义
657 |
658 | 
659 |
660 | 我简单描述一下这个算法的含义。
661 |
662 | 首先,假设我们有 100 个页面,并且已经对他们分词,并全部生成了倒排索引。此时,我们需要搜索这句话“BM25 算法的数学描述”,我们就需要按照以下步骤来计算:
663 |
664 | 1. 对“BM25 算法的数学描述”进行分词,得到“BM25”、“算法”、“的”、“数学”、“描述”五个词
665 | 2. 拿出这五个词的全部字典信息,假设包含这五个词的页面一共有 50 个
666 | 3. 逐个计算这五个词和这 50 个页面的`相关性权重`和`相关性得分`的乘积(当然,不是每个词都出现在了这 50 个网页中,有多少算多少)
667 | 4. 把这 50 页面的分数分别求和,再倒序排列,即可以获得“BM25 算法的数学描述”这句话在这 100 个页面中的搜索结果
668 |
669 | `相关性权重`和`相关性得分`名字相似,别搞混了,它们的具体定义如下:
670 |
671 | #### 某个词和包含它的某个页面的“相关性权重”
672 |
673 | 
674 |
675 | 上图中的`Wi`指代的就是相关性权重,最常用的是`TF-IDF`算法中的`IDF`权重计算法:
676 |
677 | 
678 |
679 | 这里的 N 指的是页面总数,就是你已经加入字典的页面数量,需要动态扫描 MySQL 字典,对我来说就是 784 万。而`n(Qi)`就是这个词的字典长度,就是含有这个词的页面有多少个,就是我们字典值中`-`出现的次数。
680 |
681 | 这个参数的现实意义是:如果一个词在很多页面里面都出现了,那说明这个词不重要,例如百分百空手接白刃的“的”字,哪个页面都有,说明这个词不准确,进而它就不重要。
682 |
683 | 词以稀为贵。
684 |
685 | 我的代码实现如下:
686 |
687 | ```go
688 | // 页面总数
689 | db.DbInstance0.Raw("select count(*) from pages_0f where dic_done = 1").Scan(&N)
690 | N *= 256
691 |
692 | // 字典的值中`-`出现的次数
693 | NQi := len(partsArr)
694 |
695 | // 得出相关性权重
696 | IDF := math.Log10((float64(N-NQi) + 0.5) / (float64(NQi) + 0.5))
697 | ```
698 |
699 | #### 某个词和包含它的某个页面的“相关性得分”
700 |
701 | 
702 |
703 | 这个表达式看起来是不是很复杂,但是它的复杂度是为了处理查询语句里面某一个关键词出现了多次的情况,例如“八百标兵奔北坡,炮兵并排北边跑。炮兵怕把标兵碰,标兵怕碰炮兵炮。”,“炮兵”这个词出现了 3 次。为了能快速实现一个能用的搜索引擎,我们放弃支持这种情况,然后这个看起来就刺激的表达式就可以简化成下面这种形式:
704 |
705 | 
706 |
707 | 需要注意的是,这里面的大写的 K 依然是上面那个略微复杂的样式。我们取 k1 为 2,b 为 0.75,页面(文档)平均长度我自己跑了一个,13214,你们可以用我这个数,也可以自己跑一个用。
708 |
709 | 我的代码实现如下:
710 |
711 | ```go
712 | // 使用 - 切分后的值,为此页面的字典值,形式为:
713 | // 110,85,1,195653,7101
714 | ints := strings.Split(p, ",")
715 |
716 | // 这个词在这个页面中出现总次数
717 | Fi, err := strconv.Atoi(ints[2])
718 | // 这个页面的长度
719 | Dj, _ := strconv.Atoi(ints[3])
720 |
721 | k1 := 2.0
722 | b := 0.75
723 |
724 | // 页面平均长度
725 | avgDocLength := 13214.0
726 |
727 | // 得到相关性得分
728 | RQiDj := (float64(Fi) * (k1 + 1)) / (float64(Fi) + k1*(1-b+b*(float64(Dj)/avgDocLength)))
729 | ```
730 |
731 | #### 怎么样,是不是比你想象的简单?
732 |
733 | ### 检验搜索结果
734 |
735 | 我在我搞的“翰哥搜索”页面上搜了一下“BM25 算法的数学描述”,结果如下:
736 |
737 | 
738 |
739 | 我搜索“住范儿”,结果如下:
740 |
741 | 
742 |
743 | 第一个就是我们官网,可以说相当精准了。
744 |
745 | 看起来效果还不错,要知道这只是在 784 万的网页中搜索的结果哦,如果你有足够的服务器资源,能搞定三亿个页面的爬取、索引和查询的话,效果肯定更加的好。
746 |
747 | ### 如何继续提升搜索准确性?
748 |
749 | 目前我们的简化版 BM25 算法的搜索结果已经达到能用的水平了,还能继续提升搜索准确性吗?还可以:
750 |
751 | 1. 本文全部是基于分词做的字典,你可以再做一份基于单字的,然后把单字的搜索结果排序和分词的搜索结果进行结合,搜索结果可以更准。
752 | 2. 相似的原理,打造更加合理、更加丰富的分词方式,构造不同倾向的词典,可以提升特定领域的搜索结果表现,例如医学领域、代码领域等。
753 | 3. 打造你自己的 PageRank 技术,从 URL 之间关系的角度,给单个 URL 的价值进行打分,并将这个价值分数放进搜索结果的排序参数之中。
754 | 4. 引入 proximity 相似性计算,不仅考虑精确匹配的关键词,还要考虑到含义相近的关键词的搜索结果。
755 | 5. 特殊查询的处理:修正用户可能的输入错误,处理中文独特的“拼音匹配”需求等。
756 |
757 | ### 参考资料
758 |
759 | 1. 【NLP】非监督文本匹配算法——BM25 https://zhuanlan.zhihu.com/p/499906089
760 | 2. 《自制搜索引擎》—— [日]山田浩之、末永匡
761 |
762 |
763 | 文章结束了,你学废了吗?欢迎到下列位置留下你的评论:
764 |
765 | 1. Github:https://github.com/johnlui/DIY-Search-Engine
766 | 2. 博客:https://pphc.lvwenhan.com/tech-epic/2023/diy-search-engine
767 | 【全文完】
768 |
769 |
770 |
771 | # 本项目运行方法
772 |
773 | 首先,给自己准备一杯咖啡。
774 |
775 | 1. 把本项目下载到本地
776 | 2. 编译:`go build -o ese *.go`
777 | 3. 修改配置文件:`cp .env.example .env`,然后把里面的数据库和 Redis 配置改成你的
778 | 4. 执行`./ese art init`创建数据库
779 | 5. 手动插入一个真实的 URL 到 pages_00 表中,只需要填充 url 和 host 两个字段
780 | 6. 执行`./ese`,静待好事发生 ☕️
781 |
782 | 过一段时间,等字典数据表`word_dics`里面填充了数据之后,打开[http://127.0.0.1:10086](http://127.0.0.1:10086),尝试搜一下吧!🔍
783 |
784 | #### 更多项目运行信息,请见 [wiki](https://github.com/johnlui/DIY-Search-Engine/wiki)
785 |
786 |
787 | #### 网页直接阅读:https://pphc.lvwenhan.com/tech-epic/2023/diy-search-engine
788 |
789 | ### 作者信息:
790 |
791 | 1. 姓名:吕文翰
792 | 2. GitHub:[johnlui](https://github.com/johnlui)
793 | 3. 职位:住范儿 CTO
794 |
795 | 
796 |
797 | ### 文章版权声明
798 |
799 | 本文权归属于[吕文翰](https://github.com/johnlui),采用 [CC BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode.zh-Hans) 协议开源,供 GitHub 平台用户免费阅读。
800 |
801 |
802 |
803 | ### 代码版权
804 |
805 | 本项目代码采用 MIT 协议开源。
806 |
807 |
--------------------------------------------------------------------------------
/art.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/johnlui/enterprise-search-engine/db"
7 | )
8 |
9 | type Art struct{}
10 |
11 | func (a Art) Init() {
12 |
13 | realDB := db.DbInstance0
14 |
15 | // 初始化 256 张 pages 和 status 表
16 | for i := 0; i < 256; i++ {
17 | var tableName string
18 | var statusTableName string
19 | if i < 16 {
20 | tableName = fmt.Sprintf("pages_0%x", i)
21 | statusTableName = fmt.Sprintf("status_0%x", i)
22 | } else {
23 | tableName = fmt.Sprintf("pages_%x", i)
24 | statusTableName = fmt.Sprintf("status_%x", i)
25 | }
26 |
27 | result := realDB.Exec("CREATE TABLE `" + tableName + "` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `url` varchar(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL, `host` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL, `dic_done` tinyint DEFAULT '0', `craw_done` tinyint NOT NULL DEFAULT '0', `craw_time` timestamp NOT NULL DEFAULT '2001-01-01 00:00:00', `origin_title` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL, `referrer_id` int NOT NULL DEFAULT '0', `scheme` varchar(255) DEFAULT NULL, `domain1` varchar(255) DEFAULT NULL, `domain2` varchar(255) DEFAULT NULL, `path` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL, `query` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL, `title` varchar(1000) DEFAULT NULL, `text` longtext, `created_at` timestamp NOT NULL DEFAULT '2001-01-01 08:00:00', PRIMARY KEY (`id`), KEY `url` (`url`), KEY `host_crtime` (`host`), KEY `host_cdown` (`host`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;")
28 | fmt.Println("建表结果: ", tableName, result.RowsAffected)
29 |
30 | result1 := realDB.Exec("CREATE TABLE `" + statusTableName + "` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `url` varchar(767) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL, `host` varchar(255) DEFAULT NULL, `craw_done` tinyint NOT NULL DEFAULT '0', `craw_time` timestamp NOT NULL DEFAULT '2001-01-01 00:00:00', PRIMARY KEY (`id`), KEY `idx_host_crtime` (`host`,`craw_time`), KEY `idx_url` (`url`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;")
31 | fmt.Println("建表结果: ", statusTableName, result1.RowsAffected)
32 |
33 | }
34 | // 初始化域名黑名单
35 | result2 := realDB.Exec("CREATE TABLE `domain_black_list` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `domain` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;")
36 | fmt.Println("建表结果: ", "domain_black_list", result2.RowsAffected)
37 | // 填充域名黑名单
38 | result3 := realDB.Exec("INSERT INTO `domain_black_list` (`id`, `domain`) VALUES (1, 'huangye88.com'), (2, 'gov.cn'), (3, 'nbhesen.com'), (4, 'tianyancha.com'), (5, 'qianlima.com'), (6, '99114.com'), (7, 'luosi.com'), (8, 'bidchance.com'), (9, '51zhantai.com'), (10, 'baiye5.com'), (11, 'snxx.com'), (12, '6789go.com'), (13, 'gongxiangchi.com'), (14, 'webacg.com'), (16, '912688.com'), (17, 'dihe.cn'), (18, 'maoyihang.com'), (19, 'realsee.com'), (20, 'tdzyw.com'), (21, 'anjuke.com'), (22, 'liuxue86.com'), (23, '5588.tv'), (24, '58.com');")
39 | fmt.Println("填充结果: ", "domain_black_list", result3.RowsAffected)
40 |
41 | // 初始化字典词黑名单
42 | result4 := realDB.Exec("CREATE TABLE `word_black_list` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `word` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;")
43 | fmt.Println("建表结果: ", "word_black_list", result4.RowsAffected)
44 | // 填充字典词黑名单
45 | result5 := realDB.Exec("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, '<'), (46, '>'), (47, '联系'), (48, '号'), (49, 'getElementsByTagName'), (50, '5'), (51, '、'), (52, 'script'), (53, 'js');")
46 | fmt.Println("填充结果: ", "word_black_list", result5.RowsAffected)
47 |
48 | // 初始化 kvstores
49 | result6 := realDB.Exec("CREATE TABLE `kvstores` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `k` varchar(255) DEFAULT NULL, `v` varchar(255) DEFAULT NULL, `time` timestamp NOT NULL DEFAULT '2001-01-01 00:00:01', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;")
50 | fmt.Println("建表结果: ", "kvstores", result6.RowsAffected)
51 | // 填充 kvstores
52 | result7 := realDB.Exec("INSERT INTO `kvstores` (`id`, `k`, `v`, `time`) VALUES (1, 'stop', '0', '2022-09-04 01:27:55'), (2, 'stopNew', '0', '2001-01-01 00:00:01'), (3, 'stopWashDicRedisToMySQL', '0', '2001-01-01 00:00:01');")
53 | fmt.Println("填充结果: ", "kvstores", result7.RowsAffected)
54 |
55 | // 初始化 字典表 word_dics
56 | result8 := db.DbInstanceDic.Exec("CREATE TABLE `word_dics` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) DEFAULT NULL, `positions` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci")
57 | fmt.Println("建表结果: ", "word_dics", result8.RowsAffected)
58 |
59 | fmt.Println("数据库初始化完成")
60 | }
61 |
--------------------------------------------------------------------------------
/controllers/search.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "os"
7 | "regexp"
8 | "sort"
9 | "strconv"
10 | "strings"
11 | "time"
12 | "unicode/utf8"
13 |
14 | "github.com/gin-gonic/gin"
15 | "github.com/johnlui/enterprise-search-engine/db"
16 | "github.com/johnlui/enterprise-search-engine/models"
17 | "github.com/johnlui/enterprise-search-engine/tools"
18 | "golang.org/x/exp/maps"
19 | )
20 |
21 | type SearchResult struct {
22 | Title string
23 | Score float64
24 | Brief string
25 | Url string
26 | }
27 |
28 | func Search(c *gin.Context) {
29 | t := time.Now()
30 |
31 | keyword := c.Query("keyword")
32 |
33 | N := 0
34 | values := make([]SearchResult, 0)
35 |
36 | if utf8.RuneCountInString(keyword) > 0 {
37 |
38 | r := tools.GetFenciResultArray(keyword)
39 |
40 | // var result []DocResult
41 |
42 | // 总文档数
43 | docsExistMap := make(map[string]struct{})
44 |
45 | // 总积分
46 | docsScores := make(map[string]float64)
47 |
48 | db.DbInstance0.Raw("select count(*) from pages_70 where dic_done = 1").Scan(&N)
49 | N *= 256
50 | if os.Getenv("APP_ENV") == "local" {
51 | N = 650000
52 | }
53 | if N == 0 {
54 | panic("文档总数N不能为零")
55 | }
56 |
57 | for _, v := range r {
58 | // 一个词
59 | var dic models.WordDic
60 | db.DbInstanceDic.Where("name = ?", v).Find(&dic)
61 |
62 | rawParts := strings.Split(dic.Positions, "-")
63 | rawParts = rawParts[:len(rawParts)-1]
64 |
65 | // 只保留同一个 docID count 较大的那个,同一个doc可能会重复出现在一个词中
66 | parts := make(map[string]string)
67 | for k, v := range rawParts {
68 | if k > 0 {
69 |
70 | intsV := strings.Split(v, ",")
71 |
72 | partsKey := intsV[0] + "~" + intsV[1]
73 | prsV, prs := parts[partsKey]
74 | if prs {
75 | prsIntsV := strings.Split(prsV, ",")
76 | prsVCount := prsIntsV[2]
77 | vCount := intsV[2]
78 | if vCount > prsVCount {
79 | parts[partsKey] = v
80 | }
81 | } else {
82 | parts[partsKey] = v
83 | }
84 | }
85 | }
86 | partsArr := maps.Values(parts)
87 |
88 | NQi := len(partsArr)
89 | IDF := math.Log10((float64(N-NQi) + 0.5) / (float64(NQi) + 0.5))
90 |
91 | wordExistCount := 0
92 | for _, p := range partsArr {
93 | // 一个词在一个文档里出现
94 | ints := strings.Split(p, ",")
95 |
96 | // 出现总次数
97 | Dj, _ := strconv.Atoi(ints[3])
98 | // 出现总次数
99 | Fi, err := strconv.Atoi(ints[2])
100 | // 出现总次数之和
101 | if err != nil {
102 | wordExistCount += Fi
103 | }
104 |
105 | // https://zhuanlan.zhihu.com/p/499906089
106 |
107 | k1 := 2.0
108 | b := 0.75
109 | // 平均文档长度,暂时没用,没有记录文档长度
110 | avgDocLength := 13214.0
111 |
112 | RQiDj := (float64(Fi) * (k1 + 1)) / (float64(Fi) + k1*(1-b+b*(float64(Dj)/avgDocLength)))
113 |
114 | docName := ints[0] + "-" + ints[1]
115 | _, prs := docsScores[docName]
116 | if !prs {
117 | docsScores[docName] = 0.0
118 | }
119 |
120 | docsScores[docName] += IDF * RQiDj
121 |
122 | // 总文档数
123 | _, prs1 := docsExistMap[docName]
124 | if !prs1 {
125 | docsExistMap[docName] = struct{}{}
126 | }
127 |
128 | // result := DocResult{
129 | // count: 1,
130 | // }
131 | }
132 |
133 | }
134 | // dd(len(docsExistMap))
135 | // dd(docsScores)
136 |
137 | // 按照分数排序
138 | keys := make([]string, 0, len(docsScores))
139 | for key := range docsScores {
140 | keys = append(keys, key)
141 | }
142 | sort.SliceStable(keys, func(i, j int) bool {
143 | return docsScores[keys[i]] > docsScores[keys[j]]
144 | })
145 |
146 | // 取前10个
147 | qu := 200
148 | if len(keys) < qu {
149 | qu = len(keys)
150 | }
151 | keys = keys[0:qu]
152 |
153 | for _, doc := range keys {
154 | ps := strings.Split(doc, "-")
155 |
156 | tableIndex, _ := strconv.Atoi(ps[0])
157 | var tableName string
158 | if tableIndex < 16 {
159 | tableName = fmt.Sprintf("pages_0%x", tableIndex)
160 | } else {
161 | tableName = fmt.Sprintf("pages_%x", tableIndex)
162 | }
163 |
164 | realDB := db.DbInstance0
165 |
166 | // 如果你有多个数据库,可以取消注释
167 | // if tableIndex > 127 {
168 | // realDB = db.DbInstance1
169 | // }
170 |
171 | var lake models.Page
172 | realDB.Table(tableName).Where("id = ?", ps[1]).Scan(&lake)
173 |
174 | // fmt.Println(lake.Title, docsScores[doc])
175 |
176 | re := regexp.MustCompile("[[:ascii:]]")
177 | brief := re.ReplaceAllLiteralString(lake.Text, "")
178 |
179 | length := 100
180 | briefLen := utf8.RuneCountInString(brief)
181 | if briefLen < 100 {
182 | length = briefLen
183 | }
184 | if length > 0 {
185 | brief = string([]rune(brief)[:length-1])
186 | }
187 |
188 | values = append(values, SearchResult{
189 | Title: lake.Title,
190 | Score: docsScores[doc],
191 | Brief: brief,
192 | Url: lake.Url,
193 | })
194 | }
195 | }
196 |
197 | latency := time.Since(t)
198 | c.HTML(200, "search.tpl", gin.H{
199 | "title": "翰哥搜索",
200 | "time": time.Now().Format("2006-01-02 15:04:05"),
201 | "values": values,
202 | "keyword": keyword,
203 | "N": N,
204 | "latency": latency,
205 | })
206 |
207 | }
208 |
--------------------------------------------------------------------------------
/controllers/status.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "sort"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/johnlui/enterprise-search-engine/db"
10 | "github.com/johnlui/enterprise-search-engine/tools"
11 | )
12 |
13 | func SpiderStatus(c *gin.Context) {
14 | // URL 总数
15 | keyAll := "host_counts_all_" + strconv.Itoa(int(time.Now().Unix())/86400)
16 | keyCrawd := "host_counts_crawd_" + strconv.Itoa(int(time.Now().Unix())/86400)
17 | keyCrawdInvalid := "host_counts_crawd_invalid_" + strconv.Itoa(int(time.Now().Unix())/86400)
18 |
19 | // 获得数量最多的10个域名
20 | type kv struct {
21 | Key string
22 | Value int
23 | }
24 | var ss1 []kv
25 | rr1, _ := db.Rdb.HGetAll(db.Ctx, keyAll).Result()
26 | for k, v := range rr1 {
27 | _v, _ := strconv.Atoi(v)
28 | ss1 = append(ss1, kv{k, _v})
29 | }
30 | sort.Slice(ss1, func(i, j int) bool {
31 | return ss1[i].Value > ss1[j].Value
32 | })
33 |
34 | var ss []kv
35 | rr, _ := db.Rdb.HGetAll(db.Ctx, keyCrawd).Result()
36 | for k, v := range rr {
37 | _v, _ := strconv.Atoi(v)
38 | ss = append(ss, kv{k, _v})
39 | }
40 | sort.Slice(ss, func(i, j int) bool {
41 | return ss[i].Value > ss[j].Value
42 | })
43 |
44 | r1, _ := db.Rdb.HVals(db.Ctx, keyCrawd).Result()
45 | crawdCount := 0
46 | for _, v := range r1 {
47 | _count, _ := strconv.Atoi(v)
48 | crawdCount += _count
49 | }
50 | r2, _ := db.Rdb.HVals(db.Ctx, keyCrawdInvalid).Result()
51 | crawdCountInvalid := 0
52 | for _, v := range r2 {
53 | _count, _ := strconv.Atoi(v)
54 | crawdCountInvalid += _count
55 | }
56 |
57 | // 过去1分钟爬取
58 | lastMinuteCount, err := db.Rdb.Get(db.Ctx, "ese_spider_result_in_minute_"+strconv.Itoa(int(time.Now().Unix()-60)/60)).Result()
59 | if err != nil {
60 | lastMinuteCount = "0"
61 | }
62 |
63 | // 过去10分钟爬取
64 | last10MCount := 0
65 | for i := 0; i < 10; i++ {
66 | c, _ := db.Rdb.Get(db.Ctx, "ese_spider_result_in_minute_"+strconv.Itoa(int(time.Now().Unix())/60-i)).Int()
67 | last10MCount += c
68 | }
69 |
70 | // 过去1小时爬取
71 | lastHourCount := 0
72 | for i := 0; i < 60; i++ {
73 | c, _ := db.Rdb.Get(db.Ctx, "ese_spider_result_in_minute_"+strconv.Itoa(int(time.Now().Unix())/60-i)).Int()
74 | lastHourCount += c
75 | }
76 |
77 | // 过去1分钟爬取网络错误
78 | lastMinute4Count, err := db.Rdb.Get(db.Ctx, "ese_spider_result_4_in_minute_"+strconv.Itoa(int(time.Now().Unix()-60)/60)).Result()
79 | if err != nil {
80 | lastMinute4Count = "0"
81 | }
82 |
83 | // 过去10分钟爬取网络错误
84 | last10M4Count := 0
85 | for i := 0; i < 10; i++ {
86 | c, _ := db.Rdb.Get(db.Ctx, "ese_spider_result_4_in_minute_"+strconv.Itoa(int(time.Now().Unix())/60-i)).Int()
87 | last10M4Count += c
88 | }
89 |
90 | // 过去1小时爬取网络错误
91 | lastHour4Count := 0
92 | for i := 0; i < 60; i++ {
93 | c, _ := db.Rdb.Get(db.Ctx, "ese_spider_result_4_in_minute_"+strconv.Itoa(int(time.Now().Unix())/60-i)).Int()
94 | lastHour4Count += c
95 | }
96 |
97 | // 过去1分钟新增status 全部
98 | lastMinute4All, err := db.Rdb.Get(db.Ctx, "ese_spider_all_status_in_minute_"+strconv.Itoa(int(time.Now().Unix()-60)/60)).Result()
99 | if err != nil {
100 | lastMinute4All = "0"
101 | }
102 |
103 | // 过去10分钟新增status 全部
104 | last10M4All := 0
105 | for i := 0; i < 10; i++ {
106 | c, _ := db.Rdb.Get(db.Ctx, "ese_spider_all_status_in_minute_"+strconv.Itoa(int(time.Now().Unix())/60-i)).Int()
107 | last10M4All += c
108 | }
109 |
110 | // 过去1小时新增status 全部
111 | lastHour4All := 0
112 | for i := 0; i < 60; i++ {
113 | c, _ := db.Rdb.Get(db.Ctx, "ese_spider_all_status_in_minute_"+strconv.Itoa(int(time.Now().Unix())/60-i)).Int()
114 | lastHour4All += c
115 | }
116 |
117 | // 过去1分钟新增status 纯新增
118 | lastMinute4New, err := db.Rdb.Get(db.Ctx, "ese_spider_new_status_in_minute_"+strconv.Itoa(int(time.Now().Unix()-60)/60)).Result()
119 | if err != nil {
120 | lastMinute4New = "0"
121 | }
122 |
123 | // 过去10分钟新增status 纯新增
124 | last10M4New := 0
125 | for i := 0; i < 10; i++ {
126 | c, _ := db.Rdb.Get(db.Ctx, "ese_spider_new_status_in_minute_"+strconv.Itoa(int(time.Now().Unix())/60-i)).Int()
127 | last10M4New += c
128 | }
129 |
130 | // 过去1小时新增status 纯新增
131 | lastHour4New := 0
132 | for i := 0; i < 60; i++ {
133 | c, _ := db.Rdb.Get(db.Ctx, "ese_spider_new_status_in_minute_"+strconv.Itoa(int(time.Now().Unix())/60-i)).Int()
134 | lastHour4New += c
135 | }
136 |
137 | // 预估 URL 总数
138 | totalCount := 0
139 | db.DbInstance0.Raw("select count(*) from status_70").Scan(&totalCount)
140 | totalCount *= 256
141 |
142 | // 待爬队列长度
143 | need_craw_listLength := db.Rdb.LLen(db.Ctx, "need_craw_list").Val()
144 |
145 | values := []map[string]any{
146 | map[string]any{"待爬队列长度": need_craw_listLength},
147 | map[string]any{"预估 URL 总数": tools.AddDouhao(totalCount)},
148 | map[string]any{"已爬总数": tools.AddDouhao(crawdCount)},
149 | map[string]any{"已爬无效数": tools.AddDouhao(crawdCountInvalid)},
150 | map[string]any{"过去1分钟爬取 | 多次网络错误": lastMinuteCount + " | " + lastMinute4Count},
151 | map[string]any{"过去10分钟爬取 | 多次网络错误": strconv.Itoa(last10MCount) + " | " + strconv.Itoa(last10M4Count)},
152 | map[string]any{"过去1小时爬取 | 多次网络错误": strconv.Itoa(lastHourCount) + " | " + strconv.Itoa(lastHour4Count)},
153 | map[string]any{"过去1分钟新爬到status | 新页面": lastMinute4All + " | " + lastMinute4New},
154 | map[string]any{"过去10分钟新爬到status | 新页面": strconv.Itoa(last10M4All) + " | " + strconv.Itoa(last10M4New)},
155 | map[string]any{"过去1小时新爬到status | 新页面": strconv.Itoa(lastHour4All) + " | " + strconv.Itoa(lastHour4New)},
156 | }
157 |
158 | c.HTML(200, "index.tpl", gin.H{
159 | "title": "ESE状态监控面板",
160 | "time": time.Now().Format("2006-01-02 15:04:05"),
161 | "values": values,
162 | })
163 | }
164 |
--------------------------------------------------------------------------------
/cron.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "strconv"
8 | "strings"
9 | "time"
10 | "unicode/utf8"
11 |
12 | "github.com/johnlui/enterprise-search-engine/db"
13 | "github.com/johnlui/enterprise-search-engine/models"
14 | "github.com/johnlui/enterprise-search-engine/tools"
15 | "golang.org/x/text/width"
16 | "gorm.io/gorm"
17 | )
18 |
19 | // 后台定时自动同步 pages 表到 status 表
20 | func autoParsePagesToStatus() {
21 | t := time.Now()
22 |
23 | var count int64 = 0
24 |
25 | realDB := db.DbInstance0
26 |
27 | for i := 0; i < 256; i++ {
28 | var pagesTableName string
29 | var statusTableName string
30 | if i < 16 {
31 | pagesTableName = fmt.Sprintf("pages_0%x", i)
32 | statusTableName = fmt.Sprintf("status_0%x", i)
33 | } else {
34 | pagesTableName = fmt.Sprintf("pages_%x", i)
35 | statusTableName = fmt.Sprintf("status_%x", i)
36 | }
37 |
38 | result := realDB.Exec("insert into `" + statusTableName + "` select `id`, `url`, `host`, `craw_done`, `craw_time` from `" + pagesTableName + "` where id > COALESCE((select max(id) from status_00), 0);")
39 |
40 | count += result.RowsAffected
41 | }
42 | if count > 0 {
43 | fmt.Println("从 pages 同步了一批数据到 status", time.Now().Unix()-t.Unix(), "秒,共", count, "条")
44 | }
45 | }
46 |
47 | // 定时将可以爬的 URL 从 status 表转移到 redis 中
48 | func prepareStatusesBackground() {
49 | t := time.Now()
50 |
51 | maxNumber := 1
52 | if os.Getenv("APP_DEBUG") == "false" {
53 | maxNumber = 一次准备
54 | }
55 |
56 | // host 黑名单,用于提升过滤效率
57 | hostBlackListInOneStepArray, _ := db.Rdb.SMembers(db.Ctx, "ese_spider_host_black_list").Result()
58 | if len(hostBlackListInOneStepArray) == 0 {
59 | db.Rdb.SAdd(db.Ctx, "ese_spider_host_black_list", "ooxx")
60 | db.Rdb.Expire(db.Ctx, "ese_spider_host_black_list", time.Minute*42).Err()
61 | for k := range domain1BlackList {
62 | hostBlackListInOneStepArray = append(hostBlackListInOneStepArray, k)
63 | }
64 | }
65 |
66 | count := 0
67 |
68 | for i := 0; i < 256; i++ {
69 | var tableName string
70 | if i < 16 {
71 | tableName = fmt.Sprintf("status_0%x", i)
72 | } else {
73 | tableName = fmt.Sprintf("status_%x", i)
74 | }
75 |
76 | realDB := db.DbInstance0
77 |
78 | var _statusArray []models.Status
79 | key := "table_" + tableName + "_max_into_queue_id"
80 | maxID, _ := db.Rdb.Get(db.Ctx, key).Int()
81 | realDB.Table(tableName).
82 | Where("craw_done", 0).
83 | Where("host not in (?)", hostBlackListInOneStepArray).
84 | Where("id > ?", maxID).
85 | Order("id").Limit(maxNumber).Find(&_statusArray)
86 |
87 | if len(_statusArray) > 0 {
88 | count += len(_statusArray)
89 |
90 | for _, v := range _statusArray {
91 | taskBytes, _ := json.Marshal(v)
92 | db.Rdb.LPush(db.Ctx, "need_craw_list", taskBytes)
93 | }
94 |
95 | keyTTL, _ := db.Rdb.TTL(db.Ctx, key).Result()
96 | if keyTTL == -1 {
97 | keyTTL = time.Hour
98 | }
99 |
100 | err := db.Rdb.Set(db.Ctx, key, _statusArray[len(_statusArray)-1].ID, keyTTL).Err()
101 | if err != nil {
102 | dd(err)
103 | }
104 | }
105 |
106 | }
107 |
108 | if count > 0 {
109 | fmt.Println("准备完一轮数据", time.Now().Unix()-t.Unix(), "秒,共", maxNumber*256, "条")
110 | }
111 | }
112 |
113 | // 每天刷新一次 已爬 host 数量
114 | func refreshHostCount() {
115 | t := time.Now()
116 | fmt.Println("开始刷新URL数")
117 |
118 | minutesInDay := t.Hour()*60 + t.Minute()
119 |
120 | start := minutesInDay / 5
121 | end := start + 1
122 |
123 | if start > 255 || end > 255 {
124 | return
125 | }
126 |
127 | // 总数
128 | for i := start; i < end; i++ {
129 | var tableName string
130 | if i < 16 {
131 | tableName = fmt.Sprintf("status_0%x", i)
132 | } else {
133 | tableName = fmt.Sprintf("status_%x", i)
134 | }
135 |
136 | realDB := db.DbInstance0
137 | _hostCountArr := []models.HostCount{}
138 | realDB.Raw("select host, count(*) count from " + tableName + " where host is not null group by host having count > 500").Scan(&_hostCountArr)
139 |
140 | key := "host_counts_all_" + strconv.Itoa(int(time.Now().Unix())/86400)
141 | for _, v := range _hostCountArr {
142 | db.Rdb.HIncrBy(db.Ctx, key, v.Host, int64(v.Count))
143 | }
144 | db.Rdb.Expire(db.Ctx, key, time.Hour*48).Err()
145 | }
146 |
147 | // 已爬数量
148 | for i := start; i < end; i++ {
149 | var tableName string
150 | if i < 16 {
151 | tableName = fmt.Sprintf("status_0%x", i)
152 | } else {
153 | tableName = fmt.Sprintf("status_%x", i)
154 | }
155 |
156 | realDB := db.DbInstance0
157 | _hostCountArr := []models.HostCount{}
158 | realDB.Raw("select host, count(*) crawd_count from " + tableName + " where craw_done = 1 and host is not null group by host").Scan(&_hostCountArr)
159 |
160 | key := "host_counts_crawd_" + strconv.Itoa(int(time.Now().Unix())/86400)
161 | for _, v := range _hostCountArr {
162 | db.Rdb.HIncrBy(db.Ctx, key, v.Host, int64(v.CrawdCount))
163 | }
164 | db.Rdb.Expire(db.Ctx, key, time.Hour*48).Err()
165 | }
166 |
167 | // 已爬但无效的数量
168 | for i := start; i < end; i++ {
169 | var tableName string
170 | if i < 16 {
171 | tableName = fmt.Sprintf("pages_0%x", i)
172 | } else {
173 | tableName = fmt.Sprintf("pages_%x", i)
174 | }
175 |
176 | realDB := db.DbInstance0
177 | _hostCountArr := []models.HostCount{}
178 | realDB.Raw("select host, count(*) crawd_count from " + tableName + " where craw_done = 1 and text = '' and host is not null group by host").Scan(&_hostCountArr)
179 |
180 | key := "host_counts_crawd_invalid_" + strconv.Itoa(int(time.Now().Unix())/86400)
181 | for _, v := range _hostCountArr {
182 | db.Rdb.HIncrBy(db.Ctx, key, v.Host, int64(v.CrawdCount))
183 | }
184 | db.Rdb.Expire(db.Ctx, key, time.Hour*48).Err()
185 | }
186 |
187 | fmt.Println("刷新URL数完成:start", start, "end", end, time.Now().Unix()-t.Unix(), "秒")
188 | }
189 |
190 | // 将分词结果洗到 redis DB10 里面
191 | func washHTMLToDB10() {
192 | t := time.Now()
193 | chs := make([]chan int, 256)
194 | for i := 0; i < 256; i++ {
195 | var tableName string
196 | // var statusTableName string
197 | if i < 16 {
198 | tableName = fmt.Sprintf("pages_0%x", i)
199 | // statusTableName = fmt.Sprintf("status_0%x", i)
200 | } else {
201 | tableName = fmt.Sprintf("pages_%x", i)
202 | // statusTableName = fmt.Sprintf("status_%x", i)
203 | }
204 |
205 | realDB := db.DbInstance0
206 |
207 | chs[i] = make(chan int)
208 | go asyncGenerateDics(i, realDB, tableName, chs[i])
209 | }
210 | total := 0
211 | for _, ch := range chs {
212 | total += <-ch
213 | }
214 |
215 | if total > 0 {
216 | fmt.Println("将分词结果洗到 redis 里完成", time.Now().Unix()-t.Unix(), "秒", total, "条,启动时间", t.Format("2006-01-02 15:04:05"))
217 | }
218 |
219 | // 刷新字符黑名单
220 |
221 | _wordBlackList := []string{}
222 | db.DbInstance0.Raw("select word from word_black_list").Scan(&_wordBlackList)
223 | wordBlackList = make(map[string]struct{})
224 | for _, v := range _wordBlackList {
225 | wordBlackList[v] = struct{}{}
226 | }
227 | }
228 |
229 | type WordAndSppendSrting struct {
230 | word string
231 | appendString string
232 | }
233 |
234 | // 将 redis 里的分词结果洗到数据库里
235 | func washDB10ToDicMySQL() {
236 | _stop := -1
237 | db.DbInstance0.Table("kvstores").Where("k", "stopWashDicRedisToMySQL").Select("v").Find(&_stop)
238 | if _stop == -1 {
239 | fmt.Println("kvstores数据库连接失败,请检查 gorm-log.txt 日志")
240 | os.Exit(0)
241 | } else if _stop == 1 {
242 | fmt.Println("全局开关关闭,60秒后再检测")
243 | time.Sleep(time.Second * 60)
244 | washDB10ToDicMySQL()
245 | }
246 |
247 | fmt.Println("新的一轮")
248 |
249 | // 从 redis DB10 获取字典插入数据库
250 | // 1. 随机获取一个 key
251 | // 2. 判断长度,大于1,则保留最后一条,循环取出前面所有条
252 | // 3. 每次处理 100 个? key
253 | // 4. 在 DB0 里面存一个 Hash:存储所有已经入库的词
254 | // 5. 插入之前监测一下词是否已入库,若从未入库,则执行创建语句,若已入库,跳过
255 | // 6. 使用事务批量执行 update
256 | needUpdate := make(map[string]string)
257 | t := time.Now()
258 | oneStep := 一步转移的字典条数
259 |
260 | chs := make([]chan WordAndSppendSrting, oneStep)
261 |
262 | for j := 0; j < oneStep; j++ {
263 | chs[j] = make(chan WordAndSppendSrting)
264 | go asyncGetWordAndSppendSrting(chs[j])
265 | }
266 |
267 | for _, ch := range chs {
268 | _result := <-ch
269 | if _result.word != "" {
270 | _, prs := needUpdate[_result.word]
271 | if prs {
272 | needUpdate[_result.word] += _result.appendString
273 | } else {
274 | needUpdate[_result.word] = _result.appendString
275 | }
276 | }
277 | }
278 | fmt.Println("开始插入数据库")
279 | db.DbInstanceDic.Connection(func(tx *gorm.DB) error {
280 | tx.Exec(`START TRANSACTION`)
281 |
282 | for w, s := range needUpdate {
283 | tx.Exec(`UPDATE word_dics
284 | SET positions = concat(ifnull(positions,''), ?) where name = ?`, s, w)
285 | }
286 |
287 | tx.Exec(`COMMIT`)
288 |
289 | return nil
290 | })
291 |
292 | if len(needUpdate) > 0 {
293 | fmt.Println("转移完一批字典,共", len(needUpdate), "条,启动时间", t.Format("2006-01-02 15:04:05"))
294 | }
295 |
296 | if len(needUpdate) > 0 {
297 | washDB10ToDicMySQL()
298 | } else {
299 | fmt.Println("全转移完啦!")
300 | }
301 | }
302 |
303 | func asyncGetWordAndSppendSrting(ch chan WordAndSppendSrting) {
304 | wordAndSppendSrting := WordAndSppendSrting{}
305 |
306 | word := db.Rdb10.RandomKey(db.Ctx).Val()
307 | len := db.Rdb10.LLen(db.Ctx, word).Val()
308 | if len > 0 {
309 | // fmt.Println(word, "长度", len)
310 | if !db.Rdb.HExists(db.Ctx, "HasBeenTransported", word).Val() {
311 | db.DbInstanceDic.Exec(`INSERT IGNORE INTO word_dics
312 | SET name = ?,
313 | positions = ''`, word)
314 | }
315 | db.Rdb.HSet(db.Ctx, "HasBeenTransported", word, "")
316 |
317 | stringNeedAdd := ""
318 | var i int64 = 0
319 | for i < len {
320 | if i >= 每个词转移的深度 {
321 | break
322 | }
323 | stringNeedAdd += db.Rdb10.LPop(db.Ctx, word).Val()
324 | i += 1
325 | }
326 | wordAndSppendSrting.word = word
327 | wordAndSppendSrting.appendString = stringNeedAdd
328 | }
329 |
330 | ch <- wordAndSppendSrting
331 | }
332 | func asyncGenerateDics(i int, realDB *gorm.DB, tableName string, ch chan int) {
333 | var lakes []models.Page
334 | realDB.Table(tableName).
335 | Where("dic_done = 0").
336 | Where("craw_done = 1").
337 | Order("id asc").
338 | Limit(每分钟每个表执行分词).
339 | Scan(&lakes)
340 | // tools.DD(lakes[0].Text)
341 |
342 | /*
343 | 1. 分词,然后对分词结果进行重整:
344 | 2. 统计词频
345 | 3. 计算出 文档号,位置 ,可能存在多个
346 | 4. 创建词或者 update :update tablename set col1name = concat(ifnull(col1name,""), 'a,b,c');
347 | 5. 处理成单字,另存一份倒排索引字典
348 | */
349 | for _, lake := range lakes {
350 | text := lake.Text
351 | textLength := utf8.RuneCountInString(text)
352 |
353 | r := tools.GetFenciResultArray(text)
354 | // tools.DD(r)
355 |
356 | // 计算位置+统计词频
357 | uniqueWordResult := make(map[string]WordResult)
358 | position := 0
359 | for _, w := range r {
360 | // 转半角
361 | word := width.Narrow.String(w)
362 | length := utf8.RuneCountInString(word)
363 |
364 | _, pr := wordBlackList[word]
365 | if pr {
366 | continue
367 | }
368 |
369 | _, prs := uniqueWordResult[word]
370 | if !prs {
371 | uniqueWordResult[word] = WordResult{
372 | count: 1,
373 | positions: []string{strconv.Itoa(position)},
374 | }
375 | } else {
376 | uniqueWordResult[word] = WordResult{
377 | count: uniqueWordResult[word].count + 1,
378 | positions: append(uniqueWordResult[word].positions, strconv.Itoa(position)),
379 | }
380 | }
381 |
382 | position += length
383 | }
384 |
385 | for w, v := range uniqueWordResult {
386 | appendSrting := strconv.Itoa(i) + "," +
387 | strconv.Itoa(int(lake.ID)) + "," +
388 | strconv.Itoa(v.count) + "," +
389 | strconv.Itoa(textLength) + "," +
390 | strings.Join(v.positions, ",") +
391 | "-"
392 |
393 | db.Rdb10.RPush(db.Ctx, w, appendSrting)
394 |
395 | }
396 |
397 | lake.DicDone = 1
398 | realDB.Table(tableName).Save(&lake)
399 | }
400 |
401 | ch <- len(lakes)
402 | }
403 |
404 | type WordResult struct {
405 | count int
406 | positions []string
407 | }
408 |
--------------------------------------------------------------------------------
/db/db.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "context"
5 | "log"
6 | "os"
7 | "time"
8 |
9 | "github.com/go-redis/redis/v8"
10 | "gorm.io/driver/mysql"
11 | "gorm.io/gorm"
12 | "gorm.io/gorm/logger"
13 | )
14 |
15 | // pages 仓库数据库
16 | var DbInstance0 *gorm.DB
17 |
18 | // 如果你有多个数据库,可以取消注释,注册新的 DSN 信息
19 | // var DbInstance1 *gorm.DB
20 |
21 | // 字典数据库
22 | var DbInstanceDic *gorm.DB
23 |
24 | var Ctx = context.Background()
25 | var Rdb *redis.Client
26 | var Rdb10 *redis.Client
27 |
28 | func InitDB() {
29 | // 初始化 GORM
30 |
31 | // 默认仓库数据库
32 | dsn0 := os.Getenv("DB_USERNAME0") + ":" +
33 | os.Getenv("DB_PASSWORD0") + "@(" +
34 | os.Getenv("DB_HOST0") + ":" +
35 | os.Getenv("DB_PORT0") + ")/" +
36 | os.Getenv("DB_DATABASE0") + "?charset=utf8mb4&parseTime=True&loc=Local"
37 |
38 | // 如果你有多个数据库,可以取消注释,注册新的 DSN 信息
39 | // dsn1 := os.Getenv("DB_USERNAME1") + ":" +
40 | // os.Getenv("DB_PASSWORD1") + "@(" +
41 | // os.Getenv("DB_HOST1") + ":" +
42 | // os.Getenv("DB_PORT1") + ")/" +
43 | // os.Getenv("DB_DATABASE1") + "?charset=utf8mb4&parseTime=True&loc=Local"
44 |
45 | // 字典数据库
46 | dsnDic := os.Getenv("DB_USERNAME_DIC") + ":" +
47 | os.Getenv("DB_PASSWORD_DIC") + "@(" +
48 | os.Getenv("DB_HOST_DIC") + ":" +
49 | os.Getenv("DB_PORT_DIC") + ")/" +
50 | os.Getenv("DB_DATABASE_DIC") + "?charset=utf8mb4&parseTime=True&loc=Local"
51 |
52 | // gorm SQL 日志
53 | file, err := os.Create("gorm-log.txt")
54 | if err != nil {
55 | panic(err)
56 | }
57 |
58 | logLevel := logger.Warn
59 | if os.Getenv("APP_DEBUG") == "true" {
60 | logLevel = logger.Info
61 | }
62 | fileLogger := logger.New(
63 | log.New(file, "", log.LstdFlags), // io writer(日志输出的目标,前缀和日志包含的内容——译者注)
64 | logger.Config{
65 | SlowThreshold: time.Second * 6, // 慢 SQL 阈值
66 | LogLevel: logLevel, // 日志级别
67 | IgnoreRecordNotFoundError: true, // 忽略ErrRecordNotFound(记录未找到)错误
68 | Colorful: false, // 禁用彩色打印
69 | },
70 | )
71 |
72 | gormConfig := gorm.Config{
73 | Logger: fileLogger,
74 | }
75 |
76 | _db0, _ := gorm.Open(mysql.Open(dsn0), &gormConfig)
77 | // _db1, _ := gorm.Open(mysql.Open(dsn1), &gormConfig)
78 | _dbDic, _ := gorm.Open(mysql.Open(dsnDic), &gormConfig)
79 |
80 | dbdb0, _ := _db0.DB()
81 | dbdb0.SetMaxIdleConns(1)
82 | dbdb0.SetMaxOpenConns(20)
83 | dbdb0.SetConnMaxLifetime(time.Hour)
84 |
85 | // dbdb1, _ := _db1.DB()
86 | // dbdb1.SetMaxIdleConns(1)
87 | // dbdb1.SetMaxOpenConns(100)
88 | // dbdb1.SetConnMaxLifetime(time.Hour)
89 |
90 | dbdbDic, _ := _dbDic.DB()
91 | dbdbDic.SetMaxIdleConns(1)
92 | dbdbDic.SetMaxOpenConns(20)
93 | dbdbDic.SetConnMaxLifetime(time.Hour)
94 |
95 | DbInstance0 = _db0
96 | // DbInstance1 = _db1
97 | DbInstanceDic = _dbDic
98 |
99 | // 初始化 Redis
100 | // 默认 Redis,用作缓存
101 | Rdb = redis.NewClient(&redis.Options{
102 | Addr: os.Getenv("REDIS_HOST") + os.Getenv("REDIS_PORT"),
103 | Password: os.Getenv("REDIS_PASSWORD"),
104 | DB: 0,
105 | DialTimeout: time.Second,
106 | ReadTimeout: time.Second,
107 | })
108 | // 倒排索引字典生成中转站
109 | Rdb10 = redis.NewClient(&redis.Options{
110 | Addr: os.Getenv("REDIS_HOST") + os.Getenv("REDIS_PORT"),
111 | Password: os.Getenv("REDIS_PASSWORD"),
112 | DB: 10,
113 | DialTimeout: time.Second,
114 | ReadTimeout: time.Second,
115 | })
116 |
117 | }
118 |
--------------------------------------------------------------------------------
/dict/init.go:
--------------------------------------------------------------------------------
1 | package dict
2 |
3 | func Init() {
4 |
5 | }
6 |
--------------------------------------------------------------------------------
/dict/stop_words.utf8:
--------------------------------------------------------------------------------
1 | "
2 | .
3 | 。
4 | ,
5 | 、
6 | !
7 | ?
8 | :
9 | ;
10 | `
11 | ﹑
12 | •
13 | "
14 | ^
15 | …
16 | ‘
17 | ’
18 | “
19 | ”
20 | 〝
21 | 〞
22 | ~
23 | \
24 | ∕
25 | |
26 | ¦
27 | ‖
28 | —
29 | (
30 | )
31 | 〈
32 | 〉
33 | ﹞
34 | ﹝
35 | 「
36 | 」
37 | ‹
38 | ›
39 | 〖
40 | 〗
41 | 】
42 | 【
43 | »
44 | «
45 | 』
46 | 『
47 | 〕
48 | 〔
49 | 》
50 | 《
51 | }
52 | {
53 | ]
54 | [
55 | ﹐
56 | ¸
57 | ﹕
58 | ︰
59 | ﹔
60 | ;
61 | !
62 | ¡
63 | ?
64 | ¿
65 | ﹖
66 | ﹌
67 | ﹏
68 | ﹋
69 | '
70 | ´
71 | ˊ
72 | ˋ
73 | -
74 | ―
75 | ﹫
76 | @
77 | ︳
78 | ︴
79 | _
80 | ¯
81 | _
82 |  ̄
83 | ﹢
84 | +
85 | ﹦
86 | =
87 | ﹤
88 | ‐
89 | <
90 |
91 | ˜
92 | ~
93 | ﹟
94 | #
95 | ﹩
96 | $
97 | ﹠
98 | &
99 | ﹪
100 | %
101 | ﹡
102 | *
103 | ﹨
104 | \
105 | ﹍
106 | ﹉
107 | ﹎
108 | ﹊
109 | ˇ
110 | ︵
111 | ︶
112 | ︷
113 | ︸
114 | ︹
115 | ︿
116 | ﹀
117 | ︺
118 | ︽
119 | ︾
120 | _
121 | ˉ
122 | ﹁
123 | ﹂
124 | ﹃
125 | ﹄
126 | ︻
127 | ︼
128 | 的
129 | 了
130 | the
131 | a
132 | an
133 | that
134 | those
135 | this
136 | that
137 | $
138 | 0
139 | 1
140 | 2
141 | 3
142 | 4
143 | 5
144 | 6
145 | 7
146 | 8
147 | 9
148 | ?
149 | _
150 | “
151 | ”
152 | 、
153 | 。
154 | 《
155 | 》
156 | 一
157 | 一些
158 | 一何
159 | 一切
160 | 一则
161 | 一方面
162 | 一旦
163 | 一来
164 | 一样
165 | 一般
166 | 一转眼
167 | 万一
168 | 上
169 | 上下
170 | 下
171 | 不
172 | 不仅
173 | 不但
174 | 不光
175 | 不单
176 | 不只
177 | 不外乎
178 | 不如
179 | 不妨
180 | 不尽
181 | 不尽然
182 | 不得
183 | 不怕
184 | 不惟
185 | 不成
186 | 不拘
187 | 不料
188 | 不是
189 | 不比
190 | 不然
191 | 不特
192 | 不独
193 | 不管
194 | 不至于
195 | 不若
196 | 不论
197 | 不过
198 | 不问
199 | 与
200 | 与其
201 | 与其说
202 | 与否
203 | 与此同时
204 | 且
205 | 且不说
206 | 且说
207 | 两者
208 | 个
209 | 个别
210 | 临
211 | 为
212 | 为了
213 | 为什么
214 | 为何
215 | 为止
216 | 为此
217 | 为着
218 | 乃
219 | 乃至
220 | 乃至于
221 | 么
222 | 之
223 | 之一
224 | 之所以
225 | 之类
226 | 乌乎
227 | 乎
228 | 乘
229 | 也
230 | 也好
231 | 也罢
232 | 了
233 | 二来
234 | 于
235 | 于是
236 | 于是乎
237 | 云云
238 | 云尔
239 | 些
240 | 亦
241 | 人
242 | 人们
243 | 人家
244 | 什么
245 | 什么样
246 | 今
247 | 介于
248 | 仍
249 | 仍旧
250 | 从
251 | 从此
252 | 从而
253 | 他
254 | 他人
255 | 他们
256 | 以
257 | 以上
258 | 以为
259 | 以便
260 | 以免
261 | 以及
262 | 以故
263 | 以期
264 | 以来
265 | 以至
266 | 以至于
267 | 以致
268 | 们
269 | 任
270 | 任何
271 | 任凭
272 | 似的
273 | 但
274 | 但凡
275 | 但是
276 | 何
277 | 何以
278 | 何况
279 | 何处
280 | 何时
281 | 余外
282 | 作为
283 | 你
284 | 你们
285 | 使
286 | 使得
287 | 例如
288 | 依
289 | 依据
290 | 依照
291 | 便于
292 | 俺
293 | 俺们
294 | 倘
295 | 倘使
296 | 倘或
297 | 倘然
298 | 倘若
299 | 借
300 | 假使
301 | 假如
302 | 假若
303 | 傥然
304 | 像
305 | 儿
306 | 先不先
307 | 光是
308 | 全体
309 | 全部
310 | 兮
311 | 关于
312 | 其
313 | 其一
314 | 其中
315 | 其二
316 | 其他
317 | 其余
318 | 其它
319 | 其次
320 | 具体地说
321 | 具体说来
322 | 兼之
323 | 内
324 | 再
325 | 再其次
326 | 再则
327 | 再有
328 | 再者
329 | 再者说
330 | 再说
331 | 冒
332 | 冲
333 | 况且
334 | 几
335 | 几时
336 | 凡
337 | 凡是
338 | 凭
339 | 凭借
340 | 出于
341 | 出来
342 | 分别
343 | 则
344 | 则甚
345 | 别
346 | 别人
347 | 别处
348 | 别是
349 | 别的
350 | 别管
351 | 别说
352 | 到
353 | 前后
354 | 前此
355 | 前者
356 | 加之
357 | 加以
358 | 即
359 | 即令
360 | 即使
361 | 即便
362 | 即如
363 | 即或
364 | 即若
365 | 却
366 | 去
367 | 又
368 | 又及
369 | 及
370 | 及其
371 | 及至
372 | 反之
373 | 反而
374 | 反过来
375 | 反过来说
376 | 受到
377 | 另
378 | 另一方面
379 | 另外
380 | 另悉
381 | 只
382 | 只当
383 | 只怕
384 | 只是
385 | 只有
386 | 只消
387 | 只要
388 | 只限
389 | 叫
390 | 叮咚
391 | 可
392 | 可以
393 | 可是
394 | 可见
395 | 各
396 | 各个
397 | 各位
398 | 各种
399 | 各自
400 | 同
401 | 同时
402 | 后
403 | 后者
404 | 向
405 | 向使
406 | 向着
407 | 吓
408 | 吗
409 | 否则
410 | 吧
411 | 吧哒
412 | 吱
413 | 呀
414 | 呃
415 | 呕
416 | 呗
417 | 呜
418 | 呜呼
419 | 呢
420 | 呵
421 | 呵呵
422 | 呸
423 | 呼哧
424 | 咋
425 | 和
426 | 咚
427 | 咦
428 | 咧
429 | 咱
430 | 咱们
431 | 咳
432 | 哇
433 | 哈
434 | 哈哈
435 | 哉
436 | 哎
437 | 哎呀
438 | 哎哟
439 | 哗
440 | 哟
441 | 哦
442 | 哩
443 | 哪
444 | 哪个
445 | 哪些
446 | 哪儿
447 | 哪天
448 | 哪年
449 | 哪怕
450 | 哪样
451 | 哪边
452 | 哪里
453 | 哼
454 | 哼唷
455 | 唉
456 | 唯有
457 | 啊
458 | 啐
459 | 啥
460 | 啦
461 | 啪达
462 | 啷当
463 | 喂
464 | 喏
465 | 喔唷
466 | 喽
467 | 嗡
468 | 嗡嗡
469 | 嗬
470 | 嗯
471 | 嗳
472 | 嘎
473 | 嘎登
474 | 嘘
475 | 嘛
476 | 嘻
477 | 嘿
478 | 嘿嘿
479 | 因
480 | 因为
481 | 因了
482 | 因此
483 | 因着
484 | 因而
485 | 固然
486 | 在
487 | 在下
488 | 在于
489 | 地
490 | 基于
491 | 处在
492 | 多
493 | 多么
494 | 多少
495 | 大
496 | 大家
497 | 她
498 | 她们
499 | 好
500 | 如
501 | 如上
502 | 如上所述
503 | 如下
504 | 如何
505 | 如其
506 | 如同
507 | 如是
508 | 如果
509 | 如此
510 | 如若
511 | 始而
512 | 孰料
513 | 孰知
514 | 宁
515 | 宁可
516 | 宁愿
517 | 宁肯
518 | 它
519 | 它们
520 | 对
521 | 对于
522 | 对待
523 | 对方
524 | 对比
525 | 将
526 | 小
527 | 尔
528 | 尔后
529 | 尔尔
530 | 尚且
531 | 就
532 | 就是
533 | 就是了
534 | 就是说
535 | 就算
536 | 就要
537 | 尽
538 | 尽管
539 | 尽管如此
540 | 岂但
541 | 己
542 | 已
543 | 已矣
544 | 巴
545 | 巴巴
546 | 并
547 | 并且
548 | 并非
549 | 庶乎
550 | 庶几
551 | 开外
552 | 开始
553 | 归
554 | 归齐
555 | 当
556 | 当地
557 | 当然
558 | 当着
559 | 彼
560 | 彼时
561 | 彼此
562 | 往
563 | 待
564 | 很
565 | 得
566 | 得了
567 | 怎
568 | 怎么
569 | 怎么办
570 | 怎么样
571 | 怎奈
572 | 怎样
573 | 总之
574 | 总的来看
575 | 总的来说
576 | 总的说来
577 | 总而言之
578 | 恰恰相反
579 | 您
580 | 惟其
581 | 慢说
582 | 我
583 | 我们
584 | 或
585 | 或则
586 | 或是
587 | 或曰
588 | 或者
589 | 截至
590 | 所
591 | 所以
592 | 所在
593 | 所幸
594 | 所有
595 | 才
596 | 才能
597 | 打
598 | 打从
599 | 把
600 | 抑或
601 | 拿
602 | 按
603 | 按照
604 | 换句话说
605 | 换言之
606 | 据
607 | 据此
608 | 接着
609 | 故
610 | 故此
611 | 故而
612 | 旁人
613 | 无
614 | 无宁
615 | 无论
616 | 既
617 | 既往
618 | 既是
619 | 既然
620 | 时候
621 | 是
622 | 是以
623 | 是的
624 | 曾
625 | 替
626 | 替代
627 | 最
628 | 有
629 | 有些
630 | 有关
631 | 有及
632 | 有时
633 | 有的
634 | 望
635 | 朝
636 | 朝着
637 | 本
638 | 本人
639 | 本地
640 | 本着
641 | 本身
642 | 来
643 | 来着
644 | 来自
645 | 来说
646 | 极了
647 | 果然
648 | 果真
649 | 某
650 | 某个
651 | 某些
652 | 某某
653 | 根据
654 | 欤
655 | 正值
656 | 正如
657 | 正巧
658 | 正是
659 | 此
660 | 此地
661 | 此处
662 | 此外
663 | 此时
664 | 此次
665 | 此间
666 | 毋宁
667 | 每
668 | 每当
669 | 比
670 | 比及
671 | 比如
672 | 比方
673 | 没奈何
674 | 沿
675 | 沿着
676 | 漫说
677 | 焉
678 | 然则
679 | 然后
680 | 然而
681 | 照
682 | 照着
683 | 犹且
684 | 犹自
685 | 甚且
686 | 甚么
687 | 甚或
688 | 甚而
689 | 甚至
690 | 甚至于
691 | 用
692 | 用来
693 | 由
694 | 由于
695 | 由是
696 | 由此
697 | 由此可见
698 | 的
699 | 的确
700 | 的话
701 | 直到
702 | 相对而言
703 | 省得
704 | 看
705 | 眨眼
706 | 着
707 | 着呢
708 | 矣
709 | 矣乎
710 | 矣哉
711 | 离
712 | 竟而
713 | 第
714 | 等
715 | 等到
716 | 等等
717 | 简言之
718 | 管
719 | 类如
720 | 紧接着
721 | 纵
722 | 纵令
723 | 纵使
724 | 纵然
725 | 经
726 | 经过
727 | 结果
728 | 给
729 | 继之
730 | 继后
731 | 继而
732 | 综上所述
733 | 罢了
734 | 者
735 | 而
736 | 而且
737 | 而况
738 | 而后
739 | 而外
740 | 而已
741 | 而是
742 | 而言
743 | 能
744 | 能否
745 | 腾
746 | 自
747 | 自个儿
748 | 自从
749 | 自各儿
750 | 自后
751 | 自家
752 | 自己
753 | 自打
754 | 自身
755 | 至
756 | 至于
757 | 至今
758 | 至若
759 | 致
760 | 般的
761 | 若
762 | 若夫
763 | 若是
764 | 若果
765 | 若非
766 | 莫不然
767 | 莫如
768 | 莫若
769 | 虽
770 | 虽则
771 | 虽然
772 | 虽说
773 | 被
774 | 要
775 | 要不
776 | 要不是
777 | 要不然
778 | 要么
779 | 要是
780 | 譬喻
781 | 譬如
782 | 让
783 | 许多
784 | 论
785 | 设使
786 | 设或
787 | 设若
788 | 诚如
789 | 诚然
790 | 该
791 | 说来
792 | 诸
793 | 诸位
794 | 诸如
795 | 谁
796 | 谁人
797 | 谁料
798 | 谁知
799 | 贼死
800 | 赖以
801 | 赶
802 | 起
803 | 起见
804 | 趁
805 | 趁着
806 | 越是
807 | 距
808 | 跟
809 | 较
810 | 较之
811 | 边
812 | 过
813 | 还
814 | 还是
815 | 还有
816 | 还要
817 | 这
818 | 这一来
819 | 这个
820 | 这么
821 | 这么些
822 | 这么样
823 | 这么点儿
824 | 这些
825 | 这会儿
826 | 这儿
827 | 这就是说
828 | 这时
829 | 这样
830 | 这次
831 | 这般
832 | 这边
833 | 这里
834 | 进而
835 | 连
836 | 连同
837 | 逐步
838 | 通过
839 | 遵循
840 | 遵照
841 | 那
842 | 那个
843 | 那么
844 | 那么些
845 | 那么样
846 | 那些
847 | 那会儿
848 | 那儿
849 | 那时
850 | 那样
851 | 那般
852 | 那边
853 | 那里
854 | 都
855 | 鄙人
856 | 鉴于
857 | 针对
858 | 阿
859 | 除
860 | 除了
861 | 除外
862 | 除开
863 | 除此之外
864 | 除非
865 | 随
866 | 随后
867 | 随时
868 | 随着
869 | 难道说
870 | 非但
871 | 非徒
872 | 非特
873 | 非独
874 | 靠
875 | 顺
876 | 顺着
877 | 首先
878 | !
879 | ,
880 | :
881 | ;
882 | ?
883 | to
884 | can
885 | could
886 | dare
887 | do
888 | did
889 | does
890 | may
891 | might
892 | would
893 | should
894 | must
895 | will
896 | ought
897 | shall
898 | need
899 | is
900 | a
901 | am
902 | are
903 | about
904 | according
905 | after
906 | against
907 | all
908 | almost
909 | also
910 | although
911 | among
912 | an
913 | and
914 | another
915 | any
916 | anything
917 | approximately
918 | as
919 | asked
920 | at
921 | back
922 | because
923 | before
924 | besides
925 | between
926 | both
927 | but
928 | by
929 | call
930 | called
931 | currently
932 | despite
933 | did
934 | do
935 | dr
936 | during
937 | each
938 | earlier
939 | eight
940 | even
941 | eventually
942 | every
943 | everything
944 | five
945 | for
946 | four
947 | from
948 | he
949 | her
950 | here
951 | his
952 | how
953 | however
954 | i
955 | if
956 | in
957 | indeed
958 | instead
959 | it
960 | its
961 | just
962 | last
963 | like
964 | major
965 | many
966 | may
967 | maybe
968 | meanwhile
969 | more
970 | moreover
971 | most
972 | mr
973 | mrs
974 | ms
975 | much
976 | my
977 | neither
978 | net
979 | never
980 | nevertheless
981 | nine
982 | no
983 | none
984 | not
985 | nothing
986 | now
987 | of
988 | on
989 | once
990 | one
991 | only
992 | or
993 | other
994 | our
995 | over
996 | partly
997 | perhaps
998 | prior
999 | regarding
1000 | separately
1001 | seven
1002 | several
1003 | she
1004 | should
1005 | similarly
1006 | since
1007 | six
1008 | so
1009 | some
1010 | somehow
1011 | still
1012 | such
1013 | ten
1014 | that
1015 | the
1016 | their
1017 | then
1018 | there
1019 | therefore
1020 | these
1021 | they
1022 | this
1023 | those
1024 | though
1025 | three
1026 | to
1027 | two
1028 | under
1029 | unless
1030 | unlike
1031 | until
1032 | volume
1033 | we
1034 | what
1035 | whatever
1036 | whats
1037 | when
1038 | where
1039 | which
1040 | while
1041 | why
1042 | with
1043 | without
1044 | yesterday
1045 | yet
1046 | you
1047 | your
1048 | aboard
1049 | about
1050 | above
1051 | according to
1052 | across
1053 | afore
1054 | after
1055 | against
1056 | agin
1057 | along
1058 | alongside
1059 | amid
1060 | amidst
1061 | among
1062 | amongst
1063 | anent
1064 | around
1065 | as
1066 | aslant
1067 | astride
1068 | at
1069 | athwart
1070 | bar
1071 | because of
1072 | before
1073 | behind
1074 | below
1075 | beneath
1076 | beside
1077 | besides
1078 | between
1079 | betwixt
1080 | beyond
1081 | but
1082 | by
1083 | circa
1084 | despite
1085 | down
1086 | during
1087 | due to
1088 | ere
1089 | except
1090 | for
1091 | from
1092 | in
1093 | inside
1094 | into
1095 | less
1096 | like
1097 | mid
1098 | midst
1099 | minus
1100 | near
1101 | next
1102 | nigh
1103 | nigher
1104 | nighest
1105 | notwithstanding
1106 | of
1107 | off
1108 | on
1109 | on to
1110 | onto
1111 | out
1112 | out of
1113 | outside
1114 | over
1115 | past
1116 | pending
1117 | per
1118 | plus
1119 | qua
1120 | re
1121 | round
1122 | sans
1123 | save
1124 | since
1125 | through
1126 | throughout
1127 | thru
1128 | till
1129 | to
1130 | toward
1131 | towards
1132 | under
1133 | underneath
1134 | unlike
1135 | until
1136 | unto
1137 | up
1138 | upon
1139 | versus
1140 | via
1141 | vice
1142 | with
1143 | within
1144 | without
1145 | he
1146 | her
1147 | herself
1148 | hers
1149 | him
1150 | himself
1151 | his
1152 | I
1153 | it
1154 | its
1155 | itself
1156 | me
1157 | mine
1158 | my
1159 | myself
1160 | ours
1161 | she
1162 | their
1163 | theirs
1164 | them
1165 | themselves
1166 | they
1167 | us
1168 | we
1169 | our
1170 | ourselves
1171 | you
1172 | your
1173 | yours
1174 | yourselves
1175 | yourself
1176 | this
1177 | that
1178 | these
1179 | those
1180 | "
1181 | '
1182 | ''
1183 | (
1184 | )
1185 | *LRB*
1186 | *RRB*
1187 |
1188 |
1189 |
1190 |
1191 |
1192 | @
1193 | &
1194 | [
1195 | ]
1196 | `
1197 | ``
1198 | e.g.,
1199 | {
1200 | }
1201 | "
1202 | “
1203 | ”
1204 | -RRB-
1205 | -LRB-
1206 | --
1207 | a
1208 | about
1209 | above
1210 | across
1211 | after
1212 | afterwards
1213 | again
1214 | against
1215 | all
1216 | almost
1217 | alone
1218 | along
1219 | already
1220 | also
1221 | although
1222 | always
1223 | am
1224 | among
1225 | amongst
1226 | amoungst
1227 | amount
1228 | an
1229 | and
1230 | another
1231 | any
1232 | anyhow
1233 | anyone
1234 | anything
1235 | anyway
1236 | anywhere
1237 | are
1238 | around
1239 | as
1240 | at
1241 | back
1242 | be
1243 | became
1244 | because
1245 | become
1246 | becomes
1247 | becoming
1248 | been
1249 | before
1250 | beforehand
1251 | behind
1252 | being
1253 | below
1254 | beside
1255 | besides
1256 | between
1257 | beyond
1258 | bill
1259 | both
1260 | bottom
1261 | but
1262 | by
1263 | call
1264 | can
1265 | cannot
1266 | cant
1267 | co
1268 | computer
1269 | con
1270 | could
1271 | couldnt
1272 | cry
1273 | de
1274 | describe
1275 | detail
1276 | do
1277 | done
1278 | down
1279 | due
1280 | during
1281 | each
1282 | eg
1283 | eight
1284 | either
1285 | eleven
1286 | else
1287 | elsewhere
1288 | empty
1289 | enough
1290 | etc
1291 | even
1292 | ever
1293 | every
1294 | everyone
1295 | everything
1296 | everywhere
1297 | except
1298 | few
1299 | fifteen
1300 | fify
1301 | fill
1302 | find
1303 | fire
1304 | first
1305 | five
1306 | for
1307 | former
1308 | formerly
1309 | forty
1310 | found
1311 | four
1312 | from
1313 | front
1314 | full
1315 | further
1316 | get
1317 | give
1318 | go
1319 | had
1320 | has
1321 | hasnt
1322 | have
1323 | he
1324 | hence
1325 | her
1326 | here
1327 | hereafter
1328 | hereby
1329 | herein
1330 | hereupon
1331 | hers
1332 | herself
1333 | him
1334 | himself
1335 | his
1336 | how
1337 | however
1338 | hundred
1339 | i
1340 | ie
1341 | if
1342 | in
1343 | inc
1344 | indeed
1345 | interest
1346 | into
1347 | is
1348 | it
1349 | its
1350 | itself
1351 | keep
1352 | last
1353 | latter
1354 | latterly
1355 | least
1356 | less
1357 | ltd
1358 | made
1359 | many
1360 | may
1361 | me
1362 | meanwhile
1363 | might
1364 | mill
1365 | mine
1366 | more
1367 | moreover
1368 | most
1369 | mostly
1370 | move
1371 | much
1372 | must
1373 | my
1374 | myself
1375 | name
1376 | namely
1377 | neither
1378 | never
1379 | nevertheless
1380 | next
1381 | nine
1382 | no
1383 | nobody
1384 | none
1385 | noone
1386 | nor
1387 | not
1388 | nothing
1389 | now
1390 | nowhere
1391 | of
1392 | off
1393 | often
1394 | on
1395 | once
1396 | one
1397 | only
1398 | onto
1399 | or
1400 | other
1401 | others
1402 | otherwise
1403 | our
1404 | ours
1405 | ourselves
1406 | out
1407 | over
1408 | own
1409 | part
1410 | per
1411 | perhaps
1412 | please
1413 | put
1414 | rather
1415 | re
1416 | same
1417 | see
1418 | seem
1419 | seemed
1420 | seeming
1421 | seems
1422 | serious
1423 | several
1424 | she
1425 | should
1426 | show
1427 | side
1428 | since
1429 | sincere
1430 | six
1431 | sixty
1432 | so
1433 | some
1434 | somehow
1435 | someone
1436 | something
1437 | sometime
1438 | sometimes
1439 | somewhere
1440 | still
1441 | such
1442 | system
1443 | take
1444 | ten
1445 | than
1446 | that
1447 | the
1448 | their
1449 | them
1450 | themselves
1451 | then
1452 | thence
1453 | there
1454 | thereafter
1455 | thereby
1456 | therefore
1457 | therein
1458 | thereupon
1459 | these
1460 | they
1461 | thick
1462 | thin
1463 | third
1464 | this
1465 | those
1466 | though
1467 | three
1468 | through
1469 | throughout
1470 | thru
1471 | thus
1472 | to
1473 | together
1474 | too
1475 | top
1476 | toward
1477 | towards
1478 | twelve
1479 | twenty
1480 | two
1481 | un
1482 | under
1483 | until
1484 | up
1485 | upon
1486 | us
1487 | very
1488 | via
1489 | was
1490 | we
1491 | well
1492 | were
1493 | what
1494 | whatever
1495 | when
1496 | whence
1497 | whenever
1498 | where
1499 | whereafter
1500 | whereas
1501 | whereby
1502 | wherein
1503 | whereupon
1504 | wherever
1505 | whether
1506 | which
1507 | while
1508 | whither
1509 | who
1510 | whoever
1511 | whole
1512 | whom
1513 | whose
1514 | why
1515 | will
1516 | with
1517 | within
1518 | without
1519 | would
1520 | yet
1521 | you
1522 | your
1523 | yours
1524 | yourself
1525 | yourselves
1526 |
1527 |
1528 | :
1529 | /
1530 | (
1531 | >
1532 | )
1533 | <
1534 | !
1535 |
--------------------------------------------------------------------------------
/dict/user.dict.utf8:
--------------------------------------------------------------------------------
1 | 云计算
2 | 韩玉鉴赏
3 | 蓝翔 nz
4 | 区块链 10 nz
5 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | # MySQL 服务配置
5 | mysql:
6 | image: mysql:latest
7 | restart: always
8 | ports:
9 | - "3306:3306"
10 | environment:
11 | MYSQL_ROOT_PASSWORD: your_root_password
12 | MYSQL_DATABASE: your_database_name
13 | volumes:
14 | - mysql_data:/var/lib/mysql
15 |
16 | # Redis 服务配置
17 | redis:
18 | image: redis:latest
19 | restart: always
20 | ports:
21 | - "6379:6379"
22 | volumes:
23 | - redis_data:/data
24 |
25 | # 项目服务配置
26 | ese:
27 | build:
28 | context: .
29 | ports:
30 | - "8080:8080"
31 | depends_on:
32 | - mysql
33 | - redis
34 | environment:
35 | MYSQL_HOST: mysql
36 | MYSQL_PORT: "3306"
37 | MYSQL_USER: root
38 | MYSQL_PASSWORD: your_root_password
39 | MYSQL_DBNAME: your_database_name
40 | REDIS_ADDR: redis:6379
41 | volumes:
42 | - .:/app
43 |
44 | volumes:
45 | mysql_data:
46 | redis_data:
47 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/johnlui/enterprise-search-engine
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/PuerkitoBio/goquery v1.8.1 // indirect
7 | github.com/andybalholm/brotli v1.0.4 // indirect
8 | github.com/andybalholm/cascadia v1.3.1 // indirect
9 | github.com/bytedance/sonic v1.9.1 // indirect
10 | github.com/cespare/xxhash/v2 v2.1.2 // indirect
11 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
12 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
13 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect
14 | github.com/gaukas/godicttls v0.0.3 // indirect
15 | github.com/gin-contrib/sse v0.1.0 // indirect
16 | github.com/gin-gonic/gin v1.9.1 // indirect
17 | github.com/go-playground/locales v0.14.1 // indirect
18 | github.com/go-playground/universal-translator v0.18.1 // indirect
19 | github.com/go-playground/validator/v10 v10.14.0 // indirect
20 | github.com/go-redis/redis/v8 v8.11.5 // indirect
21 | github.com/go-sql-driver/mysql v1.7.0 // indirect
22 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
23 | github.com/goccy/go-json v0.10.2 // indirect
24 | github.com/golang/mock v1.6.0 // indirect
25 | github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 // indirect
26 | github.com/hashicorp/errwrap v1.1.0 // indirect
27 | github.com/hashicorp/go-multierror v1.1.1 // indirect
28 | github.com/imroc/req/v3 v3.37.1 // indirect
29 | github.com/jinzhu/inflection v1.0.0 // indirect
30 | github.com/jinzhu/now v1.1.5 // indirect
31 | github.com/joho/godotenv v1.5.1 // indirect
32 | github.com/json-iterator/go v1.1.12 // indirect
33 | github.com/klauspost/compress v1.15.15 // indirect
34 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect
35 | github.com/leodido/go-urn v1.2.4 // indirect
36 | github.com/mattn/go-isatty v0.0.19 // indirect
37 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
38 | github.com/modern-go/reflect2 v1.0.2 // indirect
39 | github.com/onsi/ginkgo/v2 v2.10.0 // indirect
40 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect
41 | github.com/quic-go/qpack v0.4.0 // indirect
42 | github.com/quic-go/qtls-go1-19 v0.3.2 // indirect
43 | github.com/quic-go/qtls-go1-20 v0.2.2 // indirect
44 | github.com/quic-go/quic-go v0.35.1 // indirect
45 | github.com/refraction-networking/utls v1.3.2 // indirect
46 | github.com/robfig/cron/v3 v3.0.1 // indirect
47 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
48 | github.com/ugorji/go/codec v1.2.11 // indirect
49 | github.com/yanyiwu/gojieba v1.3.0 // indirect
50 | golang.org/x/arch v0.3.0 // indirect
51 | golang.org/x/crypto v0.10.0 // indirect
52 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
53 | golang.org/x/mod v0.11.0 // indirect
54 | golang.org/x/net v0.11.0 // indirect
55 | golang.org/x/sys v0.9.0 // indirect
56 | golang.org/x/text v0.10.0 // indirect
57 | golang.org/x/tools v0.10.0 // indirect
58 | google.golang.org/protobuf v1.30.0 // indirect
59 | gopkg.in/yaml.v3 v3.0.1 // indirect
60 | gorm.io/driver/mysql v1.5.1 // indirect
61 | gorm.io/gorm v1.25.1 // indirect
62 | )
63 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
2 | github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
3 | github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
4 | github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
5 | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
6 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
7 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
8 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
9 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
10 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
11 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
12 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
13 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
14 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
18 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
19 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
20 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
21 | github.com/gaukas/godicttls v0.0.3 h1:YNDIf0d9adcxOijiLrEzpfZGAkNwLRzPaG6OjU7EITk=
22 | github.com/gaukas/godicttls v0.0.3/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
23 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
24 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
25 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
26 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
27 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
28 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
29 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
30 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
31 | github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
32 | github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
33 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
34 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
35 | github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
36 | github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
37 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
38 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
39 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
40 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
41 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
42 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
43 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
44 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
45 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
46 | github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 h1:hR7/MlvK23p6+lIw9SN1TigNLn9ZnF3W4SYRKq2gAHs=
47 | github.com/google/pprof v0.0.0-20230602150820-91b7bce49751/go.mod h1:Jh3hGz2jkYak8qXPD19ryItVnUgpgeqzdkY/D0EaeuA=
48 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
49 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
50 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
51 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
52 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
53 | github.com/imroc/req/v3 v3.37.1 h1:HUs5/jazZWTlTGMs3PCV15vqQq/ha9fY1NV+RYACrxI=
54 | github.com/imroc/req/v3 v3.37.1/go.mod h1:DECzjVIrj6jcUr5n6e+z0ygmCO93rx4Jy0RjOEe1YCI=
55 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
56 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
57 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
58 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
59 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
60 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
61 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
62 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
63 | github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw=
64 | github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4=
65 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
66 | github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
67 | github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
68 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
69 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
70 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
71 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
72 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
73 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
74 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
75 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
76 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
77 | github.com/onsi/ginkgo/v2 v2.10.0 h1:sfUl4qgLdvkChZrWCYndY2EAu9BRIw1YphNAzy1VNWs=
78 | github.com/onsi/ginkgo/v2 v2.10.0/go.mod h1:UDQOh5wbQUlMnkLfVaIUMtQ1Vus92oM+P2JX1aulgcE=
79 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
80 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
81 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
82 | github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
83 | github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
84 | github.com/quic-go/qtls-go1-19 v0.3.2 h1:tFxjCFcTQzK+oMxG6Zcvp4Dq8dx4yD3dDiIiyc86Z5U=
85 | github.com/quic-go/qtls-go1-19 v0.3.2/go.mod h1:ySOI96ew8lnoKPtSqx2BlI5wCpUVPT05RMAlajtnyOI=
86 | github.com/quic-go/qtls-go1-20 v0.2.2 h1:WLOPx6OY/hxtTxKV1Zrq20FtXtDEkeY00CGQm8GEa3E=
87 | github.com/quic-go/qtls-go1-20 v0.2.2/go.mod h1:JKtK6mjbAVcUTN/9jZpvLbGxvdWIKS8uT7EiStoU1SM=
88 | github.com/quic-go/quic-go v0.35.1 h1:b0kzj6b/cQAf05cT0CkQubHM31wiA+xH3IBkxP62poo=
89 | github.com/quic-go/quic-go v0.35.1/go.mod h1:+4CVgVppm0FNjpG3UcX8Joi/frKOH7/ciD5yGcwOO1g=
90 | github.com/refraction-networking/utls v1.3.2 h1:o+AkWB57mkcoW36ET7uJ002CpBWHu0KPxi6vzxvPnv8=
91 | github.com/refraction-networking/utls v1.3.2/go.mod h1:fmoaOww2bxzzEpIKOebIsnBvjQpqP7L2vcm/9KUfm/E=
92 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
93 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
94 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
95 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
96 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
97 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
98 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
99 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
100 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
101 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
102 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
103 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
104 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
105 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
106 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
107 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
108 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
109 | github.com/yanyiwu/gojieba v1.3.0 h1:6VeaPOR+MawnImdeSvWNr7rP4tvUfnGlEKaoBnR33Ds=
110 | github.com/yanyiwu/gojieba v1.3.0/go.mod h1:54wkP7sMJ6bklf7yPl6F+JG71dzVUU1WigZbR47nGdY=
111 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
112 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
113 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
114 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
115 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
116 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
117 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
118 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
119 | golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
120 | golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
121 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
122 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
123 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
124 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
125 | golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
126 | golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
127 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
128 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
129 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
130 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
131 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
132 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
133 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
134 | golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
135 | golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
136 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
137 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
138 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
139 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
140 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
141 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
142 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
143 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
144 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
145 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
146 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
147 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
148 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
149 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
150 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
151 | golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
152 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
153 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
154 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
155 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
156 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
157 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
158 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
159 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
160 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
161 | golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
162 | golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
163 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
164 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
165 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
166 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
167 | golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
168 | golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
169 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
170 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
171 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
172 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
173 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
174 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
175 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
176 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
177 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
178 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
179 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
180 | gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
181 | gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
182 | gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
183 | gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
184 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
185 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "flag"
6 | "fmt"
7 | "log"
8 | "net/url"
9 | "os"
10 | "path"
11 | "path/filepath"
12 | "reflect"
13 | "strconv"
14 | "strings"
15 | "time"
16 |
17 | "github.com/johnlui/enterprise-search-engine/controllers"
18 | "github.com/johnlui/enterprise-search-engine/db"
19 | "github.com/johnlui/enterprise-search-engine/models"
20 | "github.com/johnlui/enterprise-search-engine/tools"
21 |
22 | "github.com/PuerkitoBio/goquery"
23 | "github.com/gin-gonic/gin"
24 | "github.com/joho/godotenv"
25 | "github.com/robfig/cron/v3"
26 | "github.com/yanyiwu/gojieba"
27 | "golang.org/x/text/width"
28 | "gorm.io/gorm"
29 | )
30 |
31 | var domain1BlackList map[string]struct{}
32 | var wordBlackList map[string]struct{}
33 |
34 | var 一次爬取 = 4
35 | var 一次准备 = 20
36 |
37 | var 每分钟每个表执行分词 = 2
38 | var 一步转移的字典条数 = 2000
39 | var 每个词转移的深度 int64 = 10000
40 |
41 | func main() {
42 | // 处理启动参数
43 | flag.Parse()
44 |
45 | // 加载 .env
46 | initENV()
47 |
48 | // 初始化结巴分词
49 | initJieba()
50 |
51 | // 初始化数据库
52 | db.InitDB()
53 |
54 | // Art 命令行工具
55 | initArtCommands()
56 |
57 | // 启动 web 页面
58 | go startServer()
59 |
60 | // 定时任务
61 | c := cron.New(cron.WithSeconds())
62 | // 自动从 pages 复制数据到 status
63 | c.AddFunc("*/20 * * * * *", autoParsePagesToStatus)
64 | // 将可以爬的 URL 插入 Redis
65 | c.AddFunc("*/20 * * * * *", prepareStatusesBackground)
66 | // 五分钟刷新一次每个 host 的页面数量
67 | c.AddFunc("0 */5 * * * *", refreshHostCount)
68 | // 分词,生成字典数据,并将数据插入 Redis
69 | c.AddFunc("25 * * * * *", washHTMLToDB10)
70 | // 字典从 Redis 批量插入 MySQL
71 | c.AddFunc("*/6 * * * * *", washDB10ToDicMySQL)
72 | go c.Start()
73 |
74 | // 生产环境专用
75 | if !tools.ENV_DEBUG {
76 | washDB10ToDicMySQL()
77 | }
78 | /*
79 | spider
80 | */
81 | // 开始爬
82 | nextStep(time.Now())
83 |
84 | // 阻塞,不跑爬虫时用于阻塞主线程
85 | select {}
86 | }
87 |
88 | func initENV() {
89 | path, _ := os.Getwd()
90 | err := godotenv.Load(path + "/.env")
91 | fmt.Println("加载.env :", path+"/.env")
92 | if err != nil {
93 | log.Fatal("Error loading .env file")
94 | }
95 | tools.ENV_DEBUG = os.Getenv("APP_DEBUG") == "true"
96 | fmt.Println("APP_ENV:", os.Getenv("APP_ENV"))
97 | }
98 | func initArtCommands() {
99 | argsWithProg := os.Args[1:]
100 |
101 | if len(argsWithProg) > 1 && argsWithProg[0] == "art" {
102 | a := Art{}
103 |
104 | meth := reflect.ValueOf(a).MethodByName(tools.FirstLetterUppercase(strings.ToLower(argsWithProg[1])))
105 |
106 | if meth.IsValid() {
107 | if len(argsWithProg) > 2 {
108 | m := meth.Interface().(func(...string))
109 | m(argsWithProg[2:]...)
110 | } else {
111 | meth.Call([]reflect.Value{})
112 | }
113 | } else {
114 | tools.DD("命令不存在")
115 | }
116 |
117 | tools.DD("命令执行结束,退出")
118 | }
119 | }
120 | func initJieba() {
121 | dictDir := path.Join(filepath.Dir(os.Args[0]), "dict")
122 | jiebaPath := path.Join(dictDir, "jieba.dict.utf8")
123 | hmmPath := path.Join(dictDir, "hmm_model.utf8")
124 | userPath := path.Join(dictDir, "user.dict.utf8")
125 | idfPath := path.Join(dictDir, "idf.utf8")
126 | stopPath := path.Join(dictDir, "stop_words.utf8")
127 | tools.JiebaInstance = gojieba.NewJieba(jiebaPath, hmmPath, userPath, idfPath, stopPath)
128 | }
129 |
130 | // 循环爬
131 | func nextStep(t time.Time) {
132 | // 判断爬虫开关是否关闭
133 | _stop := -1
134 | db.DbInstance0.Table("kvstores").Where("k", "stop").Select("v").Find(&_stop)
135 | if _stop == -1 {
136 | fmt.Println("kvstores数据库连接失败,请检查 gorm-log.txt 日志")
137 | os.Exit(0)
138 | } else if _stop == 1 {
139 | fmt.Println("全局开关关闭,30秒后再检测")
140 | time.Sleep(time.Second * 30)
141 | nextStep(time.Now())
142 | }
143 |
144 | // 重载一级域名黑名单
145 | domain1BlackList = map[string]struct{}{
146 | "huangye88.com": struct{}{},
147 | "gov.cn": struct{}{},
148 | }
149 | _domain1BlackList := []string{}
150 | db.DbInstance0.Raw("select domain from domain_black_list").Scan(&_domain1BlackList)
151 | for _, v := range _domain1BlackList {
152 | domain1BlackList[v] = struct{}{}
153 | }
154 |
155 | // fmt.Println("开始准备本轮数据,计划共", maxNumber*256, "条")
156 |
157 | var statusArr []models.Status
158 |
159 | // statusCHs := make([]chan []models.Status, 256)
160 |
161 | maxNumber := 1
162 | if os.Getenv("APP_DEBUG") == "false" {
163 | maxNumber = 一次爬取
164 | }
165 |
166 | for i := 0; i < 256*maxNumber; i++ {
167 | jsonString := db.Rdb.RPop(db.Ctx, "need_craw_list").Val()
168 | var _status models.Status
169 | err := json.Unmarshal([]byte(jsonString), &_status)
170 | if err != nil {
171 | continue
172 | }
173 | statusArr = append(statusArr, _status)
174 | }
175 |
176 | validCount := len(statusArr)
177 |
178 | fmt.Println("本轮数据共", validCount, "条")
179 |
180 | if validCount == 0 {
181 | fmt.Println("本轮无数据,60秒后再检测")
182 | time.Sleep(time.Minute)
183 | nextStep(time.Now())
184 | }
185 |
186 | chs := make([]chan int, validCount)
187 | for k, v := range statusArr {
188 | chs[k] = make(chan int)
189 | go craw(v, chs[k], k)
190 | }
191 |
192 | var results = make(map[int]int)
193 | for _, ch := range chs {
194 | r := <-ch
195 |
196 | _, prs := results[r]
197 | if prs {
198 | results[r] += 1
199 | } else {
200 | results[r] = 1
201 | }
202 | }
203 |
204 | fmt.Println("跑完一轮", time.Now().Unix()-t.Unix(), "秒,有效",
205 | results[1], "条,略过",
206 | results[0], "条,网络错误",
207 | results[2], "条,多次网络错误置done",
208 | results[4], "条")
209 | if results[3] > 0 {
210 | fmt.Println("HTML解析失败", results[3], "条")
211 | }
212 |
213 | // 有效
214 | key := "ese_spider_result_in_minute_" + strconv.Itoa(int(time.Now().Unix())/60)
215 | db.Rdb.IncrBy(db.Ctx, key, int64(results[1])).Err()
216 | db.Rdb.Expire(db.Ctx, key, time.Hour).Err()
217 | // 多次网络错误置done
218 | key1 := "ese_spider_result_4_in_minute_" + strconv.Itoa(int(time.Now().Unix())/60)
219 | db.Rdb.IncrBy(db.Ctx, key1, int64(results[4])).Err()
220 | db.Rdb.Expire(db.Ctx, key1, time.Hour).Err()
221 |
222 | nextStep(time.Now())
223 | }
224 |
225 | // 真的爬,存储标题,内容,以及子链接
226 | func craw(status models.Status, ch chan int, index int) {
227 |
228 | // 检查是否过于频繁
229 | if statusHostCrawIsTooMuch(status.Host) {
230 | ch <- 0
231 | // fmt.Println("过于频繁", time.Now().UnixMilli()-t.UnixMilli(), "毫秒")
232 | return
233 | }
234 | doc, chVal := tools.Curl(status, ch)
235 |
236 | // 如果失败,则不进行任何操作
237 | if chVal != 1 && chVal != 4 {
238 | ch <- chVal
239 |
240 | // fmt.Println("curl失败", time.Now().UnixMilli()-t.UnixMilli(), "毫秒")
241 | return
242 | }
243 |
244 | // 更新 Status
245 | status.CrawDone = 1
246 | status.CrawTime = time.Now()
247 | realDB(status.Url).Scopes(statusTable(status.Url)).Save(&status)
248 |
249 | // 更新 Lake
250 | var lake models.Page
251 | realDB(status.Url).Scopes(lakeTable(status.Url)).Where(models.Page{ID: status.ID}).FirstOrCreate(&lake)
252 |
253 | lake.Url = status.Url
254 | lake.Host = status.Host
255 | lake.CrawDone = status.CrawDone
256 | lake.CrawTime = status.CrawTime
257 | lake.Title = tools.StringStrip(strings.TrimSpace(doc.Find("title").Text()))
258 | lake.Text = tools.StringStrip(strings.TrimSpace(doc.Text()))
259 | realDB(status.Url).Scopes(lakeTable(status.Url)).Save(&lake)
260 |
261 | // 开始处理页面上新的超链接
262 | _stopNew := -1
263 | db.DbInstance0.Table("kvstores").Where("k", "stopNew").Select("v").Find(&_stopNew)
264 | if _stopNew == -1 {
265 | fmt.Println("kvstores数据库连接失败,请检查 gorm-log.txt 日志")
266 | os.Exit(0)
267 | } else if _stopNew == 1 {
268 | // fmt.Println("新URL全局开关关闭")
269 | } else {
270 | urlMap := make(map[string]int)
271 | doc.Find("a").Each(func(i int, s *goquery.Selection) {
272 | // For each item found, get the title
273 | title := strings.Trim(s.Text(), " \n")
274 | href := width.Narrow.String(strings.Trim(s.AttrOr("href", ""), " \n"))
275 | _url, _, _ := strings.Cut(href, "#")
276 | _url = strings.ToLower(_url)
277 |
278 | // 判断一个页面上是否有两个URL重复
279 | _, urlPrs := urlMap[_url]
280 | if urlPrs {
281 | return
282 | }
283 | urlMap[_url] = 1
284 |
285 | if tools.IsUrl(_url) {
286 | u, _ := url.Parse(_url)
287 |
288 | parts := strings.Split(u.Host, ".")
289 | domain1 := ""
290 | domain2 := ""
291 | if len(parts) >= 2 {
292 | domain1 = parts[len(parts)-2] + "." + parts[len(parts)-1]
293 | domain2 = domain1
294 | if len(parts) >= 3 {
295 | domain2 = parts[len(parts)-3] + "." + parts[len(parts)-2] + "." + parts[len(parts)-1]
296 | }
297 | }
298 |
299 | _, prs := domain1BlackList[domain1]
300 | if !prs {
301 | allStatusKey := "ese_spider_all_status_in_minute_" + strconv.Itoa(int(time.Now().Unix())/60)
302 |
303 | statusHashMapKey := "ese_spider_status_exist"
304 | statusExist := db.Rdb.HExists(db.Ctx, statusHashMapKey, _url).Val()
305 | // 若 HashMap 中不存在,则查询数据库
306 | if !statusExist {
307 | var newStatus models.Status
308 | result := realDB(_url).Scopes(statusTable(_url)).Where(models.Status{Url: _url}).FirstOrCreate(&newStatus)
309 |
310 | newStatus.Url = _url
311 | newStatus.Host = strings.ToLower(u.Host)
312 | newStatus.CrawTime, _ = time.Parse("2006-01-02 15:04:05", "2001-01-01 00:00:00")
313 | realDB(_url).Scopes(statusTable(_url)).Save(&newStatus)
314 |
315 | if result.RowsAffected > 0 {
316 | newStatusKey := "ese_spider_new_status_in_minute_" + strconv.Itoa(int(time.Now().Unix())/60)
317 | db.Rdb.IncrBy(db.Ctx, newStatusKey, 1).Err()
318 | db.Rdb.Expire(db.Ctx, newStatusKey, time.Hour).Err()
319 | }
320 |
321 | var newLake models.Page
322 | realDB(_url).Scopes(lakeTable(_url)).Where(models.Page{ID: newStatus.ID}).FirstOrCreate(&newLake)
323 |
324 | newLake.ID = newStatus.ID
325 | newLake.OriginTitle = title
326 | newLake.ReferrerId = status.ID
327 | newLake.Url = _url
328 | newLake.Scheme = strings.ToLower(u.Scheme)
329 | newLake.Host = strings.ToLower(u.Host)
330 | newLake.Domain1 = strings.ToLower(domain1)
331 | newLake.Domain2 = strings.ToLower(domain2)
332 | newLake.Path = u.Path
333 | newLake.Query = u.RawQuery
334 | newLake.CrawTime, _ = time.Parse("2006-01-02 15:04:05", "2001-01-01 00:00:00")
335 | realDB(_url).Scopes(lakeTable(_url)).Save(&newLake)
336 |
337 | // 无论是否新插入了数据,都将 _url 入 HashMap
338 | db.Rdb.HSet(db.Ctx, statusHashMapKey, _url, 1).Err()
339 | }
340 |
341 | db.Rdb.IncrBy(db.Ctx, allStatusKey, 1).Err()
342 | db.Rdb.Expire(db.Ctx, allStatusKey, time.Hour).Err()
343 |
344 | // fmt.Printf("新增写入 %s %s\n", title, _url)
345 | } else {
346 | // fmt.Printf("爬到旧的 %s %s\n", title, _url)
347 | }
348 |
349 | }
350 | })
351 | }
352 |
353 | // 写入 Redis,用于主动限流
354 | for _, t := range [][]int{
355 | []int{2, 1},
356 | []int{60, 15},
357 | []int{3600, 450},
358 | []int{86400, 5400},
359 | } {
360 | key := "ese_spider_xianliu_" + status.Host + "_" + strconv.Itoa(t[0]) + "s_" + strconv.FormatInt(time.Now().Unix()/int64(t[0]), 10)
361 | db.Rdb.IncrBy(db.Ctx, key, 1).Err()
362 | db.Rdb.Expire(db.Ctx, key, time.Second*time.Duration(t[0])).Err()
363 | // fmt.Println(key)
364 | }
365 |
366 | ch <- chVal
367 |
368 | // fmt.Println("正常结束", time.Now().UnixMilli()-t.UnixMilli(), "毫秒")
369 | }
370 |
371 | func startServer() {
372 |
373 | router := gin.Default()
374 |
375 | router.LoadHTMLGlob("views/*")
376 |
377 | // router.GET("/", _transStatus)
378 | router.GET("/", controllers.Search)
379 | router.GET("/status", controllers.SpiderStatus)
380 | router.Run(":" + os.Getenv("PORT"))
381 | }
382 |
383 | func statusHostCrawIsTooMuch(host string) bool {
384 |
385 | for _, t := range [][]int{
386 | []int{2, 1},
387 | []int{60, 15},
388 | []int{3600, 450},
389 | []int{86400, 5400},
390 | } {
391 |
392 | // host黑名单 redis 缓存
393 | hostBlackList, err := db.Rdb.SIsMember(db.Ctx, "ese_spider_host_black_list", host).Result()
394 | if err == nil && hostBlackList {
395 | return true
396 | }
397 |
398 | key := "ese_spider_xianliu_" + host + "_" + strconv.Itoa(t[0]) + "s_" + strconv.FormatInt(time.Now().Unix()/int64(t[0]), 10)
399 |
400 | count, err := db.Rdb.Get(db.Ctx, key).Int()
401 | if err == nil {
402 | if count >= t[1] {
403 | db.Rdb.SAdd(db.Ctx, "ese_spider_host_black_list", host)
404 |
405 | ese_spider_host_black_listTTL, _ := db.Rdb.TTL(db.Ctx, "ese_spider_host_black_list").Result()
406 | if ese_spider_host_black_listTTL == -1 {
407 | db.Rdb.Expire(db.Ctx, "ese_spider_host_black_list", time.Minute*42).Err()
408 | }
409 | // fmt.Println(strconv.Itoa(t[0])+"秒限制"+strconv.Itoa(t[1])+"条", host)
410 | return true
411 | }
412 | }
413 | }
414 | return false
415 | }
416 |
417 | func realDB(url string) *gorm.DB {
418 | // i, _ := strconv.ParseInt(tools.GetMD5Hash(url)[0:2], 16, 64)
419 |
420 | realDB := db.DbInstance0
421 |
422 | // 如果你有多个数据库,可以取消注释
423 | // if i > 127 {
424 | // realDB = db.DbInstance1
425 | // }
426 |
427 | return realDB
428 | }
429 |
430 | func statusTable(url string) func(tx *gorm.DB) *gorm.DB {
431 | return md5Table(url, "status")
432 | }
433 | func lakeTable(url string) func(tx *gorm.DB) *gorm.DB {
434 | return md5Table(url, "pages")
435 | }
436 | func md5Table(url string, table string) func(tx *gorm.DB) *gorm.DB {
437 | return func(tx *gorm.DB) *gorm.DB {
438 | tableName := table + "_" + tools.GetMD5Hash(url)[0:2]
439 | return tx.Table(tableName)
440 | }
441 | }
442 |
443 | func dd(v ...any) {
444 | tools.DD(v)
445 | }
446 |
--------------------------------------------------------------------------------
/models/host_count.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type HostCount struct {
4 | Host string
5 | TableIndex int
6 | Count int
7 | CrawdCount int
8 | }
9 |
--------------------------------------------------------------------------------
/models/page.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type Page struct {
8 | ID uint `gorm:"primaryKey"`
9 | Url string `gorm:"default:null"`
10 | Host string `gorm:"default:null"`
11 | CrawDone int `gorm:"type:tinyint(1);default:0"`
12 | DicDone int `gorm:"type:tinyint(1);default:0"`
13 | CrawTime time.Time `gorm:"default:'2001-01-01 00:00:01'"`
14 | OriginTitle string `gorm:"default:null"`
15 | ReferrerId uint `gorm:"default:0"`
16 | Scheme string `gorm:"default:null"`
17 | Domain1 string `gorm:"default:null"`
18 | Domain2 string `gorm:"default:null"`
19 | Path string `gorm:"default:null"`
20 | Query string `gorm:"default:null"`
21 | Title string `gorm:"default:null"`
22 | Text string `gorm:"default:null"`
23 | CreatedAt time.Time
24 | }
25 |
--------------------------------------------------------------------------------
/models/status.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type Status struct {
8 | ID uint `gorm:"primaryKey"`
9 | Url string `gorm:"default:null"`
10 | Host string `gorm:"default:null"`
11 | CrawDone int `gorm:"type:tinyint(1);default:0"`
12 | CrawTime time.Time `gorm:"default:'2001-01-01 00:00:01'"`
13 | }
14 |
--------------------------------------------------------------------------------
/models/word_dic.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type WordDic struct {
4 | ID uint `gorm:"primaryKey"`
5 | Name string `gorm:"default:null"`
6 | Positions string `gorm:"default:null"`
7 | }
8 |
--------------------------------------------------------------------------------
/tools/curl.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "time"
7 |
8 | "github.com/PuerkitoBio/goquery"
9 | "github.com/imroc/req/v3"
10 | "github.com/johnlui/enterprise-search-engine/db"
11 | "github.com/johnlui/enterprise-search-engine/models"
12 | )
13 |
14 | // 4 秒超时
15 | var client = req.C().SetTimeout(time.Second * 4).SetRedirectPolicy(req.NoRedirectPolicy())
16 |
17 | func Curl(status models.Status, ch chan int) (*goquery.Document, int) {
18 | // Send a request with multiple headers.
19 | resp, err := client.R().
20 | SetHeader("User-Agent", "Sogou web spider/4.0(+http://www.sogou.com/docs/help/webmasters.htm#07)").
21 | Get(status.Url)
22 | if err != nil {
23 | // fmt.Println("网络错误:", url, err)
24 | // fmt.Println(err)
25 | document, _ := goquery.NewDocumentFromReader(strings.NewReader(""))
26 |
27 | // 网络错误则使用Redis判断次数,达到3次则标记为 craw_donw
28 | key := "ese_spider_wangluocuowu_" + GetMD5Hash(status.Url)
29 |
30 | count, err := db.Rdb.Get(db.Ctx, key).Int()
31 | if err == nil {
32 | if count >= 2 { // 超时放弃次数
33 | return document, 4
34 | }
35 | }
36 |
37 | db.Rdb.IncrBy(db.Ctx, key, 1).Err()
38 | db.Rdb.Expire(db.Ctx, key, time.Hour*240).Err()
39 |
40 | return document, 2
41 | }
42 | html := resp.String()
43 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
44 | if err != nil {
45 | fmt.Println("HTML解析失败:", status.Url, err)
46 | // fmt.Println(err)
47 | document, _ := goquery.NewDocumentFromReader(strings.NewReader(""))
48 | return document, 3
49 | }
50 |
51 | return doc, 1
52 | }
53 |
--------------------------------------------------------------------------------
/tools/debug.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | )
7 |
8 | var ENV_DEBUG bool
9 |
10 | // dd 命令
11 | func DD(v ...any) {
12 | fmt.Println(v)
13 | os.Exit(0)
14 | }
15 |
--------------------------------------------------------------------------------
/tools/number.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "golang.org/x/text/language"
5 | "golang.org/x/text/message"
6 | )
7 |
8 | func AddDouhao(v int) string {
9 | p := message.NewPrinter(language.English)
10 | return p.Sprintf("%d", v)
11 | }
12 |
--------------------------------------------------------------------------------
/tools/string.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "crypto/md5"
5 | "encoding/hex"
6 | "net/url"
7 | "regexp"
8 | "unicode"
9 |
10 | "github.com/yanyiwu/gojieba"
11 | )
12 |
13 | var JiebaInstance *gojieba.Jieba
14 |
15 | // 生成小写MD5哈希值
16 | func GetMD5Hash(text string) string {
17 | hash := md5.Sum([]byte(text))
18 | return hex.EncodeToString(hash[:])
19 | }
20 |
21 | // 是否是合法URL
22 | func IsUrl(str string) bool {
23 | u, err := url.Parse(str)
24 | return err == nil && u.Scheme != "" && u.Host != ""
25 | }
26 |
27 | // 去除所有的空格和换行
28 | func StringStrip(input string) string {
29 | if input == "" {
30 | return ""
31 | }
32 | reg := regexp.MustCompile(`[\s\p{Zs}]{1,}`)
33 | return reg.ReplaceAllString(input, "-")
34 | }
35 |
36 | // 首字母大写
37 | func FirstLetterUppercase(input string) string {
38 | r := []rune(input)
39 | return string(append([]rune{unicode.ToUpper(r[0])}, r[1:]...))
40 | }
41 |
42 | // 结巴分词
43 | func GetFenciResultArray(s string) []string {
44 | result := []string{}
45 | result = JiebaInstance.CutForSearch(s, true)
46 | return result
47 | }
48 |
--------------------------------------------------------------------------------
/views/index.tpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ .title }}
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | {{ .title }}
16 | {{ .time }}
17 |
18 |
19 |
20 |
21 | Key |
22 | Value |
23 |
24 |
25 |
26 | {{range $index, $value := .values}}
27 |
28 | {{range $k, $v := $value}}
29 |
30 | {{ $k }}
31 | |
32 |
33 | {{ $v }}
34 | |
35 | {{end}}
36 |
37 | {{end}}
38 |
39 |
40 |
41 |
65 |
66 |
--------------------------------------------------------------------------------
/views/search.tpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ .keyword }} - {{ .title }}
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | {{ .title }}
16 |
17 | 查询耗时:{{ .latency }} 总已爬页面数:{{ .N }}
18 |
{{ .time }}
19 |
20 |
21 |
31 |
32 |
33 | {{range .values}}
34 | -
35 |
36 |
{{ .Brief }}
37 |
38 | {{end}}
39 |
40 |
41 |
42 |
70 |
71 |
--------------------------------------------------------------------------------