@rmdes/indiekit-syndicator-bluesky 1.0.1 → 1.0.3
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 +120 -14
- package/lib/utils.js +93 -0
- package/package.json +3 -1
package/lib/bluesky.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { AtpAgent } from "@atproto/api";
|
|
2
|
-
import { IndiekitError } from "@indiekit/error";
|
|
3
2
|
import { getCanonicalUrl, isSameOrigin } from "@indiekit/util";
|
|
4
3
|
|
|
5
4
|
import {
|
|
@@ -9,6 +8,8 @@ import {
|
|
|
9
8
|
getLikePostText,
|
|
10
9
|
getPostParts,
|
|
11
10
|
uriToPostUrl,
|
|
11
|
+
fetchOpenGraphData,
|
|
12
|
+
getExternalUrl,
|
|
12
13
|
} from "./utils.js";
|
|
13
14
|
|
|
14
15
|
export class Bluesky {
|
|
@@ -121,26 +122,109 @@ export class Bluesky {
|
|
|
121
122
|
return uriToPostUrl(this.profileUrl, quotePost.uri);
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Upload image from URL (for OG thumbnails)
|
|
127
|
+
* @param {string} imageUrl - URL of image to upload
|
|
128
|
+
* @returns {Promise<object|null>} Blob reference or null
|
|
129
|
+
*/
|
|
130
|
+
async uploadImageFromUrl(imageUrl) {
|
|
131
|
+
if (!imageUrl) return null;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const client = await this.#client();
|
|
135
|
+
const response = await fetch(imageUrl, {
|
|
136
|
+
headers: {
|
|
137
|
+
"User-Agent": "Mozilla/5.0 (compatible; IndiekitBot/1.0)",
|
|
138
|
+
},
|
|
139
|
+
redirect: "follow",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!response.ok) return null;
|
|
143
|
+
|
|
144
|
+
let blob = await response.blob();
|
|
145
|
+
let encoding = response.headers.get("Content-Type") || "image/jpeg";
|
|
146
|
+
|
|
147
|
+
// Compress if needed
|
|
148
|
+
if (encoding?.startsWith("image/")) {
|
|
149
|
+
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
150
|
+
const image = await getPostImage(buffer, encoding);
|
|
151
|
+
blob = new Blob([new Uint8Array(image.buffer)], {
|
|
152
|
+
type: image.mimeType,
|
|
153
|
+
});
|
|
154
|
+
encoding = image.mimeType;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const uploadResponse = await client.com.atproto.repo.uploadBlob(blob, {
|
|
158
|
+
encoding,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return uploadResponse.data.blob;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error(`Failed to upload OG image: ${error.message}`);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create external link embed
|
|
170
|
+
* @param {string} url - External URL
|
|
171
|
+
* @returns {Promise<object|null>} External embed or null
|
|
172
|
+
*/
|
|
173
|
+
async createExternalEmbed(url) {
|
|
174
|
+
if (!url) return null;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const ogData = await fetchOpenGraphData(url);
|
|
178
|
+
let thumb = null;
|
|
179
|
+
|
|
180
|
+
if (ogData.imageUrl) {
|
|
181
|
+
thumb = await this.uploadImageFromUrl(ogData.imageUrl);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
$type: "app.bsky.embed.external",
|
|
186
|
+
external: {
|
|
187
|
+
uri: url,
|
|
188
|
+
title: ogData.title || url,
|
|
189
|
+
description: ogData.description || "",
|
|
190
|
+
...(thumb && { thumb }),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error(`Failed to create external embed: ${error.message}`);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
124
199
|
/**
|
|
125
200
|
* Post a regular post
|
|
126
201
|
* @param {object} richText - Rich text
|
|
127
|
-
* @param {
|
|
202
|
+
* @param {object} [options] - Post options
|
|
203
|
+
* @param {Array} [options.images] - Images
|
|
204
|
+
* @param {object} [options.externalEmbed] - External link embed
|
|
128
205
|
* @returns {Promise<string>} Bluesky post URL
|
|
129
206
|
*/
|
|
130
|
-
async postPost(richText,
|
|
207
|
+
async postPost(richText, options = {}) {
|
|
131
208
|
const client = await this.#client();
|
|
209
|
+
const { images, externalEmbed } = options;
|
|
210
|
+
|
|
211
|
+
// Determine embed type - images take priority over external
|
|
212
|
+
let embed = null;
|
|
213
|
+
if (images?.length > 0) {
|
|
214
|
+
embed = {
|
|
215
|
+
$type: "app.bsky.embed.images",
|
|
216
|
+
images,
|
|
217
|
+
};
|
|
218
|
+
} else if (externalEmbed) {
|
|
219
|
+
embed = externalEmbed;
|
|
220
|
+
}
|
|
132
221
|
|
|
133
222
|
const postData = {
|
|
134
223
|
$type: "app.bsky.feed.post",
|
|
135
224
|
text: richText.text,
|
|
136
225
|
facets: richText.facets,
|
|
137
226
|
createdAt: new Date().toISOString(),
|
|
138
|
-
...(
|
|
139
|
-
embed: {
|
|
140
|
-
$type: "app.bsky.embed.images",
|
|
141
|
-
images,
|
|
142
|
-
},
|
|
143
|
-
}),
|
|
227
|
+
...(embed && { embed }),
|
|
144
228
|
};
|
|
145
229
|
|
|
146
230
|
const post = await client.post(postData);
|
|
@@ -166,7 +250,7 @@ export class Bluesky {
|
|
|
166
250
|
const mediaResponse = await fetch(mediaUrl);
|
|
167
251
|
|
|
168
252
|
if (!mediaResponse.ok) {
|
|
169
|
-
throw
|
|
253
|
+
throw new Error(`Failed to fetch media: ${mediaResponse.status} ${mediaResponse.statusText}`);
|
|
170
254
|
}
|
|
171
255
|
|
|
172
256
|
let blob = await mediaResponse.blob();
|
|
@@ -235,21 +319,43 @@ export class Bluesky {
|
|
|
235
319
|
return this.postLike(likeOfUrl);
|
|
236
320
|
}
|
|
237
321
|
|
|
238
|
-
//
|
|
322
|
+
// Syndicate likes of external URLs as posts with link card
|
|
239
323
|
if (this.syndicateExternalLikes) {
|
|
240
324
|
const text = getLikePostText(properties, likeOfUrl);
|
|
241
325
|
const richText = await createRichText(client, text);
|
|
242
|
-
|
|
326
|
+
// Create external embed for the liked URL
|
|
327
|
+
const externalEmbed = await this.createExternalEmbed(likeOfUrl);
|
|
328
|
+
return this.postPost(richText, { images, externalEmbed });
|
|
243
329
|
}
|
|
244
330
|
|
|
245
331
|
// Don't syndicate if option is disabled
|
|
246
332
|
return;
|
|
247
333
|
}
|
|
248
334
|
|
|
249
|
-
//
|
|
335
|
+
// Handle bookmarks - similar to likes but with bookmark-of
|
|
336
|
+
const bookmarkOfUrl = properties["bookmark-of"];
|
|
337
|
+
if (bookmarkOfUrl) {
|
|
338
|
+
const text = getPostText(properties, this.includePermalink);
|
|
339
|
+
const richText = await createRichText(client, text);
|
|
340
|
+
// Create external embed for the bookmarked URL
|
|
341
|
+
const externalEmbed = await this.createExternalEmbed(bookmarkOfUrl);
|
|
342
|
+
return this.postPost(richText, { images, externalEmbed });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Regular post - check for external URL in content to create link card
|
|
250
346
|
const text = getPostText(properties, this.includePermalink);
|
|
251
347
|
const richText = await createRichText(client, text);
|
|
252
|
-
|
|
348
|
+
|
|
349
|
+
// If no images, try to create external embed from URLs in content
|
|
350
|
+
let externalEmbed = null;
|
|
351
|
+
if (!images?.length) {
|
|
352
|
+
const externalUrl = getExternalUrl(properties);
|
|
353
|
+
if (externalUrl) {
|
|
354
|
+
externalEmbed = await this.createExternalEmbed(externalUrl);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return this.postPost(richText, { images, externalEmbed });
|
|
253
359
|
} catch (error) {
|
|
254
360
|
throw new Error(error.message);
|
|
255
361
|
}
|
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.3",
|
|
4
4
|
"description": "Bluesky syndicator for Indiekit with external like support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@atproto/api": "^0.14.0",
|
|
23
|
+
"html-to-text": "^9.0.0",
|
|
24
|
+
"jsdom": "^24.0.0",
|
|
23
25
|
"sharp": "^0.33.0"
|
|
24
26
|
},
|
|
25
27
|
"peerDependencies": {
|