Valerii Kozhevets about work study certificatescerts projects tools

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.

2026 softwareAI

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.

a Telegram alert with four photos of a student-housing building, the rent, the size and the source, followed by a note from the model; the location line and several note lines are blacked out
A Vestide alert on 5 September 2026, with Vestide's own photos. The black bars cover the distance line and the note lines that quoted the tenant profile.

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.

a flow diagram in eight boxes: five search pages, fetch, parse and filter, seen before, detail page, Telegram alert, free model chain, check then post, with a loop from the check back to the model chain
One scan after the audit, in a single Python process. The model stage is skipped when notes are switched off or the landlord excludes the configured age or gender.

On 8 September the bot had been up for four days since its last restart and was tracking 168 adverts.

a Telegram conversation: a status message with uptime, the number of listings tracked and the last scan, the current filters, and a language menu of six flags
8 September 2026. The language menu changes the bot's own text; advert text stays as the landlord wrote it.
the AI match status showing the model in use and a saved profile, then the help message listing thirteen commands
The same session a minute later, with Nemotron 3 Ultra in place of the Qwen default.