Собственный поиск по раздачам rutracker.org – реализация на Yii2
Навеяно этой публикацией.
Здесь описано, как реализовать поиск по раздачам rutracker.org на собственном хостинге / локалхосте.
Предварительное соглашение:
- все операции проводятся в unix-подобной среде. Нюансы для windows мне, к сожалению, неизвестны;
- предполагается наличие у вас базовых знаний Unix shell, Yii2, git
- лично я вижу довольно мало сценариев использования этого (локального поиска по раздачам) решения;
- реализация на yii2 advanced template в данном случае избыточна, но я к нему привык;
- я впервые в жизни вижу spinx, поэтому там в конфиге могут быть странности;
- в некоторых местах решения довольно спорные (буду благодарен за подсказки «как правильно»).
Прочитав предыдущий топик на эту тему, был, если честно, слегка разочарован реализацией, которую предлагает автор. Собственно, поэтому и сделал всё сам.
Весь проект — на github, код целиком можно смотреть там, здесь буду приводить только отрывки, для понимания сути.
В проекте реализован автоматический импорт csv-файлов из этой раздачи (запускается из консоли), и поиск по названию / категории / подкатегории раздачи.
Детали
Если вы хотите использовать весь проект как есть, то вот краткая инструкция:
- клонируйте репозиторий (git clone github.com/andrew72ru/rutracker-yii2.git)
- перейдите в папку проекта, установите компоненты (composer install)
- инициализируйте окружение (./init)
- создайте базу данных, настройте доступ к ней в common/config/main-local.php
- запустите миграцию (./yii migrate)
- сконфигурируйте ваш веб-сервер для доступа к проекту (корневая директория — frontend/web)
- скачайте раздачу
- создайте каталог frontend/runtime/csv
- положите последнюю версию файлов из раздачи в этот каталог. Вся раздача разделена по папкам, названы они датами, я брал папку с последней датой
- запустите в консоли ./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.
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
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, и я еще раз хочу напомнить — я его видел впервые в жизни и использовал только потому, что автор исходного топика писал о нем. Ну и для эксперимента, конечно, а как же:)