Most contact scrapers have a setting called something like defaultCountry or region. It looks harmless. It is the fastest way to fill a database with phone numbers that are structurally valid, pass every check you run, and belong to nobody.
Here is the measurement that convinced me to delete mine.
One company, 51 countries
I crawled the contact pages of ibm.com on 2026-08-28 — up to five pages per site, three were reachable, robots.txt honoured. What came back:
| Email addresses | 37 |
| Phone numbers resolved to E.164 | 115 |
| Distinct countries in those 115 | 51 |
| Phone-shaped strings that could not be resolved to E.164 | 52 |
The 51: AT AU BE BG CH CN CY CZ DE DK EE EG FI FR GB GP GR HK HR HU IE IL KR LK LT LU LV MO MY NC NG NL NO PF PH PK PL PT RO RS SE SG SI SK TH TR TW UA US VN ZA.
That is one company's own contact pages. Not a directory, not an aggregator. The same crawl over suse.com gave 11 numbers across 8 countries, ionos.com 10 across 7.
So the premise behind a default country — "this is a US site, so bare numbers on it are US numbers" — is wrong before you write a line of parsing code. Corporate contact pages are international by nature. That is what they are for.
What a default country actually does
Take the strings that had no country code of their own and parse them as US:
import { parsePhoneNumberFromString } from 'libphonenumber-js';
let valid = 0;
for (const raw of unresolvedStrings) { // 52 of them
const n = parsePhoneNumberFromString(raw, 'US');
if (n?.isValid()) valid++;
}
// valid === 18
18 of the 52 come back valid. Not "probably valid" — isValid() returns true, .number gives a clean E.164 string, and every downstream check passes.
Most of those 18 really are US numbers, so far so good. The problem is the ones that are not. From the suse.com crawl, a Budapest number printed on the page as a local string:
parsePhoneNumberFromString('361-489-4600', 'US').isValid() // true
parsePhoneNumberFromString('361-489-4600', 'US').number // '+13614894600'
+36 1 489 4600 is Budapest. +1 361 489 4600 is a real, dialable US area code. The default country did not fail loudly — it produced a different, perfectly valid number in a different country, and handed it to you as a fact.
A validator cannot save you from this. It validates the number you constructed, not the assumption you made. It is not lying: +13614894600 genuinely is a valid US number. It just is not the number that was printed on the page.
Sometimes the validator does catch it — 0180 0132 00049 from the same site parses as US and comes back invalid, so that one gets thrown away. But you cannot rely on being saved by luck. The failure mode you never see is the one that stays.
The shape that does not lie
Two fields, one rule:
phones E.164, only for numbers that carried their own country code
phonesLocal the raw string, essentially as printed, when one could not be resolved
The rule: never synthesise a country code. If the page did not say which country, you do not know which country.
That sounds like losing data. It is not — it is moving it. The 52 unresolved IBM strings are still in the output, still usable by code that has context you do not. A CRM that already knows the account is in Malaysia can resolve 1800-88-8558 correctly; your crawler cannot.
What you lose is the illusion of 52 extra E.164 numbers. That illusion is expensive, because nobody discovers it until someone dials.
While you are there: emails are obfuscated on purpose
Two patterns worth handling, both cheap:
-
name [at] example [dot] comand its bracketed variants. A plain/\S+@\S+/extracts nothing usable from those —[at],(at)and{at}forms produce no match at all. -
Cloudflare email protection. The address is replaced by
<a href="/cdn-cgi/l/email-protection" data-cfemail="a1c4d9c0ccd1cdc4e1...">. It is an XOR encoding: the first byte is the key, each following byte is a character XORed with it.
function decodeCfEmail(hex) {
const key = parseInt(hex.slice(0, 2), 16);
let out = '';
for (let i = 2; i < hex.length; i += 2) {
out += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16) ^ key);
}
return out;
}
This is not a security control — Cloudflare ships email-decode.min.js to every browser to undo it client-side, and their docs note it is enabled automatically on sign-up. It is there to stop naive harvesters, and it works: six lines is the entire difference between finding a site's contact address and reporting that it has none.
And when the fetch fails
The fourth site in my run was chiyodacorp.com. It returned:
{ "domain": "chiyodacorp.com", "ok": false, "reason": "fetch failed",
"emails": [], "phones": [], "phonesLocal": [] }
Not an absent row. Not an empty row that reads like "this company publishes no contact details". A row that says which site failed and why, so the next run can retry that one and only that one.
A crawl that quietly drops what it could not reach reports a smaller, cleaner, wronger world every time.
The short version
- Delete the default-country setting. It is not a convenience, it is a fabricator.
- Keep E.164 and un-resolvable strings in separate fields, and never invent the difference.
- Handle bracketed obfuscation and Cloudflare's XOR, or accept that you are silently missing contacts.
- Emit a row with a reason for every site you failed on.
None of this is hard. All of it is skipped by default, which is why so many contact datasets are confidently wrong.
Written with AI assistance. Every count, country list and parse result above came from a live crawl executed on 2026-08-28 before publishing.
Top comments (0)