/ src / utils / meta-ad-library.js
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  }