跳到正文
原文
Google AI:DEV 作者专属(RSS)· Daniel Pertu·· 9 小时前AI 评分37

Notifio 如何用两位数字规则区分租房订阅页与房源页

€9,99 per month is a paywall, €1,450 per month is the rent, and two digits is the whole difference

AI 导读

Notifio 的自动回复功能在检测到支付页面时会拒绝填写联系表单,其支付守卫完全基于确定性规则、不调用模型。它不匹配 "ideal" 等支付方式词,而是识别 Stripe、Mollie、Adyen 等 PSP 脚本域名和 `autocomplete="cc-number"` 等卡片字段属性,并对 HTTP 402 状态码硬拦截。

正文

Notifio watches rental search pages and, if you have bought the upgrade for it, fills in the contact form on a new listing for you. That feature exists because on a busy housing market the first few replies are the only ones that get read, and a human cannot be at a keyboard at 03:40.

It also means a program is typing in somebody's name on a website we do not control. So the interesting part of the feature is not the typing. It is the list of situations where it has to refuse, and the hardest of those to detect is money.

Why money is hard here specifically

A rental listing page and a "subscribe to contact this landlord" page are made of the same words. Both are about a property, both quote a monthly amount in euros, both have a button that looks like it starts a conversation. A page that wants your card details is not visually or textually distinct from a page that wants your enquiry, if you only look at keywords.

And the cost of being wrong is asymmetric in a way that decides the whole design:

  • Refuse a reply that would have been fine: the user misses one listing, and the app can tell them exactly why it stopped.
  • Send a reply that walks into a payment flow: at best we have wasted the attempt, at worst we have signed somebody up for a subscription they did not ask for.

So every guard in this module is tuned to be a little too cautious, and all of them are deterministic. Nothing here asks a model. The point of a refusal is that it is predictable and can be explained after the fact.

The keyword version, and why "ideal" killed it

The obvious first version of a payment detector is a list of payment method names. In the Netherlands, where a lot of our users are, the dominant one is iDEAL. It is on the checkout page of nearly every Dutch site.

The problem is in our own comment:

/**
 * 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.
 */

Idealista is one of the largest listing sites in Spain, and we have a page about it at notifio.app/alerts/idealista. Its name contains the payment method. A bare substring match sees idealista.com in the canonical link, the asset hosts, the analytics config and about forty other places in the HTML, and concludes that the listing page is a checkout.

Adding word boundaries does not rescue it either, because "ideal" is also ordinary rental copy. "Ideal for students", "ideal for a couple", "ideale locatie". A detector that refuses to reply whenever a landlord writes an enthusiastic sentence is not a detector, it is a coin flip with a vocabulary.

What we key off instead

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,
  /paypal\.com\/(sdk|smart)/i,
  /js\.braintreegateway\.com/i,
  /x\.klarnacdn\.net|klarna\.com\/(sdk|web-sdk)/i,
  /autocomplete=["']?cc-(number|exp|csc)/i,
  /name=["']?(cardnumber|card_number|cc-number|creditcard)/i,
  /id=["']?(card-number|cardNumber|card-element)/i,
  /data-(stripe|adyen|mollie)=/i,
];

Two kinds of thing, and neither of them is a word the page is trying to say to a human.

The first group is payment service provider script hosts. A page that has loaded js.stripe.com is a page that intends to take a card, regardless of what its copy says. Nobody loads a payment SDK rhetorically.

The second group is card field attributes: autocomplete="cc-number", a field named cardnumber, an element with id="card-element". These are markers that exist for the browser's benefit, not the reader's. A rental description cannot accidentally contain them, which is exactly the property a guard wants.

The difference between the two versions is the difference between asking "does this page talk about payment" and "is this page built to take a payment". The second question has an answer in the markup.

Alongside that, the status code gets a hard stop of its own, and the money branch has no override anywhere in the codebase:

// 2. Anything touching money: hard stop, no overrides.
if (status !== undefined && PAYMENT_STATUSES.has(status)) {
  return { ok: false, block: 'payment', detail: 'HTTP 402 Payment Required' };
}
if (hasPaymentMarkers(html)) {
  return { ok: false, block: 'payment', detail: 'Payment form detected on page' };
}

The two digit rule

The subtler case is the site that wants a subscription before it will let you message a landlord. There is no card form on the page yet, just a sentence about becoming a member. Text is all we have.

Wording patterns catch a lot of it in the six languages that cover most of the European rental web ("subscription required", "premium members only", "upgraden om te reageren", "kostenpflichtig", "hazte premium", "diventa membro"). But the most reliable signal that a page is selling you a subscription is that it prints a price per month, and that is also the most reliable signal that a page is advertising a flat:

/**
 * The price of a subscription to the site itself: "€9,99 per month", "9.99 p/m",
 * "€ 15 per maand", "15 € pro Monat".
 *
 * Capped at two digits on purpose. A rent is three or four figures and is very
 * often written exactly like this ("€ 1450 per month"), so matching any amount
 * makes the guard refuse ordinary listings. The amount is the only thing that
 * separates the two. Nothing in the European rental market is under €100 a
 * month, and no listing site charges three figures a month for access, so the
 * boundary is not close to either case.
 */
const SUBSCRIPTION_PER_MONTH = [
  /[€£$]\s?\d{1,2}([.,]\d{1,2})?\s*(\/|per\s|p\/)\s*(month|maand|monat|mes|mois|mese|mnd|m\b)/i,
  /(^|[^\d.,])\d{1,2}([.,]\d{1,2})?\s?[€£$]\s*(\/|pro\s|par\s|al\s|per\s)\s*(month|maand|monat|mes|mois|mese)/i,
];

\d{1,2} is the entire guard. Two identical sentence shapes, and the number of digits in the amount is the only thing that tells them apart.

I like this one because it is a case where the domain knowledge is doing the work and no amount of cleverness substitutes for it. You cannot tell these two strings apart by looking harder at the grammar. You can tell them apart because you know that nobody rents a room in Amsterdam for €15 a month and no listing site charges €450 a month for an account. Both of those facts are about the world, not about the text, and the gap between €99 and €100 has a large empty region on either side of it, which is what makes a cheap threshold safe rather than reckless.

That is also why it is written with a comment that long. A future reader looking at \d{1,2} with no explanation would widen it within a week, on the entirely sensible grounds that prices can have more digits, and then the feature would start refusing every ordinary listing with a rent on it.

Every refusal is recorded with a reason

The guards run in order of severity, and the first refusal wins: off domain, then money, then an anti-bot wall, then a login wall, then a signup wall, then a site paywall, then "is this even a listing". Each one maps to a status that gets written to a local ledger:

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';
  }
}

Seven statuses instead of one skipped is not bookkeeping for its own sake. A user whose auto reply did not fire on a listing they cared about asks exactly one question, and it is "why not". "Site requires a paid subscription" and "left pararius.nl for someone-else.com" are answers. "Skipped" is not, and a feature that cannot explain its own silence gets switched off.

The two neighbours of this post, from the same file: The guard that stops our automation from following a link is about the domain lock, and Our page check returns a score out of four, and the decision does not use it is about the last guard in the list.

What the feature does when it does go ahead is described on notifio.app/pricing and notifio.app/help, and if you want the human version of the same problem, the first message to a landlord guide is what the generated message is trying to live up to.

来源:Google AI:DEV 作者专属(RSS) · dev.to