I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them is the unglamorous workhorse of the set: you give it a list of domains, it gives you back one flat row per domain — what the site runs (CMS, e-commerce, analytics, pixels, payments, hosting, chat), plus the e-mails, phone numbers and social profiles printed on the page it already had to fetch.
The technology half gets all the attention. The contact half is where the embarrassing bugs live: a wrong contact is still a valid-looking string, and nobody notices until they mail it.
Here is the one I found this weekend, measured on 40 live home pages on 26 October 2026.
Eighteen companies, one Facebook page
I pulled 40 real sites: 21 from a published gallery of small-business Squarespace sites (a skin clinic, a bakery, a notary, a juice brand, a surf shop) and 19 bigger names from earlier runs — Stripe, Shopify, Notion, Canva, Allbirds, Figma, Hubspot, Gymshark and friends. Then I looked at the facebook field.
facebook field, 40 sites:
a real page 17
facebook.com/2008 18
facebook.com/pages 1
empty 4
Eighteen unrelated companies, one answer. facebook.com/2008 is not a page; it is a 404 with a shrug. And it is not a parsing accident on a weird site — it is the single most reliable thing in the whole sample.
The cause is one attribute in the opening tag:
<html xmlns:fb="http://www.facebook.com/2008/fbml" lang="en-US">
That is the Facebook Open Graph XML namespace, and the Squarespace template emits it on every page it builds. So on narcisorodriguez.com the first occurrence of the string facebook.com sits at byte 91. The company's actual page, facebook.com/NarcisoRodriguezStudio, sits at byte 11,847, in the footer, where a human would look.
My extractor used a non-global regex and String.match, which returns the first hit:
const SOCIAL = {
facebook: /facebook\.com\/(?!(tr|sharer|plugins))[A-Za-z0-9_.-]{2,}/i,
// …
};
const m = html.match(re);
if (m) socials[k] = `https://${m[0].replace(/^https?:\/\//, '')}`;
The negative lookahead is there because facebook.com/tr?id= (the Meta Pixel endpoint) and facebook.com/sharer had already burned me. So I knew the failure mode — I just kept patching it one reserved word at a time, and 2008 was never going to make that list by guessing.
Of the 40 pages, 19 ran Squarespace and 18 of those carried the namespace (one newer template had dropped it). Zero non-Squarespace sites carried it. A platform-specific artefact in a platform-shaped sample — which is exactly what a prospect list of small businesses is.
First match wins is the bug, not the regex
The repair is not a longer blocklist. It is to stop treating the first match as the answer: capture the handle, read every occurrence, and take the first one that can plausibly be a profile.
facebook: /facebook\.com\/(pages\/[A-Za-z0-9_.-]{2,}\/\d+|[A-Za-z0-9_.-]{2,}(?![A-Za-z0-9_.-])(?!\/fbml))/gi,
Three things earn their keep in that line:
-
(?!\/fbml)drops the namespace URL by its shape, not by its year. -
(?![A-Za-z0-9_.-])stops the engine from backtracking to capture200just to satisfy the lookahead. Without it the fix returnsfacebook.com/200, which is worse than before, because it looks less obviously wrong. - The
pages/Name/12345alternative comes first, because my own cleanup brokeuncommongoods.com: its only Facebook link is the legacy formfacebook.com/pages/UncommonGoods/44340057816, and my new reserved-word list had just killedpages. One of the ten sites I "fixed" on the first pass got worse, which is the argument for running the before/after on the full sample rather than on the site that annoyed you.
Numeric handles get a rule of their own: facebook.com/803046 is a legitimate page address but a useless one in a CSV, so it is a fallback, used only if the page names no handle anywhere.
Before and after, same 40 pages, same HTML:
| Old | New | |
|---|---|---|
| A named profile | 17 | 26 |
| A URL that is not a profile | 19 | 0 |
| Empty | 4 | 14 |
Nineteen of 40 rows changed: 9 recovered the company's real page out of HTML that had been in hand all along, and 10 traded a dead URL for null. Those ten sites genuinely have no Facebook link on the home page. null is the correct answer and it is the more valuable of the two changes — a row that says nothing invites you to look elsewhere, while a row that says facebook.com/2008 invites you to paste it into a CRM.
The other five social fields — Instagram, LinkedIn, X, YouTube, TikTok — came out identical on all 40 pages. I rewrote them the same way anyway: the next template that ships a namespace will not ship it for Facebook.
The same mistake, in the filters
What makes this worth a post rather than a changelog line: the actor made the identical mistake somewhere else, where it costs money.
Two inputs exist to turn a scan into a prospect list — keep only sites that use X, drop sites that use Y — and they match technology names loosely on purpose, so that shopify finds Shopify Plus and hubspot finds HubSpot CMS. Loosely meant String.includes:
if (mustNot.length && mustNot.some((m) => names.some((n) => n.includes(m)))) { … }
Over the actor's 209 signature names there are exactly eight pairs where one name contains another. Seven are the same vendor and behave as intended: Shopify/Shopify Plus, HubSpot/HubSpot CMS, Klarna/Klarna Checkout, Trustpilot/Trustpilot Widget, Cloudflare/Cloudflare Turnstile, LocalMed, Square Appointments. The eighth is not:
square → square | square appointments | squarespace
Square is a payment processor. Squarespace is a website builder. They share six letters and nothing else. So the obvious prospecting input — "online stores that have not already bought Square" — silently deleted every Squarespace site in the list.
Measured on the live platform, same input, two builds:
mustNotUse: ["square"], domains: [narcisorodriguez.com (Squarespace), hintwater.com (Shopify)]
build 0.0.21: 1 row delivered — narcisorodriguez.com filtered out
build 0.0.22: 2 rows delivered
This is nastier than the Facebook field: a filtered-out site produces no row, and filtered rows are not billed, so it leaves no trace in the invoice either. You do not get a wrong answer you can spot — you get a shorter list, and nothing tells you it was shortened for a reason you never asked for.
The fix is one predicate — match on whole words instead of raw substrings:
const nameMatches = (name, query) =>
new RegExp(`(^|[^a-z0-9])${escape(query)}([^a-z0-9]|$)`, 'i').test(name);
shopify still matches shopify plus (boundary is the space). localmed still matches dentrix / localmed. square no longer matches squarespace, because the character after the match is s. All seven intended loose matches survived; the one collision died. Both fixes are live for buyers as build 0.0.23 of Tech Stack Detector.
What a row looks like
Input:
{
"domains": ["uncommongoods.com", "narcisorodriguez.com"],
"includeContacts": true
}
Real output, from the verification run:
{
"domain": "uncommongoods.com",
"technologies": ["Google Tag Manager", "Google Analytics 4", "Amazon CloudFront", "OneTrust", "Afterpay"],
"categories": ["analytics", "cdn", "consent", "payments", "tag_manager"],
"payments": ["Afterpay"],
"consent": ["OneTrust"],
"technologiesDetailed": [
{ "name": "Google Analytics 4", "category": "analytics", "foundIn": "tag-manager" },
{ "name": "Amazon CloudFront", "category": "cdn", "foundIn": "header" },
{ "name": "Afterpay", "category": "payments", "foundIn": "tag-manager" }
],
"tagManagerContainers": ["GTM-PQPMHZ"],
"emails": ["help@uncommongoods.com"],
"facebook": "https://facebook.com/pages/UncommonGoods/44340057816",
"x": "https://x.com/uncommongoods",
"server": "AmazonS3",
"readVia": "direct"
}
foundIn is worth keeping in your own build of this: two of those five technologies were not in the HTML at all, they were in the site's public Tag Manager container. And from the API it is four lines:
from apify_client import ApifyClient
client = ApifyClient("<YOUR_API_TOKEN>")
run = client.actor("lergassy/tech-stack-detector").call(run_input={
"domains": ["uncommongoods.com", "narcisorodriguez.com"],
"mustNotUse": ["square"],
})
for site in client.dataset(run["defaultDatasetId"]).iterate_items():
print(site.get("domain"), site.get("technologies"), site.get("facebook"))
When this is the wrong tool
It reads one page per domain — the home page — plus the Tag Manager container it points at. That is deliberate and it is the whole cost model, but it sets a ceiling: on these 40 sites only 16 printed an e-mail address on the home page. If contacts are the actual deliverable, a tool that crawls to /contact and /about will beat it, and Website Contacts API is where I send people for that. It also will not tell you what a site runs behind a login, and it is not a page-speed or SEO audit.
And the portable lesson, which cost me nothing to learn twice in one codebase: if your extractor takes the first regex match, you are trusting document order to agree with intent. On machine-generated HTML it never does. The template's boilerplate is always at byte 91. The human's answer is 11,000 bytes further down.
Top comments (1)
The namespace case suggests starting from actual anchor hrefs, then ranking candidates. Keep the source element with each value so the next template artifact is traceable.