Harden Meta publish against dirty captions and IG duplicates.
Strip production notes before posting, persist each network id immediately, refuse IG republish if delete fails, and place FB photos via feed for better timeline visibility. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
normalizeChannels,
|
||||
type PublishChannel,
|
||||
} from '@/lib/pipeline-schedule';
|
||||
import { sanitizePublishCaption } from '@/lib/publish-caption';
|
||||
|
||||
const META = 'https://graph.facebook.com/v21.0';
|
||||
|
||||
@@ -41,6 +42,9 @@ export type PublishResult = {
|
||||
fbPermalink?: string | null;
|
||||
error?: string;
|
||||
channels: PublishChannel[];
|
||||
/** Caption actually sent to Meta (production noise stripped). */
|
||||
sanitizedCaption?: string | null;
|
||||
captionStripped?: string[];
|
||||
};
|
||||
|
||||
type GraphJson = Record<string, unknown>;
|
||||
@@ -376,12 +380,17 @@ async function waitIgContainer(
|
||||
return { ok: false, error: 'Timeout esperando contenedor IG' };
|
||||
}
|
||||
|
||||
function publicCaption(item: PipelinePublishRow, maxLen = 2200): string {
|
||||
const raw = item.caption || item.title || '';
|
||||
return sanitizePublishCaption(raw, { maxLen }).caption || (item.title || '').slice(0, maxLen);
|
||||
}
|
||||
|
||||
async function publishIgMedia(
|
||||
igUserId: string,
|
||||
pageToken: string,
|
||||
item: PipelinePublishRow
|
||||
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
|
||||
const caption = (item.caption || item.title || '').slice(0, 2200);
|
||||
const caption = publicCaption(item, 2200);
|
||||
const format = (item.format || 'post').toLowerCase();
|
||||
const slides = slidePathsOf(item.slide_paths);
|
||||
const cover = item.cover_path || slides[0] || null;
|
||||
@@ -503,12 +512,49 @@ async function publishIgMedia(
|
||||
return { id: mediaId, permalink };
|
||||
}
|
||||
|
||||
async function publishFbFeedWithPhotos(
|
||||
pageId: string,
|
||||
pageToken: string,
|
||||
message: string,
|
||||
photoIds: string[]
|
||||
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
|
||||
if (!photoIds.length) {
|
||||
return { id: null, permalink: null, error: 'Sin fotos para el feed FB' };
|
||||
}
|
||||
const form = new URLSearchParams({
|
||||
message,
|
||||
access_token: pageToken,
|
||||
});
|
||||
photoIds.forEach((id, i) => {
|
||||
form.append(`attached_media[${i}]`, JSON.stringify({ media_fbid: id }));
|
||||
});
|
||||
const r = await fetch(`${META}/${encodeURIComponent(pageId)}/feed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const raw = await r.text();
|
||||
let json: GraphJson = {};
|
||||
try {
|
||||
json = JSON.parse(raw) as GraphJson;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
if (!r.ok || typeof json.id !== 'string') {
|
||||
return { id: null, permalink: null, error: formatGraphError(raw) };
|
||||
}
|
||||
return { id: json.id, permalink: null };
|
||||
}
|
||||
|
||||
async function publishFbPost(
|
||||
pageId: string,
|
||||
pageToken: string,
|
||||
item: PipelinePublishRow
|
||||
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
|
||||
const message = (item.caption || item.title || '').slice(0, 6000);
|
||||
// Prefer /feed + unpublished photos so the post lands on the Page timeline
|
||||
// (direct /photos published=true often only shows in Fotos, not on top).
|
||||
const message = publicCaption(item, 6000);
|
||||
const format = (item.format || 'post').toLowerCase();
|
||||
const slides = slidePathsOf(item.slide_paths);
|
||||
const cover = item.cover_path || slides[0] || null;
|
||||
@@ -540,68 +586,33 @@ async function publishFbPost(
|
||||
return { id: vid.json.id, permalink: null };
|
||||
}
|
||||
|
||||
if (format === 'carrusel') {
|
||||
const paths = [cover, ...slides.filter((p) => p !== cover)].slice(0, 10);
|
||||
const mediaIds: string[] = [];
|
||||
for (const path of paths) {
|
||||
const url = await signedAssetUrl(path);
|
||||
if (!url) continue;
|
||||
const ph = await graphPost(`${encodeURIComponent(pageId)}/photos`, pageToken, {
|
||||
url,
|
||||
published: 'false',
|
||||
});
|
||||
if (ph.ok && typeof ph.json.id === 'string') {
|
||||
mediaIds.push(ph.json.id);
|
||||
}
|
||||
}
|
||||
if (mediaIds.length >= 2) {
|
||||
const form = new URLSearchParams({
|
||||
message,
|
||||
access_token: pageToken,
|
||||
});
|
||||
mediaIds.forEach((id, i) => {
|
||||
form.append(`attached_media[${i}]`, JSON.stringify({ media_fbid: id }));
|
||||
});
|
||||
const r = await fetch(`${META}/${encodeURIComponent(pageId)}/feed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const raw = await r.text();
|
||||
let json: GraphJson = {};
|
||||
try {
|
||||
json = JSON.parse(raw) as GraphJson;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
if (!r.ok || typeof json.id !== 'string') {
|
||||
return { id: null, permalink: null, error: formatGraphError(raw) };
|
||||
}
|
||||
return { id: json.id, permalink: null };
|
||||
const paths =
|
||||
format === 'carrusel'
|
||||
? [cover, ...slides.filter((p) => p !== cover)].slice(0, 10)
|
||||
: [cover];
|
||||
const mediaIds: string[] = [];
|
||||
for (const path of paths) {
|
||||
const url = await signedAssetUrl(path);
|
||||
if (!url) continue;
|
||||
const ph = await graphPost(`${encodeURIComponent(pageId)}/photos`, pageToken, {
|
||||
url,
|
||||
published: 'false',
|
||||
});
|
||||
if (ph.ok && typeof ph.json.id === 'string') {
|
||||
mediaIds.push(ph.json.id);
|
||||
}
|
||||
}
|
||||
|
||||
const url = await signedAssetUrl(cover);
|
||||
if (!url) {
|
||||
if (format === 'carrusel' && mediaIds.length < 2) {
|
||||
return {
|
||||
id: null,
|
||||
permalink: null,
|
||||
error: 'Carrusel FB necesita al menos 2 imágenes',
|
||||
};
|
||||
}
|
||||
if (!mediaIds.length) {
|
||||
return { id: null, permalink: null, error: 'No URL firmada FB imagen' };
|
||||
}
|
||||
const photo = await graphPost(`${encodeURIComponent(pageId)}/photos`, pageToken, {
|
||||
url,
|
||||
caption: message,
|
||||
published: 'true',
|
||||
});
|
||||
if (
|
||||
!photo.ok ||
|
||||
(typeof photo.json.id !== 'string' && typeof photo.json.post_id !== 'string')
|
||||
) {
|
||||
return { id: null, permalink: null, error: formatGraphError(photo.raw) };
|
||||
}
|
||||
const id =
|
||||
(typeof photo.json.post_id === 'string' && photo.json.post_id) ||
|
||||
(typeof photo.json.id === 'string' && photo.json.id) ||
|
||||
null;
|
||||
return { id, permalink: null };
|
||||
return publishFbFeedWithPhotos(pageId, pageToken, message, mediaIds);
|
||||
}
|
||||
|
||||
export async function publishPipelineItem(
|
||||
@@ -609,9 +620,22 @@ export async function publishPipelineItem(
|
||||
opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean }
|
||||
): Promise<PublishResult> {
|
||||
const channels = normalizeChannels(item.channels);
|
||||
const scrub = sanitizePublishCaption(item.caption || item.title || '', {
|
||||
maxLen: 6000,
|
||||
});
|
||||
const cleanItem: PipelinePublishRow = {
|
||||
...item,
|
||||
caption: scrub.caption || item.title || '',
|
||||
};
|
||||
const resolved = await resolveTenantMetaTargets(item.tenant_id);
|
||||
if (!resolved.targets) {
|
||||
return { ok: false, error: resolved.error, channels };
|
||||
return {
|
||||
ok: false,
|
||||
error: resolved.error,
|
||||
channels,
|
||||
sanitizedCaption: scrub.caption,
|
||||
captionStripped: scrub.stripped,
|
||||
};
|
||||
}
|
||||
const { pageId, igUserId, pageToken } = resolved.targets;
|
||||
|
||||
@@ -628,7 +652,7 @@ export async function publishPipelineItem(
|
||||
if (!igUserId) {
|
||||
errors.push('Sin Instagram Business vinculado a la Página');
|
||||
} else if (!igMediaId) {
|
||||
const ig = await publishIgMedia(igUserId, pageToken, item);
|
||||
const ig = await publishIgMedia(igUserId, pageToken, cleanItem);
|
||||
if (ig.error || !ig.id) {
|
||||
errors.push(`IG: ${ig.error || 'fallo'}`);
|
||||
} else {
|
||||
@@ -640,7 +664,7 @@ export async function publishPipelineItem(
|
||||
|
||||
if (wantFb) {
|
||||
if (!fbPostId) {
|
||||
const fb = await publishFbPost(pageId, pageToken, item);
|
||||
const fb = await publishFbPost(pageId, pageToken, cleanItem);
|
||||
if (fb.error || !fb.id) {
|
||||
errors.push(`FB: ${fb.error || 'fallo'}`);
|
||||
} else {
|
||||
@@ -662,6 +686,8 @@ export async function publishPipelineItem(
|
||||
fbPermalink,
|
||||
error: errors.length ? errors.join(' · ') : undefined,
|
||||
channels,
|
||||
sanitizedCaption: scrub.caption,
|
||||
captionStripped: scrub.stripped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -808,9 +834,16 @@ export async function updateLivePipelineItem(
|
||||
return { ok: false, error: resolved.error };
|
||||
}
|
||||
const { pageId, igUserId, pageToken } = resolved.targets;
|
||||
const caption = (item.caption || item.title || '').slice(0, 2200);
|
||||
const scrub = sanitizePublishCaption(item.caption || item.title || '', {
|
||||
maxLen: 2200,
|
||||
});
|
||||
const caption = scrub.caption || (item.title || '').slice(0, 2200);
|
||||
const cleanItem: PipelinePublishRow = { ...item, caption };
|
||||
const notes: string[] = [];
|
||||
const errors: string[] = [];
|
||||
if (scrub.stripped.length) {
|
||||
notes.push(`Copy: quitadas ${scrub.stripped.length} notas internas`);
|
||||
}
|
||||
|
||||
let igMediaId: string | null = item.meta_ig_media_id || null;
|
||||
let fbPostId: string | null = item.meta_fb_post_id || null;
|
||||
@@ -830,7 +863,7 @@ export async function updateLivePipelineItem(
|
||||
errors.push(`FB edit: ${formatGraphError(edit.raw)}`);
|
||||
} else {
|
||||
const pub = await publishFbPost(pageId, pageToken, {
|
||||
...item,
|
||||
...cleanItem,
|
||||
meta_fb_post_id: null,
|
||||
});
|
||||
if (!pub.id) {
|
||||
@@ -843,7 +876,7 @@ export async function updateLivePipelineItem(
|
||||
}
|
||||
}
|
||||
} else if (channels.includes('facebook') && !fbPostId) {
|
||||
const pub = await publishFbPost(pageId, pageToken, item);
|
||||
const pub = await publishFbPost(pageId, pageToken, cleanItem);
|
||||
if (!pub.id) errors.push(`FB: ${pub.error || 'fallo'}`);
|
||||
else {
|
||||
fbPostId = pub.id;
|
||||
@@ -851,32 +884,40 @@ export async function updateLivePipelineItem(
|
||||
}
|
||||
}
|
||||
|
||||
// Instagram: must delete + republish
|
||||
// Instagram: must delete + republish — never publish a second copy if delete fails.
|
||||
if (channels.includes('instagram')) {
|
||||
if (!igUserId) {
|
||||
errors.push('Sin Instagram Business vinculado');
|
||||
} else {
|
||||
let canRepublish = true;
|
||||
if (igMediaId) {
|
||||
const d = await graphDelete(encodeURIComponent(igMediaId), pageToken);
|
||||
if (
|
||||
!d.ok &&
|
||||
!formatGraphError(d.raw).toLowerCase().includes('does not exist') &&
|
||||
!formatGraphError(d.raw).toLowerCase().includes('unsupported')
|
||||
) {
|
||||
errors.push(`IG borrar: ${formatGraphError(d.raw)}`);
|
||||
const gone =
|
||||
d.ok ||
|
||||
formatGraphError(d.raw).toLowerCase().includes('does not exist') ||
|
||||
formatGraphError(d.raw).toLowerCase().includes('unsupported') ||
|
||||
formatGraphError(d.raw).toLowerCase().includes('no existe');
|
||||
if (!gone) {
|
||||
errors.push(
|
||||
`IG borrar: ${formatGraphError(d.raw)} (no se republica para evitar duplicado)`
|
||||
);
|
||||
canRepublish = false;
|
||||
} else {
|
||||
igMediaId = null;
|
||||
}
|
||||
igMediaId = null;
|
||||
}
|
||||
const pub = await publishIgMedia(igUserId, pageToken, {
|
||||
...item,
|
||||
meta_ig_media_id: null,
|
||||
});
|
||||
if (!pub.id) {
|
||||
errors.push(`IG republicar: ${pub.error || 'fallo'}`);
|
||||
} else {
|
||||
igMediaId = pub.id;
|
||||
igPermalink = pub.permalink;
|
||||
notes.push('IG: republicado con nuevo copy');
|
||||
if (canRepublish) {
|
||||
const pub = await publishIgMedia(igUserId, pageToken, {
|
||||
...cleanItem,
|
||||
meta_ig_media_id: null,
|
||||
});
|
||||
if (!pub.id) {
|
||||
errors.push(`IG republicar: ${pub.error || 'fallo'}`);
|
||||
} else {
|
||||
igMediaId = pub.id;
|
||||
igPermalink = pub.permalink;
|
||||
notes.push('IG: republicado con nuevo copy');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user