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

@@ -253,28 +253,55 @@ export function scoreFolderToTenant(
tenantName: string,
tenantSlug: string
): number {
const f = normalizeName(folderName);
const n = normalizeName(tenantName);
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
if (!f || (!n && !s)) return 0;
if (f === n || f === s) return 100;
if (n && (f.includes(n) || n.includes(f))) return 85;
if (s && (f.includes(s) || s.includes(f))) return 80;
// token overlap
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
const nt = new Set(
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
);
if (!ft.size || !nt.size) return 0;
let hit = 0;
for (const t of nt) if (ft.has(t)) hit += 1;
const ratio = hit / nt.size;
if (ratio >= 0.8) return 70;
if (ratio >= 0.5) return 55;
return Math.round(ratio * 50);
// «27-Agua Miia» / «11-Blue Parrot School» → strip numeric prefix
const stripped = folderName.replace(/^\d+[\.\-\s_]+/, '').trim();
const candidates = [folderName, stripped];
const aliasMap: Record<string, string[]> = {
bps: ['blue parrot', 'blue parrot school', 'blueparrotschool'],
'wine advisors': ['wine advisor', 'wineadvisor'],
'de tu a tu formacion': ['detuatu', 'de tu a tu'],
'clinica dental sonrisas': ['sonrisas', 'clinica sonrisas'],
};
let best = 0;
for (const raw of candidates) {
const f = normalizeName(raw);
const n = normalizeName(tenantName);
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
if (!f || (!n && !s)) continue;
if (f === n || f === s) best = Math.max(best, 100);
else if (n && (f.includes(n) || n.includes(f))) best = Math.max(best, 85);
else if (s && (f.includes(s) || s.includes(f))) best = Math.max(best, 80);
else {
const aliases = aliasMap[n] || aliasMap[s] || [];
for (const a of aliases) {
if (f.includes(a) || a.includes(f)) best = Math.max(best, 90);
}
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
const nt = new Set(
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
);
if (ft.size && nt.size) {
let hit = 0;
for (const t of nt) if (ft.has(t)) hit += 1;
const ratio = hit / nt.size;
if (ratio >= 0.8) best = Math.max(best, 70);
else if (ratio >= 0.5) best = Math.max(best, 55);
else best = Math.max(best, Math.round(ratio * 50));
}
}
}
return best;
}
export async function listDriveFolders(): Promise<DriveFolder[]> {
/** Prefer children of agency folder «Clientes»; fallback to all folders. */
const under = await listClientFoldersUnderClientes();
if (under.folders.length) return under.folders;
return listAllDriveFolders();
}
/** All folders visible to marketing@ (legacy / fallback). */
export async function listAllDriveFolders(): Promise<DriveFolder[]> {
const token = await getValidAccessToken();
const out: DriveFolder[] = [];
let pageToken: string | undefined;
@@ -306,6 +333,123 @@ export async function listDriveFolders(): Promise<DriveFolder[]> {
return out;
}
/** Find the agency «Clientes» root (prefer «03-Clientes»). */
export async function findClientesRootFolder(): Promise<DriveFolder | null> {
const token = await getValidAccessToken();
const preferred = [
'03-Clientes',
'Clientes',
'01.Clientes - Documentos maestros',
];
for (const name of preferred) {
const safe = name.replace(/'/g, "\\'");
const params = new URLSearchParams({
q: `name='${safe}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,
fields: 'files(id,name)',
pageSize: '5',
corpora: 'allDrives',
includeItemsFromAllDrives: 'true',
supportsAllDrives: 'true',
});
const r = await fetch(
`https://www.googleapis.com/drive/v3/files?${params}`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) continue;
const j = (await r.json()) as { files?: DriveFolder[] };
const hit = (j.files || []).find((f) => f.name === name);
if (hit) return hit;
}
// Fallback: any folder whose name contains «Clientes» with many children later
const params = new URLSearchParams({
q: "name contains 'Clientes' and mimeType='application/vnd.google-apps.folder' and trashed=false",
fields: 'files(id,name)',
pageSize: '20',
corpora: 'allDrives',
includeItemsFromAllDrives: 'true',
supportsAllDrives: 'true',
});
const r = await fetch(
`https://www.googleapis.com/drive/v3/files?${params}`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) return null;
const j = (await r.json()) as { files?: DriveFolder[] };
const files = (j.files || []).filter(
(f) => /clientes/i.test(f.name) && !/opensalud|documentos maestros/i.test(f.name)
);
// Prefer 03-Clientes style
return (
files.find((f) => /^0?\d+[-.]?\s*clientes$/i.test(f.name)) ||
files.find((f) => /clientes/i.test(f.name)) ||
null
);
}
/** Direct child folders of «Clientes» — the only valid tenant roots. */
export async function listClientFoldersUnderClientes(): Promise<{
clientesRoot: DriveFolder | null;
folders: DriveFolder[];
}> {
const root = await findClientesRootFolder();
if (!root) return { clientesRoot: null, folders: [] };
const children = await listFilesInFolder(root.id);
const folders = children
.filter((f) => f.mimeType === 'application/vnd.google-apps.folder')
.map((f) => ({ id: f.id, name: f.name }));
return { clientesRoot: root, folders };
}
/** Walk parents to check fileId is rootId or a descendant. */
export async function isUnderDriveFolder(
fileId: string,
rootId: string,
maxHops = 24
): Promise<boolean> {
if (!fileId || !rootId) return false;
if (fileId === rootId) return true;
const token = await getValidAccessToken();
let cur = fileId;
for (let i = 0; i < maxHops; i++) {
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(cur)}?fields=id,parents&supportsAllDrives=true`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) return false;
const j = (await r.json()) as { parents?: string[] };
const parents = j.parents || [];
if (!parents.length) return false;
if (parents.includes(rootId)) return true;
cur = parents[0];
}
return false;
}
export async function getDriveFileMeta(
fileId: string
): Promise<{ id: string; name: string; parents?: string[] } | null> {
const token = await getValidAccessToken();
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=id,name,parents&supportsAllDrives=true`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) return null;
return r.json();
}
export async function matchFoldersToTenants(
tenants: { id: string; name: string; slug: string }[],
folders: DriveFolder[]

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;