Add alpha to beta converter

This commit is contained in:
Joakim Soderlund 2017-11-12 18:22:17 +01:00
parent b590c60c3b
commit a4c1fe5a2d
5 changed files with 783 additions and 0 deletions

View file

@ -23,8 +23,10 @@ Converter module.
from .base import Converter
from .alpha_beta import AlphaBetaConverter
__all__ = (
'Converter',
'AlphaBetaConverter',
)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
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'<p>{html.strip()}</p>'
@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)

View file

@ -1,4 +1,5 @@
arrow
bbcode
blinker
jmespath
requests

View file

@ -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": "<p>REDACTED</p>",
"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"
}
}
]
}

View file

@ -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 <http://www.gnu.org/licenses/>.
#
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