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

Рабочий пример создаваемой поисковой системы.
- Шаг 1: сканирование данных
- Создание поискового каталога с помощью SQLite
- Представляем Datasette
- Публикация базы данных в интернете
- Фасетный поиск
- Используем собственный SQL для улучшения результатов поиска
- Простой интерфейс поиска на JavaScript
- Как избежать многопоточности в живом поиске
- На самом деле, здесь не так уж много кода
Шаг 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.

Вот код 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, '>'
).replace(
/</g, '<'
).replace(
/&/g, '&'
).replace(
/"/g, '"'
).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, то можно создавать относительно сложные приложения, с минимальными усилиями.