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