Notifio can reply to a rental listing for you. You do it yourself once on a given site, the app records what you did, and after that it repeats your reply on new listings on that site. There is no model anywhere in that path, because the action is irreversible and addressed to a stranger.
Which puts all of the risk in one question: is this page safe to act on at all? That question is answered by seven guards that run before anything is typed or clicked, in order of severity, first refusal wins.
export type SafetyBlock =
| 'offsite'
| 'payment'
| 'captcha'
| 'login'
| 'signup'
| 'paywall'
| 'not_listing';
The first one, the domain lock, has its own post: it refuses to act on a page that has left the site the saved search belongs to, which is the aggregator hand-off case. This is about the other six, and specifically about the fact that writing them was mostly an exercise in not refusing the normal case.
The order is the design
The numbered comments in evaluatePageSafety are the whole policy:
- Domain lock, the aggregator hand-off case.
- Anything touching money, a hard stop with no overrides.
- Anti-bot wall, back off rather than fight it.
- Session expired.
- Needs a new account, and we never create accounts.
- Needs a paid plan on the site itself.
- Is this even a listing?
Severity order matters because the refusal is also the explanation. Every block maps one to one onto a status written to the reply ledger, one of the 21 statuses the engine can record:
export function blockToReplyStatus(block: SafetyBlock) {
switch (block) {
case 'offsite': return 'skipped_offsite';
case 'payment': return 'skipped_payment';
case 'captcha': return 'skipped_captcha';
case 'login': return 'skipped_login';
case 'signup': return 'skipped_signup';
case 'paywall': return 'skipped_paywall';
case 'not_listing': return 'skipped_not_listing';
}
}
A page that is simultaneously behind a login and not a listing gets recorded as a login problem, because that is the one the user can do something about. Running cheapest first would have produced the opposite and much less useful answer.
Every pattern set is six languages
Notifio is not a Netherlands product, or a UK product. The sites it monitors are spread across the European rental web, so a guard written only in English would silently stop guarding the moment the page was in German.
const SIGNUP_PATTERNS = [
/\b(create (an )?account|sign ?up|register (now|to|for)|registration required)\b/i,
/\b(account aanmaken|registreren|maak een account)\b/i,
/\b(konto erstellen|registrieren|jetzt registrieren)\b/i,
/\b(crear (una )?cuenta|regístrate|registrarse)\b/i,
/\b(créer un compte|s'inscrire|inscription)\b/i,
];
English, Dutch, German, Spanish, French and Italian, repeated for login walls, paywalls, anti-bot interstitials, address formats, price formats and contact affordances. It is the least clever code in the app and it is the reason the guards work outside the one market we started in.
There is a trap in doing it this way, though, which is that more patterns means more chances to match a page that was perfectly fine. All three of the following were real near misses.
A login link is in every header
The first version of the login wall check looked at the whole page body for the words "log in". Every rental site on earth has a "Log in" link in its header, including on a listing page you are already signed in to. That check refuses everything.
export function isLoginWall(url: string, title: string, bodyText: string): boolean {
if (anyMatch(LOGIN_URL_PATTERNS, url)) return true;
// Title-only for text: a "Log in" link exists in the header of nearly every
// site, so matching body text alone would false-positive constantly.
if (anyMatch(LOGIN_TEXT_PATTERNS, title)) return true;
return anyMatch(LOGIN_TEXT_PATTERNS.slice(1), bodyText) !== null;
}
Three signals with three different levels of trust. The URL is the strongest, because a path segment of /login or /inloggen is not decoration. The page title is next: a header link does not reach the title. The body is searched only with slice(1), which drops the generic pattern and keeps the sentences that actually mean you are locked out, such as "you must be logged in" or "je moet ingelogd zijn".
The payment keyword that matches a listing site
This is my favourite bug that never shipped. The obvious way to detect a payment page is to look for payment-method words, and in the Netherlands the dominant one is iDEAL.
Idealista is one of the largest rental portals in Spain. We have a page about it. A substring check for "ideal" refuses every listing on it, and the reply never happens, and nothing in the logs says anything more useful than "payment form detected".
So the payment guard does not look at payment words at all:
/**
* Payment-provider and card-field markers. Deliberately narrow: a bare "ideal"
* keyword would match Idealista (a real listing site), so we key off PSP script
* hosts and card input attributes instead of payment-method words.
*/
const PAYMENT_HTML_MARKERS = [
/js\.stripe\.com|checkout\.stripe\.com|hooks\.stripe\.com/i,
/\.mollie\.com|mollie\.nl/i,
/(checkoutshopper|live\.adyen|test\.adyen)\./i,
/autocomplete=["']?cc-(number|exp|csc)/i,
/name=["']?(cardnumber|card_number|cc-number|creditcard)/i,
// ...
];
A script host and an autocomplete=cc-number attribute are things a page has because it is taking a card, not because of what it is about. The guard went from reading prose to reading structure, and structure does not have homonyms.
The same logic drives the hard stop rule above it: HTTP 402 and these markers are the two cases where there is no override and no user setting. Everything else in the app has a "try anyway" path somewhere. Money does not.
The number that separates rent from a subscription
The paywall guard has to notice that the site itself wants a subscription before you can message anybody. Prices are the clearest signal, and they are also where the rent lives, which is the problem: a rental listing is a page covered in prices written exactly like a subscription price.
The answer is a digit count, and because it already has a post of its own I will summarise it in one line: the subscription pattern caps the amount at two digits, because nothing in the European rental market is under 100 a month and no listing site charges three figures a month for access.
The last guard, the one that asks whether this is a listing at all, returns a score it then does not use.
Refusing is not failing
One more thing that is easy to get wrong in a guard layer: an anti-bot interstitial is not an error to be retried immediately. It is a request to go away for a while, and the right response is to stand the whole host down on a ladder rather than to try the same page again a second later.
The deterministic part is the point of all of it. Every refusal above is a line you can read, a pattern you can run against a page, and a status you can look up afterwards. None of it is a judgement, so none of it can be talked into a different answer on a Tuesday.
What this looks like from outside
Auto reply is a one-time add-on to the base licence, both at notifio.app/pricing, and every per site page is honest about where it works: the pages carry a field specifically for that, so notifio.app/alerts/wg-gesucht, notifio.app/alerts/idealista and notifio.app/alerts/pararius each say what the reply engine can and cannot do on that site rather than claiming it works everywhere.
notifio.app/help explains what happens when a guard refuses, which from the user's side is a line in the activity log and no message sent.
Top comments (0)