From 268ab7c0294c0338f3eaae3659934203812339c9 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Tue, 7 Apr 2020 17:42:10 +0200 Subject: Add support for mangaplus shueisha --- README.md | 4 +- plugins/mangaplus.shueisha.py | 314 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 3 files changed, 318 insertions(+), 2 deletions(-) create mode 100755 plugins/mangaplus.shueisha.py diff --git a/README.md b/README.md index e12e54f..85022ba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # AutoMedia -Automatically track new releases of media and download them. Currently works with rss for torrent sites (`nyaa.si`) and a for these manga sites: `manganelo.com`, `mangakakalot.com`, `lhtranslation.net` and `mangawindow.net`. +Automatically track new releases of media and download them. Currently works with rss for torrent sites (`nyaa.si`) and a for these manga sites: `manganelo.com`, `mangakakalot.com`, `lhtranslation.net`, `mangawindow.net` and `mangaplus.shueisha.co.jp`. A notification is shown on the screen when a download finishes (if notify-send is installed). ## Usage Run automedia with `sync` option and keep it running to track media. You can then use `add` option to add new media to track. @@ -11,7 +11,7 @@ Run automedia without any options to see all options. ## System transmission-cli, notify-send (optional) ## Python -feedparser, transmissionrpc, lxml, requests +feedparser, transmissionrpc, lxml, requests, pure_protobuf (optional, used with mangaplus.shueisha.co.jp) # Requirements when using read_manga.py ## System rofi, sxiv diff --git a/plugins/mangaplus.shueisha.py b/plugins/mangaplus.shueisha.py new file mode 100755 index 0000000..d0978e7 --- /dev/null +++ b/plugins/mangaplus.shueisha.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 + +# The page decryption in this file is based on komikku/servers/mangaplus.py +# available at https://gitlab.com/valos/Komikku/-/blob/master/komikku/servers/mangaplus.py +# which is licensed under GPL 3.0 + +import os +import time +import sys +import re +import requests +import uuid +import json + +from pure_protobuf.dataclasses_ import field, message +from pure_protobuf.types import int32 + +from dataclasses import dataclass +from enum import IntEnum +from typing import List + +RE_ENCRYPTION_KEY = re.compile('.{1,2}') + +headers = { + 'User-Agent': "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36", + 'Origin': 'https://mangaplus.shueisha.co.jp', + 'Referer': 'https://mangaplus.shueisha.co.jp', + 'SESSION-TOKEN': repr(uuid.uuid1()), +} + +api_url = 'https://jumpg-webapi.tokyo-cdn.com/api' +api_manga_url = api_url + '/title_detail?title_id={0}' +api_chapter_url = api_url + '/manga_viewer?chapter_id={0}&split=yes&img_quality=high' + +# Protocol Buffers messages used to deserialize API responses +# https://gist.github.com/ZaneHannanAU/437531300c4df524bdb5fd8a13fbab50 + +class ActionEnum(IntEnum): + DEFAULT = 0 + UNAUTHORIZED = 1 + MAINTAINENCE = 2 + GEOIP_BLOCKING = 3 + +class LanguageEnum(IntEnum): + ENGLISH = 0 + SPANISH = 1 + +class UpdateTimingEnum(IntEnum): + NOT_REGULARLY = 0 + MONDAY = 1 + TUESDAY = 2 + WEDNESDAY = 3 + THURSDAY = 4 + FRIDAY = 5 + SATURDAY = 6 + SUNDAY = 7 + DAY = 8 + +@message +@dataclass +class Popup: + subject: str = field(1) + body: str = field(2) + +@message +@dataclass +class ErrorResult: + action: ActionEnum = field(1) + english_popup: Popup = field(2) + spanish_popup: Popup = field(3) + debug_info: str = field(4) + +@message +@dataclass +class MangaPage: + image_url: str = field(1) + width: int32 = field(2) + height: int32 = field(3) + encryption_key: str = field(5, default=None) + +@message +@dataclass +class Page: + page: MangaPage = field(1, default=None) + +@message +@dataclass +class MangaViewer: + pages: List[Page] = field(1, default_factory=list) + +@message +@dataclass +class Chapter: + title_id: int32 = field(1) + id: int32 = field(2) + name: str = field(3) + subtitle: str = field(4, default=None) + start_timestamp: int32 = field(6, default=None) + end_timestamp: int32 = field(7, default=None) + +@message +@dataclass +class Title: + id: int32 = field(1) + name: str = field(2) + author: str = field(3) + portrait_image_url: str = field(4) + landscape_image_url: str = field(5) + view_count: int32 = field(6) + language: LanguageEnum = field(7, default=LanguageEnum.ENGLISH) + +@message +@dataclass +class TitleDetail: + title: Title = field(1) + title_image_url: str = field(2) + synopsis: str = field(3) + background_image_url: str = field(4) + next_timestamp: int32 = field(5, default=0) + update_timimg: UpdateTimingEnum = field(6, default=UpdateTimingEnum.DAY) + viewing_period_description: str = field(7, default=None) + first_chapters: List[Chapter] = field(9, default_factory=List) + last_chapters: List[Chapter] = field(10, default_factory=list) + is_simul_related: bool = field(14, default=True) + chapters_descending: bool = field(17, default=True) + +@message +@dataclass +class TitlesAll: + titles: List[Title] = field(1) + +@message +@dataclass +class TitlesRanking: + titles: List[Title] = field(1) + +@message +@dataclass +class SuccessResult: + is_featured_updated: bool = field(1, default=False) + titles_all: TitlesAll = field(5, default=None) + titles_ranking: TitlesRanking = field(6, default=None) + title_detail: TitleDetail = field(8, default=None) + manga_viewer: MangaViewer = field(10, default=None) + +@message +@dataclass +class MangaplusResponse: + success: SuccessResult = field(1, default=None) + error: ErrorResult = field(2, default=None) + + +def usage(): + print("mangaplus.shueisha.py command") + print("commands:") + print(" download") + print(" list") + exit(1) + +def usage_list(): + print("mangaplus.shueisha.py list ") + print("examples:") + print(" mangaplus.shueisha.py list \"https://mangaplus.shueisha.co.jp/titles/100056\"") + exit(1) + +def usage_download(): + print("mangaplus.shueisha.py download ") + print("examples:") + print(" mangaplus.shueisha.py download \"https://mangaplus.shueisha.co.jp/viewer/1006611\" /home/adam/Manga/MangaName") + print("") + print("Note: The manga directory has to exist.") + exit(1) + +if len(sys.argv) < 2: + usage() + +def download_file(url, page, save_path): + if page.page.encryption_key is not None: + # Decryption + key_stream = [int(v, 16) for v in RE_ENCRYPTION_KEY.findall(page.page.encryption_key)] + block_size_in_bytes = len(key_stream) + + index = 0 + with requests.get(url, headers=headers, stream=True) as response: + response.raise_for_status() + with open(save_path, "wb") as file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + content = bytes([int(v) ^ key_stream[(index + i) % block_size_in_bytes] for i, v in enumerate(chunk)]) + file.write(content) + index += len(chunk) + else: + with requests.get(url, headers=headers, stream=True) as response: + response.raise_for_status() + with open(save_path, "wb") as file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + file.write(chunk) + +def title_url_extract_manga_id(url): + result = re.search("mangaplus.shueisha.co.jp/titles/([0-9]+)", url) + if result and len(result.groups()) > 0: + return result.groups()[0] + +def list_chapters(url, chapter_list_input): + manga_id = title_url_extract_manga_id(url) + if not manga_id: + print("Failed to extract manga id from url: %s. Note: url is expected to be in this format: mangaplus.shueisha.co.jp/titles/" % url) + exit(2) + + url = api_manga_url.format(manga_id) + + response = requests.get(url, headers=headers) + if response.status_code != 200: + print("Failed to list chapters, server responded with status code %d" % response.status_code) + exit(2) + + resp = MangaplusResponse.loads(response.content) + if resp.error: + print("Mangaplus response error: %s" % str(resp.error)) + exit(1) + + seen_titles = set() + for item in chapter_list_input: + title = item.get("title") + if title and len(title) > 0: + seen_titles.add(title.lower().replace(" ", "")) + + seen_urls = set() + for item in chapter_list_input: + url = item.get("url") + if url and len(url) > 0: + seen_urls.add(url) + + resp_data = resp.success.title_detail + all_chapters_reversed = [] + for resp_chapters in (resp_data.first_chapters, resp_data.last_chapters): + for chapter in resp_chapters: + all_chapters_reversed.append(chapter) + + chapters = [] + for chapter in reversed(all_chapters_reversed): + title = chapter.subtitle + url = "https://mangaplus.shueisha.co.jp/viewer/{0}".format(chapter.id) + if title.lower().replace(" ", "") in seen_titles or url in seen_urls: + break + chapters.append({ "name": title, "url": url }) + print(json.dumps(chapters)) + +def viewer_url_extract_manga_id(url): + result = re.search("mangaplus.shueisha.co.jp/viewer/([0-9]+)", url) + if result and len(result.groups()) > 0: + return result.groups()[0] + +def download_chapter(url, download_dir): + manga_id = viewer_url_extract_manga_id(url) + if not manga_id: + print("Failed to extract manga id from url: %s. Note: url is expected to be in this format: mangaplus.shueisha.co.jp/viewer/" % url) + exit(2) + + url = api_chapter_url.format(manga_id) + + response = requests.get(url, headers=headers) + if response.status_code != 200: + print("Failed to list chapters, server responded with status code %d" % response.status_code) + exit(2) + + in_progress_filepath = os.path.join(download_dir, ".in_progress") + with open(in_progress_filepath, "w") as file: + file.write(url) + + resp = MangaplusResponse.loads(response.content) + if resp.error: + print("Mangaplus response error: %s" % str(resp.error)) + exit(1) + + img_number = 1 + for page in resp.success.manga_viewer.pages: + if page.page is None: + continue + + image_name = page.page.image_url.split('?')[0].split('/')[-1] + ext = image_name[image_name.rfind("."):] + image_name = str(img_number) + ext + image_path = os.path.join(download_dir, image_name) + print("Downloading {} to {}".format(page.page.image_url, image_path)) + download_file(page.page.image_url, page, image_path) + img_number += 1 + + with open(os.path.join(download_dir, ".finished"), "w") as file: + file.write("1") + + os.remove(in_progress_filepath) + +command = sys.argv[1] +if command == "list": + if len(sys.argv) < 3: + usage_list() + + url = sys.argv[2] + chapter_list_input = sys.stdin.read() + if len(chapter_list_input) == 0: + chapter_list_input = [] + else: + chapter_list_input = json.loads(chapter_list_input) + list_chapters(url, chapter_list_input) +elif command == "download": + if len(sys.argv) < 4: + usage_download() + url = sys.argv[2] + download_dir = sys.argv[3] + download_chapter(url, download_dir) +else: + usage() diff --git a/requirements.txt b/requirements.txt index b1d48ea..7c9c65a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ feedparser transmissionrpc lxml +requests +pure_protobuf \ No newline at end of file -- cgit v1.2.3