# Catalog Merge Safety Auditor — SKU & GTIN Collision Graph (`skyfishdev/catalog-merge-safety-auditor`) Actor

Audit multiple product catalogs before a supplier import or store migration. Find conflicting GTINs, reused SKUs, variant contradictions and unsafe proposed mappings, with exact evidence paths, affected records and HTML/CSV reports.

- **URL**: https://apify.com/skyfishdev/catalog-merge-safety-auditor.md
- **Developed by:** [Ibrahim](https://apify.com/skyfishdev) (community)
- **Categories:** E-commerce, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $8.00 / 1,000 catalog records

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?

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 Merge Safety Auditor — SKU & GTIN Collision Graph

Audit multiple product catalogs before a supplier import or store migration. Find conflicting GTINs, reused SKUs, variant contradictions and unsafe proposed mappings, with exact evidence paths, affected records and HTML/CSV reports.

### The problem

A supplier calls a red medium shirt SKU SHIRT-01. Another feed reuses SHIRT-01 for a blue large shirt. An import joins on that SKU; a third record joins on a barcode. A simple duplicate-row check can miss the contradiction at the other end of this chain. This Actor builds the candidate identity graph and shows the source values and exact connecting path, so a catalog operator can stop a bad merge before importing.

Built for PIM teams, feed agencies, supplier onboarding, store migrations and catalog operations. This is a deterministic audit, not another product scraper and not an automatic catalog editor.

### Quick start

Try the public **synthetic** fixture:

```json
{
  "catalogs": [
    {
      "key": "store",
      "scope": "demo-store",
      "url": "https://commerce-actor-demos.vercel.app/merge-store.json"
    },
    {
      "key": "supplier",
      "scope": "demo-store",
      "url": "https://commerce-actor-demos.vercel.app/merge-supplier.csv"
    }
  ],
  "proposedLinks": [
    {
      "from": {
        "catalog": "store",
        "id": "shirt-red"
      },
      "to": {
        "catalog": "supplier",
        "id": "mapped-blue"
      }
    },
    {
      "from": {
        "catalog": "store",
        "id": "mug"
      },
      "to": {
        "catalog": "supplier",
        "id": "mapped-case"
      }
    }
  ],
  "maxRecords": 100
}
```

The fixture contains deliberate errors. It should return 7 audited records, 2 collision components and 5 affected records, with complete source coverage. It is not real product inventory.

For private exports, supply inline records instead of a URL:

```json
{"catalogs":[{"key":"shop","records":[{"id":"row-1","sku":"SHIRT-01","gtin":"4006381333931","brand":"Demo","color":"red","size":"M"}]}]}
```

Each catalog needs a unique key and exactly one of url or records. URL inputs accept RSS/Atom XML, quoted CSV/TSV, or JSON arrays/items/products wrappers. Flat column mappings work like `"fieldMap":{"id":"supplier_id","sku":"seller_sku","gtin":"barcode","color":"colour"}`.

### How the graph works

1. Preserve row identity as {catalog, id}. Missing ids become row-N for this source observation. Duplicate ids in the loaded source segment reject that source rather than guessing which row a mapping meant.
2. Join records sharing an exact, checksum-valid string GTIN. GTIN-8/12/13/14 values are zero-padded to 14 digits for comparison. Numeric, malformed and all-zero barcodes are excluded and reported. A valid checksum does not certify GS1 allocation or ownership.
3. Join exact, case-sensitive SKUs **within one scope**. The default scope is the catalog key, so matching SKU text from different suppliers does not join by accident. Set the same explicit scope only when the catalogs truly use the same SKU namespace.
4. Apply your proposedLinks as hypothetical identity connections. References use the source key and string record id. Unknown references are reported and make the simulation partial.
5. Find conflicting valid GTINs, brand, color, size and positive integer multipack quantity within each connected component. Case and whitespace normalize for attributes. Missing values stay unknown; no translation or semantic synonym guessing is performed.
6. Return every loaded row with its component, issue codes, source position, coverage and review state. REPORT contains witnesses and a bounded spanning forest with exact evidence paths, plus affected-record counts. These paths explain connections; they are not claimed to be the shortest paths in the original graph.

Titles, shared photos, price differences, stock changes and manufacturer part numbers do **not** create identity links. Prices/stock can legitimately differ between sellers. Product groups, bundles, languages, aliases and retailer-specific variant names can need manual review. `no_detected_conflict` means no supported contradiction among **loaded** records; it is not permission to merge or a certification of product identity.

### Output

The Dataset has **one row per audited source record**, not per issue. Main fields: catalog, id, sku, gtin, componentId, componentSize, status, issueCodes, dataIssues, sourceUrl, rowNumber, coverageStatus and requiresReview. Status is collision_review, no_detected_conflict or insufficient_identifiers.

Run storage:

- **REPORT**: full JSON report, graph spanning forest, component witnesses, proposed-link outcomes, source coverage and delivery counts.
- **REPORT.html**: readable collision report with evidence chains and partial-coverage warnings.
- **AUDIT.csv**: delivered audit rows with formula-leading cells neutralized for spreadsheets.
- **RUN_STATS**: loaded/delivered counts, failed sources, limits and budget status.
- **MERGE_PACKET**: prepared input-fingerprinted audit used for restart recovery. Dataset identities are recovered before retrying, avoiding repeat delivery/charging in the same run/storage.

#### Verified cloud output

```json
[
    {
        "catalog":  "store",
        "id":  "shirt-red",
        "sku":  "SHIRT01",
        "gtin":  "04006381333931",
        "componentSize":  3,
        "status":  "collision_review",
        "issueCodes":  [
                           "MULTIPLE_GTINS_IN_IDENTITY",
                           "CONFLICTING_COLOR",
                           "CONFLICTING_SIZE"
                       ],
        "coverageStatus":  "complete"
    },
    {
        "catalog":  "store",
        "id":  "mug",
        "sku":  "MUG1",
        "gtin":  "01234567890128",
        "componentSize":  2,
        "status":  "collision_review",
        "issueCodes":  [
                           "MULTIPLE_GTINS_IN_IDENTITY",
                           "CONFLICTING_PACK_QUANTITY"
                       ],
        "coverageStatus":  "complete"
    },
    {
        "catalog":  "store",
        "id":  "clean",
        "sku":  "CLEAN",
        "gtin":  "01234567890135",
        "componentSize":  1,
        "status":  "no_detected_conflict",
        "issueCodes":  [

                       ],
        "coverageStatus":  "complete"
    },
    {
        "catalog":  "supplier",
        "id":  "wrong-sku",
        "sku":  "SHIRT01",
        "gtin":  "05901234123457",
        "componentSize":  3,
        "status":  "collision_review",
        "issueCodes":  [
                           "MULTIPLE_GTINS_IN_IDENTITY",
                           "CONFLICTING_COLOR",
                           "CONFLICTING_SIZE"
                       ],
        "coverageStatus":  "complete"
    },
    {
        "catalog":  "supplier",
        "id":  "mapped-blue",
        "sku":  "SHIRT-BLUE",
        "gtin":  "05901234123457",
        "componentSize":  3,
        "status":  "collision_review",
        "issueCodes":  [
                           "MULTIPLE_GTINS_IN_IDENTITY",
                           "CONFLICTING_COLOR",
                           "CONFLICTING_SIZE"
                       ],
        "coverageStatus":  "complete"
    },
    {
        "catalog":  "supplier",
        "id":  "mapped-case",
        "sku":  "CASE6",
        "gtin":  "01234567890142",
        "componentSize":  2,
        "status":  "collision_review",
        "issueCodes":  [
                           "MULTIPLE_GTINS_IN_IDENTITY",
                           "CONFLICTING_PACK_QUANTITY"
                       ],
        "coverageStatus":  "complete"
    },
    {
        "catalog":  "supplier",
        "id":  "unknown",
        "sku":  "",
        "gtin":  null,
        "componentSize":  1,
        "status":  "insufficient_identifiers",
        "issueCodes":  [

                       ],
        "coverageStatus":  "complete"
    }
]
```

### Pricing

Launch price: **$8 per 1,000 audited source records** ($0.008 each), plus the $0.00005 memory-based Actor-start event, minimum one event. Platform usage is included after pay-per-event activation. One record with several findings is still one charged dataset row. Clean, insufficient-identifier and collision rows are all completed audits. Failed source diagnostics and report artifacts are not separately charged. Check the live Pricing tab; it is authoritative.

A 100-record run is at most $0.80 in record events plus startup. The default $1 run budget may lower the loaded-record cap. Scope-dependent graph analysis uses the records actually loaded within that cap; any skipped rows/sources are plainly partial. Increase both maxRecords and the run charge limit for a full export audit. Never interpret a partial audit as whole-catalog clearance.

### Limits and access

1–10 sources; 20,000 total loaded records; 20,000 proposed links; 8 MB input and each decompressed feed; 20-second HTTP requests; five same-origin guarded redirects. Shared per-host pacing and robots.txt checks apply. No login, credentials, private-network fetch, anti-bot evasion, browser rendering, LLM or external paid API is required. Output fields are bounded; component member display caps at 100 and a witness path display at 1,000 edges, with truncation flags. Full forest edges remain in REPORT. Export formats are public HTTP feeds or customer-provided inline data. No shop/PIM writes or notifications are sent.

Use a new run for new input; restart recovery requires identical input/build/default storage. A partial source failure retains useful records but forbids whole-source clearance. All-source failure fails after saving diagnostics. Catalog corrections and merge decisions remain yours.

### API example

```bash
curl -X POST 'https://api.apify.com/v2/acts/skyfishdev~catalog-merge-safety-auditor/run-sync-get-dataset-items' \
  -H 'Authorization: Bearer YOUR_APIFY_TOKEN' \
  -H 'Content-Type: application/json' \
  --data-binary @input.json
```

Save input.json using the example above. Use asynchronous runs for large audits; read REPORT and RUN_STATS even when exporting just the Dataset. Schedule the same saved Task to check a recurring supplier feed; this Actor does not retain or compare historical catalogs between separate runs.

### Local development

```bash
npm ci
npm test
```

Put authorized input in storage/key_value_stores/default/INPUT.json and run npm start. Networking, graph tests, spending-limit tests and restart recovery are included.

Identifier context: [GS1 variants and GTINs](https://support.gs1.org/support/solutions/articles/43000734071-how-many-gs1-gtins-do-i-need-for-my-products-). This Actor is not affiliated with GS1 and cannot validate manufacturer ownership. No uniqueness or revenue guarantee is made.

# Actor input Schema

## `catalogs` (type: `array`):

1–10 objects with unique key, optional shared SKU scope, and exactly one public feed url or inline records array. Optional fieldMap maps id, sku, gtin, brand, color, size, packQuantity and title to flat column names. See README examples.

## `proposedLinks` (type: `array`):

Optional {from:{catalog,id},to:{catalog,id}} links to simulate. These are audit hypotheses; no catalog is modified.

## `maxRecords` (type: `integer`):

Global cap across sources. Pricing budget may reduce it. Any omitted source/row or unresolved mapping makes coverage partial.

## `requestDelayMs` (type: `integer`):

Public feeds respect robots.txt, guarded DNS/redirects, 8 MB response limits and cooldowns.

## Actor input object example

```json
{
  "catalogs": [
    {
      "key": "store",
      "scope": "demo-store",
      "url": "https://commerce-actor-demos.vercel.app/merge-store.json"
    },
    {
      "key": "supplier",
      "scope": "demo-store",
      "url": "https://commerce-actor-demos.vercel.app/merge-supplier.csv"
    }
  ],
  "proposedLinks": [
    {
      "from": {
        "catalog": "store",
        "id": "shirt-red"
      },
      "to": {
        "catalog": "supplier",
        "id": "mapped-blue"
      }
    },
    {
      "from": {
        "catalog": "store",
        "id": "mug"
      },
      "to": {
        "catalog": "supplier",
        "id": "mapped-case"
      }
    }
  ],
  "maxRecords": 500,
  "requestDelayMs": 500
}
```

# Actor output Schema

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

No description

## `report` (type: `string`):

No description

## `html` (type: `string`):

No description

## `csv` (type: `string`):

No description

## `stats` (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 = {
    "catalogs": [
        {
            "key": "store",
            "scope": "demo-store",
            "url": "https://commerce-actor-demos.vercel.app/merge-store.json"
        },
        {
            "key": "supplier",
            "scope": "demo-store",
            "url": "https://commerce-actor-demos.vercel.app/merge-supplier.csv"
        }
    ],
    "proposedLinks": [
        {
            "from": {
                "catalog": "store",
                "id": "shirt-red"
            },
            "to": {
                "catalog": "supplier",
                "id": "mapped-blue"
            }
        },
        {
            "from": {
                "catalog": "store",
                "id": "mug"
            },
            "to": {
                "catalog": "supplier",
                "id": "mapped-case"
            }
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("skyfishdev/catalog-merge-safety-auditor").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 = {
    "catalogs": [
        {
            "key": "store",
            "scope": "demo-store",
            "url": "https://commerce-actor-demos.vercel.app/merge-store.json",
        },
        {
            "key": "supplier",
            "scope": "demo-store",
            "url": "https://commerce-actor-demos.vercel.app/merge-supplier.csv",
        },
    ],
    "proposedLinks": [
        {
            "from": {
                "catalog": "store",
                "id": "shirt-red",
            },
            "to": {
                "catalog": "supplier",
                "id": "mapped-blue",
            },
        },
        {
            "from": {
                "catalog": "store",
                "id": "mug",
            },
            "to": {
                "catalog": "supplier",
                "id": "mapped-case",
            },
        },
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("skyfishdev/catalog-merge-safety-auditor").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 '{
  "catalogs": [
    {
      "key": "store",
      "scope": "demo-store",
      "url": "https://commerce-actor-demos.vercel.app/merge-store.json"
    },
    {
      "key": "supplier",
      "scope": "demo-store",
      "url": "https://commerce-actor-demos.vercel.app/merge-supplier.csv"
    }
  ],
  "proposedLinks": [
    {
      "from": {
        "catalog": "store",
        "id": "shirt-red"
      },
      "to": {
        "catalog": "supplier",
        "id": "mapped-blue"
      }
    },
    {
      "from": {
        "catalog": "store",
        "id": "mug"
      },
      "to": {
        "catalog": "supplier",
        "id": "mapped-case"
      }
    }
  ]
}' |
apify call skyfishdev/catalog-merge-safety-auditor --silent --output-dataset

```

## MCP server setup

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

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/FlNOlwSC1uevVSCoP/builds/W01t63UCeskShPk1x/openapi.json
