Brand Voice Extractor
Fetch a brand's published pages, strip them to clean body text, compute deterministic
style metrics (readability, sentence/paragraph length, POV pronoun mix, header/list
density), then you synthesize the Brand Voice Profile from that evidence. The scripts
do only I/O and measurement — all voice/tone judgement is yours.
When to use
- Before writing content/outreach/campaigns that must sound like an existing brand.
- "Extract brand voice for [company]" / "match [client]'s tone."
- Onboarding a new client with no voice/style guide.
- Upstream feeder for messaging, positioning, or content-generation skills.
How to run
Scripts are Python 3 stdlib-only (no install). The JS fallback needs Playwright.
1. (Optional) Discover candidate URLs from a blog/site root
When you only have a root URL, enumerate + classify candidates (sitemap first, then crawl):
python3 ${SKILL_DIR}/scripts/discover_urls.py \
--root https://acme.com/blog \
--max 200 \
--output ${WORKSPACE}/candidates.json
Then you select a diverse 10–20 sample from candidates.json — favor blog posts
(~8–10: how-to, opinion, product), 2–3 landing pages, 2–3 case studies, 1–2 comparison
pages; mix recent + older to catch voice evolution. Blog posts carry the strongest voice
signal; landing pages are formulaic — weight blogs.
2. Fetch + measure the chosen pages
python3 ${SKILL_DIR}/scripts/fetch_content.py \
--company "Acme" \
--urls https://acme.com/blog/a https://acme.com/blog/b ... \
--num-pages 15 \
--output ${WORKSPACE}/corpus.json
corpus.json has per-page {url, title, word_count, text, metrics{...}} plus a
corpus_metrics roll-up. Pages that come back as thin text (errors[].error mentions
"JS-rendered") should be re-fetched with the Playwright fallback:
# one-time: npx playwright install chromium (and: npm --prefix ${SKILL_DIR}/scripts install)
node ${SKILL_DIR}/scripts/fetch_content_js.mjs \
--company "Acme" \
--urls https://acme.com/blog/js-page ... \
--output ${WORKSPACE}/corpus-js.json
3. Synthesize the Brand Voice Profile (you, the agent)
Read corpus.json (+ any corpus-js.json) and write a Markdown Brand Voice Profile:
- Voice summary — 2–3 sentences capturing the overall voice.
- Tone profile table — formality, emotional register, authority stance, humor,
directness; attach a real quote from the corpus as evidence per dimension.
- Language & vocabulary — reading level (use
flesch_* from metrics), jargon level,
power words, signature phrases, words loved/avoided.
- Structure & formatting — sentence/paragraph length (from metrics), header/list/
emphasis density (from the
h/li/ul_ol/strong_em counts), CTA + callout habits.
- Audience / persona — target reader, knowledge assumptions, POV (use
pov_totals:
we/our vs I/me vs you to infer first-person-plural vs second-person address).
- Do / Don't writing guidelines + 2–3 "how to match it" sample sentences on a neutral
topic.
- Inconsistencies — flag content types whose metrics/tone diverge sharply (possible
multiple voice modes or ghost-writers) rather than averaging them away.
Outputs
corpus.json (and optional corpus-js.json) — deterministic fetch + metrics artifact.
- Brand Voice Profile Markdown (
brand-voice-profile-<company>.md) — your synthesis,
returned as the result and saved to the workspace; for team use, post to the Agent Teams
channel rather than an invented path.
Credentials / env
- Required: none. Page fetch is keyless (public HTTP); the keyless path is the default.
- Optional (paid upgrades, each with a keyless fallback):
APIFY_API_TOKEN — if set → route a too-hostile blog page through an Apify
article-extractor actor. If not set → default keyless path: fetch_content.py (stdlib),
then the Playwright fallback fetch_content_js.mjs. Apify is the last resort, never required.
DATAFORSEO_LOGIN/DATAFORSEO_PASSWORD (or SERPER_API_KEY/SEARCHAPI_API_KEY) — if set
→ use SERP to discover the brand's best content URLs (better recall on sites with no/partial
sitemap). If not set → default keyless discover_urls.py (sitemap.xml → same-host crawl).
- Voice/tone synthesis is done by you (the host agent) — no LLM key is consumed by the
scripts. See
env.optional.
Notes & edge cases
- 15 pages is the sweet spot: <10 misses variation, >25 adds cost without signal.
fetch_content.py flags thin pages (< --min-words, default 80) as likely JS-rendered —
escalate those to fetch_content_js.mjs before giving up.
discover_urls.py tries sitemap.xml (incl. sitemap index) first, falls back to crawling
same-host links on the root; it drops asset/tag/author/pagination URLs.
- All scripts back off on HTTP 429/503 and space requests (0.3s) to one domain. For heavy
fetching from a single host behind IP limits, route through a proxy at the platform layer
and/or reduce
--num-pages.
- Metrics are deterministic facts (readability, lengths, pronoun counts) — score the voice
from those real numbers, don't guess.
1---2name: brand-voice-extractor3description: Analyze a company's published content (blog posts, landing pages, case studies) and produce an actionable Brand Voice Profile — tone, vocabulary, sentence structure, formatting habits, and target persona — so later outreach, content, or campaigns sound like the existing brand. Deterministic scripts fetch and measure the corpus; you (the agent) write the voice profile. Keyless fetch.4---56# Brand Voice Extractor78Fetch a brand's published pages, strip them to clean body text, compute deterministic9style metrics (readability, sentence/paragraph length, POV pronoun mix, header/list10density), then **you synthesize** the Brand Voice Profile from that evidence. The scripts11do only I/O and measurement — all voice/tone judgement is yours.1213## When to use1415- Before writing content/outreach/campaigns that must sound like an existing brand.16- "Extract brand voice for [company]" / "match [client]'s tone."17- Onboarding a new client with no voice/style guide.18- Upstream feeder for messaging, positioning, or content-generation skills.1920## How to run2122Scripts are Python 3 stdlib-only (no install). The JS fallback needs Playwright.2324### 1. (Optional) Discover candidate URLs from a blog/site root2526When you only have a root URL, enumerate + classify candidates (sitemap first, then crawl):2728```bash29python3 ${SKILL_DIR}/scripts/discover_urls.py \30 --root https://acme.com/blog \31 --max 200 \32 --output ${WORKSPACE}/candidates.json33```3435Then **you select a diverse 10–20 sample** from `candidates.json` — favor blog posts36(~8–10: how-to, opinion, product), 2–3 landing pages, 2–3 case studies, 1–2 comparison37pages; mix recent + older to catch voice evolution. Blog posts carry the strongest voice38signal; landing pages are formulaic — weight blogs.3940### 2. Fetch + measure the chosen pages4142```bash43python3 ${SKILL_DIR}/scripts/fetch_content.py \44 --company "Acme" \45 --urls https://acme.com/blog/a https://acme.com/blog/b ... \46 --num-pages 15 \47 --output ${WORKSPACE}/corpus.json48```4950`corpus.json` has per-page `{url, title, word_count, text, metrics{...}}` plus a51`corpus_metrics` roll-up. Pages that come back as thin text (`errors[].error` mentions52"JS-rendered") should be re-fetched with the Playwright fallback:5354```bash55# one-time: npx playwright install chromium (and: npm --prefix ${SKILL_DIR}/scripts install)56node ${SKILL_DIR}/scripts/fetch_content_js.mjs \57 --company "Acme" \58 --urls https://acme.com/blog/js-page ... \59 --output ${WORKSPACE}/corpus-js.json60```6162### 3. Synthesize the Brand Voice Profile (you, the agent)6364Read `corpus.json` (+ any `corpus-js.json`) and write a Markdown **Brand Voice Profile**:65661. **Voice summary** — 2–3 sentences capturing the overall voice.672. **Tone profile table** — formality, emotional register, authority stance, humor,68 directness; attach a **real quote** from the corpus as evidence per dimension.693. **Language & vocabulary** — reading level (use `flesch_*` from metrics), jargon level,70 power words, signature phrases, words loved/avoided.714. **Structure & formatting** — sentence/paragraph length (from metrics), header/list/72 emphasis density (from the `h`/`li`/`ul_ol`/`strong_em` counts), CTA + callout habits.735. **Audience / persona** — target reader, knowledge assumptions, POV (use `pov_totals`:74 we/our vs I/me vs you to infer first-person-plural vs second-person address).756. **Do / Don't writing guidelines** + 2–3 "how to match it" sample sentences on a neutral76 topic.777. **Inconsistencies** — flag content types whose metrics/tone diverge sharply (possible78 multiple voice modes or ghost-writers) rather than averaging them away.7980## Outputs8182- `corpus.json` (and optional `corpus-js.json`) — deterministic fetch + metrics artifact.83- **Brand Voice Profile** Markdown (`brand-voice-profile-<company>.md`) — your synthesis,84 returned as the result and saved to the workspace; for team use, post to the Agent Teams85 channel rather than an invented path.8687## Credentials / env8889- **Required:** none. Page fetch is keyless (public HTTP); the keyless path is the default.90- **Optional (paid upgrades, each with a keyless fallback):**91 - `APIFY_API_TOKEN` — if set → route a too-hostile blog page through an Apify92 article-extractor actor. If not set → default keyless path: `fetch_content.py` (stdlib),93 then the Playwright fallback `fetch_content_js.mjs`. Apify is the last resort, never required.94 - `DATAFORSEO_LOGIN`/`DATAFORSEO_PASSWORD` (or `SERPER_API_KEY`/`SEARCHAPI_API_KEY`) — if set95 → use SERP to discover the brand's best content URLs (better recall on sites with no/partial96 sitemap). If not set → default keyless `discover_urls.py` (sitemap.xml → same-host crawl).97- Voice/tone synthesis is done by you (the host agent) — no LLM key is consumed by the98 scripts. See `env.optional`.99100## Notes & edge cases101102- 15 pages is the sweet spot: <10 misses variation, >25 adds cost without signal.103- `fetch_content.py` flags thin pages (`< --min-words`, default 80) as likely JS-rendered —104 escalate those to `fetch_content_js.mjs` before giving up.105- `discover_urls.py` tries `sitemap.xml` (incl. sitemap index) first, falls back to crawling106 same-host links on the root; it drops asset/tag/author/pagination URLs.107- All scripts back off on HTTP 429/503 and space requests (0.3s) to one domain. For heavy108 fetching from a single host behind IP limits, route through a proxy at the platform layer109 and/or reduce `--num-pages`.110- Metrics are deterministic facts (readability, lengths, pronoun counts) — score the voice111 from those real numbers, don't guess.