I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them does something narrower than a general tech-stack lookup: you give it a list of domains, it gives you back one row per domain with the technologies the site runs, the capabilities it lacks, whether the company is spending on traffic right now, and the e-mail and phone to reach it.
The "lacks" column is the whole point. Nobody replies to "I see you use WordPress". People reply to "you're running Meta ads but a visitor can't book an appointment on your site". The problem is that a missing-capability list is a negation, and a negation is only as trustworthy as your detection. Every tool you fail to see becomes a gap you'll put in an e-mail and be wrong about.
Here is the single biggest source of those wrong gaps, measured on 23 live domains on 17 October 2026.
The home page knows nothing
allbirds.com, home page, about 700 kB of HTML. I grepped it for every advertising identifier I could think of:
GTM containers: GTM-TH8KRSBJ
Google Ads (AW-…): (none)
GA4 (G-…): (none)
fbq('init', …): (none)
connect.facebook.net: 0 occurrences
analytics.tiktok.com: 0 occurrences
Allbirds is not a company that has stopped advertising. The page just doesn't contain its advertising. All it contains is the loader for container GTM-TH8KRSBJ. So I fetched that container — it's a public JavaScript file, no auth, https://www.googletagmanager.com/gtm.js?id=GTM-TH8KRSBJ, 656 kB — and grepped the same patterns:
Google Ads: AW-604448474 AW-671743546 AW-736740096 AW-786649265
AW-798041621 AW-822752074 AW-822752224
GA4: G-KJL05B1DJZ G-KB1068K0E6 G-K4JD0P61SJ G-X7QTYLMX8J … (10 unique)
Meta pixels: 1758221267590067 1689016458013442 1569821056431325 … (8 unique)
TikTok: 2 occurrences of analytics.tiktok.com
That's the whole asymmetry in two greps. The tags are not in the page; they are in a file the page tells the browser to go and get. A scraper that reads HTML and stops is reading the table of contents.
What it does to the output
So the scanner fetches the container for every domain that loads one and runs detection over HTML plus container body. To find out what that's worth I ran the same 23 domains twice, once with expandTagManager: true and once with it off — 11 e-commerce brands, 8 local businesses (dental and roofing) and 4 vertical SaaS sites. 20 answered, 3 were blocked.
| Over the 20 reachable sites | HTML only | HTML + container |
|---|---|---|
| Sites flagged as paying for traffic | 6 | 14 |
| Distinct ad platforms detected | 8 | 31 |
| Technologies detected | 79 | 124 |
| Gaps reported | 126 | 106 |
Sites reported as no_ads_pixel
|
14 | 6 |
11 of the 20 sites load a GTM container, and on 8 of them the container flipped runsPaidAds from false to true: allbirds.com, warbyparker.com, ruggable.com, chubbies.com, bostondentalgroup.com, mrroof.com, housecallpro.com, nexhealth.com. Every one of those 8 would otherwise have gone out with no_ads_pixel in its gap list — a brand running five pixels, described to a prospect as a company with no tracking.
The 20 gaps that disappeared weren't all ad pixels either:
no_ads_pixel 8
no_analytics 7
no_reviews_widget 1
no_online_payments 1
no_email_capture 1
no_crm 1
booking_unverified 1
Two of those are worth naming. bostondentalgroup.com loads Sunbit — patient financing, i.e. an online payment capability — from inside its container; HTML-only called it no_online_payments. And mrroof.com went from 7 gaps to 3, because the container held both its CRM tag and a booking vendor, so booking_unverified resolved into an actual identified vendor. On that one domain, four of seven "opportunities" were fiction.
Dropping 20 of 126 gaps sounds like a modest 16% correction until you remember which direction it runs. A gap list is a buy signal. The error mode of HTML-only scanning is never "I missed an opportunity" — it's always "I invented one", and you find out after you've sent the mail.
The cost of the extra fetch
One more HTTP request per domain, not a browser. That matters for anybody building the same thing: the honest-looking solution here is "render the page in Chrome and watch the network tab", and it works, and it costs an order of magnitude more per row. The container is a static file; you don't need to execute it to read which tags it carries.
The guard rails in the implementation are boring on purpose — at most 2 containers per site, a 3 MB cap on each, a shared timeout, and a Promise.all so the containers for one site come in parallel:
export async function expandTagManager(html, timeoutMs = 20000) {
const ids = findContainerIds(html).slice(0, MAX_CONTAINERS);
if (ids.length === 0) return { containerIds: [], body: '', adAccountIds: {} };
const bodies = await Promise.all(ids.map((id) => fetchContainer(id, timeoutMs)));
const body = bodies.join('\n');
const uniq = (re) => [...new Set([...body.matchAll(re)].map((m) => m[0]))].slice(0, 8);
const adAccountIds = {
metaPixelIds: uniq(/\b\d{15,16}\b/g),
googleAdsIds: uniq(/\bAW-\d{6,}\b/g),
ga4Ids: uniq(/\bG-[A-Z0-9]{8,12}\b/g),
};
…
}
Where this is still wrong, in writing
Four limits I'd want to know about if I were reading someone else's row:
adAccountIds is capped at 8 per type. My own grep found 10 unique GA4 IDs in the Allbirds container; the row carries 8. One of the two dropped is G-D6E2XB4D2P, sitting in a lookup table keyed to allbirds.co.nz — the container holds the whole international estate, not one property. Treat the field as evidence that accounts exist, not as an inventory. Same regex, same caveat: a literal G-XXXXXXXXXX placeholder in a container matches the GA4 pattern as happily as a real ID.
adAccountIds reads only the container. roofingbybruce.com has a hardcoded gtag/js?id=G-41VCL68TF2 in its HTML plus two other GA4 IDs in its container. The technology "Google Analytics 4" is detected correctly; the ID from the page itself never appears in ga4Ids. The categorical answer and the identifier list come from different places, and only one of them sees the HTML.
Other injectors aren't expanded. Server-side GTM and Segment load tags somewhere this approach can't follow. A site whose tags arrive that way still reads as a gap.
It's one page. The scanner reads the home page. A booking widget living only on /appointments reads as booking_unverified if the page mentions scheduling and as a flat gap if it doesn't — which is why those are two different values rather than one.
Three of the 23 domains never produced a row at all, and they're instructive about the other half of this job: bombas.com answered 429 to both fetch engines, and clio.com answered 403 to both. baker-roofing.com and getjobber.com also answered 403 to a plain fetch — then returned 200 with 227 kB and 537 kB to a retry with a browser-shaped TLS handshake, same machine, same moment. Node's TLS fingerprint is recognisable; the header set isn't the thing being read. Blocked rows come back as status: "unreachable" with the code, because a list where failures vanish silently is worse than a list with holes in it.
Calling it
curl -X POST "https://api.apify.com/v2/acts/lergassy~website-tech-gap-scanner/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"domains":["allbirds.com","bostondentalgroup.com"],"onlyPaidTraffic":true}'
Three domains took 4.3 seconds end to end in the run these numbers come from. A trimmed row:
{
"domain": "bostondentalgroup.com",
"finalUrl": "https://www.bdgdentalservices.com/",
"confidence": "high",
"prospectScore": 65,
"prospectTier": "hot",
"runsPaidAds": true,
"adPlatforms": ["Meta Pixel"],
"tagManagerContainers": ["GTM-MFVLQ3J"],
"adAccountIds": { "metaPixelIds": ["473376469722887", "421033821700845"],
"ga4Ids": ["G-FKKWNQNGDB"] },
"technologies": ["Google Tag Manager", "Google Analytics 4", "Universal Analytics",
"Meta Pixel", "WordPress", "Sunbit"],
"gaps": ["booking_unverified", "no_live_chat", "no_crm", "no_email_capture",
"no_reviews_widget", "no_contact_form"],
"bookingSignals": ["schedule appointment", "schedule an appointment"],
"city": "Las Vegas", "stateCode": "NV", "locationSource": "page text",
"phones": ["7023998888", "7024324329", "7025222888"]
}
The Actor is Website Tech Gap Scanner on Apify. Where it doesn't fit: if you want a complete inventory of what a company runs across its whole estate, this is a home-page snapshot and you want BuiltWith's historical database instead; if you want contacts rather than stack, a dedicated contact crawler reads more than one page and will do better; and if your list is 50 domains you can check by hand, checking by hand is more accurate than any of this.
What I'd take away even if you never touch the Actor: if you're writing your own detector, the HTML is the loader and the container is the payload. Deciding what a website doesn't have on the strength of the loader alone will hand you a pile of confident, specific, wrong openings.
Top comments (0)