Media Press Discovery
Build a per-journalist press outreach database from media outlet domains, anchored on Muckrack's public outlet pages, with a fetcher that handles Cloudflare reality.
🔴 Guessed emails are not contacts (read before you send anything)
guess_emails.py does exactly what its name says: it pattern-guesses (firstname.lastname@outlet.com and friends) from a name and a domain. A guess is a hypothesis, not a contact.
- 🔴 Never send to a row whose
email_verified is unverified. Verify first (NeverBounce / ZeroBounce / an MX+SMTP probe), or find the address on the journalist's own byline page, outlet masthead or public profile.
- Why this is not pedantry — these are named, real journalists. A wrong guess lands one of three ways, and all three are bad:
- Bounce → repeated hard bounces damage your sending domain's reputation, which quietly degrades every future campaign
- A different real person at that outlet reads a pitch meant for someone else — worse than not sending
- Spam trap → some outlet domains run them; a hit can blocklist your domain
- The verified column is a gate, not a note. Export/segment on it:
verified and smtp_ok are sendable, unverified goes to a manual-research queue.
- 🔴 Never invent or "reconstruct" a journalist name, byline, article URL or publish date to fill out the DB. Every row must trace to a page actually fetched in this run. Muckrack lags 2-8 weeks and journalists move — an unfetchable outlet is an
❌ not retrieved row, not a row to be filled in from memory.
- Compliance: cold press outreach must still identify you honestly and offer a way to stop hearing from you (CAN-SPAM; GDPR legitimate-interest if the journalist is in the EU/UK). Repo-wide rule: do not disguise a pitch as something it isn't.
Cloudflare Reality (read this first)
Muckrack sits behind Cloudflare. As of 2026-05, the following programmatic approaches fail:
requests / curl_cffi → 403 (Cloudflare 5-sec JS challenge)
- Headless Selenium → blocked by
navigator.webdriver detection
undetected-chromedriver → version-skew / Apple Silicon binary issues + Cloudflare detection updates
- Connecting Selenium to a freshly-launched Chrome with empty profile → no
cf_clearance cookie, fails challenge
What works:
--via html-dir (recommended for single-time use): user opens each outlet page in their normal browser (which has a valid cf_clearance), saves HTML, runs the parser against the directory. Works on any platform, $0, manual save step ~10s per outlet.
--via remote-chrome (for repeat use): user launches their normal Chrome with --remote-debugging-port=9222, browses muckrack.com once to obtain cf_clearance, then runs script which drives the same Chrome session. Faster than html-dir for repeat runs.
--via apify (paid, ~$0.001/page): Apify's Web Scraper actor handles Cloudflare in their infra. Set APIFY_TOKEN env var. Best for scale (100+ outlets).
The pipeline supports all three; choose based on scale.
Quick Start (recommended html-dir flow)
# 1. Open each outlet page in your normal browser, save HTML to a dir
# Naming convention: muckrack.com_media-outlet_<slug>.html
# For ~25 Tier A outlets, this is 5-10 minutes of save-as.
mkdir -p press_html
# (manually save pages: muckrack.com/media-outlet/teenvogue → press_html/muckrack.com_media-outlet_teenvogue.html, etc.)
# 2. Run pipeline
cp templates/outlets_template.txt outlets.txt
cp templates/keywords_template.txt keywords.txt
python3 scripts/discover_journalists.py outlets.txt --via html-dir --html-dir press_html --out journalists.jsonl
# now ALSO save each journalist's /articles page (one HTML per journalist)
# (or use remote-chrome / apify for this step — see references/multi_machine.md)
python3 scripts/find_articles.py journalists.jsonl --keywords keywords.txt --via html-dir --html-dir press_html --out articles.jsonl
# --verify is NOT optional if you intend to send. Without it every row lands as `unverified`.
# Free path: SMTP MX probe (~60% accurate per the script's own docstring).
# Better: set HUNTER_API_KEY and the script uses Hunter instead of the raw probe.
python3 scripts/guess_emails.py journalists.jsonl --out emails.csv --verify
python3 scripts/score_and_export.py journalists.jsonl articles.jsonl emails.csv \
--backlinks /path/to/kol_prospects.csv \
--out pitch_db.csv
🔴 Before any outreach, split pitch_db.csv on email_verified: verified / smtp_ok are sendable; unverified goes to a manual-research queue, not to the mailer. The SMTP probe is ~60% accurate, so smtp_fail is "unproven", not "proven wrong" — send those to manual research too, don't delete the journalist.
Quick Start (remote-chrome flow, faster repeat use)
# 1. Quit Chrome, then relaunch with debug port:
# macOS:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 &
# 2. Manually browse to muckrack.com once and verify pages load (this seeds cf_clearance cookie)
# 3. Run pipeline — scripts will drive YOUR Chrome session
python3 scripts/discover_journalists.py outlets.txt --via remote-chrome --port 9222 --out journalists.jsonl
python3 scripts/find_articles.py journalists.jsonl --keywords keywords.txt --via remote-chrome --port 9222 --out articles.jsonl
python3 scripts/guess_emails.py journalists.jsonl --out emails.csv --verify
python3 scripts/score_and_export.py journalists.jsonl articles.jsonl emails.csv --out pitch_db.csv
Multi-machine fan-out: references/multi_machine.md.
Inputs
| Required |
Description |
Example |
outlets.txt |
One outlet per line. Format: muckrack_slug,outlet_domain |
teenvogue,teenvogue.com |
keywords.txt |
Topic keywords for relevance filter (one per line) |
[your category keyword 1][your category keyword 2] |
Optional:
kol_prospects.csv from sister skill tools/backlink-kol-extractor (boosts journalists at outlets that competitors got linked from).
Output Schema
pitch_db.csv (sorted by relevance_score desc):
| Column |
Type |
Description |
outlet |
str |
Muckrack slug |
outlet_domain |
str |
Outlet root domain |
journalist |
str |
Full name |
muckrack_url |
str |
Profile URL |
topic_match_count |
int |
# of bylined articles whose title matched topic keywords |
last_topic_article_url |
str |
URL of most recent in-topic article |
last_topic_article_date |
str (ISO) |
Publish date of that article |
relevance_score |
int |
0-100 — see references/methodology.md |
email_1, email_2, email_3 |
str |
Pattern-guessed candidates — hypotheses, not contacts. Never send without verifying |
email_verified |
str |
verified / smtp_ok = sendable · smtp_fail / unverified = manual-research queue. 🔴 This column is a send gate, not a note |
When to use
- New brand pre-launch (4-week+ runway), need to seed earned media.
- Have a Tier A outlet list (e.g., from
tools/backlink-kol-extractor cross-validation).
- Want a structured outreach DB (journalist-level, not just outlet-level).
When NOT to use
- Influencer/KOL discovery → use
tools/backlink-kol-extractor (different anchor: backlinks not bylines).
- Pitch personalization at scale → out of scope (no LLM personalizer here; do it by hand or use a sister skill).
- Real-time editor changes / outlet hires → Muckrack lags 2-8 weeks; don't expect cutting-edge.
References
references/methodology.md — full 5-step playbook
references/multi_machine.md — partition + merge workflow
references/tool_choices.md — why this stack, what we tried, what failed
1---2name: media-press-discovery3description: Media Press Discovery4---56# Media Press Discovery78Build a per-journalist press outreach database from media outlet domains, anchored on Muckrack's public outlet pages, with a fetcher that handles Cloudflare reality.910## 🔴 Guessed emails are not contacts (read before you send anything)1112`guess_emails.py` does exactly what its name says: it **pattern-guesses** (`firstname.lastname@outlet.com` and friends) from a name and a domain. A guess is a hypothesis, not a contact.1314- 🔴 **Never send to a row whose `email_verified` is `unverified`.** Verify first (NeverBounce / ZeroBounce / an MX+SMTP probe), or find the address on the journalist's own byline page, outlet masthead or public profile.15- **Why this is not pedantry** — these are named, real journalists. A wrong guess lands one of three ways, and all three are bad:16 1. **Bounce** → repeated hard bounces damage your sending domain's reputation, which quietly degrades every future campaign17 2. **A different real person** at that outlet reads a pitch meant for someone else — worse than not sending18 3. **Spam trap** → some outlet domains run them; a hit can blocklist your domain19- **The verified column is a gate, not a note.** Export/segment on it: `verified` and `smtp_ok` are sendable, `unverified` goes to a manual-research queue.20- 🔴 **Never invent or "reconstruct" a journalist name, byline, article URL or publish date** to fill out the DB. Every row must trace to a page actually fetched in this run. Muckrack lags 2-8 weeks and journalists move — an unfetchable outlet is an `❌ not retrieved` row, not a row to be filled in from memory.21- **Compliance:** cold press outreach must still identify you honestly and offer a way to stop hearing from you (CAN-SPAM; GDPR legitimate-interest if the journalist is in the EU/UK). Repo-wide rule: do not disguise a pitch as something it isn't.2223## Cloudflare Reality (read this first)2425Muckrack sits behind Cloudflare. As of 2026-05, the following programmatic approaches **fail**:26- `requests` / `curl_cffi` → 403 (Cloudflare 5-sec JS challenge)27- Headless Selenium → blocked by `navigator.webdriver` detection28- `undetected-chromedriver` → version-skew / Apple Silicon binary issues + Cloudflare detection updates29- Connecting Selenium to a freshly-launched Chrome with empty profile → no `cf_clearance` cookie, fails challenge3031What **works**:321. **`--via html-dir`** (recommended for single-time use): user opens each outlet page in their normal browser (which has a valid `cf_clearance`), saves HTML, runs the parser against the directory. Works on any platform, $0, manual save step ~10s per outlet.332. **`--via remote-chrome`** (for repeat use): user launches their normal Chrome with `--remote-debugging-port=9222`, browses muckrack.com once to obtain `cf_clearance`, then runs script which drives the same Chrome session. Faster than html-dir for repeat runs.343. **`--via apify`** (paid, ~$0.001/page): Apify's Web Scraper actor handles Cloudflare in their infra. Set `APIFY_TOKEN` env var. Best for scale (100+ outlets).3536The pipeline supports all three; choose based on scale.3738## Quick Start (recommended html-dir flow)3940```bash41# 1. Open each outlet page in your normal browser, save HTML to a dir42# Naming convention: muckrack.com_media-outlet_<slug>.html43# For ~25 Tier A outlets, this is 5-10 minutes of save-as.44mkdir -p press_html45# (manually save pages: muckrack.com/media-outlet/teenvogue → press_html/muckrack.com_media-outlet_teenvogue.html, etc.)4647# 2. Run pipeline48cp templates/outlets_template.txt outlets.txt49cp templates/keywords_template.txt keywords.txt5051python3 scripts/discover_journalists.py outlets.txt --via html-dir --html-dir press_html --out journalists.jsonl52# now ALSO save each journalist's /articles page (one HTML per journalist)53# (or use remote-chrome / apify for this step — see references/multi_machine.md)54python3 scripts/find_articles.py journalists.jsonl --keywords keywords.txt --via html-dir --html-dir press_html --out articles.jsonl55# --verify is NOT optional if you intend to send. Without it every row lands as `unverified`.56# Free path: SMTP MX probe (~60% accurate per the script's own docstring).57# Better: set HUNTER_API_KEY and the script uses Hunter instead of the raw probe.58python3 scripts/guess_emails.py journalists.jsonl --out emails.csv --verify59python3 scripts/score_and_export.py journalists.jsonl articles.jsonl emails.csv \60 --backlinks /path/to/kol_prospects.csv \61 --out pitch_db.csv62```6364> 🔴 Before any outreach, split `pitch_db.csv` on `email_verified`: `verified` / `smtp_ok` are sendable; **`unverified` goes to a manual-research queue, not to the mailer.** The SMTP probe is ~60% accurate, so `smtp_fail` is "unproven", not "proven wrong" — send those to manual research too, don't delete the journalist.6566## Quick Start (remote-chrome flow, faster repeat use)6768```bash69# 1. Quit Chrome, then relaunch with debug port:70# macOS:71"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 &7273# 2. Manually browse to muckrack.com once and verify pages load (this seeds cf_clearance cookie)7475# 3. Run pipeline — scripts will drive YOUR Chrome session76python3 scripts/discover_journalists.py outlets.txt --via remote-chrome --port 9222 --out journalists.jsonl77python3 scripts/find_articles.py journalists.jsonl --keywords keywords.txt --via remote-chrome --port 9222 --out articles.jsonl78python3 scripts/guess_emails.py journalists.jsonl --out emails.csv --verify79python3 scripts/score_and_export.py journalists.jsonl articles.jsonl emails.csv --out pitch_db.csv80```8182Multi-machine fan-out: `references/multi_machine.md`.8384## Inputs8586| Required | Description | Example |87|---|---|---|88| `outlets.txt` | One outlet per line. Format: `muckrack_slug,outlet_domain` | `teenvogue,teenvogue.com` |89| `keywords.txt` | Topic keywords for relevance filter (one per line) | `[your category keyword 1]`<br>`[your category keyword 2]` |9091Optional:92- `kol_prospects.csv` from sister skill `tools/backlink-kol-extractor` (boosts journalists at outlets that competitors got linked from).9394## Output Schema9596`pitch_db.csv` (sorted by `relevance_score` desc):9798| Column | Type | Description |99|---|---|---|100| `outlet` | str | Muckrack slug |101| `outlet_domain` | str | Outlet root domain |102| `journalist` | str | Full name |103| `muckrack_url` | str | Profile URL |104| `topic_match_count` | int | # of bylined articles whose title matched topic keywords |105| `last_topic_article_url` | str | URL of most recent in-topic article |106| `last_topic_article_date` | str (ISO) | Publish date of that article |107| `relevance_score` | int | 0-100 — see `references/methodology.md` |108| `email_1`, `email_2`, `email_3` | str | **Pattern-guessed candidates — hypotheses, not contacts.** Never send without verifying |109| `email_verified` | str | `verified` / `smtp_ok` = sendable · `smtp_fail` / `unverified` = manual-research queue. 🔴 This column is a **send gate**, not a note |110111## When to use112113- New brand pre-launch (4-week+ runway), need to seed earned media.114- Have a Tier A outlet list (e.g., from `tools/backlink-kol-extractor` cross-validation).115- Want a structured outreach DB (journalist-level, not just outlet-level).116117## When NOT to use118119- Influencer/KOL discovery → use `tools/backlink-kol-extractor` (different anchor: backlinks not bylines).120- Pitch personalization at scale → out of scope (no LLM personalizer here; do it by hand or use a sister skill).121- Real-time editor changes / outlet hires → Muckrack lags 2-8 weeks; don't expect cutting-edge.122123## References124125- `references/methodology.md` — full 5-step playbook126- `references/multi_machine.md` — partition + merge workflow127- `references/tool_choices.md` — why this stack, what we tried, what failed