Собственный поисковик по раздачам The Pirate Bay

В последнее время на хабре стало популярно делать собственные поисковики по RuTracker. Мне это показалось прекрасным поводом для того, чтобы отойти от скучной enterprise разработки и попробовать что-нибудь новое.

Итак, задача: реализовать на локалхосте поисковик по базе The Pirate Bay и попутно попробовать, что же такое frontend разработка и с чем её едят. Задача осложняется тем, что TPB не публикует своих дампов, в отличие от RuTracker, и для получения дампов требуется распарсить их сайт. В результате гугления и осмысления задачи я решил в качестве поисковика использовать Elasticsearch, для которого написать client-side only фронтенд на AngularJS. Для получения данных я решил написать собственный парсер сайта TPB и отдельный загружатель дампа в индекс, оба на Go. Пикантность выбору придавал тот факт, что ни к Elasticsearch, ни к AngularJS я до этого ни разу не прикасался и именно их опробывание было моей настоящей целью.

Парсер

Краткий осмотр сайта TPB показал, что каждый торрент имеет свою страницу по адресу "/torrent/{id}". Где id во-первых численные, во-вторых увеличиваются, в-третьих последний id посможно посмотреть на странице "/recent" и потом перепробовать все id меньше последнего. Практика показала, что id увеличиваются не монотонно и не для каждого id есть корректная страница с торрентом, что потребовало дополнительной проверки и пропуска id.

Так как парсер подразумевает работу с сетью в несколько потоков, выбор Go был очевиден. Для разбора HTML я использовал модуль goquery.

Устройство парсера весьма просто: вначале запрашивается "/recent" и из неё получается максимальный id:

Получаем последний id

func getRecentId(topUrl string) int { 	var url bytes.Buffer 	url.WriteString(topUrl) 	url.WriteString("/recent")  	log.Info("Processing recent torrents page at: %s", url.String()) 	doc, err := goquery.NewDocument(url.String()) 	if err != nil { 		log.Critical("Can't download recent torrents page from TPB: %v", err) 		return 0 	}  	topTorrent := doc.Find("#searchResult .detName a").First() 	t, pT := topTorrent.Attr("title") 	u, pU := topTorrent.Attr("href") 	if pT && pU { 		rx, _ := regexp.Compile(`\/torrent\/(\d+)\/.*`) 		if rx.MatchString(u) { 			id, err := strconv.Atoi(rx.FindStringSubmatch(u)[1]) 			if err != nil { 				log.Critical("Can't retrieve latest torrent id") 				return 0 			} 			log.Info("The most recent torrent is %s and it's id is %d", t, id) 			return id 		} 	} 	return 0 } 

Затем мы просто бежим по всем значениям id от максимального до нулевого и скармливаем полученные цифры в канал:

Скучный цикл и немного синхронизации.

func (d *Downloader) run() { 	d.wg.Add(streams) 	for w := 0; w <= streams; w++ { 		go d.processPage() 	} 	for w := d.initialId; w >= 0; w-- { 		d.pageId <- w 	} 	close(d.pageId) 	log.Info("Processing complete, waiting for goroutines to finish") 	d.wg.Wait() 	d.output.Done() } 

Как видно из кода, с другой стороны канала запущено некоторое количество горутин, принимающих id торрента, скачивающих соответствующую страницу и обрабатывающую её:

Обработчик страницы торрента

func (d *Downloader) processPage() { 	for id := range d.pageId { 		var url bytes.Buffer 		url.WriteString(d.topUrl) 		url.WriteString("/torrent/") 		url.WriteString(strconv.Itoa(id))  		log.Info("Parsing torrent page at: %s", url.String()) 		doc, err := goquery.NewDocument(url.String())  		if err != nil { 			log.Warning("Can't download torrent page %s from TPB: %v", url, err) 			continue 		}  		torrentData := doc.Find("#detailsframe") 		if torrentData.Length() < 1 { 			log.Warning("Erroneous torrent %d: \"%s\"", id, url.String()) 			continue 		}  		torrent := TorrentEntry{Id: id} 		torrent.processTitle(torrentData) 		torrent.processFirstColumn(torrentData) 		torrent.processSecondColumn(torrentData) 		torrent.processHash(torrentData) 		torrent.processMagnet(torrentData) 		torrent.processInfo(torrentData)  		d.output.Put(&torrent)  		log.Info("Processed torrent %d: \"%s\"", id, torrent.Title)  	} 	d.wg.Done() } 

После обработки результат отправляется в OutputModule, который уже сохраняет его в том или ином формате. Я написал два модуля вывода, в csv и в «почти» json.

Формат csv:

id торрента, название, размер, количество файлов, категория, подкатегория, автор закачки, хэш, дата создания, магнет ссылка

Json friendly формат не совсем Json: каждая строка представляет собой отдельный json объект с теми же полями, что и в csv, плюс описание торрента.

Полный дамп содержит 3828894 торрентов и занял почти 30 часов на загрузку.

Индекс

Перед тем как загрузить данные в Elasticsearch, его надо настроить.

Так как я бы хотел получить полнотекстовый поиск по названия и описаниям торрентов, которые написаны на нескольких языках, то в первую очередь создадим Unicode friendly анализатор:

Unicode анализатор с нормализацией и прочими преобразованиями.

{   "index": {     "analysis": {       "analyzer": {         "customHTMLSnowball": {          "type": "custom",           "char_filter": [             "html_strip"           ],           "tokenizer": "icu_tokenizer",           "filter": [             "icu_normalizer",             "icu_folding",             "lowercase",             "stop",             "snowball"           ]           }       }     }   } } 

curl -XPUT http://127.0.0.1:9200/tpb -d @tpb-settings.json  

Перед созданием анализатора необходимо поставить ICU plugin, а после создания анализатора нужно связать его с полями в описании торрента:

Описание типа торрента

{       "properties" : {         "Id" : {           "type" :    "long",           "index" : "no"         },         "Title" : {           "type" :   "string",           "index" : "analyzed",           "analyzer" : "customHTMLSnowball"         },         "Size" : {           "type" :    "long",           "index" : "no"         },         "Files" : {           "type" :    "long",           "index" : "no"         },         "Category" : {           "type" :    "string",           "index" : "not_analyzed"         },         "Subcategory" : {           "type" :    "string",           "index" : "not_analyzed"         },         "By" : {           "type" :    "string",           "index" : "no"         },         "Hash" : {           "type" :    "string",           "index" : "not_analyzed"         },         "Uploaded" : {           "type" :    "date",           "index" : "no"         },         "Magnet" : {           "type" :    "string",           "index" : "no"         },         "Info" : {           "type" :   "string",           "index" : "analyzed",           "analyzer" : "customHTMLSnowball"         }       } } 

curl -XPUT http://127.0.0.1:9200/tpb/_mappings/torrent -d @tpb-mapping.json  

И теперь самое главное — загрузка данных. Загрузчик я тоже написал на Go, чтобы посмотреть, как работать с Elasticsearch из Go.

Сам загрузчик ещё проще парсера: читаем файл построчно, каждую строчку переводим из json в структуру, отправляем структуру в Elastisearch. Правильнее было бы сделать bulk indexing, но мне, честно говоря, было лень. Кстати, самой сложной частью написания загрузчика был поиск для скриншота достаточно длинного куска лога без порнухи.

Загрузчик в Elasticsearch

func (i *Indexer) Run() { 	for i.scaner.Scan() { 		var t TorrentEntry 		err := json.Unmarshal(i.scaner.Bytes(), &t) 		if err != nil { 			log.Warning("Failed to parse entry %s", i.scaner.Text()) 			continue 		} 		_, err = i.es.Index().Index(i.index).Type("torrent").BodyJson(t).Do() 		if err != nil { 			log.Warning("Failed to index torrent entry %s with id %d", t.Title, t.Id) 			continue 		} 		log.Info("Indexed %s", t.Title) 	} 	i.file.Close() } 

Сам индекс занял те же самые ~6GB и строился порядка 2х часов.

Frontend

Самая интересная часть для меня. Я хотел бы видеть все торренты в базе и фильтровать их по категориям/подкатегориям и по названию/описанию торрента. Таким образом, слева фильтры, справа торренты.

За основу для вёрстки я взял Bootstrap. Для большинства это видимо боян, но мне в новинку.

Итак, по левую руку у меня фильтр по заголовкам и содержимому:

Фильтр по заголовкам

                    <form class="form-horizontal">                         <div class="form-group">                             <label for="queryInput" class="col-sm-2 control-label">Title</label>                              <div class="col-sm-10">                                 <input type="text" class="form-control input-sm" id="queryInput"                                        placeholder="Big Buck Bunny" ng-model="query">                             </div>                         </div>                         <div class="form-group">                             <div class="col-sm-offset-2 col-sm-10">                                 <div class="checkbox">                                     <label>                                         <input type="checkbox" ng-model="useInfo"> Look in torrent info too.                                     </label>                                 </div>                             </div>                         </div>                         <div class="form-group text-right">                             <div class="col-sm-offset-2 col-sm-10">                                 <button type="submit" class="btn btn-default" ng-click="searchClick()">Search</button>                             </div>                         </div>                     </form> 

Под ним фильтры по категориям и субкатегориям:

Фильтр по категориям

            <div class="panel panel-warning" ng-cloak ng-show="categories.length >0">                 <div class="panel-heading">                     <h3 class="panel-title">Categories:</h3>                 </div>                 <div class="panel-body">                     <div ng-repeat="cat in categories | orderBy: 'key'">                         <p class="text-justify">                             <button class="btn btn-warning wide_button" ng-class="{'active': cat.active}"                                     ng-click="categoryClick(cat)">{{cat.key}} <span                                     class="badge">{{cat.doc_count}}</span></button>                         </p>                     </div>                 </div>             </div>             <div class="panel panel-warning" ng-cloak ng-show="SubCategories.length >0 && filterCategories.length >0">                 <div class="panel-heading">                     <h3 class="panel-title">Sub categories:</h3>                 </div>                 <div class="panel-body">                     <div ng-repeat="cat in SubCategories | orderBy: 'key'">                         <p class="text-justify">                             <button class="btn btn-success wide_button" ng-class="{'active': cat.active}"                                     ng-click="subCategoryClick(cat)">{{cat.key}} <span                                     class="badge">{{cat.doc_count}}</span></button>                         </p>                     </div>                 </div>             </div> 

Список категорий наполняется автоматически при загрузке приложения. Использование TermsAggregation запроса позволяет получить сразу и список категорий и количество торрентов в этих категориях. Говоря более строго — список уникальных значений поля Category и число документов для каждого такого значения.

Загрузка категорий

client.search({         index: 'tpb',         type: 'torrent',         body: ejs.Request().agg(ejs.TermsAggregation('categories').field('Category'))     }).then(function (resp) {         $scope.categories = resp.aggregations.categories.buckets;         $scope.errorCategories = null;     }).catch(function (err) {         $scope.categories = null;         $scope.errorCategories = err;         // if the err is a NoConnections error, then the client was not able to         // connect to elasticsearch. In that case, create a more detailed error         // message         if (err instanceof esFactory.errors.NoConnections) {             $scope.errorCategories = new Error('Unable to connect to elasticsearch.');         }     }); 

При клике на одну или несколько категорий, они выбираются и загружается список их подкатегорий:

Обработка подкатегорий

    $scope.categoryClick = function (category) {         /* Mark button */         category.active = !category.active;          /* Reload sub categories list */         $scope.filterCategories = [];         $scope.categories.forEach(function (item) {             if (item.active) {                 $scope.filterCategories.push(item.key);             }         });          if ($scope.filterCategories.length > 0) {             $scope.loading = true;             client.search({                 index: 'tpb',                 type: 'torrent',                 body: ejs.Request().agg(ejs.FilterAggregation('SubCategoryFilter').filter(ejs.TermsFilter('Category', $scope.filterCategories)).agg(ejs.TermsAggregation('categories').field('Subcategory').size(50)))             }).then(function (resp) {                     $scope.SubCategories = resp.aggregations.SubCategoryFilter.categories.buckets;                     $scope.errorSubCategories = null;                     //Restore selection                     $scope.SubCategories.forEach(function (item) {                         if ($scope.selectedSubCategories[item.key]) {                             item.active = true;                         }                     });                 }             ).catch(function (err) {                 $scope.SubCategories = null;                 $scope.errorSubCategories = err;                 // if the err is a NoConnections error, then the client was not able to                 // connect to elasticsearch. In that case, create a more detailed error                 // message                 if (err instanceof esFactory.errors.NoConnections) {                     $scope.errorSubCategories = new Error('Unable to connect to elasticsearch.');                 }             });         } else {             $scope.selectedSubCategories = {};             $scope.filterSubCategories = [];         }          $scope.searchClick();     }; 

Подкатегории тоже можно выбирать. При смене выбора категорий/подкатегорий или заполнении формы поиска формируется Elasticsearch query, учитывающий всё выбранное и отправляется в Elasticsearch.

Формируем запрос к Elasticsearch в зависимости от выбранного слева.

    $scope.buildQuery = function () {         var match = null;         if ($scope.query) {             if ($scope.useInfo) {                 match = ejs.MultiMatchQuery(['Title', 'Info'], $scope.query);             } else {                 match = ejs.MatchQuery('Title', $scope.query);             }         } else {             match = ejs.MatchAllQuery();         }          var filter = null;         if ($scope.filterSubCategories.length > 0) {             filter = ejs.TermsFilter('Subcategory', $scope.filterSubCategories);         }         if ($scope.filterCategories.length > 0) {             var categoriesFilter = ejs.TermsFilter('Category', $scope.filterCategories);             if (filter !== null) {                 filter = ejs.AndFilter([categoriesFilter, filter]);             } else {                 filter = categoriesFilter;             }         }          var request = ejs.Request();         if (filter !== null) {             request = request.query(ejs.FilteredQuery(match, filter));         } else {             request = request.query(match);         }          request = request.from($scope.pageNo*10);          return request;     }; 

Результаты отображаются справа:

Шаблон результатов

            <div class="panel panel-info" ng-repeat="doc in searchResults">                 <div class="panel-heading">                     <h3 class="panel-title">                         <a href="{{doc._source.Magnet}}">{{doc._source.Title}}</a>                         <!-- build:[src] img/ -->                         <a href="{{doc._source.Magnet}}"><img class="magnet_icon"                                                               src="assets/dist/img/magnet_link.png"></a>                         <!-- /build -->                     </h3>                 </div>                 <div class="panel-body">                     <p class="text-left text-warning">                         {{doc._source.Category}} / {{doc._source.Subcategory}}</p>                      <p class="text-center"><span                             class="badge">#{{doc._source.Id}}</span> <b>{{doc._source.Title}}</b>                     </p>                     <dl class="dl-horizontal">                         <dt>Size</dt>                         <dd>{{doc._source.Size}}</dd>                         <dt>Files</dt>                         <dd>{{doc._source.Files}}</dd>                         <dt>Hash</dt>                         <dd>{{doc._source.Hash}}</dd>                     </dl>                     <div class="well" ng-bind-html="doc._source.Info"></div>                     <p class="text-right text-muted">                         <small>Uploaded at {{doc._source.Uploaded}} by {{doc._source.By}}</small>                     </p>                 </div>             </div> 

Вот и всё. Теперь у меня есть собственный поисковик по The Pirate Bay, и я узнал, что можно сделать современный сайт за пару часов.

ссылка на оригинал статьи http://habrahabr.ru/post/275339/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *