Files
4ventures-client-portal/src/lib/google-organic.ts
4Ventures f4dfc50cc9 feat: calendario bajo Social, SEO/GEO con datos live, Drive = 03-Clientes
Calendario editorial anidado en Social; Orgánico con queries/páginas GSC y landings GA4 + dashboard GEO; Biblioteca enlazada a la carpeta real del cliente bajo 03-Clientes (no carpetas homónimas); previews Ads solo en Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:12:07 +02:00

753 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** GA4 + Search Console discover & sync using agency Google OAuth (marketing@). */
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { getValidAccessToken } from '@/lib/google-drive';
export type Ga4Property = {
propertyId: string; // properties/123
displayName: string;
accountName: string;
measurementId: string | null; // G-XXXX
};
export type GscSite = {
siteUrl: string;
permissionLevel: string;
};
function n(v: unknown): number {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
export async function listGa4Properties(): Promise<Ga4Property[]> {
const token = await getValidAccessToken();
const out: Ga4Property[] = [];
let pageToken: string | undefined;
do {
const url = new URL(
'https://analyticsadmin.googleapis.com/v1beta/accountSummaries'
);
url.searchParams.set('pageSize', '200');
if (pageToken) url.searchParams.set('pageToken', pageToken);
const r = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!r.ok) {
throw new Error(`GA4 Admin: ${(await r.text()).slice(0, 300)}`);
}
const j = (await r.json()) as {
accountSummaries?: {
displayName?: string;
propertySummaries?: { property?: string; displayName?: string }[];
}[];
nextPageToken?: string;
};
for (const acc of j.accountSummaries || []) {
for (const p of acc.propertySummaries || []) {
if (!p.property) continue;
const measurementId = await fetchMeasurementId(token, p.property);
out.push({
propertyId: p.property,
displayName: p.displayName || p.property,
accountName: acc.displayName || '',
measurementId,
});
}
}
pageToken = j.nextPageToken;
} while (pageToken);
return out;
}
async function fetchMeasurementId(
token: string,
propertyResource: string
): Promise<string | null> {
// propertyResource = "properties/123456"
const r = await fetch(
`https://analyticsadmin.googleapis.com/v1beta/${propertyResource}/dataStreams`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) return null;
const j = (await r.json()) as {
dataStreams?: {
webStreamData?: { measurementId?: string };
type?: string;
}[];
};
for (const s of j.dataStreams || []) {
const mid = s.webStreamData?.measurementId;
if (mid) return mid;
}
return null;
}
export async function listGscSites(): Promise<GscSite[]> {
const token = await getValidAccessToken();
const r = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!r.ok) {
throw new Error(`GSC: ${(await r.text()).slice(0, 300)}`);
}
const j = (await r.json()) as {
siteEntry?: { siteUrl?: string; permissionLevel?: string }[];
};
return (j.siteEntry || [])
.filter((s) => s.siteUrl)
.map((s) => ({
siteUrl: s.siteUrl!,
permissionLevel: s.permissionLevel || '',
}));
}
function normName(s: string): string {
return s
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function scoreNames(a: string, b: string): number {
const x = normName(a);
const y = normName(b);
if (!x || !y) return 0;
if (x === y) return 100;
if (x.includes(y) || y.includes(x)) return 80;
const xt = new Set(x.split(' ').filter(Boolean));
const yt = new Set(y.split(' ').filter(Boolean));
let hit = 0;
for (const t of xt) if (yt.has(t)) hit += 1;
if (!xt.size || !yt.size) return 0;
return Math.round((100 * hit) / Math.max(xt.size, yt.size));
}
export type OrganicMatch = {
tenantId: string;
tenantName: string;
tenantSlug: string;
ga4?: {
propertyId: string;
measurementId: string | null;
displayName: string;
score: number;
} | null;
gsc?: { siteUrl: string; score: number } | null;
};
export async function matchOrganicToTenants(
tenants: { id: string; name: string; slug: string }[],
ga4: Ga4Property[],
gsc: GscSite[]
): Promise<OrganicMatch[]> {
return tenants.map((t) => {
let bestGa: OrganicMatch['ga4'] = null;
for (const p of ga4) {
const s = Math.max(
scoreNames(t.name, p.displayName),
scoreNames(t.slug, p.displayName),
scoreNames(t.name, p.accountName)
);
if (!bestGa || s > bestGa.score) {
bestGa = {
propertyId: p.propertyId,
measurementId: p.measurementId,
displayName: p.displayName,
score: s,
};
}
}
if (bestGa && bestGa.score < 50) bestGa = null;
let bestGsc: OrganicMatch['gsc'] = null;
for (const s of gsc) {
const url = s.siteUrl.replace(/^sc-domain:/, '').replace(/\/$/, '');
const score = Math.max(
scoreNames(t.name, url),
scoreNames(t.slug, url),
scoreNames(t.name, s.siteUrl)
);
if (!bestGsc || score > bestGsc.score) {
bestGsc = { siteUrl: s.siteUrl, score };
}
}
if (bestGsc && bestGsc.score < 40) bestGsc = null;
return {
tenantId: t.id,
tenantName: t.name,
tenantSlug: t.slug,
ga4: bestGa,
gsc: bestGsc,
};
});
}
/** Apply matches → integrations ga4 / search_console connected. */
export async function applyOrganicMatches(
matches: OrganicMatch[]
): Promise<{ ga4: number; gsc: number }> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
let ga4 = 0;
let gsc = 0;
const base = supabaseUrl();
for (const m of matches) {
if (m.ga4) {
const accountId = m.ga4.measurementId || m.ga4.propertyId;
const ok = await upsertIntegration(base, headers, m.tenantId, 'ga4', {
status: 'connected',
external_account_id: accountId,
label: m.ga4.displayName,
meta: {
property_id: m.ga4.propertyId,
measurement_id: m.ga4.measurementId,
},
last_sync_at: new Date().toISOString(),
error_message: null,
});
if (ok) ga4 += 1;
}
if (m.gsc) {
const ok = await upsertIntegration(
base,
headers,
m.tenantId,
'search_console',
{
status: 'connected',
external_account_id: m.gsc.siteUrl,
label: m.gsc.siteUrl,
meta: { site_url: m.gsc.siteUrl },
last_sync_at: new Date().toISOString(),
error_message: null,
}
);
if (ok) gsc += 1;
}
}
return { ga4, gsc };
}
async function upsertIntegration(
base: string,
headers: { apikey: string; Authorization: string },
tenantId: string,
provider: string,
patch: Record<string, unknown>
): Promise<boolean> {
const r = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(patch),
}
);
if (r.ok) return true;
const ins = await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates',
},
body: JSON.stringify({
tenant_id: tenantId,
provider,
enabled: true,
...patch,
}),
});
return ins.ok;
}
function isoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
export async function fetchGscTopRows(opts: {
siteUrl: string;
startDate: string;
endDate: string;
dimension: 'query' | 'page';
rowLimit?: number;
}): Promise<
{ key: string; clicks: number; impressions: number; ctr: number; position: number }[]
> {
const token = await getValidAccessToken();
const site = encodeURIComponent(opts.siteUrl);
const r = await fetch(
`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
startDate: opts.startDate,
endDate: opts.endDate,
dimensions: [opts.dimension],
rowLimit: opts.rowLimit ?? 25,
}),
}
);
if (!r.ok) throw new Error(`GSC ${opts.dimension}: ${(await r.text()).slice(0, 160)}`);
const j = (await r.json()) as {
rows?: {
keys?: string[];
clicks?: number;
impressions?: number;
ctr?: number;
position?: number;
}[];
};
return (j.rows || []).map((row) => ({
key: row.keys?.[0] || '',
clicks: n(row.clicks),
impressions: n(row.impressions),
ctr: row.ctr != null ? Math.round(n(row.ctr) * 10000) / 100 : 0,
position: row.position != null ? Math.round(n(row.position) * 10) / 10 : 0,
}));
}
export async function fetchGa4TopLandings(opts: {
propertyId: string;
startDate: string;
endDate: string;
rowLimit?: number;
}): Promise<{ page: string; sessions: number; users: number }[]> {
const token = await getValidAccessToken();
const prop = opts.propertyId.replace(/^properties\//, '');
const r = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
dateRanges: [{ startDate: opts.startDate, endDate: opts.endDate }],
dimensions: [{ name: 'landingPagePlusQueryString' }],
metrics: [{ name: 'sessions' }, { name: 'totalUsers' }],
dimensionFilter: {
filter: {
fieldName: 'sessionDefaultChannelGroup',
stringFilter: { value: 'Organic Search' },
},
},
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
limit: opts.rowLimit ?? 20,
}),
}
);
if (!r.ok) throw new Error(`GA4 landings: ${(await r.text()).slice(0, 160)}`);
const j = (await r.json()) as {
rows?: {
dimensionValues?: { value?: string }[];
metricValues?: { value?: string }[];
}[];
};
return (j.rows || []).map((row) => ({
page: row.dimensionValues?.[0]?.value || '/',
sessions: n(row.metricValues?.[0]?.value),
users: n(row.metricValues?.[1]?.value),
}));
}
/** Sync last N days of GSC + GA4 into organic_daily for one tenant. */
export async function syncOrganicTenant(opts: {
tenantId: string;
ga4PropertyId?: string | null; // properties/123
gscSiteUrl?: string | null;
days?: number;
}): Promise<{ daysWritten: number; warning?: string }> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const days = opts.days ?? 28;
const token = await getValidAccessToken();
const until = new Date();
until.setUTCDate(until.getUTCDate() - 1); // yesterday
const since = new Date(until);
since.setUTCDate(since.getUTCDate() - (days - 1));
const start = isoDay(since);
const end = isoDay(until);
const byDay = new Map<
string,
{
gsc_clicks: number;
gsc_impressions: number;
gsc_ctr: number | null;
gsc_avg_position: number | null;
ga4_sessions: number;
ga4_users: number;
ga4_organic_sessions: number;
ga4_conversions: number;
}
>();
function ensure(day: string) {
if (!byDay.has(day)) {
byDay.set(day, {
gsc_clicks: 0,
gsc_impressions: 0,
gsc_ctr: null,
gsc_avg_position: null,
ga4_sessions: 0,
ga4_users: 0,
ga4_organic_sessions: 0,
ga4_conversions: 0,
});
}
return byDay.get(day)!;
}
const warnings: string[] = [];
if (opts.gscSiteUrl) {
try {
const site = encodeURIComponent(opts.gscSiteUrl);
const r = await fetch(
`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
startDate: start,
endDate: end,
dimensions: ['date'],
rowLimit: 1000,
}),
}
);
if (!r.ok) {
warnings.push(`GSC ${(await r.text()).slice(0, 120)}`);
} else {
const j = (await r.json()) as {
rows?: {
keys?: string[];
clicks?: number;
impressions?: number;
ctr?: number;
position?: number;
}[];
};
for (const row of j.rows || []) {
const day = (row.keys?.[0] || '').slice(0, 10);
if (!day) continue;
const cur = ensure(day);
cur.gsc_clicks = n(row.clicks);
cur.gsc_impressions = n(row.impressions);
cur.gsc_ctr =
row.ctr != null ? Math.round(n(row.ctr) * 10000) / 100 : null;
cur.gsc_avg_position =
row.position != null ? Math.round(n(row.position) * 10) / 10 : null;
}
}
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'gsc error');
}
}
if (opts.ga4PropertyId) {
try {
const prop = opts.ga4PropertyId.replace(/^properties\//, '');
// Totals by day
const r = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
dateRanges: [{ startDate: start, endDate: end }],
dimensions: [{ name: 'date' }],
metrics: [
{ name: 'sessions' },
{ name: 'totalUsers' },
{ name: 'conversions' },
],
}),
}
);
if (!r.ok) {
warnings.push(`GA4 ${(await r.text()).slice(0, 120)}`);
} else {
const j = (await r.json()) as {
rows?: {
dimensionValues?: { value?: string }[];
metricValues?: { value?: string }[];
}[];
};
for (const row of j.rows || []) {
const raw = row.dimensionValues?.[0]?.value || '';
// GA4 date = YYYYMMDD
const day =
raw.length === 8
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
: raw.slice(0, 10);
if (!day) continue;
const cur = ensure(day);
cur.ga4_sessions = n(row.metricValues?.[0]?.value);
cur.ga4_users = n(row.metricValues?.[1]?.value);
cur.ga4_conversions = n(row.metricValues?.[2]?.value);
}
}
// Organic sessions by day
const r2 = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
dateRanges: [{ startDate: start, endDate: end }],
dimensions: [{ name: 'date' }, { name: 'sessionDefaultChannelGroup' }],
metrics: [{ name: 'sessions' }],
dimensionFilter: {
filter: {
fieldName: 'sessionDefaultChannelGroup',
stringFilter: { value: 'Organic Search' },
},
},
}),
}
);
if (r2.ok) {
const j = (await r2.json()) as {
rows?: {
dimensionValues?: { value?: string }[];
metricValues?: { value?: string }[];
}[];
};
for (const row of j.rows || []) {
const raw = row.dimensionValues?.[0]?.value || '';
const day =
raw.length === 8
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
: raw.slice(0, 10);
if (!day) continue;
ensure(day).ga4_organic_sessions = n(row.metricValues?.[0]?.value);
}
}
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'ga4 error');
}
}
const rows = [...byDay.entries()].map(([day, v]) => ({
tenant_id: opts.tenantId,
day,
...v,
source: 'sync',
}));
if (!rows.length) {
return {
daysWritten: 0,
warning: warnings[0] || 'Sin filas GA4/GSC en el periodo',
};
}
const upsert = await fetch(
`${supabaseUrl()}/rest/v1/organic_daily?on_conflict=tenant_id,day`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=minimal',
},
body: JSON.stringify(rows),
}
);
if (!upsert.ok) {
const msg = await upsert.text();
throw new Error(
msg.includes('organic_daily')
? 'Aplicar sql/organic_geo.sql'
: msg.slice(0, 300)
);
}
return {
daysWritten: rows.length,
warning: warnings[0],
};
}
type IntegRow = {
tenant_id: string;
provider: string;
external_account_id?: string;
meta?: { property_id?: string; measurement_id?: string; site_url?: string };
last_sync_at?: string | null;
};
/** Sync all (or one) tenants with connected GA4/GSC. Rolling window ending yesterday. */
export async function syncAllConnectedOrganic(opts?: {
tenantId?: string | null;
/** Days ending yesterday (default 28; daily cron can use 3–7). */
days?: number;
}): Promise<{
synced: number;
results: { tenantId: string; daysWritten: number; warning?: string }[];
}> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const days = Math.min(90, Math.max(3, opts?.days ?? 28));
const tenantFilter = opts?.tenantId?.trim() || '';
const iRes = await fetch(
`${supabaseUrl()}/rest/v1/integrations?provider=in.(ga4,search_console)&status=eq.connected&select=tenant_id,provider,external_account_id,meta,last_sync_at`,
{ headers, cache: 'no-store' }
);
if (!iRes.ok) throw new Error((await iRes.text()).slice(0, 200));
const integs = (await iRes.json()) as IntegRow[];
const byTenant = new Map<
string,
{ ga4PropertyId?: string; gscSiteUrl?: string; measurementId?: string }
>();
for (const i of integs) {
if (tenantFilter && i.tenant_id !== tenantFilter) continue;
const cur = byTenant.get(i.tenant_id) || {};
if (i.provider === 'ga4') {
cur.ga4PropertyId =
i.meta?.property_id ||
(i.external_account_id?.startsWith('properties/')
? i.external_account_id
: undefined);
if (i.external_account_id?.startsWith('G-')) {
cur.measurementId = i.external_account_id;
}
cur.measurementId = cur.measurementId || i.meta?.measurement_id || undefined;
}
if (i.provider === 'search_console') {
cur.gscSiteUrl = i.meta?.site_url || i.external_account_id || undefined;
}
byTenant.set(i.tenant_id, cur);
}
let ga4Catalog: Ga4Property[] | null = null;
const results: {
tenantId: string;
daysWritten: number;
warning?: string;
}[] = [];
for (const [tenantId, cfg] of byTenant) {
let propId = cfg.ga4PropertyId;
if (!propId && cfg.measurementId?.startsWith('G-')) {
if (!ga4Catalog) {
try {
ga4Catalog = await listGa4Properties();
} catch {
ga4Catalog = [];
}
}
const hit = ga4Catalog.find((p) => p.measurementId === cfg.measurementId);
propId = hit?.propertyId;
}
if (!propId && !cfg.gscSiteUrl) continue;
try {
const r = await syncOrganicTenant({
tenantId,
ga4PropertyId: propId,
gscSiteUrl: cfg.gscSiteUrl,
days,
});
// bump last_sync on integrations
await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ last_sync_at: new Date().toISOString() }),
}
);
results.push({ tenantId, ...r });
} catch (e) {
results.push({
tenantId,
daysWritten: 0,
warning: e instanceof Error ? e.message : 'sync error',
});
}
}
return {
synced: results.filter((r) => r.daysWritten > 0).length,
results,
};
}
/** True if we should refresh this tenant (no yesterday row or sync older than maxAgeMs). */
export async function organicNeedsRefresh(
tenantId: string,
maxAgeMs = 6 * 60 * 60 * 1000
): Promise<boolean> {
const headers = serviceHeaders();
if (!headers) return false;
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const y = yesterday.toISOString().slice(0, 10);
const [od, ig] = await Promise.all([
fetch(
`${supabaseUrl()}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&day=eq.${y}&select=day&limit=1`,
{ headers, cache: 'no-store' }
),
fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)&status=eq.connected&select=last_sync_at&limit=2`,
{ headers, cache: 'no-store' }
),
]);
if (od.ok) {
const rows = await od.json();
if (!rows.length) return true;
}
if (ig.ok) {
const rows = (await ig.json()) as { last_sync_at?: string }[];
if (!rows.length) return false; // nothing connected
const latest = rows
.map((r) => (r.last_sync_at ? new Date(r.last_sync_at).getTime() : 0))
.reduce((a, b) => Math.max(a, b), 0);
if (!latest || Date.now() - latest > maxAgeMs) return true;
}
return false;
}