Пользователь
0,0
рейтинг
2 января в 21:42

Разработка → Собственный поиск по раздачам rutracker.org – реализация на Yii2

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

Здесь описано, как реализовать поиск по раздачам 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, и я еще раз хочу напомнить – я его видел впервые в жизни и использовал только потому, что автор исходного топика писал о нем. Ну и для эксперимента, конечно, а как же :)
Андрей @andrew72ru
карма
11,0
рейтинг 0,0
Пользователь
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

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

  • 0
    Единственный вопрос: как все это добро обновлять?
    • 0
      Скачиваем обновлённую раздачу, выкладываем обновлённые файлы, запускаем импорт.
      И если поправить всё так, как советует Borro, то импорт пройдёт гораздо быстрее :)
  • +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 для этого избыточна.
    • 0
      Идея понятна, благодарю.
      Поэкспериментирую :)
    • 0
      Я считаю, что mysql для этого избыточна.

      Для такой задачи и PHP избыточен. Дожили. Чтобы найти что-нибудь на rutracker, надо научиться поднимать LAMP. Решение с PHP непрактично ни в каком виде. А так, пользуюсь поиском по DHT прямо в клиенте qBittorrent, оттуда же начинаю воспроизведение видео потоком. Не знаю, ищет ли qBittorrent конкретно раздачи rutracker, но все популярное и почти все непопулярное (например, пиратские копии баз maxmind или пиратский КонсультантПлюс).
      Я не делаю никаких реклам, просто привожу пример как «непопулярных» раздач.
  • +2
    На мой взгляд, тут не хватает интеграции с какой-нибудь качалкой, имеющей веб интерфейс типа transmission.
    • 0
      Еще, чтобы на смарт-тв работало. И воспроизводило видео без полной загрузки. Прямо как в Popcorntime
  • –1
    Хотелось бы обратиться к автору.
    Этих magnet-хостингов вагон и тележка тысячи онлайном по всему миру. Все заблокировать в РФ не получится. И попросить удалить раздачу, например, с китайского трекера сложно, там местный админ не понимает ни русский ни английский.
    На вашем месте, я бы потратил часть новогодних каникул на изучение node.js.
    О Popcorn уже не раз писалось на хабре.
    Если вам не все равно, адаптируйте пожалуйста туда нормальное отображение описания видео и рейтингов или даже поиск по рейтингам.
    Поисковые провайдеры туда вроде как подключаются легко.
    • 0
      На Yii в node.js похож фреймворк Keystone.
      3 минуты на генерацию сайта. Накидать модели и шаблоны jade. — не больше 2 часов освоения.
  • 0
    а можно сделать стандартную виртуальную машину?
    • 0
      Можно, но я не знаю, как. Никогда не сталкивался с этим.
      • 0
        Не рекламы ради, но http://puphpet.com
        Ну или нуля собрать свой vagrant-box
    • 0
      Так можно и в Docker-контейнер упаковать)

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

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.