I keep a list of 120 small businesses in the province of Messina. Hotels, B&Bs, restaurants, boat rentals, a few dental practices. Before I walk into one of them I want to know one thing: does this business have a website of its own, and if so, is it any good?
That question is easy for a person and hard for a script. Search the name of a B&B and you get back eight URLs. One might be theirs. The other seven are paginegialle.it, booking.com, tripadvisor.it, and three Sicilian directories I had never heard of before this project. The script has to decide which is which, and the two possible mistakes do not cost the same.
If it misses a real site, I lose two minutes checking on my phone. If it tells me guidasicilia.it/scheda/hotel-il-mulino is their website, I walk in and say "I looked at your site" about a page somebody else built and monetises. That conversation is over before it starts, and there is no version of it where I recover.
So the scanner runs under one rule: every promotion arrives with its reasons attached, and an unproven domain stays unproven. Everything below is downstream of that rule, including the part where I was wrong.
The first design, and the bottleneck it produced
The original check was one line of reasoning. A domain belongs to the business if a distinctive token of the business name appears in the registrable domain. hotelilmulino.it for Hotel Il Mulino. Anything else went into a bucket the report calls look at this before you speak.
GENERIC = {"hotel", "bb", "casa", "villa", "ristorante", "capo", "orlando", "srl", "di", "il"}
def tokens_of(name: str) -> list[str]:
"""Words that could tell one business apart from its neighbours."""
words = re.findall(r"[a-z0-9]+", fold(name))
return [w for w in words if len(w) > 3 and w not in GENERIC]
def name_in_domain(url: str, name: str) -> Gate | None:
flat = re.sub(r"[^a-z0-9]", "", fold(registrable(url)))
for token in tokens_of(name):
if token in flat:
return Gate("name_in_domain", PROMOTE, f"{registrable(url)} carries '{token}'")
return None
Conservative, and it worked. It also left me with this, counted across the ten report files on 31 July:
| Verdict | Rows |
|---|---|
| BY HAND | 55 |
| HOT | 24 |
| SKIP | 22 |
| CHECK BY PHONE | 18 |
| WARM | 1 |
Forty per cent of the book needed a human pass. When I broke the 55 down by the sentence the report printed, it was not five problems. It was one:
| Cause | Count |
|---|---|
| A candidate domain that could not be proven theirs | 48 |
| No trace online at all | 18 |
| Their site blocks automated requests (401/403/999) | 7 |
| Name too generic to search | 0 |
Forty-eight leads where the search had found a plausible domain and the script could not commit to it. One bottleneck, one place to push.
Three signals that a portal cannot fake
The instinct was to read the page instead of only the domain, and three signals looked strong enough to promote on.
Contact identity on the page's own domain. A business site carries info@nomehotel.it. A directory carries info@paginegialle.it no matter whose page you are reading. Cheap, one regex, and apparently unfakeable.
def same_domain_contact(site) -> Gate | None:
page = registrable(site.url)
found = {}
for match in MAILTO.finditer(site.body):
found.setdefault(registrable(match.group(2)), match.group(1))
# Many addresses on many domains means the page is listing other people's contacts.
if not page or len(found) > 2 or page not in found:
return None
return Gate("same_domain_contact", PROMOTE, f"{found[page]} on the page's own domain")
Structured data, read by shape. Businesses and directories both emit schema.org JSON-LD, and I thought the shape separated them cleanly. A business emits one LocalBusiness or Hotel node under its own name. A directory emits ItemList, SearchResultsPage, or several business nodes under several different names.
Locality. An address.addressLocality naming the right town, as an independent second vote.
Promotion needed the name-in-domain gate, or any two of the others. The tests passed, and the logic was defensible in every direction I could think to argue it.
The measurement
The scan needs a search API, and on the day I finished the gates Brave returned 402 Usage limit exceeded. The five-dollar monthly cap was spent and would reset the following day.
That turned out to be lucky, because it forced the cheap check first. The 48 unresolved leads already had their candidate URLs written into the ten report files from previous runs. I could fetch those pages and run the new gates over them with no search at all. Not a fresh scan, but a replay against the exact set of leads the change was built for.
Ten candidates promoted. All ten were directories.
Seven guidasicilia.it pages, plus hotfrog.it, piatti.menu and getsicilyhotels.com. Every single one would have put the sentence "I had a look at your site" in my mouth about somebody else's page. The gates had a hundred per cent false positive rate on the only population that mattered.
Ten is a small number, and if the ten had been a mix I would have called it noise and tuned a threshold. They were not a mix. The failure was structural, and the sentence that explains it is the one I should have written on the whiteboard before I started:
Content about a business is not evidence of who owns the domain the content sits on.
A directory's page about Hotel Il Mulino reproduces every content signal a real site produces. It has to. Its entire product is being a convincing page about that hotel. It carries a contact address, though on its own domain, which my "unfakeable" gate read as proof of its identity rather than a failure of the hotel's. It emits a single LocalBusiness node with the hotel's name and the hotel's addressLocality, because that is what makes it rank. Three signals, three confirmations, zero information.
The gates were not badly implemented. They were measuring the wrong noun. They answered is this page about them, and I needed is this domain theirs.
What replaced them
The content gates all still run. Their weight is now zero.
PROMOTE = 2
CORROBORATE = 0 # recorded next to the lead, never enough to promote on
VETO = -99
That looks like deleting them, and it deliberately is not. A fired gate is a claim I can check against what happened when I walked in the door; a verdict on its own is an opinion. The report prints every gate that fired under a prove: line whether or not it moved the verdict, and when I have enough door outcomes to join against, the zero-weight gates are the ones with something to say about which signals actually predicted a wasted trip.
Only the domain promotes now. Two structural signals replaced the lost promotions, and neither of them reads page content as evidence of ownership.
One run's own search results are evidence about each other. A hardcoded aggregator blocklist can never be complete; I learned that when reserving.com slipped through the first run, and again when reteimprese.it came back as a candidate for nine leads out of twenty in a single scan. guidasicilia.it came back for seven. Any domain a search offers for two different businesses is a directory, for both of them, and the run can work that out without me maintaining a list.
def reconcile(businesses) -> None:
"""One domain cannot be the own site of two different businesses."""
offered = {}
for business in businesses:
for url in business.unverified:
offered.setdefault(registrable(url), set()).add(business.name)
shared = {domain for domain, names in offered.items() if len(names) > 1}
for business in businesses:
for url in list(business.unverified):
if registrable(url) in shared:
as_platform(business, url, Gate("shared_domain", VETO, "offered for another lead"))
This applies to candidates only, never to a proven site. Two B&Bs under one owner can legitimately share a domain, and telling someone they have no website when they have one is the expensive direction.
A candidate proved to be a directory is worth more than an unproven one. This is where the yield actually came from, and I nearly missed it because I was thinking about promotion. A lead whose only web presence is a guidasicilia page is not an unsolved lead. It is a solved lead with a good opening line: I searched for you from my phone and I find you on guidasicilia, not on a site of your own. Anyone looking for you by name lands on them. That sentence is true, checkable in front of the owner, and it is the pitch.
So proving a candidate is a directory moves the lead from BY HAND to HOT rather than dropping it.
Calibrating the veto against known-good pages
The veto had to be aggressive enough to catch directories and never fire on a real business site. I had a labelled set to test that against: the 24 sites the scanner had already proven belonged to businesses via the domain gate. Three shapes survived with no false vetoes among them.
The invitation. Directories ask the owner to come and claim the page, and businesses never do. rivendica questa scheda, aggiungi la tua attività, altre strutture a. guidasicilia writes it without the article, SEI TITOLARE DI QUESTA ATTIVITÀ?, which is the kind of thing you only find by reading the pages that beat you.
Link count. Distinct internal links on the page. The 24 proven business sites topped out at 74, a dental practice with a long service menu. The directories ran 89 to 202. The threshold sits at 90, in the gap, because a false veto costs more than a missed one.
Stacked reviews. Three or more Review nodes in the JSON-LD. Not one proven business site had a single one.
One more thing came out of the same pass. Al Taudì and ChéRi both own the obvious domain and neither was matching, because an accented token can never be a substring of an ASCII domain. Folding accents before any token comparison recovered chericapodorlando.it, which is a real site belonging to a real restaurant that the scanner had been calling unproven for weeks.
def fold(text: str) -> str:
return "".join(c for c in unicodedata.normalize("NFKD", text.lower())
if not unicodedata.combining(c))
The result
Replayed over the same 48 leads, so it is a projection of the book rather than a fresh scan:
| before | after | |
|---|---|---|
| BY HAND | 55 | 33 |
| HOT | 24 | 46 |
| SKIP | 22 | 22 |
| CHECK BY PHONE | 18 | 18 |
| WARM | 1 | 1 |
Of the 48: twenty-one became platform listings with a true opening line, one was promoted to its own site (chericapodorlando.it, checked by hand and correct), and twenty-six are still BY HAND. No wrong promotion survives.
Twenty-six unresolved leads is not a triumph. It is the honest floor of what a search API and two HTTP fetches can settle, and the remaining ones need a phone call. I would rather have 26 leads I know I have to check than 36 promotions I would have to trust.
What I would carry to the next heuristic
Measure against the population the change is for, not the population that is easy to reach. The gates passed their unit tests. They passed on the examples I invented. They failed on all ten real cases, and the real cases were sitting in a file I had already written.
Ask what the signal is evidence of. All three content gates were true statements about the page. None of them was a statement about the domain. That gap is invisible while you are writing the regex and obvious the moment you look at a directory's output.
Let the cost asymmetry set the threshold, and say the asymmetry out loud. Missing a promotion costs me two minutes. Inventing one costs me the conversation. Every threshold in this scanner sits on the safe side of a measured gap for that reason, and the two tests I will not let anyone delete are the ones asserting that an unproven candidate produces no claim at all.
Keep the evidence, not just the verdict. The zero-weight gates cost one line each in the report and they are the only thing that will let me tell, six months from now, which signals predicted a door worth knocking on.
The scanner is a Python script I run against my own lead list before going out to sell. The measurement above was taken on 31 July 2026 against 120 real businesses.
Disclosure: the implementation is AI-produced. I specify what it has to do, run it, read the output and decide what changes, and I do not hand-write the Python. The measurement, the decision to throw out the first design, and the conclusions drawn from it are mine. This article was drafted with AI assistance.