# Facebook Ads Competitor Intelligence (`outspoken_strategy/facebook-ads-competitor-intelligence`) Actor

Turn public Meta Ad Library data into structured competitor advertising intelligence: ad discovery, creative type, CTA, offer detection, ad age, landing pages, advertiser statistics, deduplication and historical change detection. Deterministic, no AI (V1).

- **URL**: https://apify.com/outspoken\_strategy/facebook-ads-competitor-intelligence.md
- **Developed by:** [code craker](https://apify.com/outspoken_strategy) (community)
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $50.00 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Facebook Ads Competitor Intelligence

**Turn public Meta Ad Library data into structured competitor advertising intelligence.**

This Actor collects publicly available advertising data from the [Meta Ad Library](https://www.facebook.com/ads/library/) for the competitors, brands or keywords you specify and turns each ad into a structured intelligence record: creative type, call-to-action, detected offers and urgency, ad age, landing-page classification, creative fingerprints, duplicate detection, per-advertiser statistics, a competitor comparison and change detection between runs.

It is **not** "another Facebook scraper". Raw ads are only the starting point – the value is the deterministic intelligence layer built on top of them.

> **V1 is deliberately not an AI product.** Every derived field is produced by transparent, rule-based logic that you can audit and reproduce. No LLM, embedding, or AI API key is used or required. An optional AI enrichment layer is planned as V2.

***

### Table of contents

- [Overview](#overview)
- [Features](#features)
- [Who should use it](#who-should-use-it)
- [Use cases](#use-cases)
- [How it works](#how-it-works)
- [Input parameters](#input-parameters)
- [Country codes](#country-codes)
- [Ad status](#ad-status)
- [Output fields](#output-fields)
- [Example input](#example-input)
- [Example output](#example-output)
- [Offer detection](#offer-detection)
- [CTA analysis](#cta-analysis)
- [Creative analysis](#creative-analysis)
- [Ad age](#ad-age)
- [Landing-page analysis](#landing-page-analysis)
- [Creative fingerprints and duplicate detection](#creative-fingerprints-and-duplicate-detection)
- [Advertiser statistics and competitor comparison](#advertiser-statistics-and-competitor-comparison)
- [Historical comparison](#historical-comparison)
- [Proxy configuration](#proxy-configuration)
- [Performance and cost](#performance-and-cost)
- [Error handling](#error-handling)
- [Limitations](#limitations)
- [Responsible usage](#responsible-usage)
- [Local development](#local-development)
- [Testing](#testing)
- [Docker](#docker)
- [Apify deployment](#apify-deployment)
- [Roadmap (V2)](#roadmap-v2)

***

### Overview

You provide competitor names, keywords and/or Facebook Page IDs plus a country. The Actor opens the public Meta Ad Library, applies the country, status and date filters, loads results with controlled scrolling, reads Meta's own public JSON payloads (instead of fragile CSS selectors), de-duplicates the ads and enriches every ad with deterministic intelligence. Each ad becomes one Dataset item; aggregated statistics and comparisons are stored in the run's key-value store.

### Features

| Area | What you get |
| --- | --- |
| **Competitor ad discovery** | Keyword / brand search (any-word or exact phrase) and direct Page-ID lookups, per country, active / inactive / all, optional start-date range |
| **Advertiser information** | Page name, Page ID, Page URL, Instagram handle, page categories, like count, disclaimer / funding byline where publicly displayed |
| **Creative tracking** | Primary text, headline, description, caption, carousel cards, creative type (IMAGE / VIDEO / CAROUSEL / COLLECTION / TEXT / UNKNOWN), public image / video / thumbnail URLs |
| **CTA analysis** | Raw CTA label plus a normalized CTA category (SHOP\_NOW, LEARN\_MORE, SIGN\_UP, BOOK\_NOW, …) |
| **Offer detection** | Rule-based detection of discounts, prices, free offers, BOGO, financing terms, sales and promotions, plus urgency wording |
| **Ad age** | Start / end dates (ISO-8601), days running, ad age and a transparent age category |
| **Platform placement** | Facebook, Instagram, Messenger, Audience Network, Threads, WhatsApp – only when the source provides evidence |
| **Landing-page analysis** | Final URL, domain, title, meta description, HTTP status, redirect count and a heuristic page type (PRODUCT, SERVICE, CONTACT, BOOKING, PRICING, BLOG, HOME, FORM, UNKNOWN) |
| **Fingerprints & duplicate detection** | Deterministic text, media and creative fingerprints; de-duplication by ad ID, ad URL and fingerprint + advertiser, across scroll batches and search terms |
| **Advertiser statistics** | Per-advertiser totals, creative mix, offer share, CTA usage, platform, landing-page and age distributions |
| **Competitor comparison** | Side-by-side observable metrics per advertiser (no "winner" claims) |
| **Historical change detection** | NEW / UNCHANGED / MODIFIED labels per ad and a REMOVED list, compared against the previous run with the same configuration |
| **Robustness** | Retries, timeouts, per-ad and per-search error capture, graceful handling of login walls / CAPTCHAs (never bypassed) |

### Who should use it

- Marketing and advertising agencies monitoring client competitors
- Brand, growth and performance-marketing teams
- E-commerce operators researching category advertising
- Market researchers and analysts
- Creative strategists studying formats, hooks and offers
- Anyone building competitor dashboards on top of Apify

### Use cases

- Competitor research and competitor ad discovery
- Creative research: which formats, hooks and CTAs competitors run
- Offer and promotion monitoring (discounts, financing, free-delivery claims)
- Campaign monitoring: new, changed and removed ads over time
- Landing-page strategy research (product pages vs. lead forms vs. booking pages)
- Brand monitoring for unauthorized or look-alike advertising
- Market research and social-media advertising analysis
- Advertising-strategy benchmarking across a set of brands

### How it works

1. For each search term / Page ID a public Ad Library URL is built with your country, status, match type and date filters.
2. A headless Chromium page (Playwright via Crawlee) loads it. Images, video and fonts are blocked to save bandwidth.
3. The first page of results is read from the JSON Meta embeds in the document; further pages are read from the JSON responses Meta's page itself requests while scrolling. No private endpoints, credentials or cookies are used.
4. Scrolling stops when `maxAdsPerSearch` is reached, when Meta reports no further page, after `maxScrollRoundsWithoutNewAds` idle rounds, or at a hard cap derived from `maxAdsPerSearch`.
5. Ads are de-duplicated (within a search and across all searches) and filtered by status.
6. Deterministic intelligence is derived; landing pages are fetched once per unique URL (only when enabled).
7. Optional historical comparison labels each ad against the previous snapshot.
8. Ads are pushed to the Dataset; statistics, comparison, history and a run summary are written to the key-value store.

If Meta serves a login wall, CAPTCHA or rate-limit page, the affected search is reported as `ACCESS_BLOCKED` and the run continues with the other searches. **The Actor never attempts to bypass such controls.**

### Input parameters

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `searchTerms` | string\[] | – | Keywords, brand or competitor names. Duplicates (case-insensitive) are removed. Required unless `pageIds` is given. |
| `pageIds` | string\[] | `[]` | Numeric Facebook Page IDs to collect ads for directly. |
| `country` | string | `ZA` | ISO 3166-1 alpha-2 code or `ALL`. Invalid codes fail validation. |
| `adStatus` | `ACTIVE` | `INACTIVE` | `ALL` | `ACTIVE` | Ad Library status filter. |
| `searchType` | `KEYWORD_UNORDERED` | `KEYWORD_EXACT_PHRASE` | `KEYWORD_UNORDERED` | Keyword match mode. |
| `maxAdsPerSearch` | integer 1–5000 | `100` | Stop collecting after this many unique ads per search term / page. |
| `startDateMin` / `startDateMax` | `YYYY-MM-DD` | – | Optional start-date range applied through the Ad Library filter. |
| `includeInactiveAds` | boolean | `false` | Keep ads whose status resolves to INACTIVE (relevant with `adStatus: ALL`). Forced to `true` when `adStatus` is `INACTIVE`. |
| `includeLandingPage` | boolean | `true` | Visit each ad's public landing page once and extract metadata. |
| `includeMedia` | boolean | `true` | Include public image / video / thumbnail URLs. Media is never downloaded. |
| `includeAdvertiserStatistics` | boolean | `true` | Compute per-advertiser statistics and the competitor comparison. |
| `enableHistoricalComparison` | boolean | `false` | Compare with the previous run that used the same search configuration. |
| `historyStoreName` | string | `facebook-ads-competitor-intelligence-history` | Named key-value store holding history snapshots. |
| `maxConcurrency` | integer 1–10 | `3` | Parallel browser pages and parallel landing-page requests. |
| `maxScrollRoundsWithoutNewAds` | integer 1–20 | `4` | Idle-scroll safety stop. |
| `navigationTimeoutSecs` | integer 15–300 | `60` | Ad Library page load timeout. |
| `landingPageTimeoutSecs` | integer 5–120 | `20` | Landing-page request timeout. |
| `proxyConfiguration` | object | `{ "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }` | Apify Proxy / custom proxy settings. Residential recommended (see below). |

### Country codes

`country` must be an ISO 3166-1 alpha-2 code as used by the Ad Library's country selector, for example:

| Code | Country |
| --- | --- |
| `ZA` | South Africa |
| `ZW` | Zimbabwe |
| `GB` | United Kingdom |
| `US` | United States |
| `CA` | Canada |
| `AU` | Australia |
| `ALL` | All countries |

Codes are case-insensitive. Anything that is not a valid alpha-2 code (e.g. `South Africa`, `UK`) is rejected with a clear validation error – it is never silently reinterpreted.

### Ad status

The public Ad Library supports three statuses, which map directly to the input:

| Input `adStatus` | Ad Library filter | Output `status` values you may see |
| --- | --- | --- |
| `ACTIVE` | active | `ACTIVE` |
| `INACTIVE` | inactive | `INACTIVE` |
| `ALL` | all | `ACTIVE`, `INACTIVE` |

`status` is normalized from Meta's `is_active` flag; `sourceStatus` keeps the original value (`active` / `inactive`). If the flag is missing, `status` is `UNKNOWN` and `sourceStatus` is `null`.

### Output fields

Each ad is one Dataset item. All values are `null` when the source does not provide them – nothing is fabricated.

**Advertiser:** `advertiserName`, `advertiserPageName`, `advertiserPageId`, `advertiserPageUrl`, `instagramHandle`, `pageCategories`, `pageLikeCount`, `disclaimer`, `fundingEntity`

**Ad identity:** `adId` (Ad Library ID), `adUrl`, `collationId`, `collationCount`, `platforms[]`, `status`, `sourceStatus`, `searchTerm`, `searchPageId`, `country`, `scrapedAt`

**Dates and age:** `startDate`, `endDate`, `daysRunning`, `adAgeDays`, `adAgeCategory`

**Creative:** `primaryText`, `headline`, `description`, `caption`, `callToAction`, `normalizedCallToAction`, `creativeType`, `sourceDisplayFormat`, `imageUrls[]`, `videoUrls[]`, `thumbnailUrls[]`, `cardCount`, `cards[]`

**Offer:** `offerDetected`, `offerType`, `offerValue`, `offerText`, `urgencyDetected`

**Landing page:** `landingPageUrl`, `finalUrl`, `landingPageDomain`, `pageTitle`, `metaDescription`, `httpStatus`, `redirectCount`, `landingPageType`

**Fingerprints / history:** `creativeFingerprint`, `textFingerprint`, `mediaFingerprint`, `changeStatus`

**Errors (non-fatal):** `error`, `errorType`, `errorStage`

Key-value store records: `OUTPUT` (run summary incl. per-search results and errors), `ADVERTISER_STATISTICS`, `COMPETITOR_COMPARISON`, `HISTORICAL_COMPARISON` (when enabled).

### Example input

```json
{
    "searchTerms": ["Toyota", "Ford", "Mazda"],
    "country": "ZA",
    "adStatus": "ACTIVE",
    "maxAdsPerSearch": 100,
    "includeInactiveAds": false,
    "includeLandingPage": true,
    "includeMedia": true,
    "includeAdvertiserStatistics": true,
    "enableHistoricalComparison": false,
    "maxConcurrency": 3,
    "proxyConfiguration": { "useApifyProxy": true }
}
```

### Example output

```json
{
    "advertiserName": "Example Motors",
    "advertiserPageName": "Example Motors",
    "advertiserPageId": "200000000000001",
    "advertiserPageUrl": "https://www.facebook.com/examplemotors/",
    "instagramHandle": "examplemotors",
    "adId": "1001000000000001",
    "adUrl": "https://www.facebook.com/ads/library/?id=1001000000000001",
    "collationId": "col-1",
    "collationCount": 3,
    "platforms": ["Facebook", "Instagram"],
    "status": "ACTIVE",
    "sourceStatus": "active",
    "startDate": "2026-08-01T00:00:00.000Z",
    "endDate": null,
    "daysRunning": 44,
    "adAgeDays": 44,
    "adAgeCategory": "ESTABLISHED",
    "primaryText": "Drive away in a new hatchback with 20% off selected models. Limited time offer - ends Sunday!",
    "headline": "Spring Sale on Hatchbacks",
    "description": "Offer valid while stocks last",
    "caption": "example-motors.test",
    "callToAction": "Learn More",
    "normalizedCallToAction": "LEARN_MORE",
    "creativeType": "VIDEO",
    "sourceDisplayFormat": "VIDEO",
    "imageUrls": [],
    "videoUrls": ["https://video.example-cdn.test/v/hd/abc123.mp4?..."],
    "thumbnailUrls": ["https://img.example-cdn.test/t/abc123.jpg?..."],
    "cardCount": null,
    "cards": [],
    "offerDetected": true,
    "offerType": "DISCOUNT",
    "offerValue": "20%",
    "offerText": "20% off",
    "urgencyDetected": true,
    "landingPageUrl": "https://www.example-motors.test/vehicles/hatchback?utm_source=fb&utm_campaign=spring",
    "finalUrl": "https://www.example-motors.test/vehicles/hatchback?utm_source=fb&utm_campaign=spring",
    "landingPageDomain": "example-motors.test",
    "pageTitle": "Hatchback Range | Example Motors",
    "metaDescription": "Explore the Example Motors hatchback range. Book a test drive today.",
    "httpStatus": 200,
    "redirectCount": 0,
    "landingPageType": "PRODUCT",
    "creativeFingerprint": "eb7ea4c3…",
    "textFingerprint": "5a1c0f…",
    "mediaFingerprint": "9d02b1…",
    "changeStatus": "NEW",
    "disclaimer": null,
    "fundingEntity": null,
    "pageCategories": ["Car dealership"],
    "pageLikeCount": 12345,
    "searchTerm": "Toyota",
    "searchPageId": null,
    "country": "ZA",
    "scrapedAt": "2026-09-14T00:00:00.000Z",
    "error": null,
    "errorType": null,
    "errorStage": null
}
```

### Offer detection

Offer detection is rule-based (regular expressions over the primary text, headline, description, CTA and carousel card texts). Rules are evaluated in priority order and the first match wins:

| `offerType` | Examples detected | `offerValue` |
| --- | --- | --- |
| `BOGO` | buy one get one, BOGO, 2 for 1 | matched phrase |
| `DISCOUNT` | 20% off, 30% discount, save up to 50%, R500 off, half price | `20%`, `R500` |
| `FREE` | free delivery, free consultation, free quote, free test drive | `free delivery` |
| `FINANCING` | no deposit, interest free, 0% APR, from R999 per month, monthly payments from … | `R999` |
| `PRICE` | from $99, only R1,999, R249 999 | `$99`, `R1,999` |
| `SALE` | sale, clearance, markdowns | – |
| `PROMOTION` | special offer, promotion, promo code, voucher, coupon, giveaway, limited time offer | – |

`urgencyDetected` is evaluated independently (limited time, ends Sunday, hurry, while stocks last, only 5 left, …).

Values are only ever copied from the text – nothing is inferred. When nothing matches, `offerDetected` is `false` and all offer fields are `null`. Currency-style patterns can occasionally match non-offer numbers; treat the result as a signal, not a verdict.

### CTA analysis

`callToAction` is the label shown in the ad. `normalizedCallToAction` is derived first from Meta's structured CTA type and, when that is absent, from the label text. Categories:

`SHOP_NOW`, `LEARN_MORE`, `SIGN_UP`, `BOOK_NOW`, `CONTACT_US`, `GET_QUOTE`, `SEND_MESSAGE`, `WHATSAPP_MESSAGE`, `DOWNLOAD`, `APPLY_NOW`, `WATCH_MORE`, `GET_OFFER`, `SUBSCRIBE`, `ORDER_NOW`, `CALL_NOW`, `GET_DIRECTIONS`, `INSTALL_APP`, `PLAY_GAME`, `DONATE`, `REQUEST_TIME`, `SEE_MENU`, `LISTEN_NOW`, `BUY_TICKETS`, `GET_SHOWTIMES`, `VISIT_PROFILE`, `OPEN_LINK`, `OTHER` (a CTA exists but is not categorised), `UNKNOWN` (no CTA available).

### Creative analysis

`creativeType` is taken from Meta's display format (`IMAGE`, `VIDEO`, `CAROUSEL`, `MULTI_IMAGES`, `DCO` → CAROUSEL, `DPA` → COLLECTION) and falls back to counting images / videos / cards. A "carousel" with a single media element is reported as that element's type. When no evidence exists the value is `UNKNOWN` – never a guess. `sourceDisplayFormat` keeps Meta's original value.

Carousel ads additionally expose `cardCount` and `cards[]` (title, body, description, CTA, link, image / video URL per card).

### Ad age

- `adAgeDays` = days between `startDate` and the run time.
- `daysRunning` = the same for active ads; for inactive ads it is `endDate − startDate` (null if `endDate` is unavailable).
- `adAgeCategory` thresholds (days since start): **NEW** 0–7 · **RECENT** 8–30 · **ESTABLISHED** 31–90 · **LONG\_RUNNING** 91+ · **UNKNOWN** when the start date is missing.

Meta reports the current date as the end date of ads that are still running; the Actor therefore leaves `endDate` `null` for active ads.

> A long-running ad has simply been observed running for that period. This is **not** evidence that the ad is successful or profitable.

### Landing-page analysis

With `includeLandingPage: true` the Actor fetches each unique landing page once (HTTP GET through the configured proxy, with one direct retry if the proxy itself fails; follows up to 10 redirects, no deeper crawling) and records `finalUrl`, `landingPageDomain`, `pageTitle`, `metaDescription`, `httpStatus` and `redirectCount`. Facebook link wrappers (`l.facebook.com/l.php?u=…`) are unwrapped first; Meta-hosted destinations (Facebook, Instagram, WhatsApp, Messenger) are recorded but not fetched.

`landingPageType` is a heuristic based on URL path segments and, secondarily, the page title:

| Type | Typical signals |
| --- | --- |
| `PRODUCT` | /product, /shop, /vehicles, /models, /deals, /specials, /collections |
| `SERVICE` | /services, /solutions, /treatments, /courses, /finance |
| `CONTACT` | /contact, /enquire, /find-a-dealer, /locations |
| `BOOKING` | /book, /reservation, /appointment, /test-drive |
| `PRICING` | /pricing, /plans, /packages, /quote |
| `BLOG` | /blog, /news, /articles, /guides |
| `FORM` | /apply, /register, /signup, /subscribe, /download, /demo |
| `HOME` | root path |
| `UNKNOWN` | nothing matched |

The classification is deliberately simple and transparent; it will be wrong for some sites.

### Creative fingerprints and duplicate detection

- `textFingerprint` – SHA-256 of the normalized primary text, headline and description (lower-cased, diacritics and punctuation removed, URLs stripped).
- `mediaFingerprint` – SHA-256 of the media asset file names (Meta's per-region CDN hosts and rotating signature parameters are ignored). It identifies the same *file reference*; it does not prove two creatives look identical, and Meta occasionally serves a different rendition of the same creative, which then registers as a change.
- `creativeFingerprint` – SHA-256 combining advertiser, text fingerprint, media fingerprint and the normalized landing URL (tracking parameters removed).

Fingerprints are stable across runs and independent of `includeMedia`.

De-duplication keys, in order: Ad Library ID → normalized ad URL → creative fingerprint + advertiser. An ad loaded twice while scrolling, or found under two search terms, appears once. `collationId` / `collationCount` expose Meta's own grouping of near-identical ad versions.

### Advertiser statistics and competitor comparison

With `includeAdvertiserStatistics: true`, `ADVERTISER_STATISTICS` contains one entry per advertiser:

```json
{
    "advertiser": "Example Motors",
    "advertiserPageId": "200000000000001",
    "statistics": {
        "totalAds": 42, "activeAds": 35, "inactiveAds": 7, "unknownStatusAds": 0,
        "imageAds": 13, "videoAds": 24, "carouselAds": 5, "otherAds": 0,
        "averageAdAgeDays": 34, "medianAdAgeDays": 28, "longestRunningAdDays": 121,
        "offerAds": 17, "discountAds": 9, "urgencyAds": 6,
        "commonCTAs": [{ "cta": "LEARN_MORE", "count": 20 }, { "cta": "SHOP_NOW", "count": 12 }],
        "platformDistribution": { "Facebook": 42, "Instagram": 39 },
        "landingPageDistribution": { "PRODUCT": 30, "BOOKING": 8, "UNKNOWN": 4 },
        "landingDomainDistribution": { "example-motors.test": 42 },
        "offerTypeDistribution": { "DISCOUNT": 9, "FINANCING": 5, "FREE": 3 },
        "adAgeCategoryDistribution": { "ESTABLISHED": 20, "LONG_RUNNING": 12, "RECENT": 10 },
        "uniqueCreatives": 31
    },
    "note": "Statistics are computed only from the ads collected in this Actor run. …"
}
```

`COMPETITOR_COMPARISON` lists observable metrics side by side (total / active ads, video / image / carousel percentages, offer and urgency percentages, average ad age, longest-running ad, top CTA, top landing-page type, platforms). **No ranking, "winner" or "best competitor" claim is made** – these are only the numbers observed in the collected sample, not the advertiser's complete history, spend or performance.

### Historical comparison

With `enableHistoricalComparison: true` the Actor stores a snapshot of every ad's identity and fingerprints in the named key-value store (`historyStoreName`) under a key derived from the search configuration (terms, page IDs, country, status, match type, date range). The next run with the same configuration loads it and sets `changeStatus` per ad:

| `changeStatus` | Meaning |
| --- | --- |
| `NEW` | Ad ID not present in the previous snapshot |
| `UNCHANGED` | Same ad ID and identical creative (or text) fingerprint |
| `MODIFIED` | Same ad ID but a different creative / text fingerprint |

Ads present previously but not observed now are listed in `HISTORICAL_COMPARISON.removed` (they are not dataset items because they were not observed in this run). Without a previous snapshot every ad is `NEW`; with the feature disabled `changeStatus` is `null`.

Limitations: comparison is only meaningful when the configuration is identical; a smaller `maxAdsPerSearch` or Meta's ranking changes can make ads look `REMOVED` while they still run. The previous snapshot is kept under `<key>-previous` for inspection. Named key-value stores persist across runs on the Apify platform (and under `./storage` locally).

### Proxy configuration

`proxyConfiguration` uses the standard Apify proxy editor and defaults to Apify Proxy's **RESIDENTIAL** group. This matters: in testing, Meta served the first page of results (about 30 ads) to datacenter IPs but rejected every pagination request, so datacenter proxies cap each search at roughly 30 ads. With RESIDENTIAL selected the Actor automatically sets the proxy country to the search country. Nothing is hard-coded: credentials come from the platform (or `APIFY_PROXY_PASSWORD` locally). Without a proxy the Actor still runs (useful locally) and reports blocks honestly.

### Performance and cost

- One browser page per search term / Page ID, at most `maxConcurrency` in parallel.
- Images, video and fonts are blocked in the browser; media is never downloaded, only URLs are stored.
- Landing pages are fetched only with `includeLandingPage: true`, once per unique URL, using lightweight HTTP requests (not a browser).
- History storage is touched only with `enableHistoricalComparison: true`.
- Scrolling is bounded by `maxAdsPerSearch`, Meta's own "no more results" signal, `maxScrollRoundsWithoutNewAds` and a hard cap.
- Rough guide: 100 ads for one term take about 30–60 seconds of browser time plus landing-page requests.

### Error handling

One failed ad or search never stops the run:

- Navigation errors are retried (2 retries with Crawlee's back-off); a search that ultimately fails is reported in `OUTPUT.searches[].error` with `errorType` (`ACCESS_BLOCKED`, `TIMEOUT`, `NETWORK_ERROR`, `PARSE_ERROR`, `UNKNOWN`).
- Landing-page failures are recorded on the ad (`error`, `errorType`, `errorStage: "landing-page-enrichment"`) while the ad itself is still emitted.
- If the structured payload is unavailable but the page renders, a text-based fallback recovers Ad IDs and dates; such records carry `errorStage: "ad-discovery"` so you know they are partial.
- The run finishes with `SUCCEEDED`, `PARTIAL` (some searches failed) or fails explicitly when every search failed (`ACCESS_BLOCKED` when Meta blocked all of them). No data is ever fabricated to make a run look successful.

### Limitations

- Only publicly available Ad Library data is used. Spend, impressions, reach, conversions, ROAS and revenue are **not** available for regular commercial ads and are not reported.
- Meta changes its interface without notice. All URL parameters, payload keys and block indicators are centralized in `src/meta/selectors.ts` for quick updates.
- Meta may block or throttle automated access (login wall, CAPTCHA, HTTP 429). The Actor detects and reports this; it does not bypass it.
- Offer detection, CTA normalization, creative classification and landing-page typing are deterministic heuristics – expect some misses and false positives.
- Historical comparison depends on identical configurations between runs (see above).
- Ad age reflects observed running time only, never success.
- Search results are governed by Meta's relevance ranking; a keyword search can include ads from unrelated advertisers that mention the keyword.

### Responsible usage

This Actor processes only publicly available advertising information from the Meta Ad Library, which Meta publishes for transparency purposes. It does not log in, does not use cookies or credentials, does not access private accounts or private ads, and does not bypass CAPTCHAs, rate limits or other security mechanisms.

You are responsible for using the collected data in compliance with Meta's terms, applicable laws, privacy and data-protection regulations (e.g. GDPR, POPIA, CCPA) and advertising / consumer-protection rules in your jurisdiction. Do not use the output to harass advertisers, to republish copyrighted creatives, or to build profiles of private individuals.

### Local development

Requirements: Node.js 20+ (22 recommended), npm, and Chromium for Playwright.

```bash
git clone <this repository>
cd facebook-ads-competitor-intelligence
npm install
npx playwright install chromium   # local browser for development runs
npm run build
```

Create the input locally (Apify SDK reads it from the default key-value store):

```bash
mkdir -p storage/key_value_stores/default
cp smoke/input.json storage/key_value_stores/default/INPUT.json
npm start
```

Or simply run the bundled smoke configuration (Toyota, ZA, 3 ads, no landing pages / media):

```bash
npm run smoke                      # prepares the input and runs the compiled Actor
node scripts/verify-smoke-output.mjs   # validates OUTPUT + dataset items against the dataset schema
```

Results land in `storage/datasets/default/` and `storage/key_value_stores/default/`. Copy `.env.example` to `.env` if you want to set `APIFY_TOKEN` / `APIFY_PROXY_PASSWORD` for Apify Proxy locally. No AI keys are needed.

Useful scripts: `npm run build`, `npm start`, `npm run start:dev` (tsx, no build), `npm test`, `npm run lint`, `npm run typecheck`, `npm run check` (build + lint + test), `npm run smoke`.

### Testing

```bash
npm test
```

Vitest runs 90+ tests covering input validation, URL / text / date normalization, CTA normalization, offer detection, creative classification, ad age, fingerprints, de-duplication, historical comparison, statistics, the Meta payload parser (GraphQL bodies, embedded HTML, block detection, DOM fallback), landing-page enrichment (mocked fetcher) and the end-to-end pipeline including a check that every emitted record conforms to `.actor/dataset_schema.json`. All external Meta traffic is replaced by small synthetic fixtures in `tests/fixtures/`.

### Docker

```bash
docker build --platform linux/amd64 -t facebook-ads-competitor-intelligence .
## full Docker smoke test: browser launch, mocked test-suite inside the container, live Actor run
./scripts/docker-smoke.sh
```

The image is based on `apify/actor-node-playwright-chrome:22-1.63.0` (Node 22, Playwright 1.63.0 with Chromium and system dependencies). The Apify base image is published for `linux/amd64`; on Apple Silicon Docker Desktop runs it under emulation.

### Apify deployment

1. Install the Apify CLI and log in: `npm i -g apify-cli && apify login`.
2. From the project directory run `apify push` – the Actor definition in `.actor/actor.json`, the input schema and the Dockerfile are used to build the Actor in the cloud.
3. Open the Actor in Apify Console, fill in the input form (the schema provides defaults, validation and a proxy editor) and press **Start**.
4. Results: **Dataset** tab (one row per ad, exportable as JSON / CSV / Excel), **Key-value store** tab (`OUTPUT`, `ADVERTISER_STATISTICS`, `COMPETITOR_COMPARISON`, `HISTORICAL_COMPARISON`).
5. Schedule the Actor (e.g. daily or weekly) with `enableHistoricalComparison: true` to track new, modified and removed competitor ads over time.

### Roadmap (V2)

V2 will add an optional, clearly separated AI enrichment layer (never required to run the Actor): AI ad summaries, creative and hook analysis, target-audience inference, competitor strategy summaries, offer analysis, landing-page analysis and narrative competitive reports. The V1 pipeline (`src/pipeline.ts`) already produces the clean, deterministic records such a layer would consume.

# Actor input Schema

## `searchTerms` (type: `array`):

Keywords, brand names or competitor page names to search for in the Meta Ad Library (one search per term). Duplicates are removed automatically. Either this or Page IDs must be provided.

## `pageIds` (type: `array`):

Numeric Facebook Page IDs to collect ads for directly (equivalent to opening a Page's advertiser view in the Ad Library). Useful when a keyword search is too broad. Either this or Search terms must be provided.

## `country` (type: `string`):

ISO 3166-1 alpha-2 country code used for the Ad Library country filter (e.g. ZA, ZW, GB, US, CA, AU) or ALL for all countries. Invalid codes fail validation – they are never silently interpreted.

## `adStatus` (type: `string`):

Ad Library status filter: ACTIVE (currently running), INACTIVE (no longer running) or ALL.

## `searchType` (type: `string`):

How search terms are matched by the Ad Library: any-word match (default) or exact phrase.

## `maxAdsPerSearch` (type: `integer`):

Stop collecting once this many unique ads were found for a single search term or page ID. Controls cost and run time.

## `startDateMin` (type: `string`):

Only include ads that started running on or after this date (YYYY-MM-DD). Applied via the Ad Library date filter where supported.

## `startDateMax` (type: `string`):

Only include ads that started running on or before this date (YYYY-MM-DD).

## `includeInactiveAds` (type: `boolean`):

When false, ads whose status resolves to INACTIVE are dropped from the output (relevant when Ad status is ALL). Automatically true when Ad status is INACTIVE.

## `includeLandingPage` (type: `boolean`):

Visit each ad's public landing page once (no deep crawling) to capture final URL, domain, title, meta description, HTTP status, redirect count and a deterministic page-type classification. Increases run time.

## `includeMedia` (type: `boolean`):

Include publicly accessible image, video and thumbnail URLs for each creative. Media is never downloaded – only source URLs are stored.

## `includeAdvertiserStatistics` (type: `boolean`):

Aggregate per-advertiser statistics and a competitor comparison from the collected ads. Stored in the run's key-value store (OUTPUT, ADVERTISER\_STATISTICS, COMPETITOR\_COMPARISON).

## `enableHistoricalComparison` (type: `boolean`):

Compare this run against the previous run with the same search configuration (stored in a named key-value store) and label ads NEW / UNCHANGED / MODIFIED; removed ads are listed in the HISTORICAL\_COMPARISON summary.

## `historyStoreName` (type: `string`):

Name of the named key-value store used to persist snapshots for historical comparison.

## `maxConcurrency` (type: `integer`):

Maximum number of browser pages (search terms) processed in parallel. Also caps parallel landing-page requests.

## `maxScrollRoundsWithoutNewAds` (type: `integer`):

Safety stop for infinite scrolling: stop a search when this many consecutive scroll rounds yield no new ads.

## `navigationTimeoutSecs` (type: `integer`):

Timeout for loading an Ad Library results page.

## `landingPageTimeoutSecs` (type: `integer`):

Timeout for a single landing-page request.

## `proxyConfiguration` (type: `object`):

Proxy settings. Apify Proxy RESIDENTIAL group is strongly recommended: Meta serves the first page of results to datacenter IPs but rejects the pagination requests needed to load more than ~30 ads per search. When RESIDENTIAL is selected the proxy country is matched to the search country automatically.

## Actor input object example

```json
{
  "searchTerms": [
    "Toyota",
    "Ford",
    "Mazda"
  ],
  "pageIds": [],
  "country": "ZA",
  "adStatus": "ACTIVE",
  "searchType": "KEYWORD_UNORDERED",
  "maxAdsPerSearch": 100,
  "includeInactiveAds": false,
  "includeLandingPage": true,
  "includeMedia": true,
  "includeAdvertiserStatistics": true,
  "enableHistoricalComparison": false,
  "historyStoreName": "facebook-ads-competitor-intelligence-history",
  "maxConcurrency": 3,
  "maxScrollRoundsWithoutNewAds": 4,
  "navigationTimeoutSecs": 60,
  "landingPageTimeoutSecs": 20,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `ads` (type: `string`):

All extracted ads (one item per unique ad) with creative type, CTA, offer detection, ad age, landing page data, fingerprints and change status.

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

Run status, totals per search, deduplication counts, embedded statistics and notes on data limitations.

## `advertiserStatistics` (type: `string`):

Per-advertiser aggregates (ad counts, creative mix, CTA mix, offer rate, ad age distribution). Present only when includeAdvertiserStatistics is enabled.

## `competitorComparison` (type: `string`):

Side-by-side comparison table of the discovered advertisers. Present only when includeAdvertiserStatistics is enabled.

## `historicalComparison` (type: `string`):

New, unchanged and modified ads compared with the previous snapshot. Present only when enableHistoricalComparison is enabled and a previous snapshot exists.

# 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 = {
    "searchTerms": [
        "Toyota",
        "Ford",
        "Mazda"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("outspoken_strategy/facebook-ads-competitor-intelligence").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 = {
    "searchTerms": [
        "Toyota",
        "Ford",
        "Mazda",
    ],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("outspoken_strategy/facebook-ads-competitor-intelligence").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 '{
  "searchTerms": [
    "Toyota",
    "Ford",
    "Mazda"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call outspoken_strategy/facebook-ads-competitor-intelligence --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,outspoken_strategy/facebook-ads-competitor-intelligence"
        }
    }
}
```

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/yQT07uMNNRldibE6i/builds/IIUYJFDy9VOKyvKID/openapi.json
