# Website Tech Stack Detector & Tracking Pixel Checker (`solidfetch/marketing-stack-scanner`) Actor

Detect tracking pixels in bulk: Meta (Facebook) Pixel, TikTok, Google Analytics (GA4), Google Tag Manager, Google Ads tag. A website tech stack detector and technology lookup: CMS, Shopify, CRM, consent, mail technologies per domain as JSON or CSV. No-ads-tag filter.

- **URL**: https://apify.com/solidfetch/marketing-stack-scanner.md
- **Developed by:** [Solid Fetch](https://apify.com/solidfetch) (community)
- **Categories:** Agents, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.80 / 1,000 domain scanneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Website Tech Stack Detector & Tracking Pixel Checker

Detect tracking pixels in bulk: a marketing pixel detector and website tech stack detector for agencies and sales teams. Paste a list of domains, or call it with one domain from an AI agent, and see which of them use Meta Pixel (Facebook Pixel), TikTok Pixel, Google Analytics (GA4), Google Tag Manager or a Google Ads tag, plus the website technologies behind each site, such as CMS, shop platform, consent tool, CRM and e-mail marketing. It is a website technology lookup that returns the tech stack of each domain as JSON or CSV, for example to enrich a list of 500 domains with the marketing tools they use, or to find the Shopify stores on your list that have no analytics installed. You get one flat row per domain, ready for CSV. $3.00 per 1,000 domains on the free plan; blocked sites are free.

```json
{ "domains": ["hubspot.com", "allbirds.com", "techcrunch.com"] }
```

One row of the result (shortened, see [Output](#output) for all fields):

```json
{ "domain": "acme.example", "status": "scanned", "hasGoogleAdsTag": true, "hasGA4": false, "hasGTM": false, "hasMetaPixel": true, "adTagIds": ["AW-123456789", "meta:123456789012345"], "cms": "WordPress", "leadSignals": ["runs Google Ads tag", "Google Ads tag without Google Analytics"] }
```

**Blocked and unreadable sites are free**, and **pixel IDs are included**: the Google Ads `AW-` id, the GA4 `G-` id, the Meta Pixel ID, the Tag Manager `GTM-` id and more come back as plain values in `adTagIds`, not only a yes/no. About 290 detection rules.

Paste a list of domains and get one flat row for each: which **ad and analytics tags** the homepage carries (Google Ads, GA4, Google Tag Manager, Meta Pixel, LinkedIn Insight, TikTok, Microsoft UET, Hotjar and more), which **CMS or shop platform** it runs on, which **consent platform, CRM, chat and e-mail marketing** tools it loads, who hosts its **mail** (Google Workspace, Microsoft 365, other), whether it has **SPF and DMARC**, and how old the **domain** is. Costs **$3.00 per 1,000 domains** on the free plan ($1.80 per 1,000 on Gold and above). Blocked sites, errors, repeats and domains left out by your filter are free.

Built for agencies and lead generation: the filters `onlyWith` and `onlyWithout` turn a prospect list into a target list, for example "only domains with no Google Ads tag" or "WordPress sites with no GA4 and no consent platform". A `leadSignals` column says in plain words what each site is missing.

### What it does and does not do

- **One public page.** For each domain the actor makes one plain HTTPS GET of the homepage, follows redirects (at most 5), and reads the HTML and the response headers. It checks the site's `robots.txt` first and does not fetch the page when the file disallows it.
- **No bot-protection bypass, no headless browser.** The actor never bypasses bot protection and never runs a headless browser. If a site answers 403, 429 or a Cloudflare, DataDome or captcha page, the actor does not try again, rotate addresses or run a browser. The domain gets a free row with `status: "blocked"` and the reason. The same goes for robots.txt refusals, parked pages and unreachable sites.
- **Polite.** Five domains at a time at most, a 15 second timeout, no retries, an honest User-Agent (`SolidfetchStackScanner/1.0`), and a redirect is never followed to an IP address or a local name.
- **No personal data.** The actor reads script, link and meta tags and response headers. It does not read, keep or output e-mail addresses, phone numbers, names or page text. An e-mail address in your list is refused as an input. Domain age comes from RDAP; only the dates and the registrar company name are read, never the registrant.
- **Tag Manager containers are read too.** Most real sites load their ad tags through Google Tag Manager, so the HTML alone would call them "no ads tag". When the homepage names a container (`GTM-XXXX`), the actor also fetches the public container script that every visitor's browser loads (`googletagmanager.com/gtm.js?id=GTM-XXXX`, one request per container for the whole run, at most 3 containers per page) and looks inside it for Google Ads conversion and remarketing tags, GA4, Meta Pixel, LinkedIn, TikTok, Microsoft UET, Hotjar and the rest of the rule list. Each technology says where it was found (`source`: `html` or `gtm`), and `viaGtm` is true when something was found only in a container. A container is a list of tags, not proof that they fire: a paused tag, or a tag that waits for a consent choice or a page rule, is listed too.
- **When a container cannot be read** (the script answers an error, times out or is too large), the row has `gtmUnread: true`. Every tag boolean that would be false is then `null` (unknown), the lead signal reads `tags may be inside GTM (container could not be read)`, and the row gets no "no ads tag" or "no analytics" signal. A filter such as `onlyWithout: ["googleAdsTag"]` leaves such a domain out, because the scan cannot back up "without". A container is asked for twice (429, 5xx and timeouts are retried once) and a failure that may pass is not remembered for the next domain. If the page shows no tag other than Tag Manager itself, the row is a free `unreadable` row; if it shows other tags, they stay in the row and the row is charged.
- **When the homepage is a JavaScript app** (built with Next.js, Nuxt, Gatsby or Angular, or a React, Vue or Svelte root with little text) and its HTML holds no tag, the row is a free `unreadable` row with every tag boolean `null`: the actor does not run scripts, and such sites often add their tags from scripts. `onlyWithout` does not return these rows as having no tag.
- **Only what the page and its containers show.** Tags that appear only after a click, on other pages or from a tag manager other than Google's are not seen.

### What people use it for

- **A target list of sites with no ads tag.** `onlyWithout: ["googleAdsTag"]` keeps the sites that do not show a Google Ads tag. Add `anyAdTag` to skip every site with an ad pixel. "No ads tag" means none was visible in the homepage HTML or its Tag Manager containers. Sites that load tags through their own platform loader or server-side tagging (large Shopify stores, for example) can still run ads, so check big brands with the [Google Ads Transparency actor](https://apify.com/solidfetch/google-ads-transparency-scraper) before you pitch.
- **Audit hooks for a pitch.** Google Ads tag but no GA4, still on Universal Analytics (stopped collecting in 2023), ad pixels with no consent platform, a WordPress version from the 4.x era, no SPF or DMARC for a domain that sends mail.
- **Sorting a client or prospect list by platform.** `onlyWith: ["Shopify"]`, `["WordPress"]`, `["HubSpot"]`.
- **Competitor and market mapping.** Which sites in a niche run Meta Pixel, TikTok, LinkedIn Insight, a chat widget or a CRM.
- **Mail and domain context.** Mail provider (Google Workspace or Microsoft 365 for a "migrate or secure" pitch), DMARC policy, and a new domain (under a year) or an established one (10+ years).

### Use it together with the Google Ads Transparency Center check

Our [Google Ads Transparency Center scraper](https://apify.com/solidfetch/google-ads-transparency-scraper) answers a different question for the same list of domains: does the domain advertise on Google right now (`domainCheck` mode, `hasAds`, `activeLast30Days`)? This actor answers what the site itself carries. Put both columns next to each other and the list sorts itself:

| Transparency Center (`hasAds`) | This actor (`hasGoogleAdsTag`, `hasGTM`) | What it means |
|---|---|---|
| advertises, active last 30 days | no Google Ads tag, container read (`gtmUnread: false`) | **Advertises on Google per the Transparency Center but has no conversion tag in the page.** Conversion tracking is missing or set up elsewhere: the strongest audit pitch. |
| advertises | `hasGoogleAdsTag: null`, `gtmUnread: true` | The container could not be read, so tracking may sit inside it. Check before pitching. |
| advertises | Google Ads tag, no GA4 | Spends on ads without Google Analytics next to it. |
| no ads listed | has a Google Ads tag | Tag left behind from a past campaign, or ads not listed in the Transparency Center. A "restart or clean up" conversation. |
| no ads listed | no ad tag at all | Cold lead: a business that does not advertise yet. |

Run the two actors on the same domain list and join on `domain` in a spreadsheet, Clay, Make or n8n:

```js
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const domains = ['acme.example', 'globex.example'];

const ads = await client.actor('solidfetch/google-ads-transparency-scraper').call({ mode: 'domainCheck', domains, region: 'PL' });
const stack = await client.actor('solidfetch/marketing-stack-scanner').call({ domains });

const hasAds = new Map((await client.dataset(ads.defaultDatasetId).listItems()).items.map((row) => [row.domain, row.hasAds]));
const { items } = await client.dataset(stack.defaultDatasetId).listItems();
const pitch = items.filter((row) => hasAds.get(row.domain) === true && row.hasGoogleAdsTag === false);
```

A domain list of 1,000 costs you the Transparency Center price for the domain check (only the domains that advertise are charged there, see that actor's page) plus $3.00 here ($1.80 on Gold and above), or less when a filter leaves domains out.

### How to use it

A list:

```json
{
    "domains": ["hubspot.com", "allbirds.com", "techcrunch.com"]
}
```

A pasted column (one per line, or separated by commas, semicolons or tabs), with URLs allowed:

```json
{
    "text": "acme.example\nhttps://www.globex.example/about\ninitech.example"
}
```

Only the sites with no Google Ads tag in the page and no GA4:

```json
{
    "domains": ["acme.example", "globex.example", "initech.example"],
    "onlyWithout": ["googleAdsTag", "ga4"]
}
```

WordPress sites that load an ad pixel but have no consent platform:

```json
{
    "domains": ["acme.example", "globex.example"],
    "onlyWith": ["WordPress", "anyAdTag"],
    "onlyWithout": ["consentManager"]
}
```

A field name or a filter word the actor does not know stops the run with a hint such as `"onlyWithout" ... "googleAdTag" (did you mean "googleAdsTag"?)`, before anything is requested or charged, so a typo cannot give you a run that ignored a setting.

### Input

| Field | What it does |
|---|---|
| `domains` | List of domains. URLs are reduced to the domain, `www.` is dropped, an entry may hold several domains separated by commas, semicolons or line breaks. Up to 10,000 entries per run. E-mail addresses are refused. |
| `text` | A text blob with one domain per line, or separated by commas, semicolons or tabs. Used together with `domains`. |
| `onlyWith` | Keep a domain only if it has every thing listed. Feature keys or technology names, see below. |
| `onlyWithout` | Keep a domain only if it has none of the things listed. |
| `saveFailedDomains` | On by default: blocked, unreachable, parked, unreadable, redirected and robots.txt-refused domains get a free row with the reason. Turn it off to save only scanned domains. |
| `followCrossDomainRedirects` | Off by default. When the homepage redirects to a different registrable domain (`old-brand.com` to `new-brand.com`), the row is free with `status: "redirected"` and nothing of the other site is attributed to your domain. Turn on to read the other site and attribute its tags to the input domain: the row is then charged, and `redirectedTo` shows the host. The registrable domain is the last two labels of the host (three under `co.uk`, `com.au` and similar), without the Public Suffix List. |

Filter words (case and spaces do not matter, so `googleadstag` and `Google Ads Tag` both work):

| Feature key | True when |
|---|---|
| `googleAdsTag` | a Google Ads tag (`AW-` id, conversion script) is in the page |
| `ga4` | Google Analytics 4 (`G-` id) |
| `universalAnalytics` | the legacy Universal Analytics (`UA-` id, `analytics.js`) |
| `gtm` | Google Tag Manager |
| `metaPixel` | Meta (Facebook) Pixel |
| `linkedinInsight` | LinkedIn Insight Tag |
| `tiktokPixel` | TikTok Pixel |
| `microsoftUet` | Microsoft Advertising UET tag |
| `hotjar` | Hotjar |
| `anyAdTag` | any ad pixel: Google Ads, Meta, LinkedIn, TikTok, Microsoft, Pinterest, Snapchat, X, Reddit, Criteo, AdSense |
| `anyAnalytics` | any analytics or session recording tool |
| `consentManager` | a cookie consent platform (Cookiebot, OneTrust, Usercentrics and others). Google Consent Mode alone does not count |
| `chatTool` | a chat or support widget (Intercom, Drift, Zendesk, Crisp and others) |
| `crm` | a CRM, marketing automation or e-mail marketing tool (HubSpot, Klaviyo, Mailchimp and others) |
| `ecommerce` | a shop platform (Shopify, WooCommerce, Magento, BigCommerce, PrestaShop) |
| `mx`, `spf`, `dmarc` | the domain has MX records, an SPF record, a DMARC record |
| `googleWorkspace`, `microsoft365` | mail is hosted there |
| `oldCms` | an outdated CMS version is shown: WordPress below 6, Drupal below 9, Joomla below 4 |

Any technology name from the detection list works too: `WordPress`, `Shopify`, `Wix`, `HubSpot`, `Klaviyo`, `Intercom`, `Cookiebot`, `Hotjar` and so on.

A filter only sees what the scan could check. If a DNS query failed, the SPF and DMARC answers are unknown (`null`), and such a domain is left out of both `spf` filters rather than listed with a claim the scan could not back up.

Domains left out by a filter are scanned, but not saved and not charged. The summary counts them.

### Output

One row per input that is saved, in input order. The row is flat, so the CSV export works as is. An example of the row shape (invented domain):

```json
{
    "domain": "acme.example",
    "status": "scanned",
    "error": null,
    "leadSignals": ["runs Google Ads tag", "Google Ads tag without Google Analytics", "established domain (10+ years)"],
    "hasGoogleAdsTag": true,
    "hasGA4": false,
    "hasUniversalAnalytics": false,
    "hasGTM": false,
    "hasMetaPixel": true,
    "hasLinkedInInsight": false,
    "hasTikTokPixel": false,
    "hasMicrosoftUet": false,
    "hasHotjar": false,
    "hasAnyAdTag": true,
    "hasAnyAnalytics": false,
    "hasConsentManager": true,
    "hasChatTool": false,
    "hasCrm": true,
    "hasEcommerce": false,
    "adTagIds": ["AW-123456789", "hubspot:1234567", "meta:123456789012345"],
    "cms": "WordPress",
    "consentManager": "Cookiebot",
    "technologyNames": ["Google Ads tag", "Meta Pixel", "Cookiebot", "HubSpot", "WordPress", "Cloudflare"],
    "technologies": [{ "name": "WordPress", "category": "cms", "version": "6.4.3" }],
    "emailProvider": "Google Workspace",
    "mxHost": "aspmx.l.google.com",
    "hasMx": true,
    "hasSpf": true,
    "hasDmarc": true,
    "dmarcPolicy": "reject",
    "dnsStatus": "ok",
    "domainCreated": "2012-03-04",
    "domainAgeYears": 14.6,
    "domainExpires": "2027-05-01",
    "registrar": "Example Registrar Inc.",
    "rdapStatus": "ok",
    "httpStatus": 200,
    "finalUrl": "https://acme.example/",
    "redirectedTo": null,
    "input": "acme.example",
    "duplicateOf": null,
    "scannedAt": "2026-10-27T12:00:00.000Z"
}
```

| Field | Meaning |
|---|---|
| `domain`, `input` | The normalised domain (lower case, ASCII). `input` is the parsed domain too, never the pasted text, so a URL with a login or a query string is not stored. An entry that is not a domain and has an at sign or six or more digits is hidden. |
| `status` | `scanned`, or why there is no scan: `blocked`, `robots-disallowed`, `robots-unavailable`, `unreachable`, `timeout`, `domain-not-found`, `http-error`, `not-html`, `too-many-redirects`, `redirect-blocked`, `parked`, `unreadable`, `redirected`, `duplicate`, `invalid-domain`. |
| `error` | The reason in a sentence. `null` for a scanned row. |
| `leadSignals` | Short sentences about what the scan means for a pitch: `runs Google Ads tag`, `no ads tag`, `tags may be inside GTM (container could not be read)`, `ad pixels but no Google Ads tag`, `Google Ads tag without Google Analytics`, `no analytics`, `still on Universal Analytics (stopped collecting in 2023)`, `tracking tags without consent manager`, `old CMS: WordPress 4.9.8`, `online shop`, `no CRM or chat tool`, `new domain (under 1 year)`, `established domain (10+ years)`, `no SPF record`, `no DMARC record`, `DMARC policy is none (monitoring only)`. |
| `hasGoogleAdsTag`, `hasGA4`, `hasUniversalAnalytics`, `hasGTM`, `hasMetaPixel`, `hasLinkedInInsight`, `hasTikTokPixel`, `hasMicrosoftUet`, `hasHotjar` | One boolean per key tag, true when the page or a Tag Manager container shows it. `null` when the page was not read (blocked, unreachable) or a container could not be read, never `false`, so an unknown site is not mistaken for a site with no tag. |
| `hasAnyAdTag`, `hasAnyAnalytics`, `hasConsentManager`, `hasChatTool`, `hasCrm`, `hasEcommerce` | Summary booleans over the whole rule list. |
| `adTagIds` | Public tag ids found in the page: `AW-` (Google Ads), `G-` (GA4), `GTM-`, `UA-`, and `meta:`, `linkedin:`, `tiktok:`, `uet:`, `hotjar:`, `hubspot:`, `klaviyo:`, `pinterest:`, `snap:`, `taboola:`, `quora:`, `adroll:`, `awin:`, `statcounter:`, `smartlook:`, `pendo:`, `piwikpro:`, `tealium:`, `cfwa:` (Cloudflare Web Analytics token) plus the id, and `DC-` for a Floodlight tag. The Meta Pixel ID is `meta:` plus the number. These ids are published in every visitor's page source. |
| `technologies`, `technologyNames` | Every technology found with its category (`advertising`, `analytics`, `tag-manager`, `session-recording`, `consent`, `cms`, `website-builder`, `ecommerce`, `chat-support`, `crm-marketing`, `email-marketing`, `scheduling`, `payments`, `hosting-cdn`, `framework`) and the version when the site shows it. `technologyNames` is the same as plain names, easy to filter in a spreadsheet. |
| `cms`, `consentManager` | The main platform name and the consent platform name, for a quick column. |
| `viaGtm`, `gtmContainers`, `gtmUnread` | Whether a tag was found only inside a Tag Manager container, the container ids the page loads, and whether a container could not be read (then false tag booleans are `null`). Each entry of `technologies` also has `source`: `html` or `gtm`. |
| `emailProvider`, `mxHost`, `hasMx` | Mail provider read from the MX hosts: `Google Workspace`, `Microsoft 365`, a named provider such as `Zoho Mail`, a gateway such as `Proofpoint (gateway)` (what sits behind a gateway is not visible), `Other`, or `None`. |
| `hasSpf`, `hasDmarc`, `dmarcPolicy` | Whether the domain publishes SPF and DMARC records, and the DMARC policy (`none`, `quarantine`, `reject`). `null` when the DNS query failed. |
| `dnsStatus` | `ok`, `nxdomain` or `error`. |
| `domainCreated`, `domainAgeYears`, `domainExpires`, `registrar`, `rdapStatus` | From the registry's public RDAP service. `rdapStatus` is `ok`, `unsupported-tld` (many country domains have no RDAP service), `not-found`, `rate-limited` or `error`. Dates are `null` then, and the row is still charged. |
| `pageTruncated` | `true` when the page was larger than the actor reads (1.5 MB) and was cut: a tag that would be `false` is `null`, because it may be after the cut. A cut page with no tag found in the part read is a free `unreadable` row. |
| `httpStatus`, `finalUrl`, `redirectedTo` | The answer for the homepage, its final address without the query string, and the host when the site redirects to another domain. |
| `duplicateOf` | The domain this row repeats. |
| `scannedAt` | When the scan ran. |

The run also writes a `SUMMARY` record with the counts by status, how many rows were charged, left out by a filter and free, and how many DNS, page and RDAP lookups were made.

### Pricing

Pay per event, prices on the Pricing tab.

| Event | Charged for |
|---|---|
| `domain-scanned` | Each domain whose homepage was read (`status: "scanned"`), is not a repeat of an earlier one, and passes your filter. |

Free: blocked sites (403, 429, bot-protection pages), `unreadable` pages (empty or tiny answers, redirect-only pages, empty app shells, JavaScript apps with no tag in their HTML, HTTP 202, a page whose Tag Manager container could not be read or that was cut when nothing was found in the part read), `redirected` rows, robots.txt refusals, unreachable and timed-out sites, domains that do not exist, parked pages, HTTP errors, repeats, entries that are not domains, and every domain left out by `onlyWith` or `onlyWithout`. Rows past your maximum cost per run are not saved.

A run's last line adds it up, for example `812 domains scanned and charged as domain-scanned. free: 140 scanned but left out by the filter, 38 that could not be scanned (blocked, unreachable, no page), 10 repeats. 1,000 inputs.`

### Limits

- **Homepage only.** A tag that is only on a checkout, a landing page or a blog post is not seen.
- **Other tag managers.** Google Tag Manager containers are read. Adobe Launch, Tealium, Segment and similar are detected as technologies, but what they load is not read.
- **Server-side and consent-gated tags.** A tag that waits for a consent choice, or that is served from the site's own domain (server-side tagging), may not match a rule.
- **Rule list, not a guess.** About 290 rules written for this actor from the install snippets and script hosts vendors publish in their own documentation (none copied from another fingerprint list, no third-party rule file loaded): ad pixels and networks, analytics and A/B testing, session recording, consent platforms, CMS, page builders and frameworks, shop platforms and shop apps, chat and booking widgets, CRM, lead capture and e-mail marketing, payments, hosting and CDN. A technology that is not on the list is not reported. The version, when shown, comes from the site's generator tag.
- **Blocked is common.** Large sites and many small ones behind bot protection answer 403. Expect a share of free `blocked` rows. The actor does not work around it.
- **RDAP coverage.** The IANA bootstrap lists a service for most generic and many country domains, not all. Registries limit the request rate: the actor spaces requests out and skips a registry that says to slow down.
- **A subdomain** is looked up in DNS and fetched as given. Its domain age is read from the registered domain above it.
- **Up to 10,000 entries per run.** About 3 domains a second, so a 5,000-domain list takes roughly half an hour.

### Privacy

Your domain list is your data. The actor does not log it (the log holds counts only), keeps it nowhere except the dataset of the run, and sends it only to the domains themselves, to DNS, to Google's public Tag Manager script address for the container ids the pages name (the ids, not your domains), and to the registry of each domain's top-level domain for the domain-age lookup. The run's restart record holds a hash of the list and counters, not domains.

The output holds no personal data: no e-mail addresses, phone numbers or names from pages, and no registrant data from RDAP.

When a run fails, an error report with the error message (your domains replaced by a placeholder) and the shape of the input (which fields are set, how long the lists are, which filter words), never your domains or your results, is sent to the developer to fix bugs.

Did this help? A review on this page, or an issue saying what is missing, helps other people find it.

# Changelog

This Actor's version history is a separate document: https://apify.com/solidfetch/marketing-stack-scanner/changelog.md

# Actor input Schema

## `domains` (type: `array`):

One domain per entry. URLs are reduced to the domain, www. is dropped, and an entry may hold several domains separated by commas, semicolons or line breaks. E-mail addresses are refused. Repeats get a free row. Use this or the text box below, or both. Up to 10,000 entries per run.

## `text` (type: `string`):

Paste a column from a spreadsheet or a CSV export: one domain per line, or separated by commas, semicolons or tabs.

## `onlyWith` (type: `array`):

Keep a domain only if it has every thing listed here. Use a feature key (googleAdsTag, ga4, universalAnalytics, gtm, metaPixel, linkedinInsight, tiktokPixel, microsoftUet, hotjar, anyAdTag, anyAnalytics, consentManager, chatTool, crm, ecommerce, mx, spf, dmarc, googleWorkspace, microsoft365, oldCms) or the name of a technology (WordPress, Shopify, HubSpot, Intercom, ...). Domains left out are not saved and not charged. A word the actor does not know stops the run.

## `onlyWithout` (type: `array`):

Keep a domain only if none of the things listed here is found. Example: googleAdsTag to list sites with no Google Ads tag in the page. A tag that loads only inside Google Tag Manager is not visible in the page, so combine with gtm if that matters. A condition the scan could not check (a DNS query failed) leaves the domain out.

## `saveFailedDomains` (type: `boolean`):

Blocked sites, unreachable sites, parked domains and robots.txt refusals get a row with the reason, so you see why. These rows are always free. Turn this off to get only scanned domains.

## `followCrossDomainRedirects` (type: `boolean`):

Off by default: when the homepage redirects to a different registrable domain (old-brand.com to new-brand.com), the row is free with status redirected and no tags are attributed to the input domain. Turn on to read the other site and attribute its tags and mail setup to the input domain. The row is then charged and shows the host in redirectedTo.

## Actor input object example

```json
{
  "domains": [
    "hubspot.com",
    "allbirds.com",
    "techcrunch.com"
  ],
  "text": "hubspot.com\nallbirds.com\ntechcrunch.com",
  "onlyWith": [
    "googleAdsTag",
    "WordPress"
  ],
  "onlyWithout": [
    "googleAdsTag"
  ],
  "saveFailedDomains": true
}
```

# Actor output Schema

## `results` (type: `string`):

Dataset view with one row per domain: ad and analytics tags, CMS and tech stack, mail provider, SPF and DMARC, domain age, lead signals and the reason when a domain was not scanned. A JavaScript app homepage with no tag in its HTML is a free unreadable row with tag flags null, not a "no tags" row. Open it for the main output.

## `summary` (type: `string`):

JSON record SUMMARY in the key-value store: counts of domains by status (scanned, blocked, unreachable and the others).

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "domains": [
        "hubspot.com",
        "allbirds.com",
        "techcrunch.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("solidfetch/marketing-stack-scanner").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "domains": [
        "hubspot.com",
        "allbirds.com",
        "techcrunch.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("solidfetch/marketing-stack-scanner").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "domains": [
    "hubspot.com",
    "allbirds.com",
    "techcrunch.com"
  ]
}' |
apify call solidfetch/marketing-stack-scanner --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,solidfetch/marketing-stack-scanner"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/gVqzk8A9DmeQUMusR/builds/T2ayC8aNmv7NSj4mH/openapi.json
