melanie ревизій цього gist 10 months ago. До ревизії
Без змін
melanie ревизій цього gist 10 months ago. До ревизії
Без змін
melanie ревизій цього gist 10 months ago. До ревизії
3 files changed, 370 insertions
README.md(файл створено)
| @@ -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(файл створено)
| @@ -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(файл створено)
| @@ -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 | + | } | |