← back to Ventura Corridor
src/enrich/gtm_followthrough.ts
180 lines
/**
* GTM second-stage fetcher.
*
* For sites where the front-page HTML reveals a GTM container but no
* direct paid-ad pixels (gtm_container=true, paid_ads_count=0), fetch
* the GTM bundle and re-apply the pixel patterns to the bundle's tag
* definitions. Catches the Starbucks/Discount Tire / Planet Fitness
* class — big retailers who load every pixel through GTM.
*
* No headless browser. Bundle URL is public, response is plaintext JS.
*
* Persisted under ad_signals.gtm_followthrough = {
* fetched_at, container_id, platforms_added: string[],
* bundle_size_kb, status: 'ok' | 'no_container_id' | 'fetch_error' | 'parse_error'
* }
*
* paid_ads_count is bumped if new platforms are detected.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { query, pool } from '../../db/pool.ts';
interface Row {
business_id: number;
name: string;
raw_html_path: string;
ad_signals: any;
}
const GTM_ID_RE = /GTM-[A-Z0-9]+/i;
const PIXEL_PATTERNS: Array<[string, RegExp]> = [
['google_ads', /googleadservices\.com|AW-\d|google_conversion_id/i],
['meta_pixel', /connect\.facebook\.net\/[^"']*\/fbevents\.js|fbevents|facebook\.com\/tr/i],
['tiktok_pixel', /analytics\.tiktok\.com|business-api\.tiktok\.com|TiktokAnalyticsObject/i],
['pinterest_tag', /s\.pinimg\.com\/ct|pintrk/i],
['linkedin_insight', /snap\.licdn\.com\/li\.lms-analytics|_linkedin_(data_)?partner_id/i],
['twitter_pixel', /static\.ads-twitter\.com|analytics\.twitter\.com\/i\/adsct|t\.co\/i\/adsct/i],
['reddit_pixel', /redditstatic\.com\/ads\/pixel|RedditPixel/i],
['bing_uet', /bat\.bing\.com|bat\.r\.msn\.com/i],
['snap_pixel', /sc-static\.net\/scevent|snaptr/i],
];
async function fetchGtmBundle(containerId: string, timeoutMs = 8000): Promise<string | null> {
try {
const url = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const r = await fetch(url, {
signal: ctrl.signal,
headers: { 'User-Agent': 'ventura-corridor/0.1 (steveabramsdesigns@gmail.com)' },
});
clearTimeout(t);
if (!r.ok) return null;
return await r.text();
} catch {
return null;
}
}
function detectInBundle(bundle: string): string[] {
const found: string[] = [];
for (const [name, re] of PIXEL_PATTERNS) {
if (re.test(bundle)) found.push(name);
}
return found;
}
async function main() {
const r = await query<Row>(`
SELECT
be.business_id,
b.name,
(SELECT raw_html_path FROM front_page_audits a
WHERE a.business_id = be.business_id AND a.raw_html_path IS NOT NULL
ORDER BY a.audited_at DESC LIMIT 1) AS raw_html_path,
be.ad_signals
FROM business_enrichment be
JOIN businesses b ON b.id = be.business_id
WHERE (be.ad_signals->>'gtm_container')::boolean = true
AND (be.ad_signals->>'paid_ads_count')::int = 0
ORDER BY b.name
`);
console.log(`[gtm-ft] ${r.rowCount} GTM-only candidates`);
let okCount = 0, addedCount = 0, noIdCount = 0, fetchErrCount = 0;
const platformDeltas: Record<string, number> = {};
for (const row of r.rows) {
const rel = row.raw_html_path?.startsWith('data/') ? row.raw_html_path : 'data/' + row.raw_html_path;
let html: string;
try {
html = readFileSync(join(process.cwd(), rel), 'utf8');
} catch {
continue;
}
const m = html.match(GTM_ID_RE);
if (!m) {
noIdCount++;
await persistResult(row.business_id, row.ad_signals, {
status: 'no_container_id',
platforms_added: [],
container_id: null,
bundle_size_kb: 0,
});
continue;
}
const containerId = m[0].toUpperCase();
const bundle = await fetchGtmBundle(containerId);
if (!bundle) {
fetchErrCount++;
await persistResult(row.business_id, row.ad_signals, {
status: 'fetch_error',
platforms_added: [],
container_id: containerId,
bundle_size_kb: 0,
});
continue;
}
const platforms = detectInBundle(bundle);
okCount++;
if (platforms.length > 0) addedCount++;
for (const p of platforms) platformDeltas[p] = (platformDeltas[p] || 0) + 1;
console.log(` ${row.name.padEnd(42)} GTM=${containerId.padEnd(15)} +[${platforms.join(',')}]`);
await persistResult(row.business_id, row.ad_signals, {
status: 'ok',
platforms_added: platforms,
container_id: containerId,
bundle_size_kb: Math.round(bundle.length / 1024),
});
// Throttle a touch to be polite to GTM
await new Promise(r => setTimeout(r, 250));
}
console.log(`\n[gtm-ft] processed: ${okCount} fetched OK · ${addedCount} gained at least one platform`);
console.log(`[gtm-ft] ${noIdCount} no_container_id · ${fetchErrCount} fetch_error`);
console.log('[gtm-ft] platform delta (new detections):');
for (const [p, n] of Object.entries(platformDeltas).sort((a, b) => b[1] - a[1])) {
console.log(` ${p.padEnd(20)} +${n}`);
}
await pool.end();
}
async function persistResult(
businessId: number,
existingSignals: any,
followthrough: { status: string; platforms_added: string[]; container_id: string | null; bundle_size_kb: number },
) {
const platformsToBump = followthrough.platforms_added;
// Build a partial overlay that bumps the platform booleans + paid_ads_count + adds gtm_followthrough
const overlay: Record<string, unknown> = {
gtm_followthrough: { ...followthrough, fetched_at: new Date().toISOString() },
};
for (const p of platformsToBump) overlay[p] = true;
// Recompute paid_ads_count: existing flags OR new flags
const merged = { ...existingSignals, ...overlay };
const paidKeys = ['google_ads','meta_pixel','tiktok_pixel','pinterest_tag','linkedin_insight','twitter_pixel','reddit_pixel','bing_uet','snap_pixel'];
let paidCount = 0;
for (const k of paidKeys) if (merged[k]) paidCount++;
overlay.paid_ads_count = paidCount;
await query(
`
INSERT INTO business_enrichment (business_id, ad_signals, signals_updated_at)
VALUES ($1, $2, now())
ON CONFLICT (business_id) DO UPDATE
SET ad_signals = COALESCE(business_enrichment.ad_signals, '{}'::jsonb) || EXCLUDED.ad_signals,
signals_updated_at = now()
`,
[businessId, JSON.stringify(overlay)],
);
}
main().catch((e) => {
console.error('[gtm-ft]', e);
process.exit(1);
});