HousingBot
A Telegram bot that watches the Eindhoven rental market on Pararius, Kamernet and Vestide, runs without a browser in a 0.5 GiB container, and checks every note a free language model writes before posting it.
| Sources | Pararius (HTML), Kamernet (Next.js JSON), Vestide (HTML); five search pages every 10 minutes |
| Fetching | curl_cffi with 8 browser TLS profiles, 120 s budget per URL |
| Host | a free container with 0.5 GiB RAM and 1 GiB disk, from 8 July 2026 |
| Storage | SQLite for dedup, a full advert archive, a geocode cache and the settings |
| Notes | free OpenRouter models in a fallback chain, every note checked before posting |
| Interface | 13 Telegram commands in six languages, and a read-only JSON mode |
| Code | Python, about 3,100 lines plus 560 lines of translation tables |
| Tests | 165 offline pytest tests, the parsers checked against three captured search pages |
Every ten minutes HousingBot reads five search pages and posts each new advert under a rent and size limit to a Telegram group: a photo album with the facts that decide whether it is worth answering. A second message follows with a short assessment from a free language model. Free models fail in ways that look like answers, so every note is checked before it goes out. The bot was built for a free container with 0.5 GiB of RAM, too small for a headless browser, and fetches pages with browser TLS fingerprints instead. I built it between 8 July and 4 September 2026.
8 and 9 July: three sites in one small container
The first version read Pararius and Kamernet; Vestide, a student-housing
provider in Eindhoven, followed the same evening. The three publish in three
formats. Pararius serves HTML search cards. Kamernet is a Next.js app whose
whole search result sits in the __NEXT_DATA__ JSON blob in the page,
student-housing flag included. Vestide publishes a public room list. For each
new advert the bot opens the detail page once, for the photos, the full text
and, on Kamernet, the landlord’s tenant preferences. Addresses are geocoded
through Nominatim (one request a second, cached in SQLite) into a straight-line
distance to campus.
The bot went onto a free container the same day: 0.5 GiB of RAM and 1 GiB of disk, where Playwright with Chromium (around 400 MB of disk and 500 MB of RAM) does not fit. Pages come through curl_cffi, an HTTP client that presents the TLS fingerprint of a real browser, working through a chain of five browser profiles with backoff between passes.
An alert is an album of up to six photos with the rent, the size, a map link
and tags: new, student housing, free to respond (no paid Kamernet account
needed), number of people and the landlord’s age band. On 9 July the interface
went to six languages (English, Dutch, German, French, Ukrainian, Russian),
each with its own Telegram command menu. The same day I fixed three silent
losses: a failed Telegram send had already marked its advert as seen, a
/maxrent change never reached the Pararius search URL, and a failed geocode
stayed cached for good. An advert is now marked as seen only after Telegram
confirms the send, so a failed send is retried on the next scan instead of
lost. From then on, a source that returned nothing for three scans in a row
triggered an outage message. A read-only JSON mode runs the same scan without Telegram and
without touching the dedup table, so another program can read the same results
without making the live bot miss or repeat an alert.
12 to 16 July: a model layer, shaped by its failures
On 12 July each alert gained a second message: a note from a free OpenRouter
model that reads the whole advert against a free-text tenant profile saved in
the group, and answers in the group’s language. The alert always goes first, so
a slow or failed model never delays it. Key, model and profile are set by
Telegram command, and the /aimodel menu offers only models on OpenRouter’s
live free list. Every scraped advert is archived in full, and /chat opens a
conversation in which a model sees the profile and the 60 most recently updated
adverts, and can call open_listing to fetch any of them again and report it
as available, gone or impossible to check.
On 13 July the notes started reading “User Safety: safe”. Under rate limiting,
the chosen model had fallen back to openrouter/free, a router that picks a
model itself, and it had routed to a content-safety classifier. I replaced that
fallback with a curated chain of general models with the router last, took
guard models out of the menu, and made the bot reject guard-shaped replies.
On 14 July the Pararius outage message fired and cleared eight times in one night: bot-protection challenges on the datacenter address, each long enough to empty three scans in a row. The fetcher went to eight profiles (Chrome, Safari, Firefox, Edge) with a growing backoff, and the outage message now waits for six empty scans in a row, about an hour, with a second message on recovery.
On 15 July a reasoning model in /chat sent its scratchpad to the group
verbatim. Chat requests now ask for hidden reasoning, and a reply that names two
or more of the internal advert ids from the prompt goes to the next model
instead. The chat also gained tools to search the whole archive (by rent, size,
source, place and keyword), to measure the distance to any address, and to
rescan on demand.
A chat log from 16 July showed several faults at once. The default model, gpt-oss-120b, had stopped being free and answered 404. It led the fallback chain, and a 404 counted as fatal, so every note failed, the retry included. I narrowed the fatal errors to account-level ones (401, 402, 403) and transport failures, and every note is now checked before it posts: it goes to the next model if it hit the token ceiling (a note is asked to stay under 400 characters, so reaching the limit means the model was still thinking), reads as leaked reasoning, is a guard verdict, or is in the wrong script for the group’s language. Reasoning-first models ignored the request to hide their reasoning, so I moved the default to Qwen3 Next 80B, an instruct model.
The same log showed three data problems. Pararius keeps withdrawn adverts online behind an “archived” label that only the detail page shows, and one had reached the group still offering “Available from: 1-4-2020”; archived adverts are now skipped. Kamernet’s default age band, 16 to 99, had been shown as a landlord preference; any band spanning 18 to 60 or wider now counts as none. Asked about women-only landlords, the chat model had invented a table of site filters and recommended a site the bot does not read; its prompt now names the three sources and forbids describing any site’s interface. An advert whose landlord excludes the configured age or gender still gets its alert and warning line, but no model call.
26 August to 8 September: a note that read the fine print, then an audit
On 26 August one note read in an advert’s Dutch text that the room went only to members of one student association, or to people joining it the next year: a condition no structured field on the site carries.
On 4 September the whole repository went through an audit, and its findings
were fixed the same day. Scraped text had gone into Telegram’s HTML mode
unescaped: one literal < or & made Telegram reject the message, and because
an advert is marked as seen only on a successful send, it was fetched and
geocoded again on every scan. The archive had treated a price of 0 as data, so
a moved selector overwrote stored prices with zero, and the chat search, which
needs a price, lost those adverts for good; each field now has an explicit
merge rule. A fetch had no overall time limit: four passes over eight profiles
at a 30-second timeout could hold the single-threaded loop for about 16 minutes
on one URL, with every command unanswered. It now has 120 seconds per URL and
15 per request. A block comes back within milliseconds, so the whole profile
chain, backoff included, still fits inside that budget.
An advert with no parsed rent still passes the rent filter, so a broken price
selector would let every over-budget advert through. If half or more of at
least five adverts from one source lose their price in one scan, the group
hears at once. Dependencies are pinned to exact versions: an older curl_cffi
lacks the newer browser profiles and fails the same way a block does. The
parsers are tested against one real captured search page per site, trimmed by
deletion only, with exact field assertions that break if
listing-search-item, totalRentalPrice or detailId is renamed. The suite
has 165 tests and runs offline.
On 8 September the bot had been up for four days since its last restart and was tracking 168 adverts.