海賊湾ディストリビューターを所有する

最近、ハブ上のRuTrackerで独自の検索エンジン作成する ことが一般的なりました。 退屈なエンタープライズ開発から離れて、何か新しいことに挑戦する絶好の機会に思えました。







そのため、タスク:ローカルホストにThe Pirate Bayに基づく検索エンジンを実装し、その過程でフロントエンド開発とは何かを試してみてください。 タスクは、RuTrackerとは異なり、TPBがダンプを発行しないという事実によって複雑になり、ダンプを受信するには、サイトを解析する必要があります。 グーグルでタスクを理解した結果、 Elasticsearchを検索エンジンとして使用することにしました。そのために、 AngularJSでクライアント側のみのフロントエンドを作成します。 データを取得するために、Goで、独自のTPBサイトパーサーと別のダンプローダーをインデックスに記述することにしました。 私が以前にElasticsearchやAngularJSに触れたことはなかったという事実によって、この選択の秘piが与えられました。それが私の本当の目標であったのは彼らのテストでした。



パーサー



TPB Webサイトを簡単に調べたところ、各トレントには、アドレス「/ torrent / {id}」に独自のページがあることがわかりました。 idが最初の数値であり、2番目に増加し、3番目に最後のIDが「/ recent」ページで表示され、最後よりも小さいすべてのidを試すことができます。 実践では、idが単調に増加することはなく、idごとにトレントを含む正しいページが存在しないため、追加の検証とidのスキップが必要であることが示されています。



パーサーは複数のスレッドでネットワークを操作するため、Goの選択は明らかでした。 HTMLを解析するために、 goqueryモジュールを使用しました







パーサーデバイスは非常に単純です。最初に、「/最近」が要求され、最大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の2つの出力モジュールを作成しました。



CSV形式:



id , , , , , , , , ,









JSONフレンドリ形式は、正確にはJSONではありません。各行は、csvと同じフィールドにトレントの説明を加えた個別のjsonオブジェクトです。



完全なダンプには3828894のトレントが含まれており、ダウンロードに約30時間かかりました。



索引



Elasticsearchにデータをアップロードする前に、構成する必要があります。



いくつかの言語で書かれたトレントの名前と説明による全文検索を取得したいので、まず、Unicode対応のアナライザーを作成します。



正規化およびその他の変換を備えた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プラグインをインストールする必要があります。アナライザーを作成した後、トレントの説明のフィールドに関連付ける必要があります。



トレントタイプの説明
 { "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のダウンローダーも作成しました。







ローダー自体はパーサーよりもさらに簡単です。ファイルを1行ずつ読み取り、jsonから構造体に各行を変換し、構造体をElastisearchに送信します。 バルクインデックス作成を行う方が正しいでしょうが、正直なところ、私は面倒でした。 ちなみに、ブートローダーの作成で最も難しい部分は、ポルノのないかなり長いログのスクリーンショットを検索することでした。



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() }
      
      







インデックス自体も同じ約6 GBを使用し、2時間程度で作成されました。



フロントエンド



私にとって最も興味深い部分です。 データベース内のすべてのトレントを確認し、カテゴリ/サブカテゴリおよびトレントの名前/説明でフィルタリングします。 したがって、左フィルター、右トレント。



レイアウトには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リクエストを使用すると、カテゴリのリストとこれらのカテゴリのトレントの数をすぐに取得できます。 より厳密には、[カテゴリ]フィールドの一意の値のリストと、そのような各値のドキュメントの数。



ダウンロードカテゴリ
 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.'); } });
      
      







1つまたは複数のカテゴリをクリックすると、それらが選択され、サブカテゴリのリストがロードされます。



サブカテゴリ処理
  $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に送信されるすべてを考慮した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用の独自の検索エンジンがあり、数時間で最新のWebサイトを作成できることがわかりました。






All Articles