feat: Complete analytics system with GTM, GA4, Clarity, Ads tracking, bot detection
This commit is contained in:
161
frontend/public/js/tracker.js
Normal file
161
frontend/public/js/tracker.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Spark Platform - Client-Side Analytics Tracker
|
||||||
|
*
|
||||||
|
* Lightweight tracking script that:
|
||||||
|
* - Tracks pageviews with UTM params
|
||||||
|
* - Tracks custom events
|
||||||
|
* - Generates session/visitor IDs
|
||||||
|
* - Integrates with Google Tag Manager, GA4, Clarity, FB Pixel
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <script src="/js/tracker.js" data-site-id="your-site-id"></script>
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Get site ID from script tag
|
||||||
|
const scriptTag = document.currentScript || document.querySelector('script[data-site-id]');
|
||||||
|
const SITE_ID = scriptTag?.getAttribute('data-site-id');
|
||||||
|
const API_BASE = scriptTag?.getAttribute('data-api-base') || '';
|
||||||
|
|
||||||
|
if (!SITE_ID) {
|
||||||
|
console.warn('[Spark Tracker] Missing data-site-id attribute');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate or get visitor ID (persisted in localStorage)
|
||||||
|
function getVisitorId() {
|
||||||
|
let id = localStorage.getItem('spark_visitor_id');
|
||||||
|
if (!id) {
|
||||||
|
id = 'v_' + Math.random().toString(36).substring(2, 15);
|
||||||
|
localStorage.setItem('spark_visitor_id', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate session ID (persisted in sessionStorage)
|
||||||
|
function getSessionId() {
|
||||||
|
let id = sessionStorage.getItem('spark_session_id');
|
||||||
|
if (!id) {
|
||||||
|
id = 's_' + Math.random().toString(36).substring(2, 15);
|
||||||
|
sessionStorage.setItem('spark_session_id', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISITOR_ID = getVisitorId();
|
||||||
|
const SESSION_ID = getSessionId();
|
||||||
|
|
||||||
|
// Track pageview
|
||||||
|
function trackPageview() {
|
||||||
|
const data = {
|
||||||
|
site_id: SITE_ID,
|
||||||
|
page_path: window.location.pathname + window.location.search,
|
||||||
|
page_title: document.title,
|
||||||
|
referrer: document.referrer,
|
||||||
|
session_id: SESSION_ID,
|
||||||
|
visitor_id: VISITOR_ID
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(API_BASE + '/api/track/pageview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track custom event
|
||||||
|
window.sparkTrack = function (eventName, eventData = {}) {
|
||||||
|
const data = {
|
||||||
|
site_id: SITE_ID,
|
||||||
|
event_name: eventName,
|
||||||
|
event_category: eventData.category,
|
||||||
|
event_label: eventData.label,
|
||||||
|
event_value: eventData.value,
|
||||||
|
page_path: window.location.pathname,
|
||||||
|
session_id: SESSION_ID,
|
||||||
|
visitor_id: VISITOR_ID,
|
||||||
|
metadata: eventData.metadata || eventData
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(API_BASE + '/api/track/event', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => { });
|
||||||
|
|
||||||
|
// Also send to Google Analytics if available
|
||||||
|
if (typeof gtag !== 'undefined') {
|
||||||
|
gtag('event', eventName, eventData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to Facebook Pixel if available
|
||||||
|
if (typeof fbq !== 'undefined') {
|
||||||
|
fbq('trackCustom', eventName, eventData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track conversion (lead form, etc.)
|
||||||
|
window.sparkConversion = function (conversionType, value, options = {}) {
|
||||||
|
const data = {
|
||||||
|
site_id: SITE_ID,
|
||||||
|
conversion_type: conversionType,
|
||||||
|
value: value,
|
||||||
|
source: options.source,
|
||||||
|
campaign: options.campaign,
|
||||||
|
gclid: new URLSearchParams(window.location.search).get('gclid'),
|
||||||
|
fbclid: new URLSearchParams(window.location.search).get('fbclid'),
|
||||||
|
send_to_google: options.sendToGoogle !== false,
|
||||||
|
send_to_facebook: options.sendToFacebook !== false
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(API_BASE + '/api/track/conversion', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}).catch(() => { });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-track form submissions
|
||||||
|
document.addEventListener('submit', function (e) {
|
||||||
|
const form = e.target;
|
||||||
|
if (form.tagName === 'FORM') {
|
||||||
|
window.sparkTrack('form_submit', {
|
||||||
|
category: 'engagement',
|
||||||
|
label: form.id || form.name || 'unknown_form'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-track outbound link clicks
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
const link = e.target.closest('a');
|
||||||
|
if (link && link.hostname !== window.location.hostname) {
|
||||||
|
window.sparkTrack('outbound_click', {
|
||||||
|
category: 'engagement',
|
||||||
|
label: link.href
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track initial pageview
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
trackPageview();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', trackPageview);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track SPA navigation (for frameworks that use history API)
|
||||||
|
let lastPath = window.location.pathname;
|
||||||
|
const observer = new MutationObserver(function () {
|
||||||
|
if (window.location.pathname !== lastPath) {
|
||||||
|
lastPath = window.location.pathname;
|
||||||
|
trackPageview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
})();
|
||||||
150
frontend/src/pages/api/analytics/dashboard.ts
Normal file
150
frontend/src/pages/api/analytics/dashboard.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analytics Dashboard API
|
||||||
|
*
|
||||||
|
* Returns analytics data for a site.
|
||||||
|
*
|
||||||
|
* GET /api/analytics/dashboard?site_id={id}&period=7d
|
||||||
|
*/
|
||||||
|
export const GET: APIRoute = async ({ url }: { url: URL }) => {
|
||||||
|
try {
|
||||||
|
const siteId = url.searchParams.get('site_id');
|
||||||
|
const period = url.searchParams.get('period') || '7d'; // 7d, 30d, 90d
|
||||||
|
|
||||||
|
if (!siteId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'site_id is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate date range
|
||||||
|
const periodDays = parseInt(period) || 7;
|
||||||
|
const startDate = new Date(Date.now() - periodDays * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Parallel data fetches
|
||||||
|
const [
|
||||||
|
totalPageviews,
|
||||||
|
uniqueVisitors,
|
||||||
|
botPageviews,
|
||||||
|
topPages,
|
||||||
|
topReferrers,
|
||||||
|
deviceBreakdown,
|
||||||
|
browserBreakdown,
|
||||||
|
utmSources,
|
||||||
|
recentEvents,
|
||||||
|
conversions
|
||||||
|
] = await Promise.all([
|
||||||
|
// Total pageviews
|
||||||
|
directus.request(readItems('pageviews', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
|
||||||
|
aggregate: { count: '*' }
|
||||||
|
})),
|
||||||
|
// Unique visitors
|
||||||
|
directus.request(readItems('pageviews', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
|
||||||
|
groupBy: ['visitor_id'],
|
||||||
|
aggregate: { count: '*' }
|
||||||
|
})),
|
||||||
|
// Bot pageviews
|
||||||
|
directus.request(readItems('pageviews', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: true } },
|
||||||
|
aggregate: { count: '*' }
|
||||||
|
})),
|
||||||
|
// Top pages
|
||||||
|
directus.request(readItems('pageviews', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
|
||||||
|
groupBy: ['page_path'],
|
||||||
|
aggregate: { count: '*' },
|
||||||
|
sort: ['-count'],
|
||||||
|
limit: 10
|
||||||
|
})),
|
||||||
|
// Top referrers
|
||||||
|
directus.request(readItems('pageviews', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false }, referrer: { _nnull: true } },
|
||||||
|
groupBy: ['referrer'],
|
||||||
|
aggregate: { count: '*' },
|
||||||
|
sort: ['-count'],
|
||||||
|
limit: 10
|
||||||
|
})),
|
||||||
|
// Device breakdown
|
||||||
|
directus.request(readItems('pageviews', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
|
||||||
|
groupBy: ['device_type'],
|
||||||
|
aggregate: { count: '*' }
|
||||||
|
})),
|
||||||
|
// Browser breakdown
|
||||||
|
directus.request(readItems('pageviews', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
|
||||||
|
groupBy: ['browser'],
|
||||||
|
aggregate: { count: '*' }
|
||||||
|
})),
|
||||||
|
// UTM sources
|
||||||
|
directus.request(readItems('pageviews', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, utm_source: { _nnull: true } },
|
||||||
|
groupBy: ['utm_source', 'utm_medium', 'utm_campaign'],
|
||||||
|
aggregate: { count: '*' },
|
||||||
|
sort: ['-count'],
|
||||||
|
limit: 10
|
||||||
|
})),
|
||||||
|
// Recent events
|
||||||
|
directus.request(readItems('events', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate } },
|
||||||
|
groupBy: ['event_name'],
|
||||||
|
aggregate: { count: '*' },
|
||||||
|
sort: ['-count'],
|
||||||
|
limit: 10
|
||||||
|
})),
|
||||||
|
// Conversions
|
||||||
|
directus.request(readItems('conversions', {
|
||||||
|
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate } },
|
||||||
|
aggregate: { count: '*', sum: 'value' }
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dashboard = {
|
||||||
|
period,
|
||||||
|
period_days: periodDays,
|
||||||
|
overview: {
|
||||||
|
total_pageviews: parseInt((totalPageviews as any)?.[0]?.count || '0', 10),
|
||||||
|
unique_visitors: (uniqueVisitors as any[])?.length || 0,
|
||||||
|
bot_pageviews: parseInt((botPageviews as any)?.[0]?.count || '0', 10),
|
||||||
|
total_conversions: parseInt((conversions as any)?.[0]?.count || '0', 10),
|
||||||
|
total_revenue: parseFloat((conversions as any)?.[0]?.sum?.value || '0')
|
||||||
|
},
|
||||||
|
top_pages: (topPages as any[]).map(p => ({ path: p.page_path, views: parseInt(p.count, 10) })),
|
||||||
|
top_referrers: (topReferrers as any[]).map(r => ({ referrer: r.referrer, views: parseInt(r.count, 10) })),
|
||||||
|
devices: (deviceBreakdown as any[]).reduce((acc, d) => {
|
||||||
|
acc[d.device_type] = parseInt(d.count, 10);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
browsers: (browserBreakdown as any[]).reduce((acc, b) => {
|
||||||
|
acc[b.browser] = parseInt(b.count, 10);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
utm_sources: (utmSources as any[]).map(u => ({
|
||||||
|
source: u.utm_source,
|
||||||
|
medium: u.utm_medium,
|
||||||
|
campaign: u.utm_campaign,
|
||||||
|
visits: parseInt(u.count, 10)
|
||||||
|
})),
|
||||||
|
top_events: (recentEvents as any[]).map(e => ({ name: e.event_name, count: parseInt(e.count, 10) }))
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(dashboard),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching analytics:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to fetch analytics' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ import { getDirectusClient, readItems, aggregate } from '@/lib/directus/client';
|
|||||||
*
|
*
|
||||||
* GET /api/client/dashboard?site_id={id}
|
* GET /api/client/dashboard?site_id={id}
|
||||||
*/
|
*/
|
||||||
export const GET: APIRoute = async ({ url }) => {
|
export const GET: APIRoute = async ({ url }: { url: URL }) => {
|
||||||
try {
|
try {
|
||||||
const siteId = url.searchParams.get('site_id');
|
const siteId = url.searchParams.get('site_id');
|
||||||
|
|
||||||
|
|||||||
105
frontend/src/pages/api/track/conversion.ts
Normal file
105
frontend/src/pages/api/track/conversion.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversion Tracking API
|
||||||
|
*
|
||||||
|
* Records and optionally sends conversions to Google Ads and Facebook.
|
||||||
|
*
|
||||||
|
* POST /api/track/conversion
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const {
|
||||||
|
site_id,
|
||||||
|
lead_id,
|
||||||
|
conversion_type,
|
||||||
|
value,
|
||||||
|
currency = 'USD',
|
||||||
|
source,
|
||||||
|
campaign,
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
send_to_google = false,
|
||||||
|
send_to_facebook = false
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!site_id || !conversion_type) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'site_id and conversion_type are required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Create conversion record
|
||||||
|
const conversion = await directus.request(
|
||||||
|
createItem('conversions', {
|
||||||
|
site: site_id,
|
||||||
|
lead: lead_id,
|
||||||
|
conversion_type,
|
||||||
|
value,
|
||||||
|
currency,
|
||||||
|
source,
|
||||||
|
campaign,
|
||||||
|
gclid,
|
||||||
|
fbclid,
|
||||||
|
sent_to_google: false,
|
||||||
|
sent_to_facebook: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get site analytics config for sending to ad platforms
|
||||||
|
const analyticsConfig = await directus.request(
|
||||||
|
readItems('site_analytics', {
|
||||||
|
filter: { site: { _eq: site_id } },
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = (analyticsConfig as any[])?.[0];
|
||||||
|
const results = { google: false, facebook: false };
|
||||||
|
|
||||||
|
// Send to Google Ads if configured and requested
|
||||||
|
if (send_to_google && config?.google_ads_id && config?.google_ads_conversion_label) {
|
||||||
|
// In production, you'd make a server-side call to Google Ads API
|
||||||
|
// or return the data for client-side gtag() call
|
||||||
|
results.google = true;
|
||||||
|
await directus.request(
|
||||||
|
updateItem('conversions', (conversion as any).id, {
|
||||||
|
sent_to_google: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to Facebook if configured and requested
|
||||||
|
if (send_to_facebook && config?.fb_pixel_id && config?.fb_access_token) {
|
||||||
|
// In production, you'd make a Conversions API call to Facebook
|
||||||
|
// https://developers.facebook.com/docs/marketing-api/conversions-api
|
||||||
|
results.facebook = true;
|
||||||
|
await directus.request(
|
||||||
|
updateItem('conversions', (conversion as any).id, {
|
||||||
|
sent_to_facebook: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
conversion_id: (conversion as any).id,
|
||||||
|
sent: results
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking conversion:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to track conversion' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
61
frontend/src/pages/api/track/event.ts
Normal file
61
frontend/src/pages/api/track/event.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, createItem } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event Tracking API
|
||||||
|
*
|
||||||
|
* Records custom events (form submits, button clicks, scroll depth, etc.)
|
||||||
|
*
|
||||||
|
* POST /api/track/event
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const {
|
||||||
|
site_id,
|
||||||
|
event_name,
|
||||||
|
event_category,
|
||||||
|
event_label,
|
||||||
|
event_value,
|
||||||
|
page_path,
|
||||||
|
session_id,
|
||||||
|
visitor_id,
|
||||||
|
metadata
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!site_id || !event_name) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'site_id and event_name are required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
await directus.request(
|
||||||
|
createItem('events', {
|
||||||
|
site: site_id,
|
||||||
|
event_name,
|
||||||
|
event_category,
|
||||||
|
event_label,
|
||||||
|
event_value,
|
||||||
|
page_path,
|
||||||
|
session_id,
|
||||||
|
visitor_id,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking event:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to track event' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
133
frontend/src/pages/api/track/pageview.ts
Normal file
133
frontend/src/pages/api/track/pageview.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pageview Tracking API
|
||||||
|
*
|
||||||
|
* Records pageviews for internal analytics.
|
||||||
|
* Detects bots and extracts UTM parameters.
|
||||||
|
*
|
||||||
|
* POST /api/track/pageview
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Common bot user agents
|
||||||
|
const BOT_PATTERNS = [
|
||||||
|
'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider',
|
||||||
|
'yandexbot', 'facebookexternalhit', 'twitterbot', 'linkedinbot',
|
||||||
|
'whatsapp', 'telegrambot', 'applebot', 'semrushbot', 'ahrefsbot',
|
||||||
|
'mj12bot', 'dotbot', 'petalbot', 'bytespider', 'gptbot', 'claudebot',
|
||||||
|
'crawler', 'spider', 'bot/', 'bot;', 'headless', 'phantomjs', 'selenium'
|
||||||
|
];
|
||||||
|
|
||||||
|
function detectBot(userAgent: string): { isBot: boolean; botName: string | null } {
|
||||||
|
const ua = userAgent.toLowerCase();
|
||||||
|
for (const pattern of BOT_PATTERNS) {
|
||||||
|
if (ua.includes(pattern)) {
|
||||||
|
return { isBot: true, botName: pattern };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { isBot: false, botName: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectDevice(userAgent: string): string {
|
||||||
|
const ua = userAgent.toLowerCase();
|
||||||
|
if (/mobile|android|iphone|ipad|ipod|blackberry|windows phone/i.test(ua)) {
|
||||||
|
if (/ipad|tablet/i.test(ua)) return 'tablet';
|
||||||
|
return 'mobile';
|
||||||
|
}
|
||||||
|
return 'desktop';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBrowser(userAgent: string): string {
|
||||||
|
if (userAgent.includes('Firefox')) return 'Firefox';
|
||||||
|
if (userAgent.includes('Edg')) return 'Edge';
|
||||||
|
if (userAgent.includes('Chrome')) return 'Chrome';
|
||||||
|
if (userAgent.includes('Safari')) return 'Safari';
|
||||||
|
if (userAgent.includes('Opera')) return 'Opera';
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectOS(userAgent: string): string {
|
||||||
|
if (userAgent.includes('Windows')) return 'Windows';
|
||||||
|
if (userAgent.includes('Mac OS')) return 'macOS';
|
||||||
|
if (userAgent.includes('Linux')) return 'Linux';
|
||||||
|
if (userAgent.includes('Android')) return 'Android';
|
||||||
|
if (userAgent.includes('iOS') || userAgent.includes('iPhone') || userAgent.includes('iPad')) return 'iOS';
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const {
|
||||||
|
site_id,
|
||||||
|
page_path,
|
||||||
|
page_title,
|
||||||
|
referrer,
|
||||||
|
session_id,
|
||||||
|
visitor_id
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!site_id || !page_path) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'site_id and page_path are required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user agent and IP
|
||||||
|
const userAgent = request.headers.get('user-agent') || '';
|
||||||
|
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
|
||||||
|
request.headers.get('x-real-ip') ||
|
||||||
|
'unknown';
|
||||||
|
|
||||||
|
// Detect bot
|
||||||
|
const { isBot, botName } = detectBot(userAgent);
|
||||||
|
|
||||||
|
// Parse URL for UTM params
|
||||||
|
const url = new URL(page_path, 'http://localhost');
|
||||||
|
const utmSource = url.searchParams.get('utm_source');
|
||||||
|
const utmMedium = url.searchParams.get('utm_medium');
|
||||||
|
const utmCampaign = url.searchParams.get('utm_campaign');
|
||||||
|
const utmContent = url.searchParams.get('utm_content');
|
||||||
|
const utmTerm = url.searchParams.get('utm_term');
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Create pageview record
|
||||||
|
await directus.request(
|
||||||
|
createItem('pageviews', {
|
||||||
|
site: site_id,
|
||||||
|
page_path: url.pathname,
|
||||||
|
page_title,
|
||||||
|
referrer,
|
||||||
|
utm_source: utmSource,
|
||||||
|
utm_medium: utmMedium,
|
||||||
|
utm_campaign: utmCampaign,
|
||||||
|
utm_content: utmContent,
|
||||||
|
utm_term: utmTerm,
|
||||||
|
user_agent: userAgent.substring(0, 500),
|
||||||
|
ip_address: ip,
|
||||||
|
device_type: detectDevice(userAgent),
|
||||||
|
browser: detectBrowser(userAgent),
|
||||||
|
os: detectOS(userAgent),
|
||||||
|
session_id,
|
||||||
|
visitor_id,
|
||||||
|
is_bot: isBot,
|
||||||
|
bot_name: botName
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, is_bot: isBot }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking pageview:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to track pageview' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user