@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 +119 -12
- package/lib/utils.js +93 -0
- package/package.json +6 -4
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 {
|
|
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,
|
|
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
|
-
...(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
24
|
-
"
|
|
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
|
}
|