# 🏷️ MAP Violation Detector - Reseller Price Enforcement (`that_red_bird/map-violation-detector`) Actor

🏷️ Match retailer listings to your brand catalogue and flag Minimum Advertised Price (MAP) violations, with enforcement-ready evidence. ✅ Brand/model/size/pack hard disqualifiers block wrong-SKU matches. ⚠️ Low-confidence matches are reported unmatched, never accused.

- **URL**: https://apify.com/that\_red\_bird/map-violation-detector.md
- **Developed by:** [mohamed alaya](https://apify.com/that_red_bird) (community)
- **Categories:** E-commerce
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

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/platform/actors/running/actors-in-store#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

## MAP Violation Detector

Brands set a Minimum Advertised Price (MAP); resellers sometimes advertise below it anyway.
This actor matches **observed retailer listings** against **your brand catalogue**, compares the
observed price to MAP with a configurable tolerance, and emits an **evidence row per violation** —
retailer, listing URL, observed price, MAP, delta, match confidence, matched-on fields, timestamp —
the kind of thing that goes straight into an enforcement letter. Brands pay real money for exactly
this.

### Why the matching step is the whole ballgame

Getting the match wrong means accusing a reseller of violating MAP on the **wrong product**. That
is not a cosmetic bug, it is a legal and reputational risk. So this actor reuses the
`product-matcher` actor's matching approach (adapted, not imported — this actor has no
cross-actor dependency): brand, model/part number, and GTIN identity, plus **size and pack-count
canonicalisation** ("500ml" vs "0.5L" vs "16.9 fl oz" all compare correctly). Crucially, a
**different size or pack count is a HARD DISQUALIFIER** — it blocks a match even when the titles
are near-identical, before any weighted scoring runs. A 500ml bottle and a 1L bottle of the exact
same brand are NOT the same SKU, and this actor will never say they are.

Any listing that cannot be matched with at least `matchThreshold` confidence is reported as
**unmatched**, never scored as a violation. Match confidence is included on every row so a human
can audit the call.

### What it does

1. **Input** — a brand catalogue (`catalogue`: product identity + MAP price per SKU) and observed
   listings (`listings`: retailer, title, price, url), inline and/or via Apify dataset IDs. Listing
   prices can also be **scraped directly** from `listingUrls` using the shared `monitor-core`
   engine's selector-free price detection (JSON-LD → microdata/OG → common price containers →
   text-scan fallback).
2. **Match** each listing to its best-scoring catalogue candidate using brand/model/GTIN identity
   and canonicalised size/pack-count, with blocking so large catalogues stay fast.
3. **Compare** price vs MAP with a configurable absolute and/or percent tolerance
   (`toleranceAbsolute`, `tolerancePercent`, `toleranceMode`), currency-aware (`requireSameCurrency`
   — a currency mismatch is reported unmatched rather than silently mis-compared, since there is no
   live FX conversion here).
4. **Classify** every listing as `compliant`, `violation`, `below-threshold-but-within-tolerance`,
   or `unmatched`.
5. **Severity** on every violation: `deltaAbsolute`, `deltaPercent`, and a `minor`/`moderate`/
   `severe` bucket (10% / 25% breakpoints).
6. **Per-retailer summary** (`RETAILER_SUMMARY` in the key-value store, and inline in `SUMMARY`):
   violation count, worst offender (URL + delta), average discount below MAP.
7. **Optional repeat-offender tracking** (`trackRepeatOffenders`) persists cumulative per-retailer
   violation counts in a named key-value store across runs.

### Input

```json
{
  "catalogue": [
    { "sku": "ACME-500", "title": "Acme Hydro Flask 500ml", "brand": "Acme", "size": "500ml", "map": 24.99, "currency": "USD" }
  ],
  "listings": [
    { "retailer": "discount-outlet.example", "title": "Acme Hydro Flask 500ml Bottle", "price": 17.99, "url": "https://discount-outlet.example/p/123", "currency": "USD" }
  ],
  "matchThreshold": 80,
  "tolerancePercent": 2
}
```

### Output

One row per checked listing (unless filtered by `includeCompliant`/`includeUnmatched`): its
classification, severity (violations only), `observedPrice`, `mapPrice`, `deltaAbsolute`,
`deltaPercent`, `matchConfidence`, `matchedOn` (which fields agreed), and full per-field
`evidence`. A `SUMMARY` key-value record with run stats and the per-retailer breakdown.

### Honest limitations — read before you act on this

- **Match confidence matters legally.** This is text/structured-attribute matching, not image or
  barcode-verified identity. A confident-looking title match is still a probabilistic call.
  Anything below `matchThreshold` is reported `unmatched`, on purpose — never guess your way into
  an accusation.
- **No live currency conversion.** Cross-currency listings are reported `unmatched`
  (`currency-mismatch`) rather than compared using a stale or assumed rate.
- **Prices must share one unit convention.** `toleranceAbsolute` is a whole number in the SAME
  currency-unit convention (dollars, or cents — pick one) as your catalogue/listing prices; the
  catalogue/listing price fields themselves are plain numbers, not schema-restricted.
- **GTIN/EAN/UPC/ASIN are normalised but never checksum-validated.**
- **Bare "oz" is treated as fluid ounce**, matching common retail convention — pass an explicit
  `size` field to avoid ambiguity for weight-in-ounces products.
- **Scraped listing prices depend on page structure.** `detectPrice`'s JSON-LD/microdata/selector
  fallback chain is reliable on most modern e-commerce pages but can miss on unusual layouts —
  those rows report an explicit scrape error rather than a fabricated price.
- Capped by the same blocking safety valve as `product-matcher`: oversized blocks are skipped
  rather than compared exhaustively.

# Actor input Schema

## `catalogue` (type: `array`):

Your product catalogue: one object per SKU with product identity fields (title/name, brand, model/mpn/sku, gtin/ean/upc/asin, size, packCount) and the MAP price under "map" (or "mapPrice"). Prices can be decimals — only this input schema's own numeric fields (thresholds, tolerances) are restricted to whole numbers, not your catalogue data. Keep all prices in one currency-unit convention (e.g. always dollars, or always cents) across catalogue and listings.

## `catalogueDatasetId` (type: `string`):

Optional Apify dataset ID to load additional catalogue rows from, merged with "catalogue". Rows must have the same shape described above.

## `listings` (type: `array`):

Retailer listings to check: one object per listing with retailer, title, price (number, same currency-unit convention as the catalogue), url, and optionally currency and timestamp.

## `listingsDatasetId` (type: `string`):

Optional Apify dataset ID to load additional observed listings from, merged with "listings". Rows must have the same shape described above.

## `listingUrls` (type: `array`):

Optional: product page URLs to scrape directly (no selector needed — JSON-LD / microdata / common price containers / text-scan fallback detect the price and title). Each entry is a URL string, or an object { "url", "retailer" }. Scraped rows are merged with "listings" before matching.

## `matchThreshold` (type: `integer`):

Minimum match confidence (0-100) required to treat a listing as the same product as a catalogue item. Below this, the listing is reported as "unmatched" rather than ever being accused of a violation — a wrong-SKU match is a real legal risk, not just a bad row.

## `toleranceAbsolute` (type: `integer`):

Amount below MAP that is still tolerated (not flagged as a violation), in the SAME currency-unit convention you used for catalogue/listing prices (e.g. both in whole dollars, or both in cents — pick one convention and use it everywhere, since this field must be a whole number). 0 disables the absolute tolerance.

## `tolerancePercent` (type: `integer`):

Percent below MAP that is still tolerated (not flagged as a violation), e.g. 2 means a listing up to 2% under MAP is not a violation. 0 disables the percent tolerance.

## `toleranceMode` (type: `string`):

"either" (default): a listing is tolerated if it satisfies the absolute OR the percent tolerance (more lenient). "both": it must satisfy every tolerance that is configured (non-zero) to be tolerated (stricter).

## `requireSameCurrency` (type: `boolean`):

When true (default), a listing whose currency differs from its matched catalogue item's currency is reported as "unmatched" (reason: currency-mismatch) instead of compared — there is no live FX conversion, so comparing across currencies would silently produce a wrong verdict.

## `weights` (type: `object`):

Optional override of the matching engine's field weights, e.g. { "brand": 3, "title": 2, "size": 2, "pack": 1 }. Leave empty to use the defaults. Size and pack-count mismatches are hard disqualifiers regardless of these weights — see README.

## `maxBlockSize` (type: `integer`):

Safety cap: if more than this many catalogue items share one blocking key (same brand, for example), that block is skipped rather than compared exhaustively, to keep large catalogues fast.

## `concurrency` (type: `integer`):

How many listing URLs (from "listingUrls") to fetch in parallel.

## `includeCompliant` (type: `boolean`):

Push a row for every listing found compliant with MAP, not just violations. Off by default so the dataset stays focused on what needs action.

## `includeUnmatched` (type: `boolean`):

Push a row for every listing that could not be matched to a catalogue item with enough confidence, so you can review near-misses. On by default — an unmatched listing might still be worth a human look.

## `trackRepeatOffenders` (type: `boolean`):

When true, cumulative per-retailer violation counts are persisted in a named key-value store across runs (see "Repeat offender store name"), and each output row is enriched with the retailer's running totals.

## `repeatOffenderStoreName` (type: `string`):

Name of the Apify key-value store used to persist cumulative per-retailer violation counts when "Track repeat offenders across runs" is on. Use the same name across runs you want tracked together.

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

Optional Apify proxy configuration used when scraping "listingUrls".

## Actor input object example

```json
{
  "catalogue": [
    {
      "sku": "ACME-500",
      "title": "Acme Hydro Flask 500ml",
      "brand": "Acme",
      "size": "500ml",
      "map": 24.99,
      "currency": "USD"
    }
  ],
  "listings": [
    {
      "retailer": "discount-outlet.example",
      "title": "Acme Hydro Flask 500ml Bottle",
      "price": 17.99,
      "url": "https://discount-outlet.example/p/123",
      "currency": "USD"
    }
  ],
  "matchThreshold": 80,
  "toleranceAbsolute": 0,
  "tolerancePercent": 0,
  "toleranceMode": "either",
  "requireSameCurrency": true,
  "maxBlockSize": 1000,
  "concurrency": 5,
  "includeCompliant": false,
  "includeUnmatched": true,
  "trackRepeatOffenders": false,
  "repeatOffenderStoreName": "map-violation-detector-repeat-offenders"
}
```

# Actor output Schema

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

No description

## `downloadCsv` (type: `string`):

No description

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

No description

## `count` (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 = {
    "catalogue": [
        {
            "sku": "ACME-500",
            "title": "Acme Hydro Flask 500ml",
            "brand": "Acme",
            "size": "500ml",
            "map": 24.99,
            "currency": "USD"
        }
    ],
    "listings": [
        {
            "retailer": "discount-outlet.example",
            "title": "Acme Hydro Flask 500ml Bottle",
            "price": 17.99,
            "url": "https://discount-outlet.example/p/123",
            "currency": "USD"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("that_red_bird/map-violation-detector").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 = {
    "catalogue": [{
            "sku": "ACME-500",
            "title": "Acme Hydro Flask 500ml",
            "brand": "Acme",
            "size": "500ml",
            "map": 24.99,
            "currency": "USD",
        }],
    "listings": [{
            "retailer": "discount-outlet.example",
            "title": "Acme Hydro Flask 500ml Bottle",
            "price": 17.99,
            "url": "https://discount-outlet.example/p/123",
            "currency": "USD",
        }],
}

# Run the Actor and wait for it to finish
run = client.actor("that_red_bird/map-violation-detector").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 '{
  "catalogue": [
    {
      "sku": "ACME-500",
      "title": "Acme Hydro Flask 500ml",
      "brand": "Acme",
      "size": "500ml",
      "map": 24.99,
      "currency": "USD"
    }
  ],
  "listings": [
    {
      "retailer": "discount-outlet.example",
      "title": "Acme Hydro Flask 500ml Bottle",
      "price": 17.99,
      "url": "https://discount-outlet.example/p/123",
      "currency": "USD"
    }
  ]
}' |
apify call that_red_bird/map-violation-detector --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,that_red_bird/map-violation-detector"
        }
    }
}

```

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/jEWUZFzL3m5jAc6Am/builds/DkQLOsyH2JdHBbiCO/openapi.json
