fix: Social FB — Page token + aviso si el Meta token no tiene pages_*

El token de ads no sirve para /posts; intentamos Page Access Token y explicamos los permisos necesarios.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 17:26:22 +02:00
parent 04ea6de08a
commit 224f060e9d
3 changed files with 103 additions and 11 deletions

View File

@@ -46,6 +46,71 @@ function isVideoType(t?: string | null): boolean {
); );
} }
function formatGraphError(raw: string): string {
try {
const j = JSON.parse(raw) as {
error?: {
message?: string;
code?: number;
error_subcode?: number;
error_user_title?: string;
error_user_msg?: string;
};
};
const e = j.error;
if (!e) return raw.slice(0, 220);
if (e.code === 190 || e.error_subcode === 2069032) {
return (
'Token Meta inválido o sin permisos de Página. En Business Manager → ' +
'Usuarios del sistema → genera un token con pages_show_list, ' +
'pages_read_engagement, pages_read_user_content, ' +
'instagram_basic, instagram_manage_insights (además de ads_*) y ' +
'pégalo en Agencia. Asigna el system user a cada Página.'
);
}
return (
e.error_user_title ||
e.error_user_msg ||
e.message ||
raw
).slice(0, 280);
} catch {
return raw.slice(0, 220);
}
}
/**
* Posts/insights need a Page Access Token. System-user tokens with ads_* alone
* fail with OAuthException 190 — exchange when Graph allows it.
*/
async function resolvePageAccessToken(
userToken: string,
pageId: string
): Promise<{ token: string; via: 'page' | 'user' }> {
const r = await fetch(
`${META}/${encodeURIComponent(pageId)}?fields=access_token,name&access_token=${encodeURIComponent(userToken)}`,
{ cache: 'no-store' }
);
if (r.ok) {
const j = (await r.json()) as { access_token?: string };
if (j.access_token) return { token: j.access_token, via: 'page' };
}
const accounts = await fetch(
`${META}/me/accounts?fields=id,access_token&limit=100&access_token=${encodeURIComponent(userToken)}`,
{ cache: 'no-store' }
);
if (accounts.ok) {
const j = (await accounts.json()) as {
data?: { id?: string; access_token?: string }[];
};
const hit = (j.data || []).find((p) => p.id === pageId && p.access_token);
if (hit?.access_token) return { token: hit.access_token, via: 'page' };
}
return { token: userToken, via: 'user' };
}
function insightValue( function insightValue(
data: { name?: string; values?: { value?: number }[] }[] | undefined, data: { name?: string; values?: { value?: number }[] }[] | undefined,
name: string name: string
@@ -55,14 +120,15 @@ function insightValue(
return typeof v === 'number' && Number.isFinite(v) ? v : null; return typeof v === 'number' && Number.isFinite(v) ? v : null;
} }
async function fetchPageBundle(token: string, pageId: string) { async function fetchPageBundle(agencyToken: string, pageId: string) {
const { token } = await resolvePageAccessToken(agencyToken, pageId);
const fields = const fields =
'name,fan_count,followers_count,link,picture{url},engagement,about,website'; 'name,fan_count,followers_count,link,picture{url},engagement,about,website';
const r = await fetch( const r = await fetch(
`${META}/${encodeURIComponent(pageId)}?fields=${fields}&access_token=${encodeURIComponent(token)}`, `${META}/${encodeURIComponent(pageId)}?fields=${fields}&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' } { cache: 'no-store' }
); );
if (!r.ok) return { error: (await r.text()).slice(0, 200) }; if (!r.ok) return { error: formatGraphError(await r.text()) };
const profile = await r.json(); const profile = await r.json();
const insightsRes = await fetch( const insightsRes = await fetch(
@@ -83,7 +149,7 @@ async function fetchPageBundle(token: string, pageId: string) {
} }
const postsRes = await fetch( const postsRes = await fetch(
`${META}/${encodeURIComponent(pageId)}/posts?fields=id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}&limit=16&access_token=${encodeURIComponent(token)}`, `${META}/${encodeURIComponent(pageId)}/published_posts?fields=id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}&limit=16&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' } { cache: 'no-store' }
); );
const posts: SocialPost[] = []; const posts: SocialPost[] = [];
@@ -123,10 +189,34 @@ async function fetchPageBundle(token: string, pageId: string) {
seen.add(p.id); seen.add(p.id);
} }
} else { } else {
profile.postsError = (await postsRes.text()).slice(0, 160); // Fallback /posts then surface clear error
const posts2 = await fetch(
`${META}/${encodeURIComponent(pageId)}/posts?fields=id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}&limit=16&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
if (posts2.ok) {
const j = await posts2.json();
for (const p of j.data || []) {
posts.push({
id: p.id,
platform: 'facebook',
message: p.message || '',
mediaUrl: p.full_picture || null,
thumbnailUrl: p.full_picture || null,
permalink: p.permalink_url || null,
createdAt: p.created_time || null,
likes: Number(p.likes?.summary?.total_count || 0),
comments: Number(p.comments?.summary?.total_count || 0),
shares: Number(p.shares?.count || 0),
mediaType: p.full_picture ? 'IMAGE' : 'status',
});
seen.add(p.id);
}
} else {
profile.postsError = formatGraphError(await postsRes.text());
}
} }
// Native page videos / reels often missing from /posts
const videosRes = await fetch( const videosRes = await fetch(
`${META}/${encodeURIComponent(pageId)}/videos?fields=id,description,created_time,source,permalink_url,picture,length,title&limit=10&access_token=${encodeURIComponent(token)}`, `${META}/${encodeURIComponent(pageId)}/videos?fields=id,description,created_time,source,permalink_url,picture,length,title&limit=10&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' } { cache: 'no-store' }
@@ -160,7 +250,7 @@ async function fetchIgBundle(token: string, igId: string) {
`${META}/${encodeURIComponent(igId)}?fields=username,name,followers_count,follows_count,media_count,profile_picture_url,biography,website&access_token=${encodeURIComponent(token)}`, `${META}/${encodeURIComponent(igId)}?fields=username,name,followers_count,follows_count,media_count,profile_picture_url,biography,website&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' } { cache: 'no-store' }
); );
if (!r.ok) return { error: (await r.text()).slice(0, 200) }; if (!r.ok) return { error: formatGraphError(await r.text()) };
const profile = await r.json(); const profile = await r.json();
const ins = await fetch( const ins = await fetch(
@@ -252,7 +342,7 @@ async function fetchIgBundle(token: string, igId: string) {
}); });
} }
} else { } else {
profile.postsError = (await mediaRes.text()).slice(0, 160); profile.postsError = formatGraphError(await mediaRes.text());
} }
return { ...profile, igInsights, posts }; return { ...profile, igInsights, posts };

View File

@@ -183,9 +183,11 @@ export default function AdsDiscoverPanel() {
Meta Ads + Google Ads (agencia) Meta Ads + Google Ads (agencia)
</h3> </h3>
<p className="text-xs text-gray-400 mt-0.5 max-w-xl"> <p className="text-xs text-gray-400 mt-0.5 max-w-xl">
Lista cuentas accesibles y las enlaza a cada ficha por nombre. Lista cuentas y las enlaza por nombre. El system user token debe
Meta Ads + páginas FB/IG usan el token system user (API 4V2030 / incluir ads_* <em>y</em> páginas/IG: pages_show_list,
App Review). Google Ads con el MCC de agencia. pages_read_engagement, pages_read_user_content, instagram_basic,
instagram_manage_insights. Asigna el system user a cada Página en
Business Manager.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -81,7 +81,7 @@ export async function saveMetaAccessToken(token: string): Promise<{
access_token: clean, access_token: clean,
account_email: me.name || me.id || null, account_email: me.name || me.id || null,
expires_at: null, expires_at: null,
scopes: 'ads_read,ads_management,business_management', scopes: 'ads_read,ads_management,business_management,pages_show_list,pages_read_engagement,pages_read_user_content,instagram_basic,instagram_manage_insights',
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
meta: { graph_id: me.id || null, saved_via: 'paste' }, meta: { graph_id: me.id || null, saved_via: 'paste' },
}; };