Link Prospecting Outreach

SkillSearch

Find sites ranking for target keywords, score every prospect with Ahrefs domain authority and page-level traffic, identify the strongest pitch angle per row ("links to competitor", "mentions brand without linking", "top-3 SERP", "resource page", "outdated content"), generate brand-voice-matched outreach emails using an outreach-type-aware template (unlinked-mention claim, competitor-link replacement, resource-page inclusion, outdated-content replacement, topical niche-edit), and propose a concrete in-article link placement as three artifacts — the verbatim source sentence, the same sentence rewritten with the link spliced in, or a fully-drafted new insertion if no natural fit exists. Use when user asks to find link building opportunities, prospect link partners, recover unlinked brand mentions, replace competitor links, build a tiered outreach list, or run cold email outreach for SEO link building.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Link Prospecting Outreach skill

What this skill tells your AI

The instructions your AI receives, as published by apify/awesome-skills in skills/apify-link-prospecting-outreach/SKILL.md and read by ahel’s review.

Turn a goal + a target keyword + a URL the user wants to promote into a tiered, ready-to-send outreach list: SERP-ranking prospects with Ahrefs-scored authority, the strongest pitch angle per prospect, an outreach-type-matched email draft, and a copy-paste-ready link placement.

Prerequisites

(No need to check it upfront)

  • .env file with APIFY_TOKEN
  • Ahrefs MCP available (the skill calls mcp__claude_ai_Ahrefs__* tools for prospect scoring)
  • Node.js 20.6+ (for native --env-file support)
  • One-time setup inside the skill's scripts/ folder: npm install

Helper scripts (one config, four steps)

After Step 1–2 inputs are collected, write them to a single campaign.json (schema in campaign.json.example). Every downstream script reads --config campaign.json, so the agent doesn't fork per-campaign copies. Sequence:

# 1. Run the Actor (writes {base}.json + sub-Actor sidecars when --fetch-sub-datasets)
node --env-file=.env scripts/run_actor.js --actor "apify/link-prospecting-tool" --input '<json>' --timeout 1800 --fetch-sub-datasets --output {base}.json --format json

# 2. Build unified prospect table from the sidecars
python3 scripts/build_prospects.py --config campaign.json

# 3. (After Step 5 Ahrefs MCP calls → save to {base}_ahrefs_domain.json + {base}_ahrefs_page.json)
python3 scripts/enrich_prospects.py --config campaign.json

# 4. (After Step 8 sub-agents write outputs to /tmp/placement_outputs/row_*.json)
python3 scripts/merge_subagent_outputs.py --config campaign.json --outputs-dir /tmp/placement_outputs

# 5. Write the final xlsx + metadata sidecar
python3 scripts/write_xlsx.py --config campaign.json

If the runner's client-side wait elapses with the Actor still running on Apify, use scripts/fetch_run_artifacts.js --run-id <id> --output {base}.json instead of restarting. If the parent run is missing SUB_ACTOR_RESULTS (post-2026-05-20 Actor schema), scripts/fetch_subactors_from_log.js resolves sub-Actor runIds from the parent log.

Workflow

Copy this checklist and track progress:

Task Progress:
- [ ] Step 1: Collect required anchor inputs incl. goal (block on these)
- [ ] Step 2: Collect brand voice, partnership type, output format
- [ ] Step 3: Run apify/link-prospecting-tool
- [ ] Step 4: Pull leads, mentions, authors, and sub-Actor datasets
- [ ] Step 5: Enrich every domain with Ahrefs metrics, assign Prospect Tier
- [ ] Step 6: Run skip pass — flag rows to drop before drafting
- [ ] Step 7: Compute "Why This Prospect" tag per surviving row
- [ ] Step 8: Compose per-row 3-artifact placement + outreach-type-aware email
- [ ] Step 9: Render output in chosen format

Step 1: Required Anchor Inputs (ask FIRST, before anything else)

Do NOT proceed to Step 2 until every required input is answered. Surface them as the very first interaction. The dedup input (#7) is optional but must still be explicitly asked.

  1. Concrete goal for this campaign — pick one preset or supply custom text. The goal drives skip-pass filtering, outreach-type template selection, and Prospect Tier thresholds. Required.

    PresetEffect downstream
    Recover unlinked brand mentionsSkip pass drops every row where brand_mentioned_in_source is false. Default outreach type = unlinked-mention-claim.
    Replace competitor linksSkip pass drops every row not tagged Links to competitor. Default outreach type = competitor-link-replacement.
    Topical authority links to specific URLNo filter. Tier thresholds tighten (DR ≥ 50 for tier A). Default outreach type chosen per-row from Why This Prospect.
    Maximum link volume from any relevant siteNo filter. Tier thresholds relax (DR ≥ 30 for tier A). Default outreach type chosen per-row.
    CustomUser-supplied paragraph; biases email tone and tier weights. No automatic skip filter.
  2. Target keyword(s) — one or more keywords the user wants their link to appear next to. The skill prospects the SERP for each. At least one required.

  3. Brand name — the user's brand or product name. The Actor will not run without this (it is the brand input field).

  4. Product/category description — one or two sentences describing what the user sells, who they sell to, and what category their product fits in. Example: "Apify — web scraping platform that runs serverless scrapers as APIs. We sell to developers and data teams who need scraped data without managing infrastructure." Required. Used in Step 6 (topical-fit gate) and Step 7 (adversarial-mention detection) to recognise prospects who are in the same product category — those won't link no matter the pitch. Without this, the skill cannot distinguish a genuine editorial opportunity from a competitor's blog.

  5. URL of content to link to — the destination URL that will be inserted into partner articles. Required.

  6. Competitors — anyone in the user's product category who would publish a "ours vs theirs" comparison page on their own site. Frame the ask this way explicitly: "List every company that would write an X-vs-YourBrand comparison page. These won't link to you no matter what — small competitors count too." Encourage 10+ entries; most users default to listing 3–5 obvious ones and miss the long tail. Mapped to competitorDomains on the Actor and reused in Steps 6 (adversarial-mention skip) and 7 (Links to competitor Why-tag).

    After the user answers, offer (do not push) an Ahrefs auto-pull of organic competitors: "Want me to pull your top organic competitors from Ahrefs and add them to this list? Adds ~50 API units and surfaces smaller competitors you may have missed." If the user says yes and Ahrefs MCP is available, call mcp__claude_ai_Ahrefs__site-explorer-organic-competitors on the user's domain (extracted from input #5) and merge results into competitorDomains. If Ahrefs is unavailable or the user declines, proceed with the user-supplied list only.

  7. Already-pitched domains (optional) — domains the user has already contacted in past campaigns. Accept a comma-separated list, a CSV/Sheet path, or "none". The skill drops these in the skip pass so the user doesn't double-pitch. Not required to proceed.

  8. Number of organic results per keyword — how many Google organic SERP results to prospect per keyword. Default 10 if the user is unsure, but ask the question so the user knows the lever exists. Mapped to organicResult.

  9. LLM sources to track — multi-select. Each enabled engine queries an additional AI search/chat surface and adds Google Search Scraper sub-Actor cost per result fetched. Default: all enabled. Mapping to Actor input flags:

    OptionActor flagCost impact
    ChatGPT SearchenableChatGptPer-result Google Search Scraper cost
    GeminienableGeminiPer-result Google Search Scraper cost
    Copilot (Microsoft / Bing)enableCopilotPer-result Google Search Scraper cost
    PerplexityenablePerplexityPer-result Google Search Scraper cost
    Google AI ModeenableAiModePer-result Google Search Scraper cost
    Google AI OverviewsenableAiOverviewsFree — parsed from the SERP already fetched. Keep on regardless of budget.

    Surface the multi-select to the user with all six pre-checked. Disabling individual engines is the main cost-cutting lever short of dropping organicResult — recommend keeping ChatGPT + Gemini on at minimum (they capture the largest share of LLM-driven discovery traffic in 2026).

  10. Run email verification? — boolean. Default: yes. Mapped to enableEmailVerification on the Actor. When enabled, the Actor verifies every email returned by the Contact Details Scraper sub-Actor and tags each lead with a verification status (verified / catch-all / risky / invalid / unknown). The skill uses the status in Step 6 (invalid emails get auto-skipped) and surfaces it as the Email Verification column in the output. Disable only if the user is rate-limited on verification quota or running cost-tight smoke tests.

Once 1–6 and 8–10 are captured (7 is optional), move on.

Step 2: Secondary Inputs

Ask these next:

  1. Brand info and voice — a short paragraph describing the product/brand and the tone for outreach (e.g., "casual and helpful", "formal B2B", "founder-led"). Used verbatim to shape every generated email.

  2. Partnership type — the offer the user is willing to make. Determines the offer paragraph substituted into the per-row email. Outreach-type template selection happens separately, per-row, in Step 8.

    OptionWhat it offers
    ABC link exchangeThree-way link swap: partner links to user, user links to a third party, third party links to partner.
    Direct A B link exchangeTwo-way link swap: partner links to user, user links to partner.
    Resource page / list inclusionAsk to be added to an existing curated list or roundup. No reciprocal link offered.
    Unilateral ask (no reciprocal)User asks for the link without offering anything in return — appropriate for unlinked-mention claims and broken-link replacements.
    OtherUser types their own offer (paid placement, free product, co-authored content, etc.).
  3. Output format:

    FormatBehavior
    xlsxrun_actor.js writes a styled spreadsheet to disk.
    markdownAgent renders the table inline in chat with email drafts beneath each row.

Step 3: Run the Actor

The Actor ID is apify/link-prospecting-tool. Full input schema lives in reference/apify-actor-usage.md.

Recommended call payload for this skill (defaults chosen for outreach-first workflow):

{
  "queries": "<keyword 1>\n<keyword 2>",
  "brand": "<user's brand name>",
  "ownDomains": ["<user-domain.com>"],
  "competitorDomains": [],
  "ignoreDomains": [
    "wikipedia.org", "github.com", "stackoverflow.com", "stackexchange.com",
    "reddit.com", "quora.com", "youtube.com", "twitter.com", "x.com",
    "linkedin.com", "facebook.com", "medium.com", "archive.org",
    "chromewebstore.google.com", "addons.mozilla.org", "apps.apple.com",
    "play.google.com", "microsoftedge.microsoft.com", "marketplace.visualstudio.com"
  ],
  "organicResult": 10,
  "maxContactsPerDomain": 3,
  "department": ["marketing"],
  "searchAuthorName": true,
  "includeMention": true,
  "enableChatGpt": true,
  "enableGemini": true,
  "enableCopilot": true,
  "enablePerplexity": true,
  "enableAiMode": true,
  "enableAiOverviews": true,
  "enableEmailVerification": true
}

The six enable* LLM-source flags map 1:1 to the user's Step 1 input #9 multi-select. Pass false for any engine the user deselected. enableEmailVerification maps to Step 1 input #10.

The ignoreDomains default includes two groups:

  • Giants and UGC (wikipedia, github, stackoverflow, reddit, etc.) — too broad to pitch as editorial partners.
  • App / extension marketplaces (Chrome Web Store, Firefox Add-ons, Apple/Google Play, VS Code Marketplace, etc.) — product directory listings, no editorial decision-makers.

Do NOT auto-add to ignoreDomains (let the user decide):

  • UGC/community sites like kaggle.com, dev.to, substack.com, producthunt.com, g2.com, capterra.com, trustpilot.com — some users get real value pitching these.
  • API directories like rapidapi.com, programmableweb.com, publicapis.dev — relevant for some products (especially developer-tool brands), irrelevant for others. Surface these as candidates only if the user wants to add them.

The URL-pattern skip rules in Step 6 catch the per-row noise (subdomain prefixes, path patterns) that ignoreDomains can't express.

department defaults to ["marketing"] only. The skill prioritises editorial-leaning contacts within the returned marketing department during row composition (see Step 8). Only add sales if the user explicitly wants BD-style partnership pitches. Only add c_suite if the prospect domains are very small (1–5 person shops) where the founder may also be the editor.

Call the runner script:

node --env-file=.env ${CLAUDE_PLUGIN_ROOT}/scripts/run_actor.js \
  --actor "apify/link-prospecting-tool" \
  --input 'JSON_INPUT' \
  --timeout 1800 \
  --fetch-sub-datasets \
  --output YYYY-MM-DD_outreach.json \
  --format json

Notes:

  • --timeout 1800 is the recommended client-side wait. The Actor itself runs 15-50+ min depending on keyword count, LLM-engine fan-out, and enableEmailVerification. Past calibration runs land in the 20–55 min range. Bumping the default avoids the partial-result situation where the runner gives up but the Actor keeps going.
  • If the client-side wait still elapses with the Actor still running on Apify (status RUNNING or READY when the runner exits), do not restart the Actor. Use scripts/fetch_run_artifacts.js --run-id <id> --output <file> to poll the existing run and download all artifacts — same output shape as run_actor.js --fetch-sub-datasets.
  • --fetch-sub-datasets downloads sibling files alongside the main output: *_mentions.json, *_authors.json, *_serp.json, *_wcc.json. You need all of them to populate every output column.

Step 4: Access All Datasets

The Actor's output schema changed on or before 2026-05-20. The build_prospects script must handle the new shape; older skill versions that joined a separate MENTIONS dataset are broken.

Current schema (verified 2026-05-20):

File written by runner / fetcherSourcePopulates
*_output.json (main)"All leads" datasetContact Full Name, Contact Job Title, Department, Seniority, Contact Email, Email Verification (when enableEmailVerification: true), Contact LinkedIn, Company, Domain. Each lead's source_url[] array contains the article URLs that produced this contact, each with a brand_mentioned_in_source boolean — this is the new home of the per-(URL, contact) mention data.
*_serp.jsonGoogle Search Results Scraper sub-Actor (one item per (query × engine) combination)SERP Position, Article Title, Publish Date (via organicResults[]), and engine attribution per URL (Google Organic, ChatGPT, Gemini, Copilot, Perplexity, Google AI Mode) by joining aiModeResult.sources[], perplexitySearchResult.sources[], chatGptSearchResult.sources[], geminiSearchResult.sources[], copilotSearchResult.sources[]. URLs from ChatGPT carry a ?utm_source=chatgpt.com query suffix — normalise URLs (strip tracking params) before joining.
*_wcc.jsonWebsite Content Crawler sub-ActorPlacement Source Sentence, Placement With Link, Placement New Insertion, Article Author cross-check, outbound-link inspection for Links to competitor and Resource / roundup page tags. Canonical URL list for building rows — every URL that got body-crawled appears here, including ones that didn't yield a lead.
*_authors.jsonAI Web Scraper sub-Actor (when searchAuthorName: true)Article Author, Author Source (set to searchAuthorName). Note: this sub-Actor frequently TIMES-OUT at its 300s default — partial results are still saved.

What changed (vs. pre-2026-05-20 runs):

  1. No separate MENTIONS / AUTHORS / DOMAINS_WITH_LEADS named datasets — mention info is folded into main_leads[i].source_url[].
  2. No SUB_ACTOR_RESULTS record in the parent run's key-value store. Sub-Actor runIds are now only discoverable from the parent run log via regex \[apify\.<slug> runId:([A-Za-z0-9]+)\]. The runner script's --fetch-sub-datasets flag now falls back to log-parsing when the KV index is missing; the standalone scripts/fetch_subactors_from_log.js does the same for runs whose runner already exited.
  3. The mentions schema reduced: source_url[i] carries only {domain, brand_mentioned_in_source, url} — no per-engine flags like the old ChatGPT_mention / Perplexity_mention. Engine attribution must be reconstructed from the SERP sub-dataset's LLM-result sub-fields (see SERP row above).

If a column's source is missing, write "Not found" and add a manual-lookup hint in Notes. Never fabricate.

Step 5: Ahrefs Enrichment and Prospect Tier

For every unique domain that survived the Actor's filtering, fetch authority and traffic metrics via Ahrefs MCP. Call all three tools in parallel per domain (and across domains — batch parallelise to keep this step under a minute for typical 20–50 prospect lists):

Ahrefs toolUsed forColumn it populates
mcp__claude_ai_Ahrefs__site-explorer-domain-rating (target = domain)Domain RatingDomain DR
mcp__claude_ai_Ahrefs__site-explorer-metrics (target = article URL, mode = exact)Page-level organic traffic (last 30 days)Page Traffic
mcp__claude_ai_Ahrefs__site-explorer-backlinks-stats (target = domain)Referring domains countReferring Domains

If Ahrefs returns no data (domain not indexed, page too new), set the column to "-" and add a Notes hint "Ahrefs has no data — verify manually before pitching". Do not fabricate values.

Assign Prospect Tier using the thresholds matching the user's goal:

GoalTier ATier BTier C
Topical authority links to specific URLDR ≥ 50 AND Page Traffic ≥ 300/moDR 30–49 OR Page Traffic 50–299everything below
Maximum link volume from any relevant siteDR ≥ 30 AND Page Traffic ≥ 100/moDR 15–29 OR Page Traffic 20–99everything below
Recover unlinked brand mentionsirrelevant — every mention is worth claiming; tier by DR alone (≥ 40 = A, 20–39 = B, < 20 = C)
Replace competitor linkstier by DR (≥ 50 = A, 30–49 = B, < 30 = C)
Customuse the Topical authority thresholds

Surface tier breakdown to the user before Step 8 — let them confirm whether to draft emails for all tiers or only A/B.

Step 6: Skip Pass

Before drafting any email, walk every row and apply skip rules. Skipped rows get Outreach Status = "Skip", a one-line reason in Notes, and no email or placement is generated (saves tokens and user review time).

Skip rules (in order):

  1. Goal mismatch. If the goal is Recover unlinked brand mentions and the row's Mentions data shows brand_mentioned_in_source: false, skip. If the goal is Replace competitor links and the row's WCC body has no outbound link to any competitorDomains entry, skip.

  2. Already pitched. If the row's domain matches an entry in the optional already-pitched list from Step 1 input #7, skip.

  3. Own / competitor domain leak. The Actor should already filter these, but double-check — if the row's domain matches ownDomains or competitorDomains, skip.

  4. Stale content. If Publish Date is older than 5 years, skip (low chance the editor will update the post).

  5. URL-pattern skip. Skip rows whose URL matches any of these patterns:

    • Subdomain prefixes: developers.*, docs.*, support.*, helpcenter.*, legacy.*, dsarequests.*, connectivity.*, community.*, dev.* (when used as a doc subdomain — e.g. dev.example.com/api/), api.* only when followed by a path that's clearly documentation (/reference/, /docs/, /spec/). Do NOT skip rapidapi.com or other API-directory domains by this rule alone — api.* is a subdomain check, not a substring check.
    • Path patterns: /api-docs/, /reference/, /marketplace/, /extensions/, /profile/, /users/, /free-tools/, /spec/, /content/privacy, /content/terms, /content/dma, /content/how_we_work, /legal/, /_redirects, /sitemap.
    • Vendor product page patterns: URL ends in -scraper.php, -scraping.php, contains -data-scraper., -data-scraping., /bots/, /extension/, /detail/ (extension detail pages).
  6. Non-editorial page type. Inspect the WCC page body. Skip vendor product pages, pricing pages, login walls, sign-up pages, terms/legal pages, and pages with fewer than 400 words of body text. Word count <400 is the threshold — most editorial articles are 800+ words.

  7. UGC slipped through. If the page URL contains /forum/, /thread/, /comments/, /answers/, /q/, /topic/, /discussion/, or the WCC body is structured as discussion replies, skip.

  8. Category-fit gate (loose). Extract 4–6 category keywords from the user's product description (Step 1 input #4) — these describe the product category, not the specific subject of the user's URL. Examples for a web-scraping product: scrape, scraping, scraper, crawl, extract, data extraction. For a CMS product: cms, headless, content, editorial. The row's WCC body must contain at least 1 of these category keywords. If not, skip with reason Article isn't in user's product category (no '<kw>' match) — kills recipe blogs, finance articles, and other off-category content that slipped through SERP filtering.

    For non-English campaigns, include both source-language and English keywords in the category set — many Czech/German/French articles cite English brand names and product categories inline. Example for a Czech water-filtration brand: {filtr, filtrace, vod, voda, filter, filtration, water}. A pure-Czech keyword set would miss articles by Czech authors who write in mixed CS/EN.

    Known false negatives this rule can't catch (the per-row sub-agent in Step 8 must catch them):

    • Local e-commerce competitors selling the exact same product category. Past campaigns have seen multiple regional e-shops survive the mechanical pass — typically platform-based stores (e.g. Shoptet, Shopify) with "add to cart" buttons embedded in the article body. The sub-agents correctly skipped them, but the wasted compute is a smell. Future versions of this rule should detect platform fingerprints (platform bundle URLs, locale-specific add-to-cart strings, /eshop/, embedded product cards with prices in body) and pre-skip.
    • Category-name homonyms. "filtr" in Czech also means "filter" in the photography or coffee sense — a coffee-filter or camera-filter blog would pass this gate but isn't a real fit. Sub-agent catches these by reading the body context.

    The category gate is intentionally loose. It is a category check, not a subject check — fine-grained "does this specific article fit my specific URL?" is delegated to the per-row sub-agent in Step 8. Example: for a user URL specifically about scraping a single travel site, a general "python web scraping" guide that never mentions that travel site passes this gate because it's in the user's category. The Step 8 sub-agent then decides whether to draft a placement (e.g., an additive line that names the specific travel site) or to recommend a content-based skip.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
252
Forks
65
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
apify-link-prospecting-outreach
Source
github.com/apify/awesome-skills