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