Ultima attività 1741068833

melanie ha revisionato questo gist 1741068833. Vai alla revisione

Nessuna modifica

melanie ha revisionato questo gist 1741068786. Vai alla revisione

Nessuna modifica

melanie ha revisionato questo gist 1741068712. Vai alla revisione

3 files changed, 370 insertions

README.md(file creato)

@@ -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 creato)

@@ -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 creato)

@@ -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 + }
Più nuovi Più vecchi