@rmdes/indiekit-syndicator-bluesky 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bluesky.js CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  getLikePostText,
10
10
  getPostParts,
11
11
  uriToPostUrl,
12
+ fetchOpenGraphData,
13
+ getExternalUrl,
12
14
  } from "./utils.js";
13
15
 
14
16
  export class Bluesky {
@@ -121,26 +123,109 @@ export class Bluesky {
121
123
  return uriToPostUrl(this.profileUrl, quotePost.uri);
122
124
  }
123
125
 
126
+ /**
127
+ * Upload image from URL (for OG thumbnails)
128
+ * @param {string} imageUrl - URL of image to upload
129
+ * @returns {Promise<object|null>} Blob reference or null
130
+ */
131
+ async uploadImageFromUrl(imageUrl) {
132
+ if (!imageUrl) return null;
133
+
134
+ try {
135
+ const client = await this.#client();
136
+ const response = await fetch(imageUrl, {
137
+ headers: {
138
+ "User-Agent": "Mozilla/5.0 (compatible; IndiekitBot/1.0)",
139
+ },
140
+ redirect: "follow",
141
+ });
142
+
143
+ if (!response.ok) return null;
144
+
145
+ let blob = await response.blob();
146
+ let encoding = response.headers.get("Content-Type") || "image/jpeg";
147
+
148
+ // Compress if needed
149
+ if (encoding?.startsWith("image/")) {
150
+ const buffer = Buffer.from(await blob.arrayBuffer());
151
+ const image = await getPostImage(buffer, encoding);
152
+ blob = new Blob([new Uint8Array(image.buffer)], {
153
+ type: image.mimeType,
154
+ });
155
+ encoding = image.mimeType;
156
+ }
157
+
158
+ const uploadResponse = await client.com.atproto.repo.uploadBlob(blob, {
159
+ encoding,
160
+ });
161
+
162
+ return uploadResponse.data.blob;
163
+ } catch (error) {
164
+ console.error(`Failed to upload OG image: ${error.message}`);
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Create external link embed
171
+ * @param {string} url - External URL
172
+ * @returns {Promise<object|null>} External embed or null
173
+ */
174
+ async createExternalEmbed(url) {
175
+ if (!url) return null;
176
+
177
+ try {
178
+ const ogData = await fetchOpenGraphData(url);
179
+ let thumb = null;
180
+
181
+ if (ogData.imageUrl) {
182
+ thumb = await this.uploadImageFromUrl(ogData.imageUrl);
183
+ }
184
+
185
+ return {
186
+ $type: "app.bsky.embed.external",
187
+ external: {
188
+ uri: url,
189
+ title: ogData.title || url,
190
+ description: ogData.description || "",
191
+ ...(thumb && { thumb }),
192
+ },
193
+ };
194
+ } catch (error) {
195
+ console.error(`Failed to create external embed: ${error.message}`);
196
+ return null;
197
+ }
198
+ }
199
+
124
200
  /**
125
201
  * Post a regular post
126
202
  * @param {object} richText - Rich text
127
- * @param {Array} [images] - Images
203
+ * @param {object} [options] - Post options
204
+ * @param {Array} [options.images] - Images
205
+ * @param {object} [options.externalEmbed] - External link embed
128
206
  * @returns {Promise<string>} Bluesky post URL
129
207
  */
130
- async postPost(richText, images) {
208
+ async postPost(richText, options = {}) {
131
209
  const client = await this.#client();
210
+ const { images, externalEmbed } = options;
211
+
212
+ // Determine embed type - images take priority over external
213
+ let embed = null;
214
+ if (images?.length > 0) {
215
+ embed = {
216
+ $type: "app.bsky.embed.images",
217
+ images,
218
+ };
219
+ } else if (externalEmbed) {
220
+ embed = externalEmbed;
221
+ }
132
222
 
133
223
  const postData = {
134
224
  $type: "app.bsky.feed.post",
135
225
  text: richText.text,
136
226
  facets: richText.facets,
137
227
  createdAt: new Date().toISOString(),
138
- ...(images?.length > 0 && {
139
- embed: {
140
- $type: "app.bsky.embed.images",
141
- images,
142
- },
143
- }),
228
+ ...(embed && { embed }),
144
229
  };
145
230
 
146
231
  const post = await client.post(postData);
@@ -235,21 +320,43 @@ export class Bluesky {
235
320
  return this.postLike(likeOfUrl);
236
321
  }
237
322
 
238
- // NEW: Syndicate likes of external URLs as posts
323
+ // Syndicate likes of external URLs as posts with link card
239
324
  if (this.syndicateExternalLikes) {
240
325
  const text = getLikePostText(properties, likeOfUrl);
241
326
  const richText = await createRichText(client, text);
242
- return this.postPost(richText, images);
327
+ // Create external embed for the liked URL
328
+ const externalEmbed = await this.createExternalEmbed(likeOfUrl);
329
+ return this.postPost(richText, { images, externalEmbed });
243
330
  }
244
331
 
245
332
  // Don't syndicate if option is disabled
246
333
  return;
247
334
  }
248
335
 
249
- // Regular post
336
+ // Handle bookmarks - similar to likes but with bookmark-of
337
+ const bookmarkOfUrl = properties["bookmark-of"];
338
+ if (bookmarkOfUrl) {
339
+ const text = getPostText(properties, this.includePermalink);
340
+ const richText = await createRichText(client, text);
341
+ // Create external embed for the bookmarked URL
342
+ const externalEmbed = await this.createExternalEmbed(bookmarkOfUrl);
343
+ return this.postPost(richText, { images, externalEmbed });
344
+ }
345
+
346
+ // Regular post - check for external URL in content to create link card
250
347
  const text = getPostText(properties, this.includePermalink);
251
348
  const richText = await createRichText(client, text);
252
- return this.postPost(richText, images);
349
+
350
+ // If no images, try to create external embed from URLs in content
351
+ let externalEmbed = null;
352
+ if (!images?.length) {
353
+ const externalUrl = getExternalUrl(properties);
354
+ if (externalUrl) {
355
+ externalEmbed = await this.createExternalEmbed(externalUrl);
356
+ }
357
+ }
358
+
359
+ return this.postPost(richText, { images, externalEmbed });
253
360
  } catch (error) {
254
361
  throw new Error(error.message);
255
362
  }
package/lib/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { RichText } from "@atproto/api";
2
2
  import { htmlToText } from "html-to-text";
3
3
  import sharp from "sharp";
4
+ import { JSDOM } from "jsdom";
4
5
 
5
6
  const AT_URI = /at:\/\/(?<did>did:[^/]+)\/(?<type>[^/]+)\/(?<rkey>[^/]+)/;
6
7
 
@@ -160,3 +161,95 @@ export const htmlToStatusText = (html) => {
160
161
  const statusText = lastHref ? `${text} ${lastHref}` : text;
161
162
  return statusText;
162
163
  };
164
+
165
+ /**
166
+ * Fetch OpenGraph metadata from a URL
167
+ * @param {string} url - URL to fetch OG data from
168
+ * @returns {Promise<{title: string, description: string, imageUrl: string|null}>} OG metadata
169
+ */
170
+ export async function fetchOpenGraphData(url) {
171
+ try {
172
+ const response = await fetch(url, {
173
+ headers: {
174
+ "User-Agent": "Mozilla/5.0 (compatible; IndiekitBot/1.0)",
175
+ Accept: "text/html,application/xhtml+xml",
176
+ },
177
+ redirect: "follow",
178
+ timeout: 10000,
179
+ });
180
+
181
+ if (!response.ok) {
182
+ return { title: url, description: "", imageUrl: null };
183
+ }
184
+
185
+ const html = await response.text();
186
+ const dom = new JSDOM(html);
187
+ const doc = dom.window.document;
188
+
189
+ // Get OG title, fallback to regular title
190
+ const ogTitle =
191
+ doc.querySelector('meta[property="og:title"]')?.getAttribute("content") ||
192
+ doc.querySelector("title")?.textContent ||
193
+ url;
194
+
195
+ // Get OG description, fallback to meta description
196
+ const ogDescription =
197
+ doc
198
+ .querySelector('meta[property="og:description"]')
199
+ ?.getAttribute("content") ||
200
+ doc.querySelector('meta[name="description"]')?.getAttribute("content") ||
201
+ "";
202
+
203
+ // Get OG image
204
+ let ogImage =
205
+ doc.querySelector('meta[property="og:image"]')?.getAttribute("content") ||
206
+ null;
207
+
208
+ // Handle relative URLs for image
209
+ if (ogImage && !ogImage.startsWith("http")) {
210
+ const baseUrl = new URL(url);
211
+ ogImage = new URL(ogImage, baseUrl.origin).href;
212
+ }
213
+
214
+ return {
215
+ title: ogTitle.slice(0, 300), // Bluesky title limit
216
+ description: ogDescription.slice(0, 1000), // Reasonable limit
217
+ imageUrl: ogImage,
218
+ };
219
+ } catch (error) {
220
+ console.error(`Failed to fetch OG data for ${url}:`, error.message);
221
+ return { title: url, description: "", imageUrl: null };
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Extract the primary URL from post properties
227
+ * @param {object} properties - JF2 properties
228
+ * @returns {string|null} Primary URL to create card for
229
+ */
230
+ export function getExternalUrl(properties) {
231
+ // For likes, use the liked URL
232
+ if (properties["like-of"]) {
233
+ return properties["like-of"];
234
+ }
235
+
236
+ // For bookmarks, use the bookmarked URL
237
+ if (properties["bookmark-of"]) {
238
+ return properties["bookmark-of"];
239
+ }
240
+
241
+ // For replies, use the in-reply-to URL
242
+ if (properties["in-reply-to"]) {
243
+ return properties["in-reply-to"];
244
+ }
245
+
246
+ // For regular posts with content, try to extract URL from content
247
+ if (properties.content?.html) {
248
+ const hrefs = [...properties.content.html.matchAll(/href="(https?:\/\/[^"]+)"/g)];
249
+ if (hrefs.length > 0) {
250
+ return hrefs.at(-1)[1];
251
+ }
252
+ }
253
+
254
+ return null;
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-syndicator-bluesky",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Bluesky syndicator for Indiekit with external like support",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -20,11 +20,13 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@atproto/api": "^0.14.0",
23
- "@indiekit/error": "^1.0.0-beta.25",
24
- "@indiekit/util": "^1.0.0-beta.25",
23
+ "html-to-text": "^9.0.0",
24
+ "jsdom": "^24.0.0",
25
25
  "sharp": "^0.33.0"
26
26
  },
27
27
  "peerDependencies": {
28
- "@indiekit/indiekit": "1.x"
28
+ "@indiekit/indiekit": "1.x",
29
+ "@indiekit/error": "^1.0.0-beta.25",
30
+ "@indiekit/util": "^1.0.0-beta.25"
29
31
  }
30
32
  }