← back to Professional Directory

scripts/enrich-reddit.js

157 lines

#!/usr/bin/env node
/**
 * Reddit signal enrichment.
 *
 * For each LA-county org, query Reddit's public search API for posts mentioning
 * the org name, restricted to subreddits where LA-area recommendations
 * actually surface. Stash hits in org_signals so the pitch flow can show
 * "what Reddit thinks of you" snippets.
 *
 * Free, no auth needed (public /search.json). Rate-limited to 1 rps.
 *
 *   node scripts/enrich-reddit.js
 *   LIMIT=20 node scripts/enrich-reddit.js              # smoke
 *   ONLY_DOCTORS=1 node scripts/enrich-reddit.js        # doctor-type orgs only
 */
const { fetch } = require('undici');
const { pool, query } = require('../agents/shared/db');
const { setHostRateLimit } = require('../agents/shared/compliance');

const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : null;
const ONLY_DOCTORS = process.env.ONLY_DOCTORS === '1';

const DOCTOR_TYPES = ['medical_group','clinic','dental_office','optometrist_office','surgery_center','hospital','fqhc','tcm_clinic','acupuncture_clinic','chiropractor_office','tcm_herbalist','rcfe','ccrc','adult_residential','home_care_agency','home_health','hospice','nursing_facility'];

// LA-area subreddits where small-biz / medical recommendations surface.
const SUBREDDITS = [
  'AskLosAngeles', 'LosAngeles', 'AskLA', 'AskDocs', 'medicine',
  'sanfernandovalley', 'Pasadena', 'longbeach', 'BeverlyHills',
  'westhollywood', 'culvercity', 'santamonica',
];

const UA = 'professional-directory/0.1 by /u/stevemdr (research)';

setHostRateLimit('www.reddit.com', 0.7);   // ~1 req/1.4s — Reddit returns 429 above ~1 rps anonymously

function safeName(name) {
  // For Reddit search we want bare org name without legal suffixes that hurt recall.
  return String(name || '')
    .replace(/\b(LLC|INC|INC\.|CORP|CORPORATION|MEDICAL GROUP|MEDICAL CENTER|HEALTH|CARE|GROUP|ASSOC|ASSOCIATES|PC|P\.C\.|PROFESSIONAL CORPORATION)\b/gi, '')
    .replace(/\s+/g, ' ').trim();
}

async function searchReddit(query) {
  const url = `https://www.reddit.com/search.json?q=${encodeURIComponent('"' + query + '"')}&limit=15&sort=relevance&type=link`;
  await new Promise(r => setTimeout(r, 1500));   // explicit pace; lib also enforces
  try {
    const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(15_000) });
    if (res.status === 429) {
      await new Promise(r => setTimeout(r, 30_000));   // back off on rate-limit
      return null;
    }
    if (!res.ok) return null;
    const j = await res.json();
    const children = j?.data?.children || [];
    return children.map(c => c.data).filter(d => d && d.subreddit);
  } catch (_) { return null; }
}

function relevanceScore(post, orgName, orgCity) {
  if (!post) return 0;
  const haystack = ((post.title || '') + ' ' + (post.selftext || '')).toLowerCase();
  const needle = orgName.toLowerCase();
  if (!haystack.includes(needle)) return 0;
  let score = 0;
  // HARD filter: must be in an LA-relevant subreddit OR mention the org's city.
  const inLaSub = SUBREDDITS.includes(post.subreddit);
  const cityHit = orgCity && haystack.includes(orgCity.toLowerCase());
  if (!inLaSub && !cityHit) return 0;
  if (inLaSub) score += 3;
  if (cityHit) score += 2;
  // Active discussion bonus.
  if ((post.num_comments || 0) > 5) score += 1;
  // Recommendation-shape bonus.
  if (/recommend|review|experience|seen|been to|tried|happy|terrible|avoid/.test(haystack)) score += 1;
  return score;
}

async function processOrg(o) {
  // Skip if we already have signals for this org.
  const existing = await query('SELECT 1 FROM org_signals WHERE organization_id = $1 AND source = \'reddit\' LIMIT 1', [o.id]);
  if (existing.rowCount > 0) return { hits: 0, skipped: true };

  const name = safeName(o.name);
  // Require longer/more-specific names. "Nail Bar" gets too many false hits;
  // require >=12 chars OR >=2 words AND >= 8 chars.
  const wordCount = name.split(/\s+/).filter(Boolean).length;
  if (!name || (name.length < 12 && wordCount < 2) || name.length < 8) return { hits: 0, skipped: 'short-name' };

  const posts = await searchReddit(name);
  if (!posts || posts.length === 0) return { hits: 0 };

  let inserted = 0;
  for (const p of posts) {
    const score = relevanceScore(p, name, o.city);
    if (score < 3) continue;   // require strong relevance: LA-sub OR city-mention
    const url = 'https://www.reddit.com' + (p.permalink || '');
    try {
      await query(`
        INSERT INTO org_signals
          (organization_id, source, subsource, url, title, body, author, posted_at, raw_json)
        VALUES ($1, 'reddit', $2, $3, $4, $5, $6, to_timestamp($7), $8::jsonb)
        ON CONFLICT (url) DO NOTHING
      `, [
        o.id, p.subreddit, url,
        (p.title || '').slice(0, 500),
        (p.selftext || '').slice(0, 2000),
        p.author || null,
        p.created_utc || 0,
        JSON.stringify({ score: p.score, comments: p.num_comments, relevance: score }),
      ]);
      inserted++;
    } catch (_) {}
  }
  return { hits: inserted };
}

async function main() {
  const where = [`opted_out = false`, `name IS NOT NULL`];
  const params = [];
  if (ONLY_DOCTORS) { where.push('type = ANY($1)'); params.push(DOCTOR_TYPES); }
  const sql = `
    SELECT id, name, type, city FROM organizations
     WHERE ${where.join(' AND ')}
     ORDER BY lead_score DESC NULLS LAST, id
     ${LIMIT ? `LIMIT ${LIMIT}` : 'LIMIT 5000'}
  `;
  const r = await query(sql, params);
  console.log(`[reddit] candidates=${r.rowCount}`);

  let orgs = 0, totalHits = 0, withAny = 0, skipped = 0;
  for (const o of r.rows) {
    orgs++;
    try {
      const stat = await processOrg(o);
      if (stat.skipped) skipped++;
      else {
        if (stat.hits > 0) withAny++;
        totalHits += stat.hits;
      }
      if (orgs % 25 === 0 || stat.hits > 0) {
        console.log(`[reddit] orgs=${orgs}/${r.rowCount} new_hits=${stat.hits} | totals: with_any=${withAny} skipped=${skipped} hits=${totalHits}`);
      }
    } catch (e) {
      console.error(`[reddit] error org=${o.id} (${o.name}): ${e.message}`);
    }
  }

  console.log(`[reddit] DONE orgs=${orgs} hits=${totalHits} with_any=${withAny} skipped=${skipped}`);
  await pool.end();
}

main().catch(async (err) => {
  console.error('[reddit] fatal:', err);
  try { await pool.end(); } catch (_) {}
  process.exit(1);
});