Статьи

Превращение просканированного сайта в поисковую систему с помощью PHP

В предыдущей части этого руководства мы использовали Diffbot для настройки обхода, который в конечном итоге собирал содержимое SitePoint в сбор данных, полностью доступный для поиска с помощью API поиска Diffbot. Мы также продемонстрировали эти возможности поиска, применив некоторые общие фильтры и перечислив результаты.

Diffbot Logo

В этой части мы создадим графический интерфейс, достаточно простой для того, чтобы обычный Джо мог его использовать, чтобы иметь относительно симпатичную, функциональную и легкую, но детализированную поисковую систему SitePoint. Более того, мы будем использовать не фреймворк, а всего три библиотеки для создания всего приложения.

Вы можете увидеть демонстрационное приложение здесь .

Это руководство является полностью автономным, и если вы решите следовать ему, вы можете начать со свежего экземпляра Homestead Improved . Обратите внимание, что для того, чтобы полностью использовать то, что мы создаем, вам нужна учетная запись Diffbot с функциями Crawljob и Search API.

Бутстрапирование

Двигаясь дальше, я предполагаю, что вы используете машину Vagrant. Если нет, выясните, почему вы должны , а затем вернуться.

На новой VM Homestead Improved процедура начальной загрузки выглядит следующим образом:

composer global require beelab/bowerphp:dev-master mkdir sp_search cd sp_search mkdir public cache template template/twig app composer require swader/diffbot-php-client composer require twig/twig composer require symfony/var-dumper --dev 

По порядку это:

  • устанавливает BowerPHP глобально, поэтому мы можем использовать его на всей виртуальной машине .
  • создает корневую папку проекта и несколько подпапок.
  • устанавливает PHP-клиент Diffbot , который мы будем использовать для выполнения всех вызовов API и для перебора результатов.
  • устанавливает шаблонизатор Twig , поэтому мы не повторяем HTML в PHP как крестьяне 🙂
  • устанавливает VarDumper в режиме разработки, поэтому мы можем легко отлаживать его во время разработки.

Чтобы загрузить «переднюю часть» нашего приложения, мы делаем следующее:

 cd public mkdir assets assets/{css,js,img} bowerphp install bootstrap bowerphp install normalize.css touch assets/css/main.css assets/js/main.js index.php token.php 

Я также использовал iconifier для создания некоторых значков и захватил большое изображение логотипа SitePoint для использования в качестве фона сайта, но это все совершенно необязательно.

Приведенные выше команды создают несколько папок и пустых файлов и устанавливают Bootstrap. Они также создают фронт-контроллер ( index.php ) нашего небольшого поискового приложения. Мы можем настроить этот файл так:

 <?php use SitePoint\Helpers\SearchHelper; use Swader\Diffbot\Diffbot; require_once '../vendor/autoload.php'; require_once '../token.php'; $loader = new Twig_Loader_Filesystem(__DIR__ . '/../template/twig'); $twig = new Twig_Environment($loader , array('cache' => false, 'debug' => true) ); $vars = []; // Get query params from request parse_str($_SERVER['QUERY_STRING'], $queryParams); // Check if the search form was submitted if (isset($queryParams['search'])) { $diffbot = new Diffbot(DIFFBOT_TOKEN); // Building the search string $string = ''; // Basics $search = $diffbot ->search($string) ->setCol('sp_search'); // Pagination // ... } echo $twig->render('home.twig', $vars); 

По сути, мы настраиваем Twig, берем содержимое $_GET и инициализируем поисковый вызов Diffbot (но никогда не выполняем его). Наконец, мы создаем файл template/twig/home.twig :

 Hello! 

Если вы попытаетесь запустить это «приложение» сейчас, вы должны увидеть «Привет». Вы также должны увидеть кешированную версию шаблона в папке cache . Обязательно сначала token.php файл token.php — ему нужно содержимое:

 <?php define('DIFFBOT_TOKEN', 'my_token'); 

Затем мы добавляем этот файл в файл проекта .gitignore . Не стесняйтесь использовать этот и обновлять его по мере необходимости. Поэтому мы случайно не передаем наш токен Diffbot на Github — украденный токен может стать очень дорогим.

Начальная загрузка сделана, теперь давайте перейдем к сути вещей.

Внешний интерфейс

Идея (на данный момент) состоит в том, чтобы иметь одно основное поле поиска, такое как Google, принимающее почти необработанные запросы API поиска, и три простых старых текстовых поля, в которые пользователи могут вводить значения, разделенные запятыми:

  • «Автор (ы)» будет поддерживать авторов. При вводе нескольких параметров будет выполняться поиск «ИЛИ» — например, статьи, написанные либо автором 1, либо автором 2, либо автором 3 и т. Д.
  • «Ключевые слова (любые)» будет искать любое из заданных ключевых слов в любом из полей, извлеченных Diffbot. Это включает тело, заголовок, мету, даже автора и т. Д
  • «Ключевые слова (все)» также ищет ключевые слова, но все они должны появляться в любом из извлеченных полей Diffbot.

Давайте обновим наш файл home.twig , созданный по home.twig HTML5.

 <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="x-ua-compatible" content="ie=edge"> <title>SitePoint Search</title> <meta name="description" content="Diffbot-powered SitePoint Search Engine"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="stylesheet" href="/bower_components/normalize.css/normalize.css"> <link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.min.css"/> <link rel="stylesheet" href="/assets/css/main.css"> </head> <body> <img src="/assets/img/sp_square.png" alt="" class="bg"/> <header> <h3>SitePoint <small>search</small> </h3> </header> <div class="content"> <!--[if lt IE 8]> <p class="browserupgrade">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p> <![endif]--> <div class="search-form"> <form id="main-form" class="submit-once"> <div class="main-search form-group"> <div class="input-group"> <input class="form-control" type="text" name="q" id="q" placeholder="Full search query"/> <span class="input-group-btn"> <button class="btn btn-default" type="button" data-toggle="modal" data-target="#examples-table">? </button> </span> </div> <a href="#" class="small detailed-search">>> Toggle Detailed searching</a> </div> <div class="detailed-search-group" style="display: none;"> <div class="form-group"> <label for="authorinput">Author(s): </label><input class="form-control" id="authorinput" name="authors" type="text" placeholder/> </div> <div class="form-group"> <label for="kanyinput">Keywords (any): </label><input class="form-control" id="kanyinput" name="keywords_any" type="text" placeholder="sitepoint, diffbot, whatever"/> </div> <div class="form-group"> <label for="kallinput">Keywords (all): </label><input class="form-control" id="kallinput" name="keywords_all" type="text" placeholder="sitepoint, diffbot, whatever"/> <a href="#" class="small detailed-search">>> Toggle Detailed searching</a> </div> </div> <div class="form-group"> <input id="submit" class="btn btn-default" type="submit" value="Search" name="search"/> </div> </form> {% include 'results.twig' %} </div> <script src="/bower_components/jquery/dist/jquery.min.js"></script> <script src="/bower_components/bootstrap/dist/js/bootstrap.min.js"></script> <script src="/assets/js/main.js"></script> {% include 'google-analytics.twig' %} </div> <footer> <a href="what.html">What's this all about?</a> <br>-<br> Built by <a href="https://twitter.com/bitfalls">@bitfalls</a> for <a href="http://sitepoint.com">SitePoint</a>. Hosted on <a href="http://bit.ly/do-ref">DigitalOcean</a>. </footer> {% include "modal-examples.twig" %} </body> </html> 

Обратите внимание, что я также извлек некоторые утомительные фрагменты HTML в вложенные шаблоны. К ним относятся отрывок из Google Analytics , модальные примеры с поисковыми запросами и, самое главное, шаблон результатов, который мы будем использовать для вывода результатов позже. Важны только результаты, поэтому создайте файл template/twig/results.twig , даже если он пуст или содержит только «Test». Остальные можно полностью удалить из шаблона home.twig или взять их из home.twig Github .

Давайте теперь добавим к этому немного магии флексбокса CSS, фоновых изображений и базовых jQuery-измов, чтобы элементы хорошо уживались. Например, мы используем класс формы, чтобы предотвратить двойную localStorage , и мы также используем localStorage чтобы запомнить, предпочитает ли пользователь подробный или регулярный поиск:

 // main.js $(document).ready(function () { $('form.submit-once').submit(function(e){ if( $(this).hasClass('form-submitted') ){ e.preventDefault(); return; } $(this).addClass('form-submitted'); $('#submit').addClass('disabled'); }); var dsg = $('.detailed-search-group'); var ms = $('.main-search'); if (localStorage.getItem('detailed-on') == "true") { dsg.show(); ms.hide(); } else { dsg.hide(); ms.show(); } $(".detailed-search").click(function (e) { ms.toggle(); dsg.toggle(); localStorage.setItem('detailed-on', dsg.is(':visible')); }); }); 
 /* main.css */ body { display: flex; min-height: 100vh; flex-direction: column; font-family: arial,sans-serif; } div.content { display: flex; flex: 1; align-items: center; justify-content: center; } div.content.what { max-width: 500px; margin: auto; } div.hidden { display: none; } div.search-form { width: 80%; } .results { max-width: 600px; font-size: small; } footer { padding: 1.5rem; background: #404040; color: #999; font-size: .85em; text-align: center; z-index: 1; } header { text-align: center; } img.bg { /* Set rules to fill background */ min-height: 100%; min-width: 1024px; /* Set up proportionate scaling */ width: 100%; height: auto; /* Set up positioning */ position: fixed; top: -60px; left: 0; z-index: -1000; opacity: 0.05; filter: alpha(opacity=5); } @media screen and (max-width: 1024px) { /* Specific to this particular image */ img.bg { left: 50%; margin-left: -512px; /* 50% */ } } 

и у нас есть наш базовый интерфейс (с «Test» из смоделированных results.twig ):

SitePoint Search GUI

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

Однако, нажав «Toggle Detail», ситуация изменится, и у нас появятся отдельные поля поиска, с помощью которых мы сможем получить более точные результаты. Давайте подключим эти поля сейчас.

Задний конец

Давайте изменим часть Building the search string в index.php следующим образом:

 // Building the search string $searchHelper = new \SitePoint\Helpers\SearchHelper(); $string = (isset($queryParams['q']) && !empty($queryParams['q'])) ? $queryParams['q'] : $searchHelper->stringFromParams($queryParams); 

В целях более чистого кода мы абстрагируем механику построения запросов в класс перебора SearchHelper.

 // [root]/app/helpers/SearchHelper.php <?php namespace SitePoint\Helpers; class SearchHelper { protected $strings = []; public function stringFromParams(array $queryParams) { $this->authorCheck($queryParams); $this->keywordCheck($queryParams); if (empty($this->strings)) { die("Please provide at least *some* search values!"); } return (count($this->strings) > 1) ? implode(' AND ', $this->strings) : $this->strings[0]; } protected function authorCheck(array $queryParams) { if (isset($queryParams['authors']) && !empty($queryParams['authors'])) { $authors = array_map(function ($item) { return 'author:"' . trim($item) . '"'; }, explode(',', $queryParams['authors'])); $this->strings[] = '(' . ((count($authors) > 1) ? implode(' OR ', $authors) : $authors[0]) . ')'; } } protected function keywordCheck(array $queryParams) { $kany = []; if (isset($queryParams['keywords_any']) && !empty($queryParams['keywords_any'])) { $kany = array_map(function ($item) { return trim($item); }, explode(',', $queryParams['keywords_any'])); } $kall = []; if (isset($queryParams['keywords_all']) && !empty($queryParams['keywords_all'])) { $kall = array_map(function ($item) { return trim($item); }, explode(',', $queryParams['keywords_all'])); } $string = ''; if (!empty($kany)) { $string .= (count($kany) > 1) ? '(' . implode(' OR ', $kany) . ')' : $kany[0]; } if (!empty($kall)) { $string .= ' AND '; $string .= (count($kall) > 1) ? implode(' AND ', $kall) : $kall[0]; } if (!empty($string)) { $this->strings[] = '(' . $string . ')'; } } } 

Метод stringFromParams вызывает некоторые вложенные методы, которые ищут некоторые предопределенные ключи массива в переданном массиве параметров и используют их для создания строки запроса, соответствующей API поиска. Для простоты я включил в этот урок только проверку автора и ключевых слов.

Естественно, нам нужно добавить пространство имен SitePoint\Helpers в Composer для автозагрузки:

 "autoload": { "psr-4": { "SitePoint\\Helpers\\": "app/Helpers/" } } 

После редактирования блока автозагрузки нам нужно обновить автозагрузчик с помощью composer dump-autoload .

Пока что у нас есть функциональность построения запросов и форма поиска.

Давайте проверим и посмотрим, получим ли мы некоторые результаты обратно.

В конце if (isset($queryParams['search'])) { index.php if (isset($queryParams['search'])) { поставьте следующее:

 dump($search->call()); dump($search->call(true)); 

Вводя diffbot в основное поле поиска, я действительно получаю 13 сообщений SitePoint назад:

Search results dump

Два аспекта нашего приложения все еще остаются:

  • получить эти данные хорошо напечатаны в шаблоне.
  • позволяет пользователям перемещаться по страницам, если возвращается более 20 результатов.

Выход

Чтобы получить правильный вывод, первое, что мы должны сделать, это присвоить данные переменным шаблона:

 // index.php modification - instead of the two `dumps` // Add to template for rendering $vars = [ 'results' => $search->call(), 'info' => $search->call(true) ]; 

Затем мы редактируем шаблон results.twig .

 <hr> <div class="results"> {% for article in results %} <div class="Media post"> <img class="Media-figure" src="{{ attribute(article.meta.og, 'og:image') is defined ? attribute(article.meta.og, 'og:image') : '/apple-touch-icon.png'}}" alt=""> <div class="Media-body"> <h3><a target="_blank" href="{{ article.pageUrl }}">{{ article.title }}</a></h3> <p class="author">Written by {{ article.author }}, published on {{ article.date|date("jS F, Y") }}</p> <p class="description">{{ article.meta.description }}</p> </div> </div> {% else %} <p>No results 🙁</p> {% endfor %} </div> 

Нам также нужно добавить стили медиа-объектов в наш CSS.

 .Media h3 { font-size: 18px; margin-top: 0; } .Media h3 a { text-decoration: none; color: #1a0dab; } .Media h3 a:visited { color: #609; } .Media h3 a:hover { text-decoration: underline; } .Media { display: flex; align-items: flex-start; width: 530px; } .Media.post { margin-bottom: 23px; } .Media-figure { margin-right: 1em; width: 50px; } .Media-body { flex: 1; } .Media .description { line-height: 1.4; word-wrap: break-word; color: #545454; } 

Вуаля. У нас есть базовая страница результатов поиска в стиле Google:

Search Results

Примечание . Некоторые результаты дублируются из-за различных активных ссылок, ведущих к одним и тем же ресурсам (перенаправлениям). Это временное ограничение API поиска, и его можно устранить, удалив дубликаты вручную, пока команда разработчиков Diffbot не добавит исправление.

пагинация

Чтобы добавить нумерацию страниц, нам нужно общее количество просмотров и количество результатов на странице. Если мы знаем текущую страницу, мы можем легко вычислить все остальное. Чтобы осуществить нумерацию страниц, мы делаем следующее.

Сначала мы редактируем home.twig , добавив следующий фрагмент кода под тегом results.twig :

 {% include 'pagination.twig' %} 

а затем создайте этот шаблон:

 {% if paginationData.pageCount > 1 %} <nav> <ul class="pagination"> {% if paginationData.currentPage != 1 %} <li><a href="/{{ qprw({ 'page': 1 }) }}">&laquo;&nbsp;First</a></li> {% else %} <li class="disabled"> <span>&laquo;&nbsp;{{ 'First' }}</span> </li> {% endif %} {% if paginationData.previousPage %} <li><a href="/{{ qprw({ 'page': paginationData.currentPage - 1 }) }}">&lsaquo;&nbsp; Previous</a></li> {% else %} <li class="disabled"> <span>&lsaquo;&nbsp;{{ 'Previous' }}</span> </li> {% endif %} {% for page in paginationData.pagesInRange %} {% if page != paginationData.currentPage %} <li> <a href="/{{ qprw({ 'page': page }) }}">{{ page }}</a> </li> {% else %} <li class="active"> <span>{{ page }}</span> </li> {% endif %} {% endfor %} {% if paginationData.nextPage %} <li><a href="/{{ qprw({ 'page': paginationData.currentPage + 1 }) }}">Next&nbsp;&rsaquo;</a></li> {% else %} <li class="disabled"> <span>{{ 'Next' }}&nbsp;&rsaquo;</span> </li> {% endif %} {% if paginationData.currentPage != paginationData.pageCount %} <li><a href="/{{ qprw({ 'page': paginationData.pageCount }) }}">Last ({{ paginationData.pageCount }})&nbsp;&raquo;</a></li> {% else %} <li class="disabled"> <span>{{ 'Last' }}&nbsp;&raquo;</span> </li> {% endif %} </ul> </nav> {% endif %} 

Первые два блока IF отображают ссылки на первую и последнюю страницы или показывают их как отключенные, если пользователь уже находится на первой странице. Цикл посередине проходит через ряд страниц и отображает их — несколько до текущей страницы и несколько после, также известную как «скользящая пагинация». Последние два блока отображают ссылки «следующая страница» и «последняя страница» соответственно.

Чтобы получить значения paginationData, которые использует этот шаблон, мы создадим еще один вспомогательный класс:

 // app/Helpers/PaginationHelper.php <?php namespace SitePoint\Helpers; use Swader\Diffbot\Entity\EntityIterator; use Swader\Diffbot\Entity\SearchInfo; class PaginationHelper { public function getPaginationData( $currentPage, $itemsPerPage, $pageRange, EntityIterator $res, SearchInfo $searchInfo ) { $paginationData = []; $paginationData['pageCount'] = !count($res) ? 0 : ceil($searchInfo->getHits() / $itemsPerPage); $paginationData['currentPage'] = ($paginationData['pageCount'] < $currentPage) ? $paginationData['pageCount'] : $currentPage; $paginationData['pageRange'] = ($pageRange > $paginationData['pageCount']) ? $paginationData['pageCount'] : $pageRange; $delta = ceil($paginationData['pageRange'] / 2); if ($paginationData['currentPage'] - $delta > $paginationData['pageCount'] - $paginationData['pageRange']) { $pages = range($paginationData['pageCount'] - $paginationData['pageRange'] + 1, $paginationData['pageCount']); } else { if ($paginationData['currentPage'] - $delta < 0) { $delta = $paginationData['currentPage']; } $offset = $paginationData['currentPage'] - $delta; $pages = range($offset + 1, $offset + $paginationData['pageRange']); } $paginationData['pagesInRange'] = $pages; $proximity = floor($paginationData['pageRange'] / 2); $paginationData['startPage'] = $paginationData['currentPage'] - $proximity; $paginationData['endPage'] = $paginationData['currentPage'] + $proximity; if ($paginationData['startPage'] < 1) { $paginationData['endPage'] = min($paginationData['endPage'] + (1 - $paginationData['startPage']), $paginationData['pageCount']); $paginationData['startPage'] = 1; } if ($paginationData['endPage'] > $paginationData['pageCount']) { $paginationData['startPage'] = max($paginationData['startPage'] - ($paginationData['endPage'] - $paginationData['pageCount']), 1); $paginationData['endPage'] = $paginationData['pageCount']; } $paginationData['previousPage'] = $paginationData['currentPage'] > 1; $paginationData['nextPage'] = $paginationData['currentPage'] < $paginationData['pageCount']; return $paginationData; } } 

Этот класс, вдохновленный аналогичной реализацией KnpLabs , собирает массив paginationData , необходимый для работы элементов управления разбиением на страницы. Пройдите через это, если вам интересно, что он делает — это довольно просто, но если нет, дайте мне знать, и я постараюсь объяснить.

Наконец, нам нужно реализовать это в index.php . Окончательная версия файла выглядит так:

 <?php use SitePoint\Helpers\PaginationHelper; use SitePoint\Helpers\SearchHelper; use Swader\Diffbot\Diffbot; require_once '../vendor/autoload.php'; require_once '../token.php'; $loader = new Twig_Loader_Filesystem(__DIR__ . '/../template/twig'); $twig = new Twig_Environment($loader , array('cache' => false, 'debug' => true) ); $function = new Twig_SimpleFunction('qprw', function (array $replacements) { parse_str($_SERVER['QUERY_STRING'], $qp); foreach ($replacements as $k => $v) { $qp[$k] = $v; } return '?'.http_build_query($qp); }); $twig->addFunction($function); $vars = []; // Get query params from request parse_str($_SERVER['QUERY_STRING'], $queryParams); $resultsPerPage = 20; $pageRange = 9; if (!isset($queryParams['page'])) { $queryParams['page'] = 1; } // Check if the search form was submitted if (isset($queryParams['search'])) { $diffbot = new Diffbot(DIFFBOT_TOKEN); // Building the search string $searchHelper = new SearchHelper(); $string = (isset($queryParams['q']) && !empty($queryParams['q'])) ? $queryParams['q'] : $searchHelper->stringFromParams($queryParams); // Basics $search = $diffbot ->search($string) ->setCol('sp_search') ->setStart(($queryParams['page'] - 1) * $resultsPerPage) ->setNum($resultsPerPage) ; // Add to template for rendering $results = $search->call(); $info = $search->call(true); $ph = new PaginationHelper(); $vars = [ 'results' => $results, 'info' => $info, 'paginationData' => $ph->getPaginationData( $queryParams['page'], $resultsPerPage, $pageRange, $results, $info ) ]; } echo $twig->render('home.twig', $vars); 

Мы добавили пользовательскую функцию Twig, которую мы будем использовать в шаблоне для замены параметра запроса другим (например, значение page в URL-адресах — см. qprw в коде шаблона выше). Мы также добавили переменные для количества результатов на страницу, а для диапазона страниц — количество страниц, отображаемых в элементе управления разбиением на страницы. Мы инициализируем первую страницу, если в нее не передан параметр страницы, а затем модифицируем вызов API поиска, чтобы учесть это. Наконец, мы передаем необходимые значения в шаблон, и у нас есть работающая поисковая система SitePoint:

Gif of usage

Автосортировка по дате

И последнее, но не менее важное: мы можем и должны работать над автосортировкой по дате публикации — прямо сейчас API-интерфейс поиска возвращает URL-адреса в порядке их обработки, что может быть абсолютно случайным. Мы можем достичь этого, улучшив наш класс SearchHelper:

 protected function sortCheck(array $queryParams) { if (isset($queryParams['sort']) && !empty($queryParams['sort'])) { $operator = (isset($queryParams['dir']) && $queryParams['dir'] == 'asc') ? "revsortby:" : "sortby:"; $this->appendStrings[] = $operator . $queryParams['sort']; } else { $this->appendStrings[] = "sortby:date"; } } 

Нам также нужно было ввести новое защищенное свойство:

 protected $appendStrings = []; 

Это связано с тем, что некондиционные значения запроса misc, такие как sortby (см. Документацию ), не могут быть логически связаны, следовательно, не могут иметь AND перед ними, иначе результаты станут непредсказуемыми. Они должны быть отделены от строки запроса пробелами.

Вывод

В этой автономной части 2 нашего учебного руководства по поисковой системе SitePoint мы создали простой графический интерфейс поиска для просканированных данных статей SitePoint, благодаря чему вся библиотека сайта мгновенно доступна для поиска по многим полям. Мы узнали, как легко начать новые проекты, и увидели, как нас делают такие эффективные инструменты, как Twig и BowerPHP — и все это без необходимости использовать целые фреймворки.

В ближайшем будущем я буду постепенно улучшать (в том числе и выше) приложение, поскольку я частично интегрирую его в другое приложение, так что следите за обновлениями и продолжайте регистрироваться! Мы даже будем иметь дело с некоторыми из этих обновлений в будущих уроках, поэтому, если приложение не выглядит так, как на скриншотах этого урока, когда вы открываете его здесь , оно просто развивается. Всегда есть оригинальная версия 0.1, которую вы можете клонировать, чтобы получить эту точную копию.

Если у вас есть какие-либо вопросы или комментарии, пожалуйста, оставьте их ниже!