# Facebook Ads Library Competitor Tracker (`herazur/facebook-ads-library-competitor-tracker`) Actor

Track competitor Facebook and Instagram ads using Meta Ad Library data. Detect new and stopped ads, copy, CTA, landing-page, offer, creative, and media-mix changes over time.

- **URL**: https://apify.com/herazur/facebook-ads-library-competitor-tracker.md
- **Developed by:** [Furkan Toluç](https://apify.com/herazur) (community)
- **Categories:** Social media, Marketing, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.40 / 1,000 ad trackeds

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

## Facebook Ads Library Competitor Tracker

Track new, stopped, changed, long-running, and variant-heavy Facebook and Instagram ads by competitor.

Facebook Ads Library Competitor Tracker is a stateful competitive-intelligence Apify Actor. It answers one narrow question: **What changed in my competitors' public Meta advertising since the last time I checked?**

It is designed for paid media agencies, DTC brands, performance marketers, competitive-intelligence teams, and growth marketers. It is not a generic scraper, performance detector, AI analyzer, or dashboard.

### What it does

For each competitor, the Actor:

1. validates one HTTPS Facebook Page or advertiser-scoped Meta Ad Library URL;
2. calls `curious_coder/facebook-ads-library-scraper` in an isolated child run;
3. normalizes the public evidence and validates that it represents one advertiser;
4. compares it with the last successful snapshot stored in a named key-value store;
5. detects supported changes using stable ad IDs;
6. groups conservative creative families and extracts duration, media, CTA, landing-domain, and English-oriented offer signals;
7. writes one concise Dataset item per competitor; and
8. advances state only after the report has been written.

The parent Actor does not scrape Facebook directly. It requires no Meta login or Meta API token. It uses the Apify run's authenticated execution context to call the upstream Actor, whose usage may have its own cost.

### Input

```json
{
  "monitorId": "demo-brands-us",
  "competitors": [
    {
      "name": "HelloFresh US",
      "pageUrl": "https://www.facebook.com/HelloFreshUS"
    },
    {
      "name": "Nike",
      "pageUrl": "https://www.facebook.com/nike"
    }
  ],
  "country": "US",
  "maxAdsPerCompetitor": 20,
  "durableAdDays": 30,
  "resetBaseline": false,
  "debug": false
}
```

Reuse the same `monitorId`, country, and normalized competitor name on future runs. That tuple selects the previous snapshot. The first successful run creates a `BASELINE`; later runs compare against it. `resetBaseline: true` deliberately ignores the previous snapshot and replaces it only after successful report output.

`monitorId` was previously called `monitorKey`. Existing tasks, schedules, and API calls that still send `monitorKey` keep working and keep their history; when both are sent, `monitorId` wins. When neither is sent, the former default `skincare-us` is used. Output records keep the field name `monitorKey` for backward compatibility; it contains the monitor ID.

The prefilled example tracks two real advertisers with 20 requested ads each, so a first run finishes in a few minutes and creates a `BASELINE` for both.

The MVP accepts 1–5 competitors, requests 20–250 ads per competitor, and uses a 7–180 day long-running threshold. URLs must use HTTPS and a real `facebook.com` host or subdomain. Keyword inputs are not accepted. `ALL` is not accepted as a country because its reliability has not been established for the upstream source.

### Change language

- `LIKELY_NEW`: a previously unseen, high-confidence ad ID has a start time at or after the previous snapshot minus a six-hour clock-skew window.
- `NEWLY_OBSERVED`: a previously unseen stable ID clearly predates the prior snapshot, or its start date is unknown. It is not presented as a newly launched ad.
- `STOPPED_CONFIRMED`: the same high-confidence ID was previously active and is now explicitly inactive or has acquired a credible end date.
- `NOT_OBSERVED_THIS_RUN`: a previously observed high-confidence ID is absent from the current sample.
- `REACTIVATED`: the same high-confidence ID changed from explicitly inactive to active.
- `COPY_CHANGED`, `CTA_CHANGED`, and `LANDING_CHANGED`: both snapshots contain evidence for the relevant field and its deterministic fingerprint changed on the same stable ID.

**Absence never proves an ad stopped.** Meta Ad Library results can be sampled or limited, so absence produces `NOT_OBSERVED_THIS_RUN`, never `STOPPED_CONFIRMED`. Disappearing low-confidence derived IDs do not produce strong disappearance or stopped claims.

The status is `CHANGES_DETECTED` only when there is at least one likely-new, explicitly stopped, reactivated, or supported copy/CTA/landing change. `NEWLY_OBSERVED` or `NOT_OBSERVED_THIS_RUN` alone yields `NO_CONFIRMED_CHANGE`.

### Current snapshot intelligence

Every successful report includes observed/active/inactive counts, evidence coverage, media mix, top CTAs and landing domains, deterministic offer signals, up to ten oldest active ads, long-running counts, and conservative creative-family counts.

`LONG_RUNNING` means only that an ad is explicitly active and its public dates show at least `durableAdDays` of duration. It does not establish profitability.

`VARIANT_HEAVY` means an observed creative family contains at least three related variants. It does not establish spend, scaling, conversion quality, or profitability.

Offer extraction is deterministic and English-focused. It recognizes explicit signals such as percentage or fixed discounts, free shipping/trials/consultations/gifts, BOGO, promo codes, and limited-time phrases. It does not infer ambiguous offers.

### Creative-family grouping

Grouping is deterministic and false-merge averse:

1. equal Meta `collationId`: confidence `1.0`;
2. exact creative fingerprint: confidence `0.95`;
3. token Jaccard similarity ≥ `0.82`, only with the same known media type and exactly the same registrable landing domain: confidence `0.75`.

The implementation uses Union-Find. It does not use image similarity, embeddings, OCR, media downloads, or AI.

### Evidence strength and report threshold

Evidence strength is a transparent 0–100 score:

```text
round(
  stableIdCoverage × 45
  + startDateCoverage × 20
  + activeStatusCoverage × 20
  + contentCoverage × 15
)
```

A successful report requires a succeeded child run, at least one valid normalized ad, unambiguous advertiser identity, stable-ID coverage of at least 70%, and evidence strength of at least 60. Failures produce an uncharged `analysis_error` and never overwrite prior state.

### Output

The default Dataset contains exactly one item per requested competitor: either a `competitor_report` or an `analysis_error`. Its overview surfaces competitor, status, evidence, observed and active ads, likely new, confirmed stopped, long-running, variant-heavy families, primary change, and snapshot time.

The default run key-value store contains:

- `OUTPUT`: run-level counts;
- `REPORT.md`: a human-readable report; and
- `EVIDENCE.json`: normalized evidence from the current run only.

With `debug: true`, the default store also receives at most five raw and five normalized diagnostic records per competitor. Debug output never includes credentials.

Persistent comparison state lives separately in the named store `meta-ads-change-radar-state-v1` (configurable through `STATE_STORE_NAME`). Keys are `state-` plus 24 hexadecimal SHA-256 characters derived from monitor ID, country, and normalized competitor name. State stores only compact diff evidence, uses `schemaVersion: 1`, and increments a revision. A compare-before-write check preserves existing history if concurrent runs race.

If the confidently resolved page ID changes for the same state key, the Actor reports `COMPETITOR_IDENTITY_CHANGED` and creates a fresh baseline instead of comparing different advertisers.

### Upstream call

One child Actor run is made per competitor, with controlled concurrency. The generated input is exactly:

```json
{
  "urls": [
    {
      "url": "<validated and normalized competitor Facebook URL>"
    }
  ],
  "scrapeAdDetails": false,
  "limitPerSource": 100,
  "scrapePageAds.activeStatus": "all",
  "scrapePageAds.sortBy": "most_recent",
  "scrapePageAds.countryCode": "US",
  "runTag": "<monitorId>-<timestamp>-<competitorIndex>"
}
```

`limitPerSource` and `countryCode` reflect public input. No `period` is set, because that would damage long-running-ad evidence. No `count`, proxy override, or Meta credential is passed. Dataset retrieval is paginated and stops at `maxAdsPerCompetitor + 50`. Only `SUCCEEDED` child runs are analyzed.

Environment variables:

```text
META_ADS_ACTOR_ID=curious_coder/facebook-ads-library-scraper
META_ADS_CHILD_CONCURRENCY=2
META_ADS_CHILD_TIMEOUT_SECS=300
STATE_STORE_NAME=meta-ads-change-radar-state-v1
ENABLE_REPORT_CHARGING=false
```

### Pricing

The Store Actor uses Pay-Per-Event pricing. You pay per ad tracked, not per report:

- Ad tracked (`ad-tracked`): **$0.002** per competitor ad snapshot on the Apify Free plan ($2 per 1,000 ads), with lower prices on paid Apify plans
- Actor Start: $0.00005
- Platform usage: included

Each ad in a successful `BASELINE`, `CHANGES_DETECTED`, or `NO_CONFIRMED_CHANGE` report is charged once per run (`metadata.chargedAds` shows the count). Analysis errors are never charged. For example, 2 competitors with about 30 returned ads each cost about $0.12 per run on the Free plan; the default 100 requested ads cost about $0.20–$0.26 per competitor, because the upstream Actor can return up to 30 ads more than requested.

Upstream Meta Ad Library data costs are billed separately by the source Actor. The Actor respects your maximum cost per run: it tracks only as many ads as the limit allows, marks a truncated report (and does not advance its saved state), and skips the remaining competitors without calling the upstream Actor once the limit is reached.

Charging is active only when the Actor runs with Pay-Per-Event pricing and `ENABLE_REPORT_CHARGING=true`; local runs are never charged.

### Local development

Node 24 is the production target.

```bash
npm ci
npm run typecheck
npm test
npm run build
npm run check
npx --yes apify-cli@latest validate-schema
npm audit
```

No live upstream run is part of automated validation. See [LIVE_VALIDATION.md](LIVE_VALIDATION.md) for the manual sequence after private deployment.

### Private deployment readiness

1. Create a private GitHub repository named `meta-ads-competitor-change-radar`.
2. Push this project without local `storage`, `.env`, or credentials.
3. Create a private Apify Actor connected to that repository.
4. Build with the included Node 24 Dockerfile.
5. Keep `ENABLE_REPORT_CHARGING=false`.
6. Follow `LIVE_VALIDATION.md`, beginning with a one-competitor, 20-ad schema probe.
7. Do not publish or enable pricing until the validation targets are met.

### Limitations

- Results are sampled public Ad Library evidence and may be truncated.
- The upstream Actor is an external dependency and its schema can change.
- Only publicly available Ad Library fields are used.
- Offer extraction is English-oriented.
- No performance metrics, spend, profitability, conversion, or ROAS inference is made.
- Meta can change data availability or visibility.
- The parent does not crawl landing pages, fetch media, or enrich advertisers.

Long-running, variant-heavy, and newly observed signals are public-ad-library evidence. They do not prove profitability, spend, ROAS, or conversion performance.

# Actor input Schema

## `monitorId` (type: `string`):

Name for this tracked set of competitors. Reuse the same monitor ID on future runs to compare against the previous snapshot. Formerly called monitorKey; API calls and saved tasks that still send monitorKey keep working.

## `competitors` (type: `array`):

One to five Facebook Page URLs or advertiser-scoped Meta Ad Library URLs. Each URL must identify one advertiser.

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

Uppercase ISO 3166-1 alpha-2 country code.

## `maxAdsPerCompetitor` (type: `integer`):

Requested upstream sample size. The upstream Actor can return a modest overage.

## `durableAdDays` (type: `integer`):

An explicitly active ad is LONG_RUNNING when its observed duration reaches this threshold.

## `resetBaseline` (type: `boolean`):

Ignore the prior snapshot for supplied competitors and replace it only after successful report output.

## `debug` (type: `boolean`):

Save at most five raw and normalized records per competitor in the run's default key-value store.

## Actor input object example

```json
{
  "monitorId": "demo-brands-us",
  "competitors": [
    {
      "name": "HelloFresh US",
      "pageUrl": "https://www.facebook.com/HelloFreshUS"
    },
    {
      "name": "Nike",
      "pageUrl": "https://www.facebook.com/nike"
    }
  ],
  "country": "US",
  "maxAdsPerCompetitor": 20,
  "durableAdDays": 30,
  "resetBaseline": false,
  "debug": false
}
```

# Actor output Schema

## `reports` (type: `string`):

One concise report or analysis error per requested competitor.

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

No description

## `markdownReport` (type: `string`):

No description

## `evidence` (type: `string`):

No description

# 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 = {
    "monitorId": "demo-brands-us",
    "competitors": [
        {
            "name": "HelloFresh US",
            "pageUrl": "https://www.facebook.com/HelloFreshUS"
        },
        {
            "name": "Nike",
            "pageUrl": "https://www.facebook.com/nike"
        }
    ],
    "maxAdsPerCompetitor": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("herazur/facebook-ads-library-competitor-tracker").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 = {
    "monitorId": "demo-brands-us",
    "competitors": [
        {
            "name": "HelloFresh US",
            "pageUrl": "https://www.facebook.com/HelloFreshUS",
        },
        {
            "name": "Nike",
            "pageUrl": "https://www.facebook.com/nike",
        },
    ],
    "maxAdsPerCompetitor": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("herazur/facebook-ads-library-competitor-tracker").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 '{
  "monitorId": "demo-brands-us",
  "competitors": [
    {
      "name": "HelloFresh US",
      "pageUrl": "https://www.facebook.com/HelloFreshUS"
    },
    {
      "name": "Nike",
      "pageUrl": "https://www.facebook.com/nike"
    }
  ],
  "maxAdsPerCompetitor": 20
}' |
apify call herazur/facebook-ads-library-competitor-tracker --silent --output-dataset

```

## MCP server setup

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

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/tt5N2xYwjh194IheZ/builds/l5iAkgu8p9MrVWdFs/openapi.json
