# Google Search Negative Keyword Preflight (`h_murdock/negative-keyword-preflight`) Actor

Check supplied Search queries against account, campaign, ad-group and attached shared-list negatives. Literal conflict evidence, explicit coverage gaps, CSV and HTML reports.

- **URL**: https://apify.com/h\_murdock/negative-keyword-preflight.md
- **Developed by:** [Gilad Ronen](https://apify.com/h_murdock) (community)
- **Categories:** Marketing, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.25 / completed report

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

### What does Negative Keyword Preflight do?

**Check desired Search queries against supplied Google Ads negative keywords before reviewing an exclusion batch.** This Actor returns literal query/negative conflict pairs with match rules, scope evidence and original source references. It accepts JSON or CSV exports for one account and produces a full JSON report, a flat review CSV and a downloadable HTML report.

Use the API tab to connect your own export pipeline, or schedule a task with updated input. The Actor processes supplied data only: it does not access Google Ads, change campaigns, remove negatives, estimate lost revenue or recommend bids.

### Why use a negative keyword preflight?

A term can conflict in one campaign and be irrelevant in another. This report keeps account, campaign, ad-group and explicitly attached shared-list scope visible. Reviewers can trace a finding back to the precise query and negative source records. Query result rows also show how many pairs were checked, excluded by supplied scope or left unresolved.

**No conflict means only that no supported literal conflict was found.** A supplied positive keyword may be used as a test string, but a conflict with that string does not establish that all traffic for the positive keyword is blocked.

### How to use it

1. Export desired test queries and negatives from a single account. Assign unique string IDs to rows.
2. Supply `accountId` and either JSON rows or CSV text for each table. Keep IDs such as `001` as strings.
3. Include scope identifiers and explicit `sharedListAttachments` for shared lists.
4. Run the Actor and review both conflict and review rows. Check coverage before drawing conclusions.
5. Download `review.csv`, `report.html` or the complete JSON. Verify original account settings before making your own changes.

### Input

See the Input tab for full options and `examples/input.json` for a synthetic example with all three match types, accents, shared lists, a cross-campaign non-conflict and unsupported syntax.

| Field | Meaning |
| --- | --- |
| `accountId` | Required string; every supplied record belongs to this single account |
| `queries` | Array of `{id, query, campaignId?, adGroupId?, campaignType?}`; campaign type defaults to `search` |
| `negatives` | Array of `{id, text, matchType, scope, campaignId?, adGroupId?, listId?}` |
| `sharedListAttachments` | Array of `{listId, campaignId}` in the supplied account |
| `queriesCsv`, `negativesCsv` | CSV text instead of the corresponding JSON array |
| `queryColumns`, `negativeColumns` | Canonical field to exact CSV header, such as `{"id":"Query ID","query":"Search term"}` |
| `delimiter` | Comma, semicolon or tab for CSV; comma is the default |
| `maxConflictPairs` | Maximum reported conflict pairs, 1–5,000; default 5,000 |

CSV accepts BOM, quoted fields, escaped quotes and multiline quoted values. Duplicate or padded headers, irregular records, duplicate IDs, numeric IDs and missing required fields are rejected. A source reference gives a one-based **data record ordinal**, not a physical CSV line number. With an explicit map, unmapped export columns are ignored. Without a map, only canonical headers are accepted. Supply match type separately; do not wrap negative text in quotes or brackets to encode match type.

Scope values are `account`, `campaign`, `adGroup` and `sharedList`. Account scope must have no narrower identifiers. Campaign scope needs only `campaignId`; ad-group scope needs `campaignId` and `adGroupId`; shared-list scope needs only `listId`. Missing or contradictory scope and unknown values produce review conditions. Missing query scope can make applicability unresolved.

The supplied attachment table defines the mapping tested by this report. If a list has attachments, other campaigns are outside that **supplied** mapping. Omitted live account attachments remain unverified. A shared list without any supplied attachment is unresolved, never global. Use separate runs for different accounts.

### Matching and coverage

The supported subset follows [Google's Search negative-keyword rules](https://support.google.com/google-ads/answer/2453972?hl=en-GB), checked September 29, 2026. Broad matching requires every distinct literal negative term; phrase matching requires a contiguous token sequence; exact matching requires the whole token sequence. Casing is ignored, accents are preserved, and periods in negative text are ignored. A standalone ampersand remains distinct from the word “and”. [Shared lists apply through campaign attachments](https://support.google.com/google-ads/answer/2453983?hl=en-GB).

The engine uses Unicode NFC, default Unicode lowercasing and whitespace tokenization. Locale-specific casing and language segmentation are not simulated. Repeated terms are supported in phrase and exact matching; repeated broad-negative terms are review-only because this checker does not infer their multiplicity behavior. Plurals and synonyms are not expanded.

Every report has overall `coverage: "partial"`. Google documents misspelling handling, which this literal engine does not simulate. Unsupported punctuation, asterisks, plus/minus syntax, search operators, match-type wrappers, queries over 16 words, and non-Search campaign types receive review or unsupported coverage. Negative periods are the one supported punctuation normalization; query punctuation remains review-only. `supported-literal` means the stated local rule was testable, not that Google matching is fully covered. Unknown scope is never treated as global.

### Output

One dataset item contains the summary, supplied input, limitations, fingerprints and a `rows` array. The rows contain conflict pairs, query summaries and review findings. For example:

```json
{
  "kind": "conflict",
  "queryId": "q002",
  "negativeId": "n003",
  "rule": "negative-exact",
  "scope": "adGroup",
  "coverage": "supported-literal",
  "matchedPositions": [1, 2]
}
```

| Evidence field | Purpose |
| --- | --- |
| `rowId`, `queryId`, `negativeId` | Stable finding and supplied record identifiers |
| `querySource`, `negativeSource` | Original table record references |
| `rule`, `scope`, `scopeReason` | Why the literal pair matched within the supplied scope |
| `severity`, `reason`, `coverage` | Review meaning and coverage limits |
| `matchedPositions` | One-based query token positions, in negative-term order |
| `checkedPairs`, `outOfScopePairs`, `unresolvedPairs` | Counts on query-result rows |

The dedicated `review.csv` is flat and includes account identity, conflicts, query summaries and review rows. Formula-like cells are prefixed with an apostrophe for spreadsheet safety. The standalone HTML escapes supplied text and uses no external scripts or assets. Download it using the output link and open it locally. The full JSON is stored as `OUTPUT`. Apify also offers dataset downloads in JSON, HTML, CSV or Excel; use the dedicated CSV for the flat evidence layout.

### Price and delivery

The price is **USD $0.25 per completed report**, including platform usage, with one `report-completed` event and no startup or dataset-item fee. New runs are billable again, even with identical input. Cloud execution refuses an unexpected report price or other positive-priced events. Invalid input, insufficient budget and exceeded report bounds stop before report publication or billing.

The complete dataset item is the primary deliverable. It is written before the report charge is confirmed; convenience downloads follow. If exports are interrupted after that point, a failed run may still have a usable charged dataset report. Resurrect the same run with unchanged input to regenerate exports without another report charge. Recovery verifies saved input and report integrity, rejects missing or modified datasets, and does not overwrite conflicting saved reports. This is recovery logic, not a transaction spanning Apify storage and billing; inspect ambiguous platform failures before retrying. Starting a fresh run incurs a new fee.

### Capacity and privacy

Each run accepts at most 1,000 queries, 2,000 negatives, 2,000 explicit list attachments and 4 MB of total JSON input. Text is limited to 512 characters and IDs to 128. Conflict output is capped at 5,000 pairs, with full JSON below 8 MB and each download below 9 MB. A bound failure stops the report rather than silently truncating it. Split large batches while preserving each query's relevant negatives and attachments.

Input and reports are stored under the run's Apify retention and access settings. Supply only data you are authorized to process; the Actor sends it to no external model or advertising service. It does not independently establish that an export is complete or current. Ask product questions through the Issues tab and include a minimal synthetic example instead of confidential campaign terms.

# Actor input Schema

## `accountId` (type: `string`):

All rows belong to this one supplied account. Use separate runs for different accounts.

## `queries` (type: `array`):

Provide either queries or queriesCsv. IDs must be unique strings. Missing campaign/ad-group scope may leave applicability unresolved.

## `negatives` (type: `array`):

Provide either negatives or negativesCsv. Scope IDs must be explicit. Duplicate IDs are rejected.

## `queriesCsv` (type: `string`):

CSV text with id,query and optional campaignId,adGroupId,campaignType. Remove queries JSON to use CSV.

## `negativesCsv` (type: `string`):

CSV text with id,text,matchType,scope and optional campaignId,adGroupId,listId. Remove negatives JSON to use CSV.

## `queryColumns` (type: `object`):

Canonical field to exact CSV header, for example {"id":"Query ID","query":"Search term"}. Used only with CSV. Unmapped columns are ignored only when an explicit map is supplied.

## `negativeColumns` (type: `object`):

Canonical field to exact CSV header. Used only with CSV.

## `delimiter` (type: `string`):

Delimiter shared by both CSV inputs.

## `sharedListAttachments` (type: `array`):

Explicit listId to campaignId mapping in this account. Omitted live attachments are unverified.

## `maxConflictPairs` (type: `integer`):

Fail before billing rather than truncate if this output bound is exceeded.

## Actor input object example

```json
{
  "accountId": "000123",
  "queries": [
    {
      "id": "q001",
      "query": "blue running shoes",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q002",
      "query": "running shoes",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q003",
      "query": "shoes running",
      "campaignId": "02",
      "adGroupId": "002"
    },
    {
      "id": "q004",
      "query": "sidewalk CAFÉ",
      "campaignId": "02",
      "adGroupId": "002"
    },
    {
      "id": "q005",
      "query": "sidewalk cafe",
      "campaignId": "02",
      "adGroupId": "002"
    },
    {
      "id": "q006",
      "query": "free training",
      "campaignId": "03",
      "adGroupId": "003"
    },
    {
      "id": "q007",
      "query": "ski boots",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q008",
      "query": "shose",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q009",
      "query": "socks & shoes",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q010",
      "query": "very very cheap",
      "campaignId": "01",
      "adGroupId": "001"
    }
  ],
  "negatives": [
    {
      "id": "n001",
      "text": "running shoes",
      "matchType": "broad",
      "scope": "account"
    },
    {
      "id": "n002",
      "text": "running shoes",
      "matchType": "phrase",
      "scope": "campaign",
      "campaignId": "01"
    },
    {
      "id": "n003",
      "text": "running shoes",
      "matchType": "exact",
      "scope": "adGroup",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "n004",
      "text": "sidewalk café",
      "matchType": "phrase",
      "scope": "campaign",
      "campaignId": "02"
    },
    {
      "id": "n005",
      "text": "free",
      "matchType": "broad",
      "scope": "sharedList",
      "listId": "L01"
    },
    {
      "id": "n006",
      "text": "boots",
      "matchType": "broad",
      "scope": "campaign",
      "campaignId": "99"
    },
    {
      "id": "n007",
      "text": "shoes*",
      "matchType": "broad",
      "scope": "campaign",
      "campaignId": "01"
    },
    {
      "id": "n008",
      "text": "shoes",
      "matchType": "broad",
      "scope": "unknown"
    },
    {
      "id": "n009",
      "text": "socks & shoes",
      "matchType": "exact",
      "scope": "account"
    },
    {
      "id": "n010",
      "text": "very very",
      "matchType": "phrase",
      "scope": "campaign",
      "campaignId": "01"
    }
  ],
  "delimiter": ",",
  "sharedListAttachments": [
    {
      "listId": "L01",
      "campaignId": "03"
    }
  ],
  "maxConflictPairs": 5000
}
```

# Actor output Schema

## `dataset` (type: `string`):

One primary dataset item, including all rows and supplied input.

## `json` (type: `string`):

No description

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

No description

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

Download and open locally. Convenience exports can be recovered by resurrecting the same run.

# 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 = {
    "accountId": "000123",
    "queries": [
        {
            "id": "q001",
            "query": "blue running shoes",
            "campaignId": "01",
            "adGroupId": "001"
        },
        {
            "id": "q002",
            "query": "running shoes",
            "campaignId": "01",
            "adGroupId": "001"
        },
        {
            "id": "q003",
            "query": "shoes running",
            "campaignId": "02",
            "adGroupId": "002"
        },
        {
            "id": "q004",
            "query": "sidewalk CAFÉ",
            "campaignId": "02",
            "adGroupId": "002"
        },
        {
            "id": "q005",
            "query": "sidewalk cafe",
            "campaignId": "02",
            "adGroupId": "002"
        },
        {
            "id": "q006",
            "query": "free training",
            "campaignId": "03",
            "adGroupId": "003"
        },
        {
            "id": "q007",
            "query": "ski boots",
            "campaignId": "01",
            "adGroupId": "001"
        },
        {
            "id": "q008",
            "query": "shose",
            "campaignId": "01",
            "adGroupId": "001"
        },
        {
            "id": "q009",
            "query": "socks & shoes",
            "campaignId": "01",
            "adGroupId": "001"
        },
        {
            "id": "q010",
            "query": "very very cheap",
            "campaignId": "01",
            "adGroupId": "001"
        }
    ],
    "negatives": [
        {
            "id": "n001",
            "text": "running shoes",
            "matchType": "broad",
            "scope": "account"
        },
        {
            "id": "n002",
            "text": "running shoes",
            "matchType": "phrase",
            "scope": "campaign",
            "campaignId": "01"
        },
        {
            "id": "n003",
            "text": "running shoes",
            "matchType": "exact",
            "scope": "adGroup",
            "campaignId": "01",
            "adGroupId": "001"
        },
        {
            "id": "n004",
            "text": "sidewalk café",
            "matchType": "phrase",
            "scope": "campaign",
            "campaignId": "02"
        },
        {
            "id": "n005",
            "text": "free",
            "matchType": "broad",
            "scope": "sharedList",
            "listId": "L01"
        },
        {
            "id": "n006",
            "text": "boots",
            "matchType": "broad",
            "scope": "campaign",
            "campaignId": "99"
        },
        {
            "id": "n007",
            "text": "shoes*",
            "matchType": "broad",
            "scope": "campaign",
            "campaignId": "01"
        },
        {
            "id": "n008",
            "text": "shoes",
            "matchType": "broad",
            "scope": "unknown"
        },
        {
            "id": "n009",
            "text": "socks & shoes",
            "matchType": "exact",
            "scope": "account"
        },
        {
            "id": "n010",
            "text": "very very",
            "matchType": "phrase",
            "scope": "campaign",
            "campaignId": "01"
        }
    ],
    "sharedListAttachments": [
        {
            "listId": "L01",
            "campaignId": "03"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("h_murdock/negative-keyword-preflight").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 = {
    "accountId": "000123",
    "queries": [
        {
            "id": "q001",
            "query": "blue running shoes",
            "campaignId": "01",
            "adGroupId": "001",
        },
        {
            "id": "q002",
            "query": "running shoes",
            "campaignId": "01",
            "adGroupId": "001",
        },
        {
            "id": "q003",
            "query": "shoes running",
            "campaignId": "02",
            "adGroupId": "002",
        },
        {
            "id": "q004",
            "query": "sidewalk CAFÉ",
            "campaignId": "02",
            "adGroupId": "002",
        },
        {
            "id": "q005",
            "query": "sidewalk cafe",
            "campaignId": "02",
            "adGroupId": "002",
        },
        {
            "id": "q006",
            "query": "free training",
            "campaignId": "03",
            "adGroupId": "003",
        },
        {
            "id": "q007",
            "query": "ski boots",
            "campaignId": "01",
            "adGroupId": "001",
        },
        {
            "id": "q008",
            "query": "shose",
            "campaignId": "01",
            "adGroupId": "001",
        },
        {
            "id": "q009",
            "query": "socks & shoes",
            "campaignId": "01",
            "adGroupId": "001",
        },
        {
            "id": "q010",
            "query": "very very cheap",
            "campaignId": "01",
            "adGroupId": "001",
        },
    ],
    "negatives": [
        {
            "id": "n001",
            "text": "running shoes",
            "matchType": "broad",
            "scope": "account",
        },
        {
            "id": "n002",
            "text": "running shoes",
            "matchType": "phrase",
            "scope": "campaign",
            "campaignId": "01",
        },
        {
            "id": "n003",
            "text": "running shoes",
            "matchType": "exact",
            "scope": "adGroup",
            "campaignId": "01",
            "adGroupId": "001",
        },
        {
            "id": "n004",
            "text": "sidewalk café",
            "matchType": "phrase",
            "scope": "campaign",
            "campaignId": "02",
        },
        {
            "id": "n005",
            "text": "free",
            "matchType": "broad",
            "scope": "sharedList",
            "listId": "L01",
        },
        {
            "id": "n006",
            "text": "boots",
            "matchType": "broad",
            "scope": "campaign",
            "campaignId": "99",
        },
        {
            "id": "n007",
            "text": "shoes*",
            "matchType": "broad",
            "scope": "campaign",
            "campaignId": "01",
        },
        {
            "id": "n008",
            "text": "shoes",
            "matchType": "broad",
            "scope": "unknown",
        },
        {
            "id": "n009",
            "text": "socks & shoes",
            "matchType": "exact",
            "scope": "account",
        },
        {
            "id": "n010",
            "text": "very very",
            "matchType": "phrase",
            "scope": "campaign",
            "campaignId": "01",
        },
    ],
    "sharedListAttachments": [{
            "listId": "L01",
            "campaignId": "03",
        }],
}

# Run the Actor and wait for it to finish
run = client.actor("h_murdock/negative-keyword-preflight").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 '{
  "accountId": "000123",
  "queries": [
    {
      "id": "q001",
      "query": "blue running shoes",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q002",
      "query": "running shoes",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q003",
      "query": "shoes running",
      "campaignId": "02",
      "adGroupId": "002"
    },
    {
      "id": "q004",
      "query": "sidewalk CAFÉ",
      "campaignId": "02",
      "adGroupId": "002"
    },
    {
      "id": "q005",
      "query": "sidewalk cafe",
      "campaignId": "02",
      "adGroupId": "002"
    },
    {
      "id": "q006",
      "query": "free training",
      "campaignId": "03",
      "adGroupId": "003"
    },
    {
      "id": "q007",
      "query": "ski boots",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q008",
      "query": "shose",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q009",
      "query": "socks & shoes",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "q010",
      "query": "very very cheap",
      "campaignId": "01",
      "adGroupId": "001"
    }
  ],
  "negatives": [
    {
      "id": "n001",
      "text": "running shoes",
      "matchType": "broad",
      "scope": "account"
    },
    {
      "id": "n002",
      "text": "running shoes",
      "matchType": "phrase",
      "scope": "campaign",
      "campaignId": "01"
    },
    {
      "id": "n003",
      "text": "running shoes",
      "matchType": "exact",
      "scope": "adGroup",
      "campaignId": "01",
      "adGroupId": "001"
    },
    {
      "id": "n004",
      "text": "sidewalk café",
      "matchType": "phrase",
      "scope": "campaign",
      "campaignId": "02"
    },
    {
      "id": "n005",
      "text": "free",
      "matchType": "broad",
      "scope": "sharedList",
      "listId": "L01"
    },
    {
      "id": "n006",
      "text": "boots",
      "matchType": "broad",
      "scope": "campaign",
      "campaignId": "99"
    },
    {
      "id": "n007",
      "text": "shoes*",
      "matchType": "broad",
      "scope": "campaign",
      "campaignId": "01"
    },
    {
      "id": "n008",
      "text": "shoes",
      "matchType": "broad",
      "scope": "unknown"
    },
    {
      "id": "n009",
      "text": "socks & shoes",
      "matchType": "exact",
      "scope": "account"
    },
    {
      "id": "n010",
      "text": "very very",
      "matchType": "phrase",
      "scope": "campaign",
      "campaignId": "01"
    }
  ],
  "sharedListAttachments": [
    {
      "listId": "L01",
      "campaignId": "03"
    }
  ]
}' |
apify call h_murdock/negative-keyword-preflight --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,h_murdock/negative-keyword-preflight"
        }
    }
}
```

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/NYrkGwQUYqAaJUl91/builds/JDT35AEYVEZOyOItm/openapi.json
