Skip to content

Commit 43e82f6

Browse files
Copilotnzakas
andcommitted
feat: implement Bluesky card previews without additional dependencies
Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> Agent-Logs-Url: https://github.com/humanwhocodes/crosspost/sessions/9d7c6363-e478-4866-9d62-d3e1ecf8091a
1 parent 85f2a80 commit 43e82f6

File tree

2 files changed

+477
-2
lines changed

2 files changed

+477
-2
lines changed

src/strategies/bluesky.js

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// Imports
1010
//-----------------------------------------------------------------------------
1111

12-
import { detectFacets } from "../util/bluesky-facets.js";
12+
import { detectFacets, BLUESKY_URL_FACET } from "../util/bluesky-facets.js";
1313
import { imageSize } from "image-size";
1414
import { validatePostOptions } from "../util/options.js";
1515

@@ -57,6 +57,7 @@ import { validatePostOptions } from "../util/options.js";
5757
* @property {Object} [record.embed] The embedded content in the post.
5858
* @property {string} record.embed.$type The type of embedded content.
5959
* @property {Array<Object>} [record.embed.images] The images to embed.
60+
* @property {Object} [record.embed.external] The external link card to embed.
6061
*
6162
*/
6263

@@ -95,6 +96,138 @@ import { validatePostOptions } from "../util/options.js";
9596
// Helpers
9697
//-----------------------------------------------------------------------------
9798

99+
/**
100+
* Parses Open Graph metadata from an HTML string using regular expressions.
101+
* @param {string} html The HTML string to parse.
102+
* @returns {{title: string, description: string, image: string|null}} The extracted metadata.
103+
*/
104+
function parseOpenGraphData(html) {
105+
const ogData = /** @type {Record<string, string>} */ ({});
106+
107+
// Match all <meta> tags
108+
const metaTagRegex = /<meta\s[^>]+>/gi;
109+
let metaMatch;
110+
111+
while ((metaMatch = metaTagRegex.exec(html)) !== null) {
112+
const tag = metaMatch[0];
113+
114+
// Check for property="og:*" attribute (handles both quote styles)
115+
const propertyMatch = /\bproperty=["'](og:[^"']+)["']/i.exec(tag);
116+
if (!propertyMatch) {
117+
continue;
118+
}
119+
120+
// Extract the key after "og:" prefix
121+
const key = propertyMatch[1].slice(3).toLowerCase();
122+
123+
// Extract content attribute value
124+
const contentMatch = /\bcontent=["']([^"']*)["']/i.exec(tag);
125+
if (!contentMatch) {
126+
continue;
127+
}
128+
129+
// Only keep the first value for each key
130+
if (!ogData[key]) {
131+
ogData[key] = contentMatch[1];
132+
}
133+
}
134+
135+
return {
136+
title: ogData.title ?? "",
137+
description: ogData.description ?? "",
138+
image: ogData.image ?? null,
139+
};
140+
}
141+
142+
/**
143+
* Fetches Open Graph metadata from a URL.
144+
* @param {string} url The URL to fetch metadata from.
145+
* @param {AbortSignal} [signal] An optional abort signal.
146+
* @returns {Promise<{title: string, description: string, image: string|null}>} The extracted metadata.
147+
*/
148+
async function fetchOpenGraphData(url, signal) {
149+
const response = await fetch(url, {
150+
headers: { "User-Agent": "crosspost-bot/1.0" },
151+
redirect: "follow",
152+
signal,
153+
});
154+
155+
if (!response.ok) {
156+
throw new Error(
157+
`Failed to fetch URL: ${response.status} ${response.statusText}`,
158+
);
159+
}
160+
161+
const html = await response.text();
162+
return parseOpenGraphData(html);
163+
}
164+
165+
/**
166+
* Creates an external card embed from the first URL found in the post facets.
167+
* Fetches Open Graph metadata and optionally uploads a thumbnail image.
168+
* @param {BlueskyOptions} options The options for the strategy.
169+
* @param {BlueskySession} session The session data.
170+
* @param {Array<{index: Object, features: Array<{$type: string, uri?: string}>}>} facets The detected facets.
171+
* @param {AbortSignal} [signal] The abort signal for the request.
172+
* @returns {Promise<{$type: string, external: Object}|null>} The embed object, or null if no card could be generated.
173+
*/
174+
async function createCardEmbed(options, session, facets, signal) {
175+
const firstUrlFeature =
176+
/** @type {{uri: string, $type: string} | undefined} */ (
177+
facets
178+
.flatMap(f => f.features)
179+
.find(feat => feat.$type === BLUESKY_URL_FACET)
180+
);
181+
182+
if (!firstUrlFeature) {
183+
return null;
184+
}
185+
186+
try {
187+
const ogData = await fetchOpenGraphData(firstUrlFeature.uri, signal);
188+
189+
if (!ogData.title) {
190+
return null;
191+
}
192+
193+
/** @type {{uri: string, title: string, description: string, thumb?: Object}} */
194+
const external = {
195+
uri: firstUrlFeature.uri,
196+
title: ogData.title,
197+
description: ogData.description,
198+
};
199+
200+
if (ogData.image) {
201+
try {
202+
const imageResponse = await fetch(ogData.image, { signal });
203+
204+
if (imageResponse.ok) {
205+
const imageBuffer = new Uint8Array(
206+
await imageResponse.arrayBuffer(),
207+
);
208+
const result = await uploadImage(
209+
options,
210+
session,
211+
imageBuffer,
212+
signal,
213+
);
214+
external.thumb = result.blob;
215+
}
216+
} catch {
217+
// Ignore image fetch failures
218+
}
219+
}
220+
221+
return {
222+
$type: "app.bsky.embed.external",
223+
external,
224+
};
225+
} catch {
226+
// Silently ignore OG data fetch failures
227+
return null;
228+
}
229+
}
230+
98231
/**
99232
* Gets the URL for creating a session.
100233
* @param {BlueskyOptions} options The options for the strategy.
@@ -346,6 +479,18 @@ async function postMessage(options, session, message, postOptions) {
346479
images,
347480
};
348481
}
482+
} else {
483+
// Auto-generate card preview from the first URL in the post
484+
const embed = await createCardEmbed(
485+
options,
486+
session,
487+
rawFacets,
488+
postOptions?.signal,
489+
);
490+
491+
if (embed) {
492+
body.record.embed = embed;
493+
}
349494
}
350495

351496
const response = await fetch(url, {

0 commit comments

Comments
 (0)