From 85f2a80596e8f3179316c836e0924b49b8540833 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:35:43 +0000 Subject: [PATCH 1/3] start: plan for Bluesky card preview without extra 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 --- package-lock.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index ba2746d..a48dbd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -821,7 +821,6 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1807,7 +1806,6 @@ "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -2156,7 +2154,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -3119,7 +3116,6 @@ "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -4471,7 +4467,6 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4999,7 +4994,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 43e82f603a22483e89d0e9a62a6d8b40a2e928b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:39:08 +0000 Subject: [PATCH 2/3] 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 --- src/strategies/bluesky.js | 147 +++++++++++++- tests/strategies/bluesky.test.js | 332 ++++++++++++++++++++++++++++++- 2 files changed, 477 insertions(+), 2 deletions(-) diff --git a/src/strategies/bluesky.js b/src/strategies/bluesky.js index a0045e1..c9e748e 100644 --- a/src/strategies/bluesky.js +++ b/src/strategies/bluesky.js @@ -9,7 +9,7 @@ // Imports //----------------------------------------------------------------------------- -import { detectFacets } from "../util/bluesky-facets.js"; +import { detectFacets, BLUESKY_URL_FACET } from "../util/bluesky-facets.js"; import { imageSize } from "image-size"; import { validatePostOptions } from "../util/options.js"; @@ -57,6 +57,7 @@ import { validatePostOptions } from "../util/options.js"; * @property {Object} [record.embed] The embedded content in the post. * @property {string} record.embed.$type The type of embedded content. * @property {Array} [record.embed.images] The images to embed. + * @property {Object} [record.embed.external] The external link card to embed. * */ @@ -95,6 +96,138 @@ import { validatePostOptions } from "../util/options.js"; // Helpers //----------------------------------------------------------------------------- +/** + * Parses Open Graph metadata from an HTML string using regular expressions. + * @param {string} html The HTML string to parse. + * @returns {{title: string, description: string, image: string|null}} The extracted metadata. + */ +function parseOpenGraphData(html) { + const ogData = /** @type {Record} */ ({}); + + // Match all tags + const metaTagRegex = /]+>/gi; + let metaMatch; + + while ((metaMatch = metaTagRegex.exec(html)) !== null) { + const tag = metaMatch[0]; + + // Check for property="og:*" attribute (handles both quote styles) + const propertyMatch = /\bproperty=["'](og:[^"']+)["']/i.exec(tag); + if (!propertyMatch) { + continue; + } + + // Extract the key after "og:" prefix + const key = propertyMatch[1].slice(3).toLowerCase(); + + // Extract content attribute value + const contentMatch = /\bcontent=["']([^"']*)["']/i.exec(tag); + if (!contentMatch) { + continue; + } + + // Only keep the first value for each key + if (!ogData[key]) { + ogData[key] = contentMatch[1]; + } + } + + return { + title: ogData.title ?? "", + description: ogData.description ?? "", + image: ogData.image ?? null, + }; +} + +/** + * Fetches Open Graph metadata from a URL. + * @param {string} url The URL to fetch metadata from. + * @param {AbortSignal} [signal] An optional abort signal. + * @returns {Promise<{title: string, description: string, image: string|null}>} The extracted metadata. + */ +async function fetchOpenGraphData(url, signal) { + const response = await fetch(url, { + headers: { "User-Agent": "crosspost-bot/1.0" }, + redirect: "follow", + signal, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch URL: ${response.status} ${response.statusText}`, + ); + } + + const html = await response.text(); + return parseOpenGraphData(html); +} + +/** + * Creates an external card embed from the first URL found in the post facets. + * Fetches Open Graph metadata and optionally uploads a thumbnail image. + * @param {BlueskyOptions} options The options for the strategy. + * @param {BlueskySession} session The session data. + * @param {Array<{index: Object, features: Array<{$type: string, uri?: string}>}>} facets The detected facets. + * @param {AbortSignal} [signal] The abort signal for the request. + * @returns {Promise<{$type: string, external: Object}|null>} The embed object, or null if no card could be generated. + */ +async function createCardEmbed(options, session, facets, signal) { + const firstUrlFeature = + /** @type {{uri: string, $type: string} | undefined} */ ( + facets + .flatMap(f => f.features) + .find(feat => feat.$type === BLUESKY_URL_FACET) + ); + + if (!firstUrlFeature) { + return null; + } + + try { + const ogData = await fetchOpenGraphData(firstUrlFeature.uri, signal); + + if (!ogData.title) { + return null; + } + + /** @type {{uri: string, title: string, description: string, thumb?: Object}} */ + const external = { + uri: firstUrlFeature.uri, + title: ogData.title, + description: ogData.description, + }; + + if (ogData.image) { + try { + const imageResponse = await fetch(ogData.image, { signal }); + + if (imageResponse.ok) { + const imageBuffer = new Uint8Array( + await imageResponse.arrayBuffer(), + ); + const result = await uploadImage( + options, + session, + imageBuffer, + signal, + ); + external.thumb = result.blob; + } + } catch { + // Ignore image fetch failures + } + } + + return { + $type: "app.bsky.embed.external", + external, + }; + } catch { + // Silently ignore OG data fetch failures + return null; + } +} + /** * Gets the URL for creating a session. * @param {BlueskyOptions} options The options for the strategy. @@ -346,6 +479,18 @@ async function postMessage(options, session, message, postOptions) { images, }; } + } else { + // Auto-generate card preview from the first URL in the post + const embed = await createCardEmbed( + options, + session, + rawFacets, + postOptions?.signal, + ); + + if (embed) { + body.record.embed = embed; + } } const response = await fetch(url, { diff --git a/tests/strategies/bluesky.test.js b/tests/strategies/bluesky.test.js index d245dd5..9998ebe 100644 --- a/tests/strategies/bluesky.test.js +++ b/tests/strategies/bluesky.test.js @@ -68,8 +68,9 @@ const UPLOAD_BLOB_RESPONSE = { }; const server = new MockServer(`https://${HOST}`); +const externalServer = new MockServer("https://example.com"); const fetchMocker = new FetchMocker({ - servers: [server], + servers: [server, externalServer], }); const __filename = fileURLToPath(import.meta.url); @@ -715,6 +716,335 @@ describe("BlueskyStrategy", function () { }); }); + describe("post with auto card preview", function () { + let strategy; + + beforeEach(function () { + strategy = new BlueskyStrategy(options); + fetchMocker.mockGlobal(); + }); + + afterEach(function () { + fetchMocker.unmockGlobal(); + server.clear(); + externalServer.clear(); + }); + + it("should auto-generate a card preview from the first URL in the post", async function () { + const text = "Check this out https://example.com/article"; + + // Mock the OG data fetch + externalServer.get( + { url: "/article" }, + { + status: 200, + headers: { "content-type": "text/html" }, + body: ` + + + `, + }, + ); + + // Mock Bluesky session + server.post( + { + url: CREATE_SESSION_URL, + headers: { "content-type": "application/json" }, + body: { + identifier: options.identifier, + password: options.password, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_SESSION_RESPONSE, + }, + ); + + // Mock post creation with auto-generated embed + server.post( + { + url: CREATE_RECORD_URL, + headers: { + "content-type": "application/json", + authorization: `Bearer ${CREATE_SESSION_RESPONSE.accessJwt}`, + }, + body: { + repo: CREATE_SESSION_RESPONSE.did, + collection: "app.bsky.feed.post", + record: { + $type: "app.bsky.feed.post", + text, + facets: [ + { + index: { + byteStart: 15, + byteEnd: 42, + }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: "https://example.com/article", + }, + ], + }, + ], + embed: { + $type: "app.bsky.embed.external", + external: { + uri: "https://example.com/article", + title: "Example Article", + description: "An interesting article", + }, + }, + }, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_RECORD_RESPONSE, + }, + ); + + const response = await strategy.post(text); + assert.deepStrictEqual(response, CREATE_RECORD_RESPONSE); + }); + + it("should auto-generate a card preview with thumbnail image", async function () { + const text = "Check this out https://example.com/article"; + + // Mock the OG data fetch + externalServer.get( + { url: "/article" }, + { + status: 200, + headers: { "content-type": "text/html" }, + body: ` + + + + `, + }, + ); + + // Mock the image fetch + externalServer.get( + { url: "/image.png" }, + { + status: 200, + headers: { "content-type": "image/png" }, + body: new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, + }, + ); + + // Mock Bluesky session + server.post( + { + url: CREATE_SESSION_URL, + headers: { "content-type": "application/json" }, + body: { + identifier: options.identifier, + password: options.password, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_SESSION_RESPONSE, + }, + ); + + // Mock image upload + server.post( + { + url: UPLOAD_BLOB_URL, + headers: { + "content-type": "*/*", + authorization: `Bearer ${CREATE_SESSION_RESPONSE.accessJwt}`, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: UPLOAD_BLOB_RESPONSE, + }, + ); + + // Mock post creation with auto-generated embed including thumb + server.post( + { + url: CREATE_RECORD_URL, + headers: { + "content-type": "application/json", + authorization: `Bearer ${CREATE_SESSION_RESPONSE.accessJwt}`, + }, + body: { + repo: CREATE_SESSION_RESPONSE.did, + collection: "app.bsky.feed.post", + record: { + $type: "app.bsky.feed.post", + text, + facets: [ + { + index: { + byteStart: 15, + byteEnd: 42, + }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: "https://example.com/article", + }, + ], + }, + ], + embed: { + $type: "app.bsky.embed.external", + external: { + uri: "https://example.com/article", + title: "Example Article", + description: "An interesting article", + thumb: UPLOAD_BLOB_RESPONSE.blob, + }, + }, + }, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_RECORD_RESPONSE, + }, + ); + + const response = await strategy.post(text); + assert.deepStrictEqual(response, CREATE_RECORD_RESPONSE); + }); + + it("should not auto-generate a card preview when OG fetch fails", async function () { + const text = "Check this out https://example.com/broken"; + + // Mock OG data fetch failure + externalServer.get( + { url: "/broken" }, + { + status: 500, + headers: { "content-type": "text/plain" }, + body: "Internal Server Error", + }, + ); + + // Mock Bluesky session + server.post( + { + url: CREATE_SESSION_URL, + headers: { "content-type": "application/json" }, + body: { + identifier: options.identifier, + password: options.password, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_SESSION_RESPONSE, + }, + ); + + // Mock post creation without embed + server.post( + { + url: CREATE_RECORD_URL, + headers: { + "content-type": "application/json", + authorization: `Bearer ${CREATE_SESSION_RESPONSE.accessJwt}`, + }, + body: { + repo: CREATE_SESSION_RESPONSE.did, + collection: "app.bsky.feed.post", + record: { + $type: "app.bsky.feed.post", + text, + facets: [ + { + index: { + byteStart: 15, + byteEnd: 41, + }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: "https://example.com/broken", + }, + ], + }, + ], + }, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_RECORD_RESPONSE, + }, + ); + + const response = await strategy.post(text); + assert.deepStrictEqual(response, CREATE_RECORD_RESPONSE); + }); + + it("should not auto-generate a card preview when no URLs in post", async function () { + const text = "Hello, world!"; + + // Mock Bluesky session + server.post( + { + url: CREATE_SESSION_URL, + headers: { "content-type": "application/json" }, + body: { + identifier: options.identifier, + password: options.password, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_SESSION_RESPONSE, + }, + ); + + // Mock post creation without embed + server.post( + { + url: CREATE_RECORD_URL, + headers: { + "content-type": "application/json", + authorization: `Bearer ${CREATE_SESSION_RESPONSE.accessJwt}`, + }, + body: { + repo: CREATE_SESSION_RESPONSE.did, + collection: "app.bsky.feed.post", + record: { + $type: "app.bsky.feed.post", + text, + }, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_RECORD_RESPONSE, + }, + ); + + const response = await strategy.post(text); + assert.deepStrictEqual(response, CREATE_RECORD_RESPONSE); + }); + }); + describe("getUrlFromResponse", function () { let strategy; From bc80408b8d481cbb9eac85389e3311838149944c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:56:40 +0000 Subject: [PATCH 3/3] feat: use as fallback when og:title is absent in card preview Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> Agent-Logs-Url: https://github.com/humanwhocodes/crosspost/sessions/e05f7b80-f4d0-4f40-85e6-4c04995e99ce --- src/strategies/bluesky.js | 8 +++ tests/strategies/bluesky.test.js | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/strategies/bluesky.js b/src/strategies/bluesky.js index c9e748e..8834c94 100644 --- a/src/strategies/bluesky.js +++ b/src/strategies/bluesky.js @@ -132,6 +132,14 @@ function parseOpenGraphData(html) { } } + // Fall back to <title> tag if og:title is not present + if (!ogData.title) { + const titleMatch = /<title[^>]*>([^<]*)<\/title>/i.exec(html); + if (titleMatch) { + ogData.title = titleMatch[1].trim(); + } + } + return { title: ogData.title ?? "", description: ogData.description ?? "", diff --git a/tests/strategies/bluesky.test.js b/tests/strategies/bluesky.test.js index 9998ebe..e467b8e 100644 --- a/tests/strategies/bluesky.test.js +++ b/tests/strategies/bluesky.test.js @@ -1043,6 +1043,89 @@ describe("BlueskyStrategy", function () { const response = await strategy.post(text); assert.deepStrictEqual(response, CREATE_RECORD_RESPONSE); }); + + it("should use <title> as fallback when og:title is not present", async function () { + const text = "Check this out https://example.com/article"; + + // Mock the OG data fetch - page has <title> but no og:title + externalServer.get( + { url: "/article" }, + { + status: 200, + headers: { "content-type": "text/html" }, + body: `<html><head> + <title>Page Title Fallback + + `, + }, + ); + + // Mock Bluesky session + server.post( + { + url: CREATE_SESSION_URL, + headers: { "content-type": "application/json" }, + body: { + identifier: options.identifier, + password: options.password, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_SESSION_RESPONSE, + }, + ); + + // Mock post creation with embed using as title + server.post( + { + url: CREATE_RECORD_URL, + headers: { + "content-type": "application/json", + authorization: `Bearer ${CREATE_SESSION_RESPONSE.accessJwt}`, + }, + body: { + repo: CREATE_SESSION_RESPONSE.did, + collection: "app.bsky.feed.post", + record: { + $type: "app.bsky.feed.post", + text, + facets: [ + { + index: { + byteStart: 15, + byteEnd: 42, + }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: "https://example.com/article", + }, + ], + }, + ], + embed: { + $type: "app.bsky.embed.external", + external: { + uri: "https://example.com/article", + title: "Page Title Fallback", + description: "An interesting article", + }, + }, + }, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: CREATE_RECORD_RESPONSE, + }, + ); + + const response = await strategy.post(text); + assert.deepStrictEqual(response, CREATE_RECORD_RESPONSE); + }); }); describe("getUrlFromResponse", function () {