使用Elasticsearch + Kibana在AtCoder的提交代码中进行全文搜索
这篇文章是Competitive Programming (1) Advent Calendar 2019第19天的条目。
我计划使用之前稍微使用过的Elasticsearch + Kibana,创建一个能够搜索AtCoder过去提交的代码的服务。
首先
大家好,你们都在进行编程竞赛吗?当然会吧。最近,竞技编程的人数似乎爆发性地增加,尤其是以AtCoder为首,这让我感到非常高兴。
随着人数的增加,我可以感受到这项活动的门槛逐渐降低,使初学者更容易入门,这得益于各种课程和便利工具的普及。
听众朋友们,你们在参加竞赛编程时是否曾想过「想要查看过去自己的代码」、「想要看看他人如何使用这个库」等等呢?对于自己的代码,我们可以在本地积累一些或者在GitHub上注册,往往可以进行搜索。但对于他人的代码来说,除了大致猜测可能会使用到的问题然后去打开,几乎没有其他可行的办法。
因此,我觉得从提交的大量代码中以特定的变量名或类名进行搜索的功能可能有一定需求,所以我打算做一个提交代码搜索的服务来试试。
用什么?
在进行搜索时,应该使用哪些技术才好呢?假设将目前存在的所有提交作为搜索对象,据说大约有10的7次方的提交存在。如果采用从前往后逐个查找的方法,每次搜索可能需要相当长的时间。我并不对搜索算法非常熟悉,所以决定使用广泛应用于大规模数据搜索的开源软件Elasticsearch。
弹性搜索
Elasticsearch是由Elastic公司开发的全文搜索引擎,可以使用REST API进行操作。详细说明可以参考nskydivingさん撰写的这篇文章。
Elasticsearch初探
Elasticsearch是基于Java编写的Lucene搜索引擎,其主要功能包括索引创建和数据注册等,以REST API形式进行封装,形成了Elasticsearch。
Kibana 可视化工具
亚马逊 Elasticsearch 服务
Elasticsearch是AWS的一项服务,用于部署。它非常方便,可以通过各种方式进行访问限制,如VPC和IP地址的指定,也可以轻松扩展。但是,请注意不要轻率使用,因为实例是按小时计费的,如果玩得太high,可能会导致收费金额惊人,所以请小心。
Flask, Heroku
只要用这套工具,就能在玩耍中创建并部署小应用程序,就像回到故乡般的安心感。
实施阶段
我觉得部署已完成的产品并不是很困难,所以我先在本地进行开发。我假设的环境是Mac OS。
启动Elasticsearch和Kibana。
从Elastic公司的官方网页(此处)下载Mac版的gzip,然后解压缩。
在解压后的文件夹里有两个可执行文件bin/elasticsearch和bin/kibana,通过执行它们可以在本地启动服务。默认端口是Elasticsearch的9200和kibana的5601,因此可以通过访问localhost:9200和localhost:5601来确认运行情况。
这次我随便找了个文件夹来执行命令,但你也可以使用守护进程等方式让其持续运行,请自行查阅。
将AtCoder提交代码进行网页抓取,并将其注册到elasticsearch中。
我将根据用户ID对AtCoder的提交代码进行抓取。获取与用户ID相关联的提交ID,我一直使用kenkoooo先生的AtCoder Problems API。通过使用用户ID和提交ID获取页面的HTML,然后通过适当地去除标签等内容,我抽取了代码部分。是否有API可以直接获取原始代码呢?如果有的话,请告诉我。
将获得的代码逐个输入。Elasticsearch可以根据数据的键值形式灵活地确定数据结构。这次我们针对每个提交的代码,准备了user_id、url、code、submission_id、contest_id、language、result、problem_id、point这些列,并将数据进行了输入。所有这些信息都包含在AtCoder Problems的API中。非常感谢。
我会将爬虫和提交部分的代码附在下面。
import os, sys
import json
import urllib.request
import requests
from html.parser import HTMLParser
from elasticsearch import Elasticsearch
from tqdm import tqdm
es = Elasticsearch()
class Parser(HTMLParser):
def __init__(self):
HTMLParser.__init__(self)
self.title = False
self.link = False
self.data = []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
# print(tag, attrs)
if tag == "pre":
self.data.append({})
self.title = True
self.link = True
if tag == "a" and self.link == True:
self.data[-1].update({"link": attrs["href"]})
def handle_data(self, data):
if self.title == True or self.link == True:
self.data[-1].update({"title": data})
self.title = False
self.link = False
def getSubmissionCode(url):
req = urllib.request.Request(url)
with urllib.request.urlopen(req) as res:
body = res.read()
return body
if __name__ == "__main__":
user_name = "yuji9511"
url_api = "https://kenkoooo.com/atcoder/atcoder-api/results?user=" + user_name
# print(url_api)
res = requests.get(url_api)
data = json.loads(res.text)
# print(data)
for i, d in tqdm(enumerate(data)):
sub_id = d["id"]
contest_id = d["contest_id"]
url = "https://atcoder.jp/contests/" + str(contest_id) + "/submissions/" + str(sub_id)
res = getSubmissionCode(url)
res = str(res)
parser = Parser()
parser.feed(res)
parser.close()
code = ""
for i in parser.data:
code = i['title'].replace("\\r\\n", "\n").replace("\\t", " ")
break
body = {
"user_id": user_name,
"url": url,
"code": code,
"submission_id": str(sub_id),
"contest_id": str(contest_id),
"language": d["language"],
"result": d["result"],
"problem_id": d["problem_id"],
"point": d["point"]
}
es.index(index="atcoder_submissions", body=body)
只需指定索引名称(类似于RDBS的表名)和要插入的数据作为主体(body)来输入数据。如果您使用的不是默认的localhost:9200,而是在其他位置安装了elasticsearch,请在 es = Elasticsearch() 的括号内设置URL和端口。
执行搜索
因为数据已经导入,所以我们开始构建搜索功能。通过使用Kibana可以轻松地确认搜索结果,因此我认为在使用Python进行实现之前先尝试一下会使工作顺利进行。
用于搜索的参数已经设置为关键词、用户ID、提交语言、结果(AC、WA等)这四种类型。对于每个参数,当有指定时,将进行AND搜索以进行过滤,当没有指定时,则不应用过滤器。
Elasticsearch可以像MySQL一样使用JSON格式来编写类似SQL语句的查询,而在Python中向查询发送请求时,需要在字典对象中指定搜索条件。
我只会挑选搜索主要部分的实现。
es = Elasticsearch()
def getSearchResults(params):
keyword = params["keyword"]
user_id = params["user_id"]
language = params["language"]
result = params["result"]
print(language)
must_query = []
if keyword != "":
must_query.append(
{
"query_string": {
"query": keyword,
"fields": [
"code"
]
}
}
)
if user_id != "":
must_query.append(
{
"query_string": {
"query": user_id,
"fields": [
"user_id"
]
}
}
)
if language != "-":
must_query.append(
{
"query_string": {
"query": '"' + language + '"',
"fields": [
"language"
]
}
}
)
if result != "-":
must_query.append(
{
"query_string": {
"query": result,
"fields": [
"result"
]
}
}
)
query = {
"_source": "*",
"size": 50,
"query": {
"bool": {
"must": must_query
}
}
}
res = es.search(index=index_name, body=query)
return res
検索结果的优先级是根据elasticsearch给出的分数来确定的。不仅仅通过完全匹配进行搜索,还会搜索与之相似的内容,如拼写变体等。当然,对于这个分数的设定也可以进行详细的指定。
展示搜索结果
由于是在Python中使用Flask实现的,所以我将获取到的结果返回并显示在HTML页面上。除了基本的问题名称、AC/WA等基本信息外,我还添加了相关提交的链接等。我本来也考虑过让代码能够即时显示,但是会占用很多空间,显得杂乱无序,所以放弃了这个想法。
部署
关于Flask应用程序,使用Heroku来部署可能是最快的方法。我认为使用elasticsearch时,使用AWS的其中一个服务Amazon Elasticsearch Service可能是最简单的。关于部署步骤,因为有许多易于理解的站点可供查找,所以将不再详述。
如果想要部署的话是可以做到的,但目前还没有进行部署。这是一件非常重要的事情,所以希望你记住,运行elasticsearch会使用相当多的资源,因此会产生相当大的费用。很可能会按照运行时间收费,就像我只是稍微玩了一会儿,就产生了近一万日元的费用。在使用云服务时,请务必注意费用金额。
最后
由于尚未发布,所以目前只有我自己能够使用的服务,当我想要查看之前实现的类似代码时,感觉它还是相当实用的。此外,我还可以了解到其他人可能不使用/常使用的变量名等无关紧要的信息。另外,此应用程序中编写的代码可以在ysugiyama12/atcoder-submission-search中找到。虽然内容不多,但如果您有兴趣,请随时查看。
顺便说一下,如果使用Flask,你可以以令人惊叹的速度创建一个不起眼的应用程序,非常推荐。如果你有兴趣的话,可以看看我最近制作的AtCoder评级图表调整应用程序——AtCoder评级图表生成器。
尽管今年即将结束,但年底的比赛异常集中,所以我希望能坚持到最后并全力以赴!