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>
This commit is contained in:
4Ventures
2026-09-24 16:12:07 +02:00
parent 3425364b07
commit f4dfc50cc9
9 changed files with 699 additions and 107 deletions

View File

@@ -283,6 +283,97 @@ 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;