melanie bu gisti düzenledi 10 months ago. Düzenlemeye git
Değişiklik yok
melanie bu gisti düzenledi 10 months ago. Düzenlemeye git
Değişiklik yok
melanie bu gisti düzenledi 10 months ago. Düzenlemeye git
3 files changed, 370 insertions
README.md(dosya oluşturuldu)
| @@ -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(dosya oluşturuldu)
| @@ -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(dosya oluşturuldu)
| @@ -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 | + | } | |