Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
BskyAgent,
RichText,
AppBskyEmbedVideo,
AppBskyEmbedExternal,
AppBskyVideoDefs,
AtpAgent,
BlobRef
Expand All @@ -24,6 +25,7 @@ import sharp from 'sharp';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { timer } from '@gitroom/helpers/utils/timer';
import axios from 'axios';
import { JSDOM } from 'jsdom';

async function reduceImageBySize(url: string, maxSizeKB = 976) {
try {
Expand Down Expand Up @@ -125,6 +127,102 @@ async function uploadVideo(agent: AtpAgent, videoPath: string): Promise<AppBskyE
} satisfies AppBskyEmbedVideo.Main;
}

interface OpenGraphData {
title?: string;
description?: string;
image?: string;
}

async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
try {
const response = await axios.get(url, {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; PostizBot/1.0; +https://postiz.com/)'
}
});
const html = response.data;
const dom = new JSDOM(html);
const document = dom.window.document;

const getMetaContent = (property: string) => {
const element = document.querySelector(`meta[property="${property}"]`) ||
document.querySelector(`meta[name="${property}"]`);
return element?.getAttribute('content') || '';
};

const ogImage = getMetaContent('og:image');
let imageUrl = ogImage;

// Handle relative URLs for images
if (ogImage && !ogImage.startsWith('http')) {
try {
imageUrl = new URL(ogImage, url).href;
} catch {
imageUrl = ogImage; // Fallback to original if URL parsing fails
}
}

return {
title: getMetaContent('og:title') || getMetaContent('title') ||
document.querySelector('title')?.textContent || '',
description: getMetaContent('og:description') || getMetaContent('description') || '',
image: imageUrl || ''
};
} catch (error) {
console.error('Error fetching OpenGraph data:', error);
return {};
}
}

async function createExternalEmbed(agent: BskyAgent, url: string): Promise<AppBskyEmbedExternal.Main | null> {
try {
const ogData = await fetchOpenGraphData(url);

const external: AppBskyEmbedExternal.External = {
uri: url,
title: ogData.title || 'Link',
description: ogData.description || ''
};

// If there's an image, upload it as thumbnail
if (ogData.image) {
try {
const imageResponse = await axios.get(ogData.image, {
responseType: 'arraybuffer',
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; PostizBot/1.0; +https://postiz.com/)'
}
});
const imageBuffer = Buffer.from(imageResponse.data);

// Reduce image size if needed (Bluesky has limits)
const processedImage = await reduceImageBySize(ogData.image, 976);

const blob = await agent.uploadBlob(new Blob([processedImage]));
Comment on lines +191 to +203
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix inefficient double download of images.

The image is downloaded twice - once in this function and again in reduceImageBySize. Pass the buffer instead of the URL to avoid redundant network requests.

-        const processedImage = await reduceImageBySize(ogData.image, 976);
+        const processedImage = imageBuffer.length / 1024 > 976 
+          ? await reduceImageBySize(ogData.image, 976)
+          : imageBuffer;

Consider refactoring reduceImageBySize to accept a buffer as an alternative to URL to improve efficiency.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const imageResponse = await axios.get(ogData.image, {
responseType: 'arraybuffer',
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; PostizBot/1.0; +https://postiz.com/)'
}
});
const imageBuffer = Buffer.from(imageResponse.data);
// Reduce image size if needed (Bluesky has limits)
const processedImage = await reduceImageBySize(ogData.image, 976);
const blob = await agent.uploadBlob(new Blob([processedImage]));
const imageResponse = await axios.get(ogData.image, {
responseType: 'arraybuffer',
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; PostizBot/1.0; +https://postiz.com/)'
}
});
const imageBuffer = Buffer.from(imageResponse.data);
// Reduce image size if needed (Bluesky has limits)
- const processedImage = await reduceImageBySize(ogData.image, 976);
+ const processedImage = imageBuffer.length / 1024 > 976
+ ? await reduceImageBySize(ogData.image, 976)
+ : imageBuffer;
const blob = await agent.uploadBlob(new Blob([processedImage]));
🤖 Prompt for AI Agents
In libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
between lines 191 and 203, the image is downloaded twice: once directly with
axios and again inside reduceImageBySize using the URL. To fix this
inefficiency, refactor reduceImageBySize to accept an image buffer as input in
addition to a URL. Then, pass the already downloaded imageBuffer to
reduceImageBySize instead of the URL to avoid redundant network requests.

external.thumb = blob.data.blob;
} catch (imageError) {
console.error('Error uploading thumbnail:', imageError);
// Continue without thumbnail
}
}

return {
$type: 'app.bsky.embed.external',
external
} satisfies AppBskyEmbedExternal.Main;
} catch (error) {
console.error('Error creating external embed:', error);
return null;
}
}

function extractUrls(text: string): string[] {
const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/g;
return text.match(urlRegex) || [];
}

export class BlueskyProvider extends SocialAbstract implements SocialProvider {
identifier = 'bluesky';
name = 'Bluesky';
Expand Down Expand Up @@ -265,7 +363,7 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {

await rt.detectFacets(agent);

// Determine embed based on media types
// Determine embed based on media types and URLs
let embed: any = {};
if (videoEmbed) {
// If there's a video, use video embed (Bluesky supports only one video per post)
Expand All @@ -279,6 +377,16 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
image: p.data.blob,
})),
};
} else {
// If no media, check for URLs to create external embeds
const urls = extractUrls(post.message);
if (urls.length > 0) {
// Use the first URL found for the external embed
const externalEmbed = await createExternalEmbed(agent, urls[0]);
if (externalEmbed) {
embed = externalEmbed;
}
}
}

// @ts-ignore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,63 @@ import {
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import axios from 'axios';
import { JSDOM } from 'jsdom';

export class FacebookProvider extends SocialAbstract implements SocialProvider {
identifier = 'facebook';
interface OpenGraphData {
title?: string;
description?: string;
image?: string;
}

async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
try {
const response = await axios.get(url, {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; PostizBot/1.0; +https://postiz.com/)'
}
});
const html = response.data;
const dom = new JSDOM(html);
const document = dom.window.document;

const getMetaContent = (property: string) => {
const element = document.querySelector(`meta[property="${property}"]`) ||
document.querySelector(`meta[name="${property}"]`);
return element?.getAttribute('content') || '';
};

const ogImage = getMetaContent('og:image');
let imageUrl = ogImage;

// Handle relative URLs for images
if (ogImage && !ogImage.startsWith('http')) {
try {
imageUrl = new URL(ogImage, url).href;
} catch {
imageUrl = ogImage; // Fallback to original if URL parsing fails
}
}

return {
title: getMetaContent('og:title') || getMetaContent('title') ||
document.querySelector('title')?.textContent || '',
description: getMetaContent('og:description') || getMetaContent('description') || '',
image: imageUrl || ''
};
} catch (error) {
console.error('Error fetching OpenGraph data:', error);
return {};
}
}

function extractUrls(text: string): string[] {
const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/g;
return text.match(urlRegex) || [];
}

Comment on lines +14 to +66
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Extract duplicated OpenGraph functions to a shared utility module.

The fetchOpenGraphData and extractUrls functions are duplicated across multiple providers. This violates the DRY principle.

Consider creating a shared utility file:

// libraries/nestjs-libraries/src/integrations/social/utils/opengraph.utils.ts
export interface OpenGraphData {
  title?: string;
  description?: string;
  image?: string;
}

export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
  // ... implementation
}

export function extractUrls(text: string): string[] {
  // ... implementation
}

Then import and use these utilities in each provider instead of duplicating the code.

🤖 Prompt for AI Agents
In libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts lines
14 to 66, the functions fetchOpenGraphData and extractUrls are duplicated across
multiple providers, violating the DRY principle. To fix this, create a new
shared utility module at
libraries/nestjs-libraries/src/integrations/social/utils/opengraph.utils.ts
containing the OpenGraphData interface and the implementations of
fetchOpenGraphData and extractUrls. Then, remove these functions from
facebook.provider.ts and import them from the new utility module to reuse the
code.

export class FacebookProvider extends SocialAbstract implements SocialProvider { identifier = 'facebook';
name = 'Facebook Page';
isBetweenSteps = true;
scopes = [
Expand Down Expand Up @@ -176,7 +230,18 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {

let finalId = '';
let finalUrl = '';

// Enhanced URL detection for Facebook link previews
const urls = extractUrls(firstPost.message);
const hasUrls = urls.length > 0;

// Log URL detection for debugging
if (hasUrls) {
console.log('Facebook: Detected URLs for potential link preview:', urls);
}

if ((firstPost?.media?.[0]?.url?.indexOf('mp4') || -2) > -1) {
// Handle video posts
const {
id: videoId,
permalink_url,
Expand All @@ -202,6 +267,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
finalUrl = 'https://www.facebook.com/reel/' + videoId;
finalId = videoId;
} else {
// Handle image/text posts with potential link previews
const uploadPhotos = !firstPost?.media?.length
? []
: await Promise.all(
Expand All @@ -227,6 +293,36 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
})
);

// Enhanced post payload for better link preview handling
const postPayload: any = {
message: firstPost.message,
published: true,
};

// Add media if available
if (uploadPhotos?.length) {
postPayload.attached_media = uploadPhotos;
}
// CRITICAL: Use Facebook's 'link' parameter for URL previews
else if (hasUrls && !uploadPhotos?.length) {
console.log('Facebook: Detected URL for link preview:', urls[0]);

// Remove URL from message text since we're putting it in the link field
const messageWithoutUrl = firstPost.message.replace(urls[0], '').trim();

postPayload.message = messageWithoutUrl;
postPayload.link = urls[0]; // Facebook's link parameter for previews

console.log('Facebook: Using link parameter for preview generation');

// Optional: Pre-warm Facebook's scraper by hitting their debug API
try {
await this.prewarmFacebookScraper(urls[0], accessToken);
} catch (error) {
console.log('Facebook: Could not prewarm scraper, proceeding with normal post');
}
}

const {
id: postId,
permalink_url,
Expand All @@ -239,11 +335,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...(uploadPhotos?.length ? { attached_media: uploadPhotos } : {}),
message: firstPost.message,
published: true,
}),
body: JSON.stringify(postPayload),
},
'finalize upload'
)
Expand All @@ -253,6 +345,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
finalId = postId;
}

// Handle comment posts
const postsArray = [];
for (const comment of comments) {
const data = await (
Expand Down Expand Up @@ -281,6 +374,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
status: 'success',
});
}

return [
{
id: firstPost.id,
Expand All @@ -292,6 +386,26 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
];
}

// Optional: Pre-warm Facebook's link scraper for better preview generation
private async prewarmFacebookScraper(url: string, accessToken: string): Promise<void> {
try {
// Use Facebook's debug API to pre-scrape the URL
await this.fetch(
`https://graph.facebook.com/v20.0/?id=${encodeURIComponent(url)}&scrape=true&access_token=${accessToken}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}
);
console.log('Facebook: Successfully prewarmed scraper for URL:', url);
} catch (error) {
console.log('Facebook: Prewarm scraper failed (this is optional):', error);
// Don't throw - this is just an optimization
}
}

async analytics(
id: string,
accessToken: string,
Expand Down
Loading