|
9 | 9 | // Imports |
10 | 10 | //----------------------------------------------------------------------------- |
11 | 11 |
|
12 | | -import { detectFacets } from "../util/bluesky-facets.js"; |
| 12 | +import { detectFacets, BLUESKY_URL_FACET } from "../util/bluesky-facets.js"; |
13 | 13 | import { imageSize } from "image-size"; |
14 | 14 | import { validatePostOptions } from "../util/options.js"; |
15 | 15 |
|
@@ -57,6 +57,7 @@ import { validatePostOptions } from "../util/options.js"; |
57 | 57 | * @property {Object} [record.embed] The embedded content in the post. |
58 | 58 | * @property {string} record.embed.$type The type of embedded content. |
59 | 59 | * @property {Array<Object>} [record.embed.images] The images to embed. |
| 60 | + * @property {Object} [record.embed.external] The external link card to embed. |
60 | 61 | * |
61 | 62 | */ |
62 | 63 |
|
@@ -95,6 +96,138 @@ import { validatePostOptions } from "../util/options.js"; |
95 | 96 | // Helpers |
96 | 97 | //----------------------------------------------------------------------------- |
97 | 98 |
|
| 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 | + |
98 | 231 | /** |
99 | 232 | * Gets the URL for creating a session. |
100 | 233 | * @param {BlueskyOptions} options The options for the strategy. |
@@ -346,6 +479,18 @@ async function postMessage(options, session, message, postOptions) { |
346 | 479 | images, |
347 | 480 | }; |
348 | 481 | } |
| 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 | + } |
349 | 494 | } |
350 | 495 |
|
351 | 496 | const response = await fetch(url, { |
|
0 commit comments