← 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);
}