Search and Sitemaps
Learning Website with Django
Chapter 9 · Search & Sitemaps
With 4,400 pages, a visitor who knows what they want should not have to click through menus to find it, and a search
engine should be told what each site contains. This chapter adds a search box that only searches the current site,
a sitemap for each site, one canonical address for every page, and a robots.txt that keeps crawlers out
until launch.
Choosing a Search Engine
| Option | Extra to run | Good for | Used here |
|---|---|---|---|
| SQLite FTS5 | Nothing: it is inside SQLite | One server, a few thousand pages | Yes |
| PostgreSQL full-text search | PostgreSQL | The same size, once the database is PostgreSQL | Described only |
| Elasticsearch or OpenSearch | A separate server | Millions of documents, typo tolerance | No: far more than this needs |
The Index
The page body is stored as HTML, so the importer first turns it into plain text (tags removed, <script>
and <style> ignored, a space at every block tag so table cells do not join into one word). The text
goes into two FTS5 tables, each using the page's own id as its row id:
| Table | Tokenizer | Holds | Why |
|---|---|---|---|
| page_fts | unicode61 remove_diacritics 2 | Every page (4,408) | Word search; “szeretnek” finds “Szeretnék” |
| page_fts_cjk | trigram | Pages with Japanese (421) | Japanese has no spaces, so there are no words to find |
The site column is stored but not searched, so one table serves all eight sites and each search filters
on it. The importer keeps the index in step: it re-indexes new and changed pages after saving them and removes pages
from the index before pruning them. The importer's version number went up, so the first import after this chapter
re-imported every page once.
? placeholders (SQLite's own) in raw SQL sent through Django's cursor,
which expects %s. The tests passed because query logging is off in tests, and the development server
logs queries. How to avoid it: use %s with Django's cursor, and run the real command
with the real settings, not only the test suite.
Real import: 4,406 pages updated and 2 created in 38 seconds, no errors, 4,408 rows in each index table (421 in the
Japanese one). The database grew from about 99 MB to 162 MB after a VACUUM, so the indexes cost roughly
63 MB.
Turning Typed Text into a Query
FTS5 has its own query language: quotes, AND, OR, NEAR, brackets, a leading
minus, column filters like title:x. Text typed by a visitor that is not valid in it is an
error, and text that is valid can change the meaning. So the visitor's text is never put in as it is. Only
word characters are kept, each word is quoted, at most eight are used, and the last one may be the start of a word:
| Typed | Query sent |
|---|---|
polite cond | "polite" "cond"* |
"; DROP TABLE x; -- OR NEAR( | "DROP" "TABLE" "x" "OR" "NEAR"* |
!!! ??? | (nothing: no search is run) |
Results are ranked with bm25, with a title match worth ten times a body match. Each result carries a
snippet. The match is wrapped in control characters (which cannot appear in page text), the snippet is HTML-escaped,
and only then are the markers turned into <mark> tags, so page text that contains a script is shown
as text.
Japanese
The trigram table finds any run of three or more characters. One or two characters (水, 水を) are too
short for it, so those are answered by scanning the 421 Japanese pages with LIKE, with %
and _ escaped. That scan took 5 ms; it will slow down if the number of Japanese pages grows a lot.
Real Searches
| Site | Search | Results | Time | First result |
|---|---|---|---|---|
| languages | szeretnek (no accents) | 18 | 12 ms | Buying Clothes: Sizes, Fit & Returns |
| languages | Szeretnék | 18 | 3 ms | the same page |
| languages | brotchen | 1 | 1 ms | Ordering Food & Drink at a Café |
| systems | partition | 44 | 8 ms | Multi-Boot With More Than Two OSes & Advanced Partitioning |
| programming | decorat (a beginning) | 63 | 9 ms | Decorators |
| languages | ありがとう | 25 | 5 ms | Za Ji Zu |
| languages | 水 (one character) | 5 | 5 ms | Days, Dates & Time |
| ai | zzzqqq | 0 | 0 ms | (none) |
| languages | "; DROP TABLE content_page; -- | 0 | 1 ms | (none; no error) |
A search on one site never returns another site's pages: the site filter is part of every query. The
search box sits in every site's header and goes to that site's own /search/. Results come twenty at a
time with Previous and Next links, and the empty search shows a short help line. The page was checked in headless
Chrome: the matched word is highlighted inside each snippet.
The PostgreSQL Version (not run)
If the database ever becomes PostgreSQL, the usual route is a generated SearchVectorField with a GIN
index, queried with SearchQuery and ranked with SearchRank. The FTS5 migration and
functions here do nothing on other databases (they check the database vendor), so the switch would replace this
app's two modules, not the rest of the project. This was not run here.
Sitemaps
A sitemap is a list of a site's pages for search engines. Each site has its own at /sitemap.xml, built
with Django's django.contrib.sitemaps from that site's pages only, with each page's own address:
| Site | Pages | Sitemap entries | Duplicates | With a date | Size |
|---|---|---|---|---|---|
| languages | 561 | 561 | 0 | 250 | 69 KB |
| programming | 1,376 | 1,376 | 0 | 654 | 189 KB |
| ai | 187 | 187 | 0 | 27 | 21 KB |
- The limit is 50,000 URLs per sitemap; the largest site has 1,376, so one file is enough.
- A
lastmoddate appears only where the page's banner has a date. No date is invented, because a wrong date teaches a crawler to ignore the field. - Addresses with non-ASCII characters are percent-encoded (
hiragana_%E3%81%82/). - Behind the proxy the addresses are
https, because the proxy settings from Chapter 2 tell Django the original request was secure.
One Address per Page
Every page now has a <link rel="canonical"> and a <meta name="description"> (from
the banner's Topic line, or the first 160 characters of text). The important rule is that the sitemap and the
canonical link get their address from the same function, so they cannot disagree. This was checked
on real pages: 75 sampled pages on the languages site (including ones with Japanese in the address) and 60 each on
programming and ai, and every page's canonical link was in its site's sitemap. Listing pages and the front page do
not have a canonical link yet.
robots.txt
Each host answers its own /robots.txt. Until launch it says Disallow: /; setting
LW_ALLOW_CRAWLING=1 switches it to Allow: / plus a Sitemap: line naming that
site's own sitemap. Putting it behind a setting makes opening the sites to search engines one deliberate line on the
launch checklist.
robots.txt was only read with crawling off in the real run
(the on case is covered by tests). The PostgreSQL search is not run. Whether the sitemaps are accepted by search
engines can only be seen after launch.
Hands-On Exercises
Make every page searchable on its own site: turn the stored HTML into plain text, create the two FTS5 tables, and keep them in step with the importer. Run it on the real content and report the row counts, the time and the growth in database size, including any bug the real run finds.
📄 View solutionBuild the search page so typed text can never be an error or an injection: quoted words, a prefix on the last one, bm25 with the title weighted, escaped snippets with highlighting, and a Japanese path. Test hostile input and run real searches on each site.
📄 View solutionAdd a sitemap and a robots.txt for each site and a canonical link on every page, all using the same address function. Parse the real sitemaps, compare their counts with the page counts, and check sampled pages' canonical links against them.
Chapter 9 Quick Reference
- Search: SQLite FTS5, no extra server;
page_fts(words, accents ignored) andpage_fts_cjk(trigram, Japanese pages only) - The importer indexes new and changed pages and removes pruned ones; the first import after the change re-imports everything
- Never put typed text into a query: keep word characters, quote each, prefix on the last, at most eight
- Ranking:
bm25with the title weighted 10 times; snippets are escaped before<mark>is added - Japanese: 3 or more characters use the trigram table; 1–2 characters use a
LIKEscan - Django's cursor wants
%splaceholders, not?; the real run found what the tests could not - Real numbers: 4,408 pages indexed in 38 s; about 63 MB added to the database; searches take 1–13 ms
- Per-site
/sitemap.xml(50,000 URL limit) with a date only where the page has one; non-ASCII addresses percent-encoded - Sitemap address and canonical link come from the same function, so they cannot disagree
robots.txtblocks everything untilLW_ALLOW_CRAWLING=1; the PostgreSQL search and search-engine acceptance are untested