# Catalog Matcher: Competitor Product Matching & Price Gaps (`cleanpull/catalog-matcher`) Actor

Match your product catalogue against competitors' by barcode (GTIN/UPC/EAN), MPN, SKU, model number or title, with match method and confidence per pair, price gap %, and the products only one side sells. Reads Shopify, any-store and Amazon datasets or your own rows.

- **URL**: https://apify.com/cleanpull/catalog-matcher.md
- **Developed by:** [CleanPull](https://apify.com/cleanpull) (community)
- **Categories:** E-commerce, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.60 / 1,000 matched pairs

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

## Catalog Matcher: Competitor Product Matching & Price Gaps

Give it **your product catalogue and one or more competitors'**: datasets from Shopify Store Scraper, Any Store Product Monitor or Amazon Product Scraper, or your own rows. It finds **the same products on both sides**, by barcode (GTIN, UPC, EAN), manufacturer part number, SKU, model number or title, and says **how each pair was matched and how sure it is**. Every pair comes with **both prices and the price gap**. It also lists **what they sell that you don't, and what you sell that they don't**, and a summary per competitor with overlap and a **price index**. From **$2 per 1,000 matched pairs**.

### What you can do with it

- **"Which of my products does this competitor also sell, and at what price?"** One row per matched pair: your price, theirs, the gap in %, and who is cheaper.
- **"What do they sell that I don't?"** Gap rows for their products with no match among yours. By default only in brands you already carry (*Gaps in your brands only*): the ranges you could add first. Turn it off for every product they sell that you don't.
- **"Where am I the only seller?"** Gap rows the other way: your products they don't carry.
- **"Am I cheaper or dearer overall?"** The summary row per competitor gives the median price index (their price as % of yours), how many pairs are cheaper on each side, and the overlap.
- **"Tell me when that changes."** Schedule it with a watchlist name and Changes only: you get only new matches, price moves, new gaps, and pairs or gaps that are gone.
- **"Tell me when a competitor sells something I sell for less."** Give your store and theirs as Shopify domains (*Competitor stores*), an *Undercut alert* and a watchlist name, and schedule it weekly. One run reads the stores, compares them and reports the products where they newly undercut you. No other task or webhook is needed.
- **"Feed it to a spreadsheet or an AI agent."** Flat rows with the same field names for every source, and one dataset view per job.

### How it matches

Each competitor product goes down a ladder and stops at the first step that matches. `matchMethod` and `confidence` (0–1) say which step it was:

| Step | `matchMethod` | Matches on                                                                                                                     | Confidence                                                                                  |
| ---- | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| 1    | `gtin`        | The same barcode (UPC, EAN-13, EAN-8, GTIN-14, written any way, check digit verified), on the product or any variant           | 1 (0.9 for an Amazon product found by a barcode search whose page doesn't show the barcode) |
| 2    | `mpn`         | The same manufacturer part number (also found in the other side's SKU field), same brand                                       | 0.95 (0.83 if one side has no brand)                                                        |
| 3    | `sku`         | The same SKU, or the style code a product's variant SKUs share (HZ8CL-02-XS, HZ8CL-02-SM → HZ8CL-02), same brand               | 0.9 (0.78)                                                                                  |
| 4    | `model`       | The same model number in the title or part number (WH-1000XM5), same brand, titles that agree                                  | 0.8–0.95                                                                                    |
| 5    | `title`       | Token-set ratio of the titles ≥ 90, within the same brand, once brand, filler words and the variant's colour names are removed | up to 0.9                                                                                   |

The title step is stricter than the ratio alone:

- **Gender must agree.** Men's never pairs with women's, and a lone "W" or "M" counts.
- **Colours must agree,** where both titles name one.
- **Numbers must agree** (55L, 13, 105).
- **A word only one title has names another model,** and blocks the match. "Arizona Suede LE" is not "Arizona Suede"; "Atom SL Hoody" is not "Atom Hoody". Words for the kind of product (jacket, shoes, backpack) and model years don't count.
- **Doubts lower the confidence by 0.1 each:** a gender named on one side only, or a number on one side only.

In tests on 146 title pairs from real outdoor stores, checked by hand, **4 were wrong (2.7%)**, and **none of the 124 with confidence 0.85 or more**. All 4 had "gender on one side only" in `matchedOn`. Filter on `confidence` to choose your own trade-off.

Code matches have checks of their own:

- **Brands compare by whole word:** "Bedrock" is "Bedrock Sandals", but "On" is not "Salomon".
- **Amounts must agree on a model-number match:** 128 GB and 256 GB, a 2-pack and a 4-pack are other products.
- **Without a brand on one side,** a part number or SKU match needs titles that don't disagree, and a SKU also needs similar titles.

Pairs are one to one, best confidence first. When a competitor lists the same product several times (each colour as its own product, or a second listing), the extras are shown on the pair as `alsoMatched`. They are not gaps and are not billed.

### What makes the data right

- **Prices in different currencies are never compared.** Such a pair has no price gap, and `priceNote` says why, for example "different currencies (USD and CAD): prices not compared". The summary counts these pairs. A currency missing on one side is compared as the same currency, and the note says so.
- **The matched variant's price.** When a variant's barcode or SKU made the match, its own price is used, not the product's lowest.
- **A source read in part never makes false gaps.** If a competitor's dataset fails part-way, that competitor is left out of the run (no rows, no charges, watchlist unchanged) and the run summary says why. If your own catalogue is read only in part, pairs are still returned but no gaps, and the watchlist is not moved.
- **A bare "$" is not assumed to be US dollars.** The store's domain decides where it can ($ on a .ca store is CAD); otherwise the currency is unknown and the prices are not compared.
- **Duplicates count once.** A product listed twice (same page, or the same barcodes) is matched once, never billed twice, and counted in the summary.
- **Shopify variant rows are merged.** Shopify Store Scraper's flat one-row-per-variant output is put back together into products.
- **One dataset, several stores.** A Shopify Store Scraper run over your store and three rivals is split by store: name yours in *Your store*, and each rival becomes its own comparison.
- **Placeholders are not codes.** Barcodes with a wrong check digit or all zeros, store-internal numeric SKUs, and codes shared by many products are ignored.
- **Only what you pick is read.** Datasets are chosen in the form, which gives this actor read access to those datasets only. It runs with limited permissions and fetches nothing from the web.

### Input

| Field                                                 | What it does                                                                                                                                                    |
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `yourDatasetId`, `yourItems`                          | Your catalogue: a dataset, rows, or both                                                                                                                        |
| `competitorStores`                                    | Up to 10 Shopify stores, each read in this run by Shopify Store Scraper and compared                                                                            |
| `competitorDatasetIds`, `competitorItems`             | One or more competitor catalogues; each store in them is its own comparison                                                                                     |
| `yourStore`                                           | Your store's domain: read in this run too with `competitorStores` and no other catalogue of yours; otherwise it picks your products out of a competitor dataset |
| `titleMatching`, `minTitleScore`, `titleWithoutBrand` | The title step: on or off, its threshold (default 90), and whether to match without a brand                                                                     |
| `minConfidence`                                       | Pairs below this confidence are not matches (default 0.6)                                                                                                       |
| `gaps`, `gapsInYourBrandsOnly`                        | Gap rows both ways, one way or none; only brands you carry                                                                                                      |
| `monitorName`, `changesOnly`, `minPriceChangePercent` | Watchlist: compare with the last run, return only changes, ignore small price moves                                                                             |
| `alertUndercutPercent`                                | Mark pairs where they are at least this % cheaper; with a watchlist, report new undercuts (off by default)                                                      |
| `maxItemsPerCatalogue`                                | Products read per catalogue (default 50,000)                                                                                                                    |
| `maxRunCostUsd`                                       | The most one run may charge, for scheduled runs (with `competitorStores`, the store reads included)                                                             |

Rows you give use these fields: `title`, `brand`, `price`, `currency`, `url`, `gtin` (or `upc`, `ean`, `barcode`), `mpn`, `sku`, `store`, and optionally `variants` (each with `sku`, `gtin`, `price`).

#### Example inputs

A weekly undercut watch: your Shopify store against two rivals, read in the same run, reporting products they newly sell at least 5% below your price:

```json
{
  "yourStore": "mystore.com",
  "competitorStores": ["rival-one.com", "rival-two.com"],
  "alertUndercutPercent": 5,
  "monitorName": "undercut-watch",
  "changesOnly": true,
  "maxRunCostUsd": 3
}
```

Your Shopify store against two rivals, from one Shopify Store Scraper run:

```json
{ "competitorDatasetIds": ["<Shopify Store Scraper dataset id>"], "yourStore": "mystore.com" }
```

Your Any Store Product Monitor dataset against Amazon products found by the same barcodes, matches only:

```json
{
  "yourDatasetId": "<Any Store dataset id>",
  "competitorDatasetIds": ["<Amazon Product Scraper dataset id>"],
  "gaps": "none"
}
```

What a rival sells in brands you carry, checked weekly for changes:

```json
{
  "yourDatasetId": "<your catalogue>",
  "competitorDatasetIds": ["<rival catalogue>"],
  "gaps": "they-sell",
  "gapsInYourBrandsOnly": true,
  "monitorName": "rival-ranges",
  "changesOnly": true,
  "maxRunCostUsd": 1
}
```

### Output

A real matched pair:

```json
{
  "recordType": "match",
  "catalogue": "alpenglowsports.com",
  "matchMethod": "title",
  "confidence": 0.9,
  "matchedOn": "title 100",
  "yourTitle": "Atom Hoody — Men's",
  "yourBrand": "Arc'Teryx",
  "yourPrice": 300,
  "yourCurrency": "USD",
  "yourUrl": "https://www.outdoorsports.com/products/arcteryx-atom_hoody-jackets-2025",
  "yourGtin": "623555579557",
  "theirTitle": "M Atom Hoody",
  "theirBrand": "Arcteryx",
  "theirPrice": 300,
  "theirCurrency": "USD",
  "theirUrl": "https://www.alpenglowsports.com/products/m-atom-hoody",
  "priceGapPercent": 0,
  "cheaper": "same",
  "priceNote": null
}
```

`priceGapPercent` is their price against yours: −10 means they are 10% cheaper. `cheaper` is `you`, `them` or `same`. Also: `yourSku`, `theirSku`, `theirGtin`, `yourInStock`, `theirInStock`, and `alsoMatched` (their other listings of the same product).

A gap row: `{ "recordType": "gap", "direction": "they-sell", "catalogue": "...", "title", "brand", "price", "currency", "url", "gtin", "sku", "inStock", "youCarryBrand": true }`. The direction is `they-sell` (they sell it, you don't) or `you-sell`.

A summary row per competitor gives: `yourProducts`, `theirProducts`, `matched`, `overlapPercent` (of yours), `theirOverlapPercent`, `byMethod`, `medianPriceIndex` (their price as % of yours: 95 means they are 5% cheaper at the median), `youAreCheaper`, `theyAreCheaper`, `samePrice`, `currencyMismatches`, `gapsTheySell`, `gapsYouSell`, `alsoMatched`, `duplicates`, and `topGapBrands` (brands with the most products you don't sell).

With a watchlist name, every row has `changes` and `firstSeen`. The change types are:

- `new-match`, `your-price` and `their-price` (with from and to; a price appearing or disappearing always counts);
- `new-gap`;
- `match-lost` and `gap-closed`, reported once when a pair or gap is missing from two runs in a row;
- `undercut` (with `gapPercent`), with *Undercut alert* set: a pair where they are now at least that much cheaper and were not on the last run, a new pair included. A pair whose price crossed the threshold through moves each smaller than *Smallest price change* is reported too.

With *Undercut alert* set, each pair also has `undercut: true` or `false`, and each summary gives `undercuts` and, with a watchlist, `newUndercuts`.

Dataset views: **Matches**, **Gaps** and **Summary**. `RUN_SUMMARY` in the key-value store gives:

- the outcome (`COMPLETE`, `PARTIAL`, `EMPTY`, `FAILED`);
- the counts per competitor and method;
- the charges;
- every source that could not be read, with the reason;
- with *Competitor stores*, `storeRuns` (the Shopify Store Scraper run for each store) and `storeReadsCostUsd`;
- `droppedCatalogues` (competitors left out because their source failed part-way, or because the store is your own: its pages are in your catalogue and *Your store* is not set) and `gapsSkipped` (why no gaps were made, when your catalogue was read only in part).

### Pricing

| Event                                                    | Free and Bronze | Silver | Gold and above |
| -------------------------------------------------------- | --------------- | ------ | -------------- |
| Matched pair (per 1,000)                                 | $2.00           | $1.80  | $1.60          |
| Gap row (per 1,000)                                      | $0.10           | $0.09  | $0.08          |
| Pair or gap checked, unchanged, Changes only (per 1,000) | $0.05           | $0.045 | $0.04          |
| Competitor catalogue compared (each, per run)            | $0.05           | $0.05  | $0.05          |

Each run also has a $0.00005 start fee. Summary rows are free. Duplicates, products you didn't ask gaps for and sources that could not be read are never charged; a competitor catalogue read only in part is left out and not charged at all. The comparison event is charged before a catalogue's rows, so a Max cost per run below $0.05 compares nothing.

Your 2,500 products against two rivals' 2,500 and 2,042 (93 pairs in common) come to:

- **$0.37** for the pairs and what they sell in brands you carry (93 pairs, 859 gap rows, 2 catalogues);
- **$0.86** with the default gaps both ways (also your 4,897 products they don't sell);
- **$1.22** with every gap both ways (*Gaps in your brands only* off: 9,344 gap rows).

A daily watchlist of the same with Changes only pays for its changes, $0.05 per 1,000 unchanged pairs and gaps, and $0.05 per competitor.

#### A weekly undercut watch on Shopify stores

With *Competitor stores*, each store (yours too, when this run reads it) is read by [Shopify Store Scraper](https://apify.com/cleanpull/shopify-store-scraper). Its run starts in your account and is billed at its own prices: $0.80 per 1,000 products on the Free tier ($0.70 Silver, $0.60 Gold and above). This actor charges its usual events on top, nothing more. For 250-product stores, on the Free tier, with Changes only after the first run:

| Rivals | Store reads (yours + theirs) | Comparisons | Unchanged checks and ~10 changes each | A run     | A month (weekly) |
| ------ | ---------------------------- | ----------- | ------------------------------------- | --------- | ---------------- |
| 1      | $0.40                        | $0.05       | about $0.03                           | **$0.48** | about $2.10      |
| 2      | $0.60                        | $0.10       | about $0.06                           | **$0.76** | about $3.30      |
| 3      | $0.80                        | $0.15       | about $0.10                           | **$1.05** | about $4.50      |

The first run sets the baseline and returns every pair, at $2 per 1,000. With full overlap (all 250 products matched per rival) it is the dearest run: **$0.95** for one rival, **$1.70** for two and **$2.45** for three. Later runs return only changes and cost the amounts in the table. If your catalogue is already in a dataset, give it as *Your products (dataset)* instead of *Your store* to skip one store read.

Set *Max cost per run* so it covers the first run: about **$2 for one rival, $3 for two and $4 for three** at 250 products each. A schedule keeps the same limit, and later runs charge only what they use. The limit covers the store reads too:

1. The actor keeps back $0.15 per catalogue (its comparison and 50 rows) and $0.10 per rival (the least useful read).
2. Your store is read first, with the rest as its limit, so it is read in full whenever the limit allows.
3. The rivals then share what is left equally, each share being that read's own limit.

A store needs about $0.0008 per product. A rival whose share would be under $0.10, or under what your store's read cost, is not started (a rival read in part is never compared, so it would be paid for nothing). The last stores listed give way first, each with a reason giving the limit to set, and the run warns once.

### Limits

- **It matches what the datasets hold.** Shopify's public catalogue has no barcodes, so two Shopify Store Scraper datasets match by title. Run Any Store Product Monitor on the products to get their barcodes.
- **Title matching needs a brand on both sides** by default. Products whose titles differ by more than the rules allow stay unmatched, as gaps.
- **Prices are compared as given:** no currency conversion, no tax or shipping.
- **Up to 50,000 products a side run in the default 256 MB** (the default limit). Give 1 GB and raise *Max products per catalogue* for up to 200,000 a side; a catalogue cut at the limit gives no gap rows.
- **Datasets** are read only when you pick them in the form. The actor cannot see your other storages.
- **Competitor stores** must be Shopify stores. Some reads are reported in `failures` and left out (no rows, no charges, watchlist unchanged): a store that can't be read, a read that stopped at its share of the limit, and a store with more products than *Max products per catalogue*. A catalogue read in part would show its missing products as lost pairs. The other stores are still compared.
- **Your store must be read in full.** If it can't be, nothing is compared, charged or alerted, and the reason gives the limit the read stopped at.
- **Nothing found**: when nothing could be compared because a source does not exist or cannot be used (a dataset id that is not there or not shared, a store that is not a Shopify store, a limit too low to read your store), the run still succeeds, with one free `recordType: "unreadable"` row per source giving the `reason`; nothing is charged. Near its time limit the run stops starting competitor catalogues and lists them the same way.

### Changelog

- **Reliability** (2026-10-24): runs that find nothing to compare (missing or unshared datasets, stores that are not Shopify stores, limits too low to read your store) end SUCCEEDED with a free `unreadable` row per source saying why, instead of FAILED; competitor catalogues not started before the time limit are listed, never TIMED-OUT. A source that breaks on every read, or a store blocked on every route, still fails the run. Competitor stores are capped at 10 in the input form. Prices and events unchanged.
- **Undercut watch** (October 2026): *Competitor stores* reads Shopify stores in the same run, each through its own Shopify Store Scraper run within its share of the cost limit. *Your store* is read too when no other catalogue of yours is given. *Undercut alert* marks pairs where a competitor is cheaper by at least a set percentage and, with a watchlist, reports new undercuts. Existing inputs work as before.
- **0.1.8** (October 2026): a run limit that leaves room for the products found now delivers them (before, the run stopped once another catalogue comparison no longer fitted, even when pairs did). Your own store found in a competitor dataset is left out instead of being compared with itself.
- **0.1** (October 2026): matching by barcode, part number, SKU and style code, model number, and title within the brand, with method and confidence. Price gaps without cross-currency comparison. Gaps both ways, per-competitor summaries and a watchlist mode. Shopify Store Scraper, Any Store Product Monitor and Amazon Product Scraper datasets, or your own rows.

# Actor input Schema

## `yourDatasetId` (type: `string`):

A dataset of your products: a run of Shopify Store Scraper, Any Store Product Monitor or Amazon Product Scraper, or any dataset with title, brand, price and (best) barcode, MPN or SKU fields. Pick it here so this actor is given read access to it; it never sees your other storages.

## `yourItems` (type: `array`):

Or give your products as rows: title, brand, price, currency, url, and where you have them gtin (UPC/EAN), mpn and sku. Used together with Your products (dataset) if both are given.

## `yourStore` (type: `string`):

Optional. Your store's domain, for example mystore.com. With Competitor stores and no other catalogue of yours, your store is read with Shopify Store Scraper in this run too. When your products are in a competitor dataset (one Shopify Store Scraper run over your store and theirs), it tells yours apart. Without any catalogue of yours, the first store in the first competitor dataset counts as yours.

## `competitorStores` (type: `array`):

Optional. Up to 10 Shopify stores by domain or URL, for example rival.com. Each is read in this run by Shopify Store Scraper, started in your account and billed at its own prices ($0.80 per 1,000 products on the Free tier), then compared with your catalogue. With a cost limit, each read gets its share of it. A store that cannot be read in full is reported and left out; the others still compare.

## `competitorDatasetIds` (type: `array`):

One or more datasets of competitor products from the same actors. Each store in them is matched as its own catalogue (a dataset of three stores gives three comparisons).

## `competitorItems` (type: `array`):

Or competitor products as rows, with the same fields plus store (rows of one store are one catalogue; without it, the URL's domain).

## `titleMatching` (type: `boolean`):

After barcode, part number, SKU and model number: match the remaining products by title within the same brand (token-set ratio). Off: only code matches.

## `minTitleScore` (type: `integer`):

Lowest token-set ratio (0–100) for a title match. 90 kept false matches at 2.7% of 146 hand-checked pairs, all below 0.85 confidence; lower finds more and risks more.

## `titleWithoutBrand` (type: `boolean`):

Also match by title when one side gives no brand (needs a score of 95 or more; confidence at most 0.75).

## `minConfidence` (type: `number`):

Pairs below this confidence (0–1) are not matches. Barcodes are 1, part numbers 0.95, SKUs 0.9, model numbers 0.8–0.95, titles up to 0.9.

## `gaps` (type: `string`):

Which unmatched products to return as gap rows (each one billed).

## `gapsInYourBrandsOnly` (type: `boolean`):

Only return products they sell and you don't from brands you already carry: the ranges you could add first, and far fewer rows than every product of a large store. Off: every product they sell that you don't.

## `monitorName` (type: `string`):

Optional. Runs with the same name compare with each other: each row says what changed since the last run (new match, price moves, new gap), and pairs or gaps gone for two runs in a row are reported once.

## `changesOnly` (type: `boolean`):

With a watchlist name: return only rows that changed since the last run. Unchanged pairs and gaps are billed as the small checked event.

## `minPriceChangePercent` (type: `number`):

Ignore price moves smaller than this percentage.

## `alertUndercutPercent` (type: `number`):

Optional. Mark pairs where the competitor is at least this much cheaper than you (0: any amount) with undercut: true. With a watchlist name, a pair that newly crosses it gets an undercut change (also with Changes only), and each summary counts undercuts and new undercuts. Off by default.

## `maxItemsPerCatalogue` (type: `integer`):

At most this many products read from your catalogue and from each competitor's (default 50,000, which runs in the default 256 MB). For up to 200,000 a side, give 1 GB. A catalogue cut short gives no gap rows, since products not read would show as gaps.

## `maxRunCostUsd` (type: `number`):

Optional. The most one run may charge in all. Use it for scheduled runs, which may have no spending limit of their own. With a run spending limit too, the lower one applies. With Competitor stores it covers their Shopify Store Scraper runs too. Your store is read first, with all but this actor's reserve; the rivals then share the rest equally. For 250-product stores, about $2 for one rival, $3 for two and $4 for three covers the first (dearest) run.

## Actor input object example

```json
{
  "yourItems": [
    {
      "title": "Atom Hoody — Men's",
      "brand": "Arc'Teryx",
      "price": 300,
      "currency": "USD",
      "url": "https://www.outdoorsports.com/products/arcteryx-atom_hoody-jackets-2025",
      "store": "outdoorsports.com"
    },
    {
      "title": "Black Hole Duffel 55L",
      "brand": "Patagonia",
      "price": 169,
      "currency": "USD",
      "url": "https://www.outdoorsports.com/products/patagonia-black-hole-duffel-55l-duffel-bag",
      "store": "outdoorsports.com"
    },
    {
      "title": "Cloudsoma — Women's",
      "brand": "On",
      "price": 180,
      "currency": "USD",
      "url": "https://www.outdoorsports.com/products/footwear-shoes-road_running_shoes-on_inc-womens-cloudsoma-2026",
      "store": "outdoorsports.com"
    }
  ],
  "competitorItems": [
    {
      "title": "M Atom Hoody",
      "brand": "Arcteryx",
      "price": 300,
      "currency": "USD",
      "url": "https://www.alpenglowsports.com/products/m-atom-hoody",
      "store": "alpenglowsports.com"
    },
    {
      "title": "Black Hole Duffel 55L",
      "brand": "Patagonia",
      "price": 169,
      "currency": "USD",
      "url": "https://www.alpenglowsports.com/products/black-hole-duffel-55l-1",
      "store": "alpenglowsports.com"
    },
    {
      "title": "W Kyanite LT Hoody",
      "brand": "Arcteryx",
      "price": 150,
      "currency": "USD",
      "url": "https://www.alpenglowsports.com/products/w-kyanite-lt-hoody",
      "store": "alpenglowsports.com"
    }
  ],
  "titleMatching": true,
  "minTitleScore": 90,
  "titleWithoutBrand": false,
  "minConfidence": 0.6,
  "gaps": "both",
  "gapsInYourBrandsOnly": true,
  "changesOnly": false,
  "minPriceChangePercent": 0,
  "maxItemsPerCatalogue": 50000
}
```

# Actor output Schema

## `matches` (type: `string`):

No description

## `gaps` (type: `string`):

No description

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

No description

## `runSummary` (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 = {
    "yourItems": [
        {
            "title": "Atom Hoody — Men's",
            "brand": "Arc'Teryx",
            "price": 300,
            "currency": "USD",
            "url": "https://www.outdoorsports.com/products/arcteryx-atom_hoody-jackets-2025",
            "store": "outdoorsports.com"
        },
        {
            "title": "Black Hole Duffel 55L",
            "brand": "Patagonia",
            "price": 169,
            "currency": "USD",
            "url": "https://www.outdoorsports.com/products/patagonia-black-hole-duffel-55l-duffel-bag",
            "store": "outdoorsports.com"
        },
        {
            "title": "Cloudsoma — Women's",
            "brand": "On",
            "price": 180,
            "currency": "USD",
            "url": "https://www.outdoorsports.com/products/footwear-shoes-road_running_shoes-on_inc-womens-cloudsoma-2026",
            "store": "outdoorsports.com"
        }
    ],
    "competitorItems": [
        {
            "title": "M Atom Hoody",
            "brand": "Arcteryx",
            "price": 300,
            "currency": "USD",
            "url": "https://www.alpenglowsports.com/products/m-atom-hoody",
            "store": "alpenglowsports.com"
        },
        {
            "title": "Black Hole Duffel 55L",
            "brand": "Patagonia",
            "price": 169,
            "currency": "USD",
            "url": "https://www.alpenglowsports.com/products/black-hole-duffel-55l-1",
            "store": "alpenglowsports.com"
        },
        {
            "title": "W Kyanite LT Hoody",
            "brand": "Arcteryx",
            "price": 150,
            "currency": "USD",
            "url": "https://www.alpenglowsports.com/products/w-kyanite-lt-hoody",
            "store": "alpenglowsports.com"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("cleanpull/catalog-matcher").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 = {
    "yourItems": [
        {
            "title": "Atom Hoody — Men's",
            "brand": "Arc'Teryx",
            "price": 300,
            "currency": "USD",
            "url": "https://www.outdoorsports.com/products/arcteryx-atom_hoody-jackets-2025",
            "store": "outdoorsports.com",
        },
        {
            "title": "Black Hole Duffel 55L",
            "brand": "Patagonia",
            "price": 169,
            "currency": "USD",
            "url": "https://www.outdoorsports.com/products/patagonia-black-hole-duffel-55l-duffel-bag",
            "store": "outdoorsports.com",
        },
        {
            "title": "Cloudsoma — Women's",
            "brand": "On",
            "price": 180,
            "currency": "USD",
            "url": "https://www.outdoorsports.com/products/footwear-shoes-road_running_shoes-on_inc-womens-cloudsoma-2026",
            "store": "outdoorsports.com",
        },
    ],
    "competitorItems": [
        {
            "title": "M Atom Hoody",
            "brand": "Arcteryx",
            "price": 300,
            "currency": "USD",
            "url": "https://www.alpenglowsports.com/products/m-atom-hoody",
            "store": "alpenglowsports.com",
        },
        {
            "title": "Black Hole Duffel 55L",
            "brand": "Patagonia",
            "price": 169,
            "currency": "USD",
            "url": "https://www.alpenglowsports.com/products/black-hole-duffel-55l-1",
            "store": "alpenglowsports.com",
        },
        {
            "title": "W Kyanite LT Hoody",
            "brand": "Arcteryx",
            "price": 150,
            "currency": "USD",
            "url": "https://www.alpenglowsports.com/products/w-kyanite-lt-hoody",
            "store": "alpenglowsports.com",
        },
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("cleanpull/catalog-matcher").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 '{
  "yourItems": [
    {
      "title": "Atom Hoody — Men'\''s",
      "brand": "Arc'\''Teryx",
      "price": 300,
      "currency": "USD",
      "url": "https://www.outdoorsports.com/products/arcteryx-atom_hoody-jackets-2025",
      "store": "outdoorsports.com"
    },
    {
      "title": "Black Hole Duffel 55L",
      "brand": "Patagonia",
      "price": 169,
      "currency": "USD",
      "url": "https://www.outdoorsports.com/products/patagonia-black-hole-duffel-55l-duffel-bag",
      "store": "outdoorsports.com"
    },
    {
      "title": "Cloudsoma — Women'\''s",
      "brand": "On",
      "price": 180,
      "currency": "USD",
      "url": "https://www.outdoorsports.com/products/footwear-shoes-road_running_shoes-on_inc-womens-cloudsoma-2026",
      "store": "outdoorsports.com"
    }
  ],
  "competitorItems": [
    {
      "title": "M Atom Hoody",
      "brand": "Arcteryx",
      "price": 300,
      "currency": "USD",
      "url": "https://www.alpenglowsports.com/products/m-atom-hoody",
      "store": "alpenglowsports.com"
    },
    {
      "title": "Black Hole Duffel 55L",
      "brand": "Patagonia",
      "price": 169,
      "currency": "USD",
      "url": "https://www.alpenglowsports.com/products/black-hole-duffel-55l-1",
      "store": "alpenglowsports.com"
    },
    {
      "title": "W Kyanite LT Hoody",
      "brand": "Arcteryx",
      "price": 150,
      "currency": "USD",
      "url": "https://www.alpenglowsports.com/products/w-kyanite-lt-hoody",
      "store": "alpenglowsports.com"
    }
  ]
}' |
apify call cleanpull/catalog-matcher --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,cleanpull/catalog-matcher"
        }
    }
}
```

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/uNfMnxgGIjRLh0XtN/builds/DiSzxO8n6JZF5xZhq/openapi.json
