Обложка статьи «Как и зачем я создал свой поисковик Pick: история создания и примеры кода»

Как и зачем я создал свой поисковик Pick: история создания и примеры кода

Дмитрий Мальцев

Дмитрий Мальцев

ведущий 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:

$STRUCT_INDEX = [
            'field_id' => [
                        'type'   => 'bignum', // bigint
                        'auto'    => true,
                        'length' => 255,
                        'fill'   => true
            ],
            'field_uri' => [
                        'type'   => 'text', // varchar
                        'length' => 1000,
                        'fill'   => true
            ],
            'field_host' => [
                        'type'   => 'text', // varchar
                        'length' => 200,
                        'fill'   => true,
                        'index'  => [
                                    'type' => 'simple'
                        ]
            ],
            'field_date' => [
                        'type'   => 'text', // varchar
                        'length' => 20,
                        'fill'   => true
            ],
            'field_hash' => [
                        'type'   => 'text', // varchar
                        'length' => 50,
                        'fill'   => true
            ],
            'field_title' => [
                        'type'   => 'text', // varchar
                        'length' => 600,
                        'fill'   => true,
                        'index'  => [
                                    'type' => 'full'
                        ]
            ],
            'field_description' => [
                        'type'   => 'text', // varchar
                        'length' => 250,
                        'fill'   => true,
                        'index'  => [
                                    'type' => 'full'
                        ]
            ],
            'field_content' => [
                        'type'   => 'text', // varchar
                        'length' => 9000,
                        'fill'   => true,
                        'index'  => [
                                    'type' => 'full'
                        ]
            ]
];

Мы создали структуру будущей таблицы 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 пользователя и оценку.

Давайте зарегистрируем структуры в схеме базы данных:

// Compare DBX Schema
$DBX_KERNEL_SCHEMA = [
        ...
	// Pick index
	'index'                => $STRUCT_INDEX,
	'index_ratings'	  => $STRUCT_INDEX_RATINGS,

Таблицы сформированы и описаны и нам осталось выполнить 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 есть такое понятие как сервисы. Они используются для выполнения каких-то задач при обращении к ним с аргументами, но не имеют кэширования и не обрабатываются шаблоном.

Чтобы зарегистрировать сервис индексации просто пропишем параметры в файл /private/config.php:

// search engine crawler
'picker' => [
	'title' => 'Search engine crawler',
	'param_check' => [
		'menu'    => 0,
		'hidden'  => 1
	],
	'route'  => '/picker/',
	'node'   => '#picker',
	'type'   => 'service',
	'id'     => 'picker',
],

Здесь все предельно просто. Type service указывает на то, что URL /picker/ будет служить обработчиком запросов, которые избегают систему кэширования фреймворка и игнорируют формирование шаблона.

Теперь сразу же зарегистрируем путь, который будет отображать страницу выполнения поисковых запросов к базе данных. Для этого в этом же файле добавим строки:

// search engine service
TRANSLATIONS[ $ipl ]['Pick'] => [
	'title' => TRANSLATIONS[ $ipl ]['Pick'],
	'param_check' => [
		'menu'		=> 1
	],
	'route'  => '/pick/',
	'node'   => '#pick',
	'type'    => 'node',
	'id'	    => 'pick',
],

Параметр 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 и достает содержимое страницы:

function getUri(string $url): iterable {
	$ch = curl_init();
	curl_setopt($ch, CURLOPT_URL, $url);
	curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36 Picker/1.0');
	curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
	curl_setopt($ch, CURLOPT_AUTOREFERER, 1);
	curl_setopt($ch, CURLOPT_DNS_SHUFFLE_ADDRESSES, 1);
	curl_setopt($ch, CURLOPT_FAILONERROR, 1);
	curl_setopt($ch, CURLOPT_FILETIME, 1);
	curl_setopt($ch, CURLOPT_HEADER, 1);
	curl_setopt($ch, CURLOPT_FRESH_CONNECT, 1);
	curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
	curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 1);
	curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1);
	curl_setopt($ch, CURLOPT_TIMEOUT, 5);
	$data = curl_exec($ch);
	if( !curl_errno($ch) ) {
		$i = curl_getinfo($ch);
		$ssl_pass = (int)$i['ssl_verify_result']; 
		if( !(bool)$ssl_pass ) {
			$ok = true;
		}
		switch( $i['http_code'] ) {
			case 200:
			case 301:
			case 302:
				$ok = true;
				break;
			default:
				$ok = null;
				break;
		}
		switch( explode(';', $i['content_type'])[0] ) {
			case 'application/xhtml+xml':
			case 'text/html':
				$ok = true;
				break;
			default:
				$ok = null;
				break;
		}
		list($hdr, $body) = explode("\r\n\r\n", $data, 2);
		$headers = explode("\r\n", $hdr);
		$xh = [];
		foreach( $headers as $h ) {
			$r = explode(':', $h, 2);
			$xh[ $r[0] ] = trim($r[1]);
		}
		if( isset( $xh['Content-Encoding'] ) ) {
			switch( $xh['Content-Encoding'] ) {
				case 'gzip':
				case 'deflate':
				case 'compress':
					$data = gzuncompress($body);
					break;
			}
		}
		else {
			$data = $body;
		}
		if( isset( $xh['Date'] ) ) {
			$date = DateTime::createFromFormat('D, d M Y H:i:s O', $xh['Date']);
			$date = $date->format('h:i d-m-Y');
		}
		if( $data && $ok ) {
			curl_close($ch);
			return [ $data,  $date ];
		}
		else {
			curl_close($ch);
			return [ null, null ];
		}
	}
	else {
		curl_close($ch);
		return [ null, null ];
	}
}

Работает алгоритм очень просто. При передаче URL происходит открытие web-страницы и обработчик проверяет корректность SSL соединения. Далее мы смотрим что тип документа характеризует ценные для нас данные HTML или Application xHTML, а также проверяем код ответа сервера. Все, что препятствует получению данных приводит к возврату значения null.

Дополнительно проверяем, что отдаваемый сервером контент может быть сжатым в gzip, deflate или compress.

Теперь нам нужна функция для работы с самим полученным документом. Мы должны извлечь текстовое содержимое без тегов и получит все ссылки на странице:

function parse(string $html, string $url): ?iterable {
	$host_links = [];
	// Perform title
	preg_match_all('#<title>(.+?)</title>#su', $html, $meta_title);
	// Perform body
	preg_match('/<body[^>]*>(.*?)<\/body>/is', $html, $meta_body);
	// Perform links only for host
	preg_match_all("/<a\s[^>]*href\s*=\s*(["']??)([^"' >]*?)\1[^>]*>(.*)<\/a>/siU", $html, $prelinks, PREG_SET_ORDER);
	$meta_links = [];
	foreach( $prelinks as $plnk ) {
		$meta_links[] = $plnk[2]; // $plnk[3] - title
	}
	foreach( $meta_links as $l ) {
		$flnk = getHost($l, $url);
		if( getHost($url, $url) === $flnk ) {
			$lnk  = parse_url($l);
			$xlnk = parse_url($url)['scheme'] .'://'. getHost($url, $url);
			if( isset($lnk['path']) ) {
				$xlnk .= $lnk['path'];
			}
			if( isset($lnk['query']) ) {
				$xlnk .= '?'. $lnk['query'];
			}
			$host_links[] = $xlnk;
		}
	}
	$usefull_text = trim(
		html_entity_decode(
			preg_replace([
					'/<.+?>/mi', 
					'/\s*$^\s*/m', 
					'/[\r\n]+/', 
					'/\s+/',
					'/&(quot|#34);/i',
					'/&(amp|#38);/i',
					'/&(lt|#60);/i',
					'/&(gt|#62);/i',
					'/&(nbsp|#160);/i',
					'/&(iexcl|#161);/i',
					'/&(cent|#162);/i',
					'/&(pound|#163);/i',
					'/&(copy|#169);/i',
				], 
				[
					'',
					"\n",
					"\n",
					' ',
					'"',
					'&',
					'<',
					'>',
					' ',
					chr(161),
					chr(162),
					chr(163),
					chr(169),
				], 
				preg_replace(
					[
						'/\s?<style[^>]*?>.*?<\/style>\s?/si',
						'/\s?<script[^>]*?>.*?<\/script>\s?/si',
						'/\s?<a[^>]*?>.*?<\/a>\s?/si',
						'/<(header|footer|time).+?(style|script|header|footer|time)>/miU',
						'/\s?<nav[^>]*?>.*?<\/nav>\s?/si',
						'/\s?<form[^>]*?>.*?<\/form>\s?/si',
						'/<!--(.|\s)*?-->/', 
						'/s(w+s)1/i', 
						'#(\.|\?|!|\(|\)){3,}#', 
						'/"b([^"x84x93x94rn]+)b"/'
					], 
					[
						'',
						'',
						'',
						'',
						'',
						'',
						'', 
						'', 
						'', 
						'$1', 
						'\1\1\1', 
						'«1»'
					], $meta_body)
			)[0]
		)
	);
	if( strlen( $usefull_text) >= 120 ) {
		return [
			'title' => $meta_title[1][0],
			'meta'  => getMetaTags($html),
			'href'  => array_unique($host_links),
			'text'  => $usefull_text,
			'body'  =>  $meta_body
		];
	}
	else {
		return null;
	}
}

Здесь вы могли заметить еще две вспомогательные функции. Одна из них, getMetaTags(), извлекает из HTML содержимого все мета теги, а другая, getHost(), распаковывает URL и возвращает host.

Исходный код функций получения meta тегов и хоста:

function getMetaTags(string $str): iterable {
	$pattern = '~<\s*meta\s
	# using lookahead to capture type to $1
		(?=[^>]*?
		\b(?:name|property|http-equiv)\s*=\s*
		(?|"\s*([^"]*?)\s*"|'\s*([^']*?)\s*'|
		([^"'>]*?)(?=\s*/?\s*>|\s\w+\s*=))
	)
	# capture content to $2
	[^>]*?\bcontent\s*=\s*
		(?|"\s*([^"]*?)\s*"|'\s*([^']*?)\s*'|
		([^"'>]*?)(?=\s*/?\s*>|\s\w+\s*=))
	[^>]*>
	~ix';
	if( preg_match_all($pattern, $str, $out) ) {
		return array_combine( $out[1], $out[2] );
	}
	return [];
}
function getHost(string $uri, string $url): ?string {
	$segments = parse_url(
		str_ireplace('.www', '', $uri)
	);
	$r = null;
	if( isset($segments['host']) ) {
		$r = $segments['host'];
	} 
	else {
		$r = parse_url(
		str_ireplace('.www', '', $url)
	);
		$r = $r['host'];
	}
	return $r;
}

При этом алгоритм рассчитан таким образом, что превращает все относительные ссылки документа в абсолютные и фильтрует бесполезные ссылки содержащие хэш фрагменты.

Мы собираем только ссылки на этот же ресурс для того, чтобы 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'].

if( isset(SV['g']['host']) && in_array(ROLE, ['Admin', 'Writer']) ) {
    $url = filter_var('https://'. SV['g']['host']['value'], FILTER_VALIDATE_URL);
    // исходник паука
}

Таким образом мы получаем значение host из GET запроса и можем приступить к созданию поискового индекса.

Обработчик индекса поисковой базы

Изначально мы делаем запрос с проверкой наличия искомого URL в базе данных. Если индекс уже существует — просто выясняем свежий ли он, а если его нет, то запишем результат в базу данных. Попутно мы делаем запрос к robots.txt, распаковываем ссылки и метаданные из документов.

Отвечает за это следующая функция:

function setIndex( ?iterable $robotstxt, string $url, Model $model, iterable &$indexed ) {
	if( !in_array($url, $indexed) ) {
	$indexed[] = $url;
	$info = getUri($url);
	$xdata = $info[0];
	$xdate = $info[1];
	if( $xdata ) {
		$meta_data = parse(
		$xdata, $url
		);
		if( $meta_data ) {
			foreach( $meta_data['href'] as $uri ) {
				$testIndex = iterator_to_array(
				$model::get('index', [
						'criterion' => 'uri::'. $uri,
						'course'    => 'backward',
						'sort'      => 'id'
					])
				)['model::index'];
				if( $testIndex ) {
					$testIndex = $testIndex[0];
					if( indexingAllowed( $robotstxt, $uri) ) {
						if( !in_array($uri, $indexed) ) {
							$xinfo = getUri($uri);
							$udata = $xinfo[0];
							$udate = $xinfo[1];
							if( $udata ) {
								$xmeta_data = parse(
									$udata, $uri
								);
				if( $xmeta_data ) {
					$hash = md5($xmeta_data['text']);
						$double_check = iterator_to_array(
							$model::get('index', [
									'criterion' => 'hash::'. $hash,
									'course'    => 'forward',
									'sort'      => 'id'
								])
							)['model::index'];
						$adate = date('d-m-Y');
						$idate = explode(' ', $testIndex['date'])[1]; 
						if( $hash !== $testIndex['hash'] && !$double_check ) {
							if( $adate !== $idate ) {
								// Intelligent update when uri exist and expired
								$model::erase('index', [
									'criterion'   => 'uri::'. $uri 
								]);
								// Intelligent update when uri exist and expired
								$model::set('index', [
									'uri'         => $uri,
									'host'        => getHost($url, $url),
									'hash'        => $hash,
									'date'		  => $udate,
									'title'       => $xmeta_data['title'],
									'description' => $xmeta_data['meta']['og:description'] ?? $xmeta_data['meta']['description'] ?? 'null',
									'content'     => $xmeta_data['text'],
									'criterion'   => 'uri'
								]);
							}
							foreach( $xmeta_data['href'] as $xlnk ) {
								if( indexingAllowed( $robotstxt, $uri) ) {
									setIndex( $robotstxt, $uri, $model, $indexed );
								}
							}
						}
				}
							}
						}
					}
				} 
				else {
					if( indexingAllowed( $robotstxt, $url) ) {
						if( !in_array($uri, $indexed) ) {
							$xinfo = getUri($uri);
							$udata = $xinfo[0];
							$udate = $xinfo[1];
							if( $udata ) {
								$xmeta_data = parse(
									$udata, $uri
								);
				if( $xmeta_data ) {
					$hash = md5($xmeta_data['text']);
					$double_check = iterator_to_array(
						$model::get('index', [
								'criterion' => 'hash::'. $hash,
								'course'    => 'forward',
								'sort'      => 'id'
							])
						)['model::index'];
					if( !$double_check ) {
						// Intelligent insert when uri not indexed
						$model::set('index', [
							'id'          => 0,
							'uri'         => $uri,
							'host'        => getHost($url, $url),
							'date'		  => $udate,
							'hash'        => $hash,
							'title'       => $xmeta_data['title'],
							'description' => $xmeta_data['meta']['og:description'] ?? $xmeta_data['meta']['description'] ?? 'null',
							'content'     => $xmeta_data['text'],
						]);
						foreach( $xmeta_data['href'] as $xlnk ) {
							if( indexingAllowed( $robotstxt, $uri) ) {
								setIndex( $robotstxt, $uri, $model, $indexed );
							}
						}
					}
				}
							}
						}
					}
				}
				sleep(.5);
			}
			}
		}
	}
}

После записи основной страницы, с которой начинается индексация, происходит обработка всех URL, которые она содержит. Здесь работают две модели:

$testIndex = iterator_to_array(
    $model::get('index', [
		'criterion' => 'uri::'. $uri,
		'course'    => 'backward',
		'sort'      => 'id'
	])
)['model::index'];

Модель GET проверяет наличие адреса в индексе.

// Intelligent update when uri exist and expired
$model::set('index', [
	'uri'         => $uri,
	'host'      => getHost($url, $url),
	'hash'     => $hash,
	'date'      => $udate,
	'title'       => $xmeta_data['title'],
	'description' => (isset( $xmeta_data['meta']['og:description'] ) ? $xmeta_data['meta']['og:description'] : (isset($xmeta_data['meta']['description']) ? $xmeta_data['meta']['description'] : 'null')),
	'content'     => $xmeta_data['text'],
	'criterion'   => 'uri'
]);

Модель 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:

// Picking results
$results = [];
// Index picking
foreach( iterator_to_array(
  $model::get( 'index', [
    'criterion' => 'content::'. $qs,
    'bound'   => [
      5000,   // limit
    ],
    'course'  => 'forward', // backward
    'expert'  => true,
    'sort'    => 'id'
  ])
)['model::index'] as $k => $v ) {
  if( preg_match('/'. $qs .'/i', $v['content']) ) {
    $rating = iterator_to_array(
        $model::get( 'index_ratings', [
          'criterion' => 'index_id::'. $v['id'],
          'course'    => 'forward',
          'sort'      => 'id'
        ])
      )['model::index_ratings'];
    $snippet = search( $qs, $v, $rating, $model );
    $results[ $snippet[0] ][] = $snippet[1];
  }
}

Здесь мы не используем классический LIKE MySQL запрос, а применяем RegExp поиска по базе данных.

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

Сам аргумент qs мы будем брать из контроллера переменных SV['p'] (стек POST запросов):

$query = null;
if( !empty(SV['p']) ) {
  if( isset(SV['p']['revolver_pick_query']) ) {
    if( (bool)SV['p']['revolver_pick_query']['valid'] ) {
      $query = SV['p']['revolver_pick_query']['value'];
    }
  }
  if( isset(SV['p']['revolver_captcha']) ) {
    if( (bool)SV['p']['revolver_captcha']['valid'] ) {
      if( $captcha::verify(SV['p']['revolver_captcha']['value']) ) {
        define('form_pass', 'pass');
      }
    }
  }
}

Также в этом коде происходит сверка значения captcha, которая усиливает надёжность и предотвращает спам запросы с удаленных серверов.

Сама форма строится с использованием Form API и ее структура (FS) выглядит следующим образом:

$form_parameters = [
  // main para
  'id'      => 'pick-query-box',
  'class'   => 'revolver__pick-query-box revolver__new-fetch',
  'method'  => 'post',
  'action'  => RQST,
  'encrypt' => true,
  'captcha' => true,
  'submit'  => 'Pick it',
  // included fieldsets
  'fieldsets' => [
    // fieldset contents parameters
    'fieldset_1' => [
      'title' => 'Pick query box',
      // wrap fields into label
      'labels' => [
        'label_1' => [
          'title'  => 'Query phrase',
          'access' => 'comment',
          'auth'   => 'all',
          'fields' => [
            0 => [
              'type'        => 'input:text',
              'name'        => 'revolver_pick_query',
              'placeholder' => 'Query phrase',
              'required'    => true
            ],
          ],
        ],
      ],
    ],
  ],
];

К форме подключен автоматический перевод заголовков полей и меток, а сама структура формы должна быть передана в CLASS:

// Construct Picks query box
$output .= $form::build( $form_parameters );

Теперь наша форма работает и умеет передавать пост параметр динамически используя fetch запрос, а каптча предотвращает перегрузку и генерацию запросов ботами.

Алгоритм ранжирования

Сначала мы отсортируем результаты по рейтингу, а дальше перетасуем их в пределах своей цифры рейтинга:

// Sort results by rating
ksort($results);
foreach( array_reverse($results) as $r ) {
  shuffle( $r ); // randomize positions
  foreach( $r as $s ) {
    $output .= $s;
  }
}

Пишем обработку сниппета

Нам осталось передать поля выбранные предварительным регулярным выражением из базы данных и генерировать сниппет поисковой выдачи.

Мы будем выбирать фрагмент из текста и помечать совпадение запросу:

function search( string $qs, iterable $v, ?iterable $crating, Model $model ): iterable {
  $ptitle = htmlspecialchars_decode($v['title']);
  $pdescr = htmlspecialchars_decode($v['description']);
  $rgxp = '#[^\p{L}:;._,? -]+#u';
  if( $pdescr === 'null' ) { // use short snippet of content as description
    $pdescr = preg_replace($rgxp, '', substr(
      html_entity_decode(
          $v['content']
      ), 0, 100)
    ) .'...';
  } 
  else {
    $pdescr = preg_replace($rgxp, '', substr(
      html_entity_decode(
          $pdescr
      ), 0, 100) .'...'
    );
  }
  /* Rating block */
  $crate = 0;
  if( $crating ) {
    foreach( $crating as $r => $rv ) {
      $crate += $rv['rate'];
    }
    $crate /= count( $crating ); 
  }
  else {
    $crating = [];
  }
  $output  = '<li>';
  $output .= '<a target="_blank" href="'. $v['uri'] .'" title="'. $pdescr .'">';
  $output .= str_ireplace( $qs, '<mark>'. $qs .'</mark>', $ptitle) .'</a>';
  $output .= '<em>'. (isset($v['date']) ? $v['date'] : date('d-m-Y h:i')) .'</em>';
  $output .= '<span>'. str_ireplace( $qs, '<mark>'. $qs .'</mark>', $pdescr ) .'</span>';
  $replace = trim(
    preg_replace(
      ['/ +/', '/~\w*~/', '/<[^>]*>/' ],
      [' ', ' ', ''],
      str_replace(
        [ '&nbsp;', "\n", "\r" ], 
        '',
        html_entity_decode(
          $v['content'], ENT_QUOTES, 'UTF-8'
        )
      )
    )
  );
  $snippet = preg_split('/'. $qs .'/i', $replace);
  $c = 1;
  foreach( $snippet as $snip ) {
    $length  = strlen( $snip ) * .3;
    $xlength = strlen( explode( $qs, $snip )[0] ); 
    if( $c % 2 !== 0 ) {
      $highlight_1 = substr( $snip, $xlength * .3, $xlength );
    }
    else {
      $highlight_2 = substr( $snip, 0, $length );
    }
    $c++;
  }
  $output .= '<dfn class="revolver__search-snippet">... '. preg_replace($rgxp, '', $highlight_1) . '<mark>'. $qs .'</mark>'. preg_replace($rgxp, '', $highlight_2) .' ...</dfn>';
  $tpe = 'index';
  $output .= '<div class="revolver-rating">';
  $output .= '<ul class="rated-'. floor($crate) .'" data-node="'. $v['id'] .'" data-user="'. USER['id'] .'" data-type="'. $tpe .'">';
  $output .= '<li data-rated="1">1</li>';
  $output .= '<li data-rated="2">2</li>';
  $output .= '<li data-rated="3">3</li>';
  $output .= '<li data-rated="4">4</li>';
  $output .= '<li data-rated="5">5</li>';
  $output .= '</ul>';
  $output .= '<span>'. floor($crate) .'</span> / <span>5</span> #<span class="closest">'. count($crating) .'</span>';
  $output .= '</div>';
  $output .= '</li>';
  return [ floor($crate), $output  ];
}

Здесь пришлось повозиться. Простой подход совсем не подразумевал, что PHP начнет обрабатывать UTF-8 корректно, но я смог добиться работы с русским и английским языками.

$output .= '<div class="revolver-rating">';
$output .= '<ul class="rated-'. floor($crate) .'" data-node="'. $v['id'] .'" data-user="'. USER['id'] .'" data-type="'. $tpe .'">';
$output .= '<li data-rated="1">1</li>';
$output .= '<li data-rated="2">2</li>';
$output .= '<li data-rated="3">3</li>';
$output .= '<li data-rated="4">4</li>';
$output .= '<li data-rated="5">5</li>';
$output .= '</ul>';
$output .= '<span>'. floor($crate) .'</span> / <span>5</span> #<span class="closest">'. count($crating) .'</span>';
$output .= '</div>';

Это обычный список возможностью выбора одного из 5ти вариантов голосования по шкале звезд. Голосовать мы предоставим возможность только зарегистрированным пользователям не более одно раза за ссыку, что исключит факт накрутки.

Сам JavaScript для обработки голоса находится в файле /Interface/interface.js и он также подключен к другим материалам подвергаемым голосованию(новости, страницы блога, страницы форума, комментарии и так далее).

setTimeout(() => {
    R.event('.revolver-rating li', 'click::lock', (e) => {
        e.preventDefault();
        let paramsBlock = e.target.closest('ul');
        let rateValue   = e.target.dataset.rated;
        let ratingType  = paramsBlock.dataset.type;
        
        if( !R.storage('rate-'+ ratingType +'-'+ paramsBlock.dataset.node, 'get') ) {
            R.removeClass(paramsBlock.querySelectorAll('li'), 'point');
            R.addClass([ e.target ], 'point');
            let data = new FormData();
            data.append( btoa('revolver_rating_node'), R.utoa( paramsBlock.dataset.node +'~:::~text~:::~'+ -1) );
            data.append( btoa('revolver_rating_user'), R.utoa( paramsBlock.dataset.user +'~:::~text~:::~'+ -1) );
            data.append( btoa('revolver_rating_value'), R.utoa( rateValue +'~:::~text~:::~'+ -1) );
            data.append( btoa('revolver_rating_type'), R.utoa( paramsBlock.dataset.type +'~:::~text~:::~'+ -1) );

            R.FormData = data;
            // Perform parameterized fetch request
            R.fetch('/rating-d/', 'POST', 'text', true, function() {
                R.storage('rate-'+ ratingType +'-'+ paramsBlock.dataset.node +'=1', 'set');
                R.FormData = null;
                console.log('Node rated :: '+ paramsBlock.dataset.node +'::'+ paramsBlock.dataset.user +'::'+ rateValue);
                setTimeout(() => {
                    R.fetchRoute(true);
                }, 1000);
            });
        } 
        else {
            console.log('You already rate node '+ paramsBlock.dataset.node);
        }
    });
}, 1000);

Отдельно обратим внимание на обработку голосования. В Revolver CMF уже есть функциональность для голосования и она располагается в сервисе в файле /Kernel/Routes/RouteRating.php.

Нам нужно просто добавить HTML разметку хэндлера для, которая будет инициализировать по клику функцию голосования:

$output .= '<div class="revolver-rating">';
$output .= '<ul class="rated-'. floor($crate) .'" data-node="'. $v['id'] .'" data-user="'. USER['id'] .'" data-type="'. $tpe .'">';
$output .= '<li data-rated="1">1</li>';
$output .= '<li data-rated="2">2</li>';
$output .= '<li data-rated="3">3</li>';
$output .= '<li data-rated="4">4</li>';
$output .= '<li data-rated="5">5</li>';
$output .= '</ul>';
$output .= '<span>'. floor($crate) .'</span> / <span>5</span> #<span class="closest">'. count($crating) .'</span>';
$output .= '</div>';

Это обычный список возможностью выбора одного из 5ти вариантов голосования по шкале звезд. Голосовать мы предоставим возможность только зарегистрированным пользователям не более одно раза за ссыку, что исключит факт накрутки.

Сам JavaScript для обработки голоса находится в файле /Interface/interface.js и он также подключен к другим материалам подвергаемым голосованию (новости, страницы блога, страницы форума, комментарии и так далее).

setTimeout(() => {
    R.event('.revolver-rating li', 'click::lock', (e) => {
        e.preventDefault();
        let paramsBlock = e.target.closest('ul');
        let rateValue   = e.target.dataset.rated;
        let ratingType  = paramsBlock.dataset.type;
        if( !R.storage('rate-'+ ratingType +'-'+ paramsBlock.dataset.node, 'get') ) {
            R.removeClass(paramsBlock.querySelectorAll('li'), 'point');
            R.addClass([ e.target ], 'point');
            let data = new FormData();
            data.append( btoa('revolver_rating_node'), R.utoa( paramsBlock.dataset.node +'~:::~text~:::~'+ -1) );
            data.append( btoa('revolver_rating_user'), R.utoa( paramsBlock.dataset.user +'~:::~text~:::~'+ -1) );
            data.append( btoa('revolver_rating_value'), R.utoa( rateValue +'~:::~text~:::~'+ -1) );
            data.append( btoa('revolver_rating_type'), R.utoa( paramsBlock.dataset.type +'~:::~text~:::~'+ -1) );
            R.FormData = data;
            // Perform parameterized fetch request
            R.fetch('/rating-d/', 'POST', 'text', true, function() {
                R.storage('rate-'+ ratingType +'-'+ paramsBlock.dataset.node +'=1', 'set');
                R.FormData = null;
                console.log('Node rated :: '+ paramsBlock.dataset.node +'::'+ paramsBlock.dataset.user +'::'+ rateValue);
                setTimeout(() => {
                    R.fetchRoute(true);
                }, 1000);
            });
        } 
        else {
            console.log('You already rate node '+ paramsBlock.dataset.node);
        }
    });
}, 1000);

Отдельно обратим внимание на обработку голосования. В Revolver CMF уже есть функциональность для голосования и она располагается в сервисе в файле /Kernel/Routes/RouteRating.php.

Handler голосования автоматически подключается к fetch, а нам осталось только добавить параметр $tpe и прописать таблицу для которой устанавливаются голоса:

case 'index':
        if( Auth ) {
          $model::set('index_ratings', [
            'index_id'    => $node,
            'user_id'     => $user,
            'rate'        => $value
          ]);
        }
        break;

Будущее Pick

В будущем, в Revolver CMF будет интегрирована опция связывания индексов и поисковая база расшириться результатами других инсталляций.

Это мне кажется идеально. Во первых, пользователи сами решают какие сайты индексировать, а во вторых положение в поисковой выдаче — это продукт оценки живых людей, которые выполняют поисковые запросы.

Выдачи с разных сайтов могут отличаться и выдача будет формироваться на основе рейтингов разных включенных в индекс ресурсов.

Здесь найдется и место для нейронной сети, чтобы было интереснее и круче.

Запросы будут монетизироваться. Стоимость использования внешнего индекса будет определяться мощностью поисковой базы (размером тематического индекса) и частотой запросов. Также есть мысли о создании собственной валюты (не крипто), которую можно будет приобретать и выводить через основной сайт проекта Pick.

Скачать дистрибутив RevolveR CMF с поисковой системой Pick можно со страницы проекта GitHub.

Сейчас индекс поиска официального сайта почти пустой, но протестировать поисковую систему можно здесь.