mirror of
https://github.com/OpenClaudia/openclaudia-skills.git
synced 2026-09-19 08:06:49 +08:00
Add competitor-traffic-report skill
Benchmarks any domain against its competitors on three metrics that answer different questions: SimilarWeb monthly visits (all channels), Ahrefs organic traffic (search only) and Ahrefs Domain Rating (backlink authority). Renders one self-contained HTML page — ranked dot plots, a three-month indexed trend, a DR bar chart and a multi-year log overlay, all filterable. Documents the constraints that are expensive to rediscover: SimilarWeb's endpoint needs an X-Extension-Version header and rate-limits per IP, its root-domain figure already includes subdomains, a returned 0 means "below detection floor" rather than "no traffic", and Ahrefs domain-mode silently drops the www. host. Also carries the Ahrefs DR licence attribution the template must keep when the report is published. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: competitor-traffic-report
|
||||
description: Build an interactive competitive-traffic report for any company and its rivals — monthly visits (SimilarWeb), organic search traffic and Domain Rating (Ahrefs) — as one self-contained HTML page with ranked charts, a three-month trend, a multi-year overlay and a data table. Use when asked to benchmark a product against competitors, size up a market, answer "how much traffic does X get compared to Y", build a competitive landscape, or produce a traffic/authority comparison deck or page.
|
||||
---
|
||||
|
||||
# competitor-traffic-report
|
||||
|
||||
Three metrics, three different questions. Do not average them or treat one as a proxy for another:
|
||||
|
||||
| Metric | Source | Answers |
|
||||
|---|---|---|
|
||||
| Monthly visits | SimilarWeb | total demand, every channel |
|
||||
| Organic traffic | Ahrefs | how much of it search is producing |
|
||||
| Domain Rating | Ahrefs | backlink authority — the structural position |
|
||||
|
||||
Visits and organic move on a single viral page. **DR does not**, which is why a gap in DR says
|
||||
more about where a company actually sits than a good traffic month does.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
export AHREFS_API_KEY=... # required
|
||||
export BRANDDEV_API_KEY=... # optional, for logos
|
||||
S=skills/competitor-traffic-report/scripts
|
||||
|
||||
node $S/fetch-traffic.js acme.com rival1.com,rival2.com,rival3.com --out /tmp/data.json
|
||||
node $S/build-report.js /tmp/data.json "AI presentation tools, ranked" --out /tmp/report.html
|
||||
```
|
||||
|
||||
Open the HTML, or publish it with the **Artifact** tool. It is self-contained — logos inlined as
|
||||
data URIs, no external requests — so it survives a strict CSP.
|
||||
|
||||
The focus domain is coloured orange and ranked in every chart; competitors take a five-step blue
|
||||
ramp ordered by rank, so colour carries magnitude instead of being decoration.
|
||||
|
||||
## Choosing the competitor set
|
||||
|
||||
Whoever asked usually names three or four. Fill the set out to **20–40** before running: a
|
||||
five-company chart cannot show where anyone sits, and the interesting findings (how much of the
|
||||
market one player holds, how many are shrinking) need the tail. Sources for names, cheapest first:
|
||||
|
||||
- the client's own "alternatives to X" and comparison pages;
|
||||
- an `X alternatives` / `best X tools` SERP, read deep enough to reach the top 100;
|
||||
- Ahrefs `competitors_overview` for domains ranking on the same keywords.
|
||||
|
||||
Drop anything that is not a real peer: a general-purpose giant (Adobe, Google) dominates every
|
||||
chart while competing for none of the same buyers, and its presence compresses everyone else into
|
||||
the bottom decile. Same for a friendly company the requester does not consider a rival — ask.
|
||||
|
||||
**Subdomains are already counted.** SimilarWeb's root-domain figure includes them, so listing both
|
||||
`acme.com` and `app.acme.com` double-counts and buries the real number. (Verified: wikipedia.org
|
||||
3.53B vs en.wikipedia.org 0.88B.) List the root only.
|
||||
|
||||
## Constraints worth knowing before the first run
|
||||
|
||||
- **SimilarWeb rate-limits by IP**, roughly 15 calls per window, and the block lasts hours. The
|
||||
script paces at 15s and still misses on a long list — rerun to fill gaps, or spread a 40-domain
|
||||
set across two sessions. The `X-Extension-Version` header is mandatory; a browser User-Agent
|
||||
alone returns 403.
|
||||
- **A returned `0` means "below SimilarWeb's detection floor", not "no traffic".** The template
|
||||
drops those from the charts rather than ranking a live site last. For a domain you own, read
|
||||
Search Console instead.
|
||||
- **Ahrefs organic uses `mode=subdomains`.** Domain mode silently excludes the `www.` host, which
|
||||
reads as an empty site for anyone whose canonical is `www.`.
|
||||
- **The free DR endpoint costs no API units.** Organic history does — one row per month per domain.
|
||||
|
||||
## Publishing the report
|
||||
|
||||
The Ahrefs licence (`ahrefs.com/legal/domain-rating-license`) grants a royalty-free right to
|
||||
display and publish DR data **provided** the attribution *Domain Rating by [Ahrefs](https://ahrefs.com/)*
|
||||
appears next to it with a live link. The template ships that line; do not remove it. The same
|
||||
licence prohibits reselling the data or using it to build something that competes with Ahrefs.
|
||||
|
||||
## Reading the result
|
||||
|
||||
The report shows position. The story is usually in a gap between two of the three metrics:
|
||||
|
||||
- **Organic rank far above visits rank** — search is carrying more than its share; paid, social or
|
||||
direct is the thin part.
|
||||
- **DR far above organic** — the authority is there and the content is not converting it. This is
|
||||
the most actionable gap, because it is a content problem, not a link-building one.
|
||||
- **DR far below peers at similar traffic** — the traffic is bought or borrowed, and it stops the
|
||||
moment spend stops.
|
||||
- **A near-identical name at a fraction of the DR** (`brand.app` at DR 87 vs `brand.com.ai` at 38)
|
||||
is a copycat domain, not a competitor.
|
||||
|
||||
State the denominator and the window beside any figure taken from this report. Both sources are
|
||||
modelled estimates, least reliable exactly where it matters most — the small end.
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// Turn fetch-traffic.js output into one self-contained HTML report.
|
||||
// node build-report.js data.json "Report title" [--out report.html]
|
||||
// Logos are downloaded and inlined as data: URIs — the page must work with no
|
||||
// network at all (a published artifact's CSP blocks every external host).
|
||||
const fs = require('fs'), path = require('path'), { execFileSync } = require('child_process')
|
||||
|
||||
const [dataPath, title] = process.argv.slice(2).filter(a => !a.startsWith('--'))
|
||||
if (!dataPath) { console.error('usage: build-report.js <data.json> ["title"] [--out report.html]'); process.exit(1) }
|
||||
const oi = process.argv.indexOf('--out')
|
||||
const OUT = oi >= 0 ? process.argv[oi + 1] : 'report.html'
|
||||
|
||||
const D = JSON.parse(fs.readFileSync(dataPath, 'utf8'))
|
||||
const TITLE = title || `${D.focus} vs competitors — traffic and authority`
|
||||
|
||||
// ponytail: shells out to curl + magick rather than pulling image libs. Both are
|
||||
// already required elsewhere in this workflow; a missing logo just degrades to a swatch.
|
||||
const logos = {}
|
||||
for (const [d, url] of Object.entries(D.logo_urls || {})) {
|
||||
const tmp = path.join('/tmp', `lg_${d}`)
|
||||
try {
|
||||
execFileSync('curl', ['-sL', '--noproxy', '*', '--max-time', '30', '-o', tmp + '.src',
|
||||
'-H', 'User-Agent: Mozilla/5.0', url])
|
||||
execFileSync('magick', ['-background', 'none', tmp + '.src[0]', '-resize', '48x48',
|
||||
'-gravity', 'center', '-extent', '48x48', '-strip', tmp + '.png'])
|
||||
logos[d] = 'data:image/png;base64,' + fs.readFileSync(tmp + '.png').toString('base64')
|
||||
} catch { /* no logo for this one */ }
|
||||
}
|
||||
|
||||
const tpl = fs.readFileSync(path.join(__dirname, '..', 'templates', 'report.html'), 'utf8')
|
||||
const blob = `const DATA=${JSON.stringify(D)};const LOGOS=${JSON.stringify(logos)};`
|
||||
fs.writeFileSync(OUT, tpl.replace(/__TITLE__/g, TITLE).replace('__DATA__', blob))
|
||||
console.log(`${OUT} — ${Object.keys(D.sw).length} products, ${Object.keys(logos).length} logos, ${Math.round(fs.statSync(OUT).size / 1024)} KB`)
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
// Pull traffic + authority metrics for one focus domain and its competitors.
|
||||
// node fetch-traffic.js <focus.com> <comp1.com,comp2.com,...> [--out data.json]
|
||||
//
|
||||
// Three metrics, three sources, deliberately not interchangeable:
|
||||
// total visits SimilarWeb all traffic, any channel, 3 months
|
||||
// organic Ahrefs search-only estimate, ~30 months of history
|
||||
// DR Ahrefs 0-100 backlink authority, a single current value
|
||||
//
|
||||
// Needs AHREFS_API_KEY. Logos need BRANDDEV_API_KEY (optional; omit and the report
|
||||
// falls back to a colour swatch).
|
||||
const fs = require('fs'), { execFileSync } = require('child_process')
|
||||
|
||||
const [focus, listArg] = process.argv.slice(2).filter(a => !a.startsWith('--'))
|
||||
if (!focus || !listArg) { console.error('usage: fetch-traffic.js <focus.com> <comp1,comp2,...> [--out data.json]'); process.exit(1) }
|
||||
const oi = process.argv.indexOf('--out')
|
||||
const OUT = oi >= 0 ? process.argv[oi + 1] : 'data.json'
|
||||
const DOMAINS = [...new Set([focus, ...listArg.split(',').map(s => s.trim()).filter(Boolean)])]
|
||||
|
||||
const AH = process.env.AHREFS_API_KEY
|
||||
if (!AH) { console.error('AHREFS_API_KEY not set'); process.exit(1) }
|
||||
const BD = process.env.BRANDDEV_API_KEY
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
// curl, not fetch: a proxy in the environment turns these into 403s and 405s that
|
||||
// look like auth failures. --noproxy sidesteps it.
|
||||
const curl = (url, ...h) => execFileSync('curl',
|
||||
['-sL', '--noproxy', '*', '--max-time', '45', ...h.flatMap(x => ['-H', x]), url], { maxBuffer: 1 << 26 })
|
||||
|
||||
// --- SimilarWeb: total visits, last 3 months -------------------------------
|
||||
// The X-Extension-Version header is what gets past CloudFront; a browser User-Agent
|
||||
// alone 403s. The endpoint also rate-limits BY IP at roughly 15 calls per window, so
|
||||
// this paces at 15s and still expects misses on a long list — rerun to fill them.
|
||||
async function similarweb(domain) {
|
||||
const h = ['Content-Type: application/json', 'X-Extension-Version: 6.12.21', `User-Agent: ${UA}`]
|
||||
const raw = curl(`https://data.similarweb.com/api/v1/data?domain=${encodeURIComponent(domain)}`, ...h)
|
||||
const j = JSON.parse(raw)
|
||||
const v = j.EstimatedMonthlyVisits || {}
|
||||
if (!Object.keys(v).length) throw new Error('no EstimatedMonthlyVisits')
|
||||
return Object.fromEntries(Object.entries(v).map(([d, n]) => [d.slice(0, 7), Math.round(n / 10) / 100]))
|
||||
}
|
||||
|
||||
// --- Ahrefs -----------------------------------------------------------------
|
||||
const ahrefs = (path, qs) => JSON.parse(curl(`https://api.ahrefs.com/v3/${path}?${qs}`, `Authorization: Bearer ${AH}`))
|
||||
|
||||
// The free public DR endpoint does not consume API units. Its licence permits
|
||||
// publishing the value provided the report shows "Domain Rating by Ahrefs" with a
|
||||
// live link next to it — the template already does.
|
||||
const domainRating = (d) =>
|
||||
ahrefs('public/domain-rating-free', `date=${today}&target=${encodeURIComponent(d)}`).domain_rating.domain_rating
|
||||
|
||||
// Organic history. This one DOES spend Ahrefs units, one row per month requested.
|
||||
function organic(domain, months = 30) {
|
||||
const end = new Date(), start = new Date(end.getFullYear(), end.getMonth() - months, 1)
|
||||
const j = ahrefs('site-explorer/metrics-history',
|
||||
`target=${encodeURIComponent(domain)}&date_from=${start.toISOString().slice(0, 10)}` +
|
||||
`&history_grouping=monthly&volume_mode=monthly&mode=subdomains&select=date,org_traffic`)
|
||||
// mode=subdomains, not domain: domain-mode silently excludes the www. host, which
|
||||
// reads as a near-empty site for anyone whose canonical is www.
|
||||
return Object.fromEntries((j.metrics || []).map(m => [m.date.slice(0, 7), Math.round(m.org_traffic / 10) / 100]))
|
||||
}
|
||||
|
||||
// --- logos (optional) -------------------------------------------------------
|
||||
function logo(domain) {
|
||||
if (!BD) return null
|
||||
try {
|
||||
const j = JSON.parse(curl(`https://api.brand.dev/v1/brand/retrieve?domain=${domain}`, `Authorization: Bearer ${BD}`))
|
||||
const ls = (j.brand || {}).logos || []
|
||||
const pick = ls.find(l => l.type === 'icon') || ls.find(l => l.type === 'symbol') || ls[0]
|
||||
return pick ? pick.url : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
const out = { focus, generated_at: new Date().toISOString(), sw: {}, ah: {}, dr: {}, logo_urls: {}, misses: [] }
|
||||
for (const d of DOMAINS) {
|
||||
const row = []
|
||||
try { out.sw[d] = await similarweb(d); row.push('visits') }
|
||||
catch (e) { out.misses.push(`${d} visits: ${e.message}`) }
|
||||
try { out.dr[d] = domainRating(d); row.push('DR') } catch (e) { out.misses.push(`${d} DR: ${e.message}`) }
|
||||
try { out.ah[d] = organic(d); row.push('organic') } catch (e) { out.misses.push(`${d} organic: ${e.message}`) }
|
||||
const l = logo(d); if (l) out.logo_urls[d] = l
|
||||
console.log(` ${d.padEnd(24)} ${row.join(' ') || 'nothing'}`)
|
||||
await sleep(15000) // SimilarWeb's per-IP window; everything else is far cheaper
|
||||
}
|
||||
fs.writeFileSync(OUT, JSON.stringify(out))
|
||||
console.log(`\n${Object.keys(out.sw).length}/${DOMAINS.length} with visits, ${Object.keys(out.dr).length} with DR -> ${OUT}`)
|
||||
if (out.misses.length) console.log('misses:\n ' + out.misses.join('\n '))
|
||||
})()
|
||||
@@ -0,0 +1,307 @@
|
||||
<title>__TITLE__</title>
|
||||
<style>
|
||||
.viz-root{
|
||||
color-scheme:light;
|
||||
--surface-1:#fcfcfb; --surface-2:#f4f3f0; --border:#dedcd6;
|
||||
--text-primary:#0b0b0b; --text-secondary:#52514e; --text-muted:#83817a;
|
||||
--me:#eb6834; --up:#1baf7a; --grid:#e8e6e1;
|
||||
/* one hue, five steps, assigned by rank. Ten steps fail the adjacent-lightness
|
||||
check; five pass in both modes. Stays clear of the orange reserved for focus. */
|
||||
--b0:#86b6ef;--b1:#5598e7;--b2:#2a78d6;--b3:#1c5cab;--b4:#104281;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root:where(:not([data-theme="light"])) .viz-root{
|
||||
color-scheme:dark;
|
||||
--surface-1:#1a1a19; --surface-2:#232322; --border:#3a3a37;
|
||||
--text-primary:#fff; --text-secondary:#c3c2b7; --text-muted:#8e8d84;
|
||||
--me:#d95926; --up:#199e70; --grid:#2e2e2c;
|
||||
--b0:#cde2fb;--b1:#9ec5f4;--b2:#6da7ec;--b3:#3987e5;--b4:#256abf;
|
||||
}}
|
||||
:root[data-theme="dark"] .viz-root{
|
||||
color-scheme:dark;
|
||||
--surface-1:#1a1a19; --surface-2:#232322; --border:#3a3a37;
|
||||
--text-primary:#fff; --text-secondary:#c3c2b7; --text-muted:#8e8d84;
|
||||
--me:#d95926; --up:#199e70; --grid:#2e2e2c;
|
||||
--b0:#cde2fb;--b1:#9ec5f4;--b2:#6da7ec;--b3:#3987e5;--b4:#256abf;
|
||||
}
|
||||
.viz-root{background:var(--surface-1);color:var(--text-primary);
|
||||
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
padding:30px 20px 64px;max-width:1080px;margin:0 auto}
|
||||
h1{font-size:25px;margin:0 0 6px;letter-spacing:-.015em}
|
||||
.sub{color:var(--text-secondary);font-size:13px;margin:0 0 26px}
|
||||
h2{font-size:16px;margin:40px 0 4px}
|
||||
.note{color:var(--text-secondary);font-size:12.5px;margin:0 0 14px}
|
||||
.note em a{color:inherit}
|
||||
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:20px 0 8px}
|
||||
.tile{background:var(--surface-2);border:1px solid var(--border);border-radius:10px;padding:13px 15px}
|
||||
.tile .k{font-size:11.5px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.05em}
|
||||
.tile .v{font-size:25px;font-weight:640;margin-top:3px;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
||||
.tile .d{font-size:12px;color:var(--text-secondary);margin-top:2px;font-variant-numeric:tabular-nums}
|
||||
.up{color:#0ca30c} .down{color:#d03b3b}
|
||||
.scroll{overflow-x:auto} svg{display:block;max-width:100%} text{font-family:inherit}
|
||||
.lbl{font-size:11px;fill:var(--text-secondary)}
|
||||
.lbl-p{font-size:11.5px;fill:var(--text-primary);font-weight:600}
|
||||
.ax{font-size:10.5px;fill:var(--text-muted)}
|
||||
.rank{font-size:10.5px;fill:var(--text-muted);font-variant-numeric:tabular-nums}
|
||||
.rank-me{font-size:11.5px;fill:var(--me);font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.legend{display:flex;gap:16px;flex-wrap:wrap;font-size:12.5px;color:var(--text-secondary);margin:10px 0 2px;align-items:center}
|
||||
.legend i{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:6px;vertical-align:-1px}
|
||||
.ctrls{display:flex;gap:8px;align-items:center;margin:10px 0 8px;flex-wrap:wrap}
|
||||
.ctrls button{font:inherit;font-size:12.5px;padding:3px 11px;border-radius:999px;cursor:pointer;
|
||||
background:var(--surface-2);color:var(--text-primary);border:1px solid var(--border)}
|
||||
.cnt{font-size:12.5px;color:var(--text-secondary)}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px}
|
||||
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;padding:3px 9px 3px 5px;border-radius:999px;
|
||||
cursor:pointer;border:1px solid var(--border);background:var(--surface-2);color:var(--text-secondary);user-select:none}
|
||||
.chip img{width:14px;height:14px;object-fit:contain;border-radius:3px}
|
||||
.chip .sw{width:8px;height:8px;border-radius:2px;background:var(--b2)}
|
||||
.chip.me .sw{background:var(--me)}
|
||||
.chip.off{opacity:.38;text-decoration:line-through}
|
||||
.chip.me{color:var(--text-primary);font-weight:600;border-color:var(--me)}
|
||||
.ser{cursor:pointer}
|
||||
svg.focus .ser{opacity:.18;transition:opacity .1s}
|
||||
svg.focus .ser:hover{opacity:1}
|
||||
svg.focus .ser:hover .ln{stroke-width:3.5}
|
||||
table{border-collapse:collapse;width:100%;font-size:12.5px;font-variant-numeric:tabular-nums;margin-top:8px}
|
||||
th,td{padding:5px 9px;border-bottom:1px solid var(--border);text-align:right;white-space:nowrap}
|
||||
th:first-child,td:first-child,td.dm{text-align:left}
|
||||
td.rk{color:var(--text-muted);width:1%}
|
||||
tr.me td{font-weight:640;background:color-mix(in srgb,var(--me) 8%,transparent)}
|
||||
tr.me td.rk{color:var(--me)}
|
||||
td.dm img{width:15px;height:15px;object-fit:contain;vertical-align:-3px;margin-right:6px;border-radius:3px}
|
||||
td.dm a{color:inherit;text-decoration:none;border-bottom:1px solid var(--border)}
|
||||
th{color:var(--text-secondary);font-weight:560;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em}
|
||||
details{margin-top:12px} summary{cursor:pointer;color:var(--text-secondary);font-size:13px}
|
||||
.tip{position:fixed;pointer-events:none;background:var(--surface-2);color:var(--text-primary);
|
||||
border:1px solid var(--border);border-radius:8px;padding:7px 10px;font-size:12.5px;opacity:0;
|
||||
transition:opacity .1s;z-index:9;box-shadow:0 4px 14px rgba(0,0,0,.14);
|
||||
font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.foot{color:var(--text-muted);font-size:11.5px;margin-top:34px;border-top:1px solid var(--border);padding-top:12px}
|
||||
</style>
|
||||
|
||||
<div class="viz-root">
|
||||
<h1>__TITLE__</h1>
|
||||
<p class="sub" id="sub"></p>
|
||||
<div class="tiles" id="tiles"></div>
|
||||
|
||||
<h2>Monthly visits</h2>
|
||||
<p class="note">SimilarWeb estimate, log scale. Root-domain figures include subdomains.</p>
|
||||
<div class="legend"><span><i style="background:var(--me)"></i><b id="lgme"></b></span><span><i style="background:var(--b2)"></i>Others</span></div>
|
||||
<div class="scroll"><svg id="dot"></svg></div>
|
||||
|
||||
<h2>Three-month trend</h2>
|
||||
<p class="note">Indexed to the first month = 100. Click any product below to hide it.</p>
|
||||
<div class="ctrls"><button id="all">All</button><button id="none">None</button><button id="only">Focus only</button><span id="cnt" class="cnt"></span></div>
|
||||
<div class="chips" id="chips"></div>
|
||||
<div class="scroll"><svg id="slope"></svg></div>
|
||||
|
||||
<h2>Organic search traffic</h2>
|
||||
<p class="note">Ahrefs estimate of search-driven visits only. Different basis from total visits above — the two are not additive.</p>
|
||||
<div class="scroll"><svg id="dot2"></svg></div>
|
||||
|
||||
<h2>Domain Rating</h2>
|
||||
<p class="note">A bounded 0–100 backlink-authority score. The one structural metric here: a single viral page moves traffic, never DR.<br>
|
||||
<em>Domain Rating by <a href="https://ahrefs.com/" target="_blank" rel="noopener">Ahrefs</a></em></p>
|
||||
<div class="scroll"><svg id="bars"></svg></div>
|
||||
|
||||
<h2>Organic traffic over time</h2>
|
||||
<p class="note">Log scale, all series overlaid. Click a line to hide it; hover to isolate it.</p>
|
||||
<div class="ctrls"><button id="all2">All</button><button id="none2">None</button><button id="only2">Focus only</button><span id="cnt2" class="cnt"></span></div>
|
||||
<div class="chips" id="chips2"></div>
|
||||
<div class="scroll"><svg id="line"></svg></div>
|
||||
|
||||
<details><summary>Full data table</summary><div class="scroll"><table id="tbl"></table></div></details>
|
||||
<p class="foot" id="foot"></p>
|
||||
<div class="tip" id="tip"></div>
|
||||
</div>
|
||||
|
||||
<script>__DATA__</script>
|
||||
<script>
|
||||
const $=s=>document.querySelector(s), ME=DATA.focus;
|
||||
const fmt=v=>v==null?'—':v>=1000?(v/1000).toFixed(v>=10000?0:1)+'M':v>=1?Math.round(v)+'K':(v*1000).toFixed(0);
|
||||
const tip=$('#tip'), S=(t,a)=>{const n=document.createElementNS('http://www.w3.org/2000/svg',t);for(const k in a)n.setAttribute(k,a[k]);return n};
|
||||
function hook(el,html){
|
||||
el.addEventListener('pointerenter',()=>{tip.innerHTML=html;tip.style.opacity=1});
|
||||
el.addEventListener('pointermove',e=>{tip.style.left=Math.min(e.clientX+14,innerWidth-200)+'px';tip.style.top=(e.clientY-40)+'px'});
|
||||
el.addEventListener('pointerleave',()=>tip.style.opacity=0);
|
||||
}
|
||||
const ramp=(i,n)=>`var(--b${n<2?2:Math.round(i/(n-1)*4)})`;
|
||||
const DOMS=Object.keys(DATA.sw);
|
||||
const SWM=[...new Set(Object.values(DATA.sw).flatMap(v=>Object.keys(v)))].sort().slice(-3);
|
||||
const AHMS=[...new Set(Object.values(DATA.ah).flatMap(v=>Object.keys(v)))].sort();
|
||||
const AL=AHMS[AHMS.length-1];
|
||||
$('#lgme').textContent=ME;
|
||||
$('#sub').textContent=`${DOMS.length} products · visits through ${SWM[2]} · organic and DR through ${AL}`;
|
||||
$('#foot').textContent=`SimilarWeb and Ahrefs are third-party estimates, not measured truth, and are least reliable for low-traffic sites. Generated ${DATA.generated_at.slice(0,10)}.`;
|
||||
|
||||
function dotPlot(sel,items,unit){
|
||||
const W=980,rh=20,PT=24,PB=32,L=196,R=64,H=PT+items.length*rh+PB;
|
||||
const s=$(sel);s.setAttribute('viewBox',`0 0 ${W} ${H}`);s.setAttribute('width',W);s.setAttribute('height',H);
|
||||
const vals=items.map(r=>Math.max(r.v,.5));
|
||||
const lo=Math.log10(Math.min(...vals)),hi=Math.log10(Math.max(...vals));
|
||||
const x=v=>L+(Math.log10(Math.max(v,.5))-lo)/((hi-lo)||1)*(W-L-R);
|
||||
for(let e=Math.ceil(lo);e<=Math.floor(hi);e++){
|
||||
const px=x(Math.pow(10,e));
|
||||
s.appendChild(S('line',{x1:px,x2:px,y1:PT-8,y2:H-PB+4,stroke:'var(--grid)','stroke-width':1}));
|
||||
const t=S('text',{x:px,y:H-PB+18,'text-anchor':'middle',class:'ax'});t.textContent=fmt(Math.pow(10,e));s.appendChild(t);
|
||||
}
|
||||
items.forEach((r,i)=>{
|
||||
const y=PT+i*rh+rh/2, mine=r.d===ME, col=mine?'var(--me)':ramp(i,items.length);
|
||||
s.appendChild(S('line',{x1:L,x2:x(r.v),y1:y,y2:y,stroke:'var(--grid)','stroke-width':1}));
|
||||
const g=S('g',{});
|
||||
const rk=S('text',{x:22,y:y+4,'text-anchor':'end',class:mine?'rank-me':'rank'});rk.textContent=i+1;g.appendChild(rk);
|
||||
if(LOGOS[r.d]) g.appendChild(S('image',{href:LOGOS[r.d],x:L-24,y:y-8,width:16,height:16,preserveAspectRatio:'xMidYMid meet'}));
|
||||
const a=S('a',{href:'https://'+r.d,target:'_blank',rel:'noopener'});
|
||||
const n=S('text',{x:L-30,y:y+4,'text-anchor':'end',class:mine?'lbl-p':'lbl'});n.textContent=r.d;a.appendChild(n);g.appendChild(a);
|
||||
g.appendChild(S('circle',{cx:x(r.v),cy:y,r:mine?6:4.5,fill:col,stroke:'var(--surface-1)','stroke-width':2}));
|
||||
const v=S('text',{x:x(r.v)+11,y:y+4,class:mine?'lbl-p':'lbl'});v.textContent=fmt(r.v);g.appendChild(v);
|
||||
hook(g,`<b>${r.d}</b><br>${unit} ${fmt(r.v)}${r.ch!=null?`<br>MoM ${r.ch>=0?'+':''}${r.ch.toFixed(1)}%`:''}`);
|
||||
s.appendChild(g);
|
||||
});
|
||||
}
|
||||
const rows=DOMS.map(d=>({d,v:DATA.sw[d][SWM[2]],prev:DATA.sw[d][SWM[1]],j:DATA.sw[d][SWM[0]]}))
|
||||
.filter(r=>r.v>0).sort((a,b)=>b.v-a.v).map(r=>({...r,ch:r.prev?((r.v/r.prev-1)*100):null}));
|
||||
dotPlot('#dot',rows,SWM[2]+' visits');
|
||||
const ahRows=DOMS.map(d=>({d,v:DATA.ah[d]?.[AL]})).filter(r=>r.v>0).sort((a,b)=>b.v-a.v);
|
||||
dotPlot('#dot2',ahRows.map(r=>({...r,ch:DATA.ah[r.d]?.[AHMS[AHMS.length-2]]?((r.v/DATA.ah[r.d][AHMS[AHMS.length-2]]-1)*100):null})),AL+' organic');
|
||||
|
||||
const drRows=Object.entries(DATA.dr).filter(([,v])=>v!=null).map(([d,v])=>({d,v})).sort((a,b)=>b.v-a.v);
|
||||
(()=>{
|
||||
const items=drRows,W=980,rh=20,PT=26,PB=30,L=196,R=54,H=PT+items.length*rh+PB;
|
||||
const s=$('#bars');s.setAttribute('viewBox',`0 0 ${W} ${H}`);s.setAttribute('width',W);s.setAttribute('height',H);
|
||||
const x=v=>L+v/100*(W-L-R);
|
||||
[0,25,50,75,100].forEach(v=>{
|
||||
s.appendChild(S('line',{x1:x(v),x2:x(v),y1:PT-8,y2:H-PB+4,stroke:'var(--grid)','stroke-width':1}));
|
||||
const t=S('text',{x:x(v),y:H-PB+18,'text-anchor':'middle',class:'ax'});t.textContent=v;s.appendChild(t);
|
||||
});
|
||||
items.forEach((r,i)=>{
|
||||
const y=PT+i*rh, mine=r.d===ME, col=mine?'var(--me)':ramp(i,items.length);
|
||||
const g=S('g',{});
|
||||
const rk=S('text',{x:22,y:y+rh/2+4,'text-anchor':'end',class:mine?'rank-me':'rank'});rk.textContent=i+1;g.appendChild(rk);
|
||||
if(LOGOS[r.d]) g.appendChild(S('image',{href:LOGOS[r.d],x:L-24,y:y+rh/2-8,width:16,height:16,preserveAspectRatio:'xMidYMid meet'}));
|
||||
const a=S('a',{href:'https://'+r.d,target:'_blank',rel:'noopener'});
|
||||
const n=S('text',{x:L-30,y:y+rh/2+4,'text-anchor':'end',class:mine?'lbl-p':'lbl'});n.textContent=r.d;a.appendChild(n);g.appendChild(a);
|
||||
g.appendChild(S('rect',{x:L,y:y+3,width:Math.max(x(r.v)-L,3),height:rh-8,rx:4,fill:col}));
|
||||
const v=S('text',{x:x(r.v)+9,y:y+rh/2+4,class:mine?'lbl-p':'lbl'});v.textContent=r.v;g.appendChild(v);
|
||||
hook(g,`<b>${r.d}</b><br>DR ${r.v}`);
|
||||
s.appendChild(g);
|
||||
});
|
||||
})();
|
||||
|
||||
const me=rows.find(r=>r.d===ME), ahMe=ahRows.find(r=>r.d===ME), drMe=drRows.find(r=>r.d===ME);
|
||||
$('#tiles').innerHTML=[
|
||||
['Visits',me?fmt(me.v):'—',me&&me.ch!=null?`${me.ch>=0?'↑':'↓'} ${Math.abs(me.ch).toFixed(1)}% MoM`:'',me&&me.ch>=0?'up':'down'],
|
||||
['Visits rank',me?`#${rows.indexOf(me)+1} / ${rows.length}`:'—','SimilarWeb',''],
|
||||
['Organic',ahMe?fmt(ahMe.v):'—',ahMe?`#${ahRows.indexOf(ahMe)+1} / ${ahRows.length}`:'','' ],
|
||||
['Domain Rating',drMe?String(drMe.v):'—',drMe?`#${drRows.indexOf(drMe)+1} / ${drRows.length}`:'','']
|
||||
].map(([k,v,d,c])=>`<div class="tile"><div class="k">${k}</div><div class="v">${v}</div><div class="d ${c}">${d}</div></div>`).join('');
|
||||
|
||||
const SLOPE=rows.filter(r=>r.j>0&&r.prev>0).map(r=>({...r,g:r.v/r.j})).sort((a,b)=>b.g-a.g), OFF=new Set();
|
||||
function drawSlope(){
|
||||
const s=$('#slope');s.textContent='';
|
||||
const on=SLOPE.filter(r=>!OFF.has(r.d));
|
||||
$('#cnt').textContent=`${on.length} / ${SLOPE.length} shown`;
|
||||
const W=980,H=380,PT=22,PB=34,L=48,R=190;
|
||||
s.setAttribute('viewBox',`0 0 ${W} ${H}`);s.setAttribute('width',W);s.setAttribute('height',H);
|
||||
if(!on.length) return;
|
||||
const idx=r=>[r.j,r.prev,r.v].map(v=>v/r.j*100);
|
||||
const flat=on.flatMap(idx), lo=Math.min(...flat,100), hi=Math.max(...flat,100);
|
||||
const y=v=>PT+(hi-v)/((hi-lo)||1)*(H-PT-PB), x=i=>L+i*((W-L-R)/2);
|
||||
[...new Set([Math.round(hi),100,Math.round(lo)])].forEach(v=>{
|
||||
s.appendChild(S('line',{x1:L,x2:W-R,y1:y(v),y2:y(v),stroke:'var(--grid)','stroke-width':v===100?1.5:1,'stroke-dasharray':v===100?'':'3 3'}));
|
||||
const t=S('text',{x:L-8,y:y(v)+4,'text-anchor':'end',class:'ax'});t.textContent=v;s.appendChild(t);
|
||||
});
|
||||
SWM.forEach((m,i)=>{const t=S('text',{x:x(i),y:H-PB+20,'text-anchor':'middle',class:'ax'});t.textContent=m;s.appendChild(t)});
|
||||
const sorted=[...on].sort((a,b)=>b.g-a.g);
|
||||
const named=new Set([...sorted.slice(0,3),...sorted.slice(-3)].map(r=>r.d)); named.add(ME);
|
||||
const ends=on.map(r=>({r,p:idx(r)})).sort((a,b)=>b.p[2]-a.p[2]);
|
||||
let lastY=-1e9;
|
||||
ends.forEach(e=>{if(!named.has(e.r.d))return;let ly=y(e.p[2]);if(ly<lastY+15)ly=lastY+15;e.ly=ly;lastY=ly});
|
||||
ends.forEach(({r,p,ly})=>{
|
||||
const mine=r.d===ME, col=mine?'var(--me)':(r.g>=1?'var(--up)':ramp(ends.findIndex(z=>z.r.d===r.d),ends.length));
|
||||
const g=S('g',{class:'ser'});
|
||||
g.appendChild(S('polyline',{points:p.map((v,i)=>`${x(i)},${y(v)}`).join(' '),fill:'none',stroke:col,
|
||||
'stroke-width':mine?3.5:1.8,'stroke-linecap':'round','stroke-linejoin':'round',opacity:mine?1:.72}));
|
||||
p.forEach((v,i)=>g.appendChild(S('circle',{cx:x(i),cy:y(v),r:mine?5.5:3.2,fill:col,stroke:'var(--surface-1)','stroke-width':mine?2:1.4})));
|
||||
if(ly!=null){
|
||||
g.appendChild(S('line',{x1:x(2)+7,x2:W-R+2,y1:y(p[2]),y2:ly-4,stroke:col,'stroke-width':1,opacity:.45}));
|
||||
if(LOGOS[r.d]) g.appendChild(S('image',{href:LOGOS[r.d],x:W-R+6,y:ly-12,width:14,height:14,preserveAspectRatio:'xMidYMid meet'}));
|
||||
const t=S('text',{x:W-R+(LOGOS[r.d]?24:6),y:ly,class:mine?'lbl-p':'lbl'});
|
||||
t.textContent=r.d+' '+(p[2]>=100?'+':'')+(p[2]-100).toFixed(0)+'%';g.appendChild(t);
|
||||
}
|
||||
hook(g,`<b>${r.d}</b><br>`+SWM.map((m,i)=>`${m} ${fmt([r.j,r.prev,r.v][i])}`).join('<br>'));
|
||||
s.appendChild(g);
|
||||
});
|
||||
}
|
||||
const SERIES=ahRows.map(r=>({d:r.d,pts:AHMS.map((m,i)=>({i,m,v:DATA.ah[r.d]?.[m]})).filter(p=>p.v>0)}))
|
||||
.filter(x=>x.pts.length>1), OFF2=new Set();
|
||||
function drawLine(){
|
||||
const s=$('#line');s.textContent='';
|
||||
const on=SERIES.filter(x=>!OFF2.has(x.d));
|
||||
$('#cnt2').textContent=`${on.length} / ${SERIES.length} shown`;
|
||||
const W=980,H=430,PT=18,PB=44,L=60,R=178;
|
||||
s.setAttribute('viewBox',`0 0 ${W} ${H}`);s.setAttribute('width',W);s.setAttribute('height',H);
|
||||
if(!on.length) return;
|
||||
const vals=on.flatMap(x=>x.pts.map(p=>p.v));
|
||||
const lo=Math.log10(Math.min(...vals)), hi=Math.log10(Math.max(...vals)), span=(hi-lo)||1;
|
||||
const y=v=>PT+(hi-Math.log10(Math.max(v,1e-3)))/span*(H-PT-PB), x=i=>L+i/((AHMS.length-1)||1)*(W-L-R);
|
||||
for(let e=Math.floor(lo);e<=Math.ceil(hi);e++){
|
||||
const py=y(Math.pow(10,e)); if(py<PT-2||py>H-PB+2) continue;
|
||||
s.appendChild(S('line',{x1:L,x2:W-R,y1:py,y2:py,stroke:'var(--grid)','stroke-width':1}));
|
||||
const t=S('text',{x:L-8,y:py+4,'text-anchor':'end',class:'ax'});t.textContent=fmt(Math.pow(10,e));s.appendChild(t);
|
||||
}
|
||||
s.appendChild(S('line',{x1:L,x2:W-R,y1:H-PB,y2:H-PB,stroke:'var(--border)','stroke-width':1.5}));
|
||||
AHMS.forEach((m,i)=>{ if(i%3) return;
|
||||
s.appendChild(S('line',{x1:x(i),x2:x(i),y1:H-PB,y2:H-PB+5,stroke:'var(--border)','stroke-width':1}));
|
||||
const t=S('text',{x:x(i),y:H-PB+19,'text-anchor':'middle',class:'ax'});t.textContent=m.slice(2);s.appendChild(t);
|
||||
});
|
||||
const ends=on.map(x=>({...x,last:x.pts[x.pts.length-1]})).sort((a,b)=>b.last.v-a.last.v);
|
||||
const named=new Set([...ends.slice(0,5).map(x=>x.d),ME]);
|
||||
let lastY=-1e9;
|
||||
ends.forEach(x=>{if(!named.has(x.d))return;let ly=y(x.last.v);if(ly<lastY+15)ly=lastY+15;x.ly=ly;lastY=ly});
|
||||
ends.forEach((x,xi)=>{
|
||||
const mine=x.d===ME, col=mine?'var(--me)':ramp(xi,ends.length);
|
||||
const pts=x.pts.map(p=>`${x2(p.i)},${y(p.v)}`).join(' ');
|
||||
const g=S('g',{class:'ser'});
|
||||
g.appendChild(S('polyline',{points:pts,fill:'none',stroke:'transparent','stroke-width':11}));
|
||||
g.appendChild(S('polyline',{points:pts,fill:'none',stroke:col,class:'ln',
|
||||
'stroke-width':mine?3.5:1.6,'stroke-linecap':'round','stroke-linejoin':'round',opacity:mine?1:.6}));
|
||||
g.appendChild(S('circle',{cx:x2(x.last.i),cy:y(x.last.v),r:mine?5.5:3,fill:col,stroke:'var(--surface-1)','stroke-width':mine?2:1.4}));
|
||||
if(x.ly!=null){
|
||||
g.appendChild(S('line',{x1:x2(x.last.i)+6,x2:W-R+2,y1:y(x.last.v),y2:x.ly-4,stroke:col,'stroke-width':1,opacity:.45}));
|
||||
if(LOGOS[x.d]) g.appendChild(S('image',{href:LOGOS[x.d],x:W-R+6,y:x.ly-12,width:14,height:14,preserveAspectRatio:'xMidYMid meet'}));
|
||||
const a=S('a',{href:'https://'+x.d,target:'_blank',rel:'noopener'});
|
||||
const t=S('text',{x:W-R+(LOGOS[x.d]?24:6),y:x.ly,class:mine?'lbl-p':'lbl'});
|
||||
t.textContent=x.d+' '+fmt(x.last.v);a.appendChild(t);g.appendChild(a);
|
||||
}
|
||||
const peak=x.pts.reduce((m,p)=>p.v>m.v?p:m,x.pts[0]);
|
||||
hook(g,`<b>${x.d}</b><br>${x.last.m} ${fmt(x.last.v)}<br>peak ${fmt(peak.v)} (${peak.m})<br><span style="opacity:.7">click to hide</span>`);
|
||||
g.addEventListener('click',ev=>{if(ev.target.closest('a'))return;ev.preventDefault();OFF2.add(x.d);drawChips2();drawLine();tip.style.opacity=0});
|
||||
g.addEventListener('pointerenter',()=>s.classList.add('focus'));
|
||||
g.addEventListener('pointerleave',()=>s.classList.remove('focus'));
|
||||
s.appendChild(g);
|
||||
});
|
||||
function x2(i){return x(i)}
|
||||
}
|
||||
const chipHTML=(d,off)=>`<span class="chip ${d===ME?'me':''} ${off?'off':''}" data-d="${d}">`
|
||||
+(LOGOS[d]?`<img src="${LOGOS[d]}" alt="">`:`<span class="sw"></span>`)+d+'</span>';
|
||||
function drawChips(){$('#chips').innerHTML=SLOPE.map(r=>chipHTML(r.d,OFF.has(r.d))).join('')}
|
||||
function drawChips2(){$('#chips2').innerHTML=SERIES.map(x=>chipHTML(x.d,OFF2.has(x.d))).join('')}
|
||||
$('#chips').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;
|
||||
const d=c.dataset.d;OFF.has(d)?OFF.delete(d):OFF.add(d);c.classList.toggle('off',OFF.has(d));drawSlope()});
|
||||
$('#chips2').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;
|
||||
const d=c.dataset.d;OFF2.has(d)?OFF2.delete(d):OFF2.add(d);c.classList.toggle('off',OFF2.has(d));drawLine()});
|
||||
$('#all').onclick=()=>{OFF.clear();drawChips();drawSlope()};
|
||||
$('#none').onclick=()=>{SLOPE.forEach(r=>OFF.add(r.d));drawChips();drawSlope()};
|
||||
$('#only').onclick=()=>{OFF.clear();SLOPE.forEach(r=>{if(r.d!==ME)OFF.add(r.d)});drawChips();drawSlope()};
|
||||
$('#all2').onclick=()=>{OFF2.clear();drawChips2();drawLine()};
|
||||
$('#none2').onclick=()=>{SERIES.forEach(x=>OFF2.add(x.d));drawChips2();drawLine()};
|
||||
$('#only2').onclick=()=>{OFF2.clear();SERIES.forEach(x=>{if(x.d!==ME)OFF2.add(x.d)});drawChips2();drawLine()};
|
||||
drawChips();drawSlope();drawChips2();drawLine();
|
||||
|
||||
$('#tbl').innerHTML='<thead><tr><th>#</th><th>Product</th>'+SWM.map(m=>`<th>${m}</th>`).join('')
|
||||
+'<th>MoM</th><th>Organic</th><th>DR</th></tr></thead><tbody>'
|
||||
+rows.map((r,i)=>`<tr class="${r.d===ME?'me':''}"><td class="rk">${i+1}</td><td class="dm">${LOGOS[r.d]?`<img src="${LOGOS[r.d]}" alt="">`:''}<a href="https://${r.d}" target="_blank" rel="noopener">${r.d}</a></td>
|
||||
<td>${fmt(r.j)}</td><td>${fmt(r.prev)}</td><td>${fmt(r.v)}</td>
|
||||
<td class="${r.ch==null?'':r.ch>=0?'up':'down'}">${r.ch==null?'—':(r.ch>=0?'+':'')+r.ch.toFixed(1)+'%'}</td>
|
||||
<td>${fmt(DATA.ah[r.d]?.[AL])}</td><td>${DATA.dr[r.d]??'—'}</td></tr>`).join('')+'</tbody>';
|
||||
</script>
|
||||
Reference in New Issue
Block a user