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[]