Быстрый живой поиск для сайта

Я собираюсь создать быструю поисковую систему для сайта, которая сможет поддерживать поисковые запросы с автоматическим заполнением (живым поиском) и бесплатным размещением на хостинге. Для этого буду использовать wget, Python, SQLite, Jupyter, sqlite-utils. А также открытое программное обеспечение Datasette, чтобы создать API-интерфейс.

Рабочий пример создаваемой поисковой системы.

Шаг 1: сканирование данных

Сначала нужно получить копию данных, которые должны быть доступны для поиска.

Для этого можно создать копию прямо из базы данных или извлечь ее с помощью API. Но я создам простой сканер, используя wget – инструмент командной строки с мощным «рекурсивным» режимом, который идеально подходит для загрузки содержимого сайтов.

Начнем со страницы https://24ways.org/archives/. На странице представлены публикации за каждый год работы сайта. Затем дадим команду для wget провести рекурсивное сканирование сайта, используя флаг --recursive.

Нам не нужно загружать каждую страницу сайта ‒ нас интересуют только актуальные статьи. Поэтому вытянем только публикации за определенные года. Для этого используем аргумент -I следующим образом:

-I /2005,/2006,/2007,/2008,/2009,/2010,/2011,/2012,/2013,/2014,/2015,/2016,/2017

Установим время ожидания для каждого запроса продолжительностью в 2 секунды: --wait 2

Также исключим из сканирования станицы комментариев, используя команду -X "/*/*/comments".

Чтобы повторно не загружать в результатах поиска одинаковые страницы, используем параметр --no-clobber.

Код, отвечающий за запуск перечисленных выше команд:

wget --recursive --wait 2 --no-clobber 
  -I /2005,/2006,/2007,/2008,/2009,/2010,/2011,/2012,/2013,/2014,/2015,/2016,/2017 
  -X "/*/*/comments" 
  https://24ways.org/archives/ 

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

$ find 24ways.org
24ways.org
24ways.org/2013
24ways.org/2013/why-bother-with-accessibility
24ways.org/2013/why-bother-with-accessibility/index.html
24ways.org/2013/levelling-up
24ways.org/2013/levelling-up/index.html
24ways.org/2013/project-hubs
24ways.org/2013/project-hubs/index.html
24ways.org/2013/credits-and-recognition
24ways.org/2013/credits-and-recognition/index.html
...

Для проверки работоспособности примера подсчитаем количество найденных HTML-страниц:

$ find 24ways.org | grep index.html | wc -l
328

Мы загрузили все публикации до 2017 года включительно. Но также нужно загрузить и статьи, которые опубликованы в 2018 г. Поэтому необходимо указать сканеру на публикации, размещенные на главной странице:

wget --recursive --wait 2 --no-clobber 
  -I /2018 
  -X "/*/*/comments" 
  https://24ways.org/

Теперь в компьютере есть папка, которая содержит HTML-файлы каждой статьи, опубликованной на сайте. Используем их, чтобы создать поисковой каталог.

Создание поискового каталога с помощью SQLite

SQLite – это наиболее широко используемая СУБД в мире. Она используется встроенными мобильными приложениями.

SQLite имеет очень мощную функциональность полнотекстового поиска. Откройте для себя расширение FTS5.

Я создал библиотеку утилит на Python под названием sqlite-utils для того, чтобы создание баз данных SQLite было максимально простым. Библиотека предназначена для использования в веб-приложении Jupyter Notebook.

Для использования Jupyter на компьютере должен быть установлен Python 3. Можно использовать виртуальную среду Python, чтобы убедиться, что устанавливаемое программное обеспечение не конфликтует с другими установленными пакетами:

$ python3 -m venv ./jupyter-venv
$ ./jupyter-venv/bin/pip install jupyter
# ... много выводов установщика
# Установим несколько дополнительных пакетов, которые понадобятся нам позже
$ ./jupyter-venv/bin/pip install beautifulsoup4 sqlite-utils html5lib
# Запускаем веб-приложение для блокнота
$ ./jupyter-venv/bin/jupyter-notebook
# Открываем браузер в Jupyter по адресу http://localhost:8888/

Теперь вы должны оказаться в веб-приложении Jupyter. Нажмите New -> Python 3, чтобы начать новый блокнот.

Особенность блокнотов Jupyter заключается в том, что при их публикации на GitHub они будут отображаться как обычный HTML. Это делает их очень мощным способом обмена кодом с аннотациями. Я опубликовал блокнот, который использовал для построения поискового каталога, в своей учетной записи GitHub.

Создание поискового каталога с помощью SQLite

Вот код Python, который я использовал для очистки релевантных данных из загруженных HTML- файлов. В блокноте приводится построчное объяснение того, что делает код.

from pathlib import Path
from bs4 import BeautifulSoup as Soup
base = Path("/Users/simonw/Dropbox/Development/24ways-search")
articles = list(base.glob("*/*/*/*.html"))
# теперь статьи – это просто список путей, которые выглядят так:
# PosixPath('...24ways-search/24ways.org/2013/why-bother-with-accessibility/index.html')
docs = []
for path in articles:
    year = str(path.relative_to(base)).split("/")[1]
    url = 'https://' + str(path.relative_to(base).parent) + '/'
    soup = Soup(path.open().read(), "html5lib")
    author = soup.select_one(".c-continue")["title"].split(
        "More information about"
    )[1].strip()
    author_slug = soup.select_one(".c-continue")["href"].split(
        "/authors/"
    )[1].split("/")[0]
    published = soup.select_one(".c-meta time")["datetime"]
    contents = soup.select_one(".e-content").text.strip()
    title = soup.find("title").text.split(" ◆")[0]
    try:
        topic = soup.select_one(
            '.c-meta a[href^="/topics/"]'
        )["href"].split("/topics/")[1].split("/")[0]
    except TypeError:
        topic = None
    docs.append({
        "title": title,
        "contents": contents,
        "year": year,
        "author": author,
        "author_slug": author_slug,
        "published": published,
        "url": url,
        "topic": topic,
    })

Запустив этот код, я получил список словарей Python. Каждый из них содержит документы, которые необходимо добавить в каталог. Список выглядит примерно так:

[
  {
    "title": "Why Bother with Accessibility?",
    "contents": "Web accessibility (known in other fields as inclus...",
    "year": "2013",
    "author": "Laura Kalbag",
    "author_slug": "laurakalbag",
    "published": "2013-12-10T00:00:00+00:00",
    "url": "https://24ways.org/2013/why-bother-with-accessibility/",
    "topic": "design"
  },
  {
    "title": "Levelling Up",
    "contents": "Hello, 24 ways. Iu2019m Ashley and I sell property ins...",
    "year": "2013",
    "author": "Ashley Baxter",
    "author_slug": "ashleybaxter",
    "published": "2013-12-06T00:00:00+00:00",
    "url": "https://24ways.org/2013/levelling-up/",
    "topic": "business"
  },
  ...

Библиотека sqlite-utils может взять такой список и на его основе создать таблицу базы данных SQLite. Вот как это сделать, используя приведенный выше список словарей.

import sqlite_utils
db = sqlite_utils.Database("/tmp/24ways.db")
db["articles"].insert_all(docs)

Библиотека создаст новую базу данных и добавит в нее таблицу «articles» с необходимыми столбцами. А затем вставит все документы в эту таблицу.

Можно проверить созданную таблицу с помощью утилиты командной строки sqlite3 (изначально встроена в OS X). Это можно сделать следующим образом:

$ sqlite3 /tmp/24ways.db
sqlite> .headers on
sqlite> .mode column
sqlite> select title, author, year from articles;
title                           author        year      
------------------------------  ------------  ----------
Why Bother with Accessibility?  Laura Kalbag  2013      
Levelling Up                    Ashley Baxte  2013      
Project Hubs: A Home Base for   Brad Frost    2013      
Credits and Recognition         Geri Coady    2013      
Managing a Mind                 Christopher   2013      
Run Ragged                      Mark Boulton  2013      
Get Started With GitHub Pages   Anna Debenha  2013      
Coding Towards Accessibility    Charlie Perr  2013      
...
<Ctrl+D to quit>

Вызываем метод enable_fts(), чтобы получить возможность поиска по полям заголовка, автора и содержимого:

db["articles"].enable_fts(["title", "author", "contents"])

Представляем Datasette

Datasette – это открытое программное обеспечение, которое позволяет с легкостью публиковать базы данных SQLite в интернете. Мы анализировали нашу новую базу данных SQLite с помощью sqlite3 –инструмента командной строки. Возможно, стоит использовать интерфейс, который был бы удобнее для пользователей?

Если вы не хотите устанавливать Datasette прямо сейчас, можете зайти на сайт https://search-24ways.herokuapp.com/, чтобы протестировать его на данных каталога поиска, созданного для сайта 24 ways.

Если хотите установить Datasette локально, можно повторно использовать виртуальную среду, которую мы создали при разработке Jupyter:

./jupyter-venv/bin/pip install datasette

Приведенная выше команда установит Datasette в папку ./jupyter-venv/bin/. Также можно установить его для всей системы, используя команду pip install datasette.

Теперь можно запустить Datasette для файла 24ways.db, который мы создали ранее:

./jupyter-venv/bin/datasette /tmp/24ways.db

Эта команда запустит локальный сервер. Откройте в браузере адрес http://localhost:8001/, чтобы начать работу с веб-приложением Datasette.

Если вы хотите протестировать Datasette, не создавая собственный файл 24ways.db, можете загрузить мой.

Публикация базы данных в интернете

Одна из целей Datasette заключается в упрощении развертывания API-интерфейсов. Для этого у Datasette есть встроенная команда – datasette publish. Если у вас есть учетная запись в Heroku или Zeit Now, то можете опубликовать базу данных в интернете с помощью одной команды. Вот как я развернул https://search-24ways.herokuapp.com/ (работает в бесплатной версии Heroku), используя команду datasette publish:

$ ./jupyter-venv/bin/datasette publish heroku /tmp/24ways.db --name search-24ways
-----> Python app detected
-----> Installing requirements with pip

-----> Running post-compile hook
-----> Discovering process types
       Procfile declares types -> web

-----> Compressing...
       Done: 47.1M
-----> Launching...
       Released v8
       https://search-24ways.herokuapp.com/ deployed to Heroku

Если решите испробовать приведенный выше код, выберите другое значение --name, потому что имя «search-24ways» уже занято.

Фасетный поиск

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

Фасетный поиск

Поиск SQLite поддерживает универсальные символы. Поэтому если вы хотите выполнять поиск с автозаполнением, нужно добавить * в конце запроса.

Особенность Datasette заключается в возможности рассчитать фасеты на основе ваших данных. Вот страница, показывающая результаты поиска с подсчетом фасетов, рассчитанных по столбцам year и topic:

Каждая страница, видимая через Datasette, имеет соответствующий JSON API, доступ к которому можно получить, добавив расширение .json к URL:

http://search-24ways.herokuapp.com/24ways-ae60295/articles.json?_search=acces%2A

Используем собственный SQL для улучшения результатов поиска

Результаты поиска, которые мы получаем из ../articles?_search=svg, достаточно релевантны. Но порядок, в котором они отображаются, не идеален. Результаты размещаются так, как они были загружены в базу данных.

Базовый SQL-запрос выглядит следующим образом:

select rowid, * from articles where rowid in (
  select rowid from articles_fts where articles_fts match :search
) order by rowid limit 101

Можно добиться лучшего результата, создав собственный SQL-запрос. Мы будем использовать следующий запрос:

select
  snippet(articles_fts, -1, 'b4de2a49c8', '8c94a2ed4b', '...', 100) as snippet,
  articles_fts.rank, articles.title, articles.url, articles.author, articles.year
from articles
  join articles_fts on articles.rowid = articles_fts.rowid
where articles_fts match :search || "*"
  order by rank limit 10;

Давайте разберем код SQL построчно:

select
  snippet(articles_fts, -1, 'b4de2a49c8', '8c94a2ed4b', '...', 100) as snippet,

Мы используем snippet(), встроенную функцию SQLite, чтобы получить те слова, которые соответствуют запросу.

articles_fts.rank, articles.title, articles.url, articles.author, articles.year

Это другие поля, которые нам нужно вернуть. Большинство из них из таблицы articles. но мы извлекаем rank (демонстрирует релевантность поиска) из таблицы article_fts.

from articles
  join articles_fts on articles.rowid = articles_fts.rowid

articles – это таблица, которая содержит наши данные. article_fts – это виртуальная таблица SQLite, которая реализует полнотекстовый поиск. Нужно подключиться к ней, чтобы появилась возможность отправлять ей запросы.

where articles_fts match :search || "*"
  order by rank limit 10;

:search || "*" принимает аргумент ?search= из строки запроса страницы и добавляет * в ее конец, предоставляя поиск при помощи универсальных символов. Они нужны для автоматического заполнения.

Затем мы сопоставляем это с таблицей article_fts с помощью оператора match. После этого применяем команду order by rank, чтобы самые релевантные результаты выводились наверх (ограничиваем их количество значением «10»).

Как превратить это в API? Секрет заключается в добавлении расширения .json. Datasette поддерживает множество видов JSON. Но мы будем использовать ?_Shape=array, чтобы получить простой массив объектов:

JSON API вызывает поиск по статьям с ключевым словом SVG

Простой интерфейс поиска на JavaScript

Для создания интерфейса живого поиска мы будем использовать Vanilla JS. Нам нужно несколько служебных функций. Во-первых, классическая функция debounce:

function debounce(func, wait, immediate) {
  let timeout;
  return function() {
    let context = this, args = arguments;
    let later = () => {
      timeout = null;
      if (!immediate) func.apply(context, args);
    };
    let callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) func.apply(context, args);
  };
};

Она будет отправлять запросы fetch() один раз в 100 мс во  время ввода пользователем своего поискового запроса. Поскольку мы отображаем данные, которые могут включать в себя теги HTML, используем функцию htmlEscape. Я очень удивлен, что браузеры все еще не поддерживают одну из следующих команд по умолчанию:

const htmlEscape = (s) => s.replace(
  />/g, '&gt;'
).replace(
  /</g, '&lt;'
).replace(
  /&/g, '&'
).replace(
  /"/g, '&quot;'
).replace(
  /'/g, '''
);

Нам нужен HTML для формы поиска и div для отображения результатов:

<h1>Autocomplete search</h1>
<form>
  <p><input id="searchbox" type="search" placeholder="Search 24ways" style="width: 60%"></p>
</form>
<div id="results"></div>

А вот и реализация живого поиска на JavaScript:

// Встраиваем запрос SQL в строку с кавычками:
const sql = `select
  snippet(articles_fts, -1, 'b4de2a49c8', '8c94a2ed4b', '...', 100) as snippet,
  articles_fts.rank, articles.title, articles.url, articles.author, articles.year
from articles
  join articles_fts on articles.rowid = articles_fts.rowid
where articles_fts match :search || "*"
  order by rank limit 10`;

// Получаем ссылку на <input type="search">
const searchbox = document.getElementById("searchbox");

// Используем, чтобы избежать многопоточности:
let requestInFlight = null;

searchbox.onkeyup = debounce(() => {
  const q = searchbox.value;
  // Создаем URL-адрес API, используя encodeURIComponent() для параметров
  const url = (
    "https://search-24ways.herokuapp.com/24ways-866073b.json?sql=" +
    encodeURIComponent(sql) +
    `&search=${encodeURIComponent(q)}&_shape=array`
  );
  // Уникальный объект, используемый только для сравнения многопоточности
  let currentRequest = {};
  requestInFlight = currentRequest;
  fetch(url).then(r => r.json()).then(d => {
    if (requestInFlight !== currentRequest) {
      // Избегаем многопоточности, если медленный запрос следует
      // после быстрого запроса.
      return;
    }
    let results = d.map(r => `
      <div class="result">
        <h3><a href="${r.url}">${htmlEscape(r.title)}</a></h3>
        <p><small>${htmlEscape(r.author)} - ${r.year}</small></p>
        <p>${highlight(r.snippet)}</p>
      </div>
    `).join("");
    document.getElementById("results").innerHTML = results;
  });
}, 100); // опрашиваем каждые 100мс

Есть еще одна вспомогательная функция, используемая для построения HTML:

const highlight = (s) => htmlEscape(s).replace(
  /b4de2a49c8/g, '<b>'
).replace(
  /8c94a2ed4b/g, '</b>'
);

Как избежать многопоточности в живом поиске

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

К счастью, есть простой способ избежать этого. Я создал переменную с именем requestInFlight, которая изначально равна нулю.

Каждый раз, когда запускается новый запрос fetch(), создается новый объект currentRequest={} и присваивается переменной requestInFlight.

Когда функция fetch() завершается, я использую requestInFlight!==currentRequest для проверки, что объект currentRequest идентичен объекту requestInFlight. Если пользователь вводит новый запрос в момент обработки текущего запроса, мы сможем это обнаружить и не допустить обновления результатов.

На самом деле, здесь не так уж много кода

Код выглядит весьма неаккуратно. Но вряд ли это важно, если вся реализация поисковой системы укладывается менее чем в 70 строк JavaScript. Использование SQLite– это надежный вариант. СУБД легко масштабируется до сотен МБ (или даже ГБ) данных. А тот факт, что он основан на SQL, обеспечивает простое взаимодействие.

Если использовать Datasette для API, то можно создавать относительно сложные приложения, с минимальными усилиями.

Ангелина Писанюкавтор-переводчик статьи «Fast Autocomplete Search for Your Website»