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 Misskey support #87

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
97354bc
Add: support for Misskey notes
Kare-Udon Oct 9, 2023
0126d41
Fix: lint error
Kare-Udon Oct 25, 2023
cc26d9c
Update: remove commands for Misskey
Kare-Udon Oct 25, 2023
0d923f5
Add: __init__.py
Kare-Udon Oct 26, 2023
1ed31f0
Fix: wrong JSON post args issue
Kare-Udon Oct 26, 2023
853781d
Fix: datetime.fromisoformat format error
Kare-Udon Oct 26, 2023
6c2451b
Fix: interface - post_id wrong type issue
Kare-Udon Oct 26, 2023
6f31420
Fix: file "url" missing issue
Kare-Udon Oct 30, 2023
b7a89fe
Fix: misskey note parsing issue
Kare-Udon Oct 30, 2023
490d863
Fix: ffmpeg overwrite issue
Kare-Udon Oct 30, 2023
e9f4290
Add: docs for Misskey site
Kare-Udon Oct 31, 2023
68f83c2
Fix: GIF sending issue
Kare-Udon Nov 19, 2023
d8af27a
Merge branch 'master' into master
Kare-Udon Nov 19, 2023
67491af
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 19, 2023
29d4d35
Fix: Handle ClientResponseError in Misskey API
Kare-Udon Nov 20, 2023
c573f8f
fix: Refactor build_caption to handle note URI
Kare-Udon Nov 20, 2023
6e3f89f
fix: Update Misskey file name template
Kare-Udon Nov 20, 2023
69b67e8
add: JSON format validation in Misskey API
Kare-Udon Nov 21, 2023
97a899e
Merge master
Kare-Udon Nov 21, 2023
0ae1447
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 21, 2023
4665bbd
refactor: validate response with pydantic model
y-young Nov 21, 2023
9c07648
refactor: return Misskey error message
y-young Nov 21, 2023
9f6efd8
fix: requirements.txt
y-young Nov 21, 2023
82d4433
refactor: update match pattern for note ID
y-young Nov 21, 2023
71bb371
fix: Python 3.8 typing syntax
y-young Nov 22, 2023
5ed8e0b
Fix: note visibility check in Misskey API
Kare-Udon Dec 2, 2023
64a190f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 2, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/> | | ✔ |
| Misskey | Any Misskey instance | | ✔ |
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/> | | ✔ |
| Misskey | 各种 Misskey 实例 | | ✔ |
57 changes: 57 additions & 0 deletions docs/site/misskey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Misskey Notes

From any Misskey instance.

## Customizing Storage Path & File Name

For more information, refer to [Customizing Storage Path & File Name](./index.md/#customizing-storage-path--file-name).

### MISSKEY_FILE_PATH

:material-lightbulb-on: Optional, defaults to `Misskey`

Storage path for downloaded images.

### MISSKEY_FILE_NAME

:material-lightbulb-on: Optional, defaults to `{id} - {filename} - {user[name]}({user[username]})`

File name for downloaded images.

### Available Variables

_Only common used ones are listed._

```json
{
"id": "<note id>",
"createdAt": "2023-10-05T23:10:13.016Z",
"userId": "<user id>",
"user": {
"id": "<user id>",
"name": "<user display name>",
"username": "<username>",
"host": "<instance URL>"
},
"text": "<note texts>",
"fileIds": ["<file id>"],
"files": [
{
"id": "<file id>",
"createdAt": "2023-10-05T23:10:16.445Z",
"name": "<file name>",
"type": "image/webp",
"md5": "<file md5>",
"size": 800000,
"isSensitive": false,
"properties": {
"width": 2048,
"height": 1969
},
"url": "<file URL>",
"thumbnailUrl": "<thumbnail URL>"
}
],
"uri": "<URL from the original instance of this note>" // Only available when the note is from a remote instance.
}
```
57 changes: 57 additions & 0 deletions docs/site/misskey_zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Misskey Notes

从任何 Misskey 实例获取。

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

更多信息请查阅 [自定义存储路径和文件名](./index.zh.md/#customizing-storage-path--file-name)。

### Misskey_FILE_PATH

:material-lightbulb-on: 可选,默认为 `Misskey`

存储路径。

### Misskey_FILE_NAME

:material-lightbulb-on: 可选,默认为 `{id} - {filename} - {user[name]}({user[username]})`

文件名称。

### 可用变量

_此处只列出常用项。_

```json
{
"id": "<note id>",
"createdAt": "2023-10-05T23:10:13.016Z",
"userId": "<用户 id>",
"user": {
"id": "<用户 id>",
"name": "<用户展示名称>",
"username": "<用户名>",
"host": "<用户所在实例 URL>"
},
"text": "<note 文字>",
"fileIds": ["<文件 id>"],
"files": [
{
"id": "<文件 id>",
"createdAt": "2023-10-05T23:10:16.445Z",
"name": "<文件名>",
"type": "image/webp",
"md5": "<文件 md5>",
"size": 800000,
"isSensitive": false,
"properties": {
"width": 2048,
"height": 1969
},
"url": "<文件 URL>",
"thumbnailUrl": "<缩略图 URL>"
}
],
"uri": "<该 note 在源实例的 URL>" // Only available when the note is from a remote instance.
}
```
6 changes: 6 additions & 0 deletions nazurin/sites/misskey/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Misskey site plugin"""
from .api import Misskey
from .config import PRIORITY
from .interface import handle, patterns

__all__ = ["Misskey", "PRIORITY", "patterns", "handle"]
164 changes: 164 additions & 0 deletions nazurin/sites/misskey/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import os
import shlex
import subprocess

Check warning on line 3 in nazurin/sites/misskey/api.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

nazurin/sites/misskey/api.py#L3

Consider possible security implications associated with the subprocess module.
from pathlib import Path
from typing import List, Tuple

from aiohttp.client_exceptions import ClientResponseError
from pydantic import ValidationError

from nazurin.models import Caption, Illust, Image, Ugoira
from nazurin.models.file import File
from nazurin.sites.misskey.models import File as NoteFile
from nazurin.sites.misskey.models import Note
from nazurin.utils import Request, logger
from nazurin.utils.decorators import async_wrap, network_retry
from nazurin.utils.exceptions import NazurinError
from nazurin.utils.helpers import fromisoformat

from .config import DESTINATION, FILENAME


class Misskey:
@network_retry
async def get_note(self, site_url: str, note_id: str) -> Note:
"""Fetch a note from a Misskey instance."""
api = f"https://{site_url}/api/notes/show"
json = {"noteId": note_id}

async with Request() as request:
async with request.post(url=api, json=json) as response:
if response.status == 400:
Kare-Udon marked this conversation as resolved.
Show resolved Hide resolved
result = await response.json()
error = result["error"]
raise NazurinError(
f"Error: {error['message']} ({error['code']})"
) from None
try:
response.raise_for_status()
except ClientResponseError as err:
raise NazurinError(err) from None

data = await response.json()
Kare-Udon marked this conversation as resolved.
Show resolved Hide resolved
try:
note = Note.model_validate(data)
if note.visibility not in ["public", "home"]:
raise NazurinError("Note is not public.")
except ValidationError as err:
raise NazurinError(err) from None

def build_caption(self, note: Note, site_url: str) -> Caption:
url = f"https://{site_url}/notes/{note.id}"
caption = {
"url": url,
"author": f"{note.user.name} #{note.user.username}",
"text": note.text,
}
# URL from the original instance
if note.uri is not None:
caption["original_url"] = note.uri
return Caption(caption)

async def get_video(self, file: NoteFile, destination: str, filename: str) -> File:
file_type = file.type
if file_type not in ["video/mp4", "image/gif"]:

@async_wrap
def convert(config: File, output: File):
config_path = Path(config.path).as_posix()
# Copy video and audio streams
args = [
"ffmpeg",
"-i",
config_path,
"-vcodec",
"copy",
"-acodec",
"copy",
"-y",
output.path,
]
cmd = shlex.join(args)
logger.info("Calling FFmpeg with command: {}", cmd)
try:
output = subprocess.check_output(
args, stderr=subprocess.STDOUT, shell=False

Check warning on line 85 in nazurin/sites/misskey/api.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

nazurin/sites/misskey/api.py#L85

subprocess call - check for execution of untrusted input.
)
except subprocess.CalledProcessError as error:
logger.error(
"FFmpeg failed with code {}, output:\n {}",
error.returncode,
error.output.decode(),
)
raise NazurinError("Failed to convert ugoira to mp4.") from None

ori_video = File(filename, file.url)
async with Request() as session:
await ori_video.download(session)
filename, _ = os.path.splitext(filename)
video = File(filename + ".mp4", "", destination)
await convert(ori_video, video)
else:
video = File(filename, file.url, destination)
return video

async def parse_note(self, note: Note, site_url: str) -> Illust:
"""Build caption and get images."""
# Build note caption
caption = self.build_caption(note, site_url)

images: List[Image] = []
files: List[File] = []
note_files = note.files
for index, file in enumerate(note_files):
if not file.url:
continue
destination, filename = self.get_storage_dest(note, file, index)
file_type = file.type
if file_type.startswith("image") and not file_type.endswith("gif"):
images.append(
Image(
filename,
file.url,
destination,
file.thumbnailUrl,
file.size,
file.properties.width,
file.properties.height,
)
)
elif file_type.startswith("video") or file_type.endswith("gif"):
return Ugoira(
await self.get_video(file, destination, filename),
caption,
note.model_dump(),
)

return Illust(images, caption, note.model_dump(), files)

async def fetch(self, site_url: str, note_id: str) -> Illust:
note = await self.get_note(site_url, note_id)
return await self.parse_note(note, site_url)

@staticmethod
def get_storage_dest(note: Note, file: NoteFile, index: int) -> Tuple[str, str]:
"""
Format destination and filename.
"""
created_at = fromisoformat(note.createdAt)
filename, extension = os.path.splitext(file.name)
context = {
"user": note.user.model_dump(),
**file.properties.model_dump(),
"md5": file.md5,
# Human-friendly filename, without extension
"filename": filename,
"index": index,
"id": note.id,
"created_at": created_at,
"extension": extension,
}
return (
DESTINATION.format_map(context),
FILENAME.format_map(context) + extension,
)
11 changes: 11 additions & 0 deletions nazurin/sites/misskey/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from nazurin.config import env

PRIORITY = 30
COLLECTION = "misskey"

with env.prefixed("MISSKEY_"):
with env.prefixed("FILE_"):
DESTINATION: str = env.str("PATH", default="Misskey")
FILENAME: str = env.str(
"NAME", default="{id}_{index} - {filename} - {user[name]}({user[username]})"
)
24 changes: 24 additions & 0 deletions nazurin/sites/misskey/interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from time import time

from nazurin.database import Database
from nazurin.models import Illust

from .api import Misskey
from .config import COLLECTION

patterns = [
# https://site.example/notes/9khcu788zb
r"https?://(.*?)/notes/([0-9a-z]+)",
]


async def handle(match) -> Illust:
site_url = match.group(1)
post_id = match.group(2)
db = Database().driver()
collection = db.collection(COLLECTION)

illust = await Misskey().fetch(site_url, post_id)
illust.metadata["collected_at"] = time()
await collection.insert(str(post_id), illust.metadata)
return illust
43 changes: 43 additions & 0 deletions nazurin/sites/misskey/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from typing import List, Optional

from pydantic import BaseModel, ConfigDict


class User(BaseModel):
model_config = ConfigDict(extra="allow")

id: str
username: str
name: Optional[str]


class FileProperties(BaseModel):
model_config = ConfigDict(extra="allow")

width: int
height: int


class File(BaseModel):
model_config = ConfigDict(extra="allow")

name: str
type: str
md5: str
size: int
properties: FileProperties
url: Optional[str]
thumbnailUrl: Optional[str]


class Note(BaseModel):
model_config = ConfigDict(extra="allow")

id: str
createdAt: str
userId: str
user: User
text: Optional[str]
files: List[File]
uri: Optional[str] = None
visibility: str
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ environs~=9.3.4
async_lru~=2.0.2
loguru~=0.6.0
humanize~=4.8.0
pydantic~=2.5.1

pixivpy3~=3.7.2
beautifulsoup4~=4.10.0
Expand Down
Loading