diff --git a/fimfarchive/converters/__init__.py b/fimfarchive/converters/__init__.py index 54b534d..93a6d7d 100644 --- a/fimfarchive/converters/__init__.py +++ b/fimfarchive/converters/__init__.py @@ -23,8 +23,10 @@ Converter module. from .base import Converter +from .alpha_beta import AlphaBetaConverter __all__ = ( 'Converter', + 'AlphaBetaConverter', ) diff --git a/fimfarchive/converters/alpha_beta.py b/fimfarchive/converters/alpha_beta.py new file mode 100644 index 0000000..eb2fa5d --- /dev/null +++ b/fimfarchive/converters/alpha_beta.py @@ -0,0 +1,468 @@ +""" +Alpha to beta converter for story meta. +""" + + +# +# Fimfarchive, preserves stories from Fimfiction. +# Copyright (C) 2015 Joakim Soderlund +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + + +from copy import deepcopy +from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple +from urllib.parse import quote_plus as urlquote + +import arrow +import bbcode +from jmespath import compile as jmes +from jmespath.parser import ParsedResult + +from fimfarchive.flavors import MetaFormat +from fimfarchive.stories import Story + +from .base import Converter + + +__all__ = ( + 'AlphaBetaConverter', +) + + +HOST = 'https://www.fimfiction.net' +EPOCH = arrow.get(0).isoformat() + + +TAGS = { + '2nd Person': { + 'id': 225, + 'name': 'Second Person', + 'old_id': 'g:second_person', + 'type': 'content', + 'url': 'https://www.fimfiction.net/tag/second-person', + }, + 'Adventure': { + 'id': 226, + 'name': 'Adventure', + 'old_id': 'g:adventure', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/adventure', + }, + 'Alternate Universe': { + 'id': 240, + 'name': 'Alternate Universe', + 'old_id': 'g:alternate_universe', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/alternate-universe', + }, + 'Anthro': { + 'id': 227, + 'name': 'Anthro', + 'old_id': 'g:anthro', + 'type': 'content', + 'url': 'https://www.fimfiction.net/tag/anthro', + }, + 'Comedy': { + 'id': 228, + 'name': 'Comedy', + 'old_id': 'g:comedy', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/comedy', + }, + 'Crossover': { + 'id': 229, + 'name': 'Crossover', + 'old_id': 'g:crossover', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/crossover', + }, + 'Dark': { + 'id': 122, + 'name': 'Dark', + 'old_id': 'g:dark', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/dark', + }, + 'Drama': { + 'id': 230, + 'name': 'Drama', + 'old_id': 'g:drama', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/drama', + }, + 'Equestria Girls': { + 'id': 123, + 'name': 'Equestria Girls', + 'old_id': 'g:equestria_girls', + 'type': 'series', + 'url': 'https://www.fimfiction.net/tag/equestria-girls', + }, + 'Horror': { + 'id': 231, + 'name': 'Horror', + 'old_id': 'g:horror', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/horror', + }, + 'Human': { + 'id': 232, + 'name': 'Human', + 'old_id': 'g:human', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/human', + }, + 'Mystery': { + 'id': 233, + 'name': 'Mystery', + 'old_id': 'g:mystery', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/mystery', + }, + 'Random': { + 'id': 234, + 'name': 'Random', + 'old_id': 'g:random', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/random', + }, + 'Romance': { + 'id': 120, + 'name': 'Romance', + 'old_id': 'g:romance', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/romance', + }, + 'Sad': { + 'id': 235, + 'name': 'Sad', + 'old_id': 'g:sad', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/sad', + }, + 'Sci-Fi': { + 'id': 236, + 'name': 'Science Fiction', + 'old_id': 'g:scifi', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/scifi', + }, + 'Slice of Life': { + 'id': 237, + 'name': 'Slice of Life', + 'old_id': 'g:slice_of_life', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/slice-of-life', + }, + 'Thriller': { + 'id': 238, + 'name': 'Thriller', + 'old_id': 'g:thriller', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/thriller', + }, + 'Tragedy': { + 'id': 239, + 'name': 'Tragedy', + 'old_id': 'g:tragedy', + 'type': 'genre', + 'url': 'https://www.fimfiction.net/tag/tragedy', + }, +} + + +class Handler(Iterable[Tuple[str, Any]]): + """ + Maps story meta to another style. + """ + attrs: Iterable[str] = tuple() + static: Dict[str, Any] = dict() + paths: Dict[str, ParsedResult] = dict() + + def __init__(self, meta: Dict[str, Any]) -> None: + """ + Constructor. + + Args: + meta: The story meta to map. + """ + self.meta = meta + + def __getattr__(self, key: str) -> Any: + """ + Returns values from indirect sources. + """ + if key in self.static: + return self.static[key] + + if key in self.paths: + meta = self.meta + path = self.paths[key] + return path.search(meta) + + return self.meta.get(key) + + def __iter__(self) -> Iterator[Tuple[str, Any]]: + """ + Yields all story meta items. + """ + for attr in self.attrs: + value = getattr(self, attr) + yield attr, value + + +class ArchiveHandler(Handler): + """ + Maps an archive meta dict from root. + """ + attrs = ( + 'date_checked', + 'date_created', + 'date_fetched', + 'date_updated', + 'path', + ) + + paths = { + 'date_checked': jmes('archive.date_checked'), + 'date_created': jmes('archive.date_created'), + 'date_fetched': jmes('archive.date_fetched'), + 'date_updated': jmes('archive.date_updated'), + 'path': jmes('archive.path || path'), + } + + +class AuthorHandler(Handler): + """ + Maps an author meta dict. + """ + attrs = ( + 'avatar', + 'bio_html', + 'date_joined', + 'id', + 'name', + 'num_blog_posts', + 'num_followers', + 'num_stories', + 'url', + ) + + @property + def url(self): + uid = int(self.id) + name = urlquote(str(self.name)) + return f'{HOST}/user/{uid}/{name}' + + +class ChapterHandler(Handler): + """ + Maps a chapter meta dict. + """ + attrs = ( + 'chapter_number', + 'date_modified', + 'date_published', + 'id', + 'num_views', + 'num_words', + 'published', + 'title', + 'url', + ) + + static = { + 'published': True, + } + + paths = { + 'url': jmes('link'), + 'num_views': jmes('views'), + 'num_words': jmes('words'), + } + + def __init__(self, meta: Dict[str, Any], index: int) -> None: + """ + Constructor. + + Args: + meta: The chapter meta to map. + index: The current chapter index. + """ + self.meta = meta + self.chapter_number = int(index) + 1 + + @property + def date_modified(self) -> Optional[str]: + timestamp = self.meta.get('date_modified') + + if timestamp is None: + return None + + return arrow.get(timestamp).isoformat() + + +class RootHandler(Handler): + """ + Maps a root meta dict. + """ + attrs = ( + 'archive', + 'author', + 'chapters', + 'color', + 'completion_status', + 'content_rating', + 'cover_image', + 'date_modified', + 'date_published', + 'date_updated', + 'description_html', + 'id', + 'num_chapters', + 'num_comments', + 'num_dislikes', + 'num_likes', + 'num_views', + 'num_words', + 'prequel', + 'published', + 'rating', + 'short_description', + 'status', + 'submitted', + 'tags', + 'title', + 'total_num_views', + 'url', + ) + + static = { + 'date_modified': EPOCH, + 'published': True, + 'status': 'visible', + 'submitted': True, + } + + paths = { + 'num_chapters': jmes('chapter_count'), + 'num_comments': jmes('comments'), + 'num_dislikes': jmes('dislikes'), + 'num_likes': jmes('likes'), + 'num_views': jmes('views'), + 'num_words': jmes('words'), + 'total_num_views': jmes('total_views'), + } + + @property + def archive(self) -> Dict[str, Any]: + handler = ArchiveHandler(self.meta) + return dict(iter(handler)) + + @property + def author(self) -> Dict[str, Any]: + author = self.meta.get('author') or dict() + handler = AuthorHandler(author) + return dict(iter(handler)) + + @property + def chapters(self) -> List[Dict[str, Any]]: + items = enumerate(self.meta.get('chapters') or list()) + handlers = (ChapterHandler(c, i) for i, c in items) + return [dict(iter(handler)) for handler in handlers] + + @property + def completion_status(self) -> Optional[str]: + status = self.meta.get('status') + return status and status.strip().lower() + + @property + def content_rating(self) -> Optional[str]: + rating = self.meta.get('content_rating_text') + return rating and rating.strip().lower() + + @property + def cover_image(self) -> Dict[str, Any]: + image = self.meta.get('image') + + if image is None: + return None + + base = image.rsplit("-", 1)[0] + assert base.startswith('http') + + return { + 'full': f'{base}-full', + 'large': f'{base}-large', + 'medium': f'{base}-medium', + 'thumbnail': f'{base}-tiny', + } + + @property + def date_updated(self) -> Optional[str]: + timestamp = self.meta.get('date_modified') + + if timestamp is None: + return None + + return arrow.get(timestamp).isoformat() + + @property + def description_html(self) -> Optional[str]: + desc = self.meta.get('description') + + if desc is None: + return None + + html = bbcode.render_html(desc) + return f'

{html.strip()}

' + + @property + def rating(self) -> int: + likes = self.num_likes + dislikes = self.num_dislikes + + if None in (likes, dislikes): + return None + + try: + return round(likes / (likes + dislikes) * 100) + except ZeroDivisionError: + return 50 + + @property + def tags(self) -> List[Dict[str, Any]]: + cats = self.meta.get('categories') or dict() + tags = [TAGS[k] for k, v in cats.items() if v] + return deepcopy(tags) + + +class AlphaBetaConverter(Converter): + """ + Converts story meta from alpha to beta format. + """ + + def __call__(self, story: Story) -> Story: + if MetaFormat.ALPHA not in story.flavors: + raise ValueError(f"Missing flavor: {MetaFormat.ALPHA}") + + handler = RootHandler(story.meta) + meta = dict(iter(handler)) + + flavors = set(story.flavors) + flavors.remove(MetaFormat.ALPHA) + flavors.add(MetaFormat.BETA) + + return story.merge(meta=meta, flavors=flavors) diff --git a/requirements.txt b/requirements.txt index 0bc4ef1..175e8de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ arrow +bbcode blinker jmespath requests diff --git a/tests/converters/test_alpha_beta.json b/tests/converters/test_alpha_beta.json new file mode 100644 index 0000000..5bb4fc8 --- /dev/null +++ b/tests/converters/test_alpha_beta.json @@ -0,0 +1,164 @@ +{ + "pairs": [ + { + "alpha": { + "author": { + "id": 18, + "name": "Sethisto" + }, + "categories": { + "2nd Person": false, + "Adventure": false, + "Alternate Universe": false, + "Anthro": false, + "Comedy": false, + "Crossover": false, + "Dark": false, + "Drama": false, + "Equestria Girls": false, + "Horror": false, + "Human": false, + "Mystery": false, + "Random": true, + "Romance": true, + "Sad": false, + "Sci-Fi": false, + "Slice of Life": false, + "Thriller": false, + "Tragedy": false + }, + "chapter_count": 1, + "chapters": [ + { + "date_modified": 1390908352, + "id": 10, + "link": "https://www.fimfiction.net/story/9/1/the-greatest-equine-who-has-ever-lived/chapter-1", + "title": "Chapter 1", + "views": 9943, + "words": 321 + } + ], + "comments": 223, + "content_rating": 0, + "content_rating_text": "Everyone", + "date_modified": 1309035953, + "description": "REDACTED", + "dislikes": 51, + "full_image": "https://cdn-img.fimfiction.net/story/vr3n-1432418803-9-full", + "id": 9, + "image": "https://cdn-img.fimfiction.net/story/vr3n-1432418803-9-medium", + "likes": 365, + "short_description": "", + "status": "Incomplete", + "title": "The Greatest Equine Who has Ever Lived!", + "total_views": 9943, + "url": "https://www.fimfiction.net/story/9/the-greatest-equine-who-has-ever-lived", + "views": 9943, + "words": 321 + }, + "beta": { + "author": { + "avatar": { + "128": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-128", + "16": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-16", + "192": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-192", + "256": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-256", + "32": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-32", + "384": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-384", + "48": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-48", + "512": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-512", + "64": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-64", + "96": "https://cdn-img.fimfiction.net/user/t74v-1431818459-18-96" + }, + "bio_html": "", + "date_joined": "2011-06-25T16:53:48-04:00", + "id": 18, + "name": "Sethisto", + "num_blog_posts": 0, + "num_followers": 137, + "num_stories": 1, + "url": "https://www.fimfiction.net/user/18/Sethisto" + }, + "chapters": [ + { + "chapter_number": 1, + "date_modified": "2014-01-28T06:25:52-05:00", + "date_published": "2011-07-08T14:04:11-04:00", + "id": 10, + "num_views": 9943, + "num_words": 321, + "published": true, + "title": "Chapter 1", + "url": "https://www.fimfiction.net/story/9/1/the-greatest-equine-who-has-ever-lived/chapter-1" + } + ], + "color": { + "hex": "3e3e7e", + "rgb": [ + 62, + 62, + 126 + ] + }, + "completion_status": "incomplete", + "content_rating": "everyone", + "cover_image": { + "full": "https://cdn-img.fimfiction.net/story/vr3n-1432418803-9-full", + "large": "https://cdn-img.fimfiction.net/story/vr3n-1432418803-9-large", + "medium": "https://cdn-img.fimfiction.net/story/vr3n-1432418803-9-medium", + "thumbnail": "https://cdn-img.fimfiction.net/story/vr3n-1432418803-9-tiny" + }, + "date_modified": "1969-12-31T19:00:00-05:00", + "date_published": "2011-07-08T14:04:11-04:00", + "date_updated": "2011-06-25T17:05:53-04:00", + "description_html": "

REDACTED

", + "id": 9, + "num_chapters": 1, + "num_comments": 223, + "num_dislikes": 51, + "num_likes": 365, + "num_views": 9943, + "num_words": 321, + "prequel": null, + "published": true, + "rating": 88, + "short_description": "", + "status": "visible", + "submitted": true, + "tags": [ + { + "id": 20, + "name": "Trixie", + "old_id": "c:21", + "type": "character", + "url": "https://www.fimfiction.net/tag/trixie" + }, + { + "id": 6, + "name": "Twilight Sparkle", + "old_id": "c:7", + "type": "character", + "url": "https://www.fimfiction.net/tag/twilight-sparkle" + }, + { + "id": 234, + "name": "Random", + "old_id": "g:random", + "type": "genre", + "url": "https://www.fimfiction.net/tag/random" + }, + { + "id": 120, + "name": "Romance", + "old_id": "g:romance", + "type": "genre", + "url": "https://www.fimfiction.net/tag/romance" + } + ], + "title": "The Greatest Equine Who has Ever Lived!", + "total_num_views": 9943, + "url": "https://www.fimfiction.net/story/9/the-greatest-equine-who-has-ever-lived" + } + } + ] +} \ No newline at end of file diff --git a/tests/converters/test_alpha_beta.py b/tests/converters/test_alpha_beta.py new file mode 100644 index 0000000..f28e204 --- /dev/null +++ b/tests/converters/test_alpha_beta.py @@ -0,0 +1,148 @@ +""" +Alpha to beta converter tests. +""" + + +# +# Fimfarchive, preserves stories from Fimfiction. +# Copyright (C) 2015 Joakim Soderlund +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + + +import json +from copy import deepcopy +from typing import Any, Dict + +import arrow +import pytest + +from fimfarchive.converters import AlphaBetaConverter +from fimfarchive.flavors import MetaFormat + + +def to_null(data: Dict[str, Any], *keys: str) -> None: + """ + Nulls the requested keys. + """ + for key in keys: + data[key] = None + + +def to_utc(data: Dict[str, Any], *keys: str) -> None: + """ + Converts the requested keys to UTC time strings. + """ + for key in keys: + value = data.get(key) + + if value is None: + continue + + time = arrow.get(value).to('utc') + data[key] = time.isoformat() + + +@pytest.fixture(scope='module') +def data(): + """ + Returns test data from JSON. + """ + path = f'{__file__[:-3]}.json' + + with open(path, 'rt') as fobj: + return json.load(fobj) + + +class TestAlphaBetaConverter: + """ + AlphaBetaConverter tests. + """ + + @pytest.fixture + def converter(self): + """ + Returns an alpha beta converter instance. + """ + return AlphaBetaConverter() + + @pytest.fixture(params=range(1)) + def pair(self, request, data): + """ + Returns meta test data pairs. + """ + return data['pairs'][request.param] + + @pytest.fixture + def alpha(self, pair): + """ + Returns meta in alpha format. + """ + return deepcopy(pair['alpha']) + + @pytest.fixture + def beta(self, pair): + """ + Returns meta in beta format. + """ + return deepcopy(pair['beta']) + + @pytest.fixture + def expected(self, beta): + """ + Returns the expected meta result. + """ + data = deepcopy(beta) + + data['archive'] = { + 'date_checked': None, + 'date_created': None, + 'date_fetched': None, + 'date_updated': None, + 'path': None, + } + + to_null(data, 'color', 'date_published') + to_utc(data, 'date_modified', 'date_updated') + + to_null(data['author'], *( + 'avatar', + 'bio_html', + 'date_joined', + 'num_blog_posts', + 'num_followers', + 'num_stories', + )) + + for chapter in data['chapters']: + to_null(chapter, 'date_published') + to_utc(chapter, 'date_modified') + + data['tags'] = [ + tag for tag in data['tags'] + if tag['type'] in {'content', 'genre', 'series'} + ] + + return data + + def test_conversion(self, converter, story, expected, alpha): + """ + Tests conversion of story meta from alpha to beta format. + """ + story = story.merge(flavors=[MetaFormat.ALPHA], meta=alpha) + converted = converter(story) + + assert MetaFormat.BETA in converted.flavors + assert expected == converted.meta