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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user