meta-ad-library.js
1 /** 2 * Meta Ad Library API Integration 3 * 4 * Queries the Meta Ad Library to determine if a business has active 5 * Facebook/Instagram ads. This is definitive proof of current ad spend. 6 * 7 * Prerequisites: 8 * - Meta developer account with an app that has Ads Library API access 9 * - Long-lived access token (env: META_AD_LIBRARY_TOKEN) 10 * - Rate limit: 200 calls/hour per app 11 * 12 * Flow: 13 * 1. Extract Facebook Page slug from site HTML (ad-detector.js does this) 14 * 2. Resolve slug to Page ID via Graph API 15 * 3. Query Ad Library API for active ads from that Page 16 * 17 * @see https://www.facebook.com/ads/library/api/ 18 */ 19 20 import Logger from './logger.js'; 21 22 const logger = new Logger('MetaAdLibrary'); 23 24 const META_ACCESS_TOKEN = process.env.META_AD_LIBRARY_TOKEN || ''; 25 const GRAPH_API_VERSION = 'v19.0'; 26 const GRAPH_API_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`; 27 28 /** 29 * Check if a Facebook Page has active ads via the Ad Library API. 30 * 31 * @param {string} pageSlugOrId - Facebook Page slug (e.g. "mybusiness") or numeric Page ID 32 * @param {string} countryCode - ISO country code for ad search (e.g. "AU") 33 * @returns {Promise<Object>} { has_active_ads, ad_count, page_id, error? } 34 */ 35 export async function checkActiveAds(pageSlugOrId, countryCode = 'ALL') { 36 if (!META_ACCESS_TOKEN) { 37 return { has_active_ads: null, error: 'META_AD_LIBRARY_TOKEN not configured' }; 38 } 39 40 if (!pageSlugOrId) { 41 return { has_active_ads: null, error: 'No page slug or ID provided' }; 42 } 43 44 try { 45 // Step 1: Resolve slug to Page ID if not numeric 46 let pageId = pageSlugOrId; 47 if (!/^\d+$/.test(pageSlugOrId)) { 48 pageId = await resolvePageId(pageSlugOrId); 49 if (!pageId) { 50 return { has_active_ads: null, error: `Could not resolve page: ${pageSlugOrId}` }; 51 } 52 } 53 54 // Step 2: Query Ad Library 55 const params = new URLSearchParams({ 56 access_token: META_ACCESS_TOKEN, 57 ad_reached_countries: countryCode === 'ALL' ? '' : `["${countryCode}"]`, 58 ad_active_status: 'ACTIVE', 59 search_page_ids: pageId, 60 fields: 'id,ad_creative_bodies,ad_delivery_start_time', 61 limit: '5', // We only need to know if any exist 62 }); 63 64 // Remove empty params 65 if (!params.get('ad_reached_countries')) params.delete('ad_reached_countries'); 66 67 const url = `${GRAPH_API_BASE}/ads_archive?${params}`; 68 const response = await fetch(url); 69 const data = await response.json(); 70 71 if (data.error) { 72 logger.warn(`Ad Library API error for ${pageSlugOrId}: ${data.error.message}`); 73 return { has_active_ads: null, page_id: pageId, error: data.error.message }; 74 } 75 76 const ads = data.data || []; 77 return { 78 has_active_ads: ads.length > 0, 79 ad_count: ads.length, 80 page_id: pageId, 81 sample_ad: ads[0] ? { 82 started: ads[0].ad_delivery_start_time, 83 body_preview: ads[0].ad_creative_bodies?.[0]?.slice(0, 100), 84 } : null, 85 }; 86 } catch (e) { 87 logger.warn(`Ad Library lookup failed for ${pageSlugOrId}: ${e.message}`); 88 return { has_active_ads: null, error: e.message }; 89 } 90 } 91 92 /** 93 * Resolve a Facebook Page slug to a numeric Page ID. 94 * 95 * @param {string} slug - Page slug (e.g. "mybusiness") 96 * @returns {Promise<string|null>} Page ID or null 97 */ 98 async function resolvePageId(slug) { 99 try { 100 const url = `${GRAPH_API_BASE}/${encodeURIComponent(slug)}?fields=id&access_token=${META_ACCESS_TOKEN}`; 101 const response = await fetch(url); 102 const data = await response.json(); 103 104 if (data.error) { 105 logger.debug(`Page resolve failed for ${slug}: ${data.error.message}`); 106 return null; 107 } 108 109 return data.id || null; 110 } catch (e) { 111 logger.debug(`Page resolve error for ${slug}: ${e.message}`); 112 return null; 113 } 114 } 115 116 /** 117 * Batch check multiple pages for active ads. 118 * Respects Meta's 200 calls/hour rate limit. 119 * 120 * @param {Array<{siteId: number, pageSlug: string, countryCode: string}>} pages 121 * @param {number} delayMs - Delay between requests (default: 18500ms = ~195 calls/hour) 122 * @returns {AsyncGenerator<{siteId: number, result: Object}>} 123 */ 124 export async function* batchCheckActiveAds(pages, delayMs = 18500) { 125 for (const { siteId, pageSlug, countryCode } of pages) { 126 const result = await checkActiveAds(pageSlug, countryCode); 127 yield { siteId, result }; 128 129 // Rate limit — Meta allows 200/hour, we stay under 130 if (delayMs > 0) { 131 await new Promise(resolve => setTimeout(resolve, delayMs)); 132 } 133 } 134 }