<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: autocomplete</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/autocomplete.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2018-12-19T04:11:09+00:00</updated><author><name>Simon Willison</name></author><entry><title>Fast Autocomplete Search for Your Website</title><link href="https://simonwillison.net/2018/Dec/19/fast-autocomplete-search/#atom-tag" rel="alternate"/><published>2018-12-19T04:11:09+00:00</published><updated>2018-12-19T04:11:09+00:00</updated><id>https://simonwillison.net/2018/Dec/19/fast-autocomplete-search/#atom-tag</id><summary type="html">
    &lt;p&gt;Every website deserves a great search engine - but building a search engine can be a lot of work, and hosting it can quickly get expensive.&lt;/p&gt;
&lt;p&gt;I’m going to build a search engine for &lt;a href="https://24ways.org/"&gt;24 ways&lt;/a&gt; that’s fast enough to support autocomplete (a.k.a. typeahead) search queries and can be hosted for free. I’ll be using wget, Python, SQLite, Jupyter, sqlite-utils and my open source &lt;a href="https://datasette.readthedocs.io/"&gt;Datasette&lt;/a&gt; tool to build the API backend, and a few dozen lines of modern vanilla JavaScript to build the interface.&lt;/p&gt;
&lt;figure&gt;&lt;img style="max-width: 100%" src="https://static.simonwillison.net/static/2018/24ways-autocomplete.gif" alt="Animated demo of autocomplete search against 24 ways" /&gt;&lt;/figure&gt;
&lt;p&gt;&lt;a href="https://static.simonwillison.net/static/2018/search-24ways.html"&gt;Try it out here&lt;/a&gt;, then read on to see how I built it.&lt;/p&gt;
&lt;h3&gt;First step: crawling the data&lt;/h3&gt;
&lt;p&gt;The first step in building a search engine is to grab a copy of the data that you plan to make searchable.&lt;/p&gt;
&lt;p&gt;There are plenty of potential ways to do this: you might be able to pull it directly from a database, or extract it using an API. If you don’t have access to the raw data, you can imitate Google and write a crawler to extract the data that you need.&lt;/p&gt;
&lt;p&gt;I’m going to do exactly that against 24 ways: I’ll build a simple crawler using &lt;a href="https://en.wikipedia.org/wiki/Wget"&gt;wget&lt;/a&gt;, a command-line tool that features a powerful “recursive” mode that’s ideal for scraping websites.&lt;/p&gt;
&lt;p&gt;We’ll start at the &lt;code&gt;https://24ways.org/archives/&lt;/code&gt; page, which links to an archived index for every year that 24 ways has been running.&lt;/p&gt;
&lt;p&gt;Then we’ll tell &lt;code&gt;wget&lt;/code&gt; to recursively crawl the website, using the &lt;code&gt;--recursive&lt;/code&gt; flag.&lt;/p&gt;
&lt;p&gt;We don’t want to fetch every single page on the site - we’re only interested in the actual articles. Luckily, 24 ways has nicely designed URLs, so we can tell &lt;code&gt;wget&lt;/code&gt; that we only care about pages that start with one of the years it has been running, using the &lt;code&gt;-I&lt;/code&gt; argument like this: &lt;code&gt;-I /2005,/2006,/2007,/2008,/2009,/2010,/2011,/2012,/2013,/2014,/2015,/2016,/2017&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;We want to be polite, so let’s wait for 2 seconds between each request rather than hammering the site as fast as we can: &lt;code&gt;--wait 2&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The first time I ran this, I accidentally downloaded the comments pages as well. We don’t want those, so let’s exclude them from the crawl using &lt;code&gt;-X "/*/*/comments"&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Finally, it’s useful to be able to run the command multiple times without downloading pages that we have already fetched. We can use the &lt;code&gt;--no-clobber&lt;/code&gt; option for this.&lt;/p&gt;
&lt;p&gt;Tie all of those options together and we get this command:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;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/ &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you leave this running for a few minutes, you’ll end up with a folder structure something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;$ 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
...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As a quick check, let’s count the number of HTML pages we have retrieved:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;$ find 24ways.org | grep index.html | wc -l
328&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There’s one last step! We got everything up to 2017, but we need to fetch the articles for 2018 (so far) as well. They aren’t linked in the &lt;code&gt;/archives/&lt;/code&gt; yet so we need to point our crawler at the site’s front page instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;wget --recursive --wait 2 --no-clobber 
  -I /2018 
  -X "/*/*/comments" 
  https://24ways.org/&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Thanks to &lt;code&gt;--no-clobber&lt;/code&gt;, this is safe to run every day in December to pick up any new content.&lt;/p&gt;
&lt;p&gt;We now have a folder on our computer containing an HTML file for every article that has ever been published on the site! Let’s use them to build ourselves a search index.&lt;/p&gt;
&lt;h3&gt;Building a search index using SQLite&lt;/h3&gt;
&lt;p&gt;There are many tools out there that can be used to build a search engine. You can use an open-source search server like &lt;a href="https://www.elastic.co/products/elasticsearch"&gt;Elasticsearch&lt;/a&gt; or &lt;a href="http://lucene.apache.org/solr/"&gt;Solr&lt;/a&gt;, a hosted option like &lt;a href="https://www.algolia.com/"&gt;Algolia&lt;/a&gt; or &lt;a href="https://aws.amazon.com/cloudsearch/"&gt;Amazon CloudSearch&lt;/a&gt; or you can tap into the built-in search features of relational databases like MySQL or PostgreSQL.&lt;/p&gt;
&lt;p&gt;I’m going to use something that’s less commonly used for web applications but makes for a powerful and extremely inexpensive alternative: &lt;a href="https://sqlite.org/index.html"&gt;SQLite&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;SQLite is the world’s most widely deployed database, even though many people have never even heard of it. That’s because it’s designed to be used as an embedded database: it’s commonly used by native mobile applications and even runs as part of the default set of apps on the Apple Watch!&lt;/p&gt;
&lt;p&gt;SQLite has one major limitation: unlike databases like MySQL and PostgreSQL, it isn’t really designed to handle large numbers of concurrent writes. For this reason, most people avoid it for building web applications.&lt;/p&gt;
&lt;p&gt;This doesn’t matter nearly so much if you are building a search engine for infrequently updated content - say one for &lt;a href="https://24ways.org/"&gt;a site&lt;/a&gt; that only publishes new content on 24 days every year.&lt;/p&gt;
&lt;p&gt;It turns out SQLite has very powerful full-text search functionality built into the core database - the &lt;a href="https://sqlite.org/fts5.html"&gt;FTS5 extension&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I’ve been doing &lt;a href="https://simonwillison.net/tags/sqlite/"&gt;a lot of work with SQLite&lt;/a&gt; recently, and as part of that, I’ve been building a Python utility library to make building new SQLite databases as easy as possible, called &lt;a href="https://sqlite-utils.readthedocs.io"&gt;sqlite-utils&lt;/a&gt;. It’s designed to be used within a &lt;a href="https://jupyter.org/"&gt;Jupyter notebook&lt;/a&gt; - an enormously productive way of interacting with Python code that’s similar to the Observable notebooks Natalie &lt;a href="https://24ways.org/2018/observable-notebooks-and-inaturalist/"&gt;described on 24 ways yesterday&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you haven’t used Jupyter before, here’s the fastest way to get up and running with it - assuming you have Python 3 installed on your machine. We can use a Python &lt;a href="https://docs.python.org/3/tutorial/venv.html"&gt;virtual environment&lt;/a&gt; to ensure the software we are installing doesn’t clash with any other installed packages:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;$ python3 -m venv ./jupyter-venv
$ ./jupyter-venv/bin/pip install jupyter
# ... lots of installer output
# Now lets install some extra packages we will need later
$ ./jupyter-venv/bin/pip install beautifulsoup4 sqlite-utils html5lib
# And start the notebook web application
$ ./jupyter-venv/bin/jupyter-notebook
# This will open your browser to Jupyter at http://localhost:8888/&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should now be in the Jupyter web application. Click &lt;em&gt;New -&amp;gt; Python 3&lt;/em&gt; to start a new notebook.&lt;/p&gt;
&lt;p&gt;A neat thing about Jupyter notebooks is that if you publish them to GitHub (either in a regular repository or as a Gist), it will render them as HTML. This makes them a very powerful way to share annotated code. I’ve published &lt;a href="https://nbviewer.jupyter.org/github/simonw/24ways-datasette/blob/master/24-ways-search-index.ipynb"&gt;the notebook I used to build the search index&lt;/a&gt; on my GitHub account. &lt;/p&gt;
&lt;figure&gt;&lt;picture&gt;&lt;source srcset='​"https://static.simonwillison.net/static/2018/24ways-jupyter.webp"' type='​"image/​webp"'&gt;​
&lt;img style="max-width: 100%" src="https://static.simonwillison.net/static/2018/24ways-jupyter.png" alt="Juptyer notebook with my scraping code" /&gt;&lt;/source&gt;&lt;/picture&gt;&lt;/figure&gt;
&lt;p&gt;Here’s the Python code I used to scrape the relevant data from the downloaded HTML files. Check out &lt;a href="https://nbviewer.jupyter.org/github/simonw/24ways-datasette/blob/master/24-ways-search-index.ipynb"&gt;the notebook&lt;/a&gt; for a line-by-line explanation of what’s going on.&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;pathlib&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Path&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;bs4&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;BeautifulSoup&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-v"&gt;Soup&lt;/span&gt;

&lt;span class="pl-s1"&gt;base&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Path&lt;/span&gt;(&lt;span class="pl-s"&gt;"/Users/simonw/Dropbox/Development/24ways-search"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;articles&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;list&lt;/span&gt;(&lt;span class="pl-s1"&gt;base&lt;/span&gt;.&lt;span class="pl-en"&gt;glob&lt;/span&gt;(&lt;span class="pl-s"&gt;"*/*/*/*.html"&lt;/span&gt;))
&lt;span class="pl-c"&gt;# articles is now a list of paths that look like this:&lt;/span&gt;
&lt;span class="pl-c"&gt;# PosixPath('...24ways-search/24ways.org/2013/why-bother-with-accessibility/index.html')&lt;/span&gt;
&lt;span class="pl-s1"&gt;docs&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; []
&lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;path&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;articles&lt;/span&gt;:
    &lt;span class="pl-s1"&gt;year&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;str&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;.&lt;span class="pl-en"&gt;relative_to&lt;/span&gt;(&lt;span class="pl-s1"&gt;base&lt;/span&gt;)).&lt;span class="pl-en"&gt;split&lt;/span&gt;(&lt;span class="pl-s"&gt;"/"&lt;/span&gt;)[&lt;span class="pl-c1"&gt;1&lt;/span&gt;]
    &lt;span class="pl-s1"&gt;url&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'https://'&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-en"&gt;str&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;.&lt;span class="pl-en"&gt;relative_to&lt;/span&gt;(&lt;span class="pl-s1"&gt;base&lt;/span&gt;).&lt;span class="pl-s1"&gt;parent&lt;/span&gt;) &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;'/'&lt;/span&gt;
    &lt;span class="pl-s1"&gt;soup&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Soup&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;.&lt;span class="pl-en"&gt;open&lt;/span&gt;().&lt;span class="pl-en"&gt;read&lt;/span&gt;(), &lt;span class="pl-s"&gt;"html5lib"&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;author&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;soup&lt;/span&gt;.&lt;span class="pl-en"&gt;select_one&lt;/span&gt;(&lt;span class="pl-s"&gt;".c-continue"&lt;/span&gt;)[&lt;span class="pl-s"&gt;"title"&lt;/span&gt;].&lt;span class="pl-en"&gt;split&lt;/span&gt;(
        &lt;span class="pl-s"&gt;"More information about"&lt;/span&gt;
    )[&lt;span class="pl-c1"&gt;1&lt;/span&gt;].&lt;span class="pl-en"&gt;strip&lt;/span&gt;()
    &lt;span class="pl-s1"&gt;author_slug&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;soup&lt;/span&gt;.&lt;span class="pl-en"&gt;select_one&lt;/span&gt;(&lt;span class="pl-s"&gt;".c-continue"&lt;/span&gt;)[&lt;span class="pl-s"&gt;"href"&lt;/span&gt;].&lt;span class="pl-en"&gt;split&lt;/span&gt;(
        &lt;span class="pl-s"&gt;"/authors/"&lt;/span&gt;
    )[&lt;span class="pl-c1"&gt;1&lt;/span&gt;].&lt;span class="pl-en"&gt;split&lt;/span&gt;(&lt;span class="pl-s"&gt;"/"&lt;/span&gt;)[&lt;span class="pl-c1"&gt;0&lt;/span&gt;]
    &lt;span class="pl-s1"&gt;published&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;soup&lt;/span&gt;.&lt;span class="pl-en"&gt;select_one&lt;/span&gt;(&lt;span class="pl-s"&gt;".c-meta time"&lt;/span&gt;)[&lt;span class="pl-s"&gt;"datetime"&lt;/span&gt;]
    &lt;span class="pl-s1"&gt;contents&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;soup&lt;/span&gt;.&lt;span class="pl-en"&gt;select_one&lt;/span&gt;(&lt;span class="pl-s"&gt;".e-content"&lt;/span&gt;).&lt;span class="pl-s1"&gt;text&lt;/span&gt;.&lt;span class="pl-en"&gt;strip&lt;/span&gt;()
    &lt;span class="pl-s1"&gt;title&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;soup&lt;/span&gt;.&lt;span class="pl-en"&gt;find&lt;/span&gt;(&lt;span class="pl-s"&gt;"title"&lt;/span&gt;).&lt;span class="pl-s1"&gt;text&lt;/span&gt;.&lt;span class="pl-en"&gt;split&lt;/span&gt;(&lt;span class="pl-s"&gt;" ◆"&lt;/span&gt;)[&lt;span class="pl-c1"&gt;0&lt;/span&gt;]
    &lt;span class="pl-k"&gt;try&lt;/span&gt;:
        &lt;span class="pl-s1"&gt;topic&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;soup&lt;/span&gt;.&lt;span class="pl-en"&gt;select_one&lt;/span&gt;(
            &lt;span class="pl-s"&gt;'.c-meta a[href^="/topics/"]'&lt;/span&gt;
        )[&lt;span class="pl-s"&gt;"href"&lt;/span&gt;].&lt;span class="pl-en"&gt;split&lt;/span&gt;(&lt;span class="pl-s"&gt;"/topics/"&lt;/span&gt;)[&lt;span class="pl-c1"&gt;1&lt;/span&gt;].&lt;span class="pl-en"&gt;split&lt;/span&gt;(&lt;span class="pl-s"&gt;"/"&lt;/span&gt;)[&lt;span class="pl-c1"&gt;0&lt;/span&gt;]
    &lt;span class="pl-k"&gt;except&lt;/span&gt; &lt;span class="pl-v"&gt;TypeError&lt;/span&gt;:
        &lt;span class="pl-s1"&gt;topic&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt;
    &lt;span class="pl-s1"&gt;docs&lt;/span&gt;.&lt;span class="pl-en"&gt;append&lt;/span&gt;({
        &lt;span class="pl-s"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s1"&gt;title&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"contents"&lt;/span&gt;: &lt;span class="pl-s1"&gt;contents&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"year"&lt;/span&gt;: &lt;span class="pl-s1"&gt;year&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"author"&lt;/span&gt;: &lt;span class="pl-s1"&gt;author&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"author_slug"&lt;/span&gt;: &lt;span class="pl-s1"&gt;author_slug&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"published"&lt;/span&gt;: &lt;span class="pl-s1"&gt;published&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"url"&lt;/span&gt;: &lt;span class="pl-s1"&gt;url&lt;/span&gt;,
        &lt;span class="pl-s"&gt;"topic"&lt;/span&gt;: &lt;span class="pl-s1"&gt;topic&lt;/span&gt;,
    })&lt;/pre&gt;
&lt;p&gt;After running this code, I have a list of Python dictionaries representing each of the documents that I want to add to the index. The list looks something like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;[
  {
    &lt;span class="pl-ent"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Why Bother with Accessibility?&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"contents"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Web accessibility (known in other fields as inclus...&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"year"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;2013&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"author"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Laura Kalbag&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"author_slug"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;laurakalbag&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"published"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;2013-12-10T00:00:00+00:00&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"url"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://24ways.org/2013/why-bother-with-accessibility/&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"topic"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;design&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  },
  {
    &lt;span class="pl-ent"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Levelling Up&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"contents"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Hello, 24 ways. Iu2019m Ashley and I sell property ins...&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"year"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;2013&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"author"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Ashley Baxter&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"author_slug"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;ashleybaxter&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"published"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;2013-12-06T00:00:00+00:00&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"url"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://24ways.org/2013/levelling-up/&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"topic"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;business&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  },
  ...&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;My &lt;code&gt;sqlite-utils&lt;/code&gt; library has the ability to take a list of objects like this and automatically create a SQLite database table with the right schema to store the data. Here’s how to do that using this list of dictionaries.&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt;
&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt;.&lt;span class="pl-v"&gt;Database&lt;/span&gt;(&lt;span class="pl-s"&gt;"/tmp/24ways.db"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"articles"&lt;/span&gt;].&lt;span class="pl-en"&gt;insert_all&lt;/span&gt;(&lt;span class="pl-s1"&gt;docs&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;That’s all there is to it! The library will create a new database and add a table to it called &lt;code&gt;articles&lt;/code&gt; with the necessary columns, then insert all of the documents into that table.&lt;/p&gt;
&lt;p&gt;(I put the database in &lt;code&gt;/tmp/&lt;/code&gt; for the moment - you can move it to a more sensible location later on.)&lt;/p&gt;
&lt;p&gt;You can inspect the table using the &lt;code&gt;sqlite3&lt;/code&gt; command-line utility (which comes with OS X) like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;$ sqlite3 /tmp/24ways.db
sqlite&amp;gt; .headers on
sqlite&amp;gt; .mode column
sqlite&amp;gt; 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      
...
&amp;lt;Ctrl+D to quit&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There’s one last step to take in our notebook. We know we want to use SQLite’s full-text search feature, and &lt;code&gt;sqlite-utils&lt;/code&gt; has a simple convenience method for enabling it for a specified set of columns in a table. We want to be able to search by the &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;author&lt;/code&gt; and &lt;code&gt;contents&lt;/code&gt; fields, so we call the &lt;code&gt;enable_fts()&lt;/code&gt; method like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-python"&gt;db["articles"].enable_fts(["title", "author", "contents"])&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Introducing Datasette&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://datasette.readthedocs.io/"&gt;Datasette&lt;/a&gt; is the open-source tool I’ve been building that makes it easy to both explore SQLite databases and publish them to the internet.&lt;/p&gt;
&lt;p&gt;We’ve been exploring our new SQLite database using the &lt;code&gt;sqlite3&lt;/code&gt; command-line tool. Wouldn’t it be nice if we could use a more human-friendly interface for that?&lt;/p&gt;
&lt;p&gt;If you don’t want to install Datasette right now, you can visit &lt;a href="https://search-24ways.herokuapp.com/"&gt;https://search-24ways.herokuapp.com/&lt;/a&gt; to try it out against the 24 ways search index data. I’ll show you how to deploy Datasette to Heroku like this later in the article.&lt;/p&gt;
&lt;p&gt;If you want to install Datasette locally, you can reuse the virtual environment we created to play with Jupyter:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;./jupyter-venv/bin/pip install datasette&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will install Datasette in the &lt;code&gt;./jupyter-venv/bin/&lt;/code&gt; folder. You can also install it system-wide using regular &lt;code&gt;pip install datasette&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Now you can run Datasette against the &lt;code&gt;24ways.db&lt;/code&gt; file we created earlier like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;./jupyter-venv/bin/datasette /tmp/24ways.db&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will start a local webserver running. Visit &lt;code&gt;http://localhost:8001/&lt;/code&gt; to start interacting with the Datasette web application.&lt;/p&gt;
&lt;p&gt;If you want to try out Datasette without creating your own &lt;code&gt;24ways.db&lt;/code&gt; file you can download the one I created directly from &lt;a href="https://search-24ways.herokuapp.com/24ways-ae60295.db"&gt;https://search-24ways.herokuapp.com/24ways-ae60295.db&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Publishing the database to the internet&lt;/h3&gt;
&lt;p&gt;One of the goals of the Datasette project is to make deploying data-backed APIs to the internet as easy as possible. Datasette has a built-in command for this, &lt;a href="https://datasette.readthedocs.io/en/stable/publish.html"&gt;datasette publish&lt;/a&gt;. If you have an account with &lt;a href="https://www.heroku.com/"&gt;Heroku&lt;/a&gt; or &lt;a href="https://zeit.co/now"&gt;Zeit Now&lt;/a&gt;, you can deploy a database to the internet with a single command. Here’s how I deployed &lt;a href="https://search-24ways.herokuapp.com/"&gt;https://search-24ways.herokuapp.com/&lt;/a&gt; (running on Heroku’s free tier) using &lt;code&gt;datasette publish&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-bash"&gt;$ ./jupyter-venv/bin/datasette publish heroku /tmp/24ways.db --name search-24ways
-----&amp;gt; Python app detected
-----&amp;gt; Installing requirements with pip

-----&amp;gt; Running post-compile hook
-----&amp;gt; Discovering process types
       Procfile declares types -&amp;gt; web

-----&amp;gt; Compressing...
       Done: 47.1M
-----&amp;gt; Launching...
       Released v8
       https://search-24ways.herokuapp.com/ deployed to Heroku&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you try this out, you’ll need to pick a different &lt;code&gt;--name&lt;/code&gt;, since I’ve already taken &lt;code&gt;search-24ways&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You can run this command as many times as you like to deploy updated versions of the underlying database.&lt;/p&gt;
&lt;h3&gt;Searching and faceting&lt;/h3&gt;
&lt;p&gt;Datasette can detect tables with SQLite full-text search configured, and will add a search box directly to the page. Take a look at &lt;a href="http://search-24ways.herokuapp.com/24ways-b607e21/articles"&gt;http://search-24ways.herokuapp.com/24ways-b607e21/articles&lt;/a&gt; to see this in action.&lt;/p&gt;
&lt;figure&gt;&lt;picture&gt;&lt;source srcset='​"https://static.simonwillison.net/static/2018/24ways-facets.webp"' type='​"image/​webp"'&gt;​
&lt;img style="max-width: 100%" src="https://static.simonwillison.net/static/2018/24ways-facets.png" alt="Datasette faceted browse" /&gt;&lt;/source&gt;&lt;/picture&gt;&lt;/figure&gt;
&lt;p&gt;SQLite search supports wildcards, so if you want autocomplete-style search where you don’t need to enter full words to start getting results you can add a &lt;code&gt;*&lt;/code&gt; to the end of your search term. Here’s a search for &lt;code&gt;access*&lt;/code&gt; which returns articles on accessibility:&lt;/p&gt;
&lt;p&gt;&lt;a href="http://search-24ways.herokuapp.com/24ways-ae60295/articles?_search=acces%2A"&gt;&lt;code&gt;http://search-24ways.herokuapp.com/24ways-ae60295/articles?_search=acces%2A&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;A neat feature of Datasette is the ability to &lt;a href="https://datasette.readthedocs.io/en/stable/facets.html"&gt;calculate facets&lt;/a&gt; against your data. Here’s a page showing search results for &lt;code&gt;svg&lt;/code&gt; with facet counts calculated against both the &lt;code&gt;year&lt;/code&gt; and the &lt;code&gt;topic&lt;/code&gt; columns:&lt;/p&gt;
&lt;p&gt;&lt;a href="http://search-24ways.herokuapp.com/24ways-ae60295/articles?_search=svg&amp;amp;_facet=year&amp;amp;_facet=topic"&gt;&lt;code&gt;http://search-24ways.herokuapp.com/24ways-ae60295/articles?_search=svg&amp;amp;_facet=year&amp;amp;_facet=topic&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Every page visible via Datasette has a corresponding JSON API, which can be accessed using the JSON link on the page - or by adding a &lt;code&gt;.json&lt;/code&gt; extension to the URL:&lt;/p&gt;
&lt;p&gt;&lt;a href="http://search-24ways.herokuapp.com/24ways-ae60295/articles.json?_search=acces%2A"&gt;&lt;code&gt;http://search-24ways.herokuapp.com/24ways-ae60295/articles.json?_search=acces%2A&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Better search using custom SQL&lt;/h3&gt;
&lt;p&gt;The search results we get back from &lt;code&gt;../articles?_search=svg&lt;/code&gt; are OK, but the order they are returned in is not ideal - they’re actually being returned in the order they were inserted into the database! You can see why this is happening by clicking the &lt;a href="http://search-24ways.herokuapp.com/24ways-ae60295?sql=select+rowid%2C+%2A+from+articles+where+rowid+in+%28select+rowid+from+articles_fts+where+articles_fts+match+%3Asearch%29+order+by+rowid+limit+101&amp;amp;search=svg"&gt;View and edit SQL&lt;/a&gt; link on that search results page.&lt;/p&gt;
&lt;p&gt;This exposes the underlying SQL query, which looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; rowid, &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; articles &lt;span class="pl-k"&gt;where&lt;/span&gt; rowid &lt;span class="pl-k"&gt;in&lt;/span&gt; (
  &lt;span class="pl-k"&gt;select&lt;/span&gt; rowid &lt;span class="pl-k"&gt;from&lt;/span&gt; articles_fts &lt;span class="pl-k"&gt;where&lt;/span&gt; articles_fts match :search
) &lt;span class="pl-k"&gt;order by&lt;/span&gt; rowid &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;101&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;We can do better than this by constructing a custom SQL query. Here’s the query we will use instead:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt;
  snippet(articles_fts, &lt;span class="pl-k"&gt;-&lt;/span&gt;&lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;b4de2a49c8&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;8c94a2ed4b&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;...&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-c1"&gt;100&lt;/span&gt;) &lt;span class="pl-k"&gt;as&lt;/span&gt; snippet,
  &lt;span class="pl-c1"&gt;articles_fts&lt;/span&gt;.&lt;span class="pl-c1"&gt;rank&lt;/span&gt;, &lt;span class="pl-c1"&gt;articles&lt;/span&gt;.&lt;span class="pl-c1"&gt;title&lt;/span&gt;, &lt;span class="pl-c1"&gt;articles&lt;/span&gt;.&lt;span class="pl-c1"&gt;url&lt;/span&gt;, &lt;span class="pl-c1"&gt;articles&lt;/span&gt;.&lt;span class="pl-c1"&gt;author&lt;/span&gt;, &lt;span class="pl-c1"&gt;articles&lt;/span&gt;.&lt;span class="pl-c1"&gt;year&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; articles
  &lt;span class="pl-k"&gt;join&lt;/span&gt; articles_fts &lt;span class="pl-k"&gt;on&lt;/span&gt; &lt;span class="pl-c1"&gt;articles&lt;/span&gt;.&lt;span class="pl-c1"&gt;rowid&lt;/span&gt; &lt;span class="pl-k"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;articles_fts&lt;/span&gt;.&lt;span class="pl-c1"&gt;rowid&lt;/span&gt;
&lt;span class="pl-k"&gt;where&lt;/span&gt; articles_fts match :search &lt;span class="pl-k"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;*&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-k"&gt;order by&lt;/span&gt; rank &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;10&lt;/span&gt;;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You can &lt;a href="http://search-24ways.herokuapp.com/24ways-ae60295?sql=select%0D%0A++snippet%28articles_fts%2C+-1%2C+%27b4de2a49c8%27%2C+%278c94a2ed4b%27%2C+%27...%27%2C+100%29+as+snippet%2C%0D%0A++articles_fts.rank%2C+articles.title%2C+articles.url%2C+articles.author%2C+articles.year%0D%0Afrom+articles%0D%0A++join+articles_fts+on+articles.rowid+%3D+articles_fts.rowid%0D%0Awhere+articles_fts+match+%3Asearch+%7C%7C+%22*%22%0D%0A++order+by+rank+limit+10%3B&amp;amp;search=svg"&gt;try this query out&lt;/a&gt; directly - since Datasette opens the underling SQLite database in read-only mode and enforces a one second time limit on queries, it’s safe to allow users to provide arbitrary SQL select queries for Datasette to execute.&lt;/p&gt;
&lt;p&gt;There’s a lot going on here! Let’s break the SQL down line-by-line:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;select
  snippet(articles_fts, -1, 'b4de2a49c8', '8c94a2ed4b', '...', 100) as snippet,&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We’re using &lt;code&gt;snippet()&lt;/code&gt;, a &lt;a href="https://sqlite.org/fts5.html#the_snippet_function"&gt;built-in SQLite function&lt;/a&gt;, to generate a snippet highlighting the words that matched the query. We use two unique strings that I made up to mark the beginning and end of each match - you’ll see why in the JavaScript later on.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;  articles_fts.rank, articles.title, articles.url, articles.author, articles.year&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are the other fields we need back - most of them are from the &lt;code&gt;articles&lt;/code&gt; table but we retrieve the &lt;code&gt;rank&lt;/code&gt; (representing the strength of the search match) from the magical &lt;code&gt;articles_fts&lt;/code&gt; table.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;from articles
  join articles_fts on articles.rowid = articles_fts.rowid&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;articles&lt;/code&gt; is the table containing our data. &lt;code&gt;articles_fts&lt;/code&gt; is a magic SQLite virtual table which implements full-text search - we need to join against it to be able to query it.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sql"&gt;where articles_fts match :search || "*"
  order by rank limit 10;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;:search || "*"&lt;/code&gt; takes the &lt;code&gt;?search=&lt;/code&gt; argument from the page querystring and adds a &lt;code&gt;*&lt;/code&gt; to the end of it, giving us the wildcard search that we want for autocomplete. We then match that against the &lt;code&gt;articles_fts&lt;/code&gt; table using the &lt;code&gt;match&lt;/code&gt; operator. Finally, we &lt;code&gt;order by rank&lt;/code&gt; so that the best matching results are returned at the top - and limit to the first 10 results.&lt;/p&gt;
&lt;p&gt;How do we turn this into an API? As before, the secret is to add the &lt;code&gt;.json&lt;/code&gt; extension. Datasette actually supports &lt;a href="https://datasette.readthedocs.io/en/stable/json_api.html#different-shapes"&gt;multiple shapes of JSON&lt;/a&gt; - we’re going to use &lt;code&gt;?_shape=array&lt;/code&gt; to get back a plain array of objects:&lt;/p&gt;
&lt;p&gt;&lt;a href="http://search-24ways.herokuapp.com/24ways-ae60295.json?sql=select%0D%0A++snippet(articles_fts%2C+-1%2C+%27b4de2a49c8%27%2C+%278c94a2ed4b%27%2C+%27...%27%2C+100)+as+snippet%2C%0D%0A++articles_fts.rank%2C+articles.title%2C+articles.url%2C+articles.author%2C+articles.year%0D%0Afrom+articles%0D%0A++join+articles_fts+on+articles.rowid+%3D+articles_fts.rowid%0D%0Awhere+articles_fts+match+%3Asearch+||+%22*%22%0D%0A++order+by+rank+limit+10%3B&amp;amp;search=svg&amp;amp;_shape=array"&gt;JSON API call to search for articles matching SVG&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The HTML version of that page shows the time taken to execute the SQL in the footer. Hitting refresh a few times, I get response times between 2 and 5ms - easily fast enough to power a responsive autocomplete feature.&lt;/p&gt;
&lt;h3&gt;A simple JavaScript autocomplete search interface&lt;/h3&gt;
&lt;p&gt;I considered building this using &lt;a href="https://svelte.technology/"&gt;React&lt;/a&gt; or &lt;a href="https://svelte.technology/"&gt;Svelte&lt;/a&gt; or another of the myriad of JavaScript framework options available today, but then I remembered that vanilla JavaScript in 2018 is a very productive environment all on its own.&lt;/p&gt;
&lt;p&gt;We need a few small utility functions: first, a classic debounce function adapted from &lt;a href="https://davidwalsh.name/javascript-debounce-function"&gt;this one by David Walsh&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;debounce&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;func&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;wait&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;immediate&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;timeout&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;context&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;this&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;args&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;arguments&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-en"&gt;later&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-s1"&gt;timeout&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;null&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;!&lt;/span&gt;&lt;span class="pl-s1"&gt;immediate&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-s1"&gt;func&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;apply&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;context&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;args&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;callNow&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;immediate&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-c1"&gt;!&lt;/span&gt;&lt;span class="pl-s1"&gt;timeout&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-en"&gt;clearTimeout&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;timeout&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;timeout&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;setTimeout&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-en"&gt;later&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;wait&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;callNow&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-s1"&gt;func&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;apply&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;context&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;args&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;We’ll use this to only send &lt;code&gt;fetch()&lt;/code&gt; requests a maximum of once every 100ms while the user is typing.&lt;/p&gt;
&lt;p&gt;Since we’re rendering data that might include HTML tags (24 ways is a site about web development after all), we need an HTML escaping function. I’m amazed that browsers still don’t bundle a default one of these:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-en"&gt;htmlEscape&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;s&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;s&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&amp;gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;g&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'&amp;amp;gt;'&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&amp;lt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;g&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'&amp;amp;lt;'&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&amp;amp;&lt;span class="pl-c1"&gt;/&lt;/span&gt;g&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'&amp;amp;'&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;"&lt;span class="pl-c1"&gt;/&lt;/span&gt;g&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'&amp;amp;quot;'&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;'&lt;span class="pl-c1"&gt;/&lt;/span&gt;g&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'&amp;amp;#039;'&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;We need some HTML for the search form, and a &lt;code&gt;div&lt;/code&gt; in which to render the results:&lt;/p&gt;
&lt;div class="highlight highlight-text-html-basic"&gt;&lt;pre&gt;&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;h1&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;Autocomplete search&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;h1&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;form&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;p&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;input&lt;/span&gt; &lt;span class="pl-c1"&gt;id&lt;/span&gt;="&lt;span class="pl-s"&gt;searchbox&lt;/span&gt;" &lt;span class="pl-c1"&gt;type&lt;/span&gt;="&lt;span class="pl-s"&gt;search&lt;/span&gt;" &lt;span class="pl-c1"&gt;placeholder&lt;/span&gt;="&lt;span class="pl-s"&gt;Search 24ways&lt;/span&gt;" &lt;span class="pl-c1"&gt;style&lt;/span&gt;="&lt;span class="pl-s"&gt;width: 60%&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;p&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;form&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;div&lt;/span&gt; &lt;span class="pl-c1"&gt;id&lt;/span&gt;="&lt;span class="pl-s"&gt;results&lt;/span&gt;"&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;div&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And now the autocomplete implementation itself, as a glorious, messy stream-of-consciousness of JavaScript:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;// Embed the SQL query in a multi-line backtick string:&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;sql&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;`select&lt;/span&gt;
&lt;span class="pl-s"&gt;  snippet(articles_fts, -1, 'b4de2a49c8', '8c94a2ed4b', '...', 100) as snippet,&lt;/span&gt;
&lt;span class="pl-s"&gt;  articles_fts.rank, articles.title, articles.url, articles.author, articles.year&lt;/span&gt;
&lt;span class="pl-s"&gt;from articles&lt;/span&gt;
&lt;span class="pl-s"&gt;  join articles_fts on articles.rowid = articles_fts.rowid&lt;/span&gt;
&lt;span class="pl-s"&gt;where articles_fts match :search || "*"&lt;/span&gt;
&lt;span class="pl-s"&gt;  order by rank limit 10`&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// Grab a reference to the &amp;lt;input type="search"&amp;gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;searchbox&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"searchbox"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-c"&gt;// Used to avoid race-conditions:&lt;/span&gt;
&lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;requestInFlight&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;null&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-s1"&gt;searchbox&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;onkeyup&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;debounce&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;q&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;searchbox&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;value&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Construct the API URL, using encodeURIComponent() for the parameters&lt;/span&gt;
  &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;url&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;
    &lt;span class="pl-s"&gt;"https://search-24ways.herokuapp.com/24ways-866073b.json?sql="&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt;
    &lt;span class="pl-en"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;sql&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt;
    &lt;span class="pl-s"&gt;`&amp;amp;search=&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;q&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;amp;_shape=array`&lt;/span&gt;
  &lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-c"&gt;// Unique object used just for race-condition comparison&lt;/span&gt;
  &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;currentRequest&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-s1"&gt;requestInFlight&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;currentRequest&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;url&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;d&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;requestInFlight&lt;/span&gt; &lt;span class="pl-c1"&gt;!==&lt;/span&gt; &lt;span class="pl-s1"&gt;currentRequest&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-c"&gt;// Avoid race conditions where a slow request returns&lt;/span&gt;
      &lt;span class="pl-c"&gt;// after a faster one.&lt;/span&gt;
      &lt;span class="pl-k"&gt;return&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;results&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;d&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;map&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-s"&gt;`&lt;/span&gt;
&lt;span class="pl-s"&gt;      &amp;lt;div class="result"&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;        &amp;lt;h3&amp;gt;&amp;lt;a href="&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;url&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;"&amp;gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;htmlEscape&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;title&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;lt;/a&amp;gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;        &amp;lt;p&amp;gt;&amp;lt;small&amp;gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;htmlEscape&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;author&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt; - &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;year&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;lt;/small&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;        &amp;lt;p&amp;gt;&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-en"&gt;highlight&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;snippet&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;      &amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;    `&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;join&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"results"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;innerHTML&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;results&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;100&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt; &lt;span class="pl-c"&gt;// debounce every 100ms&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;There’s just one more utility function, used to help construct the HTML results:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-en"&gt;highlight&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;s&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="pl-en"&gt;htmlEscape&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;s&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;b4de2a49c8&lt;span class="pl-c1"&gt;/&lt;/span&gt;g&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'&amp;lt;b&amp;gt;'&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;
  &lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;8c94a2ed4b&lt;span class="pl-c1"&gt;/&lt;/span&gt;g&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'&amp;lt;/b&amp;gt;'&lt;/span&gt;
&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This is what those unique strings passed to the &lt;code&gt;snippet()&lt;/code&gt; function were for.&lt;/p&gt;
&lt;h3&gt;Avoiding race conditions in autocomplete&lt;/h3&gt;
&lt;p&gt;One trick in this code that you may not have seen before is the way race-conditions are handled. Any time you build an autocomplete feature, you have to consider the following case:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User types &lt;code&gt;acces&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Browser sends &lt;strong&gt;request A&lt;/strong&gt; - querying documents matching &lt;code&gt;acces*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;User continues to type &lt;code&gt;accessibility&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Browser sends &lt;strong&gt;request B&lt;/strong&gt; - querying documents matching &lt;code&gt;accessibility*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Request B&lt;/strong&gt; returns. It was fast, because there are fewer documents matching the full term&lt;/li&gt;
&lt;li&gt;The results interface updates with the documents from &lt;strong&gt;request B&lt;/strong&gt;, matching &lt;code&gt;accessibility*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Request A&lt;/strong&gt; returns results (this was the slower of the two requests)&lt;/li&gt;
&lt;li&gt;The results interface updates with the documents from &lt;strong&gt;request A&lt;/strong&gt; - results matching &lt;code&gt;access*&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is a terrible user experience: the user saw their desired results for a brief second, and then had them snatched away and replaced with those results from earlier on.&lt;/p&gt;
&lt;p&gt;Thankfully there’s an easy way to avoid this. I set up a variable in the outer scope called &lt;code&gt;requestInFlight&lt;/code&gt;, initially set to &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Any time I start a new &lt;code&gt;fetch()&lt;/code&gt; request, I create a new &lt;code&gt;currentRequest = {}&lt;/code&gt; object and assign it to the outer &lt;code&gt;requestInFlight&lt;/code&gt; as well.&lt;/p&gt;
&lt;p&gt;When the &lt;code&gt;fetch()&lt;/code&gt; completes, I use &lt;code&gt;requestInFlight !== currentRequest&lt;/code&gt; to verify that the &lt;code&gt;currentRequest&lt;/code&gt; object is strictly identical to the one that was in flight. If a new request has been triggered since we started the current request we can detect that and avoid updating the results.&lt;/p&gt;
&lt;h3&gt;It’s not a lot of code, really&lt;/h3&gt;
&lt;p&gt;And that’s the whole thing! The code is pretty ugly, but when the entire implementation clocks in at fewer than 70 lines of JavaScript, I honestly don’t think it matters. You’re welcome to refactor it as much you like.&lt;/p&gt;
&lt;p&gt;How good is this search implementation? I’ve been building search engines for a long time using a wide variety of technologies and I’m happy to report that using SQLite in this way is genuinely a really solid option. It scales happily up to hundreds of MBs (or even GBs) of data, and the fact that it’s based on SQL makes it easy and flexible to work with.&lt;/p&gt;
&lt;p&gt;A surprisingly large number of desktop and mobile applications you use every day implement their search feature on top of SQLite.&lt;/p&gt;
&lt;p&gt;More importantly though, I hope that this demonstrates that using Datasette for an API means you can build relatively sophisticated API-backed applications with very little backend programming effort. If you’re working with a small-to-medium amount of data that changes infrequently, you may not need a more expensive database. Datasette-powered applications easily fit within the free tier of both Heroku and Zeit Now.&lt;/p&gt;
&lt;p&gt;For more of my writing on Datasette, check out &lt;a href="https://simonwillison.net/tags/datasette/"&gt;the datasette tag on my blog&lt;/a&gt;. And if you do build something fun with it, please &lt;a href="https://twitter.coml/simonw"&gt;let me know on Twitter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article &lt;a href="https://24ways.org/2018/fast-autocomplete-search-for-your-website/"&gt;originally appeared on 24ways&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/24-ways"&gt;24-ways&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/autocomplete"&gt;autocomplete&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/beautifulsoup"&gt;beautifulsoup&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="24-ways"/><category term="autocomplete"/><category term="beautifulsoup"/><category term="javascript"/><category term="datasette"/><category term="sqlite-utils"/></entry><entry><title>Fast Autocomplete Search for Your Website</title><link href="https://simonwillison.net/2018/Dec/19/fast-autocomplete-search-your-website/#atom-tag" rel="alternate"/><published>2018-12-19T00:26:32+00:00</published><updated>2018-12-19T00:26:32+00:00</updated><id>https://simonwillison.net/2018/Dec/19/fast-autocomplete-search-your-website/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://24ways.org/2018/fast-autocomplete-search-for-your-website/"&gt;Fast Autocomplete Search for Your Website&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I wrote a tutorial for the 24 ways advent calendar on building fast autocomplete search for a website on top of Datasette and SQLite. I built the demo against 24 ways itself—I used wget to recursively fetch all 330 articles as HTML, then wrote code in a Jupyter notebook to extract the raw data from them (with BeautifulSoup) and load them into SQLite using my sqlite-utils Python library. I deployed the resulting database using Datasette, then wrote some vanilla JavaScript to implement autocomplete using fast SQL queries against the Datasette JSON API.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/simonw/status/1075181158327934976"&gt;@simonw&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/24-ways"&gt;24-ways&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/autocomplete"&gt;autocomplete&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/beautifulsoup"&gt;beautifulsoup&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/search"&gt;search&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jupyter"&gt;jupyter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;&lt;/p&gt;



</summary><category term="24-ways"/><category term="autocomplete"/><category term="beautifulsoup"/><category term="search"/><category term="sqlite"/><category term="jupyter"/><category term="datasette"/></entry><entry><title>Typesense</title><link href="https://simonwillison.net/2018/Apr/6/typesense/#atom-tag" rel="alternate"/><published>2018-04-06T17:07:51+00:00</published><updated>2018-04-06T17:07:51+00:00</updated><id>https://simonwillison.net/2018/Apr/6/typesense/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/typesense/typesense"&gt;Typesense&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
A new (to me) open source search engine, with a focus on being “typo-tolerant” and offering great, fast autocomplete—incredibly important now that most searches take place using a mobile phone keyboard. Similar to Elasticsearch or Solr in that it runs as an HTTP server that you serve JSON via POST and GET—and it offers read-only replicas for scaling and high availability. And since it’s 2018, if you have Docker running (I use Docker for Mac) you can start up a test instance with a one-line shell command.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://typesense.org/"&gt;typesense.org&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/autocomplete"&gt;autocomplete&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/search"&gt;search&lt;/a&gt;&lt;/p&gt;



</summary><category term="autocomplete"/><category term="open-source"/><category term="search"/></entry><entry><title>Jeremiah Grossman: I know who your name, where you work, and live</title><link href="https://simonwillison.net/2010/Jul/22/jeremiah/#atom-tag" rel="alternate"/><published>2010-07-22T08:44:00+00:00</published><updated>2010-07-22T08:44:00+00:00</updated><id>https://simonwillison.net/2010/Jul/22/jeremiah/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://jeremiahgrossman.blogspot.com/2010/07/i-know-who-your-name-where-you-work-and.html"&gt;Jeremiah Grossman: I know who your name, where you work, and live&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Appalling unfixed vulnerability in Safari 4 and 5 —if you have the “AutoFill web forms using info from my Address Book card” feature enabled (it’s on by default) malicious JavaScript on any site can steal your name, company, state and e-mail address—and would be able to get your phone number too if there wasn’t a bug involving strings that start with a number. The temporary fix is to disable that preference.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/apple"&gt;apple&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/autocomplete"&gt;autocomplete&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/browsers"&gt;browsers&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/exploit"&gt;exploit&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/safari"&gt;safari&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vulnerability"&gt;vulnerability&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/recovered"&gt;recovered&lt;/a&gt;&lt;/p&gt;



</summary><category term="apple"/><category term="autocomplete"/><category term="browsers"/><category term="exploit"/><category term="safari"/><category term="security"/><category term="vulnerability"/><category term="recovered"/></entry><entry><title>Building Fast Client-side Searches</title><link href="https://simonwillison.net/2009/Mar/19/boselecta/#atom-tag" rel="alternate"/><published>2009-03-19T15:35:03+00:00</published><updated>2009-03-19T15:35:03+00:00</updated><id>https://simonwillison.net/2009/Mar/19/boselecta/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://code.flickr.com/blog/2009/03/18/building-fast-client-side-searches/"&gt;Building Fast Client-side Searches&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Flickr now lazily loads your entire contact list in to memory for auto-completion. Extensive benchmarking found that a control character delimited string was the fastest option for shipping thousands of contacts around as quickly as possible.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ajax"&gt;ajax&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/autocomplete"&gt;autocomplete&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/flickr"&gt;flickr&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/json"&gt;json&lt;/a&gt;&lt;/p&gt;



</summary><category term="ajax"/><category term="autocomplete"/><category term="flickr"/><category term="javascript"/><category term="json"/></entry><entry><title>freebase-suggest</title><link href="https://simonwillison.net/2008/Sep/24/freebasesuggest/#atom-tag" rel="alternate"/><published>2008-09-24T23:58:22+00:00</published><updated>2008-09-24T23:58:22+00:00</updated><id>https://simonwillison.net/2008/Sep/24/freebasesuggest/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://code.google.com/p/freebase-suggest/"&gt;freebase-suggest&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
A jQuery plugin that performs auto-completion against the Freebase JSONP API, and allows the results to be limited to specific categories or subsets.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="http://hublog.hubmed.org/archives/001752.html"&gt;Alf Eaton&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/autocomplete"&gt;autocomplete&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/freebase"&gt;freebase&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/freebasesuggest"&gt;freebasesuggest&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jquery"&gt;jquery&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jsonp"&gt;jsonp&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/metadata"&gt;metadata&lt;/a&gt;&lt;/p&gt;



</summary><category term="autocomplete"/><category term="freebase"/><category term="freebasesuggest"/><category term="javascript"/><category term="jquery"/><category term="jsonp"/><category term="metadata"/></entry></feed>