Рассказ о написании собственного поискового движка, который умеет индексировать сжатый HTML, а также позволяет ранжировать выдачу на основе рейтинга.
8К открытий8К показов
Дмитрий Мальцев
ведущий full-stack программист в RevolveR Labs
Поскольку Яндекс не захотел парсить мои сайты сославшись на то, что они не умеют обрабатывать контент в формате deflate мне захотелось разобраться в чем дело и попробовать написать свой поисковый сервис. Вообще служба техподдержки Яндекс оказалась для меня бесполезной, поскольку два дня Платоны доказывали мне, что сайты на Revolver CMF отдают битую кодировку. В то же время это был просто сжатый в deflate HTML. В итоге я решил написать свой индексатор, который умеет индексировать сжатый HTML и не только.
Создавать было решено антибюрократический Open Source поисковик, ранжирующий результаты в выдаче на основе голосов зарегистрированных пользователей без участия модерации.
Название мы с друзьями выбрали созвучно всем известной Picus Networks из мира компьютерной игры DeusEx. Осталось создать два алгоритма Pick для выполнения запросов и Picker для индексации контента.
Как создавался Pick
Можно было реализовать поисковую систему отдельно, но я использовал framework RevolveR, который предоставляет доступ к API работы с базой данных и ее кэширование, обработку POST и GET запросов с защитой, а также fetch API для динамических запросов.
А после интеграции Pick стал частью ядра. Скачать RevolveR CMF можно со страницы проекта GitHub.
Создаем индекс в базе данных
Очевидно, что нам нужен свой поисковый индекс, который будет храниться в базе данных. Для этого сформируем структуру на SBQ (structure based queries), которая хранится в файле /Kernel/Structures/DataBase.php:
Мы создали структуру будущей таблицы revolver_index, которую будут использовать модели для записи и хранения данных. Полям content, description и title назначаем полнотекстовый индекс для ускорения запросов SELECT, а для поля host укажем тип индекса simple (это поможет сделать быстрый поиск по всем индексированным ссылкам определённого ресурса).
Также у нас есть поля date и hash. Дата хранит последний момент индексации ресурса, а hash указывает на актуальность данных (если хэш заново полученной страницы не отличается от хранимого в БД значения, то обновление не выполняется).
Поле uri будет содержать полную ссылку страницы.
Теперь нам понадобится таблица в БД которая будет хранить рейтинги материалов в формате 5 звезд на основе голосов зарегистрированных пользователей (API для рейтингов есть и о том как оно работает чуть ниже).
Создадим еще одну структуру:
$STRUCT_INDEX_RATINGS = [
'field_id' => [
'type' => 'bignum', // big int
'auto' => true, // auto increment
'length' => 255
],
'field_user_id' => [
'type' => 'bignum', // big int
'length' => 255,
'fill' => true
],
'field_index_id' => [
'type' => 'bignum', // big int
'length' => 255,
'fill' => true,
'index' => [
'type' => 'simple'
]
],
'field_rate' => [
'type' => 'minnum', // big int
'length' => 1,
'fill' => true
]
];
Таблица очень простая. Она хранит ID ресурса, ID пользователя и оценку.
Давайте зарегистрируем структуры в схеме базы данных:
Таблицы сформированы и описаны и нам осталось выполнить SBQ через API RevolveR CMF для создания этих таблиц в базе данных:
// Create table index
$dbx::query('c', 'revolver__index', $STRUCT_INDEX);
// Create table index
$dbx::query('c', 'revolver__index_ratings', $STRUCT_INDEX_RATINGS);
После выполнения этого кода в базе данных появится таблицы revolver__index и revolver__index_ratings, а мы сможем использовать API моделей для работы с ними.
Регистрируем сервис индексации и страницу поиска
В RevolveR CMF есть такое понятие как сервисы. Они используются для выполнения каких-то задач при обращении к ним с аргументами, но не имеют кэширования и не обрабатываются шаблоном.
Здесь все предельно просто. Type service указывает на то, что URL /picker/ будет служить обработчиком запросов, которые избегают систему кэширования фреймворка и игнорируют формирование шаблона.
Теперь сразу же зарегистрируем путь, который будет отображать страницу выполнения поисковых запросов к базе данных. Для этого в этом же файле добавим строки:
Параметр menu указывает на то, что мы отображаем пункт в главном меню, а type равное node указывает на то, что регистрируемый путь является узлом, который подвергается кэшированию по умолчанию и может быть подключен к шаблону.
Мы зарегистрировали 2 URI и теперь нужно подключить обработчики сервиса и узла. Поскольку было решено сделать Pick компонентом ядра, мы модернизируем файл /Kernel/Modules/Switch.php:
case '#pick':
ob_start('ob_gzhandler');
// Search
require_once('./Kernel/Nodes/NodePick.php');
break;
case '#picker':
ob_start('ob_gzhandler');
// Search
require_once('./Kernel/Routes/RoutePicker.php');
break;
Этими строками мы создали подключение NodePick и RoutePicker, которые будут содержать основные исходные коды алгоритмов поискового движка. Нам достаточно всего 2 файла.
Индексатор URL Picker
Чтобы проиндексировать какой либо сайт мы должны иметь доступ по сети и уметь парсить сайты. Для этого была использована стандартная библиотека cURL для PHP.
Вот исходный код функции, которая открывает URL и достает содержимое страницы:
Работает алгоритм очень просто. При передаче URL происходит открытие web-страницы и обработчик проверяет корректность SSL соединения. Далее мы смотрим что тип документа характеризует ценные для нас данные HTML или Application xHTML, а также проверяем код ответа сервера. Все, что препятствует получению данных приводит к возврату значения null.
Дополнительно проверяем, что отдаваемый сервером контент может быть сжатым в gzip, deflate или compress.
Теперь нам нужна функция для работы с самим полученным документом. Мы должны извлечь текстовое содержимое без тегов и получит все ссылки на странице:
Здесь вы могли заметить еще две вспомогательные функции. Одна из них, getMetaTags(), извлекает из HTML содержимого все мета теги, а другая, getHost(), распаковывает URL и возвращает host.
Исходный код функций получения meta тегов и хоста:
При этом алгоритм рассчитан таким образом, что превращает все относительные ссылки документа в абсолютные и фильтрует бесполезные ссылки содержащие хэш фрагменты.
Мы собираем только ссылки на этот же ресурс для того, чтобы crawler не убежал слишком далеко, а корректно закончил индексацию всего ресурса.
Поддержка Robots.txt
Не все ссылки бывают полезны и не все страницы несут какую либо смысловую нагрузку. Чтобы профильтровать информацию добавим поддержку подгрузки файла robots.txt:
function getRobotsTxt(string $url): ?iterable {
// location of robots.txt file, only pay attention to it if the server says it exists
$hrobots = curl_init($url .'/robots.txt');
curl_setopt($hrobots, CURLOPT_RETURNTRANSFER, TRUE);
$response = curl_exec($hrobots);
$httpCode = curl_getinfo($hrobots, CURLINFO_HTTP_CODE);
if( (int)$httpCode === 200 ) {
$robots = explode("\n", $response);
}
else {
$robots = null;
}
curl_close($hrobots);
return array_filter(
preg_replace([
'/#.*/m', // 1 :: trim single lines comments exclude quoted strings
'!\s+!', // 2 :: trim multiple spaces
'/ /' // 3 :: trim tabulations
],
[
'',
' ',
''
], $robots
)
);
}
Загружаем мы robots.txt только единожды за проход и сохраняем полученный массив правил в переменную:
$robotstxt = getRobotsTxt($url);
Далее нам понадобится обработчик правил robots.txt. Для этого используем функцию:
function indexingAllowed(?iterable $robots, string $xurl): ?bool {
if( !$robots ) {
return null;
}
// Parse url to retrieve host and path
$parsed = parse_url($xurl);
$rules = [];
$ruleApplies = null;
foreach( $robots as $line ) {
// Following rules only apply if User-agent matches $useragent or '*'
if( preg_match('/^\s*User-agent: (.*)/i', $line, $match) ) {
$ruleApplies = preg_match('/(\*)/i', $match[1]);
continue;
}
if( $ruleApplies ) {
list($type, $rule) = explode(':', $line, 2);
$type = trim(strtolower($type));
// add rules that apply to array for testing
$rules[] = [
'type' => $type,
'match' => preg_quote(trim($rule), '/')
];
}
}
$isAllowed = true;
$currentStrength = 0;
foreach( $rules as $rule ) {
// Check if page hits on a rule
if( preg_match("/^{$rule['match']}/", $parsed['path']) ) {
// Prefer longer (more specific) rules and Allow trumps Disallow if rules same length
$strength = strlen($rule['match']);
if( $currentStrength < $strength ) {
$currentStrength = $strength;
$isAllowed = $rule['type'] === 'allow' ? true : null;
}
else if( $currentStrength === $strength && $rule['type'] === 'allow' ) {
$currentStrength = $strength;
$isAllowed = true;
}
}
}
return $isAllowed;
При передаче аргумента $xurl происходит сверка с правилами robots.txt и функция возвращает либо true либо null, что символизирует разрешение на добавление в базу данных.
Обработка индекса
Чтобы базу индекса могли индексировать только администраторы и писатели ресурса мы обернем код в проверку роли и добавим фильтр запроса. Черпать аргумент будем из контроллера переменных SV['g'].
Таким образом мы получаем значение host из GET запроса и можем приступить к созданию поискового индекса.
Обработчик индекса поисковой базы
Изначально мы делаем запрос с проверкой наличия искомого URL в базе данных. Если индекс уже существует — просто выясняем свежий ли он, а если его нет, то запишем результат в базу данных. Попутно мы делаем запрос к robots.txt, распаковываем ссылки и метаданные из документов.
Модель SET использует автоматическое чтение схемы БД из SBQ и выполняет запрос записи или обновления автоматически.
Алгоритм использует timeout .5 секунды между запросами по ссылкам и не нагружает ресурсы, когда происходит сканирование.
Стоит обратить внимание на hash. В данном случае мы сначала распаковываем тело документа, а затем избавляемся от всех тегов. MD5 полученного текста мы будем использовать для проверки актуальности данных.
Если страницы изменялись, то алгоритм подметит это при проверке:
$hash = md5($xmeta_data['text']);
if( $hash !== $testIndex['hash'] ) {
// Intelligent update when uri exist and expired
$model::erase('index', [
'criterion' => 'uri::'. $uri
]);
// обновляем
}
Для того, чтобы не загружать заново обработанные в процессе прохода ссылки мы передаем аргумент &$indexed по ссылке и на каждую итерацию заполняет глобальный массив ссылками при этом проверяя, что url нет в списке.
Выполняем поисковые запросы
Обладая собственным индексом мы можем приступить к созданию самого сервиса поиска. Для этого мы применим экспертную модель работающую на основе SBQ:
Теперь наша форма работает и умеет передавать пост параметр динамически используя fetch запрос, а каптча предотвращает перегрузку и генерацию запросов ботами.
Алгоритм ранжирования
Сначала мы отсортируем результаты по рейтингу, а дальше перетасуем их в пределах своей цифры рейтинга:
// Sort results by rating
ksort($results);
foreach( array_reverse($results) as $r ) {
shuffle( $r ); // randomize positions
foreach( $r as $s ) {
$output .= $s;
}
}
Пишем обработку сниппета
Нам осталось передать поля выбранные предварительным регулярным выражением из базы данных и генерировать сниппет поисковой выдачи.
Мы будем выбирать фрагмент из текста и помечать совпадение запросу:
Здесь пришлось повозиться. Простой подход совсем не подразумевал, что PHP начнет обрабатывать UTF-8 корректно, но я смог добиться работы с русским и английским языками.
Это обычный список возможностью выбора одного из 5ти вариантов голосования по шкале звезд. Голосовать мы предоставим возможность только зарегистрированным пользователям не более одно раза за ссыку, что исключит факт накрутки.
Сам JavaScript для обработки голоса находится в файле /Interface/interface.js и он также подключен к другим материалам подвергаемым голосованию(новости, страницы блога, страницы форума, комментарии и так далее).
Отдельно обратим внимание на обработку голосования. В Revolver CMF уже есть функциональность для голосования и она располагается в сервисе в файле /Kernel/Routes/RouteRating.php.
Нам нужно просто добавить HTML разметку хэндлера для, которая будет инициализировать по клику функцию голосования:
Это обычный список возможностью выбора одного из 5ти вариантов голосования по шкале звезд. Голосовать мы предоставим возможность только зарегистрированным пользователям не более одно раза за ссыку, что исключит факт накрутки.
Сам JavaScript для обработки голоса находится в файле /Interface/interface.js и он также подключен к другим материалам подвергаемым голосованию (новости, страницы блога, страницы форума, комментарии и так далее).
Отдельно обратим внимание на обработку голосования. В Revolver CMF уже есть функциональность для голосования и она располагается в сервисе в файле /Kernel/Routes/RouteRating.php.
Handler голосования автоматически подключается к fetch, а нам осталось только добавить параметр $tpe и прописать таблицу для которой устанавливаются голоса:
В будущем, в Revolver CMF будет интегрирована опция связывания индексов и поисковая база расшириться результатами других инсталляций.
Это мне кажется идеально. Во первых, пользователи сами решают какие сайты индексировать, а во вторых положение в поисковой выдаче — это продукт оценки живых людей, которые выполняют поисковые запросы.
Выдачи с разных сайтов могут отличаться и выдача будет формироваться на основе рейтингов разных включенных в индекс ресурсов.
Здесь найдется и место для нейронной сети, чтобы было интереснее и круче.
Запросы будут монетизироваться. Стоимость использования внешнего индекса будет определяться мощностью поисковой базы (размером тематического индекса) и частотой запросов. Также есть мысли о создании собственной валюты (не крипто), которую можно будет приобретать и выводить через основной сайт проекта Pick.
Скачать дистрибутив RevolveR CMF с поисковой системой Pick можно со страницы проекта GitHub.
Сейчас индекс поиска официального сайта почти пустой, но протестировать поисковую систему можно здесь.
Puter — интернет-ОС с открытым исходным кодом, предлагающая работу через браузер и возможность самохостинга. Проект, начавшийся как личная инициатива, уже собрал большое сообщество разработчиков. Puter обеспечивает легковесность, гибкость и интуитивно понятный интерфейс.