最終更新 1741068833

melanie revised this gist 1741068833. Go to revision

No changes

melanie revised this gist 1741068786. Go to revision

No changes

melanie revised this gist 1741068712. 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 + }
Newer Older