diff --git a/.env.example b/.env.example
index b40a631b..846712e1 100644
--- a/.env.example
+++ b/.env.example
@@ -46,7 +46,7 @@ DATABASE = Local
# ALLOW_ID =
# Allowed username(s)
-# ALLOW_USERNAME =
+# ALLOW_USERNAME =
# Allowed group ID(s)
# GROUP_ID =
@@ -94,6 +94,13 @@ DATABASE = Local
# File name
# BILIBILI_FILE_NAME = {dynamic_id_str}_{index} - {user[name]}({user[uid]})
+# ----- Bluesky -----
+# File directory
+# BLUESKY_FILE_PATH = Bluesky
+
+# File name
+# BLUESKY_FILE_NAME = {rkey}_{index} - {user[display_name]}({user[handle]})
+
# ----- Danbooru -----
# File directory
# DANBOORU_FILE_PATH = Danbooru
@@ -225,7 +232,7 @@ DATABASE = Local
# Refresh token
# OD_RF_TOKEN =
-# Client secret
+# Client secret
# OD_SECRET =
# ----- S3 -----
diff --git a/README.md b/README.md
index 233e1eff..a1c7b70c 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@
| DeviantArt | | | ✔ |
| Lofter | | | ✔ |
| Kemono.party | | | ✔ |
+| Bluesky | | | ✔ |
### Supported Databases
diff --git a/docs/includes/site.md b/docs/includes/site.md
index 3ccd6977..7ac2ede3 100644
--- a/docs/includes/site.md
+++ b/docs/includes/site.md
@@ -16,3 +16,4 @@
| DeviantArt | | | ✔ |
| Lofter | | | ✔ |
| Kemono.party | | | ✔ |
+| Bluesky | | | ✔ |
diff --git a/docs/includes/site.zh.md b/docs/includes/site.zh.md
index 0fd3bfac..475f7956 100644
--- a/docs/includes/site.zh.md
+++ b/docs/includes/site.zh.md
@@ -16,3 +16,4 @@
| DeviantArt | | | ✔ |
| Lofter | | | ✔ |
| Kemono.party | | | ✔ |
+| Bluesky | | | ✔ |
diff --git a/docs/site/bluesky.md b/docs/site/bluesky.md
new file mode 100644
index 00000000..b47ab4a2
--- /dev/null
+++ b/docs/site/bluesky.md
@@ -0,0 +1,53 @@
+# Bluesky
+
+
+
+## Customizing Storage Path & File Name
+
+For more information, refer to [Customizing Storage Path & File Name](./index.md/#customizing-storage-path--file-name).
+
+### BLUESKY_FILE_PATH
+
+:material-lightbulb-on: Optional, defaults to `Bluesky`
+
+Storage path for downloaded images.
+
+### BLUESKY_FILE_NAME
+
+:material-lightbulb-on: Optional, defaults to `{rkey}_{index} - {user[display_name]}({user[handle]})`
+
+File name for downloaded images.
+
+### Available Variables
+
+_Only common used ones are listed._
+
+```json
+{
+ "rkey": "Record Key of the post",
+ "uri": "at:// uri of the post",
+ "cid": "cid of the post",
+ "user": {
+ "did": "User DID",
+ "handle": "User handle",
+ "display_name": "User display name"
+ },
+ "filename": "Original file name, without extension",
+ "index": "Image index",
+ "timestamp": "Timestamp",
+ "extension": "Extension",
+ "reply_count": "Reply count",
+ "repost_count": "Repost count",
+ "like_count": "Like count",
+ "pic": {
+ "aspectRatio": {
+ "height": 1440,
+ "width": 1080
+ },
+ "image": {
+ "mimeType": "image/jpeg",
+ "size": 123456,
+ }
+ }
+}
+```
diff --git a/docs/site/bluesky.zh.md b/docs/site/bluesky.zh.md
new file mode 100644
index 00000000..7bb17dc1
--- /dev/null
+++ b/docs/site/bluesky.zh.md
@@ -0,0 +1,53 @@
+# Bluesky
+
+
+
+## 自定义存储路径和文件名
+
+更多信息请查阅 [自定义存储路径和文件名](./index.zh.md/#customizing-storage-path--file-name)。
+
+### BLUESKY_FILE_PATH
+
+:material-lightbulb-on: 可选,默认为 `Bluesky`
+
+存储路径。
+
+### BLUESKY_FILE_NAME
+
+:material-lightbulb-on: 可选,默认为 `{rkey}_{index} - {user[display_name]}({user[handle]})`
+
+文件名称。
+
+### 可用变量
+
+_此处只列出常用项。_
+
+```json
+{
+ "rkey": "帖子的 Record Key",
+ "uri": "帖子的 at:// uri",
+ "cid": "帖子的 cid",
+ "user": {
+ "did": "用户 DID",
+ "handle": "用户 handle",
+ "display_name": "用户显示名称"
+ },
+ "filename": "原始文件名称,不含扩展名",
+ "index": "图片索引",
+ "timestamp": "时间戳",
+ "extension": "扩展名",
+ "reply_count": "回复数",
+ "repost_count": "转发数",
+ "like_count": "点赞数",
+ "pic": {
+ "aspectRatio": {
+ "height": 1440,
+ "width": 1080
+ },
+ "image": {
+ "mimeType": "image/jpeg",
+ "size": 123456,
+ }
+ }
+}
+```
diff --git a/nazurin/sites/bluesky/__init__.py b/nazurin/sites/bluesky/__init__.py
new file mode 100644
index 00000000..6cf0b3c7
--- /dev/null
+++ b/nazurin/sites/bluesky/__init__.py
@@ -0,0 +1,6 @@
+"""Bluesky site plugin."""
+from .api import Bluesky
+from .config import PRIORITY
+from .interface import handle, patterns
+
+__all__ = ["Bluesky", "PRIORITY", "patterns", "handle"]
diff --git a/nazurin/sites/bluesky/api.py b/nazurin/sites/bluesky/api.py
new file mode 100644
index 00000000..527a5c07
--- /dev/null
+++ b/nazurin/sites/bluesky/api.py
@@ -0,0 +1,126 @@
+import os
+from datetime import datetime
+from typing import List, Tuple
+
+from nazurin.models import Caption, Illust, Image
+from nazurin.utils import Request
+from nazurin.utils.decorators import network_retry
+from nazurin.utils.exceptions import NazurinError
+
+from .config import DESTINATION, FILENAME
+
+
+class Bluesky:
+ @network_retry
+ async def resolve_handle(self, handle: str):
+ """
+ Get the DID from handle.
+ https://www.docs.bsky.app/docs/api/com-atproto-identity-resolve-handle
+ """
+ api = "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"
+ async with Request() as request:
+ async with request.get(api, params={"handle": handle}) as response:
+ data = await response.json()
+ if "error" in data:
+ raise NazurinError(data["message"])
+ response.raise_for_status()
+ return data["did"]
+
+ @network_retry
+ async def get_post_thread(self, uri: str, depth: int, parent_height: int):
+ """
+ Get posts in a thread.
+ https://www.docs.bsky.app/docs/api/app-bsky-feed-get-post-thread
+ """
+ api = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread"
+ params = {"uri": uri, "depth": depth, "parentHeight": parent_height}
+ async with Request() as request:
+ async with request.get(api, params=params) as response:
+ data = await response.json()
+ if "error" in data:
+ raise NazurinError(data["message"])
+ response.raise_for_status()
+ return data["thread"]["post"]
+
+ async def fetch(self, user_handle: str, post_rkey: str) -> Illust:
+ """Fetch images and detail."""
+ user_did = await self.resolve_handle(user_handle)
+ post_uri = self.construct_at_uri(user_did, "app.bsky.feed.post", post_rkey)
+ item = await self.get_post_thread(post_uri, 0, 0)
+ imgs = self.get_images(item)
+ caption = self.build_caption(item)
+ caption["url"] = f"https://bsky.app/profile/{user_handle}/post/{post_rkey}"
+ return Illust(imgs, caption, item)
+
+ @staticmethod
+ def construct_at_uri(authority: str, collection: str, rkey: str):
+ """
+ Construct at:// URI.
+ https://atproto.com/specs/at-uri-scheme
+ """
+ return f"at://{authority}/{collection}/{rkey}"
+
+ @staticmethod
+ def get_images(item: dict) -> List[Image]:
+ """Get all images in a post."""
+ embed_images = item["embed"]["images"]
+ if not embed_images or len(embed_images) == 0:
+ raise NazurinError("No image found")
+ imgs = []
+ for index, pic in enumerate(embed_images):
+ url = pic["fullsize"]
+ thumbnail = pic["thumb"]
+ destination, filename = Bluesky.get_storage_dest(item, pic, index)
+ imgs.append(
+ Image(
+ filename,
+ url,
+ destination,
+ thumbnail,
+ )
+ )
+ return imgs
+
+ @staticmethod
+ def get_storage_dest(item: dict, pic: dict, index: int = 0) -> Tuple[str, str]:
+ """
+ Format destination and filename.
+ """
+
+ url = pic["fullsize"]
+ created_at = datetime.fromisoformat(item["record"]["createdAt"])
+ basename = os.path.basename(url)
+ filename, extension = basename.split("@")
+ context = {
+ "rkey": item["uri"].split("/")[-1],
+ "uri": item["uri"],
+ "cid": item["cid"],
+ "user": {
+ "did": item["author"]["did"],
+ "handle": item["author"]["handle"],
+ "display_name": item["author"]["displayName"],
+ },
+ # Original filename, without extension
+ "filename": filename,
+ # Image index
+ "index": index,
+ "timestamp": created_at,
+ "extension": extension,
+ "reply_count": item["replyCount"],
+ "repost_count": item["repostCount"],
+ "like_count": item["likeCount"],
+ "pic": pic,
+ }
+ return (
+ DESTINATION.format_map(context),
+ FILENAME.format_map(context) + "." + extension,
+ )
+
+ @staticmethod
+ def build_caption(item: dict) -> Caption:
+ return Caption(
+ {
+ "author": "#" + item["author"]["displayName"],
+ "text": item["record"]["text"],
+ }
+ )
diff --git a/nazurin/sites/bluesky/config.py b/nazurin/sites/bluesky/config.py
new file mode 100644
index 00000000..36cb6362
--- /dev/null
+++ b/nazurin/sites/bluesky/config.py
@@ -0,0 +1,11 @@
+from nazurin.config import env
+
+PRIORITY = 10
+COLLECTION = "bluesky"
+
+with env.prefixed("BLUESKY_"):
+ with env.prefixed("FILE_"):
+ DESTINATION: str = env.str("PATH", default="Bluesky")
+ FILENAME: str = env.str(
+ "NAME", default="{rkey}_{index} - {user[display_name]}({user[handle]})"
+ )
diff --git a/nazurin/sites/bluesky/interface.py b/nazurin/sites/bluesky/interface.py
new file mode 100644
index 00000000..0e71041f
--- /dev/null
+++ b/nazurin/sites/bluesky/interface.py
@@ -0,0 +1,25 @@
+from time import time
+
+from nazurin.database import Database
+from nazurin.models import Illust
+
+from .api import Bluesky
+from .config import COLLECTION
+
+patterns = [
+ # https://atproto.com/specs/record-key#record-key-syntax
+ # https://bsky.app/profile/shiratamacaron.bsky.social/post/3kkt7oj5rmw2j
+ r"bsky\.app/profile/([\w\.\-]+)/post/([\w\.\-~]+)"
+]
+
+
+async def handle(match) -> Illust:
+ user_handle = match.group(1)
+ post_rkey = match.group(2)
+ db = Database().driver()
+ collection = db.collection(COLLECTION)
+
+ illust = await Bluesky().fetch(user_handle, post_rkey)
+ illust.metadata["collected_at"] = time()
+ await collection.insert("_".join([user_handle, post_rkey]), illust.metadata)
+ return illust