competitor-intel · git:20260911.cdc6661 · 2026-09-11 · sha256 003b8fd3bcd3dbc5
competitor-intel git:20260911.cdc6661A
Immutable. This exact content is served forever at /api/v1/blob/003b8fd3bcd3dbc5.
---
name: competitor-intel
description: Scan competitor websites and ad libraries, detect what changed since the last scan, and write a competitive briefing. Use when the user says "/competitor-intel", "competitor scan", "what are our competitors doing", "competitive intelligence", "competitor briefing", or asks to track a competitor's website or ads over time. First run walks through a short intake that stores company and competitor details in a competitor-intel/ folder in the current project — no company data lives in the skill. Works with no dependencies (curl); adds screenshots and LinkedIn ad extraction when Node and Playwright are available.
---
# Competitor Intel
Scans competitor websites (and, at the deep tier, LinkedIn ad libraries), diffs the result
against the last scan, and writes a briefing a person actually reads. Everything the skill
learns about a company lives in a `competitor-intel/` folder inside the current project — never
in this skill folder, never in any repo this skill ships from.
**Paths in this file.** `scripts/…` and `references/…` are relative to **this skill's
folder**. `competitor-intel/…` is relative to the **current working directory** — the user's
project. This skill deliberately works in two roots at once, so every script invocation below
needs the skill folder's own path in front of it (written `<skill>/scripts/…`), while
`competitor-intel/…` arguments stay relative to the project.
## Routing
Check for `competitor-intel/company.md` in the current working directory.
- Missing → run **Intake**.
- Present, user said `scan` or gave no argument → run **Scan**.
- Present, user said `setup` → re-run **Intake** against the existing files (confirm before
overwriting).
- Present, user said `report [run-id]` → run **Report** (no re-scraping — read the existing
`runs/<run-id>/snapshot.json` and `changes.json`, or the latest run if `run-id` is omitted,
and regenerate `briefing.md` from them per `references/briefing-format.md`).
## Intake
**Always ask the intake question, even when the project looks pre-loaded and the setup seems
obvious.** Do not infer company or competitor details from the repo, from CLAUDE.md, or from
earlier conversation and skip straight to writing files. The user's own words — how they name
their competitors, what they think changed, which one worries them — shape the whole briefing
downstream, and skipping the question throws that away even when you could technically guess
right.
Ask first, before anything else:
> "Setting up competitor tracking. Do you want to point me at files you already have (a
> competitor list, positioning doc, past research), or start from scratch — one question at a
> time?"
Offer this as an AskUserQuestion with two paths:
**(a) Point me at files.** Never assume a `competitor-intel/` or context folder already holds
the right thing. Scan the current launch directory for likely candidates (a competitor list,
CLAUDE.md/AGENTS.md, a `context/` folder, anything with "competitor," "positioning," or
"research" in the name). List what you found by filename only — don't read contents yet — then
ask the user to confirm one of: use these / redirect me to other files / paste it instead /
none of this, start from scratch. Wait for the answer, then read only what was approved.
**(b) From scratch.** Ask one question at a time, in this order:
1. Company name and URL (this is *your* company, for framing — not tracked as a competitor).
2. Competitors to track: name + URL for each.
3. For each competitor, the LinkedIn account name — **but only ask this when it would differ
from the company name.** State why when you ask: a name-similar company on LinkedIn is an
easy false match, and matching the wrong account silently pollutes the ad data with someone
else's ads with no signal that it happened. If the company name is a safe-enough guess for
the LinkedIn handle, don't force the question — confirm the guess instead.
From the answers, write:
**`competitor-intel/company.md`** — the user's own company name, URL, and any framing notes
they gave.
**`competitor-intel/competitors.md`** — one line per competitor, this exact format:
```
- Name | https://url/ | linkedin-account-name
```
**Validate every URL before you write this file**, whichever intake path produced it: it must
match `^https?://[A-Za-z0-9.:-]+` — an `http`/`https` scheme and a host of letters, digits,
dots, hyphens and an optional `:port`, nothing else. If a URL doesn't, say which one and ask
for a corrected value rather than writing it. `competitors.md` is hand-edited afterwards and
meant to be committed and shared across a team, so it is not self-trusted input: it is the one
place a URL enters the scripts. Both scripts refuse a URL that fails this check (and refuse
`file://` outright), so an unvalidated entry silently costs you that competitor's whole scan.
**`competitor-intel/config.json`** — this exact default, before any user override:
```json
{ "tier": "auto", "maxSubpages": 10, "locale": "en-US", "lastRun": "" }
```
Then, regardless of which path was taken, check for the deep tier: is `node` on PATH, and is
it **Node 20 or newer**? Check the version, not just the presence — `node -v` — because
Playwright 1.63.0 declares `engines.node >= 20` and will not install cleanly below it. If yes,
offer it — explain that the deep tier adds screenshots and real LinkedIn ad extraction via
Playwright, and that turning it on means running `npm install` and `npx playwright install
chromium` in the skill's `scripts/deep/` folder, which downloads about 310 MB. Only do this
after the user says yes. If `node` is missing or older than 20, or the user declines, stay on
the default tier — `tier: "auto"` will still resolve to default at scan time (see Scan, and
Error handling below).
## Scan
You are operating autonomously during a scan. The user is not watching in real time. For
reversible steps that follow from the scan request, proceed without asking. Stop only for the
deep-tier install (downloads ~310 MB) and for anything destructive.
The fetch loop below is mechanical and cheap — a script does the extraction, you just batch the
calls and file the output. Don't spend judgment there. Save your attention for the briefing:
that's the one artifact a person reads, and it's where materiality calls, wording, and the
Quoting rule actually matter.
Procedure:
1. Read `competitor-intel/config.json` and `competitor-intel/competitors.md`.
2. Resolve the tier: `tier: "auto"` means deep if `node` is on PATH **and** `node -v` reports
major version 20 or newer **and** `scripts/deep/node_modules` exists; otherwise default. If
the config says `"deep"` but any part of that check fails, fall back to default and say so
once (see Error handling).
3. Per competitor, run
```bash
FETCH_LOCALE='<config.locale>' bash "<skill>/scripts/discover.sh" '<url>' <maxSubpages>
```
**Single-quote the URL.** You are composing a Bash line from `competitor-intel/competitors.md`,
a file a human edits and a team shares; an unquoted URL containing `;`, `&` or a backtick
would execute. Same rule everywhere below: step 4's URLs and step 5's paths.
`<maxSubpages>` is config's `maxSubpages`, default 10 — it caps `other` pages only;
`services` is uncapped, `about` and `case-study` are capped at 5 by the script itself.
`FETCH_LOCALE` is config's `locale`; it becomes the `Accept-Language` header on every
default-tier fetch, and `discover.sh` passes it down to `fetch.sh`.
The output, in order:
- `SOURCE: nav|sitemap|none` — which path produced the primary candidate list. This is
run provenance, **not** a page's `source`; never stamp it onto pages.
- `CAPPED: about=N case-study=N other=N` — present only when a cap actually dropped rows.
`N` is the number of rows **dropped**, not kept. Carry it into Data notes item 3.
- Then one row per candidate: `<kind>\t<source>\t<url>`, tab-separated. `kind` is
`services`, `about`, `case-study` or `other`. `source` is that page's own provenance —
`nav` or `sitemap`, and only those two values (a page can only exist if one of the two
lists produced it; `nav` wins when both did).
4. **Batch the fetches.** List what you need, then request every fetch that doesn't depend on
another's result in one response. Run `FETCH_LOCALE='<config.locale>' bash
"<skill>/scripts/fetch.sh" '<url>' 3000` for the homepage and the same with `1500` for each
subpage. Each call emits `URL:`, `STATUS:`, `TITLE:`, `META:`, `H1:`, `HEADINGS:`
(pipe-separated), `LINKS:` (pipe-separated), and `TEXT:` (last, the only multi-word field)
— or `STATUS:` + `ERROR:` on a non-200 response, a transport failure, or a URL the script
refuses, and the script still exits 0 in every one of those cases.
5. **Deep tier only — run the browser pass.** Skip this step entirely on the default tier.
Decide the run stamp now (`YYYY-MM-DD_HH-mm`); step 6 writes to the same one. Run this
once for the whole run, not once per competitor:
```bash
node "<skill>/scripts/deep/scan.mjs" \
--competitors 'competitor-intel/competitors.md' \
--out 'competitor-intel/runs/<stamp>' \
--locale '<config.locale>'
```
It prints **one JSON object per line on stdout**, one line per competitor, shaped
`{ "name", "screenshots": { "home" }, "linkedinAds": { "tier", "count", "ads" }, "errors": [] }`.
Screenshots are written under `competitor-intel/runs/<stamp>/screenshots/`. For each
line, find the matching competitor **by `name`** (the value echoed from
`competitors.md`) and merge into that competitor's snapshot as you assemble it in
step 6:
- `screenshots.home` ← the line's `screenshots.home` — a path relative to the run
directory, e.g. `screenshots/acme-analytics-home.png`. Keep it relative; never
rewrite it to an absolute path.
- `linkedinAds` ← the line's `linkedinAds` object entire (`tier`, `count`, `ads[]`).
- `errors[]` ← **append** the line's `errors[]` to whatever the fetch loop already
recorded for that competitor. Never replace.
- top-level `tier` ← `"deep"`.
A competitor with **no matching line** (scan.mjs crashed partway, or the name didn't
match) keeps its default-tier values — `tier: "default"`, `linkedinAds.tier:
"default"`, `count: 0`, `ads: []`, `screenshots.home: ""` — and gets a Data notes
line saying the deep pass produced nothing for it. `linkedinAds.tier` becomes
`"deep"` **only when the merge actually happened**: it is the field that tells a
reader whether the ad numbers came from a browser or from nothing at all, so never
write `"deep"` onto a competitor whose merge didn't happen. Same for the top-level
`tier`.
If the command itself fails — non-zero exit, or no output at all — fall back to the
default tier for the whole run, say so once, and note it in Data notes. A failed deep
pass never stops the scan.
6. **Fill `references/snapshot-schema.json` literally.** Every key present, no extras, and an
empty value written as `""`, `0`, or `[]` — never omitted, never `null`. This is the same
rigidity contract `diff-rules.md` §0 states for the diff step, restated here because a
schema you don't re-read at fill time is a schema you drift from:
- Top level: `name`, `url`, `fetchedAt`, `tier`, `home`, `pages`, `linkedinAds`,
`screenshots`, `errors`.
- `home`: `title`, `metaDescription`, `h1`, `headings` (array), `text` (≤3000 chars).
- Each `pages[]` entry: `url`, `kind`, `source`, `title`, `h1`, `text` (≤1500 chars) — no
`metaDescription` key on page entries, only `home` has one. `url` and `kind` come from
columns 3 and 1 of that page's `discover.sh` row; **`source` comes from column 2 of the
same row** — that page's own provenance, `nav` or `sitemap`. Never derive it from the
global `SOURCE:` line: stamping that label on every page makes every page read `nav`
whenever nav returned anything, which silently empties the briefing's "not in nav"
bullet and quietly folds sitemap-only pages into its "from nav" row. The briefing
asserts this provenance per page twice; it has to be observed, not assumed.
- `linkedinAds`: `tier`, `count`, `ads[]` — each ad: `headline`, `body`, `firstSeen`. On the
default tier this stays at the schema's own defaults — `tier: "default"`, `count: 0`,
`ads: []` — the default tier has no ad-fetch path at all, so there is nothing to populate,
symmetric with `screenshots` staying empty below. On the deep tier it is whatever step 5
merged in for that competitor, and nothing at all if step 5 produced no line for it.
- `screenshots`: `home`, `pages` (object keyed by page URL) — leave paths empty on the
default tier. On the deep tier, `home` is step 5's run-relative path
(`screenshots/<name>-home.png`) and `pages` stays `{}`: `scan.mjs` screenshots the
homepage only.
- `errors`: one entry per failed fetch, each `{ "url", "status", "note" }` (see Error
handling for what goes in each field).
Write this to `competitor-intel/runs/<stamp>/snapshot.json`, where `<stamp>` is
`YYYY-MM-DD_HH-mm`.
7. If a previous run directory exists under `competitor-intel/runs/`, apply
`references/diff-rules.md` in full — including its §6 schema-drift guard — to produce
`competitor-intel/runs/<stamp>/changes.json`. If there is no previous run, or a competitor's
own comparison fails the drift guard, write the baseline form per diff-rules.md §5/§6 instead
of skipping the file.
8. Read only `snapshot.json` and `changes.json` for this — never go back to raw HTML or the
fetch output — and write `competitor-intel/runs/<stamp>/briefing.md` per
`references/briefing-format.md`, all five sections, in order.
9. Update `competitor-intel/config.json`'s `lastRun` to the run stamp.
Scope: scan, diff, brief. If you find a bug in the scripts, or notice a page you could also be
scraping, note it as a follow-up at the end of the briefing's Data notes — don't fix or extend
anything mid-scan.
Progress: say in one line what you're about to do before you start; one short line per
competitor as it completes; close with the briefing itself, not a description of it — the
briefing must stand alone as the final message.
Before you send that final message, check your last paragraph: the turn ends with the briefing,
not a plan to write one.
## Error handling and size guard
- Every per-URL failure — a non-200 `STATUS:`, a transport error, a timeout — becomes one entry
in that competitor's `errors[]`, shaped `{ "url", "status", "note" }`:
- `url` — the URL that failed, exactly as fetched.
- `status` — the HTTP status as an integer (e.g. `403`, `404`, `500`). For a transport
failure with no HTTP response at all, use `0` — this is `fetch.sh`'s own convention: it
prints `STATUS: 000` in that case (curl couldn't complete the request), and `000` becomes
the integer `0` here, matching the schema's `"status": 0` default.
- `note` — a short human-readable reason (e.g. `"blocked with 403"`, `"timed out"`,
`"transport failure"`).
The scan continues past it; a failed fetch never stops the run.
- A `403` or `429` on the homepage or a subpage means: skip the rest of that competitor's
subpages for this run, record it in `errors[]`, and note in the briefing's Data notes that
the site blocked default-tier fetches. **Be precise about the remedy: the deep tier does not
recover the missing content.** `scan.mjs` takes a homepage screenshot and reads the LinkedIn
Ad Library — it extracts no title, h1, meta, text or links, so it cannot supply a single
field the 403 cost you. What the deep tier buys here is a screenshot a person can read by
eye. Say that, and recommend a manual check for the content itself.
- `node` is on PATH but `scripts/deep/node_modules` is missing (Playwright never installed, or
removed), or `node -v` is older than 20: fall back to the default tier for this run and say
so once, in one line, before the fetch loop starts — not once per competitor.
- If a competitor's assembled snapshot exceeds 60 KB (the hard guard; target is 40 KB), truncate
that snapshot's `text` fields — home first, then subpages, longest first — until it's under
the guard, and note in Data notes which competitor was truncated and by roughly how much.
## Report
`report [run-id]` never re-scrapes. Read `competitor-intel/runs/<run-id>/snapshot.json` and
`changes.json` (or the most recent run directory if `run-id` is omitted), and regenerate
`briefing.md` from them per `references/briefing-format.md`. Use this to re-render a briefing
after a formatting fix, or to review an older run without burning a new scan.
If `competitor-intel/runs/` doesn't exist yet, or is empty, or the named `run-id` doesn't exist:
don't scan and don't fabricate a briefing. Say plainly that there's no run to report on yet, and
point to `scan` as the next step (naming the missing/requested `run-id` if one was given).