From 795099c5b87ac242410916db2bbf7911df23bf2c Mon Sep 17 00:00:00 2001 From: Joshua Benton <2439459+jbenton@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:42:06 -0400 Subject: [PATCH] feat: Add rich link previews on posts to Bluesky, LinkedIn, and Facebook. Uses OpenGraph scraping for LinkedIn, API link parameter for Facebook, and app.bsky.embed.external for Bluesky. Adds PostizBot user agents to metadata fetching to make it distinguishable to firewalls. --- .../integrations/social/bluesky.provider.ts | 110 ++++++++++++- .../integrations/social/facebook.provider.ts | 128 ++++++++++++++- .../social/linkedin.page.provider.ts | 117 +++++++++++++- .../integrations/social/linkedin.provider.ts | 150 +++++++++++++++++- 4 files changed, 489 insertions(+), 16 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts index 68809c598..35ec7d320 100644 --- a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts @@ -13,6 +13,7 @@ import { BskyAgent, RichText, AppBskyEmbedVideo, + AppBskyEmbedExternal, AppBskyVideoDefs, AtpAgent, BlobRef @@ -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 { @@ -125,6 +127,102 @@ async function uploadVideo(agent: AtpAgent, videoPath: string): Promise { + 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 { + 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])); + 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'; @@ -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) @@ -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 diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index ded8016e8..d5110e59b 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -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 { + 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) || []; +} + +export class FacebookProvider extends SocialAbstract implements SocialProvider { identifier = 'facebook'; name = 'Facebook Page'; isBetweenSteps = true; scopes = [ @@ -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, @@ -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( @@ -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, @@ -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' ) @@ -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 ( @@ -281,6 +374,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { status: 'success', }); } + return [ { id: firstPost.id, @@ -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 { + 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, diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts index 8d36cb064..73632372e 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -11,6 +11,118 @@ import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; import { Plug } from '@gitroom/helpers/decorators/plug.decorator'; import { timer } from '@gitroom/helpers/utils/timer'; +import { LinkedinDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/linkedin.dto'; +import axios from 'axios'; +import { JSDOM } from 'jsdom'; + +interface OpenGraphData { + title?: string; + description?: string; + image?: string; +} + +async function fetchOpenGraphData(url: string): Promise { + 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) || []; +} + +async function createLinkedInContentEntities(url: string, accessToken: string, personId: string, type: 'company', uploadPictureMethod: any): Promise { + try { + const ogData = await fetchOpenGraphData(url); + + // Use LinkedIn's "article" format + if (ogData.title || ogData.description) { + const article: any = { + source: url, + title: ogData.title || 'Link', + description: ogData.description || '' + }; + + // Try to upload thumbnail if image is available + if (ogData.image) { + try { + console.log('Attempting to upload thumbnail for LinkedIn article:', ogData.image); + + // Download the image + 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); + + // Upload via LinkedIn's Image API + const uploadedImageUrn = await uploadPictureMethod( + ogData.image, // filename + accessToken, + personId, + imageBuffer, + type + ); + + if (uploadedImageUrn) { + article.thumbnail = uploadedImageUrn; + console.log('Successfully uploaded LinkedIn article thumbnail:', uploadedImageUrn); + } + } catch (imageError) { + console.error('Error uploading LinkedIn article thumbnail:', imageError); + // Continue without thumbnail - better to have the link card than fail completely + } + } + + return { article }; + } + + return null; + } catch (error) { + console.error('Error creating LinkedIn content entities:', error); + return null; + } +} export class LinkedinPageProvider extends LinkedinProvider @@ -244,12 +356,15 @@ export class LinkedinPageProvider }; } + // ENHANCED POST METHOD WITH LINK PREVIEW SUPPORT override async post( id: string, accessToken: string, - postDetails: PostDetails[], + postDetails: PostDetails[], integration: Integration ): Promise { + // Call the parent's enhanced post method with 'company' type + // This will use all the enhanced functionality from the base LinkedinProvider return super.post(id, accessToken, postDetails, integration, 'company'); } diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index e11143911..86fb2c325 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -14,9 +14,119 @@ import { PostPlug } from '@gitroom/helpers/decorators/post.plug'; import { LinkedinDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/linkedin.dto'; import imageToPDF from 'image-to-pdf'; import { Readable } from 'stream'; +import axios from 'axios'; +import { JSDOM } from 'jsdom'; -export class LinkedinProvider extends SocialAbstract implements SocialProvider { - identifier = 'linkedin'; +interface OpenGraphData { + title?: string; + description?: string; + image?: string; +} + +async function fetchOpenGraphData(url: string): Promise { + 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) || []; +} + +async function createLinkedInContentEntities(url: string, accessToken: string, personId: string, type: 'personal' | 'company', uploadPictureMethod: any): Promise { + try { + const ogData = await fetchOpenGraphData(url); + + // Use LinkedIn's "article" format + if (ogData.title || ogData.description) { + const article: any = { + source: url, + title: ogData.title || 'Link', + description: ogData.description || '' + }; + + // Try to upload thumbnail if image is available + if (ogData.image) { + try { + console.log('Attempting to upload thumbnail for LinkedIn article:', ogData.image); + + // Download the image + 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); + + // Upload via LinkedIn's Image API + const uploadedImageUrn = await uploadPictureMethod( + ogData.image, // filename + accessToken, + personId, + imageBuffer, + type + ); + + if (uploadedImageUrn) { + article.thumbnail = uploadedImageUrn; + console.log('Successfully uploaded LinkedIn article thumbnail:', uploadedImageUrn); + } + } catch (imageError) { + console.error('Error uploading LinkedIn article thumbnail:', imageError); + // Continue without thumbnail - better to have the link card than fail completely + } + } + + return { article }; + } + + return null; + } catch (error) { + console.error('Error creating LinkedIn content entities:', error); + return null; + } +} + +export class LinkedinProvider extends SocialAbstract implements SocialProvider { identifier = 'linkedin'; name = 'LinkedIn'; oneTimeToken = true; @@ -504,12 +614,13 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { type: 'company' | 'personal', message: string, mediaIds: string[], - isPdf: boolean + isPdf: boolean, + linkContent?: any ) { const author = type === 'personal' ? `urn:li:person:${id}` : `urn:li:organization:${id}`; - return { + const payload: any = { author, commentary: this.fixText(message), visibility: 'PUBLIC', @@ -518,10 +629,20 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { targetEntities: [] as string[], thirdPartyDistributionChannels: [] as string[], }, - ...this.buildPostContent(isPdf, mediaIds), lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false, }; + + // Add media content if available + if (mediaIds.length > 0) { + payload.content = this.buildPostContent(isPdf, mediaIds).content; + } + // Add link content if no media but link detected + else if (linkContent) { + payload.content = linkContent; + } + + return payload; } private async createMainPost( @@ -532,12 +653,28 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { type: 'company' | 'personal', isPdf: boolean ): Promise { + // Check for URLs in the post message if no media + let linkContent = null; + if (mediaIds.length === 0) { + const urls = extractUrls(firstPost.message); + if (urls.length > 0) { + linkContent = await createLinkedInContentEntities( + urls[0], + accessToken, + id, + type, + this.uploadPicture.bind(this) + ); + } + } + const postPayload = this.createLinkedInPostPayload( id, type, firstPost.message, mediaIds, - isPdf + isPdf, + linkContent ); const response = await this.fetch('https://api.linkedin.com/rest/posts', { @@ -608,7 +745,6 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { releaseURL: `${baseUrl}${postId}`, }; } - async post( id: string, accessToken: string,