Навеяно этой публикацией.

Здесь описано, как реализовать поиск по раздачам rutracker.org на собственном хостинге / локалхосте.



Предварительное соглашение:
  • все операции проводятся в unix-подобной среде. Нюансы для windows мне, к сожалению, неизвестны;
  • предполагается наличие у вас базовых знаний Unix shell, Yii2, git
  • лично я вижу довольно мало сценариев использования этого (локального поиска по раздачам) решения;
  • реализация на yii2 advanced template в данном случае избыточна, но я к нему привык;
  • я впервые в жизни вижу spinx, поэтому там в конфиге могут быть странности;
  • в некоторых местах решения довольно спорные (буду благодарен за подсказки «как правильно»).



Прочитав предыдущий топик на эту тему, был, если честно, слегка разочарован реализацией, которую предлагает автор. Собственно, поэтому и сделал всё сам.

Весь проект – на github, код целиком можно смотреть там, здесь буду приводить только отрывки, для понимания сути.

В проекте реализован автоматический импорт csv-файлов из этой раздачи (запускается из консоли), и поиск по названию / категории / подкатегории раздачи.

Детали

Если вы хотите использовать весь проект как есть, то вот краткая инструкция:

  1. клонируйте репозиторий (git clone github.com/andrew72ru/rutracker-yii2.git)
  2. перейдите в папку проекта, установите компоненты (composer install)
  3. инициализируйте окружение (./init)
  4. создайте базу данных, настройте доступ к ней в common/config/main-local.php
  5. запустите миграцию (./yii migrate)
  6. сконфигурируйте ваш веб-сервер для доступа к проекту (корневая директория – frontend/web)
  7. скачайте раздачу
  8. создайте каталог frontend/runtime/csv
  9. положите последнюю версию файлов из раздачи в этот каталог. Вся раздача разделена по папкам, названы они датами, я брал папку с последней датой
  10. запустите в консоли ./yii import/import


На моем сервере импорт продолжался примерно шесть часов – там больше полутора миллионов записей в таблице раздач, не удивляйтесь.

Схема БД
Таблица для категорий:

CREATE TABLE `categories` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `category_name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `file_name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;


Таблица подкатегорий:

CREATE TABLE `subcategory` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `forum_name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1239 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;


Таблица раздач:

CREATE TABLE `torrents` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `forum_id` int(11) DEFAULT NULL,
  `forum_name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `topic_id` int(11) DEFAULT NULL,
  `hash` varchar(50) COLLATE utf8_unicode_ci DEFAULT NULL,
  `topic_name` text COLLATE utf8_unicode_ci,
  `size` bigint(20) DEFAULT NULL,
  `datetime` int(11) DEFAULT NULL,
  `category_id` int(11) NOT NULL,
  `forum_name_id` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `topic_id` (`topic_id`),
  UNIQUE KEY `hash` (`hash`),
  KEY `category_torrent_fk` (`category_id`),
  KEY `torrent_subcat_id` (`forum_name_id`),
  CONSTRAINT `category_torrent_fk` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT `torrent_subcat_id` FOREIGN KEY (`forum_name_id`) REFERENCES `subcategory` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=1635590 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;


Таблица с раздачами несколько избыточна (колонка forum_name теперь не нужна, реализована в виде связи), но удалять я её не стал, чтоб можно было обратиться непосредственно к ней и не задействовать JOIN.


Модели

Модели используются сгенерированные через gii практически без изменений. не думаю, что стоит их все здесь приводить (смотрите github), кроме одной, использующейся для поиска через Sphinx.

TorrentSearch.php
namespace common\models;

use Yii;
use yii\helpers\ArrayHelper;
use yii\sphinx\ActiveDataProvider; // для работы
use yii\sphinx\ActiveRecord;          // используется расширение yii2-sphinx

/**
 * This is the model class for index "torrentz".
 *
 * @property integer $id
 * @property string $size
 * @property string $datetime
 * @property integer $id_attr
 * @property integer $size_attr
 * @property integer $datetime_attr
 * @property string $topic_name
 * @property string $topic_id
 * @property integer $topic_id_attr
 * @property integer $category_attr
 * @property string $category_id
 * @property string $name_attr
 * @property integer $forum_name_id_attr
 */
class TorrentSearch extends ActiveRecord
{
    /**
     * @inheritdoc
     */
    public static function indexName()
    {
        return '{{%torrentz}}';
    }

    /**
     * @inheritdoc
     */
    public function rules()
    {
        return [
            [['id'], 'required'],
            [['id'], 'unique'],
            [['id'], 'integer'],
            [['id_attr'], 'integer'],
            [['topic_name', 'topic_id', 'category_id'], 'string'],
            [['name_attr'], 'string'],
            [['id', 'size_attr', 'datetime_attr', 'id_attr', 'topic_id_attr', 'category_attr', 'forum_name_id_attr'], 'integer'],
            [['size', 'datetime', 'topic_name', 'name_attr'], 'string']
        ];
    }

    /**
     * @inheritdoc
     */
    public function attributeLabels()
    {
        return [
            'id_attr' => Yii::t('app', 'ID'),
            'name_attr' => Yii::t('app', 'Topic Name'),
            'id' => Yii::t('app', 'ID'),
            'size' => Yii::t('app', 'Size'),
            'datetime' => Yii::t('app', 'Datetime'),
            'topic_name' => Yii::t('app', 'Topic Name'),
            'size_attr' => Yii::t('app', 'Size'),
            'datetime_attr' => Yii::t('app', 'Torrent Registered Date'),
            'category_attr' => Yii::t('app', 'Category Name'),
            'forum_name_id_attr' => Yii::t('app', 'Forum Name'),
        ];
    }

    /**
     * Функция для поиска
     * 
     * @param $params
     * @return ActiveDataProvider
     */
    public function search($params)
    {
        $query = self::find();
        $dataProvider = new ActiveDataProvider([
            'query' => $query,
        ]);

        $this->load($params);

        $query->match($this->name_attr);
        $query->filterWhere(['category_attr' => $this->category_attr]);
        $query->andFilterWhere(['forum_name_id_attr' => $this->forum_name_id_attr]);

        $dataProvider->sort = [
            'defaultOrder' => ['category_attr' => SORT_ASC, 'datetime_attr' => SORT_DESC],
        ];

        return $dataProvider;
    }

    /**
     * Возвращает массив подкатегорий (forum_name) для переданной категории
     *
     * @param null|integer $id
     * @return array
     */
    public static function subsForCat($id = null)
    {
        $query = Subcategory::find();
        if ($id != null && ($cat = Categories::findOne($id)) !== null)
        {
            $subcatsArr = array_keys(self::find()
                ->where(['category_attr' => $id])
                ->groupBy('forum_name_id_attr')
                ->indexBy('forum_name_id_attr')
                ->limit(10000)
                ->asArray()
                ->all());
            $query->andWhere(['id' => $subcatsArr]);
        }

        return ArrayHelper::map($query->asArray()->all(), 'id', 'forum_name');
    }

    /**
     * Возвращает массив с одной категорией, если передана подкатегория
     *
     * @param null|integer $id
     * @return array
     */
    public static function catForSubs($id = null)
    {
        $query = Categories::find();
        if($id != null && ($subCat = Subcategory::findOne($id)) !== null)
        {
            /** @var TorrentSearch $category */
            $category = self::find()->where(['forum_name_id_attr' => $id])->one();
            $query->andWhere(['id' => $category->category_attr]);
        }

        return ArrayHelper::map($query->asArray()->all(), 'id', 'category_name');
    }
}




Импорт

Основная идея – сначала импортируем категории (файл category_info.csv), затем – раздачи (файлы category_*.csv), по ходу импорта раздач из них берем подкатегории и пишем в отдельную модель.

Контроллер импорта
namespace console\controllers;

use common\models\Categories;
use common\models\Subcategory;
use common\models\Torrents;
use Yii;
use yii\console\Controller;
use yii\helpers\Console;
use yii\helpers\VarDumper;

/**
 * Импорт раздач и категорий из csv-файлов
 *
 * Class ImportController
 * @package console\controllers
 */
class ImportController extends Controller
{
    public $color = true;

    /**
     * Инструкция
     * @return int
     */
    public function actionIndex()
    {
        $this->stdout("Default: import/import [file_path]. \nDefault file path is frontend/runtime/csv\n\n");

        return Controller::EXIT_CODE_NORMAL;
    }

    /**
     * Основная функция импорта
     *
     * @param string $path
     * @return int
     */
    public function actionImport($path = 'frontend/runtime/csv')
    {
        $fullPath = Yii::getAlias('@' . $path);
        if(!is_dir($fullPath))
        {
            $this->stderr("Path '{$fullPath}' not found\n", Console::FG_RED);
            return Controller::EXIT_CODE_ERROR;
        }

        if(is_file($fullPath . DIRECTORY_SEPARATOR . 'category_info.csv'))
            $categories = $this->importCategories($fullPath);
        else
        {
            $this->stderr("File 'category_info.csv' not found\n", Console::FG_RED);
            return Controller::EXIT_CODE_ERROR;
        }

        if($categories === false)
        {
            $this->stderr("Categories is NOT imported", Console::FG_RED);
            return Controller::EXIT_CODE_ERROR;
        }

        /** @var Categories $cat */
        foreach ($categories as $cat)
        {
            if(!is_file($fullPath . DIRECTORY_SEPARATOR . $cat->file_name))
                continue;

            $this->importTorrents($cat, $path);
        }


        return Controller::EXIT_CODE_NORMAL;
    }

    /**
     * Импорт торрентов
     *
     * @param \common\models\Categories $cat
     * @param                           $path
     */
    private function importTorrents(Categories $cat, $path)
    {
        $filePath = Yii::getAlias('@' . $path . DIRECTORY_SEPARATOR . $cat->file_name);

        $row = 0;
        if (($handle = fopen($filePath, "r")) !== FALSE)
        {
            while (($data = fgetcsv($handle, 0, ";")) !== FALSE)
            {
                $row++;

                $model = Torrents::findOne(['forum_id' => $data[0], 'topic_id' => $data[2]]);
                if($model !== null)
                    continue;


                // Subcategory
                $subcat = $this->importSubcategory($data[1]);
                if(!($subcat instanceof Subcategory))
                {
                    $this->stderr("Error! Unable to import subcategory!");
                    $this->stdout("\n");
                    continue;
                }

                $this->stdout("Row {$row} of category \"{$cat->category_name}\" ");
                $this->stdout("and subcategory \"{$subcat->forum_name}\": \n");

                if($model === null)
                {
                    if(isset($data[4]))
                    $data[4] = str_replace('\\', '/', $data[4]);

                    // Здесь надо проверить, определились ли поля, а то с этим бывают проблемы
                    // Можно поподробнее распарсить название и убрать оттуда все подозрительные символы, 
                    // но я решил пропускать, если возникает ошибка 
                    if(!isset($data[0]) || !isset($data[1]) || !isset($data[2]) || !isset($data[3]) || !isset($data[4]) || !isset($data[5]) || !isset($data[6]))
                    {
                    $this->stderr("Error! Undefined Field!\n", Console::FG_RED);
                    \yii\helpers\VarDumper::dump($data);
                    $this->stdout("\n");
                    continue;
                    }

                    $model = new Torrents([
                        'forum_id' => $data[0],
                        'forum_name' => $data[1],
                        'topic_id' => $data[2],
                        'hash' => $data[3],
                        'topic_name' => $data[4],
                        'size' => $data[5],
                        'datetime' => strtotime($data[6]),
                        'category_id' => $cat->id,
                    ]);
                }
                $model->forum_name_id = $subcat->id;
                if($model->save())
                {
                    $this->stdout("Torrent \t");
                    $this->stdout($model->topic_name, Console::FG_YELLOW);
                    $this->stdout(" added\n");
                }

                $this->stdout("\n");
            }
        }
    }

    /**
     * Создание подкатегории (forum_name)
     *
     * @param string $subcat_name
     * @return bool|Subcategory
     */
    private function importSubcategory($subcat_name)
    {
        $model = Subcategory::findOne(['forum_name' => $subcat_name]);
        if($model === null)
            $model = new Subcategory(['forum_name' => $subcat_name]);

        if($model->save())
            return $model;
        else
        {
            VarDumper::dump($model->errors);
        }

        return false;
    }

    /**
     * Импорт категорий
     *
     * @param $path
     * @return array|\yii\db\ActiveRecord[]
     */
    private function importCategories($path)
    {
        $file = $path . DIRECTORY_SEPARATOR . 'category_info.csv';
        $row = 1;
        if (($handle = fopen($file, "r")) !== FALSE)
        {
            while (($data = fgetcsv($handle, 0, ";")) !== FALSE)
            {
                $row++;
                $this->stdout("Row " . $row . ":\n");

                $model = Categories::findOne($data[0]);

                if($model === null)
                {
                    $model = new Categories([
                        'id' => $data[0],
                        'category_name' => $data[1],
                        'file_name' => $data[2]
                    ]);
                }

                if($model->save())
                    $this->stdout("Category {$model->id} with name '{$model->category_name}' imported\n");

                $this->stdout("\n");
            }
        } else
            return false;

        return Categories::find()->all();
    }
}



Импорт лучше запускать в screen, чтобы можно было консоль закрыть. Можно, конечно, перенаправить вывод в файл и почитать потом, на досуге.

Sphinx

Для debian – apt-get install sphinxsearch
У меня установлена версия Sphinx 2.2.9

/etc/sphinxsearch/sphinx.conf
source torrentz {
        type = mysql
        sql_host = localhost
        sql_user = webmaster # логин в MySQL
        sql_pass = webmaster # пароль в MySQL 
        sql_db = rutracker # измените на название вашей БД 
        sql_port = 3306

        sql_query_pre = SET NAMES utf8
        sql_query_pre = SET CHARACTER SET utf8

        sql_query = SELECT id, id AS id_attr,                 size, size AS size_attr,                 datetime, datetime as datetime_attr,                 topic_name, topic_name AS name_attr,                 topic_id, topic_id AS topic_id_attr,                 category_id, category_id AS category_attr,                 forum_name_id, forum_name_id AS forum_name_id_attr                 FROM torrents

        sql_attr_string = name_attr
        sql_attr_uint = id_attr
        sql_attr_uint = size_attr
        sql_attr_uint = datetime_attr
        sql_attr_uint = topic_id_attr
        sql_attr_uint = category_attr
        sql_attr_uint = forum_name_id_attr

}

index torrentz {
        source = torrentz
        path = /var/lib/sphinxsearch/data/
        docinfo = extern
        morphology = stem_enru
        min_word_len = 2
        charset_table = 0..9, A..Z->a..z, _, a..z, U+410..U+42C->U+430..U+44C, U+42E..U+42F->U+44E..U+44F, U+430..U+44C, U+44E..U+44F, U+0401->U+0435, U+0451->U+0435, U+042D->U+0435, U+044D->U+0435
        min_infix_len = 2
}

indexer {
        mem_limit = 512M
}

searchd {
        listen = 0.0.0.0:9306:mysql41
        log = /var/log/sphinxsearch/searchd.log
        query_log = /var/log/sphinxsearch/query.log
        read_timeout = 5
        max_children = 30
        pid_file = /var/run/sphinxsearch/searchd.pid
}



Индексация запускается командой

indexer --config /etc/sphinxsearch/sphinx.conf --all # для первой индексации

indexer --config /etc/sphinxsearch/sphinx.conf --rotate --all # переиндексация при запущенном демоне


На этом всё.
В веб-интерфейсе – стандартный Yii2 GridView, поиск – через стандартные фильтры.

Что бы стоило доделать

Развивать это можно бесконечно, если хочется. В первую очередь можно сделать выборочный импорт категорий / подкатегорий, более правильный зависимый список категорий / подкатегорий в GridView, API для удаленных запросов ну и потом вообще всё что в голову придет.

Может быть, займусь на досуге.

P.S. Очень приветствую замечания и дополнения по коду, но пожалуйста, не трудитесь писать «php отстой, пиши на …<вставить любой другой язык>» – мы всё это давно уже обсудили.
Также приветствуются замечания / дополнения по конфигу sphinx, и я еще раз хочу напомнить – я его видел впервые в жизни и использовал только потому, что автор исходного топика писал о нем. Ну и для эксперимента, конечно, а как же :)

Комментарии (13)


  1. mifistor
    03.01.2016 00:13

    Единственный вопрос: как все это добро обновлять?


    1. andrew72ru
      03.01.2016 00:36

      Скачиваем обновлённую раздачу, выкладываем обновлённые файлы, запускаем импорт.
      И если поправить всё так, как советует Borro, то импорт пройдёт гораздо быстрее :)


  1. Borro
    03.01.2016 00:28
    +3

    Чтобы не вставляло 6 часов надо:

    • $model = Torrents::findOne(['forum_id' => $data[0], 'topic_id' => $data[2]]); убрать за цикл и сделать полный фетч. Причем лучше без AR, массив должен состоять из ключей forum_id + '_' + topic_id, и значений true, например
      $exists = [];
      while (($row = $reader->read()) {
          $exists[$row['forum_id '] . '_' . $row['topic_id']] = true;
      }
      

      или просто сделайте уникальный ключ на forum_id + topic_id, а в insert добавьте ключик IGNORE
      В основном цикле заменяете if($model !== null) continue; на if (isset($exists[$data[0] . '_' . $data[2]])) continue;
    • После этого выкидываем вставку через AR и делаем опять же через DAO, причем вне цикла делаете prepare, а в цикле просто байндите значения.
    • if(!isset($data[0]) || !isset($data[1]) || !isset($data[2]) || !isset($data[3]) || !isset($data[4]) || !isset($data[5]) || !isset($data[6]))
      

      нужно заменить на count($data) > 1, и проверять if (!array_key_exists(6, $data)) { $data[6] = 0 } Бывает, что у некоторых топиков не бывает даты.
    • Старайтесь не делать лишних движений в большом цикле, поэтому стоит убрать все выводы на экран.
    • В конфиге сфинкса используйте sql_query_range

    Мой индексатор индексирует за 200 секунд в 4 потока. Правда я не использую mysql вообще, всё сделано с помощью sphinx'а и его xmlpipe2. Правда пока я не доделал выдергивание категорий/подкатегорий. Может на досуге доделаю, чтобы формировал php-массив и далее его нужно будет просто инклудить в код. Я считаю, что mysql для этого избыточна.


    1. andrew72ru
      03.01.2016 00:37

      Идея понятна, благодарю.
      Поэкспериментирую :)


    1. ckr
      03.01.2016 12:36

      Я считаю, что mysql для этого избыточна.

      Для такой задачи и PHP избыточен. Дожили. Чтобы найти что-нибудь на rutracker, надо научиться поднимать LAMP. Решение с PHP непрактично ни в каком виде. А так, пользуюсь поиском по DHT прямо в клиенте qBittorrent, оттуда же начинаю воспроизведение видео потоком. Не знаю, ищет ли qBittorrent конкретно раздачи rutracker, но все популярное и почти все непопулярное (например, пиратские копии баз maxmind или пиратский КонсультантПлюс).
      Я не делаю никаких реклам, просто привожу пример как «непопулярных» раздач.


  1. past
    03.01.2016 10:32
    +2

    На мой взгляд, тут не хватает интеграции с какой-нибудь качалкой, имеющей веб интерфейс типа transmission.


    1. ckr
      03.01.2016 12:04

      Еще, чтобы на смарт-тв работало. И воспроизводило видео без полной загрузки. Прямо как в Popcorntime


  1. ckr
    03.01.2016 13:39
    -1

    Хотелось бы обратиться к автору.
    Этих magnet-хостингов вагон и тележка тысячи онлайном по всему миру. Все заблокировать в РФ не получится. И попросить удалить раздачу, например, с китайского трекера сложно, там местный админ не понимает ни русский ни английский.
    На вашем месте, я бы потратил часть новогодних каникул на изучение node.js.
    О Popcorn уже не раз писалось на хабре.
    Если вам не все равно, адаптируйте пожалуйста туда нормальное отображение описания видео и рейтингов или даже поиск по рейтингам.
    Поисковые провайдеры туда вроде как подключаются легко.


    1. ckr
      03.01.2016 14:01

      На Yii в node.js похож фреймворк Keystone.
      3 минуты на генерацию сайта. Накидать модели и шаблоны jade. — не больше 2 часов освоения.


  1. baxtep
    03.01.2016 18:22

    а можно сделать стандартную виртуальную машину?


    1. andrew72ru
      03.01.2016 20:08

      Можно, но я не знаю, как. Никогда не сталкивался с этим.


      1. Jodes
        03.01.2016 21:59

        Не рекламы ради, но http://puphpet.com
        Ну или нуля собрать свой vagrant-box


    1. rhamdeew
      08.01.2016 15:12

      Так можно и в Docker-контейнер упаковать)

      Причем это довольно просто делается