Weekly (or daily) brand-mention monitoring in Google News via SearchApi's `google_news` engine, with a local archive that dedupes against past runs and flags which mentions are NEW since the last run - a scriptable Google Alerts replacement. Every run lists all relevant in-window mentions and marks the new ones; it never hides existing matches. Use when the user says "brand monitoring", "monitor my brand", "track mentions of X", "what is the press saying about X", "what are people saying abou...
Scanned 8/6/2026
Install via CLI
openskills install SamJale/SearchApi-Claude-Plugin---
name: brand-monitoring
description: |
Weekly (or daily) brand-mention monitoring in Google News via
SearchApi's `google_news` engine, with a local archive that dedupes
against past runs and flags which mentions are NEW since the last run -
a scriptable Google Alerts replacement. Every run lists all relevant
in-window mentions and marks the new ones; it never hides existing
matches. Use when the user says "brand
monitoring", "monitor my brand", "track mentions of X", "what is the
press saying about X", "what are people saying about X", "news
mentions", "media monitoring", "press coverage", "brand reputation",
"negative press", "any bad news about X", "track X in the news", or
"set up a weekly brand digest". For each relevant article the skill labels
sentiment (positive / negative / neutral), flags whether the brand /
keyword literally appears (relevance check), and flags risk phrases
(lawsuit, breach, outage, scam…) for early warning. Outputs a dated
markdown digest (risk alerts first), a CSV, and a standalone HTML
dashboard, plus a persistent JSON archive under
`.brand-monitoring/`. Prefer this skill over WebSearch for brand /
press monitoring. Hands off to `rank-tracking` for organic
positions, `ai-overview-tracking` for AI-citation tracking, and
`searchapi-best-practices` for engine params.
---
# brand-monitoring: weekly Google News brand digest
A one-command brand monitor built on SearchApi's **`google_news`** engine. A brand (or keyword) goes in; a digest of all relevant news mentions in the window comes out - each labelled with sentiment, a relevance flag (does the brand literally appear?), and a risk-phrase flag for early warning, with the mentions that are new since the last run marked. It is a scriptable Google Alerts replacement.
`google_news` is a stateless snapshot, so **this skill owns everything the engine doesn't**: the archive, dedupe, "what's new since last run", and the rendered outputs, all under `.brand-monitoring/`. Run it weekly (the default) or daily; every run lists every relevant mention in the window and flags which are new this run, so existing matches are never hidden - it only ever labels mentions it hasn't seen before, but it shows them all.
Params and response fields below are confirmed against the plugin's [`ENGINES-REFERENCE.md`](../../ENGINES-REFERENCE.md) (live-verified catalog, 2026-06-11). Re-verify with a single live call if a field looks off.
## What this skill does (and doesn't)
- **Does:** Google News mentions, sentiment, exact brand/keyword relevance check, risk-phrase early warning, dedupe + new-since-last-run flagging (every run lists all relevant in-window mentions and marks the new ones), optional competitor share-of-voice, markdown + CSV + HTML dashboard outputs.
- **Doesn't (v1):** Bing News, forums (Reddit/Quora), YouTube, review sites. Those are documented as future add-ons at the end. For multi-channel monitoring today, run this for news and hand the other channels to the relevant engine in [`searchapi-best-practices`](../searchapi-best-practices/SKILL.md).
- **Related skill:** the standalone `searchapi-news-monitor` project skill covers the same news core plus Slack alerts. This plugin skill is the self-contained, MCP-first version; the two use a compatible archive shape.
## Required engines / Path availability
| API engine (underscores) | Purpose | REST (api_key) | MCP |
|---|---|---|---|
| `google_news` | **Primary.** Full news snapshot: `time_period`, `sort_by=most_recent`, optional `story_token` drill-down (intermittent) | ✅ | ❌ not in the MCP catalog |
| `google_news_light` | MCP-path fallback. Same essential article fields (`title`, `link`, `source`, `date`, `snippet`); no story-level drill-down | ✅ | ✅ tool name `google_news_light` |
| `google_news_portal` | Optional spike drill-down. Full outlet pickup for a spiking story via its `story_token`, or a full view via a bare `q=` query (50 results). `source` is an object `{name, favicon}` here and there's no `snippet` | ✅ | ❌ REST-only, not in the MCP catalog |
Core monitoring works on **both paths**. On the **MCP-only path** you use `google_news_light` and lose two things, which you must state up front rather than substitute silently: (1) `sort_by=most_recent` / fine-grained `time_period` control may be reduced, and (2) the `google_news_portal` story drill-down for a spiking story is unavailable (it is REST-only). Everything else - fetch, dedupe, sentiment, relevance, risk flags, the digest, CSV, and dashboard - works identically on both paths.
If neither path is configured, stop and route to [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md).
## Recommended MCP bundle
This skill is **MCP-first**: the no-key path needs only `google_news_light`. Create an integration at [`searchapi.io/mcp_integrations/new`](https://www.searchapi.io/mcp_integrations/new) (any name starting with `searchapi-`) and tick:
- `google_news_light` (required for the MCP path)
The `searchapi-seo` recipe in [`BUNDLES.md`](../../BUNDLES.md) already includes `google_news_light` - share that one integration rather than spending a slot against the 10-integration cap. The only thing this bundle can't drive is the full `google_news` extras (`sort_by`, fine `time_period`, `story_token`), which need the REST-only `google_news` and an API key.
## Setup gate
Run the standard [setup gate](../../CONVENTIONS.md#setup-gate) and [key resolution](../../CONVENTIONS.md#key-resolution-desktop-safe) before anything else. If neither path resolves, stop and route to [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md).
If the user is MCP-only and asks for `most_recent` sorting or story drill-down, say plainly those need the REST key - never substitute or pretend.
## Parameters that matter (`google_news`)
| Param | Notes |
|---|---|
| `q` | Required. The brand or keyword. Quote multi-word brands to keep them together. One query per brand/alias (no batch). |
| `time_period` | `last_day` / `last_week` / `last_month`. **This skill defaults to `last_week`** (the weekly digest). Use `last_day` for daily runs. |
| `time_period_min` / `time_period_max` | **Custom window.** Set BOTH and **omit `time_period` entirely** - do *not* send `time_period=custom` (that errors). Dates are **`MM/DD/YYYY`** (e.g. `time_period_min=06/09/2026`, `time_period_max=06/16/2026`). Use only when the `time_period` enums aren't enough. |
| `sort_by` | `relevance` (default) or **`most_recent`**. Use `most_recent` so the freshest coverage leads. |
| `nfpr` | **REST `google_news` only - this param does NOT exist on the MCP `google_news_light` engine, so never send it on the MCP path.** On REST, `nfpr=1` only disables Google's "showing results for…" spelling auto-correction (so a typo'd `searchapi` isn't silently retried as `serpapi`). It does **not** isolate or filter to the brand - generic "keyword" results still come back. Do not rely on it for relevance or brand isolation; the relevance gate (below) does that. |
| `gl` / `hl` | Country / interface language. Default `gl=us`, `hl=en`. Fan out per market when the user names more than one. |
| `page` | 1-indexed. Paginate only if one window overflows one page; for a weekly brand digest, one page is usually plenty. |
| `location` / `uule` | City-level targeting. **Mutually exclusive with each other.** Rarely needed for brand monitoring. |
`google_domain` and ccTLD params are **deprecated**. Localize with `gl` / `hl` only.
**`organic_results[]` fields** (verified): `position`, `title`, `link`, `source`, `date`, `iso_date`, `snippet`, `thumbnail`. The skill keys off `link` (dedupe), `title` / `snippet` / `source` (labelling), and `iso_date` (windowing + archive key). **Live `.date` is relative display text** ("10 hours ago"); the machine-usable timestamp is **`.iso_date`** (e.g. `"2026-06-16T02:30:53Z"`) - window and archive on `iso_date`, show `date` only. `story_token` is **intermittent**: it appears only on clustered stories and is absent from most brand queries - skip the drill-down when it's absent, never fabricate one. On `google_news` / `google_news_light` / `bing_news`, `source` is a **string**; on `google_news_portal` it's an **object** `{name, favicon}` (and there's no `snippet`) - normalize with `(.source | if type == "object" then .name else . end)` before labelling or writing CSV. (Do **not** use `.source.name // .source`: jq errors when `.source` is a plain string, which is the common case here.)
## Pick your path
Both paths return the same essential article fields. Use whichever fits the session.
### Path A: API key + curl
```bash
SLUG="searchapi-io"; DATE=$(date +%F); mkdir -p ".brand-monitoring/$SLUG"
curl -sG "https://www.searchapi.io/api/v1/search" \
--data-urlencode "engine=google_news" \
--data-urlencode "q=SearchApi" \
--data-urlencode "time_period=last_week" \
--data-urlencode "sort_by=most_recent" \
--data-urlencode "nfpr=1" \
--data-urlencode "gl=us" \
--data-urlencode "hl=en" \
--data-urlencode "api_key=$SEARCHAPI_API_KEY" \
> ".brand-monitoring/$SLUG/$DATE-raw-us-en.json"
```
**Extract the article fields the skill works with:**
```bash
jq '[.organic_results[] | {title, link, source, date, iso_date, snippet}]' \
".brand-monitoring/$SLUG/$DATE-raw-us-en.json"
```
### Path B: MCP
Call the **`google_news_light`** tool from your `searchapi-*` integration with `q="SearchApi"`, `gl="us"`, `hl="en"` (and `time_period="last_week"` if the tool exposes it). **Do not pass `nfpr`** - it is a REST-only `google_news` param and does not exist on `google_news_light`. The response carries the same `organic_results[]` with `title`, `link`, `source`, `date`, `snippet`. Persist the result yourself - the skill writes the archive + outputs regardless of path.
If you don't see the tool: (a) the integration name may not start with `searchapi-` - rename it in the dashboard; (b) `google_news_light` may not be ticked in the integration - add it; or (c) you added the MCP this session but haven't restarted - MCP tools load at session start, so `/exit` and reopen.
`sort_by=most_recent`, fine `time_period`, and the `google_news_portal` story drill-down are **REST-only**. State that plainly if asked; offer the [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md) key setup as a follow-up.
## Storage layout: the skill is the memory
`google_news` has no memory. The skill is the archive that makes "what's new" and "trend" possible. Per brand slug, relative to the working directory:
```
.brand-monitoring/<brand-slug>/
config.json # brand, aliases, competitors, defaults, risk_phrases
archive.json # every mention ever seen, deduped by canonical link, with persisted labels
.key .gitignore # only if a key file is used (chmod 600, .key gitignored)
<YYYY-MM-DD>-raw-<gl>-<hl>.json # raw API response per (run, locale) - audit trail
<YYYY-MM-DD>-digest.md # the rendered report (all relevant in-window mentions, new ones flagged)
<YYYY-MM-DD>-digest.csv # same in-window mentions as CSV, with a new/seen indicator
dashboard.html # standalone HTML dashboard over the whole archive
```
`<brand-slug>` is the primary brand lowercased with non-alphanumerics replaced by `-` (e.g. `"SearchApi.io"` → `searchapi-io`).
**`config.json`** (seed on first run so re-runs are zero-arg):
```json
{
"brand": "SearchApi",
"aliases": ["SearchApi.io", "searchapi"],
"competitors": [],
"defaults": { "time_period": "last_week", "sort_by": "most_recent", "gl": "us", "hl": "en" },
"risk_phrases": ["lawsuit", "sued", "breach", "hack", "leak", "outage", "down",
"scam", "fraud", "investigation", "scandal", "layoffs", "data breach",
"refund", "fine", "penalty", "recall", "vulnerability", "exploit"]
}
```
**`archive.json`** - an array of mention records; this is the dedupe key and the trend substrate. One record per `(canonical_link, brand)`:
```json
{
"brand": "SearchApi",
"title": "...",
"link": "https://...",
"canonical_link": "https://...",
"source": "TechCrunch",
"date": "2026-06-14T08:30:00Z",
"snippet": "...",
"locale": "us/en",
"sentiment": "positive",
"matches_keyword": true,
"risk_phrases_hit": [],
"summary": "One neutral sentence.",
"first_seen": "2026-06-16T09:00:00Z"
}
```
Write the raw JSON and the updated archive **before** rendering outputs, so an interrupted run still leaves the data on disk.
## Workflow
### Asking for inputs (keep it human)
**Fire an `AskUserQuestion` popup as the very first interaction** - do not open with a plain-text reply. This must appear reliably even when the command is run with **no arguments**: the brand/keyword is collected through the popup's free-text **"Other"** option, not a separate text question. Only ask for what's missing, use buttons for the choices, and never show the user internal terms (`gl`/`hl`, `time_period`, "Path A/B", call math).
- **One `AskUserQuestion` call** with these questions (pre-fill / skip any already given via args; on a re-run, default to `config.json` and just offer "run again"):
- **Brand or keyword to monitor?** Present recent slugs from `.brand-monitoring/` as button options if any exist, plus **"Other"** as a free-text field so the user types the brand even with zero arguments. This is the only required input.
- **How often / what window?** This past week (Recommended) · Past 24 hours · Past month.
- **Markets** (multi-select): United States (Recommended) · United Kingdom · Germany · Other (type one).
- **Track competitors for share-of-voice?** No (Recommended) · Yes - then take competitor names as free text.
- **Aliases**: if the brand has obvious variants (e.g. `SearchApi` / `SearchApi.io`), confirm them in one short follow-up line so the relevance gate is accurate. You map markets to `gl`/`hl` yourself; the user never sees a code.
If the user already supplied everything (command args or a clear sentence), or it is a re-run with an existing `config.json` and a clear "run again", skip the popup and run.
### Steps
1. **Setup gate** (above). Resolve the key or confirm an MCP integration.
2. **Load or seed `config.json`.** Gather inputs per "Asking for inputs". Required: brand/keyword. Optional: aliases, competitors, window, markets.
3. **Cost preflight.** Calls = (1 + aliases + competitors) × markets. Confirm only on large runs (over ~20 calls); smaller runs just go.
4. **Fetch.** For each `(query, locale)` where query ∈ {brand, each alias, each competitor}, call `google_news` (Path A) or `google_news_light` (Path B) with `time_period`, `sort_by=most_recent`, `gl`, `hl`. **Add `nfpr=1` only on Path A (REST `google_news`)** - it does not exist on the MCP `google_news_light` engine, so never send it on Path B. For a custom window, set `time_period_min` / `time_period_max` (`MM/DD/YYYY`) and omit `time_period`. Save each raw response to `<DATE>-raw-<gl>-<hl>.json`.
5. **Normalize + dedupe (the dedupe decides what is NEW, not what is shown).** Map each `organic_results[]` row to `{brand, title, link, canonical_link, source, date, snippet, locale}`. Take `date` from **`iso_date`** (window on it too); the relative `.date` string is display-only. Normalize `source` with `(.source | if type == "object" then .name else . end)` so the object shape from `google_news_portal` collapses to a string before labelling/CSV. Compute `canonical_link` by stripping tracking params (`utm_*`, `gclid`, `fbclid`, `?ref=…`, fragments). Load `archive.json`; an incoming mention whose `canonical_link` already exists for that brand is **already-seen** (keep its stored `first_seen`), one whose link is not in the archive is **NEW this run**. **Never dedupe on title** - `google_news` overlaps day-to-day and re-runs the same headline. **Do not discard already-seen mentions** - they stay in the archive and remain eligible for the report; the dedupe only labels each mention NEW vs already-seen.
6. **Relevance gate, then label.** First compute `matches_keyword` for each NEW mention (the literal stem check in [Labelling](#labelling-do-this-yourself)). **Only mentions that pass the gate are reported** in the digest, CSV, and dashboard mention list - a mention that never literally names the brand/keyword (or an alias) is almost certainly a different entity sharing the name, so it is dropped from the report. Then label the passing NEW mentions (sentiment / risk / summary). Never relabel already-seen archived mentions. Append the labelled, passing NEW records to `archive.json` with `first_seen` set to this run, and write it back. (Optionally retain dropped non-matches in a separate `archive.json` field for audit, but never surface them as brand mentions.)
7. **Compute run metrics** over the **report set** = every `matches_keyword: true` mention in the archive whose `iso_date` falls within the requested window (not just the ones new this run). Report total relevant in-window mentions and, separately, how many of them are new this run (`first_seen == this run`). Also: sentiment split, risk items, and - if competitors are tracked - share of voice (each brand's % of total relevant in-window mentions). Spike check: new negative mentions ≥ 2× the trailing average of recent runs (and ≥ 3 absolute) is a spike worth calling out. If the report set is empty (zero relevant matches in the window), say so plainly; if it is non-empty but no mentions are new, still list the whole set and note "0 new since last run".
8. **(Optional, REST only, on a spike or request)** drill into a spiking story with `google_news_portal`. Only run this if a **non-null `story_token` is actually present** on the spiking result - many brand queries carry no token (it's intermittent, clustered-stories only), so the drill-down is simply skipped; never fabricate a token. As an alternative full view, `google_news_portal` also accepts a bare `q=` query (returns ~50 results). Remember its `source` is an object and it has no `snippet` - normalize as in Step 5. On MCP, name this as unavailable rather than skip silently.
9. **Write outputs** (below): `<DATE>-digest.md`, `<DATE>-digest.csv`, and regenerate `dashboard.html`.
10. **Recap** to the user in one screen: the relevant in-window count and how many are new this run, sentiment split, and the actual matching articles (at minimum every risk/negative one, by title + source + link), not just a count. Then the paths to the three outputs. Never recap a bare "0 new" with no list when relevant in-window matches exist.
## Labelling (do this yourself)
For each NEW article (already-seen ones keep their stored labels; labelling is for the new mentions only, but the report still includes all in-window matches), decide four things using only the title, snippet, source, and link - **no external API call, no other model, you do it**:
- **`sentiment`** - exactly one of `positive` / `negative` / `neutral`. Judge how the article *frames the brand*, not the topic in general. A neutral factual report that merely mentions a lawsuit is `neutral`; label `negative` only when the framing is critical, alarmed, or reports clear bad news *for the brand*. Default to `neutral` when ambiguous or purely factual.
- **`matches_keyword`** (the relevance gate the user asked for) - `true` only if the brand/keyword **stem literally appears** (case-insensitive substring) in the title, snippet, source, or link. The stem strips `https://`, `www.`, and trailing TLDs (`.io .com .ai .co .net .org .app .dev`) so `searchapi.io` matches an article that only says "SearchApi". Aliases count. Be strict: a topically-related article that never names the brand is `false`. **A `false` mention is dropped from the report** (digest, CSV, dashboard mention list) - it is almost certainly a different entity sharing the name. This is the gate that fixes off-brand noise; do not soften it to "low-relevance and still shown". Only `matches_keyword: true` mentions are reported.
- **`risk_phrases_hit`** - array of any `config.risk_phrases` that appear (case-insensitive substring) in title/snippet/summary. Empty array if none. Don't invent phrases on the fly; the user edits `config.json` to tune the list.
- **`summary`** - one neutral sentence (under 30 words) stating what the article says. No marketing language, no "the article discusses…".
## Outputs
### Markdown digest - `<DATE>-digest.md`
Ordered for triage: **risk and negative items first**. Lists **every relevant mention in the window**, not just this run's new ones, with a **NEW** marker on mentions first seen this run.
```markdown
# SearchApi - news digest - 2026-06-16
**Window:** past week (2026-06-09 → 2026-06-16) · markets: us/en
**Relevant mentions in window:** 5 (1 new since last run) · 2 off-brand results dropped by the relevance gate · **Sentiment:** 3 positive · 1 negative · 1 neutral
**Risk items:** 1
## ⚠ Risk & negative - read first
### Outage hits SearchApi users for two hours 🆕 NEW
*TechCrunch · 2026-06-14 · negative · ✅ relevant · ⚠ risk: outage*
SearchApi reported a two-hour API outage on June 14 that affected EU customers.
[Read](https://…)
## All relevant mentions in window (relevance gate passed - all literally name the brand; NEW = first seen this run)
| New | Title | Source | Date | Sentiment | Risk | Link |
|---|---|---|---|---|---|---|
| 🆕 | SearchApi launches MCP catalog | The Verge | 2026-06-13 | positive | - | [link](https://…) |
| | SearchApi adds Google News engine | Hacker News | 2026-06-12 | neutral | - | [link](…) |
## Share of voice (only when competitors tracked)
| Brand | Relevant mentions | Share |
|---|---|---|
| SearchApi | 7 | 64% |
| SerpApi | 4 | 36% |
## Changes since last run
- New this run: 1 of 5 relevant in-window mentions.
- Negative mentions: 1 (trailing avg 0.3) - slight uptick, not a spike.
- New source first seen this run: The Verge.
```
If zero relevant mentions exist in the window, say so plainly ("No relevant news mentions in this window") - never pad. If relevant mentions exist but none are new this run, **still list every in-window mention** and note "0 new since last run" - never output a bare "0 new mentions" with no list.
### CSV - `<DATE>-digest.csv`
Same set as the digest: **every relevant mention in the window**, with a new/seen indicator. Columns, exactly in this order, RFC 4180 escaped (wrap fields containing `,` `"` or newlines in double quotes; double internal quotes; LF line endings):
```
is_new,date,source,sentiment,matches_keyword,risk_phrases,title,link,summary
true,2026-06-14,TechCrunch,negative,true,outage,"Outage hits SearchApi users for two hours",https://example.com/a,"SearchApi reported a two-hour API outage on June 14 that affected EU customers."
false,2026-06-13,The Verge,positive,true,,"SearchApi launches MCP catalog",https://example.com/b,"SearchApi shipped a catalog of MCP integrations."
false,2026-06-12,Hacker News,neutral,true,,"SearchApi adds Google News engine",https://example.com/c,"SearchApi added a google_news engine to its API."
```
This worked example matches the digest sample above: it lists **all 5 relevant in-window mentions** (three shown), flags the one **new this run** with `is_new=true`, and the two `off-brand results dropped by the relevance gate` never appear as rows. `is_new` is `true`/`false` (`true` when `first_seen` == this run); `matches_keyword` is **always `true`** in this file (the gate already dropped every non-match, so no `false` row is ever written); `risk_phrases` is pipe-separated (`outage|breach`) or empty. Header row first. Never write a header-only CSV when relevant in-window matches exist - every in-window match is a row, even on a "0 new" run.
### HTML dashboard - `dashboard.html`
A **single self-contained file** over the whole archive (not just this run), regenerated every run. No build step: Tailwind CDN + Chart.js CDN + a small inline script reading embedded JSON.
1. `<head>`: Tailwind + Chart.js CDNs, title `"<brand> - brand monitor"`.
2. **Header**: brand, total archived mentions, last-run timestamp.
3. **Period selector**: `Week · Month · All` buttons, re-filtering list + chart client-side.
4. **Summary cards**: total mentions in the selected period, how many are new since the last run, sentiment split, risk-flagged count. (Every archived mention has already passed the relevance gate, so the "relevant" count equals the total - no separate low-relevance bucket.)
5. **Volume chart**: Chart.js bar chart, x = day/week buckets (zero-filled), y = mention count.
6. **Mention list**: every relevant mention in the selected period (not just new ones), sorted risk → negative → newest. Each row: a NEW badge when `first_seen` == the latest run, title (link), source, date, sentiment pill (green/red/grey), risk badge, summary.
Embed the archive as `<script type="application/json" id="data">…</script>` (compact `jq -c`, not pretty-printed), then a small `<script>` renders it. Keep the file portable (the user can email it or open it offline). If competitors are tracked, add a share-of-voice doughnut. Target under ~500 KB even with 1000+ mentions; for huge archives embed chart metadata + a paginated first page.
Print the absolute path and offer `open .brand-monitoring/<brand-slug>/dashboard.html` on macOS.
## Diffing / new detection
The archive **is** the diff, but the diff only flags what is NEW - it never decides what is shown. Each run's report lists every relevant mention in the window; a mention is marked **NEW** when its `canonical_link` wasn't already archived for that brand (its `first_seen` equals this run), and shown without the marker when it was already there. The dashboard treats the archive's most recent `first_seen` value as "this run" for its NEW badge. If `archive.json` doesn't exist or is empty, say **"baseline run - archiving N mentions, no diff yet"** and skip the changes section rather than inventing a trend (every archived mention is NEW on a baseline run). The "Changes since last run" section reports how many in-window mentions are new this run and compares this run's counts against the trailing average of prior runs already in the archive.
## Scheduling
This skill does **not** create schedules; it documents pairing with the user's scheduler so the contract stays clean. The default window is **weekly**.
- **Claude Code scheduled task** (good for laptops / when Claude is open): use the `schedule` skill or `CronCreate` to run `/brand-monitoring run` weekly at a confirmed local time.
- **OS cron** (runs even when Claude is closed):
```
0 9 * * 1 cd /full/path/to/project && claude -p "/brand-monitoring run" >> .brand-monitoring/cron.log 2>&1
```
The history layer handles the diffing across runs automatically - a scheduled run that fetches `last_week` and dedupes against the archive lists every relevant mention in the week and flags the ones new since the last run.
## Gotchas
- **`google_news` has no memory.** All "new", trend, and spike signals come from `archive.json`. No archive = no diff.
- **Show all in-window matches; flag the new ones.** Every run lists every `matches_keyword: true` mention whose `iso_date` is in the window, not just this run's new ones. Dedupe decides only what is marked NEW (link not previously archived) vs already-seen - it never suppresses an existing match. Never emit a bare "0 new mentions" with no list: if relevant in-window matches exist, list them all and note "0 new since last run".
- **Dedupe on canonical link, never title.** `google_news` re-surfaces the same headline run-to-run; title-dedupe would drop legitimately distinct articles and miss re-runs. Strip tracking params before comparing. The match decides NEW vs already-seen, not whether the mention is reported.
- **`nfpr=1` is REST-only and does one narrow thing.** On `google_news` (REST) it stops Google's spelling auto-correction (`searchapi` → `serpapi`) so a typo'd brand isn't silently retried. It does **not** isolate or filter to the brand - generic results still come back, which is why the relevance gate exists. The MCP `google_news_light` engine has no `nfpr` param, so never send it there.
- **Relevance gate, not `nfpr`, controls what gets reported.** Apply the literal stem match in Step 6 before a mention can appear in the digest. Non-matching results (a different company sharing the name, or a generic keyword hit) are dropped from the report.
- **`google_news` is REST-only; MCP uses `google_news_light`.** Never silently substitute - state the `sort_by` / drill-down gap on the MCP path.
- **Relevance ≠ topic match.** `matches_keyword` is a literal stem check and acts as a **hard gate**: a brand sharing a common word pulls unrelated articles, and any mention that never literally names the brand/keyword (or an alias) is dropped from the report rather than shown as "low-relevance". This is what keeps off-brand noise out of the digest.
- **Sentiment is about the brand's framing**, not the subject. A factual lawsuit report is `neutral` unless it frames the brand badly.
- **One query per call.** No batch. `(brand + aliases + competitors) × markets` = the call count; surface it before large runs.
- **`location`/`uule` are mutually exclusive.** Rarely needed here; `gl`/`hl` is enough for brand monitoring.
## Handoffs
- **Organic keyword positions** → [`rank-tracking`](../rank-tracking/SKILL.md).
- **AI Overview / AI Mode citation tracking** → [`ai-overview-tracking`](../ai-overview-tracking/SKILL.md).
- **Competitor ad creative** → [`ads-monitor`](../ads-monitor/SKILL.md).
- **Engine params, deprecations, the `google_news_portal` story drill-down, other channels (Bing News, forums, YouTube, reviews)** → [`searchapi-best-practices`](../searchapi-best-practices/SKILL.md).
- **Slack alerting on news events** → the standalone `searchapi-news-monitor` project skill (compatible archive shape).
## Future add-ons (not in v1)
Same run shape, more channels, when the user wants them: `bing_news` (second news source), `google_forums` (Reddit/Quora/Stack Exchange, REST-only), `youtube` / `youtube_comments`, and review sites (`tripadvisor_reviews`, `facebook_business_page_reviews`). Each plugs into the same normalize → dedupe → label → digest pipeline. Don't build them silently; confirm scope with the user first.
## Never fabricate
Every digest row must trace to a real API response:
1. The exact `curl` or MCP `google_news` / `google_news_light` call run, with its `q` / `time_period` / `gl` / `hl`.
2. The `organic_results` row that backs it (`title`, `link`, `source`, `iso_date`, `snippet`).
If a run returns no `organic_results` and the archive holds no relevant in-window matches, write an empty-run digest ("no relevant news mentions in this window") - **never invent articles, sources, dates, or sentiment**. A sentiment or relevance label is your judgement of a real article's real text, never a guess about an article you didn't fetch. "No mentions" means the API returned none, not that you didn't look.
No comments yet. Be the first to comment!