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

Pick Networks

Название Pick было созвучно всем известной компании Picus Networks из мира компьютерной игры DeusEx. Индексатор поисковой базы был в чистом виде без интеграции в систему составляет всего 333 строчки кода PHP, а его формирующий результаты поиска компонент поисковой выдачи составляет еще 157 строчек кода.

Таким образом весь поисковый движок Pick помещается в двух файлах и занимает 590 строк PHP программы и время на его создание равняется примерно двум дням.



Как создавался Pick



Можно было реализовать поисковую систему отдельно, но я использовал framework, который предоставляет доступ к API работы с базой данных и ее кэширование, обработку POST и GET запросов с защитой, а также fetch API для динамических запросов.

Создаем индекс в базе данных:



Очевидно, что нам нужен свой поисковый индекс, который будет храниться в базе данных. Для этого сформируем структуру на 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' => 50,
		'fill'      => true,

		'index'	 => [

			'type' => 'simple'

		]

	],

	'field_date' => [

		'type'   => 'text', // varchar
		'length' => 10,
		'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(это поможет делать быстрый поиск по всем индексированным ссылкам определённого ресурса).

Поле uri будет содержать полную ссылку страницы, а поле date будет хранить дату индексации страницы.

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

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


Таблица сформирована и нам осталось выполнить SBQ через API RevolveR CMF:

// Create table index
$dbx::query('c', 'revolver__index', $STRUCT_INDEX);


После выполнения этого кода в базе данных появится таблица revolver__index и мы сможем использовать API моделей для работы с ней.

Регистрируем сервис индексации и страницу поиска



В RevolveR CMF есть такое понятие как сервисы. Они используются для выполнения каких-то задач при обращении к ним с аргументами, но не имеют кэширования и не обрабатываются шаблоном.

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

// serarch 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_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_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;

      }

      if( $data && $ok ) {

        curl_close($ch);

        return [ $data,  $i['fileatime'] ];

      } 
      else {

        curl_close($ch);

        return [ null, null ];

      }

    } 
    else {

      curl_close($ch);

      return [ null, null ];

    }

  }


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

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

  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.*?href=["\'](.*?)["\'].*?>/i', $html, $meta_links);

    foreach( $meta_links[1] 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['path'];

        }

        $host_links[] = $xlnk;

      }

    }

    return [

      'title' => $meta_title[1][0],
      'meta'  => getMetaTags($html),
      'href'  => array_unique($host_links),
      'text'  => trim(

                  html_entity_decode(

                    preg_replace([

                        '/<script\b[^>]*>(.*?)<\/script>/si',
                        '/<style\b[^>]*>(.*?)<\/style>/si',
                        '/<.+?>/mi', 
                        '/<a[^>]+\>/i', 
                        '/\s*$^\s*/m', 
                        '/[\r\n]+/', 
                        '/\s+/'

                      ], 
                      
                      [
                        '',
                        '',
                        '',
                        '',
                        "\n",
                        "\n",
                        ' '

                      ], $meta_body)[0]

                  )

      ),

      'body'  =>  $meta_body

    ];

  }


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

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

Мы собираем только ссылки на этот же ресурс для того, чтобы crawler не убежал слишком далеко, а корректно закончил индексацию всего ресурса.

Обработка индекса



Чтобы базу индекса могли индексировать только администраторы и писатели ресурса мы обернем код в проверку роли и добавим фильтр запроса. Черпать аргумент будем из контроллера переменных 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 в базе данных. Если индекс уже существует — просто выясняем свежий ли он, а если его нет, то запишем результат в базу данных:

  $xdata = getUri($url)[0]; // todo :: test file-a-time [1]

  if( $xdata ) {

    $meta_data = parse(

      $xdata, $url

    );

    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( date('d-m-Y') !== $testIndex['date'] ) {

          $udata = getUri($uri)[0]; // todo :: test file-a-time [1]

          if( $udata ) {

            $xmeta_data = parse(

              $udata, $uri

            );

            // Intelligent update when uri exist and expired
            $model::set('index', [

              'uri'         => $uri,
              'host'        => getHost($url, $url),
              'date'        => date('d-m-Y'),
              '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'

            ]);

          }

        }

      } 
      else {

        $udata = getUri($uri)[0]; // todo :: test file-a-time [1]

        if( $udata ) {

          $xmeta_data = parse(

            $udata, $uri

          );

          // Intelligent insert when uri not indexed
          $model::set('index', [

            'id'          => 0,
            'uri'         => $uri,
            'host'        => getHost($url, $url),
            'date'        => date('d-m-Y'),
            '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'],

          ]);

        }

      }

      sleep(3);

    }

  }



После запили основной страницы, с которой начинается индексация, происходит обработка всех 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),
              'date'        => date('d-m-Y'),
              '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 и выполняет запрос записи или обновления автоматически.

Алгоритм использует tiemout 3 секунды между запросами по ссылкам и не нагружает ресурсы, когда происходит сканирование.

Выполняем поисковые запросы



Обладая собственным индексом мы можем приступить к созданию самого сервиса поиска. Для этого мы применим экспертную модель работающую на основании SBQ:

      // Picking results
      $results = [];

      // Index picking
      foreach( iterator_to_array(

        $model::get( 'index', [

          'criterion' => 'content::'. $qs,

          'bound'   => [

            5000,   // limit

          ],

          'course'  => 'backward', // backward
          'expert'  => true,
          'sort'    => 'id'

        ])

      )['model::index'] as $k => $v ) {

        if( preg_match('/'. $qs .'/i', $v['content']) ) {

          $results[] = search( $qs, $v );

        }

      }


Здесь мы не используем классический LIKE MySQL запрос, а применяет RegExp поиска по базе данных. Поскольку двигатель DBX обладает функцией кэширования мы можем совершенно не волноваться за нагрузку. Повторные запросы выборки будут получать данные из статических файлов.

Сам аргумент 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 запрос, а капча предотвращает перегрузку и генерацию запросов ботами.

Форма запроса

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



Поскольку у нас нет вообще ни каких исходных данных о качестве информации, мы изначально просто перетасуем полученные результаты:

      // Shuffle results
      shuffle($results);

      foreach( $results as $r ) {

        $output .= $r;

      }


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



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

      function search( string $qs, iterable $v ): string {

        $ptitle = htmlspecialchars_decode($v['title']);
        $pdescr = htmlspecialchars_decode($v['description']);

        if( $pdescr === 'null' ) { // use short snippet of content as description

          $pdescr = preg_replace("#[^а-яА-ЯA-Za-z:;._,? -]+#u", '', substr(

            html_entity_decode(

                $v['content']

            ), 0, 100)

          ) .'...';

        } 
        else {

          $pdescr = preg_replace("#[^а-яА-ЯA-Za-z:;._,? -]+#u", '', substr(

            html_entity_decode(

                $pdescr

            ), 0, 100) .'...'

          );

        }

        $output  = '<li>';

        $output .= '<a target="_blank" href="'. $v['uri'] .'" title="'. $pdescr .'">';
        $output .= str_ireplace( $qs, '<mark>'. $qs .'</mark>', $ptitle) .'</a>';

        $output .= '<em>'. (isset($v['time']) ? $v['time'] : date('d-m-Y h:i')) .'</em>';

        $output .= '<span>'. str_ireplace( $qs, '<mark>'. $qs .'</mark>', $pdescr ) .'</span>';

        $replace = trim(

          preg_replace(

            ['/ +/', '/~\w*~/', '/<[^>]*>/' ],

            [' ', ' ', ''],

            str_replace(

              [ ' ', "\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("#[^а-яА-ЯA-Za-z:;._,? -]+#u", '', $highlight_1) . '<mark>'. $qs .'</mark>'. preg_replace("#[^а-яА-ЯA-Za-z:.;_,? -]+#u", '', $highlight_2) .' ...</dfn></li>';

        return $output;

      }


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

image

Будущее Pick



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

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

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

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

Вижу это так. Мы будем отслеживать все установки, а в настройках появится опция подключения к другим ресурсам. Запрос подтверждения будет неотъемлемой частью согласия связывания одного индекса с другим.

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

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

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