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