世界上已经有了这么多种子搜索引擎,为什么你还要不厌其烦的做一个新的?

可以这么说,地球上大多数的种子搜索引擎的前后端技术都比较古老,虽然古老的技术既经典又好用,但是作为一个喜欢尝鲜的人,我仍然决定使用目前最为先进的开发技术制作一个功能简明的种子搜索引擎。

采用了什么技术?

前端:在vue,angular,react三大现代开发框架中选择了vue,做出这个决定的原因也仅仅是一直以来对vue的谜之好感。有时候就是这样,缘分到了不得不从,恰巧nuxtjs在9月更新了2.0,因此毫不犹豫选择了vue。
后端:在koa,gin,springboot中权衡良久,由于很长时间没有写过java,最后选择了springboot + jdk11,用写javascript的感觉来写java,还是很不错的。从追求速度上来讲,可能使用gin或Koa要更快,但是这一点提升对于我这种实验性网站来说,意义并不是很大。
全文检索:尝试了全文检索里面的比较潮的couchbaseredissearch、elasticsearch,最后选定elasticsearch,另外两个的速度虽然远高于elasticsearch,但毕竟是内存式数据库,简单功能尚可,复杂度上去后则吃内存太多。

制作过程呢?

下面我分享下大概过程,涉及到复杂原理,请自行谷歌,我不认为我可以把复杂原理描述的很简单。

关于命名:

从手中的十来个域名选择了

btzhai.top

中国国内同名的网站有几个,但是这不是问题。

关于服务器:

几经周折,购买了一台美国服务器。配置是:E5-1620|24G|1TB|200M带宽,真正的24小时人工服务。考虑到要用cloudfare,所以不需要硬防。一月1200RMB。

在此期间尝试了很多家服务器,深感这免备案服务器这一行真的是泥沙俱下。

关于爬虫:

大约8月初终于有空来着手bt搜索引擎这件事情。

首先摆在我面前的问题就是数据来源问题,要知道所谓的dht网络,说白了就是一个节点既是服务器又是客户端,你在利用dht网络下载时会广播到网络中,别的节点就会接收到你所下载文件的唯一标识符infohash(有的地方称之为神秘代码)和metadata,这里面包括了这个文件的名称、大小、创建时间、包含文件等信息,利用这个原理,dht爬虫就可以收集dht网络中的即时热门下载。

如果仅仅依靠依靠dht爬虫去爬的话,理论上初期速度大约为40w一天,30天可以收集上千万,但是dht网络里面的节点不可能总是下载新的文件,现实情况是:大多数情况下冷门的种子几年无人问津,热门种子天天数十万人下载。可以推想,随着种子基数增加,重复的infohash会越来越多,慢慢地只会增加所谓的种子热度而不会增加基数,但是没有1000w+的种子,从门面上来讲不好看。

去哪里弄1000w种子成了当时我主要研究的问题。首先我从github上选取了几个我认为比较好用的dht爬虫进行改造,让之可以直接将数据入库到elasticsearch中,并且在infohash重复的时候自动对热度+1。

elasticsearch的mapping如下,考虑到中文分词,选用了smartcn作为分词器,当然ik也是可以的。种子内的文件列表files,本来设置为nested object,因为nested query性能不高已经取消:

{
	"properties": {
		"name": {
			"type": "text",
			"analyzer": "smartcn",
			"search_analyzer": "smartcn"
		},
		"length": {
			"type": "long"
		},
		"popularity": {
			"type": "integer"
		},
		"create_time": {
			"type": "date",
			"format": "epoch_millis"
		},
		"files": {
			"properties": {
				"length": {
					"type": "long"
				},
				"path": {
					"type": "text",
					"analyzer": "smartcn",
					"search_analyzer": "smartcn"
				}
			}
		}
	}
}

服务器上开始24小时挂着dht爬虫。期间我也尝试过多种不同语言的开源爬虫来比较性能,甚至还找人试图购买bt种子。下面这些爬虫我都实际使用过:
https://github.com/shiyanhui/dht
https://github.com/fanpei91/p2pspider
https://github.com/fanpei91/simDHT
https://github.com/keenwon/antcolony
https://github.com/wenguonideshou/zsky
然而这些dht爬虫经试验,或多或少都有些问题,有的是只能采集infohash而不能采集metadata,有的采集速度不够,有的则随时间增加资源占用越来越大。

最终确定的是这个最优解:

https://github.com/neoql/btlet

唯一不妥是运行一段时间(大约10个小时)后就会崩溃退出,可能跟采集速度有关。而在我写这篇文章的前几天,作者称已经将此问题修复,我还没有来得及跟进更新。可以说这是我实验过采集速度最快的dht爬虫。有兴趣的同学可以去尝试、PR。

爬虫正常化运行以后,我终于发现了基数问题的解决之道,那就是skytorrent关闭后dump出来的数据库和openbay,利用这大约4000w infohash数据和bthub,每天都一定可以保证有数万新的metadata入库。

关于bthub我要说的是,api请求频率太高会被封ip,发邮件询问的结果如下。经过我的反复测试,api请求间隔设为1s也是没问题的:
用elasticsearch和nuxtjs搭建bt搜索引擎-LMLPHP

关于前端:

我比较习惯于先画出简单的前端再开始写后端,前端确定清楚功能以后就可以很快写出对应的接口。bt搜索引擎目前具有以下这么几个功能就足够了:

  1. 可以搜索关键词

  2. 首页可以展现之前搜索过的排行前十的关键词

  3. 可以随机推荐一些文件

  4. 可以按照相关性、大小、创建时间、热度排序

首页启动时,为了提高速度,从后台读cache,包括收录了多少infohash、随机推荐的文件名称、搜索关键词top10等等,这些cache使用@Scheduled每天自动更新一次。

点击搜索后,跳转到结果展现页面,这里只展现elasticsearch处理过highlight之后的结果而不展现所有原始结果,每页展示10个结果。

原始结果的展现放在最后一个详细画面上。

前端承载的另一个重要问题就是seo,这也是我使用nuxtjs的原因。前端功能完成以后,我为它添加了meta描述、google analytics、百度。

sitemap的添加倒是耗废了一些时间,因为是动态网页的缘故,只能用nuxt-sitemap来动态生成。

另外用媒体查询和vh、vw做了移动适配。不敢说100%,至少可以覆盖90%的设备。

关于后端:

spring data在实现核心搜索api时遇到了问题,核心搜索如果写成json,举个例子的话,可能是下面的这个样子:

{
	"from": 0,
	"size": 10,
	"sort": [{
		"_score": "desc"
	}, {
		"length": "desc"
	}, {
		"popularity": "desc"
	}, {
		"create_time": "desc"
	}],
	"query": {
		"multi_match": {
			"query": "这里是要搜索的关键词",
			"fields": ["name", "files.path"]
		}
	},
	"highlight": {
		"pre_tags": ["<strong>"],
		"post_tags": ["</strong>"],
		"fields": {
			"name": {
				"number_of_fragments": 1,
				"no_match_size": 150
			},
			"files.path": {
				"number_of_fragments": 3,
				"no_match_size": 150
			}
		}
	}
}

highlight返回的结果将没有办法自动和entity匹配,因为这一部分数据不在source中,spring data无法通过getSourceAsMap来获取。这里需要用到NativeSearchQueryBuilder去手动配置,如果有更好的方式,请务必赐教。java代码如下:

var searchQuery = new NativeSearchQueryBuilder()
                .withIndices("torrent_info").withTypes("common")
                .withQuery(QueryBuilders.multiMatchQuery(param.getKeyword(), "name", "files.path"))
                .withHighlightFields(new HighlightBuilder.Field("name").preTags("<strong>").postTags("</strong>").noMatchSize(150).numOfFragments(1), new HighlightBuilder.Field("files.path").preTags("<strong>").postTags("</strong>").noMatchSize(150).numOfFragments(3))
                .withPageable(PageRequest.of(param.getPageNum(), param.getPageSize(), sort))
                .build();
var torrentInfoPage = elasticsearchTemplate.queryForPage(searchQuery, TorrentInfoDo.class, new SearchResultMapper() {
            @SuppressWarnings("unchecked")
            @Override
            public <T> AggregatedPage<T> mapResults(SearchResponse searchResponse, Class<T> aClass, Pageable pageable) {
                if (searchResponse.getHits().getHits().length <= 0) {
                    return null;
                }
                var chunk = new ArrayList<>();

                for (var searchHit : searchResponse.getHits()) {
                    // 设置info部分
                    var torrentInfo = new TorrentInfoDo();
                    torrentInfo.setId(searchHit.getId());
                    torrentInfo.setName((String) searchHit.getSourceAsMap().get("name"));
                    torrentInfo.setLength(Long.parseLong(searchHit.getSourceAsMap().get("length").toString()));
                    torrentInfo.setCreate_time(Long.parseLong(searchHit.getSourceAsMap().get("create_time").toString()));
                    torrentInfo.setPopularity((Integer) searchHit.getSourceAsMap().get("popularity"));
                    // ArrayList<Map>->Map->FileList->List<FileList>
                    var resList = ((ArrayList<Map>) searchHit.getSourceAsMap().get("files"));
                    var fileList = new ArrayList<FileList>();
                    for (var map : resList) {
                        FileList file = new FileList();
                        file.setPath((String) map.get("path"));
                        file.setLength(Long.parseLong(map.get("length").toString()));
                        fileList.add(file);
                    }
                    torrentInfo.setFiles(fileList);
                    // 设置highlight部分
                    // 种子名称highlight(一般只有一个)
                    var nameHighlight = searchHit.getHighlightFields().get("name").getFragments()[0].toString();
                    // path highlight列表
                    var pathHighlight = getFileListFromHighLightFields(searchHit.getHighlightFields().get("files.path").fragments(), fileList);
                    torrentInfo.setNameHighLight(nameHighlight);
                    torrentInfo.setPathHighlight(pathHighlight);
                    chunk.add(torrentInfo);
                }
                if (chunk.size() > 0) {
                    // 不设置total返回不了正确的page结果
                    return new AggregatedPageImpl<>((List<T>) chunk, pageable, searchResponse.getHits().getTotalHits());
                }
                return null;
            }
        });

关于elasticsearch:

种子搜索不需要多高的实时性,一台服务器也不需要副本,因此,index的设置都是这样:

{
	"settings": {
		"number_of_shards": 2,
		"number_of_replicas": 0,
		"refresh_interval": "90s"
	}
}

jvm配置了8G内存,G1GC,另外还禁了swapping:

## IMPORTANT: JVM heap size
-Xms8g
-Xmx8g
## GC configuration
-XX:+UseG1GC
-XX:MaxGCPauseMillis=50

运行得怎么样?

由于搜索比较复杂,平均搜索时间1s左右,搜索命中上百万数据时会大于2s。

下面是cloudfare的统计:
用elasticsearch和nuxtjs搭建bt搜索引擎-LMLPHP

10-02 16:13