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,