Dernière activité 1741068833

Révision d720a05f6be643ad825eb9a822b44ca6534bce01

README.md Brut

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 Brut
1import { AtpAgent } from "@atproto/api";
2
3export 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
16function filter(feed) {
17 return feed.filter(({ reply, reason }) => {
18 return !(reply || reason);
19 });
20}
21
22function 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
46function 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
101function 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
131function 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
174export 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 Brut
1import sanitizeHtml from "sanitize-html";
2import { request } from "undici";
3
4const MASTODON_HOST = "https://nyan.lol";
5const TIMELINE_API = `${MASTODON_HOST}/api/v1/timelines/home?limit=40`;
6const VERIFY_API = `${MASTODON_HOST}/api/v1/accounts/verify_credentials`;
7
8export 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
20export 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
32function 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
46function 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
87function 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
121export 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