回归最本质的信息安全

;

自建搜索引擎:一个专门服务于安全圈的工具

2018年1月18日发布

86,244
5
19

导语:前几周的时候爬下了嘶吼的全部数据,但是如果光是单纯拿到这些数据好像是没有什么用的。全部技术文章大概也就只有20M的大小,但是这20M的数据中干货还是相当多的。

闲话

前几周的时候爬下了嘶吼的全部数据,但是如果光是单纯拿到这些数据好像是没有什么用的。全部技术文章大概也就只有20M的大小,但是这20M的数据中干货还是相当多的。要想办法把这些数据利用起来,可以帮助大家进一步寻找需要的资料。于是,就有了这篇文章,基于Flask搭建搜索引擎。可以对es中的数据进行检索,并将这些文章展示出来。

这个搜索引擎的功能相对而言也是比较齐全,智能提示,容错功能,来源分类,该有的都有了。附一张搜索图:

环境安装

这一次将数据直接写入elasticsearch中,不再使用mysql保存数据,es现在是全文搜索的首选,维基百科,github,这些有名的组织都用的是elasticsearch,可见它的强大之处!

· 但是用之前需要安装java 8或java 8+以上的环境。

· 之后到github上去下载elasticsearch : git clone https://github.com/medcl/elasticsearch-rtf.git

PS: elasticsearch-rtf相比于elasticsearch就是安装了很多插件,而这些插件基本都是针对中文的处理而安装的,如果安装的是elasticsearch,还需要安装许多插件来支持对中文的支持,比如说分词器

之后cd bin目录下,linux执行linux的可执行文件,windows执行bat文件

· 安装flask

pip install flask

写入数据

想要搭建搜索引擎,在之前代码的基础上需要做一些修改,要将数据写入elasticsearch中,不在是Mysql

在爬虫的目录下新建一个models,需要models文件夹中新建一个名为elaticsearch_type_4hou.py的文件,内同如下代码,他是类似Mysql表结构的东西,专业名词叫做映射结构。其它专业等碰到再说。

PS:这里其实跟Django里面的模块声明很像。

from elasticsearch_dsl.analysis import CustomAnalyzer as _CustomAnalyzer
class CustomAnalyzer(_CustomAnalyzer):
    def get_analysis_definition(self):
        return {}
ik_analyzer = CustomAnalyzer("ik_max_word", filter=["lowercase"])#filter=["lowercase"]作用是大小写的转换
connections.create_connection(hosts=["localhost"])
class Article_4houType(DocType):
    suggest = Completion(analyzer=ik_analyzer)  #搜索建议
    image_local = Keyword()
    title = Text(analyzer="ik_max_word")
    url_id = Keyword()
    create_time = Date()
    url = Keyword()
    author = Keyword()
    tags = Text(analyzer="ik_max_word")
    watch_nums = Integer()
    comment_nums = Integer()
    praise_nums = Integer()
    content = Text(analyzer="ik_max_word")
    class Meta:
        index = "teachnical_4hou"
        doc_type = "A_4hou"

学过python web开发的看眼就知道上面这段代码的作用,跟Mysql的表结构类似,只是多了一些关键字,如:analazy="ik_max_word"。ik_max_word他是elasticsearch-rtf已经安装的插件,它的作用是对中文进行分词。举个例子,"Linux运维工程师",这样一串中文,它会将这串中文分为Linux,运维,工程师等等的,分的非常细,拿一条数据库中数据给大家看下:

1.png

suggest的内容是ik_max_word根据title,tags这两个字段的内容分割而成的,在上面的代码中,也可以看到具体设置,下面是分词过后的结果:

2.png

这里要说下,分割出来的词,过滤掉了长度小于2的字符串,就是单字,分出来一个单字是没有实际意义的,比如说,我告诉你了个“了”,你肯定不知道什么意思。。

有了分出来的词,在输入框中输入的时候让它基于Ajax来请求后台,就能实现自动补全的功能。还有一点,至于Suggest这个映射为什么不能向下面一样,Text(analyzer="ik_max_word")这样写,据说是个bug,模仿我的代码就没有问题了。完成上面代码之后,执行之后将会在es中生成一个名为teachnical_4hou的索引(索引就是就相当于mysql中的库)。

3.gif

有了映射结构,就该考虑如何把数据写入es中了,跟之前写入mysql一样,在items.py中的ArticleSpider4hou这个类中添加一个save_to_es的方法。

es_4hou = connections.create_connection(Article_4houType._doc_type.using)
def save_to_es(self):
    article = Article_4houType()
    article.image_local = self["image_url"]
    article.title = self["title"]
    article.url_id = self["url_id"]
    article.create_time = self["create_date"]
    article.url = self["url"]
    article.author = self["author"]
    article.tags = self["tags"]
    article.watch_nums = self["watch_num"]
    article.comment_nums = self["comment_num"]
    article.praise_nums = self["praise_nums"]
    article.content = self["content"]
    article.suggest = gen_suggests(es_4hou,Article_4houType._doc_type.index,((article.title,10),(article.tags,7)))
    article.save()
    return

这段代码也是比较容易理解 ,实例化Article_4houType这个对象之后,把获取到的值填充进去就ok了,这样就能将数据写入到es中。眼尖的肯定看到了Suggest,这一项调用了一个gen_suggests函数,这个方法究竟做了什么能把Tags,title分词之后放入es中这个先放一放,先看看piplines怎么写。直接在piplines.py中添加如下代码

#将数据写入到es中,
    def process_item(self,item,spider):
        #提升代码性能
        item.save_to_es()
        return item

现在来看之前的gen_suggests函数,这个代码也写在items中,是一个全局函数

def gen_suggests(es,index,info_tuple):
    #根据字符串生生搜索建议数据
    used_words = set() #供去重使用
    suggests = []
    for text,weight in info_tuple:
        if text:
            #调用analyze接口分析字符串
            words = es.indices.analyze(index=index,analyzer="ik_max_word",params={'filter':["lowercase"]},body=text)
            anylyzed_words = set([r["token"] for r in words["tokens"] if len(r["token"])>1])
            new_words = anylyzed_words - used_words
        else:
            new_words = set()
        if new_words:
            suggests.append({"input":list(new_words),"weight":weight})
    return suggests

第一个es为一个连接对象,第二个是index(索引名),第三个是一个元组(因为要处理的字段不止一个,所以使用元组循环处理),就拿当前这个例子来说,我们需要对两个字段进行分词,一个是title,一个是tags,而且,传进来的元组中需要带着weight (权重,不明白的百度下)。继续看代码,首先设置了一个Set ,它的作用是去重。举个例子,如果Title和Tags都出现了hacker这个词,谁先进来取谁,如果Title的权重为10,而Tags中又出现了hacker这个单词,直接过滤掉。肯定不能修改之前已经设置好的值。Suggest为返回列表,之后进入for循环,for循环的对象是之前传入的info_tuple,调用es的analyze来分析字符串,返回处理生成的词语列表,之后,使用列表生成式那到这些值。并把它添加到列表中(es的固定格式)。完成之后,数据就可以大量的写入,方便后面的测试。

这个过程起始并不是很复杂,都是elasticsearc的操作,都是死格式。

展示数据

接下来要做的事情就比较简单了,从elasticsearch中取数据,展示出来就好了,前端页面是我胡诌的。主要讨论后台功能的实现

实用Pycharm创建好项目之后,在项目根目录下新建一个名为moudels.py,直接将scrapy项目下的moudels.py复制过来就好,不需要任何的修改。

然后再创建一个Config.py的文件,看名字就知道里面写什么了,配置文件啊等等的,如果是连接Mysql数据库,那么在里面写的就是数据库地址啊,账号密码什么的,但是这里既然是elasticsearch,那些es的配置信息就ok了,代码如下

from elasticsearch import Elasticsearch
client = Elasticsearch(hosts=['127.0.0.1'])

开始实现主要逻辑,如果实用Pycharm创建的Flask项目,那直接写在跟你项目名一致,但是后面是py结尾的文件就可以了。写之前来看看前端Ajax是怎么写的,

3.png

每当输入框中的字符变化的时候他会将搜索框当前的内容传给后台,有两个参数

url: suggest_url + "?
s=" + searchText + "&s_type=" + $(".searchItem.current").attr('data-type'),

一个是s,他代表的是要搜索的字符串,s_type代表的是文章来源,当然,在这里传到后台的s_type一定是A4hou(这是我在html中设置的名字)

4.png

Suggest_url就是要请求的地址,实用url_for反转成视图函数

5.png

开始写后台代码,比Ajax简单

from flask import Flask,render_template,request
from moudels import Article_4houType
import json
app = Flask(__name__)
@app.route('/')
def search_index():
    return render_template("index.html")
@app.route('/suggest/')
def suggest():
    key_words = request.args.get('s','')
    type = request.args.get('s_type','')
    if "A4hou" == type:
        fuzzing = elasticsearch_search(type=Article_4houType)
        re_dates = fuzzing.return_fuzzing_search(key_words=key_words)
    return json.dumps(re_dates)

代码非常的简单,如果访问的是/根目录,返回index.html这个页面给他,之后如果在搜索框中填入数据,就会提交到后台处理,也就请求了Suggest这个函数。接收s和s_type这两个参数,之后到指定的es索引中取查找对应的值。

为了降低代码耦合性,我将各大功能全部分装在一个类中。这样,如果之后添加了别的网站的数据,这里的代码动起来就非常容易,只需要判断一下传过来的s_type,之后传入索引名称创建一个elasticsearch_search的实例类。

在项目文件夹下创建common.py的文件,写这个elasticsearch_search类

from moudels import Article_4houType,Article_anquankeType,Article_freebuf
from config import client
from datetime import datetime
import re
class elasticsearch_search(object):
    def __init__(self,type):
        self.s = type.search()
        if Article_4houType == type:
            self.index = "teachnical_4hou"
    def return_fuzzing_search(self,key_words):
        """
        模糊查询,分词匹配
        :param key_words:
        :return:re_dates
        """
        re_dates = []
        if key_words:
            s = self.s.suggest("my_suggest", key_words, completion={
                "field": "suggest",
                "fuzzy": {
                    "fuzziness": 2
                },
                "size": 10
            })
            suggestions = s.execute_suggest()
            for match in suggestions.my_suggest[0].options:
                source = match._source
                re_dates.append(source["title"])
        return re_dates

在init函数中,调用了Article_4houType.search()方法,返回了一个供查询的s对象,而且,判断了传入的Type类型,设置了索引名称。之后就是上面调用的return_fuzzing_search方法,将要查询的关键字传进来,返回一个查询到的列表。方法中的"fuzziness": 2是设置的是模糊匹配的属性。举个例子,如果你要查询Linux这个关键字,如果你输入了Linnx,他一样可以成功的模糊匹配到Linux这个关键字,但是,这个宽度如果大于2,就查不到了。(这里设置成2是相对而言比较准的),这个方法返回的是一段json,前台接收到之后进行解析。看看效果:

6.gif

最后是文章搜索详情页。代码如下

@app.route('/search/')
def search():
    #文章来源
    all_options = [["all","全部"],["A4hou","嘶吼"]] 
    key_words = request.args.get('q','')
    types = request.args.get('s_type','')
    page = request.args.get('p','1')
    try:
        page = int(page)
    except:
        page = 1
    if "A4hou" == types:
        search_obj = elasticsearch_search(type=Article_4houType)
        response, last_seconds = search_obj.get_date(key_words=key_words, page=page)
        total_nums = response["hits"]["total"]
        all_hits = search_obj.analyze_date(key_words, response)
    x = get_elasticsearch_data_count()
    alldate_nums = x.return_count()
    if (page%12) > 0:
        page_nums = int(total_nums/12)+1
    else:
        page_nums = int(total_nums/12)
    return render_template("result.html",
                           alldate_nums = alldate_nums,
                           page=page,
                           all_hits=all_hits,
                           key_words=key_words,
                           total_nums=total_nums,
                           page_nums=page_nums,
                           last_seconds=last_seconds,
                           type = types,
                           all_options = all_options
                           )

开头的列表是用来显示文章来源,如果之后有了别的网站的文章数据可以直接在当前列表中添加。其实用字典是更好的,但是我不明白字典为什么会乱序,导致每一次刷新列表的值都是随机的。all_options列表会直接返回给前端页面,中间并没有对它进行处理。接下来key_words,types,page这三个参数,跟之前相比就是多了一个page参数,page代表页码,如果这些文章全部显示在一页中。 啧啧。

之后调用了get_date方法来获取文章数据,需要的两个参数是key_words和page,page的作用是限制查询条数。之后在elasticsearch_search类中添加一个新的方法get_date

def get_date(self,key_words,page):
    """
    从elasticsearch中获取数据
    :param key_words:
    :param page:
    :return: response
    :return : last_seconds
    """
    start_time = datetime.now()
    response = client.search(
        index = self.index,
        body = {
            "query":{
                "multi_match":{
                    "query":key_words,
                    "fields":["tags","title","content"]
                }
            },
            "from":(page-1)*12,
            "size":12,
            "highlight":{
                "pre_tags":['<span>'],
                "post_tags":['</span>'],
                "fields":{
                    "title":{},
                    "content":{},
                }
            }
        }
    )
    end_time = datetime.now()
    last_seconds = (end_time - start_time).total_seconds()
    return response,last_seconds

在body中的是查询的一个结构,虽然这个结构很复杂,但是我觉的比sql语句友好的多,

· query代表查询的关键字,

· query 中的fields是要在那几个字段中查询当前出现的值,

· from,size看一眼就知道,当然是开始和结束,因为每夜显示的条数是12,所以除了个12

· highlight高亮显示,pre_tags代表开始标签,post_tags代表结束标签

· highlight中的fields代表返回字段

highlight其实就是做了关键字标红处理,看一下演示

7.png

这个方法会把查询返回的response和查询所花的时间返回

total_nums = response["hits"]["total"]
all_hits = search_obj.analyze_date(key_words, response)

total_nums会取出匹配到数据的总条数

8.png

之后调用analyze_date方法来分析数据,因为response也是一大串,来看下

9.png

这里只取了3条,需要按照这个结构来解析返回的response,在elasticsearch_search类中添加analyze_date方法

def analyze_date(self,key_words,response):
    """
    返回分析后的数据列表集合
    :return:hit_list
    """
    hit_list = []
    for hit in response["hits"]["hits"]:
        hit_dict = {}
        hit_dict["origin"] = self.get_origin(hit)
        if "highlight" in hit:
            if "title" in hit["highlight"]:
                hit_dict["title"] = "".join(hit["highlight"]["title"])
            else:
                hit_dict["title"] = hit["_source"]["title"]
            if "content" in hit["highlight"]:
                hit_dict["content"] = self.filter_tags("".join(hit["highlight"]["content"]))
                hit_dict["content"] = hit_dict["content"][:500]
            else:
                hit_dict["content"] = self.filter_tags(hit["_source"]["content"])
                hit_dict["content"] = hit_dict["content"][:500]
        else:
            hit_dict["title"] = hit["_source"]["title"]
            hit_dict["content"] = self.filter_tags(hit["_source"]["content"][:500])
        hit_dict["create_date"] = hit["_source"]["create_time"]
        hit_dict["url"] = hit["_source"]["url"]
        hit_dict["score"] = hit["_score"]
        replace_text = '<span>' + key_words + "</span>"
        words = "(?i)"+key_words
        hit_dict["content"] = re.sub(words,replace_text,hit_dict["content"])
        hit_list.append(hit_dict)
    return hit_list

从上面的图中可以看到返回的数据其实是一个列表,对它进行for循环遍历就好。之后挨个去取值就ok了,不做过多的解释。

这个方法中调用了get_origin()方法,作用是获取文章来源地址,代码实现如下

def get_origin(self,hit):
    """
    获取文章来源
    :return:index_name 来源名称
    """
    if "_index" in hit:
        if "teachnical_4hou" == hit["_index"]:
            origin = "嘶吼"
    else:
        origin = "未知来源"
    return origin

还有filter_tags这个方法,作用是过滤html标签,这里就不给出,上面的代码有一点小问题就是返回的response中起始已经做了高亮处理,但是为了界面的美化,过滤了一遍html,之后在加上高亮标签。如果不这样做,你看到的文章内容将是乱七八糟一坨html代码。

在回到项目主文件往下看

x = get_elasticsearch_data_count()
alldate_nums = x.return_count()
if (page%12) > 0:
    page_nums = int(total_nums/12)+1
else:
    page_nums = int(total_nums/12)

又写了个名为get_elasticsearch_data_count()类,这个类作用是返回当前数据库中的全部数据条数,之后展示到页面中,

class get_elasticsearch_data_count(object):
    def __init__(self):
        self.index = []
        self.counts = []
        self.index.append("article_anquanke")
        self.index.append("teachnical_4hou")
        self.index.append("teachnical_freebuf")
        for index in self.index:
            count = self.__get_datecount(index)
            self.counts.append(count)
        self.counts.append(self.counts[0]+self.counts[1]+self.counts[2])
    def __get_datecount(self,index):
        response = client.count(index)
        return response["count"]
    def return_count(self):
        return self.counts

之后把这些数据全部返回到html页面中就ok了。flask跟Django填充数据很像,而且都非常简单,不做过多的介绍。

总结

代码只是实现了最简单的功能,很多问题都是没有处理的。还有,千万别问这个有什么用,除了好玩以外,是没有一点用。T_T 。  如果还有问题的话发邮箱tt.jiaqi@gmail.com,源代码点这里

数据来源的爬虫点这里

最后一张成品图

PS:千万不要盯着那个圈看,虽然挺好看,也挺好玩。但是会晕,伤眼睛!

本文为 smileTT 原创稿件,授权嘶吼独家发布,未经许可禁止转载,如若转载,请联系嘶吼编辑: http://www.4hou.com/technology/9868.html

点赞 19
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

smileTT

tt.jiaqi@gmail.com

发私信

发表评论

    Torjan 2018-01-18 21:41

    这才是专业的搬运工,给表哥一个膝盖,务必收下

    牛小妖 2018-01-18 14:31

    旋转!跳跃!我闭着眼……

    bt0sea 2018-01-18 11:02

    你这动画是用什么工具录的?mac上有这个工具么?

      smileTT 2018-01-18 14:36

      回复 @bt0sea LICEcap 这个,有windows和mac的版本 。 就是想不明白为啥不支持linux