agentleFS
risukisurisukisu/marketing-skills/skills/Marketing-Ops/competitor-intel/SKILL.md

competitor-intel

Skill6 starsChanged 48 days agoMITView on GitHub

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.

9 other projects use a version of this.

Adopted by

Most recently taken up by

The file

Show its rules and text

We split this skill into the rules it gives an agent

15 rules. 0 are common, 4 are distinctive, and 11 are very specific to this project.

  • 0

    Common

    Most of the 9 edited versions we read keep these.

  • 4

    Distinctive

    This author's own choices. Worth a look.

  • 11

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Check for competitor-intel/company.md in the current working directory.

    line 21
  2. Distinctive:

    Ask first, before anything else:

    line 40
  3. Very specific:

    (a) Point me at files: Never assume a competitor-intel/ or context folder already holds the right thing.

    line 48
  4. Distinctive:

    (b) From scratch: Ask one question at a time, in this order:

    line 55
  5. Very specific:

    Read competitor-intel/config.json and competitor-intel/competitors.md.

    line 112
  6. Distinctive:

    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 …

    line 182
  7. Very specific:

    Fill references/snapshot-schema.json literally: Every key present, no extras, and an empty value written as "", 0, or [] — never omitted, …

    line 186
  8. Very specific:

    Write this to competitor-intel/runs/<stamp>/snapshot.json, where <stamp> is YYYY-MM-DD_HH-mm.

    line 212
  9. Very specific:

    If a previous run directory exists under competitor-intel/runs/, apply references/diff-rules.md in full — including its §6 schema-drift …

    line 214
  10. Very specific:

    Read only snapshot.json and changes.json for this — never go back to raw HTML or the fetch output — and write …

    line 219
  11. Very specific:

    Update competitor-intel/config.json's lastRun to the run stamp.

    line 222
  12. Distinctive:

    Before you send that final message, check your last paragraph: the turn ends with the briefing, not a plan to write one.

    line 232
  13. Very specific:

    Every per-URL failure — a non-200 STATUS:, a transport error, a timeout — becomes one entry in that competitor's errors[], shaped { "url", …

    line 237
  14. Very specific:

    If a competitor's assembled snapshot exceeds 60 KB (the hard guard.

    line 257
  15. Very specific:

    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.

    line 268

“Common” is measured: kept by at least 6 in 10 of the 9 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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:

{ "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

    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:

    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).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.