diff --git a/lib/v2/bsky/maintainer.js b/lib/v2/bsky/maintainer.js
index 61f792cbb..f7a7bde20 100644
--- a/lib/v2/bsky/maintainer.js
+++ b/lib/v2/bsky/maintainer.js
@@ -1,3 +1,4 @@
module.exports = {
'/keyword/:keyword': ['untitaker'],
+ '/profile/:handle': ['TonyRL'],
};
diff --git a/lib/v2/bsky/posts.js b/lib/v2/bsky/posts.js
new file mode 100644
index 000000000..c53c59fb9
--- /dev/null
+++ b/lib/v2/bsky/posts.js
@@ -0,0 +1,42 @@
+const { parseDate } = require('@/utils/parse-date');
+const { resolveHandle, getProfile, getAuthorFeed } = require('./utils');
+const { art } = require('@/utils/render');
+const { join } = require('path');
+
+module.exports = async (ctx) => {
+ const { handle } = ctx.params;
+ const DID = await resolveHandle(handle, ctx.cache.tryGet);
+ const profile = await getProfile(DID, ctx.cache.tryGet);
+ const authorFeed = await getAuthorFeed(DID, ctx.cache.tryGet);
+
+ const items = authorFeed.feed.map(({ post }) => ({
+ title: post.record.text.split('\n')[0],
+ description: art(join(__dirname, 'templates/post.art'), {
+ text: post.record.text.replace(/\n/g, '
'),
+ embed: post.embed,
+ // embed.$type "app.bsky.embed.record#view" and "app.bsky.embed.recordWithMedia#view"
+ // are not handled
+ }),
+ author: post.author.displayName,
+ pubDate: parseDate(post.record.createdAt),
+ link: `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('app.bsky.feed.post/')[1]}`,
+ upvotes: post.likeCount,
+ comments: post.replyCount,
+ }));
+
+ ctx.state.data = {
+ title: `${profile.displayName} (@${profile.handle}) — Bluesky`,
+ description: profile.description.replace(/\n/g, ' '),
+ link: `https://bsky.app/profile/${profile.handle}`,
+ image: profile.banner,
+ icon: profile.avatar,
+ logo: profile.avatar,
+ item: items,
+ };
+
+ ctx.state.json = {
+ DID,
+ profile,
+ authorFeed,
+ };
+};
diff --git a/lib/v2/bsky/radar.js b/lib/v2/bsky/radar.js
index ef04b280a..70a5f2d0d 100644
--- a/lib/v2/bsky/radar.js
+++ b/lib/v2/bsky/radar.js
@@ -8,6 +8,12 @@ module.exports = {
source: '/search',
target: (params, url) => `/bsky/keyword/${new URL(url).searchParams.get('q')}`,
},
+ {
+ title: 'Post',
+ docs: 'https://docs.rsshub.app/routes/social-media#bluesky-bsky',
+ source: '/profile/:handle',
+ target: '/bsky/profile/:handle',
+ },
],
},
};
diff --git a/lib/v2/bsky/router.js b/lib/v2/bsky/router.js
index 3d4d1c62d..ed6101bd4 100644
--- a/lib/v2/bsky/router.js
+++ b/lib/v2/bsky/router.js
@@ -1,3 +1,4 @@
module.exports = (router) => {
router.get('/keyword/:keyword', require('./keyword'));
+ router.get('/profile/:handle', require('./posts'));
};
diff --git a/lib/v2/bsky/templates/post.art b/lib/v2/bsky/templates/post.art
new file mode 100644
index 000000000..06b42960a
--- /dev/null
+++ b/lib/v2/bsky/templates/post.art
@@ -0,0 +1,15 @@
+{{ if text }}
+ {{@ text }}
+{{ /if }}
+
+{{ if embed }}
+ {{ if embed.$type == 'app.bsky.embed.images#view'}}
+ {{ each embed.images i }}
+ 
+ {{ /each }}
+ {{ else if embed.$type == 'app.bsky.embed.external#view' }}
+ {{ embed.external.title }}
+ {{ embed.external.description }}
+
+ {{ /if }}
+{{ /if }}
diff --git a/lib/v2/bsky/utils.js b/lib/v2/bsky/utils.js
new file mode 100644
index 000000000..09f5a9f6e
--- /dev/null
+++ b/lib/v2/bsky/utils.js
@@ -0,0 +1,52 @@
+const got = require('@/utils/got');
+const config = require('@/config').value;
+
+/**
+ * docs: https://atproto.com/lexicons/app-bsky
+ */
+
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/identity/resolveHandle.json
+const resolveHandle = (handle, tryGet) =>
+ tryGet(`bsky:${handle}`, async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle', {
+ searchParams: {
+ handle,
+ },
+ });
+ return data.did;
+ });
+
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/actor/getProfile.json
+const getProfile = (did, tryGet) =>
+ tryGet(`bsky:profile:${did}`, async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile', {
+ searchParams: {
+ actor: did,
+ },
+ });
+ return data;
+ });
+
+// https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/getAuthorFeed.json
+const getAuthorFeed = (did, tryGet) =>
+ tryGet(
+ `bsky:authorFeed:${did}`,
+ async () => {
+ const { data } = await got('https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed', {
+ searchParams: {
+ actor: did,
+ filter: 'posts_and_author_threads',
+ limit: 30,
+ },
+ });
+ return data;
+ },
+ config.cache.routeExpire,
+ false
+ );
+
+module.exports = {
+ resolveHandle,
+ getProfile,
+ getAuthorFeed,
+};
diff --git a/website/docs/routes/social-media.mdx b/website/docs/routes/social-media.mdx
index 7246fb282..2fad79f99 100644
--- a/website/docs/routes/social-media.mdx
+++ b/website/docs/routes/social-media.mdx
@@ -339,6 +339,10 @@
## Bluesky (bsky) {#bluesky-bsky}
+### Post {#bluesky-bsky-post}
+
+
+
### Keywords {#bluesky-bsky-keywords}