melanie ha revisionato questo gist 7 months ago. Vai alla revisione
Nessuna modifica
melanie ha revisionato questo gist 7 months ago. Vai alla revisione
Nessuna modifica
melanie ha revisionato questo gist 7 months ago. 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 | + | } | |