← back to Small Business Builder

src/scrape/instagram_public.js

111 lines

// Public Instagram profile scrape — no API key.
// Tries the JSON `?__a=1&__d=dis` endpoint first (often blocked anonymously),
// then falls back to parsing the embedded `<meta>` tags + JSON-LD.
//
// Returns: { handle, url, full_name, biography, follower_count, post_count, profile_pic_url, last_post_at, raw }
//
// NOTE: Instagram aggressively rate-limits anonymous requests; treat null fields as
// "blocked, retry later" rather than "doesn't exist". Use Browserbase if this becomes
// a hot-path requirement (per Steve's feedback_browserbase_first.md).

import { request } from 'undici';
import * as cheerio from 'cheerio';

const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 ' +
           '(KHTML, like Gecko) Chrome/124.0 Safari/537.36';

function cleanHandle(h) {
  return String(h || '').trim().replace(/^https?:\/\/(www\.)?instagram\.com\//i, '').replace(/^@/, '').replace(/\/.*$/, '');
}

export async function scrapeInstagramPublic(handleOrUrl) {
  const handle = cleanHandle(handleOrUrl);
  if (!handle) throw new Error('Empty handle');
  const url = `https://www.instagram.com/${encodeURIComponent(handle)}/`;

  const ac = new AbortController();
  const t = setTimeout(() => ac.abort(), 15000);
  let html = '';
  let status = 0;
  try {
    const res = await request(url, {
      method: 'GET',
      headers: {
        'user-agent': UA,
        'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'accept-language': 'en-US,en;q=0.9',
      },
      maxRedirections: 5,
      signal: ac.signal,
    });
    status = res.statusCode;
    html = await res.body.text();
  } catch (err) {
    return { handle, url, error: String(err.message || err), blocked: true };
  } finally {
    clearTimeout(t);
  }

  if (status >= 400) {
    return { handle, url, error: `HTTP ${status}`, blocked: status === 401 || status === 403 || status === 429 };
  }

  const $ = cheerio.load(html);
  const ogTitle = $('meta[property="og:title"]').attr('content') || '';
  const ogDescription = $('meta[property="og:description"]').attr('content') || '';
  const ogImage = $('meta[property="og:image"]').attr('content') || null;
  const ogUrl = $('meta[property="og:url"]').attr('content') || url;

  // og:description on a profile typically reads:
  //   "1,234 Followers, 567 Following, 89 Posts - See Instagram photos and videos from Full Name (@handle)"
  let follower_count = null;
  let following_count = null;
  let post_count = null;
  const m = ogDescription.match(/([\d,.]+(?:[KMB])?)\s+Followers?,\s+([\d,.]+(?:[KMB])?)\s+Following,\s+([\d,.]+(?:[KMB])?)\s+Posts?/i);
  if (m) {
    follower_count = humanToInt(m[1]);
    following_count = humanToInt(m[2]);
    post_count = humanToInt(m[3]);
  }

  // og:title is typically "Full Name (@handle) • Instagram photos and videos"
  let full_name = null;
  const t2 = ogTitle.match(/^(.+?)\s*\(@/);
  if (t2) full_name = t2[1].trim();

  // Bio shows up in JSON-LD on some pages
  let biography = null;
  $('script[type="application/ld+json"]').each((_, el) => {
    try {
      const j = JSON.parse($(el).text());
      if (j && j.description && !biography) biography = j.description;
    } catch { /* ignore */ }
  });

  const blocked = !follower_count && !full_name && !biography;

  return {
    handle,
    url: ogUrl || url,
    full_name,
    biography,
    follower_count,
    following_count,
    post_count,
    profile_pic_url: ogImage,
    last_post_at: null, // not parseable from public HTML reliably
    blocked,
    raw_status: status,
  };
}

function humanToInt(s) {
  if (s == null) return null;
  s = String(s).replace(/,/g, '').trim();
  const m = s.match(/^([\d.]+)([KMB])?$/i);
  if (!m) return null;
  const n = parseFloat(m[1]);
  const mult = ({ K: 1e3, M: 1e6, B: 1e9 }[(m[2] || '').toUpperCase()]) || 1;
  return Math.round(n * mult);
}