From 6cc983c32d4afc4af93d782713ccd1b2b862d5a7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 21 Sep 2026 16:56:59 -0300 Subject: [PATCH 01/18] Attach link previews on LinkedIn and Facebook text posts. Neither API unfurls a URL left in the caption: LinkedIn needs an article payload, and Facebook only scrapes when the Page feed post sets link. A text-only post now sends that card from the first URL, and the editor preview shows it. --- .../Social/AbstractLinkedInPublisher.php | 206 ++++++++++++++--- app/Services/Social/FacebookPublisher.php | 33 ++- .../posts/previews/FacebookPreview.vue | 39 +++- .../posts/previews/LinkedInPreview.vue | 19 +- .../posts/previews/MastodonPreview.vue | 15 +- .../Services/Social/FacebookPublisherTest.php | 71 +++++- .../Social/LinkedInPagePublisherTest.php | 3 + .../Services/Social/LinkedInPublisherTest.php | 213 +++++++++++++++++- 8 files changed, 547 insertions(+), 52 deletions(-) diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index f20724178..969d7918d 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -11,24 +11,43 @@ use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Brand\SafeHttpFetcher; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Services\Social\LinkCard\LinkCardFetcher; +use App\Services\Social\LinkCard\LinkCardMetadata; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +use Throwable; /** * Shared LinkedIn publishing pipeline. The publish format follows the attached - * media — a PDF becomes a document post, 2+ images a multi-image post, and - * anything else (a single image/video or text only) a regular post. Subclasses + * media — a PDF becomes a document post, 2+ images a multi-image post, a bare + * link with no media becomes an article post (the Posts API does not scrape + * URLs), and anything else is a regular post. Subclasses * provide the author identity (member vs. company page) and its public URL. */ abstract class AbstractLinkedInPublisher { use HasSocialHttpClient; + /** + * ArticleContent limits are exclusive: title under 400 characters, + * description under 4,086. + * + * @see https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api + */ + private const int ARTICLE_TITLE_MAX = 399; + + private const int ARTICLE_DESCRIPTION_MAX = 4085; + + /** og:image downloads are small and attacker-influenced; don't wait on a hung host. */ + private const int ARTICLE_THUMB_TIMEOUT_SECONDS = 10; + private string $apiVersion = '202601'; private string $accessToken; @@ -110,7 +129,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, $this->accessToken = $this->account->access_token; return $this->dispatchByMedia($content, $postPlatform); - } catch (\Throwable $e) { + } catch (Throwable $e) { Log::error("{$this->label()} refresh failed during retry", [ 'account_id' => $this->account->id, 'error' => $e->getMessage(), @@ -133,11 +152,126 @@ private function publishPost(?string $content, $media): array 'altText' => $item->isImage() ? $item->altTextFor($this->platform()) : null, ], fn ($v) => $v !== null)]; } + } else { + $article = $this->articleContent($content); + + if ($article !== null) { + $payload['content'] = ['article' => $article]; + } } return $this->createPost($payload, 'post creation'); } + /** + * Article payload for the first link in a text-only post, or null when there + * is no link, the page has no title, or the scrape fails. LinkedIn will not + * unfurl a URL left in `commentary`, so the card has to be sent explicitly. + * A thumbnail failure still publishes the article — the image is optional. + * + * @return array{source: string, title: string, description?: string, thumbnail?: string}|null + */ + private function articleContent(?string $content): ?array + { + if (! filled($content)) { + return null; + } + + try { + $card = app(LinkCardFetcher::class)->fetch($content); + } catch (Throwable $e) { + Log::warning("{$this->label()} link preview lookup failed", [ + 'error' => $e->getMessage(), + ]); + + return null; + } + + $title = $card !== null && filled($card->title) + ? Str::limit($card->title, self::ARTICLE_TITLE_MAX, '') + : null; + + if ($card === null || $title === null || $title === '') { + return null; + } + + $article = [ + 'source' => $card->uri, + 'title' => $title, + ]; + + if (filled($card->description)) { + $article['description'] = Str::limit($card->description, self::ARTICLE_DESCRIPTION_MAX, ''); + } + + $thumbnail = $this->uploadArticleThumbnail($card); + + if ($thumbnail !== null) { + $article['thumbnail'] = $thumbnail; + } + + return $article; + } + + /** + * Upload the card's og:image and return its Image URN. Returns null (the + * article renders without a thumbnail) when there is no image, the host is + * not a public URL, or the upload fails. A dead token still propagates so + * the publish retry can refresh it. + */ + private function uploadArticleThumbnail(LinkCardMetadata $card): ?string + { + if ($card->imageUrl === null || $card->imageUrl === '') { + return null; + } + + $tempFile = tempnam(sys_get_temp_dir(), 'li_article_'); + + if ($tempFile === false) { + return null; + } + + try { + $response = app(SafeHttpFetcher::class) + ->guardedRequest($card->imageUrl, followRedirects: false) + ->timeout(self::ARTICLE_THUMB_TIMEOUT_SECONDS) + ->withOptions(['sink' => $tempFile]) + ->get($card->imageUrl); + + $size = filesize($tempFile); + + if (! $response->successful() || $size === false || $size === 0) { + return null; + } + + $detectedMime = File::mimeType($tempFile) ?: ''; + + if (MediaType::classify($detectedMime) !== MediaType::Image) { + return null; + } + + if (! MediaType::isGif($detectedMime)) { + $optimizedPath = app(MediaOptimizer::class)->optimizeImage($tempFile, $this->platform()); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + return $this->uploadImageFile($tempFile); + } catch (TokenExpiredException $e) { + throw $e; + } catch (Throwable $e) { + Log::warning("{$this->label()} article thumbnail skipped", [ + 'error' => $e->getMessage(), + ]); + + return null; + } finally { + if (is_file($tempFile)) { + @unlink($tempFile); + } + } + } + private function publishCarousel(?string $content, $media): array { $images = []; @@ -260,6 +394,29 @@ private function uploadMedia($mediaItem): ?string } private function uploadImage($mediaItem): ?string + { + $tempFile = tempnam(sys_get_temp_dir(), 'li_image_'); + + try { + $this->downloadToTempFile($mediaItem->url, $tempFile); + + $detectedMime = File::mimeType($tempFile) ?: ''; + if (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) { + $optimizedPath = app(MediaOptimizer::class)->optimizeImage($tempFile, $this->platform()); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + return $this->uploadImageFile($tempFile); + } finally { + @unlink($tempFile); + } + } + + /** + * Initialize a LinkedIn image upload and PUT the bytes already on disk. + */ + private function uploadImageFile(string $path): string { $initResponse = $this->getHttpClient() ->post("{$this->baseUrl()}/rest/images?action=initializeUpload", [ @@ -275,45 +432,30 @@ private function uploadImage($mediaItem): ?string $uploadUrl = data_get($initData, 'value.uploadUrl'); $imageUrn = data_get($initData, 'value.image'); - if (! $uploadUrl || ! $imageUrn) { + if (! is_string($uploadUrl) || $uploadUrl === '' || ! is_string($imageUrn) || $imageUrn === '') { throw new LinkedInPublishException( userMessage: "{$this->label()} did not accept the image upload. Please try again.", category: ErrorCategory::ServerError, ); } - $tempFile = tempnam(sys_get_temp_dir(), 'li_image_'); - - try { - $this->downloadToTempFile($mediaItem->url, $tempFile); - - $detectedMime = File::mimeType($tempFile) ?: ''; - if (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) { - $optimizedPath = app(MediaOptimizer::class)->optimizeImage($tempFile, $this->platform()); - @unlink($tempFile); - $tempFile = $optimizedPath; - } + $stream = fopen($path, 'r'); - $stream = fopen($tempFile, 'r'); + $uploadResponse = Http::withToken($this->accessToken) + ->withHeaders(['Content-Type' => 'application/octet-stream']) + ->withBody($stream, 'application/octet-stream') + ->put($uploadUrl); - $uploadResponse = Http::withToken($this->accessToken) - ->withHeaders(['Content-Type' => 'application/octet-stream']) - ->withBody($stream, 'application/octet-stream') - ->put($uploadUrl); - - if (is_resource($stream)) { - fclose($stream); - } - - if ($uploadResponse->failed()) { - Log::error("{$this->label()} image upload failed", ['body' => $this->redactResponseBody($uploadResponse->body())]); - $this->handleApiError($uploadResponse); - } + if (is_resource($stream)) { + fclose($stream); + } - return $imageUrn; - } finally { - @unlink($tempFile); + if ($uploadResponse->failed()) { + Log::error("{$this->label()} image upload failed", ['body' => $this->redactResponseBody($uploadResponse->body())]); + $this->handleApiError($uploadResponse); } + + return $imageUrn; } private function uploadVideo($mediaItem): ?string diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index b9faf1839..785c57bce 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -15,6 +15,7 @@ use App\Services\Social\Concerns\CropsImageForAspectRatio; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Services\Social\Meta\GraphError; +use App\Support\UrlDetector; use Closure; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; @@ -101,12 +102,38 @@ private function publishTextPost(string $pageId, string $accessToken, ?string $c ); } - $response = $this->postToGraph("{$pageId}/feed", [ + $link = UrlDetector::firstUrl($content); + + try { + $response = $this->postTextToFeed($pageId, $accessToken, $content, $link); + } catch (FacebookPublishException $exception) { + // Graph error 1609005: Facebook could not scrape the URL. The post + // would have published as plain text before `link` was sent, so drop + // the card and try once more instead of failing the whole post. + if ($link === null || $exception->platformErrorCode !== '1609005') { + throw $exception; + } + + Log::warning('Facebook could not scrape the link preview; publishing the text without it'); + + $response = $this->postTextToFeed($pageId, $accessToken, $content, null); + } + + return $this->feedPostResult(data_get($response->json(), 'id')); + } + + /** + * A text post carries `link` when the caption contains a URL. Facebook does + * not unfurl a URL left only in `message`; the Page Feed `link` field is + * what makes it scrape Open Graph and render the preview. + */ + private function postTextToFeed(string $pageId, string $accessToken, string $content, ?string $link): Response + { + return $this->postToGraph("{$pageId}/feed", [ 'message' => $content, 'access_token' => $accessToken, + ...$this->optionalField('link', $link), ], 'text post'); - - return $this->feedPostResult(data_get($response->json(), 'id')); } /** diff --git a/resources/js/components/posts/previews/FacebookPreview.vue b/resources/js/components/posts/previews/FacebookPreview.vue index a6ff12c77..a3bc53390 100644 --- a/resources/js/components/posts/previews/FacebookPreview.vue +++ b/resources/js/components/posts/previews/FacebookPreview.vue @@ -1,10 +1,12 @@