#!/usr/bin/env node /* * find-your-subreddits.mjs * * Given a few words about your business, find the Reddit communities your customers and * competitors are actually in, and how big they are. Ten minutes of this saves you from * researching the wrong community for a week. * * It deliberately uses only the Arctic Shift endpoint I measured as fast and reliable * (subreddit metadata, about 380ms). It does not run text searches or aggregates, because * those are best effort on a free service and fail under load. See the cookbook for why. * * No dependencies. Node 18 or later. * * Usage: * node find-your-subreddits.mjs laundr laundromat washing * node find-your-subreddits.mjs carwash detailing auto --min 1000 * node find-your-subreddits.mjs storage selfstorage moving --json > subs.json * * Options: * --min N minimum subscribers (default 300) * --limit N results per term (default 20) * --json machine-readable output * --delay MS pause between requests (default 1200, please be kind) * * From https://jwatte.com/blog/reddit-data-arctic-shift-for-business/ * Arctic Shift is by Arthur Heitmann: https://github.com/ArthurHeitmann/arctic_shift * Free to copy and adapt. No warranty. Read it before you run it. */ const BASE = 'https://arctic-shift.photon-reddit.com'; const UA = 'find-your-subreddits.mjs (+https://jwatte.com)'; const argv = process.argv.slice(2); const flag = (name, dflt) => { const i = argv.indexOf(name); return i === -1 ? dflt : (Number(argv[i + 1]) || dflt); }; const MIN = flag('--min', 300); const LIMIT = flag('--limit', 20); const DELAY = flag('--delay', 1200); const asJson = argv.includes('--json'); const terms = argv.filter((a, i) => { if (a.startsWith('--')) return false; const prev = argv[i - 1]; if (prev === '--min' || prev === '--limit' || prev === '--delay') return false; return true; }); if (!terms.length) { console.error('usage: node find-your-subreddits.mjs [term...] [--min N] [--limit N] [--json]'); console.error('example: node find-your-subreddits.mjs laundr laundromat washing'); process.exit(2); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function lookup(term) { const url = `${BASE}/api/subreddits/search?subreddit_prefix=${encodeURIComponent(term)}` + `&min_subscribers=${MIN}&limit=${LIMIT}` + `&fields=display_name,subscribers,created_utc,public_description`; const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 45000); try { const r = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' }, signal: ctl.signal }); clearTimeout(t); if (!r.ok) return { term, error: `HTTP ${r.status}` }; const j = await r.json(); // The API reports its own soft failures in the body with a 200 status. if (j && j.error) return { term, error: j.error }; return { term, rows: j.data || [] }; } catch (e) { clearTimeout(t); return { term, error: String((e && (e.cause?.code || e.name)) || 'ERR') }; } } const seen = new Map(); const problems = []; for (const term of terms) { const res = await lookup(term); if (res.error) { problems.push(`${term}: ${res.error}`); if (!asJson) console.error(` ! ${term}: ${res.error}`); } else { for (const row of res.rows) { const name = row.display_name; if (!name) continue; const subs = Number(row.subscribers) || 0; if (!seen.has(name) || seen.get(name).subscribers < subs) { seen.set(name, { name, subscribers: subs, created: row.created_utc ? new Date(Number(row.created_utc) * 1000).toISOString().slice(0, 7) : null, description: (row.public_description || '').replace(/\s+/g, ' ').slice(0, 100), matched: term, }); } } } await sleep(DELAY); } const all = [...seen.values()].sort((a, b) => b.subscribers - a.subscribers); if (asJson) { console.log(JSON.stringify({ terms, min: MIN, found: all.length, problems, subreddits: all }, null, 2)); } else { if (!all.length) { console.log('\nNothing found. Try shorter prefixes: "laundr" matches more than "laundromat".\n'); process.exit(1); } const pad = Math.min(28, Math.max(...all.map((s) => s.name.length)) + 2); console.log(`\n ${all.length} communities, largest first\n`); for (const s of all) { console.log( ` r/${s.name.padEnd(pad)} ${String(s.subscribers.toLocaleString()).padStart(11)} ${s.created || ' '} ${s.description}` ); } // The point of the whole exercise: the biggest one is rarely the one named after the industry. const biggest = all[0]; const named = all.find((s) => terms.some((t) => s.name.toLowerCase() === t.toLowerCase())); console.log(''); if (named && named.name !== biggest.name) { const ratio = named.subscribers ? Math.round(biggest.subscribers / named.subscribers) : 0; console.log(` Note: r/${named.name} has ${named.subscribers.toLocaleString()} members,`); console.log(` but r/${biggest.name} has ${biggest.subscribers.toLocaleString()}${ratio > 1 ? `, about ${ratio}x more` : ''}.`); console.log(` The community named after the industry is usually not the one to study.`); } else { console.log(` Largest: r/${biggest.name} at ${biggest.subscribers.toLocaleString()} members.`); } console.log(`\n Next: read fifty posts in the top two or three by hand before writing any code.\n`); if (problems.length) console.log(` ${problems.length} term(s) failed: ${problems.join('; ')}\n`); }