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.

Run for real, on Django 6.1 and SQLite
The project has 217 tests (all passing). Every search and sitemap below was run on the real database of 4,408 pages. The PostgreSQL version is described, not run, because this project uses SQLite.

Choosing a Search Engine

OptionExtra to runGood forUsed here
SQLite FTS5Nothing: it is inside SQLiteOne server, a few thousand pagesYes
PostgreSQL full-text searchPostgreSQLThe same size, once the database is PostgreSQLDescribed only
Elasticsearch or OpenSearchA separate serverMillions of documents, typo toleranceNo: 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:

TableTokenizerHoldsWhy
page_ftsunicode61 remove_diacritics 2Every page (4,408)Word search; “szeretnek” finds “Szeretnék”
page_fts_cjktrigramPages with Japanese (421)Japanese has no spaces, so there are no words to find
CREATE VIRTUAL TABLE page_fts USING fts5( title, body, site UNINDEXED, tokenize='unicode61 remove_diacritics 2' )

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.

A bug that only the real run found
What went wrong: the first real import crashed with “not all arguments converted during string formatting”. I had written ? 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:

TypedQuery 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

SiteSearchResultsTimeFirst result
languagesszeretnek (no accents)1812 msBuying Clothes: Sizes, Fit & Returns
languagesSzeretnék183 msthe same page
languagesbrotchen11 msOrdering Food & Drink at a Café
systemspartition448 msMulti-Boot With More Than Two OSes & Advanced Partitioning
programmingdecorat (a beginning)639 msDecorators
languagesありがとう255 msZa Ji Zu
languages水 (one character)55 msDays, Dates & Time
aizzzqqq00 ms(none)
languages"; DROP TABLE content_page; --01 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:

SitePagesSitemap entriesDuplicatesWith a dateSize
languages561561025069 KB
programming1,3761,3760654189 KB
ai18718702721 KB
  • The limit is 50,000 URLs per sitemap; the largest site has 1,376, so one file is enough.
  • A lastmod date 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.

What was not verified
Nothing was submitted to a search engine. 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

Exercise 1

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 solution
Exercise 2

Build 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 solution
Exercise 3

Add 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.

📄 View solution

Chapter 9 Quick Reference

  • Search: SQLite FTS5, no extra server; page_fts (words, accents ignored) and page_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: bm25 with 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 LIKE scan
  • Django's cursor wants %s placeholders, 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.txt blocks everything until LW_ALLOW_CRAWLING=1; the PostgreSQL search and search-engine acceptance are untested