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 |