melanie revised this gist . Go to revision
No changes
melanie revised this gist . Go to revision
No changes
melanie revised this gist . Go to revision
3 files changed, 370 insertions
README.md(file created)
@@ -0,0 +1,57 @@ | |||
1 | + | _From the blog post [I Made a Mastodon and Bluesky Client](https://melkat.blog/p/mastodon-bsky-app/)_ | |
2 | + | ||
3 | + | ## Usage | |
4 | + | ||
5 | + | ### Bluesky | |
6 | + | ||
7 | + | #### Install dependencies | |
8 | + | ||
9 | + | ```sh | |
10 | + | npm i --save @atproto/api | |
11 | + | ``` | |
12 | + | ||
13 | + | ||
14 | + | #### Customize server | |
15 | + | ||
16 | + | ```diff | |
17 | + | - const agent = new AtpAgent({ service: "https://melkat.blue" }); | |
18 | + | + const agent = new AtpAgent({ service: "Your Bluesky PDS" }); | |
19 | + | ``` | |
20 | + | ||
21 | + | ||
22 | + | #### Example usage | |
23 | + | ||
24 | + | ```js | |
25 | + | import { buildJsonFeed, getTimeline } from "./bluesky.js"; | |
26 | + | ||
27 | + | const timeline = await getTimeline(identifier, appPassword); | |
28 | + | buildJsonFeed(identifier, timeline) | |
29 | + | ``` | |
30 | + | ||
31 | + | ||
32 | + | ### Mastodon | |
33 | + | ||
34 | + | #### Install dependencies | |
35 | + | ||
36 | + | ```sh | |
37 | + | npm i --save sanitize-html undici | |
38 | + | ``` | |
39 | + | ||
40 | + | ||
41 | + | #### Customize server | |
42 | + | ||
43 | + | ```diff | |
44 | + | - const MASTODON_HOST = "https://nyan.lol"; | |
45 | + | + const MASTODON_HOST = "Your Mastodon Host"; | |
46 | + | ``` | |
47 | + | ||
48 | + | ||
49 | + | #### Example usage | |
50 | + | ||
51 | + | ```js | |
52 | + | import { buildJsonFeed, getTimeline, getUser } from "./mastodon.js"; | |
53 | + | ||
54 | + | const user = await getUser(token); | |
55 | + | const timeline = await getTimeline(token); | |
56 | + | buildJsonFeed(user, timeline) | |
57 | + | ``` |
bluesky.js(file created)
@@ -0,0 +1,183 @@ | |||
1 | + | import { AtpAgent } from "@atproto/api"; | |
2 | + | ||
3 | + | export async function getTimeline(identifier, password) { | |
4 | + | const agent = new AtpAgent({ service: "https://melkat.blue" }); | |
5 | + | await agent.login({ | |
6 | + | identifier, | |
7 | + | password, | |
8 | + | }); | |
9 | + | const did = agent.assertDid; | |
10 | + | if (!did) { | |
11 | + | return; | |
12 | + | } | |
13 | + | return await agent.getTimeline({ limit: 100 }); | |
14 | + | } | |
15 | + | ||
16 | + | function filter(feed) { | |
17 | + | return feed.filter(({ reply, reason }) => { | |
18 | + | return !(reply || reason); | |
19 | + | }); | |
20 | + | } | |
21 | + | ||
22 | + | function parseFacets(facets) { | |
23 | + | const links = []; | |
24 | + | const tags = []; | |
25 | + | if (Array.isArray(facets)) { | |
26 | + | for (const facet of facets) { | |
27 | + | if (Array.isArray(facet.features)) { | |
28 | + | for (const feature of facet.features) { | |
29 | + | switch (feature?.$type) { | |
30 | + | case "app.bsky.richtext.facet#link": | |
31 | + | links.push(feature.uri); | |
32 | + | break; | |
33 | + | case "app.bsky.richtext.facet#tag": | |
34 | + | tags.push(feature.tag); | |
35 | + | break; | |
36 | + | default: | |
37 | + | break; | |
38 | + | } | |
39 | + | } | |
40 | + | } | |
41 | + | } | |
42 | + | } | |
43 | + | return { links, tags }; | |
44 | + | } | |
45 | + | ||
46 | + | function parseEmbed(embed) { | |
47 | + | const images = []; | |
48 | + | const video = {}; | |
49 | + | const quote = {}; | |
50 | + | ||
51 | + | switch (embed?.$type) { | |
52 | + | case "app.bsky.embed.images#view": | |
53 | + | if (embed.images) { | |
54 | + | for (const image of embed.images) { | |
55 | + | images.push({ | |
56 | + | fullsize: image?.fullsize, | |
57 | + | alt: image?.alt, | |
58 | + | }); | |
59 | + | } | |
60 | + | } | |
61 | + | break; | |
62 | + | case "app.bsky.embed.video#view": | |
63 | + | video.playlist = embed.playlist; | |
64 | + | video.thumbnail = embed.thumbnail; | |
65 | + | video.alt = embed.alt; | |
66 | + | break; | |
67 | + | case "app.bsky.embed.record#view": | |
68 | + | if (embed.record) { | |
69 | + | quote.handle = embed.record.author.handle; | |
70 | + | quote.displayName = embed.record.author.displayName; | |
71 | + | quote.avatar = embed.record.author.avatar; | |
72 | + | quote.url = `https://bsky.app/profile/${ | |
73 | + | quote.handle | |
74 | + | }/post/${embed.record.uri.split("/").at(-1)}`; | |
75 | + | const moreEmbeds = parseEmbed(embed.record?.embeds?.[0]); | |
76 | + | const { links, tags } = parseFacets(embed?.record?.value?.facets); | |
77 | + | quote.text = embed?.record?.value?.text; | |
78 | + | quote.images = moreEmbeds?.images; | |
79 | + | quote.video = moreEmbeds?.video; | |
80 | + | quote.links = links; | |
81 | + | quote.tags = tags; | |
82 | + | quote.stats = { | |
83 | + | likeCount: embed.record?.likeCount || 0, | |
84 | + | replyCount: embed.record?.replyCount || 0, | |
85 | + | repostCount: embed.record?.repostCount || 0, | |
86 | + | quoteCount: embed.record?.quoteCount || 0, | |
87 | + | }; | |
88 | + | } | |
89 | + | break; | |
90 | + | default: | |
91 | + | break; | |
92 | + | } | |
93 | + | ||
94 | + | return { | |
95 | + | images, | |
96 | + | video, | |
97 | + | quote, | |
98 | + | }; | |
99 | + | } | |
100 | + | ||
101 | + | function buildHtml({ images, links, text, video, stats, quote }) { | |
102 | + | const videoHtml = | |
103 | + | video?.thumbnail && video?.playlist | |
104 | + | ? `<video poster="${video?.thumbnail}" controls playsinline><source src="${video?.playlist}" type="application/x-mpegURL"></source></video>` | |
105 | + | : ""; | |
106 | + | const imagesHtml = | |
107 | + | images | |
108 | + | .map(({ alt, fullsize }) => `<img src="${fullsize}" alt="${alt}" />`) | |
109 | + | .join("<br />") || ""; | |
110 | + | const linksHtml = | |
111 | + | links?.length > 0 | |
112 | + | ? `<ul>${links | |
113 | + | .map((link) => `<li><a href="${link}">${link}</a></li>`) | |
114 | + | .join("")}</ul>` | |
115 | + | : ""; | |
116 | + | const quoteHtml = quote?.text | |
117 | + | ? `<blockquote><cite><a href="${quote.url}">${quote.displayName} (${ | |
118 | + | quote.handle | |
119 | + | })</a></cite><br/>${buildHtml(quote)}</blockquote>` | |
120 | + | : ""; | |
121 | + | const statsHtml = `<ul>${ | |
122 | + | stats?.likeCount > 0 ? `<li>❤️ Likes: ${stats.likeCount}</li>` : "" | |
123 | + | }${stats?.replyCount > 0 ? `<li>🗣️ Replies: ${stats.replyCount}</li>` : ""}${ | |
124 | + | stats?.repostCount > 0 ? `<li>🔄 Reposts: ${stats.repostCount}</li>` : "" | |
125 | + | }${ | |
126 | + | stats?.quoteCount > 0 ? `<li>💬 Quotes: ${stats.quoteCount}</li>` : "" | |
127 | + | }</ul>`; | |
128 | + | return `<p>${text}</p>${quoteHtml}${videoHtml}${imagesHtml}${linksHtml}${statsHtml}`; | |
129 | + | } | |
130 | + | ||
131 | + | function processFeed(feed) { | |
132 | + | const cleanFeed = filter(feed); | |
133 | + | return cleanFeed.map(({ post }) => { | |
134 | + | const text = post.record.text; | |
135 | + | const name = post.author.displayName || post.author.handle; | |
136 | + | const url = `https://bsky.app/profile/${post.author.handle}/post/${post.uri | |
137 | + | .split("/") | |
138 | + | .at(-1)}`; | |
139 | + | const { links, tags } = parseFacets(post.record.facets); | |
140 | + | const { images, video, quote } = parseEmbed(post.embed); | |
141 | + | const stats = { | |
142 | + | replyCount: post.replyCount, | |
143 | + | repostCount: post.repostCount, | |
144 | + | likeCount: post.likeCount, | |
145 | + | quoteCount: post.quoteCount, | |
146 | + | }; | |
147 | + | const content_html = buildHtml({ | |
148 | + | images, | |
149 | + | links, | |
150 | + | quote, | |
151 | + | stats, | |
152 | + | text, | |
153 | + | video, | |
154 | + | }); | |
155 | + | return { | |
156 | + | author: { | |
157 | + | name, | |
158 | + | url: `https://bsky.app/profile/${post.author.handle}`, | |
159 | + | avatar: post.author.avatar, | |
160 | + | }, | |
161 | + | date_published: post.indexedAt, | |
162 | + | external_url: links?.[0], | |
163 | + | id: url, | |
164 | + | image: images?.[0]?.fullsize, | |
165 | + | summary: post.record.text, | |
166 | + | tags, | |
167 | + | title: name, | |
168 | + | url, | |
169 | + | content_html, | |
170 | + | }; | |
171 | + | }); | |
172 | + | } | |
173 | + | ||
174 | + | export function buildJsonFeed(handle, feed) { | |
175 | + | const items = processFeed(feed.data.feed); | |
176 | + | return { | |
177 | + | version: "https://jsonfeed.org/version/1.1", | |
178 | + | title: `${handle}'s Timeline`, | |
179 | + | description: "A personalized Bluesky feed", | |
180 | + | home_page_url: `https://bsky.app/profile/${handle}`, | |
181 | + | items, | |
182 | + | }; | |
183 | + | } |
mastodon.js(file created)
@@ -0,0 +1,130 @@ | |||
1 | + | import sanitizeHtml from "sanitize-html"; | |
2 | + | import { request } from "undici"; | |
3 | + | ||
4 | + | const MASTODON_HOST = "https://nyan.lol"; | |
5 | + | const TIMELINE_API = `${MASTODON_HOST}/api/v1/timelines/home?limit=40`; | |
6 | + | const VERIFY_API = `${MASTODON_HOST}/api/v1/accounts/verify_credentials`; | |
7 | + | ||
8 | + | export async function getTimeline(token) { | |
9 | + | const { body } = await request(TIMELINE_API, { | |
10 | + | method: "GET", | |
11 | + | headers: { | |
12 | + | Authorization: `Bearer ${token}`, | |
13 | + | "Content-Type": "application/json", | |
14 | + | }, | |
15 | + | }); | |
16 | + | const json = await body.json(); | |
17 | + | return json; | |
18 | + | } | |
19 | + | ||
20 | + | export async function getUser(token) { | |
21 | + | const { body } = await request(VERIFY_API, { | |
22 | + | method: "GET", | |
23 | + | headers: { | |
24 | + | Authorization: `Bearer ${token}`, | |
25 | + | "Content-Type": "application/json", | |
26 | + | }, | |
27 | + | }); | |
28 | + | const json = await body.json(); | |
29 | + | return json; | |
30 | + | } | |
31 | + | ||
32 | + | function filter(timeline) { | |
33 | + | return timeline.filter( | |
34 | + | ({ in_reply_to_id, in_reply_to_account_id, reblog, filtered, muted }) => { | |
35 | + | return !( | |
36 | + | in_reply_to_id || | |
37 | + | in_reply_to_account_id || | |
38 | + | reblog || | |
39 | + | filtered.length > 0 || | |
40 | + | muted | |
41 | + | ); | |
42 | + | }, | |
43 | + | ); | |
44 | + | } | |
45 | + | ||
46 | + | function buildHtml({ | |
47 | + | content, | |
48 | + | card, | |
49 | + | favourites_count, | |
50 | + | media_attachments, | |
51 | + | reblogs_count, | |
52 | + | replies_count, | |
53 | + | }) { | |
54 | + | const mediaHtml = media_attachments | |
55 | + | .map(({ type, url, description }) => { | |
56 | + | if (type === "image") { | |
57 | + | return `<img src="${url}" alt="${description}" />`; | |
58 | + | } | |
59 | + | if (type === "video") { | |
60 | + | return `<video controls playsinline><source src="${url}"></source></video>`; | |
61 | + | } | |
62 | + | }) | |
63 | + | .join("<br />"); | |
64 | + | const cardHtml = card | |
65 | + | ? `<blockquote><a href="${card.url}">${ | |
66 | + | card.image | |
67 | + | ? `<img src="${card.image}" alt="${card.image_description}" /><br />` | |
68 | + | : "" | |
69 | + | }${card.title ? `<strong>${card.title}</strong>` : ""}</a></blockquote>` | |
70 | + | : ""; | |
71 | + | const statsHtml = `<ul>${ | |
72 | + | favourites_count > 0 ? `<li>❤️ Favorites: ${favourites_count}</li>` : "" | |
73 | + | }${replies_count > 0 ? `<li>🗣️ Replies: ${replies_count}</li>` : ""}${ | |
74 | + | reblogs_count > 0 ? `<li>🔄 Reblogs: ${reblogs_count}</li>` : "" | |
75 | + | }</ul>`; | |
76 | + | ||
77 | + | const sanitizedContent = sanitizeHtml(content, { | |
78 | + | allowedTags: ["b", "i", "em", "strong", "a", "span", "br"], | |
79 | + | allowedAttributes: { | |
80 | + | a: ["href", "title"], | |
81 | + | img: ["src", "alt"], | |
82 | + | }, | |
83 | + | }); | |
84 | + | return `<p>${sanitizedContent}</p>${mediaHtml}${cardHtml}${statsHtml}`; | |
85 | + | } | |
86 | + | ||
87 | + | function processTimeline(timeline) { | |
88 | + | const cleanTimeline = filter(timeline); | |
89 | + | return cleanTimeline.map((status) => { | |
90 | + | const content = status.content; | |
91 | + | const summary = sanitizeHtml(content, { | |
92 | + | allowedTags: [], | |
93 | + | allowedAttributes: {}, | |
94 | + | }); | |
95 | + | const name = status.account.display_name || status.account.acct; | |
96 | + | const firstImage = status.media_attachments.find( | |
97 | + | ({ type }) => type === "image", | |
98 | + | )?.[0]?.url; | |
99 | + | ||
100 | + | const content_html = buildHtml(status); | |
101 | + | return { | |
102 | + | author: { | |
103 | + | name, | |
104 | + | url: status.account.url, | |
105 | + | avatar: status.account.avatar, | |
106 | + | }, | |
107 | + | date_published: status.created_at, | |
108 | + | date_modified: status.edited_at, | |
109 | + | external_url: status?.card?.url, | |
110 | + | id: status.url, | |
111 | + | image: firstImage, | |
112 | + | summary, | |
113 | + | tags: status.tags?.map(({ name }) => name), | |
114 | + | title: name, | |
115 | + | url: status.url, | |
116 | + | content_html, | |
117 | + | }; | |
118 | + | }); | |
119 | + | } | |
120 | + | ||
121 | + | export function buildJsonFeed(user, timeline) { | |
122 | + | const items = processTimeline(timeline); | |
123 | + | return { | |
124 | + | version: "https://jsonfeed.org/version/1.1", | |
125 | + | title: `${user.username}'s Timeline`, | |
126 | + | description: "A personalized Mastodon feed", | |
127 | + | home_page_url: user.url, | |
128 | + | items, | |
129 | + | }; | |
130 | + | } |