Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add bsky.app support #94

Merged
merged 13 commits into from
Feb 15, 2024
11 changes: 9 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ DATABASE = Local
# ALLOW_ID =

# Allowed username(s)
# ALLOW_USERNAME =
# ALLOW_USERNAME =

# Allowed group ID(s)
# GROUP_ID =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -225,7 +232,7 @@ DATABASE = Local
# Refresh token
# OD_RF_TOKEN =

# Client secret
# Client secret
# OD_SECRET =

# ----- S3 -----
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
| DeviantArt | <https://www.deviantart.com/> | | ✔ |
| Lofter | <https://www.lofter.com/> | | ✔ |
| Kemono.party | <https://kemono.party/> | | ✔ |
| Bluesky | <https://bsky.app/> | | ✔ |

### Supported Databases

Expand Down
1 change: 1 addition & 0 deletions docs/includes/site.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@
| DeviantArt | <https://www.deviantart.com/> | | ✔ |
| Lofter | <https://www.lofter.com/> | | ✔ |
| Kemono.party | <https://kemono.party/> | | ✔ |
| Bluesky | <https://bsky.app/> | | ✔ |
1 change: 1 addition & 0 deletions docs/includes/site.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@
| DeviantArt | <https://www.deviantart.com/> | | ✔ |
| Lofter | <https://www.lofter.com/> | | ✔ |
| Kemono.party | <https://kemono.party/> | | ✔ |
| Bluesky | <https://bsky.app/> | | ✔ |
53 changes: 53 additions & 0 deletions docs/site/bluesky.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Bluesky

<https://bsky.app/>

## 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,
}
}
}
```
53 changes: 53 additions & 0 deletions docs/site/bluesky.zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Bluesky

<https://bsky.app/>

## 自定义存储路径和文件名

更多信息请查阅 [自定义存储路径和文件名](./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,
}
}
}
```
6 changes: 6 additions & 0 deletions nazurin/sites/bluesky/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
126 changes: 126 additions & 0 deletions nazurin/sites/bluesky/api.py
Original file line number Diff line number Diff line change
@@ -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"],
}
)
11 changes: 11 additions & 0 deletions nazurin/sites/bluesky/config.py
Original file line number Diff line number Diff line change
@@ -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]})"
)
25 changes: 25 additions & 0 deletions nazurin/sites/bluesky/interface.py
Original file line number Diff line number Diff line change
@@ -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
Misaka13514 marked this conversation as resolved.
Show resolved Hide resolved
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
Loading