README.md
· 967 B · Markdown
Raw
_From the blog post [I Made a Mastodon and Bluesky Client](https://melkat.blog/p/mastodon-bsky-app/)_
## Usage
### Bluesky
#### Install dependencies
```sh
npm i --save @atproto/api
```
#### Customize server
```diff
- const agent = new AtpAgent({ service: "https://melkat.blue" });
+ const agent = new AtpAgent({ service: "Your Bluesky PDS" });
```
#### Example usage
```js
import { buildJsonFeed, getTimeline } from "./bluesky.js";
const timeline = await getTimeline(identifier, appPassword);
buildJsonFeed(identifier, timeline)
```
### Mastodon
#### Install dependencies
```sh
npm i --save sanitize-html undici
```
#### Customize server
```diff
- const MASTODON_HOST = "https://nyan.lol";
+ const MASTODON_HOST = "Your Mastodon Host";
```
#### Example usage
```js
import { buildJsonFeed, getTimeline, getUser } from "./mastodon.js";
const user = await getUser(token);
const timeline = await getTimeline(token);
buildJsonFeed(user, timeline)
```
From the blog post I Made a Mastodon and Bluesky Client
Usage
Bluesky
Install dependencies
npm i --save @atproto/api
Customize server
- const agent = new AtpAgent({ service: "https://melkat.blue" });
+ const agent = new AtpAgent({ service: "Your Bluesky PDS" });
Example usage
import { buildJsonFeed, getTimeline } from "./bluesky.js";
const timeline = await getTimeline(identifier, appPassword);
buildJsonFeed(identifier, timeline)
Mastodon
Install dependencies
npm i --save sanitize-html undici
Customize server
- const MASTODON_HOST = "https://nyan.lol";
+ const MASTODON_HOST = "Your Mastodon Host";
Example usage
import { buildJsonFeed, getTimeline, getUser } from "./mastodon.js";
const user = await getUser(token);
const timeline = await getTimeline(token);
buildJsonFeed(user, timeline)
bluesky.js
· 4.7 KiB · JavaScript
Raw
import { AtpAgent } from "@atproto/api";
export async function getTimeline(identifier, password) {
const agent = new AtpAgent({ service: "https://melkat.blue" });
await agent.login({
identifier,
password,
});
const did = agent.assertDid;
if (!did) {
return;
}
return await agent.getTimeline({ limit: 100 });
}
function filter(feed) {
return feed.filter(({ reply, reason }) => {
return !(reply || reason);
});
}
function parseFacets(facets) {
const links = [];
const tags = [];
if (Array.isArray(facets)) {
for (const facet of facets) {
if (Array.isArray(facet.features)) {
for (const feature of facet.features) {
switch (feature?.$type) {
case "app.bsky.richtext.facet#link":
links.push(feature.uri);
break;
case "app.bsky.richtext.facet#tag":
tags.push(feature.tag);
break;
default:
break;
}
}
}
}
}
return { links, tags };
}
function parseEmbed(embed) {
const images = [];
const video = {};
const quote = {};
switch (embed?.$type) {
case "app.bsky.embed.images#view":
if (embed.images) {
for (const image of embed.images) {
images.push({
fullsize: image?.fullsize,
alt: image?.alt,
});
}
}
break;
case "app.bsky.embed.video#view":
video.playlist = embed.playlist;
video.thumbnail = embed.thumbnail;
video.alt = embed.alt;
break;
case "app.bsky.embed.record#view":
if (embed.record) {
quote.handle = embed.record.author.handle;
quote.displayName = embed.record.author.displayName;
quote.avatar = embed.record.author.avatar;
quote.url = `https://bsky.app/profile/${
quote.handle
}/post/${embed.record.uri.split("/").at(-1)}`;
const moreEmbeds = parseEmbed(embed.record?.embeds?.[0]);
const { links, tags } = parseFacets(embed?.record?.value?.facets);
quote.text = embed?.record?.value?.text;
quote.images = moreEmbeds?.images;
quote.video = moreEmbeds?.video;
quote.links = links;
quote.tags = tags;
quote.stats = {
likeCount: embed.record?.likeCount || 0,
replyCount: embed.record?.replyCount || 0,
repostCount: embed.record?.repostCount || 0,
quoteCount: embed.record?.quoteCount || 0,
};
}
break;
default:
break;
}
return {
images,
video,
quote,
};
}
function buildHtml({ images, links, text, video, stats, quote }) {
const videoHtml =
video?.thumbnail && video?.playlist
? `<video poster="${video?.thumbnail}" controls playsinline><source src="${video?.playlist}" type="application/x-mpegURL"></source></video>`
: "";
const imagesHtml =
images
.map(({ alt, fullsize }) => `<img src="${fullsize}" alt="${alt}" />`)
.join("<br />") || "";
const linksHtml =
links?.length > 0
? `<ul>${links
.map((link) => `<li><a href="${link}">${link}</a></li>`)
.join("")}</ul>`
: "";
const quoteHtml = quote?.text
? `<blockquote><cite><a href="${quote.url}">${quote.displayName} (${
quote.handle
})</a></cite><br/>${buildHtml(quote)}</blockquote>`
: "";
const statsHtml = `<ul>${
stats?.likeCount > 0 ? `<li>❤️ Likes: ${stats.likeCount}</li>` : ""
}${stats?.replyCount > 0 ? `<li>🗣️ Replies: ${stats.replyCount}</li>` : ""}${
stats?.repostCount > 0 ? `<li>🔄 Reposts: ${stats.repostCount}</li>` : ""
}${
stats?.quoteCount > 0 ? `<li>💬 Quotes: ${stats.quoteCount}</li>` : ""
}</ul>`;
return `<p>${text}</p>${quoteHtml}${videoHtml}${imagesHtml}${linksHtml}${statsHtml}`;
}
function processFeed(feed) {
const cleanFeed = filter(feed);
return cleanFeed.map(({ post }) => {
const text = post.record.text;
const name = post.author.displayName || post.author.handle;
const url = `https://bsky.app/profile/${post.author.handle}/post/${post.uri
.split("/")
.at(-1)}`;
const { links, tags } = parseFacets(post.record.facets);
const { images, video, quote } = parseEmbed(post.embed);
const stats = {
replyCount: post.replyCount,
repostCount: post.repostCount,
likeCount: post.likeCount,
quoteCount: post.quoteCount,
};
const content_html = buildHtml({
images,
links,
quote,
stats,
text,
video,
});
return {
author: {
name,
url: `https://bsky.app/profile/${post.author.handle}`,
avatar: post.author.avatar,
},
date_published: post.indexedAt,
external_url: links?.[0],
id: url,
image: images?.[0]?.fullsize,
summary: post.record.text,
tags,
title: name,
url,
content_html,
};
});
}
export function buildJsonFeed(handle, feed) {
const items = processFeed(feed.data.feed);
return {
version: "https://jsonfeed.org/version/1.1",
title: `${handle}'s Timeline`,
description: "A personalized Bluesky feed",
home_page_url: `https://bsky.app/profile/${handle}`,
items,
};
}
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 | } |
184 |
mastodon.js
· 3.3 KiB · JavaScript
Raw
import sanitizeHtml from "sanitize-html";
import { request } from "undici";
const MASTODON_HOST = "https://nyan.lol";
const TIMELINE_API = `${MASTODON_HOST}/api/v1/timelines/home?limit=40`;
const VERIFY_API = `${MASTODON_HOST}/api/v1/accounts/verify_credentials`;
export async function getTimeline(token) {
const { body } = await request(TIMELINE_API, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
const json = await body.json();
return json;
}
export async function getUser(token) {
const { body } = await request(VERIFY_API, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
const json = await body.json();
return json;
}
function filter(timeline) {
return timeline.filter(
({ in_reply_to_id, in_reply_to_account_id, reblog, filtered, muted }) => {
return !(
in_reply_to_id ||
in_reply_to_account_id ||
reblog ||
filtered.length > 0 ||
muted
);
},
);
}
function buildHtml({
content,
card,
favourites_count,
media_attachments,
reblogs_count,
replies_count,
}) {
const mediaHtml = media_attachments
.map(({ type, url, description }) => {
if (type === "image") {
return `<img src="${url}" alt="${description}" />`;
}
if (type === "video") {
return `<video controls playsinline><source src="${url}"></source></video>`;
}
})
.join("<br />");
const cardHtml = card
? `<blockquote><a href="${card.url}">${
card.image
? `<img src="${card.image}" alt="${card.image_description}" /><br />`
: ""
}${card.title ? `<strong>${card.title}</strong>` : ""}</a></blockquote>`
: "";
const statsHtml = `<ul>${
favourites_count > 0 ? `<li>❤️ Favorites: ${favourites_count}</li>` : ""
}${replies_count > 0 ? `<li>🗣️ Replies: ${replies_count}</li>` : ""}${
reblogs_count > 0 ? `<li>🔄 Reblogs: ${reblogs_count}</li>` : ""
}</ul>`;
const sanitizedContent = sanitizeHtml(content, {
allowedTags: ["b", "i", "em", "strong", "a", "span", "br"],
allowedAttributes: {
a: ["href", "title"],
img: ["src", "alt"],
},
});
return `<p>${sanitizedContent}</p>${mediaHtml}${cardHtml}${statsHtml}`;
}
function processTimeline(timeline) {
const cleanTimeline = filter(timeline);
return cleanTimeline.map((status) => {
const content = status.content;
const summary = sanitizeHtml(content, {
allowedTags: [],
allowedAttributes: {},
});
const name = status.account.display_name || status.account.acct;
const firstImage = status.media_attachments.find(
({ type }) => type === "image",
)?.[0]?.url;
const content_html = buildHtml(status);
return {
author: {
name,
url: status.account.url,
avatar: status.account.avatar,
},
date_published: status.created_at,
date_modified: status.edited_at,
external_url: status?.card?.url,
id: status.url,
image: firstImage,
summary,
tags: status.tags?.map(({ name }) => name),
title: name,
url: status.url,
content_html,
};
});
}
export function buildJsonFeed(user, timeline) {
const items = processTimeline(timeline);
return {
version: "https://jsonfeed.org/version/1.1",
title: `${user.username}'s Timeline`,
description: "A personalized Mastodon feed",
home_page_url: user.url,
items,
};
}
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 | } |
131 |