From 88ee3329d43424950551764082e3a98c720e6d79 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Tue, 12 Nov 2019 18:44:44 +0300 Subject: [PATCH 01/26] Add .idea files to gitignore --- .gitignore | 3 +++ rss_reader.py | 0 setup.py | 0 3 files changed, 3 insertions(+) create mode 100644 rss_reader.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 894a44c..3da6272 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ venv.bak/ # mypy .mypy_cache/ + +#PyCharm +.idea \ No newline at end of file diff --git a/rss_reader.py b/rss_reader.py new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e69de29 From 7838b27b3ed4516ed2a35e9ffb7e3706e3456102 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 17 Nov 2019 14:31:10 +0300 Subject: [PATCH 02/26] init component module. add parser load class. add few arguments for parser --- components/parser/arguments/__init__.py | 0 .../parser/arguments/arguments_abstract.py | 15 ++++++++ components/parser/arguments/source.py | 10 ++++++ components/parser/arguments/version.py | 12 +++++++ components/parser/parser.py | 34 +++++++++++++++++++ rss_reader.py | 9 +++++ 6 files changed, 80 insertions(+) create mode 100644 components/parser/arguments/__init__.py create mode 100644 components/parser/arguments/arguments_abstract.py create mode 100644 components/parser/arguments/source.py create mode 100644 components/parser/arguments/version.py create mode 100644 components/parser/parser.py diff --git a/components/parser/arguments/__init__.py b/components/parser/arguments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/parser/arguments/arguments_abstract.py b/components/parser/arguments/arguments_abstract.py new file mode 100644 index 0000000..63cb7c6 --- /dev/null +++ b/components/parser/arguments/arguments_abstract.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod + + +class ArgumentsAbstract(ABC): + + # @property + # def _parser(self): + # return self._parser + + def __init__(self, parser): + self._parser = parser + + @abstractmethod + def add_argument(self): + pass diff --git a/components/parser/arguments/source.py b/components/parser/arguments/source.py new file mode 100644 index 0000000..9f5ef11 --- /dev/null +++ b/components/parser/arguments/source.py @@ -0,0 +1,10 @@ +from .arguments_abstract import ArgumentsAbstract + + +class Source(ArgumentsAbstract): + + def __init__(self, *args): + pass + + def add_argument(self): + pass diff --git a/components/parser/arguments/version.py b/components/parser/arguments/version.py new file mode 100644 index 0000000..bdf30af --- /dev/null +++ b/components/parser/arguments/version.py @@ -0,0 +1,12 @@ +from .arguments_abstract import ArgumentsAbstract + + +class Version(ArgumentsAbstract): + + def __init__(self, parser): + super().__init__(parser) + + def add_argument(self): + self._parser.add_argument( + '-v', '--version', help='Show script version' + ) diff --git a/components/parser/parser.py b/components/parser/parser.py new file mode 100644 index 0000000..d26bfeb --- /dev/null +++ b/components/parser/parser.py @@ -0,0 +1,34 @@ +import importlib +import argparse +import os + +from .arguments.version import Version + +class Parser: + + _arguments_list = ( + 'version', + ) + + # @property + # def _parser(self): + # return self._parser + # + # @_parser.setter + # def _parser(self, description): + # self._parser = argparse.ArgumentParser(description) + + def __init__(self, description, **kwargs): + self._parser = argparse.ArgumentParser(description) + self.init_arguments() + self._parser.parse_args() + + def init_arguments(self): + Version(self._parser).add_argument() + # for argument in self._arguments_list: + # module = importlib.import_module( + # '.arguments.version', '.' + # ) + # argument_class = getattr(module, argument) + # instance = argument_class(self._parser).add_argument() + diff --git a/rss_reader.py b/rss_reader.py index e69de29..26e3f91 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -0,0 +1,9 @@ +from components.parser import parser + + +def main(): + parser.Parser('test') + + +if __name__ == "__main__": + main() From 0787966c15f7712f4ad60121cec066a92985a22a Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 17 Nov 2019 16:15:31 +0300 Subject: [PATCH 03/26] add all required optional and positional argument to parser --- components/__init__.py | 0 components/feed/feed.py | 4 ++++ components/parser/arguments/__init__.py | 5 ++++ components/parser/arguments/optional/json.py | 12 ++++++++++ components/parser/arguments/optional/limit.py | 13 +++++++++++ .../parser/arguments/optional/verbose.py | 13 +++++++++++ .../arguments/{ => optional}/version.py | 4 ++-- .../parser/arguments/positional/source.py | 14 +++++++++++ components/parser/arguments/source.py | 10 -------- components/parser/parser.py | 23 +++++++++---------- rss_reader.py | 5 +++- 11 files changed, 78 insertions(+), 25 deletions(-) create mode 100644 components/__init__.py create mode 100644 components/feed/feed.py create mode 100644 components/parser/arguments/optional/json.py create mode 100644 components/parser/arguments/optional/limit.py create mode 100644 components/parser/arguments/optional/verbose.py rename components/parser/arguments/{ => optional}/version.py (50%) create mode 100644 components/parser/arguments/positional/source.py delete mode 100644 components/parser/arguments/source.py diff --git a/components/__init__.py b/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/feed/feed.py b/components/feed/feed.py new file mode 100644 index 0000000..3e98a99 --- /dev/null +++ b/components/feed/feed.py @@ -0,0 +1,4 @@ + + +class Feed: + pass \ No newline at end of file diff --git a/components/parser/arguments/__init__.py b/components/parser/arguments/__init__.py index e69de29..84a72d2 100644 --- a/components/parser/arguments/__init__.py +++ b/components/parser/arguments/__init__.py @@ -0,0 +1,5 @@ +from .optional.version import * +from .optional.json import * +from .optional.limit import * +from .optional.verbose import * +from .positional.source import * \ No newline at end of file diff --git a/components/parser/arguments/optional/json.py b/components/parser/arguments/optional/json.py new file mode 100644 index 0000000..1f4d3d8 --- /dev/null +++ b/components/parser/arguments/optional/json.py @@ -0,0 +1,12 @@ +from components.parser.arguments.arguments_abstract import ArgumentsAbstract + + +class Json(ArgumentsAbstract): + + def __init__(self, parser): + super().__init__(parser) + + def add_argument(self): + self._parser.add_argument( + '--json', action='store_true', help='Print result as JSON in stdout' + ) diff --git a/components/parser/arguments/optional/limit.py b/components/parser/arguments/optional/limit.py new file mode 100644 index 0000000..765acdb --- /dev/null +++ b/components/parser/arguments/optional/limit.py @@ -0,0 +1,13 @@ +from components.parser.arguments.arguments_abstract import ArgumentsAbstract + + +class Limit(ArgumentsAbstract): + + def __init__(self, parser): + super().__init__(parser) + + def add_argument(self): + self._parser.add_argument( + '--limit', type=int, help='Limit news topics if this parameter provided' + ) + diff --git a/components/parser/arguments/optional/verbose.py b/components/parser/arguments/optional/verbose.py new file mode 100644 index 0000000..608e8a4 --- /dev/null +++ b/components/parser/arguments/optional/verbose.py @@ -0,0 +1,13 @@ +from components.parser.arguments.arguments_abstract import ArgumentsAbstract + + +class Verbose(ArgumentsAbstract): + + def __init__(self, parser): + super().__init__(parser) + + def add_argument(self): + self._parser.add_argument( + '--verbose', action='store_true', help='Outputs verbose status messages' + ) + diff --git a/components/parser/arguments/version.py b/components/parser/arguments/optional/version.py similarity index 50% rename from components/parser/arguments/version.py rename to components/parser/arguments/optional/version.py index bdf30af..bcfae26 100644 --- a/components/parser/arguments/version.py +++ b/components/parser/arguments/optional/version.py @@ -1,4 +1,4 @@ -from .arguments_abstract import ArgumentsAbstract +from components.parser.arguments.arguments_abstract import ArgumentsAbstract class Version(ArgumentsAbstract): @@ -8,5 +8,5 @@ def __init__(self, parser): def add_argument(self): self._parser.add_argument( - '-v', '--version', help='Show script version' + '-v', '--version', action='version', version='%(prog)s 1.0', help='Print version info' ) diff --git a/components/parser/arguments/positional/source.py b/components/parser/arguments/positional/source.py new file mode 100644 index 0000000..080d30d --- /dev/null +++ b/components/parser/arguments/positional/source.py @@ -0,0 +1,14 @@ +from components.parser.arguments.arguments_abstract import ArgumentsAbstract + + +class Source(ArgumentsAbstract): + + def __init__(self, parser): + super().__init__(parser) + + def add_argument(self): + self._parser.add_argument( + 'source', type=str, help='RSS URL' + ) + + diff --git a/components/parser/arguments/source.py b/components/parser/arguments/source.py deleted file mode 100644 index 9f5ef11..0000000 --- a/components/parser/arguments/source.py +++ /dev/null @@ -1,10 +0,0 @@ -from .arguments_abstract import ArgumentsAbstract - - -class Source(ArgumentsAbstract): - - def __init__(self, *args): - pass - - def add_argument(self): - pass diff --git a/components/parser/parser.py b/components/parser/parser.py index d26bfeb..474852d 100644 --- a/components/parser/parser.py +++ b/components/parser/parser.py @@ -1,13 +1,15 @@ -import importlib import argparse -import os +import importlib -from .arguments.version import Version class Parser: _arguments_list = ( + 'source', 'version', + 'json', + 'verbose', + 'limit', ) # @property @@ -18,17 +20,14 @@ class Parser: # def _parser(self, description): # self._parser = argparse.ArgumentParser(description) - def __init__(self, description, **kwargs): - self._parser = argparse.ArgumentParser(description) + def __init__(self, description, usage, **kwargs): + self._parser = argparse.ArgumentParser(description=description, usage=usage) self.init_arguments() self._parser.parse_args() def init_arguments(self): - Version(self._parser).add_argument() - # for argument in self._arguments_list: - # module = importlib.import_module( - # '.arguments.version', '.' - # ) - # argument_class = getattr(module, argument) - # instance = argument_class(self._parser).add_argument() + for argument in self._arguments_list: + module = importlib.import_module('components.parser.arguments') + argument_class = getattr(module, argument[0].upper() + argument[1:]) + argument_class(self._parser).add_argument() diff --git a/rss_reader.py b/rss_reader.py index 26e3f91..9197fbf 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -2,7 +2,10 @@ def main(): - parser.Parser('test') + parser.Parser( + 'Pure Python command-line RSS reader.', + 'rss_reader.py [-h] [--version] [--json] [--verbose] [--limit LIMIT] source' + ) if __name__ == "__main__": From 6a3c62c5f3522684194c10ef877096ad46450132 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 17 Nov 2019 19:35:29 +0300 Subject: [PATCH 04/26] add helpers classes. add feed classes for parsing rss data. fill setup.py with conf data --- components/{ => feed}/__init__.py | 0 components/feed/feed.py | 25 ++++++++++++++++++- components/feed/feed_entry.py | 15 +++++++++++ components/helper/singleton.py | 9 +++++++ components/parser/__init__.py | 0 .../parser/arguments/optional/__init__.py | 0 components/parser/arguments/optional/limit.py | 2 +- .../parser/arguments/positional/__init__.py | 0 components/parser/parser.py | 10 +++++--- conf.py | 7 ++++++ rss_reader.py | 25 +++++++++++++++---- setup.py | 13 ++++++++++ 12 files changed, 95 insertions(+), 11 deletions(-) rename components/{ => feed}/__init__.py (100%) create mode 100644 components/feed/feed_entry.py create mode 100644 components/helper/singleton.py create mode 100644 components/parser/__init__.py create mode 100644 components/parser/arguments/optional/__init__.py create mode 100644 components/parser/arguments/positional/__init__.py create mode 100644 conf.py diff --git a/components/__init__.py b/components/feed/__init__.py similarity index 100% rename from components/__init__.py rename to components/feed/__init__.py diff --git a/components/feed/feed.py b/components/feed/feed.py index 3e98a99..8925803 100644 --- a/components/feed/feed.py +++ b/components/feed/feed.py @@ -1,4 +1,27 @@ +import feedparser +from components.feed.feed_entry import FeedEntry class Feed: - pass \ No newline at end of file + + def __init__(self, args): + self._args = args + self._entry_list = [] + self._title = [] + + self._parse_feeds() + + def show_feeds(self): + return + + def _parse_feeds(self): + feeds = feedparser.parse(self._args.source) + + for feed in feeds.entries: + self._append_feed_entry(feed) + + def _append_feed_entry(self, feed): + self._entry_list.append(FeedEntry(feed)) + + def get_entries(self): + return type(self._feeds) diff --git a/components/feed/feed_entry.py b/components/feed/feed_entry.py new file mode 100644 index 0000000..3bb51c1 --- /dev/null +++ b/components/feed/feed_entry.py @@ -0,0 +1,15 @@ +import html + + +class FeedEntry: + + def __init__(self, feed): + self._title = feed.title + + @property + def title(self): + return self._title + + @title.setter + def title(self, title): + self.title = html.unescape(title) diff --git a/components/helper/singleton.py b/components/helper/singleton.py new file mode 100644 index 0000000..4a71422 --- /dev/null +++ b/components/helper/singleton.py @@ -0,0 +1,9 @@ +class Singleton(object): + + _instance = None + + def __new__(class_, *args, **kwargs): + if not isinstance(class_._instance, class_): + class_._instance = object.__new__(class_, *args, **kwargs) + + return class_._instance diff --git a/components/parser/__init__.py b/components/parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/parser/arguments/optional/__init__.py b/components/parser/arguments/optional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/parser/arguments/optional/limit.py b/components/parser/arguments/optional/limit.py index 765acdb..63471e3 100644 --- a/components/parser/arguments/optional/limit.py +++ b/components/parser/arguments/optional/limit.py @@ -8,6 +8,6 @@ def __init__(self, parser): def add_argument(self): self._parser.add_argument( - '--limit', type=int, help='Limit news topics if this parameter provided' + '--limit', type=int, default=3, help='Limit news topics if this parameter provided' ) diff --git a/components/parser/arguments/positional/__init__.py b/components/parser/arguments/positional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/parser/parser.py b/components/parser/parser.py index 474852d..4c1a463 100644 --- a/components/parser/parser.py +++ b/components/parser/parser.py @@ -20,12 +20,14 @@ class Parser: # def _parser(self, description): # self._parser = argparse.ArgumentParser(description) - def __init__(self, description, usage, **kwargs): + def __init__(self, description, usage): self._parser = argparse.ArgumentParser(description=description, usage=usage) - self.init_arguments() - self._parser.parse_args() + self._init_arguments() - def init_arguments(self): + def get_args(self): + return self._parser.parse_args() + + def _init_arguments(self): for argument in self._arguments_list: module = importlib.import_module('components.parser.arguments') argument_class = getattr(module, argument[0].upper() + argument[1:]) diff --git a/conf.py b/conf.py new file mode 100644 index 0000000..bdc2aed --- /dev/null +++ b/conf.py @@ -0,0 +1,7 @@ +__author__ = 'Mikhan Victor' +__email__ = 'victormikhan@gmail.com' +__package__ = 'rss_reader' +__version__ = '1.0.0' +__status__ = 'Prototype' +__description__ = 'RSS Reader' +__url__ = 'https://github.com/victormikhan/PythonHomework' diff --git a/rss_reader.py b/rss_reader.py index 9197fbf..d4612f1 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -1,11 +1,26 @@ -from components.parser import parser +from components.helper.singleton import Singleton +from components.parser.parser import Parser +from components.feed.feed import Feed + + +class App(Singleton): + + def __init__(self): + console = Parser( + 'Pure Python command-line RSS reader.', + 'rss_reader.py [-h] [--version] [--json] [--verbose] [--limit LIMIT] source' + ) + + self._console_args = console.get_args() + self._feed = Feed(self._console_args) + + @classmethod + def start(cls): + return cls()._feed.show_feeds() def main(): - parser.Parser( - 'Pure Python command-line RSS reader.', - 'rss_reader.py [-h] [--version] [--json] [--verbose] [--limit LIMIT] source' - ) + App.start() if __name__ == "__main__": diff --git a/setup.py b/setup.py index e69de29..83c99b7 100644 --- a/setup.py +++ b/setup.py @@ -0,0 +1,13 @@ +import setuptools +import conf + +setuptools.setup( + name=conf.__package__, + version=conf.__version__, + author=conf.__author__, + author_email=conf.__email__, + description=conf.__description__, + url=conf.__url__, + packages=[conf.__package__], + python_requires='>=3.8', +) From 6f19af3eb83ec8a9e6855e9da06a9c714e291dd5 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 17 Nov 2019 22:14:11 +0300 Subject: [PATCH 05/26] complete default parsing without json. add feed formater for provision output for rss --- components/feed/feed.py | 28 +++++++++++++------ components/feed/feed_entry.py | 24 +++++++++++----- components/feed/feed_formatter.py | 18 ++++++++++++ .../parser/arguments/optional/version.py | 3 +- components/parser/parser.py | 14 +++++----- rss_reader.py | 5 +++- 6 files changed, 67 insertions(+), 25 deletions(-) create mode 100644 components/feed/feed_formatter.py diff --git a/components/feed/feed.py b/components/feed/feed.py index 8925803..c17eea7 100644 --- a/components/feed/feed.py +++ b/components/feed/feed.py @@ -1,27 +1,37 @@ import feedparser from components.feed.feed_entry import FeedEntry +from components.feed.feed_formatter import FeedFormatter class Feed: def __init__(self, args): - self._args = args - self._entry_list = [] - self._title = [] + self._is_json = args.json + self._limit = args.limit + self._url = args.source + self._entities_list = [] + self._feeds_title = '' self._parse_feeds() def show_feeds(self): - return + formatted_feeds = FeedFormatter.generate_output( + self._entities_list, self._limit, self._is_json + ) + + print('Feed: {0}\n\n{1}'.format(self._feeds_title, formatted_feeds)) + def _parse_feeds(self): - feeds = feedparser.parse(self._args.source) + feeds = feedparser.parse(self._url) + + self._set_global_feed_data(feeds.feed) for feed in feeds.entries: self._append_feed_entry(feed) - def _append_feed_entry(self, feed): - self._entry_list.append(FeedEntry(feed)) + def _set_global_feed_data(self, feed): + self._feeds_title = feed.title - def get_entries(self): - return type(self._feeds) + def _append_feed_entry(self, feed): + self._entities_list.append(FeedEntry(feed)) diff --git a/components/feed/feed_entry.py b/components/feed/feed_entry.py index 3bb51c1..42ddf33 100644 --- a/components/feed/feed_entry.py +++ b/components/feed/feed_entry.py @@ -1,15 +1,25 @@ import html +from bs4 import BeautifulSoup class FeedEntry: def __init__(self, feed): - self._title = feed.title + self.title = html.unescape(feed.title) + self.date = feed.published + self.link = feed.link + self.description = self._process_description(feed.description) + self.links = self._process_links(feed.links) - @property - def title(self): - return self._title + def _process_links(self, links): + def format_links(link, count): + return f'[{count}]: {link["href"]} ({link["type"]})\n' - @title.setter - def title(self, title): - self.title = html.unescape(title) + return ''.join( + format_links(link, count) for count, link in enumerate(links, start=1) + ) + + def _process_description(self, description): + return html.unescape( + BeautifulSoup(description, 'html.parser').get_text() + ) diff --git a/components/feed/feed_formatter.py b/components/feed/feed_formatter.py new file mode 100644 index 0000000..82b5926 --- /dev/null +++ b/components/feed/feed_formatter.py @@ -0,0 +1,18 @@ + +class FeedFormatter: + + @classmethod + def generate_output(cls, feeds, limit, is_json=False): + if not is_json: + return ''.join(cls._single_feed_format(feed) for feed in feeds[:limit]) + + return Exception('json is not implemented') + + @classmethod + def _single_feed_format(self,feed): + return f'\ + \rTitle: {feed.title}\n\ + \rDate: {feed.date}\n\ + \rLink: {feed.link}\n\n\ + \r{feed.description}\n\n\ + \rLinks:\n\r{feed.links}\n\n' diff --git a/components/parser/arguments/optional/version.py b/components/parser/arguments/optional/version.py index bcfae26..16e1dc6 100644 --- a/components/parser/arguments/optional/version.py +++ b/components/parser/arguments/optional/version.py @@ -1,4 +1,5 @@ from components.parser.arguments.arguments_abstract import ArgumentsAbstract +import conf class Version(ArgumentsAbstract): @@ -8,5 +9,5 @@ def __init__(self, parser): def add_argument(self): self._parser.add_argument( - '-v', '--version', action='version', version='%(prog)s 1.0', help='Print version info' + '-v', '--version', action='version', version=conf.__version__, help='Print version info' ) diff --git a/components/parser/parser.py b/components/parser/parser.py index 4c1a463..4b5441c 100644 --- a/components/parser/parser.py +++ b/components/parser/parser.py @@ -12,13 +12,13 @@ class Parser: 'limit', ) - # @property - # def _parser(self): - # return self._parser - # - # @_parser.setter - # def _parser(self, description): - # self._parser = argparse.ArgumentParser(description) + @property + def parser(self): + return self._parser + + @parser.setter + def parser(self, description): + self._parser = argparse.ArgumentParser(description) def __init__(self, description, usage): self._parser = argparse.ArgumentParser(description=description, usage=usage) diff --git a/rss_reader.py b/rss_reader.py index d4612f1..1d0913d 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -20,7 +20,10 @@ def start(cls): def main(): - App.start() + try: + App.start() + except KeyboardInterrupt: + print(f'\nStop reader') if __name__ == "__main__": From 150cec40193e4dc590c3d6665c51b99317a70624 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 17 Nov 2019 23:50:33 +0300 Subject: [PATCH 06/26] add json feature output to rss reader --- components/feed/feed.py | 12 +++-- components/feed/feed_formatter.py | 46 +++++++++++++++++-- .../parser/arguments/optional/verbose.py | 2 +- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/components/feed/feed.py b/components/feed/feed.py index c17eea7..d2cf800 100644 --- a/components/feed/feed.py +++ b/components/feed/feed.py @@ -1,4 +1,5 @@ import feedparser +import sys from components.feed.feed_entry import FeedEntry from components.feed.feed_formatter import FeedFormatter @@ -12,15 +13,18 @@ def __init__(self, args): self._entities_list = [] self._feeds_title = '' + if args.verbose : + sys.exit('[--verbose] did\'t implement yet') + self._parse_feeds() def show_feeds(self): - formatted_feeds = FeedFormatter.generate_output( - self._entities_list, self._limit, self._is_json + FeedFormatter.is_json = self._is_json + output = FeedFormatter.generate_output( + self._entities_list, self._limit, self._feeds_title ) - print('Feed: {0}\n\n{1}'.format(self._feeds_title, formatted_feeds)) - + print(output) def _parse_feeds(self): feeds = feedparser.parse(self._url) diff --git a/components/feed/feed_formatter.py b/components/feed/feed_formatter.py index 82b5926..926f2e8 100644 --- a/components/feed/feed_formatter.py +++ b/components/feed/feed_formatter.py @@ -1,18 +1,54 @@ +import json + class FeedFormatter: + is_json = False + + @classmethod + def generate_output(cls, feeds, limit, title): + if not cls.is_json: + return cls._default_output(feeds, limit, title) + + return cls._json_output(feeds, limit, title) + @classmethod - def generate_output(cls, feeds, limit, is_json=False): - if not is_json: - return ''.join(cls._single_feed_format(feed) for feed in feeds[:limit]) + def _default_output(cls, feeds, limit, title): + formatted_feeds = ''.join(cls._single_feed_format_default(feed) for feed in feeds[:limit]) - return Exception('json is not implemented') + return 'Feed: {0}\n\n{1}'.format(title, formatted_feeds) @classmethod - def _single_feed_format(self,feed): + def _json_output(cls, feeds, limit, title): + formatted_feeds = ',\n'.join(cls._single_feed_format_json(feed) for feed in feeds[:limit]) + + #tmp + output = json.dumps({ + "title" : title, + "items" : formatted_feeds + }, indent=4, sort_keys=True) + + return formatted_feeds + + @classmethod + def _single_feed_format_default(self,feed): return f'\ \rTitle: {feed.title}\n\ \rDate: {feed.date}\n\ \rLink: {feed.link}\n\n\ \r{feed.description}\n\n\ \rLinks:\n\r{feed.links}\n\n' + + @classmethod + def _single_feed_format_json(cls, feed): + return json.dumps({ + "item": { + "link": feed.link, + "body": { + "title": feed.title, + "date": feed.date, + "links": feed.links, + "description": feed.description + } + } + }, indent=4) diff --git a/components/parser/arguments/optional/verbose.py b/components/parser/arguments/optional/verbose.py index 608e8a4..6eddc7c 100644 --- a/components/parser/arguments/optional/verbose.py +++ b/components/parser/arguments/optional/verbose.py @@ -8,6 +8,6 @@ def __init__(self, parser): def add_argument(self): self._parser.add_argument( - '--verbose', action='store_true', help='Outputs verbose status messages' + '--verbose', default=False, action='store_true', help='Outputs verbose status messages' ) From 998c60b7acba5eff60b3e677582a424aa42a5a83 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 17 Nov 2019 23:54:53 +0300 Subject: [PATCH 07/26] add cli entry_point to conf --- cmd.py | 9 +++++++++ setup.py | 4 ++++ 2 files changed, 13 insertions(+) create mode 100644 cmd.py diff --git a/cmd.py b/cmd.py new file mode 100644 index 0000000..09edb0a --- /dev/null +++ b/cmd.py @@ -0,0 +1,9 @@ +import rss_reader + + +def main(): + rss_reader.main() + + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py index 83c99b7..fdc24a2 100644 --- a/setup.py +++ b/setup.py @@ -10,4 +10,8 @@ url=conf.__url__, packages=[conf.__package__], python_requires='>=3.8', + entry_points={ + 'console_scripts': + ['rss-reader = cmd:main'] + } ) From ffb9b3a007025cceb265a8177fff56271cda12b3 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Fri, 29 Nov 2019 20:10:10 +0300 Subject: [PATCH 08/26] add logging module to rss_reader. add logging message to rss_reader --- components/feed/feed.py | 20 +++++++++--- components/logger/conf.yml | 32 +++++++++++++++++++ components/logger/logger.py | 28 ++++++++++++++++ components/parser/arguments/__init__.py | 3 +- .../parser/arguments/arguments_abstract.py | 4 --- .../parser/arguments/optional/colorize.py | 12 +++++++ components/parser/arguments/optional/limit.py | 1 + components/parser/parser.py | 16 +++------- rss_reader.py | 11 +++++-- setup.py | 2 +- 10 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 components/logger/conf.yml create mode 100644 components/logger/logger.py create mode 100644 components/parser/arguments/optional/colorize.py diff --git a/components/feed/feed.py b/components/feed/feed.py index d2cf800..8978c6a 100644 --- a/components/feed/feed.py +++ b/components/feed/feed.py @@ -1,7 +1,7 @@ import feedparser -import sys from components.feed.feed_entry import FeedEntry from components.feed.feed_formatter import FeedFormatter +from components.logger.logger import Logger class Feed: @@ -13,12 +13,19 @@ def __init__(self, args): self._entities_list = [] self._feeds_title = '' - if args.verbose : - sys.exit('[--verbose] did\'t implement yet') + Logger.log('Initialize console variables') + + if self._limit <= 0: + Logger.log_error('Limit must be up to zero') + raise ValueError('limit equal or less 0') self._parse_feeds() - def show_feeds(self): + def show_feeds(self) -> object: + Logger.log(f'Preparation for output feeds. ' + f'Output type: {"JSON" if self._is_json else "DEFAULT"}. ' + f'Feeds choosen: {self._limit}') + FeedFormatter.is_json = self._is_json output = FeedFormatter.generate_output( self._entities_list, self._limit, self._feeds_title @@ -27,14 +34,19 @@ def show_feeds(self): print(output) def _parse_feeds(self): + + Logger.log(f'Start parsing data from url: {self._url}') feeds = feedparser.parse(self._url) self._set_global_feed_data(feeds.feed) + Logger.log('Generate feeds instances') for feed in feeds.entries: self._append_feed_entry(feed) def _set_global_feed_data(self, feed): + Logger.log('Setting global feed data') + self._feeds_title = feed.title def _append_feed_entry(self, feed): diff --git a/components/logger/conf.yml b/components/logger/conf.yml new file mode 100644 index 0000000..424ae85 --- /dev/null +++ b/components/logger/conf.yml @@ -0,0 +1,32 @@ +version: 1 +disable_existing_loggers: True + +loggers: + standard: + level: INFO + handlers: [console, error_file_handler] + propagate: no +formatters: + standard: + format: '%(asctime)s - %(message)s' + datefmt: '%H:%M:%S' + error: + format: '%(levelname)s %(name)s.%(funcName)s(): %(message)s' +handlers: + console: + class: logging.StreamHandler + level: INFO + formatter: standard + stream: ext://sys.stdout + error_file_handler: + class: logging.handlers.RotatingFileHandler + level: ERROR + formatter: error + filename: /tmp/rss-reader-errors.log + maxBytes: 10485760 + backupCount: 20 + encoding: utf8 +root: + level: NOTSET + handlers: [console, error_file_handler] + propogate: yes \ No newline at end of file diff --git a/components/logger/logger.py b/components/logger/logger.py new file mode 100644 index 0000000..3ba8fcc --- /dev/null +++ b/components/logger/logger.py @@ -0,0 +1,28 @@ +import os +import logging +import logging.config +import yaml +from components.helper.singleton import Singleton + + +class Logger(Singleton): + + logger_name = 'standard' + + @classmethod + def initialize(cls): + with open(os.path.join(os.path.dirname(os.path.realpath(__file__)),'conf.yml'), 'r') as file: + config = yaml.safe_load(file.read()) + logging.config.dictConfig(config) + + cls._logger = logging.getLogger(cls.logger_name) + + @classmethod + def log(cls, message: str): + if getattr(cls, '_logger', None) is not None: + cls._logger.info(message) + + @classmethod + def log_error(cls, message: str): + if getattr(cls, '_logger', None) is not None: + cls()._logger.error(message) diff --git a/components/parser/arguments/__init__.py b/components/parser/arguments/__init__.py index 84a72d2..a06f777 100644 --- a/components/parser/arguments/__init__.py +++ b/components/parser/arguments/__init__.py @@ -2,4 +2,5 @@ from .optional.json import * from .optional.limit import * from .optional.verbose import * -from .positional.source import * \ No newline at end of file +from .optional.colorize import * +from .positional.source import * diff --git a/components/parser/arguments/arguments_abstract.py b/components/parser/arguments/arguments_abstract.py index 63cb7c6..b02cbc8 100644 --- a/components/parser/arguments/arguments_abstract.py +++ b/components/parser/arguments/arguments_abstract.py @@ -3,10 +3,6 @@ class ArgumentsAbstract(ABC): - # @property - # def _parser(self): - # return self._parser - def __init__(self, parser): self._parser = parser diff --git a/components/parser/arguments/optional/colorize.py b/components/parser/arguments/optional/colorize.py new file mode 100644 index 0000000..4731d3b --- /dev/null +++ b/components/parser/arguments/optional/colorize.py @@ -0,0 +1,12 @@ +from components.parser.arguments.arguments_abstract import ArgumentsAbstract + + +class Colorize(ArgumentsAbstract): + + def __init__(self, parser): + super().__init__(parser) + + def add_argument(self): + self._parser.add_argument( + '--colorize', default=False, action='store_true', help='Colorize console output' + ) diff --git a/components/parser/arguments/optional/limit.py b/components/parser/arguments/optional/limit.py index 63471e3..ab4c084 100644 --- a/components/parser/arguments/optional/limit.py +++ b/components/parser/arguments/optional/limit.py @@ -4,6 +4,7 @@ class Limit(ArgumentsAbstract): def __init__(self, parser): + super().__init__(parser) def add_argument(self): diff --git a/components/parser/parser.py b/components/parser/parser.py index 4b5441c..7e5ad83 100644 --- a/components/parser/parser.py +++ b/components/parser/parser.py @@ -10,26 +10,20 @@ class Parser: 'json', 'verbose', 'limit', + 'colorize', ) - @property - def parser(self): - return self._parser - - @parser.setter - def parser(self, description): - self._parser = argparse.ArgumentParser(description) - def __init__(self, description, usage): - self._parser = argparse.ArgumentParser(description=description, usage=usage) - self._init_arguments() + self._parser = argparse.ArgumentParser(description=description, usage=usage) + self._init_arguments() def get_args(self): return self._parser.parse_args() def _init_arguments(self): + module = importlib.import_module('components.parser.arguments') + for argument in self._arguments_list: - module = importlib.import_module('components.parser.arguments') argument_class = getattr(module, argument[0].upper() + argument[1:]) argument_class(self._parser).add_argument() diff --git a/rss_reader.py b/rss_reader.py index 1d0913d..92eddc2 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -1,21 +1,26 @@ from components.helper.singleton import Singleton from components.parser.parser import Parser from components.feed.feed import Feed +from components.logger.logger import Logger class App(Singleton): - def __init__(self): + def __init__(self) -> None: console = Parser( 'Pure Python command-line RSS reader.', 'rss_reader.py [-h] [--version] [--json] [--verbose] [--limit LIMIT] source' ) self._console_args = console.get_args() + + if self._console_args.verbose: + Logger.initialize() + self._feed = Feed(self._console_args) @classmethod - def start(cls): + def start(cls) -> object: return cls()._feed.show_feeds() @@ -23,7 +28,7 @@ def main(): try: App.start() except KeyboardInterrupt: - print(f'\nStop reader') + Logger.log_error('Stop reader') if __name__ == "__main__": diff --git a/setup.py b/setup.py index fdc24a2..3365767 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ description=conf.__description__, url=conf.__url__, packages=[conf.__package__], - python_requires='>=3.8', + python_requires='>=3.6', entry_points={ 'console_scripts': ['rss-reader = cmd:main'] From 6d5c0112db8bb4363e3e666eac703ff156215f72 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sat, 30 Nov 2019 08:39:47 +0300 Subject: [PATCH 09/26] fix setup module from package. add caching mechanism without loading cache yet. add loggers to cache files --- README.md | 3 + conf.py | 5 +- components/feed/__init__.py => habr.com | 0 requirements.txt | 2 + setup.py | 24 +++++- {components/parser => src}/__init__.py | 0 cmd.py => src/__main__.py | 8 +- .../components/cache}/__init__.py | 0 src/components/cache/cache.py | 86 +++++++++++++++++++ .../components/cache/db}/__init__.py | 0 src/components/cache/db/sqlite.py | 81 +++++++++++++++++ src/components/cache/db/sqlite_scripts.py | 39 +++++++++ src/components/feed/__init__.py | 5 ++ {components => src/components}/feed/feed.py | 35 +++++--- .../components}/feed/feed_entry.py | 17 ++-- .../components}/feed/feed_formatter.py | 0 src/components/helper/__init__.py | 1 + .../components}/helper/singleton.py | 0 src/components/logger/__init__.py | 1 + .../components}/logger/conf.yml | 0 .../components}/logger/logger.py | 2 +- src/components/parser/__init__.py | 0 .../components}/parser/arguments/__init__.py | 2 + .../parser/arguments/arguments_abstract.py | 0 .../parser/arguments/optional/__init__.py | 0 .../parser/arguments/optional/colorize.py | 2 +- .../parser/arguments/optional/json.py | 2 +- .../parser/arguments/optional/limit.py | 2 +- .../parser/arguments/optional/verbose.py | 2 +- .../parser/arguments/optional/version.py | 2 +- .../parser/arguments/positional/__init__.py | 0 .../parser/arguments/positional/source.py | 2 +- .../components}/parser/parser.py | 2 +- rss_reader.py => src/rss_reader.py | 10 +-- 34 files changed, 293 insertions(+), 42 deletions(-) create mode 100644 README.md rename components/feed/__init__.py => habr.com (100%) create mode 100644 requirements.txt rename {components/parser => src}/__init__.py (100%) rename cmd.py => src/__main__.py (54%) rename {components/parser/arguments/optional => src/components/cache}/__init__.py (100%) create mode 100644 src/components/cache/cache.py rename {components/parser/arguments/positional => src/components/cache/db}/__init__.py (100%) create mode 100644 src/components/cache/db/sqlite.py create mode 100644 src/components/cache/db/sqlite_scripts.py create mode 100644 src/components/feed/__init__.py rename {components => src/components}/feed/feed.py (56%) rename {components => src/components}/feed/feed_entry.py (51%) rename {components => src/components}/feed/feed_formatter.py (100%) create mode 100644 src/components/helper/__init__.py rename {components => src/components}/helper/singleton.py (100%) create mode 100644 src/components/logger/__init__.py rename {components => src/components}/logger/conf.yml (100%) rename {components => src/components}/logger/logger.py (92%) create mode 100644 src/components/parser/__init__.py rename {components => src/components}/parser/arguments/__init__.py (78%) rename {components => src/components}/parser/arguments/arguments_abstract.py (100%) create mode 100644 src/components/parser/arguments/optional/__init__.py rename {components => src/components}/parser/arguments/optional/colorize.py (77%) rename {components => src/components}/parser/arguments/optional/json.py (76%) rename {components => src/components}/parser/arguments/optional/limit.py (77%) rename {components => src/components}/parser/arguments/optional/verbose.py (78%) rename {components => src/components}/parser/arguments/optional/version.py (78%) create mode 100644 src/components/parser/arguments/positional/__init__.py rename {components => src/components}/parser/arguments/positional/source.py (73%) rename {components => src/components}/parser/parser.py (89%) rename rss_reader.py => src/rss_reader.py (67%) diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc1afc4 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# RSS reader + +Python RSS reader - command-line utility. diff --git a/conf.py b/conf.py index bdc2aed..233f056 100644 --- a/conf.py +++ b/conf.py @@ -1,7 +1,6 @@ __author__ = 'Mikhan Victor' __email__ = 'victormikhan@gmail.com' -__package__ = 'rss_reader' -__version__ = '1.0.0' -__status__ = 'Prototype' +__package__ = 'rss-reader' +__version__ = '2.0.0' __description__ = 'RSS Reader' __url__ = 'https://github.com/victormikhan/PythonHomework' diff --git a/components/feed/__init__.py b/habr.com similarity index 100% rename from components/feed/__init__.py rename to habr.com diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1d7baad --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +feedparser==5.2.1 +bs4==0.0.1 diff --git a/setup.py b/setup.py index 3365767..cf2c574 100644 --- a/setup.py +++ b/setup.py @@ -1,17 +1,35 @@ import setuptools import conf +import os + +here = os.path.abspath(os.path.dirname(__file__)) + + +def get_install_requirements(): + with open(os.path.join(here,'requirements.txt'), 'r') as file: + return [requirement.strip() for requirement in file] + + +with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + setuptools.setup( name=conf.__package__, version=conf.__version__, + license='MIT', author=conf.__author__, author_email=conf.__email__, - description=conf.__description__, + description=long_description, + long_description=conf.__description__, + long_description_content_type='text/markdown', url=conf.__url__, - packages=[conf.__package__], + packages=setuptools.find_packages(where='src'), + package_dir={'': 'src'}, + install_requires=get_install_requirements(), python_requires='>=3.6', entry_points={ 'console_scripts': - ['rss-reader = cmd:main'] + ['%s = __main__:main' % conf.__package__] } ) diff --git a/components/parser/__init__.py b/src/__init__.py similarity index 100% rename from components/parser/__init__.py rename to src/__init__.py diff --git a/cmd.py b/src/__main__.py similarity index 54% rename from cmd.py rename to src/__main__.py index 09edb0a..76b3fd5 100644 --- a/cmd.py +++ b/src/__main__.py @@ -1,9 +1,5 @@ -import rss_reader - - -def main(): - rss_reader.main() +from src import rss_reader if __name__ == '__main__': - main() + rss_reader.main() diff --git a/components/parser/arguments/optional/__init__.py b/src/components/cache/__init__.py similarity index 100% rename from components/parser/arguments/optional/__init__.py rename to src/components/cache/__init__.py diff --git a/src/components/cache/cache.py b/src/components/cache/cache.py new file mode 100644 index 0000000..a6c2407 --- /dev/null +++ b/src/components/cache/cache.py @@ -0,0 +1,86 @@ +from src.components.cache.db.sqlite import Sqlite +from src.components.logger import Logger +from src.components.helper import Singleton + +from pathlib import Path +import conf +import html + +class Cache(Singleton): + + _db_name = 'cache.db' + + def __init__(self) -> None : + + self._cache_db_file = self._storage_initialize() + + self.db = Sqlite(str(self._cache_db_file)) + + def _storage_initialize(self): + + cache_path = Path.home().joinpath('.' + conf.__package__) + + if not cache_path.exists(): + cache_path.mkdir() + Logger.log(f'Created {conf.__package__} local dir with path: {cache_path}') + + cache_file = cache_path.joinpath(self._db_name) + + if not cache_file.exists(): + Sqlite.create_database(str(cache_file)) + Logger.log(f'Created local storage with path: {cache_file}') + + Logger.log(f'Cache local storage with path: {cache_file}') + + return cache_file + + def append_feeds(self, feed: dict, feed_entities_list: list) -> None: + + Logger.log(f'Check on feed cache exist on url: {feed.get("url")}') + + feed_id = self.db.findWhere('feeds', 'url', feed.get("url")) + + if not feed_id: + feed_id = self._insert_feed_data(feed) + + Logger.log('Start caching feeds: \n') + + for feed_entry in feed_entities_list: + + if not self.db.findWhere('feeds_entries', 'link', feed_entry.link): + Logger.log(f'Caching feed [[{feed_entry.title}]] INSERTED') + else: + Logger.log(f'Caching feed [[{feed_entry.title}]] UPDATED') + + self._insert_feed_entry_into_cache(feed_entry, feed_id) + + print("\n") + Logger.log('Cached feeds was updated') + + self.db.close() + + def _insert_feed_entry_into_cache(self, entry: "src.components.feed.feed_entry.FeedEntry", feed_id): + return self.db.write('feeds_entries', [ + 'feed_id', + 'title', + 'description', + 'link', + 'links', + 'date', + 'published' + ], [ + feed_id, + html.escape(entry.title), + html.escape(entry.description), + entry.link, + entry.links, + entry.date, + entry.published, + ]) + + def _insert_feed_data(self, feed): + Logger.log(f'Add feed cache exist on url: {feed.get("url")}') + + self.db.write('feeds', ['url', 'encoding'], [feed.get('url'), feed.get("encoding")]) + + return self.db.cursor.lastrowid diff --git a/components/parser/arguments/positional/__init__.py b/src/components/cache/db/__init__.py similarity index 100% rename from components/parser/arguments/positional/__init__.py rename to src/components/cache/db/__init__.py diff --git a/src/components/cache/db/sqlite.py b/src/components/cache/db/sqlite.py new file mode 100644 index 0000000..1543151 --- /dev/null +++ b/src/components/cache/db/sqlite.py @@ -0,0 +1,81 @@ +import sqlite3 +import sys +from .sqlite_scripts import scripts + + +class Sqlite: + def __init__(self, path): + + self.conn = None + self.cursor = None + + self.open(path) + + def open(self, path: str) -> None: + + try: + self.conn = sqlite3.connect(path, isolation_level=None) + self.cursor = self.conn.cursor() + + except sqlite3.Error as e: + sys.exit(e) + + def close(self): + + if self.conn: + self.conn.commit() + self.cursor.close() + self.conn.close() + + @classmethod + def create_database(self, path: str) -> str: + try: + self.conn = sqlite3.connect(path, isolation_level=None) + cursor = self.conn.cursor() + + cursor.executescript(scripts['create_db_tables']['feeds']) + cursor.executescript(scripts['create_db_tables']['feeds_entries']) + + cursor.close() + + except sqlite3.Error as e: + sys.exit(e) + + def get(self, table, columns, limit=None): + + query = scripts.get('get').format(columns, table) + self.cursor.execute(query) + + rows = self.cursor.fetchall() + + return rows[len(rows) - limit if limit else 0:] + + def findWhere(self, table, column, value, type='='): + + query = scripts.get('find_where').format(table, column, type,value) + + self.cursor.execute(query) + row = self.cursor.fetchone() + + return row[0] if row is not None else False + + def getLast(self, table, columns): + + return self.get(table, columns, limit=1)[0] + + def write(self, table, columns, data): + + query = scripts.get('write').format( + table, ', '.join(column for column in columns) , ', '.join( "'" + str(item) + "'" for item in data) + ) + + self.cursor.execute(query) + + return self.cursor.fetchall() or False + + def query(self, sql): + self.cursor.execute(sql) + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + diff --git a/src/components/cache/db/sqlite_scripts.py b/src/components/cache/db/sqlite_scripts.py new file mode 100644 index 0000000..fcae61e --- /dev/null +++ b/src/components/cache/db/sqlite_scripts.py @@ -0,0 +1,39 @@ +scripts = { + 'create_db_tables': { + 'feeds': """ + CREATE TABLE feeds( + id integer PRIMARY KEY autoincrement, + url text UNIQUE NOT NULL, + encoding text NOT NULL + ); + CREATE UNIQUE index unique_feeds_url on feeds (url); + """, + 'feeds_entries': """ + CREATE TABLE feeds_entries ( + id integer PRIMARY KEY autoincrement, + feed_id integer NOT NULL, + title text NOT NULL, + description text, + link text UNIQUE NOT NULL, + links text, + date text NOT NULL, + published timestamp NOT NULL, + FOREIGN KEY(feed_id) + REFERENCES feeds ( id ) + ON UPDATE CASCADE + ON DELETE CASCADE + ); + CREATE UNIQUE index unique_feeds_entries_link ON feeds_entries (link); + """, + }, + 'write': """ + INSERT OR REPLACE INTO {0} ({1}) VALUES ({2}); + """, + 'find_where': """ + SELECT id FROM {0} WHERE {1}{2}'{3}'; + """, + 'get': """ + SELECT {0} FROM {1}; + """ +} + diff --git a/src/components/feed/__init__.py b/src/components/feed/__init__.py new file mode 100644 index 0000000..b5becf1 --- /dev/null +++ b/src/components/feed/__init__.py @@ -0,0 +1,5 @@ +from .feed import Feed +from .feed import FeedEntry +from .feed import FeedFormatter + + diff --git a/components/feed/feed.py b/src/components/feed/feed.py similarity index 56% rename from components/feed/feed.py rename to src/components/feed/feed.py index 8978c6a..daf3e55 100644 --- a/components/feed/feed.py +++ b/src/components/feed/feed.py @@ -1,7 +1,9 @@ import feedparser -from components.feed.feed_entry import FeedEntry -from components.feed.feed_formatter import FeedFormatter -from components.logger.logger import Logger + +from src.components.feed.feed_entry import FeedEntry +from src.components.feed.feed_formatter import FeedFormatter +from src.components.logger.logger import Logger +from src.components.cache.cache import Cache class Feed: @@ -11,7 +13,6 @@ def __init__(self, args): self._limit = args.limit self._url = args.source self._entities_list = [] - self._feeds_title = '' Logger.log('Initialize console variables') @@ -36,18 +37,30 @@ def show_feeds(self) -> object: def _parse_feeds(self): Logger.log(f'Start parsing data from url: {self._url}') - feeds = feedparser.parse(self._url) - self._set_global_feed_data(feeds.feed) + feed = feedparser.parse(self._url) + + self._set_global_feed_data(feed) Logger.log('Generate feeds instances') - for feed in feeds.entries: - self._append_feed_entry(feed) + + for item in feed.entries: + self._append_feed_entry(item) + + if self._entities_list: + self._store_cache_instances() def _set_global_feed_data(self, feed): Logger.log('Setting global feed data') - self._feeds_title = feed.title + self._feeds_title = feed.feed.title + self._feeds_encoding = feed.encoding + + def _append_feed_entry(self, item): + self._entities_list.append(FeedEntry(item)) - def _append_feed_entry(self, feed): - self._entities_list.append(FeedEntry(feed)) + def _store_cache_instances(self): + Cache().append_feeds({ + 'url': self._url, + 'encoding': self._feeds_encoding, + }, self._entities_list) diff --git a/components/feed/feed_entry.py b/src/components/feed/feed_entry.py similarity index 51% rename from components/feed/feed_entry.py rename to src/components/feed/feed_entry.py index 42ddf33..2de3943 100644 --- a/components/feed/feed_entry.py +++ b/src/components/feed/feed_entry.py @@ -1,15 +1,17 @@ import html from bs4 import BeautifulSoup +from datetime import datetime class FeedEntry: - def __init__(self, feed): - self.title = html.unescape(feed.title) - self.date = feed.published - self.link = feed.link - self.description = self._process_description(feed.description) - self.links = self._process_links(feed.links) + def __init__(self, entry): + self.title = html.unescape(entry.title) + self.description = self._process_description(entry.description) + self.link = entry.link + self.links = self._process_links(entry.links) + self.date = entry.published + self.published = self._process_published(entry) def _process_links(self, links): def format_links(link, count): @@ -23,3 +25,6 @@ def _process_description(self, description): return html.unescape( BeautifulSoup(description, 'html.parser').get_text() ) + + def _process_published(self, entry): + datetime(*entry.published_parsed[:6]) diff --git a/components/feed/feed_formatter.py b/src/components/feed/feed_formatter.py similarity index 100% rename from components/feed/feed_formatter.py rename to src/components/feed/feed_formatter.py diff --git a/src/components/helper/__init__.py b/src/components/helper/__init__.py new file mode 100644 index 0000000..ae53f8b --- /dev/null +++ b/src/components/helper/__init__.py @@ -0,0 +1 @@ +from .singleton import Singleton diff --git a/components/helper/singleton.py b/src/components/helper/singleton.py similarity index 100% rename from components/helper/singleton.py rename to src/components/helper/singleton.py diff --git a/src/components/logger/__init__.py b/src/components/logger/__init__.py new file mode 100644 index 0000000..dd0a8a7 --- /dev/null +++ b/src/components/logger/__init__.py @@ -0,0 +1 @@ +from .logger import Logger \ No newline at end of file diff --git a/components/logger/conf.yml b/src/components/logger/conf.yml similarity index 100% rename from components/logger/conf.yml rename to src/components/logger/conf.yml diff --git a/components/logger/logger.py b/src/components/logger/logger.py similarity index 92% rename from components/logger/logger.py rename to src/components/logger/logger.py index 3ba8fcc..086994e 100644 --- a/components/logger/logger.py +++ b/src/components/logger/logger.py @@ -2,7 +2,7 @@ import logging import logging.config import yaml -from components.helper.singleton import Singleton +from src.components.helper.singleton import Singleton class Logger(Singleton): diff --git a/src/components/parser/__init__.py b/src/components/parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/parser/arguments/__init__.py b/src/components/parser/arguments/__init__.py similarity index 78% rename from components/parser/arguments/__init__.py rename to src/components/parser/arguments/__init__.py index a06f777..3a8ac42 100644 --- a/components/parser/arguments/__init__.py +++ b/src/components/parser/arguments/__init__.py @@ -1,3 +1,5 @@ +from .arguments_abstract import ArgumentsAbstract + from .optional.version import * from .optional.json import * from .optional.limit import * diff --git a/components/parser/arguments/arguments_abstract.py b/src/components/parser/arguments/arguments_abstract.py similarity index 100% rename from components/parser/arguments/arguments_abstract.py rename to src/components/parser/arguments/arguments_abstract.py diff --git a/src/components/parser/arguments/optional/__init__.py b/src/components/parser/arguments/optional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/parser/arguments/optional/colorize.py b/src/components/parser/arguments/optional/colorize.py similarity index 77% rename from components/parser/arguments/optional/colorize.py rename to src/components/parser/arguments/optional/colorize.py index 4731d3b..f9e5450 100644 --- a/components/parser/arguments/optional/colorize.py +++ b/src/components/parser/arguments/optional/colorize.py @@ -1,4 +1,4 @@ -from components.parser.arguments.arguments_abstract import ArgumentsAbstract +from src.components.parser.arguments import ArgumentsAbstract class Colorize(ArgumentsAbstract): diff --git a/components/parser/arguments/optional/json.py b/src/components/parser/arguments/optional/json.py similarity index 76% rename from components/parser/arguments/optional/json.py rename to src/components/parser/arguments/optional/json.py index 1f4d3d8..5b1abe2 100644 --- a/components/parser/arguments/optional/json.py +++ b/src/components/parser/arguments/optional/json.py @@ -1,4 +1,4 @@ -from components.parser.arguments.arguments_abstract import ArgumentsAbstract +from src.components.parser.arguments import ArgumentsAbstract class Json(ArgumentsAbstract): diff --git a/components/parser/arguments/optional/limit.py b/src/components/parser/arguments/optional/limit.py similarity index 77% rename from components/parser/arguments/optional/limit.py rename to src/components/parser/arguments/optional/limit.py index ab4c084..c943686 100644 --- a/components/parser/arguments/optional/limit.py +++ b/src/components/parser/arguments/optional/limit.py @@ -1,4 +1,4 @@ -from components.parser.arguments.arguments_abstract import ArgumentsAbstract +from src.components.parser.arguments import ArgumentsAbstract class Limit(ArgumentsAbstract): diff --git a/components/parser/arguments/optional/verbose.py b/src/components/parser/arguments/optional/verbose.py similarity index 78% rename from components/parser/arguments/optional/verbose.py rename to src/components/parser/arguments/optional/verbose.py index 6eddc7c..353e91d 100644 --- a/components/parser/arguments/optional/verbose.py +++ b/src/components/parser/arguments/optional/verbose.py @@ -1,4 +1,4 @@ -from components.parser.arguments.arguments_abstract import ArgumentsAbstract +from src.components.parser.arguments import ArgumentsAbstract class Verbose(ArgumentsAbstract): diff --git a/components/parser/arguments/optional/version.py b/src/components/parser/arguments/optional/version.py similarity index 78% rename from components/parser/arguments/optional/version.py rename to src/components/parser/arguments/optional/version.py index 16e1dc6..6aef286 100644 --- a/components/parser/arguments/optional/version.py +++ b/src/components/parser/arguments/optional/version.py @@ -1,4 +1,4 @@ -from components.parser.arguments.arguments_abstract import ArgumentsAbstract +from .. import ArgumentsAbstract import conf diff --git a/src/components/parser/arguments/positional/__init__.py b/src/components/parser/arguments/positional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/parser/arguments/positional/source.py b/src/components/parser/arguments/positional/source.py similarity index 73% rename from components/parser/arguments/positional/source.py rename to src/components/parser/arguments/positional/source.py index 080d30d..7a2b696 100644 --- a/components/parser/arguments/positional/source.py +++ b/src/components/parser/arguments/positional/source.py @@ -1,4 +1,4 @@ -from components.parser.arguments.arguments_abstract import ArgumentsAbstract +from src.components.parser.arguments.arguments_abstract import ArgumentsAbstract class Source(ArgumentsAbstract): diff --git a/components/parser/parser.py b/src/components/parser/parser.py similarity index 89% rename from components/parser/parser.py rename to src/components/parser/parser.py index 7e5ad83..ded45bb 100644 --- a/components/parser/parser.py +++ b/src/components/parser/parser.py @@ -21,7 +21,7 @@ def get_args(self): return self._parser.parse_args() def _init_arguments(self): - module = importlib.import_module('components.parser.arguments') + module = importlib.import_module('src.components.parser.arguments') for argument in self._arguments_list: argument_class = getattr(module, argument[0].upper() + argument[1:]) diff --git a/rss_reader.py b/src/rss_reader.py similarity index 67% rename from rss_reader.py rename to src/rss_reader.py index 92eddc2..fe2b4d6 100644 --- a/rss_reader.py +++ b/src/rss_reader.py @@ -1,7 +1,7 @@ -from components.helper.singleton import Singleton -from components.parser.parser import Parser -from components.feed.feed import Feed -from components.logger.logger import Logger +from .components.helper.singleton import Singleton +from .components.parser.parser import Parser +from .components.feed import * +from .components.logger.logger import Logger class App(Singleton): @@ -9,7 +9,7 @@ class App(Singleton): def __init__(self) -> None: console = Parser( 'Pure Python command-line RSS reader.', - 'rss_reader.py [-h] [--version] [--json] [--verbose] [--limit LIMIT] source' + 'src.py [-h] [--version] [--json] [--verbose] [--limit LIMIT] source' ) self._console_args = console.get_args() From dca3b8616c6d0c4575aaa918ab52559fbb1ab644 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sat, 30 Nov 2019 11:32:20 +0300 Subject: [PATCH 10/26] add helper "map" for working with dicts. modifing cache mechanism for fully work with date option and loading specified cache data --- habr.com | 0 src/components/cache/__init__.py | 3 + src/components/cache/cache.py | 25 ++++++- src/components/cache/cache_entry.py | 5 ++ src/components/cache/db/sqlite.py | 30 +++++--- src/components/cache/db/sqlite_scripts.py | 74 ++++++++++--------- src/components/feed/feed.py | 21 +++++- src/components/feed/feed_entry.py | 2 +- src/components/helper/__init__.py | 1 + src/components/helper/map.py | 28 +++++++ src/components/parser/arguments/__init__.py | 2 + .../parser/arguments/optional/colorize.py | 3 - .../parser/arguments/optional/date.py | 18 +++++ .../parser/arguments/optional/json.py | 3 - .../parser/arguments/optional/limit.py | 4 - .../parser/arguments/optional/verbose.py | 3 - .../parser/arguments/optional/version.py | 3 - .../parser/arguments/positional/source.py | 3 - src/components/parser/parser.py | 1 + src/rss_reader.py | 4 +- 20 files changed, 160 insertions(+), 73 deletions(-) delete mode 100644 habr.com create mode 100644 src/components/cache/cache_entry.py create mode 100644 src/components/helper/map.py create mode 100644 src/components/parser/arguments/optional/date.py diff --git a/habr.com b/habr.com deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/cache/__init__.py b/src/components/cache/__init__.py index e69de29..4bb9bcd 100644 --- a/src/components/cache/__init__.py +++ b/src/components/cache/__init__.py @@ -0,0 +1,3 @@ +from .cache import Cache + + diff --git a/src/components/cache/cache.py b/src/components/cache/cache.py index a6c2407..d53a83a 100644 --- a/src/components/cache/cache.py +++ b/src/components/cache/cache.py @@ -1,11 +1,16 @@ from src.components.cache.db.sqlite import Sqlite from src.components.logger import Logger from src.components.helper import Singleton +from src.components.helper import Map +from .db.sqlite_scripts import scripts +from datetime import timedelta +from datetime import datetime from pathlib import Path import conf import html + class Cache(Singleton): _db_name = 'cache.db' @@ -38,7 +43,7 @@ def append_feeds(self, feed: dict, feed_entities_list: list) -> None: Logger.log(f'Check on feed cache exist on url: {feed.get("url")}') - feed_id = self.db.findWhere('feeds', 'url', feed.get("url")) + feed_id = self.db.find_where('feeds', 'url', feed.get("url")) if not feed_id: feed_id = self._insert_feed_data(feed) @@ -46,8 +51,7 @@ def append_feeds(self, feed: dict, feed_entities_list: list) -> None: Logger.log('Start caching feeds: \n') for feed_entry in feed_entities_list: - - if not self.db.findWhere('feeds_entries', 'link', feed_entry.link): + if not self.db.find_where('feeds_entries', 'link', feed_entry.link): Logger.log(f'Caching feed [[{feed_entry.title}]] INSERTED') else: Logger.log(f'Caching feed [[{feed_entry.title}]] UPDATED') @@ -84,3 +88,18 @@ def _insert_feed_data(self, feed): self.db.write('feeds', ['url', 'encoding'], [feed.get('url'), feed.get("encoding")]) return self.db.cursor.lastrowid + + def load_feeds_entries(self, date: str, limit=100) -> list: + Logger.log(f'Load file from cache storage ' + f'{date.strftime("from %d, %b %Y")}' + f'{(date + timedelta(days=1)).strftime(" to %d, %b %Y")}') + + date = datetime.combine(date, datetime.min.time()) + + cache_list = self.get_specify_by_date(date, limit) + + return [Map(row) for row in cache_list] + + def get_specify_by_date(self, date, limit=100): + cache_list = self.db.query(scripts.get('load_news'), date, date + timedelta(days=1), limit) + return cache_list.fetchall() diff --git a/src/components/cache/cache_entry.py b/src/components/cache/cache_entry.py new file mode 100644 index 0000000..84ce6a9 --- /dev/null +++ b/src/components/cache/cache_entry.py @@ -0,0 +1,5 @@ +from src.components.feed.feed_entry import FeedEntry + + +class CacheEntry(FeedEntry): + pass diff --git a/src/components/cache/db/sqlite.py b/src/components/cache/db/sqlite.py index 1543151..a4e1b9c 100644 --- a/src/components/cache/db/sqlite.py +++ b/src/components/cache/db/sqlite.py @@ -15,6 +15,7 @@ def open(self, path: str) -> None: try: self.conn = sqlite3.connect(path, isolation_level=None) + self.conn.row_factory = sqlite3.Row self.cursor = self.conn.cursor() except sqlite3.Error as e: @@ -41,16 +42,24 @@ def create_database(self, path: str) -> str: except sqlite3.Error as e: sys.exit(e) - def get(self, table, columns, limit=None): + def get(self, table, columns, limit=100): - query = scripts.get('get').format(columns, table) + query = scripts.get('get').format(columns, table, limit) self.cursor.execute(query) - rows = self.cursor.fetchall() + return self.cursor.fetchall() - return rows[len(rows) - limit if limit else 0:] + def get_last(self, table, columns): + return self.get(table, columns, limit=1)[0] + + def where(self, table, column, value, type='=', limit=100): + + query = scripts.get('where').format(table, value, type, limit) + self.cursor.execute(query) + + return self.cursor.fetchall() - def findWhere(self, table, column, value, type='='): + def find_where(self, table, column, value, type='='): query = scripts.get('find_where').format(table, column, type,value) @@ -59,10 +68,6 @@ def findWhere(self, table, column, value, type='='): return row[0] if row is not None else False - def getLast(self, table, columns): - - return self.get(table, columns, limit=1)[0] - def write(self, table, columns, data): query = scripts.get('write').format( @@ -73,9 +78,10 @@ def write(self, table, columns, data): return self.cursor.fetchall() or False - def query(self, sql): - self.cursor.execute(sql) + def query(self, sql, *args): + self.cursor = self.conn.cursor() + + return self.cursor.execute(sql, args) def __exit__(self, exc_type, exc_value, traceback): self.close() - diff --git a/src/components/cache/db/sqlite_scripts.py b/src/components/cache/db/sqlite_scripts.py index fcae61e..17da536 100644 --- a/src/components/cache/db/sqlite_scripts.py +++ b/src/components/cache/db/sqlite_scripts.py @@ -1,39 +1,45 @@ scripts = { + 'write': 'INSERT OR REPLACE INTO {0} ({1}) VALUES ({2});', + 'find_where': 'SELECT id FROM {0} WHERE {1}{2}\'{3}\';', + 'where': 'SELECT * FROM {0} WHERE {1}{2}\'{3}\' LIMIT {4};', + 'get': 'SELECT {1} FROM {0} LIMIT {2};', + 'create_db_tables': { - 'feeds': """ - CREATE TABLE feeds( - id integer PRIMARY KEY autoincrement, - url text UNIQUE NOT NULL, - encoding text NOT NULL - ); - CREATE UNIQUE index unique_feeds_url on feeds (url); - """, - 'feeds_entries': """ - CREATE TABLE feeds_entries ( - id integer PRIMARY KEY autoincrement, - feed_id integer NOT NULL, - title text NOT NULL, - description text, - link text UNIQUE NOT NULL, - links text, - date text NOT NULL, - published timestamp NOT NULL, - FOREIGN KEY(feed_id) - REFERENCES feeds ( id ) - ON UPDATE CASCADE - ON DELETE CASCADE - ); - CREATE UNIQUE index unique_feeds_entries_link ON feeds_entries (link); - """, + 'feeds': ''' + CREATE TABLE feeds( + id integer PRIMARY KEY autoincrement, + url text UNIQUE NOT NULL, + encoding text NOT NULL + ); + CREATE UNIQUE index unique_feeds_url on feeds (url); + ''', + 'feeds_entries': ''' + CREATE TABLE feeds_entries ( + id integer PRIMARY KEY autoincrement, + feed_id integer NOT NULL, + title text NOT NULL, + description text, + link text UNIQUE NOT NULL, + + links text, + date text NOT NULL, + published timestamp NOT NULL, + FOREIGN KEY(feed_id) + REFERENCES feeds ( id ) + ON UPDATE CASCADE + ON DELETE CASCADE + ); + CREATE UNIQUE index unique_feeds_entries_link ON feeds_entries (link); + ''', }, - 'write': """ - INSERT OR REPLACE INTO {0} ({1}) VALUES ({2}); - """, - 'find_where': """ - SELECT id FROM {0} WHERE {1}{2}'{3}'; - """, - 'get': """ - SELECT {0} FROM {1}; - """ + + 'load_news': ''' + SELECT fe.* + FROM feeds as f + JOIN feeds_entries as fe ON f.id = fe.feed_id + WHERE fe.published >= ? AND fe.published <= ? + ORDER BY fe.published DESC + LIMIT ? + ''' } diff --git a/src/components/feed/feed.py b/src/components/feed/feed.py index daf3e55..2bf9932 100644 --- a/src/components/feed/feed.py +++ b/src/components/feed/feed.py @@ -1,4 +1,5 @@ import feedparser +from datetime import timedelta from src.components.feed.feed_entry import FeedEntry from src.components.feed.feed_formatter import FeedFormatter @@ -10,17 +11,26 @@ class Feed: def __init__(self, args): self._is_json = args.json + self._cache_date = args.date self._limit = args.limit self._url = args.source self._entities_list = [] Logger.log('Initialize console variables') + self._pre_validate_params() + + self._parse_feeds() + + def _pre_validate_params(self): if self._limit <= 0: Logger.log_error('Limit must be up to zero') raise ValueError('limit equal or less 0') - self._parse_feeds() + if not Cache().get_specify_by_date(self._cache_date, self._limit): + raise Exception(f'There is no cached news ' + f'{self._cache_date.strftime("from %d, %b %Y")}' + f'{(self._cache_date + timedelta(days=1)).strftime(" to %d, %b %Y")}') def show_feeds(self) -> object: Logger.log(f'Preparation for output feeds. ' @@ -28,12 +38,19 @@ def show_feeds(self) -> object: f'Feeds choosen: {self._limit}') FeedFormatter.is_json = self._is_json + output = FeedFormatter.generate_output( - self._entities_list, self._limit, self._feeds_title + self._decide_output(), self._limit, self._feeds_title ) print(output) + def _decide_output(self): + if self._cache_date: + return Cache().load_feeds_entries(self._cache_date, self._limit) + + return self._entities_list + def _parse_feeds(self): Logger.log(f'Start parsing data from url: {self._url}') diff --git a/src/components/feed/feed_entry.py b/src/components/feed/feed_entry.py index 2de3943..e9a346e 100644 --- a/src/components/feed/feed_entry.py +++ b/src/components/feed/feed_entry.py @@ -27,4 +27,4 @@ def _process_description(self, description): ) def _process_published(self, entry): - datetime(*entry.published_parsed[:6]) + return datetime(*entry.published_parsed[:6]) diff --git a/src/components/helper/__init__.py b/src/components/helper/__init__.py index ae53f8b..4f19b9c 100644 --- a/src/components/helper/__init__.py +++ b/src/components/helper/__init__.py @@ -1 +1,2 @@ from .singleton import Singleton +from .map import Map diff --git a/src/components/helper/map.py b/src/components/helper/map.py new file mode 100644 index 0000000..ba60deb --- /dev/null +++ b/src/components/helper/map.py @@ -0,0 +1,28 @@ +class Map(dict): + def __init__(self, *args, **kwargs): + super(Map, self).__init__(*args, **kwargs) + for arg in args: + if isinstance(arg, dict): + for k, v in arg.iteritems(): + self[k] = v + + if kwargs: + for k, v in kwargs.iteritems(): + self[k] = v + + def __getattr__(self, attr): + return self.get(attr) + + def __setattr__(self, key, value): + self.__setitem__(key, value) + + def __setitem__(self, key, value): + super(Map, self).__setitem__(key, value) + self.__dict__.update({key: value}) + + def __delattr__(self, item): + self.__delitem__(item) + + def __delitem__(self, key): + super(Map, self).__delitem__(key) + del self.__dict__[key] \ No newline at end of file diff --git a/src/components/parser/arguments/__init__.py b/src/components/parser/arguments/__init__.py index 3a8ac42..c29ea9d 100644 --- a/src/components/parser/arguments/__init__.py +++ b/src/components/parser/arguments/__init__.py @@ -5,4 +5,6 @@ from .optional.limit import * from .optional.verbose import * from .optional.colorize import * +from .optional.date import * + from .positional.source import * diff --git a/src/components/parser/arguments/optional/colorize.py b/src/components/parser/arguments/optional/colorize.py index f9e5450..c2260c9 100644 --- a/src/components/parser/arguments/optional/colorize.py +++ b/src/components/parser/arguments/optional/colorize.py @@ -3,9 +3,6 @@ class Colorize(ArgumentsAbstract): - def __init__(self, parser): - super().__init__(parser) - def add_argument(self): self._parser.add_argument( '--colorize', default=False, action='store_true', help='Colorize console output' diff --git a/src/components/parser/arguments/optional/date.py b/src/components/parser/arguments/optional/date.py new file mode 100644 index 0000000..ab49710 --- /dev/null +++ b/src/components/parser/arguments/optional/date.py @@ -0,0 +1,18 @@ +from src.components.parser.arguments import ArgumentsAbstract +from datetime import datetime +import argparse + + +class Date(ArgumentsAbstract): + + def add_argument(self): + self._parser.add_argument( + '--date', type=self._validate_caching_date, + help='Cached news from the specified date. YYYYMMDD is proper date format.' + ) + + def _validate_caching_date(self, date: str): + try: + return datetime.strptime(date, '%Y%m%d').date() + except ValueError: + raise argparse.ArgumentTypeError(f'Invalid date typed for caching: {date} \n Use YYYYMMDD format') diff --git a/src/components/parser/arguments/optional/json.py b/src/components/parser/arguments/optional/json.py index 5b1abe2..d56bdba 100644 --- a/src/components/parser/arguments/optional/json.py +++ b/src/components/parser/arguments/optional/json.py @@ -3,9 +3,6 @@ class Json(ArgumentsAbstract): - def __init__(self, parser): - super().__init__(parser) - def add_argument(self): self._parser.add_argument( '--json', action='store_true', help='Print result as JSON in stdout' diff --git a/src/components/parser/arguments/optional/limit.py b/src/components/parser/arguments/optional/limit.py index c943686..c42f4d6 100644 --- a/src/components/parser/arguments/optional/limit.py +++ b/src/components/parser/arguments/optional/limit.py @@ -3,10 +3,6 @@ class Limit(ArgumentsAbstract): - def __init__(self, parser): - - super().__init__(parser) - def add_argument(self): self._parser.add_argument( '--limit', type=int, default=3, help='Limit news topics if this parameter provided' diff --git a/src/components/parser/arguments/optional/verbose.py b/src/components/parser/arguments/optional/verbose.py index 353e91d..4c40682 100644 --- a/src/components/parser/arguments/optional/verbose.py +++ b/src/components/parser/arguments/optional/verbose.py @@ -3,9 +3,6 @@ class Verbose(ArgumentsAbstract): - def __init__(self, parser): - super().__init__(parser) - def add_argument(self): self._parser.add_argument( '--verbose', default=False, action='store_true', help='Outputs verbose status messages' diff --git a/src/components/parser/arguments/optional/version.py b/src/components/parser/arguments/optional/version.py index 6aef286..a287efa 100644 --- a/src/components/parser/arguments/optional/version.py +++ b/src/components/parser/arguments/optional/version.py @@ -4,9 +4,6 @@ class Version(ArgumentsAbstract): - def __init__(self, parser): - super().__init__(parser) - def add_argument(self): self._parser.add_argument( '-v', '--version', action='version', version=conf.__version__, help='Print version info' diff --git a/src/components/parser/arguments/positional/source.py b/src/components/parser/arguments/positional/source.py index 7a2b696..f9db6e4 100644 --- a/src/components/parser/arguments/positional/source.py +++ b/src/components/parser/arguments/positional/source.py @@ -3,9 +3,6 @@ class Source(ArgumentsAbstract): - def __init__(self, parser): - super().__init__(parser) - def add_argument(self): self._parser.add_argument( 'source', type=str, help='RSS URL' diff --git a/src/components/parser/parser.py b/src/components/parser/parser.py index ded45bb..58955b3 100644 --- a/src/components/parser/parser.py +++ b/src/components/parser/parser.py @@ -10,6 +10,7 @@ class Parser: 'json', 'verbose', 'limit', + 'date', 'colorize', ) diff --git a/src/rss_reader.py b/src/rss_reader.py index fe2b4d6..d84429a 100644 --- a/src/rss_reader.py +++ b/src/rss_reader.py @@ -2,14 +2,14 @@ from .components.parser.parser import Parser from .components.feed import * from .components.logger.logger import Logger - +import conf class App(Singleton): def __init__(self) -> None: console = Parser( 'Pure Python command-line RSS reader.', - 'src.py [-h] [--version] [--json] [--verbose] [--limit LIMIT] source' + conf.__description__ ) self._console_args = console.get_args() From b261db61aa68184368742bc5d0da76f3796c008e Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sat, 30 Nov 2019 13:42:59 +0300 Subject: [PATCH 11/26] add colorize libs to requirements. add colorize output to rss-reader in verbose and feeds --- requirements.txt | 2 ++ src/components/feed/feed.py | 8 +++++-- src/components/feed/feed_formatter.py | 34 +++++++++++++++++++++++---- src/components/logger/logger.py | 15 +++++++++++- src/rss_reader.py | 5 +++- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1d7baad..fd2a668 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ feedparser==5.2.1 bs4==0.0.1 +coloredlogs==10.0.0 +fabulous==0.3.0 \ No newline at end of file diff --git a/src/components/feed/feed.py b/src/components/feed/feed.py index 2bf9932..57e773e 100644 --- a/src/components/feed/feed.py +++ b/src/components/feed/feed.py @@ -11,6 +11,7 @@ class Feed: def __init__(self, args): self._is_json = args.json + self._is_colorize = args.colorize self._cache_date = args.date self._limit = args.limit self._url = args.source @@ -27,7 +28,7 @@ def _pre_validate_params(self): Logger.log_error('Limit must be up to zero') raise ValueError('limit equal or less 0') - if not Cache().get_specify_by_date(self._cache_date, self._limit): + if self._cache_date and not Cache().get_specify_by_date(self._cache_date, self._limit): raise Exception(f'There is no cached news ' f'{self._cache_date.strftime("from %d, %b %Y")}' f'{(self._cache_date + timedelta(days=1)).strftime(" to %d, %b %Y")}') @@ -40,7 +41,10 @@ def show_feeds(self) -> object: FeedFormatter.is_json = self._is_json output = FeedFormatter.generate_output( - self._decide_output(), self._limit, self._feeds_title + self._decide_output(), + self._limit, + self._feeds_title, + self._is_colorize ) print(output) diff --git a/src/components/feed/feed_formatter.py b/src/components/feed/feed_formatter.py index 926f2e8..81fde27 100644 --- a/src/components/feed/feed_formatter.py +++ b/src/components/feed/feed_formatter.py @@ -1,4 +1,6 @@ import json +from fabulous import image, color +from fabulous.text import Text class FeedFormatter: @@ -6,15 +8,21 @@ class FeedFormatter: is_json = False @classmethod - def generate_output(cls, feeds, limit, title): + def generate_output(cls, feeds, limit, title, is_colorize=False): + if not cls.is_json: - return cls._default_output(feeds, limit, title) + return cls._default_output(feeds, limit, title, is_colorize) return cls._json_output(feeds, limit, title) @classmethod - def _default_output(cls, feeds, limit, title): - formatted_feeds = ''.join(cls._single_feed_format_default(feed) for feed in feeds[:limit]) + def _default_output(cls, feeds, limit, title, is_colorize): + + if is_colorize: + print(Text("Console Rss Reader!", fsize=19, color='#f44a41', shadow=False, skew=4)) + formatted_feeds = ''.join(cls._colorize_single_feed_format_default(feed) for feed in feeds[:limit]) + else: + formatted_feeds = ''.join(cls._single_feed_format_default(feed) for feed in feeds[:limit]) return 'Feed: {0}\n\n{1}'.format(title, formatted_feeds) @@ -33,11 +41,23 @@ def _json_output(cls, feeds, limit, title): @classmethod def _single_feed_format_default(self,feed): return f'\ + \r{self._delimiter()}\n\n\ \rTitle: {feed.title}\n\ \rDate: {feed.date}\n\ \rLink: {feed.link}\n\n\ \r{feed.description}\n\n\ - \rLinks:\n\r{feed.links}\n\n' + \rLinks:\n\r{feed.links}\n' + + @classmethod + def _colorize_single_feed_format_default(self, feed): + return f'\ + \r{color.highlight_red(self._delimiter())}\n\n\ + \r{color.italic(color.magenta("Title"))}: {color.highlight_magenta(feed.title)}\n\ + \r{color.bold(color.yellow("Date"))}: {color.highlight_yellow(feed.date)}\n\ + \r{color.bold(color.blue("Link"))}: {color.highlight_blue(feed.link)}\n\n\ + \r{color.highlight_green(feed.description)}\n\n\ + \r{color.bold("Links")}:\n\r{color.bold(feed.links)}\n' + @classmethod def _single_feed_format_json(cls, feed): @@ -52,3 +72,7 @@ def _single_feed_format_json(cls, feed): } } }, indent=4) + + @staticmethod + def _delimiter(): + return ''.join('#' * 100) diff --git a/src/components/logger/logger.py b/src/components/logger/logger.py index 086994e..aafddee 100644 --- a/src/components/logger/logger.py +++ b/src/components/logger/logger.py @@ -3,6 +3,7 @@ import logging.config import yaml from src.components.helper.singleton import Singleton +import coloredlogs class Logger(Singleton): @@ -10,13 +11,25 @@ class Logger(Singleton): logger_name = 'standard' @classmethod - def initialize(cls): + def initialize(cls, is_colorize): + with open(os.path.join(os.path.dirname(os.path.realpath(__file__)),'conf.yml'), 'r') as file: config = yaml.safe_load(file.read()) logging.config.dictConfig(config) cls._logger = logging.getLogger(cls.logger_name) + if is_colorize: + coloredlogs.install( + fmt='%(asctime)s - %(message)s', + datefmt='%H:%M:%S', + field_styles={ + 'message' : dict(color='green'), + 'asctime' : dict(color='red'), + }, + level='DEBUG', logger=cls._logger + ) + @classmethod def log(cls, message: str): if getattr(cls, '_logger', None) is not None: diff --git a/src/rss_reader.py b/src/rss_reader.py index d84429a..2c1b52e 100644 --- a/src/rss_reader.py +++ b/src/rss_reader.py @@ -4,9 +4,12 @@ from .components.logger.logger import Logger import conf + + class App(Singleton): def __init__(self) -> None: + console = Parser( 'Pure Python command-line RSS reader.', conf.__description__ @@ -15,7 +18,7 @@ def __init__(self) -> None: self._console_args = console.get_args() if self._console_args.verbose: - Logger.initialize() + Logger.initialize(self._console_args.colorize) self._feed = Feed(self._console_args) From e362f8c76ccc183dd09e23c3c47373cbdd039ef8 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sat, 30 Nov 2019 15:12:43 +0300 Subject: [PATCH 12/26] add initialization validation to no bool rss-reader param source, limit --- src/components/converter/converter_abstract.py | 0 .../converter/html/html_converter.py | 0 src/components/helper/map.py | 2 +- .../parser/arguments/optional/limit.py | 14 ++++++++++++-- .../parser/arguments/positional/source.py | 18 ++++++++++++++++-- 5 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 src/components/converter/converter_abstract.py create mode 100644 src/components/converter/html/html_converter.py diff --git a/src/components/converter/converter_abstract.py b/src/components/converter/converter_abstract.py new file mode 100644 index 0000000..e69de29 diff --git a/src/components/converter/html/html_converter.py b/src/components/converter/html/html_converter.py new file mode 100644 index 0000000..e69de29 diff --git a/src/components/helper/map.py b/src/components/helper/map.py index ba60deb..c9649b6 100644 --- a/src/components/helper/map.py +++ b/src/components/helper/map.py @@ -25,4 +25,4 @@ def __delattr__(self, item): def __delitem__(self, key): super(Map, self).__delitem__(key) - del self.__dict__[key] \ No newline at end of file + del self.__dict__[key] diff --git a/src/components/parser/arguments/optional/limit.py b/src/components/parser/arguments/optional/limit.py index c42f4d6..508a4e5 100644 --- a/src/components/parser/arguments/optional/limit.py +++ b/src/components/parser/arguments/optional/limit.py @@ -1,10 +1,20 @@ from src.components.parser.arguments import ArgumentsAbstract - +import argparse +import sys class Limit(ArgumentsAbstract): def add_argument(self): self._parser.add_argument( - '--limit', type=int, default=3, help='Limit news topics if this parameter provided' + '--limit', type=self._validate_limit, default=3, help='Limit news topics if this parameter provided' ) + def _validate_limit(self, limit): + try: + if not int(limit) > 0: + raise argparse.ArgumentTypeError + + return int(limit) + + except argparse.ArgumentTypeError: + raise argparse.ArgumentTypeError('Argument limit equal or less 0') diff --git a/src/components/parser/arguments/positional/source.py b/src/components/parser/arguments/positional/source.py index f9db6e4..239193d 100644 --- a/src/components/parser/arguments/positional/source.py +++ b/src/components/parser/arguments/positional/source.py @@ -1,11 +1,25 @@ from src.components.parser.arguments.arguments_abstract import ArgumentsAbstract - +import argparse +import urllib.request as url +import sys class Source(ArgumentsAbstract): def add_argument(self): self._parser.add_argument( - 'source', type=str, help='RSS URL' + 'source', type=self._validate_source, help='RSS URL' ) + def _validate_source(self, source): + + try: + if url.urlopen(source).getcode() is not 200: + raise argparse.ArgumentError + + return source + + except argparse.ArgumentError: + raise argparse.ArgumentError('Server answer code is not 200') + except (url.HTTPError, url.URLError) as e: + raise url.URLError(f'Something wrong with your source. Please try another rss feed: {e}') From 05e5b431e8f74f04b7b295aeced081b8010a4fe6 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sat, 30 Nov 2019 18:35:35 +0300 Subject: [PATCH 13/26] change to-html validator exceptions. add part of convertation module html --- requirements.txt | 3 +- setup.py | 8 +- src/components/cache/cache.py | 3 +- .../converter/converter_abstract.py | 61 + src/components/converter/html/__init__.py | 1 + .../converter/html/html_converter.py | 48 + .../converter/html/templates/__init__.py | 4 + .../converter/html/templates/empty_media.py | 7 + .../converter/html/templates/entry.py | 26 + .../converter/html/templates/layout.py | 18 + .../converter/html/templates/media.py | 7 + src/components/feed/feed.py | 11 +- src/components/feed/feed_entry.py | 2 +- src/components/logger/logger.py | 5 +- src/components/parser/arguments/__init__.py | 1 + .../parser/arguments/optional/limit.py | 2 +- .../parser/arguments/optional/to_html.py | 23 + .../parser/arguments/positional/source.py | 2 +- src/components/parser/parser.py | 7 +- src/rss_reader.py | 7 +- ~/test/test.html | 10283 ++++++++++++++++ 21 files changed, 10512 insertions(+), 17 deletions(-) create mode 100644 src/components/converter/html/__init__.py create mode 100644 src/components/converter/html/templates/__init__.py create mode 100644 src/components/converter/html/templates/empty_media.py create mode 100644 src/components/converter/html/templates/entry.py create mode 100644 src/components/converter/html/templates/layout.py create mode 100644 src/components/converter/html/templates/media.py create mode 100644 src/components/parser/arguments/optional/to_html.py create mode 100644 ~/test/test.html diff --git a/requirements.txt b/requirements.txt index fd2a668..bfb48ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ feedparser==5.2.1 bs4==0.0.1 coloredlogs==10.0.0 -fabulous==0.3.0 \ No newline at end of file +fabulous==0.3.0 +jinja2==2.10.3 \ No newline at end of file diff --git a/setup.py b/setup.py index cf2c574..82a510e 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,16 @@ import setuptools import conf -import os +from pathlib import Path -here = os.path.abspath(os.path.dirname(__file__)) +here = Path(__file__).resolve() def get_install_requirements(): - with open(os.path.join(here,'requirements.txt'), 'r') as file: + with open(here.joinpath('requirements.txt'), 'r') as file: return [requirement.strip() for requirement in file] -with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: +with open(here.joinpath('README.md'), encoding='utf-8') as f: long_description = f.read() diff --git a/src/components/cache/cache.py b/src/components/cache/cache.py index d53a83a..5a99eba 100644 --- a/src/components/cache/cache.py +++ b/src/components/cache/cache.py @@ -2,6 +2,7 @@ from src.components.logger import Logger from src.components.helper import Singleton from src.components.helper import Map +from src.components.feed.feed_entry import FeedEntry from .db.sqlite_scripts import scripts from datetime import timedelta @@ -63,7 +64,7 @@ def append_feeds(self, feed: dict, feed_entities_list: list) -> None: self.db.close() - def _insert_feed_entry_into_cache(self, entry: "src.components.feed.feed_entry.FeedEntry", feed_id): + def _insert_feed_entry_into_cache(self, entry: FeedEntry, feed_id): return self.db.write('feeds_entries', [ 'feed_id', 'title', diff --git a/src/components/converter/converter_abstract.py b/src/components/converter/converter_abstract.py index e69de29..8c9a172 100644 --- a/src/components/converter/converter_abstract.py +++ b/src/components/converter/converter_abstract.py @@ -0,0 +1,61 @@ +from src.components.logger import Logger +from abc import ABC, abstractmethod +from pathlib import Path +import conf +import urllib.request as request +from src.components.feed import FeedEntry + + +class ConverterAbstract(ABC): + + def __init__(self, path: str) -> None: + self._path_initialize(Path(path)) + + + @abstractmethod + def render(self, feeds_entries: list, title: str) -> str: + pass + + @abstractmethod + def _entry_render(self, entry: FeedEntry): + pass + + def _path_initialize(self, path: Path): + + self._path = path + + if not self._path.parent.exists(): + self._path.parent.mkdir( + parents=True, + exist_ok=True + ) + + self._media_path = Path.home()\ + .joinpath('.' + conf.__package__)\ + .joinpath('media') + + if not self._media_path.exists(): + self._media_path.mkdir( + parents=True, exist_ok=True + ) + + def _download_media(self, media_url: str) -> bool: + + media_file = self._media_path.joinpath( + hash(media_url) + ) + + try: + data = request.urlretrieve(media_url) + except(request.HTTPError, request.URLError): + Logger.log(f'Image with url {media_url} did not download') + return False + + with open(media_file, 'wb') as file: + file.write(data.content) + + return media_file + + def _save_render_file(self, output, encoding: str='UTF-8') -> None: + with open(self._path, 'w', encoding=encoding) as file: + file.write(output) diff --git a/src/components/converter/html/__init__.py b/src/components/converter/html/__init__.py new file mode 100644 index 0000000..2303011 --- /dev/null +++ b/src/components/converter/html/__init__.py @@ -0,0 +1 @@ +from .html_converter import HtmlConverter \ No newline at end of file diff --git a/src/components/converter/html/html_converter.py b/src/components/converter/html/html_converter.py index e69de29..b261857 100644 --- a/src/components/converter/html/html_converter.py +++ b/src/components/converter/html/html_converter.py @@ -0,0 +1,48 @@ +from src.components.converter.converter_abstract import ConverterAbstract +from src.components.logger import Logger +from src.components.feed import FeedEntry +from .templates import * + + +class HtmlConverter(ConverterAbstract): + + def render(self, feeds_entries: list, title: str, encoding: str='UTF-8') -> str: + + render_feeds_entries = [] + for entry in feeds_entries: + + render_feeds_entries.append( + entry_templ.render( + # images=images_html, + title=entry.title, + date=entry.date, + text=entry.description, + link=entry.link, + links=entry.links + ) + ) + + self._save_render_file( + layout_templ.render( + feeds_entries=render_feeds_entries, + title=title, + encoding=encoding + ) + ) + + def _media_render(self, entry: FeedEntry): + #make loop!! + media = self._download_media(entry) + + if not media: + return empty_media_templ.render() + + return media_templ.render(src=media, alt=entry.title) + + def _entry_render(self, entry: FeedEntry): + media = self._download_media(entry) + + if not media: + return empty_media_templ.render() + + return media_templ.render(src=media, alt=entry.title) diff --git a/src/components/converter/html/templates/__init__.py b/src/components/converter/html/templates/__init__.py new file mode 100644 index 0000000..09e0ec3 --- /dev/null +++ b/src/components/converter/html/templates/__init__.py @@ -0,0 +1,4 @@ +from src.components.converter.html.templates.layout import layout as layout_templ +from src.components.converter.html.templates.entry import entry as entry_templ +from src.components.converter.html.templates.media import media as media_templ +from src.components.converter.html.templates.empty_media import empty_media as empty_media_templ diff --git a/src/components/converter/html/templates/empty_media.py b/src/components/converter/html/templates/empty_media.py new file mode 100644 index 0000000..067664d --- /dev/null +++ b/src/components/converter/html/templates/empty_media.py @@ -0,0 +1,7 @@ +from jinja2 import Template + +empty_media = Template(''' +
+ Image for this block did not download! +
+

{{title}}

+
+
+ # {% for img in images %} + # {{img}} + # {% endfor %} +
+
+

{{date}}

+

+ {{description}} +

+ Links + {% for link in links %} + {{link}} + {% endfor %} +
+ Source: {{link}} +
+
+ +''') diff --git a/src/components/converter/html/templates/layout.py b/src/components/converter/html/templates/layout.py new file mode 100644 index 0000000..c70863b --- /dev/null +++ b/src/components/converter/html/templates/layout.py @@ -0,0 +1,18 @@ +from jinja2 import Template + +layout = Template(''' + + + + {{title}} + + +

{{title}}

+
+ {% for entry in feeds_entries %} + {{entry}} + {% endfor %} +
+ + +''') diff --git a/src/components/converter/html/templates/media.py b/src/components/converter/html/templates/media.py new file mode 100644 index 0000000..7c65525 --- /dev/null +++ b/src/components/converter/html/templates/media.py @@ -0,0 +1,7 @@ +from jinja2 import Template + +media = Template(''' +
+ {{alt}} +
str: + parts = string.split('_') + return parts[0].capitalize() + ''.join(part.title() for part in parts[1:]) diff --git a/src/rss_reader.py b/src/rss_reader.py index 2c1b52e..0ddc7e2 100644 --- a/src/rss_reader.py +++ b/src/rss_reader.py @@ -2,6 +2,7 @@ from .components.parser.parser import Parser from .components.feed import * from .components.logger.logger import Logger +from .components.converter.html import HtmlConverter import conf @@ -9,7 +10,6 @@ class App(Singleton): def __init__(self) -> None: - console = Parser( 'Pure Python command-line RSS reader.', conf.__description__ @@ -22,6 +22,11 @@ def __init__(self) -> None: self._feed = Feed(self._console_args) + if self._console_args.to_html: + HtmlConverter(self._console_args.to_html).render( + self._feed.entities_list, self._feed.feeds_title + ) + @classmethod def start(cls) -> object: return cls()._feed.show_feeds() diff --git a/~/test/test.html b/~/test/test.html new file mode 100644 index 0000000..06e9235 --- /dev/null +++ b/~/test/test.html @@ -0,0 +1,10283 @@ + + + + + Yahoo News - Latest News & Headlines + + +

{title}

+
+ + +
+

The Next Trump Bombshell To Drop: The Justice Department's 2016 Trump Campaign Report

+
+
+ # +
+
+

Sat, 30 Nov 2019 02:32:00 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + n
+ + e
+ + x
+ + t
+ + -
+ + t
+ + r
+ + u
+ + m
+ + p
+ + -
+ + b
+ + o
+ + m
+ + b
+ + s
+ + h
+ + e
+ + l
+ + l
+ + -
+ + d
+ + r
+ + o
+ + p
+ + -
+ + j
+ + u
+ + s
+ + t
+ + i
+ + c
+ + e
+ + -
+ + 0
+ + 7
+ + 3
+ + 2
+ + 0
+ + 0
+ + 2
+ + 4
+ + 9
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/next-trump-bombshell-drop-justice-073200249.html +
+
+
+ + +
+

Hotpot vs bread: the culinary symbols of Hong Kong's political divide

+
+
+ # +
+
+

Fri, 29 Nov 2019 04:40:51 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + h
+ + o
+ + t
+ + p
+ + o
+ + t
+ + -
+ + v
+ + s
+ + -
+ + b
+ + r
+ + e
+ + a
+ + d
+ + -
+ + c
+ + u
+ + l
+ + i
+ + n
+ + a
+ + r
+ + y
+ + -
+ + s
+ + y
+ + m
+ + b
+ + o
+ + l
+ + s
+ + -
+ + h
+ + o
+ + n
+ + g
+ + -
+ + k
+ + o
+ + n
+ + g
+ + s
+ + -
+ + p
+ + o
+ + l
+ + i
+ + t
+ + i
+ + c
+ + a
+ + l
+ + -
+ + 0
+ + 9
+ + 4
+ + 0
+ + 5
+ + 1
+ + 4
+ + 1
+ + 8
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/hotpot-vs-bread-culinary-symbols-hong-kongs-political-094051418.html +
+
+
+ + +
+

'Very disturbing': Chicago officer under investigation for body-slamming man to the ground

+
+
+ # +
+
+

Fri, 29 Nov 2019 15:14:16 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + v
+ + e
+ + r
+ + y
+ + -
+ + d
+ + i
+ + s
+ + t
+ + u
+ + r
+ + b
+ + i
+ + n
+ + g
+ + -
+ + c
+ + h
+ + i
+ + c
+ + a
+ + g
+ + o
+ + -
+ + o
+ + f
+ + f
+ + i
+ + c
+ + e
+ + r
+ + -
+ + u
+ + n
+ + d
+ + e
+ + r
+ + -
+ + 1
+ + 8
+ + 5
+ + 3
+ + 2
+ + 4
+ + 5
+ + 9
+ + 8
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/very-disturbing-chicago-officer-under-185324598.html +
+
+
+ + +
+

Millions Around The World Strike on Black Friday for Action on Climate Change

+
+
+ # +
+
+

Fri, 29 Nov 2019 17:16:38 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + m
+ + i
+ + l
+ + l
+ + i
+ + o
+ + n
+ + s
+ + -
+ + a
+ + r
+ + o
+ + u
+ + n
+ + d
+ + -
+ + w
+ + o
+ + r
+ + l
+ + d
+ + -
+ + s
+ + t
+ + r
+ + i
+ + k
+ + e
+ + -
+ + b
+ + l
+ + a
+ + c
+ + k
+ + -
+ + 2
+ + 2
+ + 1
+ + 6
+ + 3
+ + 8
+ + 3
+ + 3
+ + 4
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/millions-around-world-strike-black-221638334.html +
+
+
+ + +
+

Italy’s ‘Miss Hitler’ Among 19 Investigated for Starting New Nazi Party in Italy

+
+
+ # +
+
+

Fri, 29 Nov 2019 11:03:52 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + i
+ + t
+ + a
+ + l
+ + y
+ + -
+ + m
+ + i
+ + s
+ + s
+ + -
+ + h
+ + i
+ + t
+ + l
+ + e
+ + r
+ + -
+ + a
+ + m
+ + o
+ + n
+ + g
+ + -
+ + 1
+ + 9
+ + -
+ + 1
+ + 6
+ + 0
+ + 3
+ + 5
+ + 2
+ + 7
+ + 3
+ + 4
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/italy-miss-hitler-among-19-160352734.html +
+
+
+ + +
+

U.K. Police Shoot Man After Potential Terrorist Attack in London

+
+
+ # +
+
+

Fri, 29 Nov 2019 10:52:14 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + a
+ + r
+ + m
+ + e
+ + d
+ + -
+ + p
+ + o
+ + l
+ + i
+ + c
+ + e
+ + -
+ + c
+ + l
+ + o
+ + s
+ + e
+ + -
+ + l
+ + o
+ + n
+ + d
+ + o
+ + n
+ + -
+ + b
+ + r
+ + i
+ + d
+ + g
+ + e
+ + -
+ + 1
+ + 4
+ + 3
+ + 6
+ + 4
+ + 2
+ + 3
+ + 1
+ + 2
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/armed-police-close-london-bridge-143642312.html +
+
+
+ + +
+

Behind in polls, Taiwan president contender tells supporters to lie to pollsters

+
+
+ # +
+
+

Fri, 29 Nov 2019 03:32:02 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + b
+ + e
+ + h
+ + i
+ + n
+ + d
+ + -
+ + p
+ + o
+ + l
+ + l
+ + s
+ + -
+ + t
+ + a
+ + i
+ + w
+ + a
+ + n
+ + -
+ + p
+ + r
+ + e
+ + s
+ + i
+ + d
+ + e
+ + n
+ + t
+ + -
+ + c
+ + o
+ + n
+ + t
+ + e
+ + n
+ + d
+ + e
+ + r
+ + -
+ + 0
+ + 8
+ + 3
+ + 2
+ + 0
+ + 2
+ + 4
+ + 9
+ + 8
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/behind-polls-taiwan-president-contender-083202498.html +
+
+
+ + +
+

Who made the new drapes? It’s among high court’s mysteries

+
+
+ # +
+
+

Fri, 29 Nov 2019 08:05:16 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + m
+ + a
+ + d
+ + e
+ + -
+ + d
+ + r
+ + a
+ + p
+ + e
+ + s
+ + -
+ + a
+ + m
+ + o
+ + n
+ + g
+ + -
+ + h
+ + i
+ + g
+ + h
+ + -
+ + c
+ + o
+ + u
+ + r
+ + t
+ + -
+ + 1
+ + 2
+ + 5
+ + 6
+ + 3
+ + 3
+ + 0
+ + 5
+ + 0
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/made-drapes-among-high-court-125633050.html +
+
+
+ + +
+

DR Congo buries 27 massacre victims as anger mounts

+
+
+ # +
+
+

Fri, 29 Nov 2019 13:16:48 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + d
+ + r
+ + -
+ + c
+ + o
+ + n
+ + g
+ + o
+ + -
+ + b
+ + u
+ + r
+ + i
+ + e
+ + s
+ + -
+ + 2
+ + 7
+ + -
+ + m
+ + a
+ + s
+ + s
+ + a
+ + c
+ + r
+ + e
+ + -
+ + v
+ + i
+ + c
+ + t
+ + i
+ + m
+ + s
+ + -
+ + a
+ + n
+ + g
+ + e
+ + r
+ + -
+ + m
+ + o
+ + u
+ + n
+ + t
+ + s
+ + -
+ + 1
+ + 7
+ + 0
+ + 2
+ + 3
+ + 6
+ + 1
+ + 9
+ + 3
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/dr-congo-buries-27-massacre-victims-anger-mounts-170236193.html +
+
+
+ + +
+

Nuclear Nightmare? Russia’s Avangard Hypersonic Missile Is About to Go Operational.

+
+
+ # +
+
+

Sat, 30 Nov 2019 01:00:00 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + n
+ + u
+ + c
+ + l
+ + e
+ + a
+ + r
+ + -
+ + n
+ + i
+ + g
+ + h
+ + t
+ + m
+ + a
+ + r
+ + e
+ + -
+ + r
+ + u
+ + s
+ + s
+ + i
+ + a
+ + -
+ + a
+ + v
+ + a
+ + n
+ + g
+ + a
+ + r
+ + d
+ + -
+ + h
+ + y
+ + p
+ + e
+ + r
+ + s
+ + o
+ + n
+ + i
+ + c
+ + -
+ + 0
+ + 6
+ + 0
+ + 0
+ + 0
+ + 0
+ + 2
+ + 8
+ + 6
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/nuclear-nightmare-russia-avangard-hypersonic-060000286.html +
+
+
+ + +
+

Iraqi PM offers resignation after security forces carry out 'bloodbath' killing of protesters

+
+
+ # +
+
+

Fri, 29 Nov 2019 08:12:28 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + i
+ + r
+ + a
+ + q
+ + i
+ + -
+ + p
+ + m
+ + -
+ + o
+ + f
+ + f
+ + e
+ + r
+ + s
+ + -
+ + r
+ + e
+ + s
+ + i
+ + g
+ + n
+ + a
+ + t
+ + i
+ + o
+ + n
+ + -
+ + s
+ + e
+ + c
+ + u
+ + r
+ + i
+ + t
+ + y
+ + -
+ + 1
+ + 3
+ + 1
+ + 2
+ + 2
+ + 8
+ + 1
+ + 0
+ + 5
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/iraqi-pm-offers-resignation-security-131228105.html +
+
+
+ + +
+

Inmate wanted by ICE released on bail. He was arrested weeks later for attempted murder

+
+
+ # +
+
+

Fri, 29 Nov 2019 19:01:47 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + i
+ + n
+ + m
+ + a
+ + t
+ + e
+ + -
+ + w
+ + a
+ + n
+ + t
+ + e
+ + d
+ + -
+ + i
+ + c
+ + e
+ + -
+ + r
+ + e
+ + l
+ + e
+ + a
+ + s
+ + e
+ + d
+ + -
+ + b
+ + a
+ + i
+ + l
+ + -
+ + 0
+ + 0
+ + 0
+ + 1
+ + 4
+ + 7
+ + 1
+ + 8
+ + 8
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/inmate-wanted-ice-released-bail-000147188.html +
+
+
+ + +
+

Tens of thousands rally in Europe, Asia before UN climate summit

+
+
+ # +
+
+

Fri, 29 Nov 2019 17:40:30 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + g
+ + l
+ + o
+ + b
+ + a
+ + l
+ + -
+ + c
+ + l
+ + i
+ + m
+ + a
+ + t
+ + e
+ + -
+ + p
+ + r
+ + o
+ + t
+ + e
+ + s
+ + t
+ + s
+ + -
+ + k
+ + i
+ + c
+ + k
+ + -
+ + o
+ + f
+ + f
+ + -
+ + s
+ + m
+ + o
+ + k
+ + e
+ + -
+ + c
+ + o
+ + v
+ + e
+ + r
+ + e
+ + d
+ + -
+ + s
+ + y
+ + d
+ + n
+ + e
+ + y
+ + -
+ + 0
+ + 5
+ + 1
+ + 0
+ + 2
+ + 0
+ + 0
+ + 9
+ + 0
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/global-climate-protests-kick-off-smoke-covered-sydney-051020090.html +
+
+
+ + +
+

Airlines are joining in on Black Friday with major flight sales — here's how you can save

+
+
+ # +
+
+

Thu, 28 Nov 2019 10:54:00 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + a
+ + i
+ + r
+ + l
+ + i
+ + n
+ + e
+ + s
+ + -
+ + j
+ + o
+ + i
+ + n
+ + i
+ + n
+ + g
+ + -
+ + b
+ + l
+ + a
+ + c
+ + k
+ + -
+ + f
+ + r
+ + i
+ + d
+ + a
+ + y
+ + -
+ + m
+ + a
+ + j
+ + o
+ + r
+ + -
+ + 1
+ + 5
+ + 5
+ + 4
+ + 0
+ + 0
+ + 2
+ + 3
+ + 9
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/airlines-joining-black-friday-major-155400239.html +
+
+
+ + +
+

7 Homes for Sale in the Most Secluded Parts of the World

+
+
+ # +
+
+

Fri, 29 Nov 2019 08:00:00 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + 7
+ + -
+ + h
+ + o
+ + m
+ + e
+ + s
+ + -
+ + s
+ + a
+ + l
+ + e
+ + -
+ + m
+ + o
+ + s
+ + t
+ + -
+ + s
+ + e
+ + c
+ + l
+ + u
+ + d
+ + e
+ + d
+ + -
+ + 1
+ + 3
+ + 0
+ + 0
+ + 0
+ + 0
+ + 8
+ + 4
+ + 4
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/7-homes-sale-most-secluded-130000844.html +
+
+
+ + +
+

U.S. panel sets deadline for Trump to decide participation in impeachment hearings

+
+
+ # +
+
+

Fri, 29 Nov 2019 14:53:11 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + u
+ + -
+ + p
+ + a
+ + n
+ + e
+ + l
+ + -
+ + g
+ + i
+ + v
+ + e
+ + s
+ + -
+ + t
+ + r
+ + u
+ + m
+ + p
+ + -
+ + d
+ + e
+ + a
+ + d
+ + l
+ + i
+ + n
+ + e
+ + -
+ + 1
+ + 9
+ + 5
+ + 3
+ + 1
+ + 1
+ + 4
+ + 3
+ + 1
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/u-panel-gives-trump-deadline-195311431.html +
+
+
+ + +
+

Families of Mexico massacre victims face backlash after cartel shooting

+
+
+ # +
+
+

Fri, 29 Nov 2019 12:46:50 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + f
+ + a
+ + m
+ + i
+ + l
+ + i
+ + e
+ + s
+ + -
+ + m
+ + e
+ + x
+ + i
+ + c
+ + o
+ + -
+ + m
+ + a
+ + s
+ + s
+ + a
+ + c
+ + r
+ + e
+ + -
+ + v
+ + i
+ + c
+ + t
+ + i
+ + m
+ + s
+ + -
+ + f
+ + a
+ + c
+ + e
+ + -
+ + 1
+ + 7
+ + 4
+ + 6
+ + 5
+ + 0
+ + 3
+ + 8
+ + 2
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/families-mexico-massacre-victims-face-174650382.html +
+
+
+ + +
+

U.S. Rebukes Zambia for Jailing Two Men for Homosexuality

+
+
+ # +
+
+

Sat, 30 Nov 2019 07:27:13 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + u
+ + -
+ + r
+ + e
+ + b
+ + u
+ + k
+ + e
+ + s
+ + -
+ + z
+ + a
+ + m
+ + b
+ + i
+ + a
+ + -
+ + j
+ + a
+ + i
+ + l
+ + i
+ + n
+ + g
+ + -
+ + t
+ + w
+ + o
+ + -
+ + 1
+ + 5
+ + 3
+ + 2
+ + 1
+ + 4
+ + 2
+ + 1
+ + 4
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/u-rebukes-zambia-jailing-two-153214214.html +
+
+
+ + +
+

Is Israel Taking Advantage of Regional Confusion to Expand Its Territory?

+
+
+ # +
+
+

Fri, 29 Nov 2019 20:00:00 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + i
+ + s
+ + r
+ + a
+ + e
+ + l
+ + -
+ + t
+ + a
+ + k
+ + i
+ + n
+ + g
+ + -
+ + a
+ + d
+ + v
+ + a
+ + n
+ + t
+ + a
+ + g
+ + e
+ + -
+ + r
+ + e
+ + g
+ + i
+ + o
+ + n
+ + a
+ + l
+ + -
+ + c
+ + o
+ + n
+ + f
+ + u
+ + s
+ + i
+ + o
+ + n
+ + -
+ + 0
+ + 1
+ + 0
+ + 0
+ + 0
+ + 0
+ + 8
+ + 5
+ + 9
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/israel-taking-advantage-regional-confusion-010000859.html +
+
+
+ + +
+

Why the LDS Church Joined LGBTQ Advocates in Supporting Utah's Conversion Therapy Ban

+
+
+ # +
+
+

Fri, 29 Nov 2019 19:24:22 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + w
+ + h
+ + y
+ + -
+ + l
+ + d
+ + s
+ + -
+ + c
+ + h
+ + u
+ + r
+ + c
+ + h
+ + -
+ + j
+ + o
+ + i
+ + n
+ + e
+ + d
+ + -
+ + l
+ + g
+ + b
+ + t
+ + q
+ + -
+ + 0
+ + 0
+ + 2
+ + 4
+ + 2
+ + 2
+ + 4
+ + 1
+ + 7
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/why-lds-church-joined-lgbtq-002422417.html +
+
+
+ + +
+

Thanksgiving photo Bill O'Reilly posted to Twitter freaks people out

+
+
+ # +
+
+

Fri, 29 Nov 2019 10:54:49 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + t
+ + h
+ + a
+ + n
+ + k
+ + s
+ + g
+ + i
+ + v
+ + i
+ + n
+ + g
+ + -
+ + p
+ + h
+ + o
+ + t
+ + o
+ + -
+ + b
+ + i
+ + l
+ + l
+ + -
+ + o
+ + r
+ + e
+ + i
+ + l
+ + l
+ + y
+ + -
+ + p
+ + o
+ + s
+ + t
+ + e
+ + d
+ + -
+ + 1
+ + 5
+ + 5
+ + 4
+ + 4
+ + 9
+ + 0
+ + 5
+ + 6
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/thanksgiving-photo-bill-oreilly-posted-155449056.html +
+
+
+ + +
+

Third occupant of Spain 'narco-sub' arrested: police

+
+
+ # +
+
+

Fri, 29 Nov 2019 15:54:19 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + t
+ + h
+ + i
+ + r
+ + d
+ + -
+ + o
+ + c
+ + c
+ + u
+ + p
+ + a
+ + n
+ + t
+ + -
+ + s
+ + p
+ + a
+ + i
+ + n
+ + -
+ + n
+ + a
+ + r
+ + c
+ + o
+ + -
+ + s
+ + u
+ + b
+ + -
+ + a
+ + r
+ + r
+ + e
+ + s
+ + t
+ + e
+ + d
+ + -
+ + p
+ + o
+ + l
+ + i
+ + c
+ + e
+ + -
+ + 2
+ + 0
+ + 5
+ + 4
+ + 1
+ + 9
+ + 2
+ + 4
+ + 1
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/third-occupant-spain-narco-sub-arrested-police-205419241.html +
+
+
+ + +
+

Giraffes among 10 animals killed in 'tragic' Ohio safari wildlife park fire

+
+
+ # +
+
+

Fri, 29 Nov 2019 15:12:05 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + f
+ + i
+ + r
+ + e
+ + -
+ + k
+ + i
+ + l
+ + l
+ + s
+ + -
+ + u
+ + n
+ + k
+ + n
+ + o
+ + w
+ + n
+ + -
+ + n
+ + u
+ + m
+ + b
+ + e
+ + r
+ + -
+ + a
+ + n
+ + i
+ + m
+ + a
+ + l
+ + s
+ + -
+ + 0
+ + 3
+ + 1
+ + 9
+ + 0
+ + 7
+ + 0
+ + 2
+ + 2
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/fire-kills-unknown-number-animals-031907022.html +
+
+
+ + +
+

Worker who survived New Orleans hotel collapse deported

+
+
+ # +
+
+

Fri, 29 Nov 2019 17:52:21 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + w
+ + o
+ + r
+ + k
+ + e
+ + r
+ + -
+ + s
+ + u
+ + r
+ + v
+ + i
+ + v
+ + e
+ + d
+ + -
+ + o
+ + r
+ + l
+ + e
+ + a
+ + n
+ + s
+ + -
+ + h
+ + o
+ + t
+ + e
+ + l
+ + -
+ + c
+ + o
+ + l
+ + l
+ + a
+ + p
+ + s
+ + e
+ + -
+ + 2
+ + 2
+ + 5
+ + 2
+ + 2
+ + 1
+ + 3
+ + 0
+ + 6
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/worker-survived-orleans-hotel-collapse-225221306.html +
+
+
+ + +
+

Trump Pledges to Restart Taliban Peace Talks in Surprise Visit to Afghanistan

+
+
+ # +
+
+

Thu, 28 Nov 2019 15:16:28 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + t
+ + r
+ + u
+ + m
+ + p
+ + -
+ + p
+ + l
+ + e
+ + d
+ + g
+ + e
+ + s
+ + -
+ + r
+ + e
+ + s
+ + t
+ + a
+ + r
+ + t
+ + -
+ + t
+ + a
+ + l
+ + i
+ + b
+ + a
+ + n
+ + -
+ + p
+ + e
+ + a
+ + c
+ + e
+ + -
+ + 2
+ + 0
+ + 1
+ + 6
+ + 2
+ + 8
+ + 9
+ + 7
+ + 1
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/trump-pledges-restart-taliban-peace-201628971.html +
+
+
+ + +
+

Russia and China deepen ties with River Amur bridge

+
+
+ # +
+
+

Fri, 29 Nov 2019 09:06:47 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + r
+ + u
+ + s
+ + s
+ + i
+ + a
+ + -
+ + c
+ + h
+ + i
+ + n
+ + a
+ + -
+ + d
+ + e
+ + e
+ + p
+ + e
+ + n
+ + -
+ + t
+ + i
+ + e
+ + s
+ + -
+ + r
+ + i
+ + v
+ + e
+ + r
+ + -
+ + 1
+ + 4
+ + 0
+ + 6
+ + 4
+ + 7
+ + 7
+ + 9
+ + 6
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/russia-china-deepen-ties-river-140647796.html +
+
+
+ + +
+

Hong Kong Police End Campus Siege After Finding 3,989 Petrol Bombs

+
+
+ # +
+
+

Fri, 29 Nov 2019 04:37:38 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + h
+ + o
+ + n
+ + g
+ + -
+ + k
+ + o
+ + n
+ + g
+ + -
+ + p
+ + o
+ + l
+ + i
+ + c
+ + e
+ + -
+ + m
+ + o
+ + v
+ + e
+ + -
+ + e
+ + n
+ + d
+ + -
+ + 1
+ + 5
+ + 1
+ + 8
+ + 1
+ + 2
+ + 6
+ + 6
+ + 9
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/hong-kong-police-move-end-151812669.html +
+
+
+ + +
+

The China Challenge Continues to Mount

+
+
+ # +
+
+

Fri, 29 Nov 2019 16:00:00 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + c
+ + h
+ + i
+ + n
+ + a
+ + -
+ + c
+ + h
+ + a
+ + l
+ + l
+ + e
+ + n
+ + g
+ + e
+ + -
+ + c
+ + o
+ + n
+ + t
+ + i
+ + n
+ + u
+ + e
+ + s
+ + -
+ + m
+ + o
+ + u
+ + n
+ + t
+ + -
+ + 2
+ + 1
+ + 0
+ + 0
+ + 0
+ + 0
+ + 3
+ + 3
+ + 6
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/china-challenge-continues-mount-210000336.html +
+
+
+ + +
+

Transgender paedophile sues NHS for refusing her reassignment surgery while she serves prison sentence

+
+
+ # +
+
+

Thu, 28 Nov 2019 15:11:27 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + t
+ + r
+ + a
+ + n
+ + s
+ + g
+ + e
+ + n
+ + d
+ + e
+ + r
+ + -
+ + p
+ + a
+ + e
+ + d
+ + o
+ + p
+ + h
+ + i
+ + l
+ + e
+ + -
+ + s
+ + u
+ + e
+ + s
+ + -
+ + n
+ + h
+ + s
+ + -
+ + r
+ + e
+ + f
+ + u
+ + s
+ + i
+ + n
+ + g
+ + -
+ + 2
+ + 0
+ + 1
+ + 1
+ + 2
+ + 7
+ + 4
+ + 1
+ + 4
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/transgender-paedophile-sues-nhs-refusing-201127414.html +
+
+
+ + +
+

The US fertility rate has dropped for the fourth year in a row, and it might forecast a 'demographic time bomb'

+
+
+ # +
+
+

Fri, 29 Nov 2019 15:45:03 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + u
+ + s
+ + -
+ + f
+ + e
+ + r
+ + t
+ + i
+ + l
+ + i
+ + t
+ + y
+ + -
+ + r
+ + a
+ + t
+ + e
+ + -
+ + d
+ + r
+ + o
+ + p
+ + p
+ + e
+ + d
+ + -
+ + f
+ + o
+ + u
+ + r
+ + t
+ + h
+ + -
+ + 2
+ + 0
+ + 4
+ + 5
+ + 0
+ + 3
+ + 2
+ + 2
+ + 5
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/us-fertility-rate-dropped-fourth-204503225.html +
+
+
+ + +
+

Romania's 1989 generation relive pain at ex-president's trial

+
+
+ # +
+
+

Fri, 29 Nov 2019 10:21:02 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + r
+ + o
+ + m
+ + a
+ + n
+ + i
+ + a
+ + s
+ + -
+ + 1
+ + 9
+ + 8
+ + 9
+ + -
+ + r
+ + e
+ + v
+ + o
+ + l
+ + u
+ + t
+ + i
+ + o
+ + n
+ + -
+ + g
+ + e
+ + n
+ + e
+ + r
+ + a
+ + t
+ + i
+ + o
+ + n
+ + -
+ + a
+ + w
+ + a
+ + i
+ + t
+ + -
+ + e
+ + x
+ + -
+ + p
+ + r
+ + e
+ + s
+ + i
+ + d
+ + e
+ + n
+ + t
+ + s
+ + -
+ + t
+ + r
+ + i
+ + a
+ + l
+ + -
+ + 0
+ + 4
+ + 4
+ + 0
+ + 0
+ + 9
+ + 8
+ + 8
+ + 0
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/romanias-1989-revolution-generation-await-ex-presidents-trial-044009880.html +
+
+
+ + +
+

UPS workers allegedly trafficked 1,000s of pounds of drugs and fake vape pens across the country

+
+
+ # +
+
+

Thu, 28 Nov 2019 09:56:24 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + u
+ + p
+ + s
+ + -
+ + w
+ + o
+ + r
+ + k
+ + e
+ + r
+ + s
+ + -
+ + a
+ + l
+ + l
+ + e
+ + g
+ + e
+ + d
+ + l
+ + y
+ + -
+ + t
+ + r
+ + a
+ + f
+ + f
+ + i
+ + c
+ + k
+ + e
+ + d
+ + -
+ + 1
+ + -
+ + 1
+ + 4
+ + 5
+ + 6
+ + 2
+ + 4
+ + 4
+ + 5
+ + 1
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/ups-workers-allegedly-trafficked-1-145624451.html +
+
+
+ + +
+

Clemson University students mentor elementary school kids through nonprofit work

+
+
+ # +
+
+

Fri, 29 Nov 2019 10:32:02 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + c
+ + l
+ + e
+ + m
+ + s
+ + o
+ + n
+ + -
+ + u
+ + n
+ + i
+ + v
+ + e
+ + r
+ + s
+ + i
+ + t
+ + y
+ + -
+ + s
+ + t
+ + u
+ + d
+ + e
+ + n
+ + t
+ + s
+ + -
+ + m
+ + e
+ + n
+ + t
+ + o
+ + r
+ + -
+ + e
+ + l
+ + e
+ + m
+ + e
+ + n
+ + t
+ + a
+ + r
+ + y
+ + -
+ + 1
+ + 5
+ + 3
+ + 2
+ + 0
+ + 2
+ + 2
+ + 9
+ + 1
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/clemson-university-students-mentor-elementary-153202291.html +
+
+
+ + +
+

OK, Mayor: Why 37-Year-Old Pete Buttigieg Is Attracting Boomers

+
+
+ # +
+
+

Thu, 28 Nov 2019 15:00:49 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + o
+ + k
+ + -
+ + m
+ + a
+ + y
+ + o
+ + r
+ + -
+ + w
+ + h
+ + y
+ + -
+ + 3
+ + 7
+ + -
+ + o
+ + l
+ + d
+ + -
+ + 2
+ + 0
+ + 0
+ + 0
+ + 4
+ + 9
+ + 2
+ + 2
+ + 8
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/ok-mayor-why-37-old-200049228.html +
+
+
+ + +
+

Pakistani man aims to bring shade to Iraq's Arbaeen pilgrims

+
+
+ # +
+
+

Fri, 29 Nov 2019 06:16:34 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + p
+ + a
+ + k
+ + i
+ + s
+ + t
+ + a
+ + n
+ + i
+ + -
+ + m
+ + a
+ + n
+ + -
+ + a
+ + i
+ + m
+ + s
+ + -
+ + b
+ + r
+ + i
+ + n
+ + g
+ + -
+ + s
+ + h
+ + a
+ + d
+ + e
+ + -
+ + 1
+ + 1
+ + 1
+ + 6
+ + 3
+ + 4
+ + 4
+ + 3
+ + 3
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/pakistani-man-aims-bring-shade-111634433.html +
+
+
+ + +
+

Malaysia Drains Crowdsourced ‘Hope Fund’ to Repay 1MDB Debt

+
+
+ # +
+
+

Sat, 30 Nov 2019 00:15:40 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + m
+ + a
+ + l
+ + a
+ + y
+ + s
+ + i
+ + a
+ + -
+ + d
+ + r
+ + a
+ + i
+ + n
+ + s
+ + -
+ + c
+ + r
+ + o
+ + w
+ + d
+ + s
+ + o
+ + u
+ + r
+ + c
+ + e
+ + d
+ + -
+ + h
+ + o
+ + p
+ + e
+ + -
+ + f
+ + u
+ + n
+ + d
+ + -
+ + 0
+ + 5
+ + 1
+ + 5
+ + 4
+ + 0
+ + 4
+ + 0
+ + 0
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/malaysia-drains-crowdsourced-hope-fund-051540400.html +
+
+
+ + +
+

This Is America's Role in Saudi Arabia's Power Struggle

+
+
+ # +
+
+

Fri, 29 Nov 2019 15:00:00 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + a
+ + m
+ + e
+ + r
+ + i
+ + c
+ + a
+ + s
+ + -
+ + r
+ + o
+ + l
+ + e
+ + -
+ + s
+ + a
+ + u
+ + d
+ + i
+ + -
+ + a
+ + r
+ + a
+ + b
+ + i
+ + a
+ + s
+ + -
+ + p
+ + o
+ + w
+ + e
+ + r
+ + -
+ + 2
+ + 0
+ + 0
+ + 0
+ + 0
+ + 0
+ + 9
+ + 0
+ + 0
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/americas-role-saudi-arabias-power-200000900.html +
+
+
+ + +
+

River watchers already wary about 2020 spring flooding

+
+
+ # +
+
+

Fri, 29 Nov 2019 14:03:28 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + r
+ + i
+ + v
+ + e
+ + r
+ + -
+ + w
+ + a
+ + t
+ + c
+ + h
+ + e
+ + r
+ + s
+ + -
+ + a
+ + l
+ + r
+ + e
+ + a
+ + d
+ + y
+ + -
+ + w
+ + a
+ + r
+ + y
+ + -
+ + 2
+ + 0
+ + 2
+ + 0
+ + -
+ + 1
+ + 9
+ + 0
+ + 3
+ + 2
+ + 8
+ + 1
+ + 4
+ + 4
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/river-watchers-already-wary-2020-190328144.html +
+
+
+ + +
+

Brother of convicted terrorist faces deportation despite US citizenship

+
+
+ # +
+
+

Sat, 30 Nov 2019 02:30:29 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + b
+ + r
+ + o
+ + t
+ + h
+ + e
+ + r
+ + -
+ + c
+ + o
+ + n
+ + v
+ + i
+ + c
+ + t
+ + e
+ + d
+ + -
+ + t
+ + e
+ + r
+ + r
+ + o
+ + r
+ + i
+ + s
+ + t
+ + -
+ + f
+ + a
+ + c
+ + e
+ + s
+ + -
+ + d
+ + e
+ + p
+ + o
+ + r
+ + t
+ + a
+ + t
+ + i
+ + o
+ + n
+ + -
+ + 0
+ + 7
+ + 3
+ + 0
+ + 2
+ + 9
+ + 0
+ + 1
+ + 6
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/brother-convicted-terrorist-faces-deportation-073029016.html +
+
+
+ + +
+

Zimbabwe facing 'man-made' starvation, UN expert warns

+
+
+ # +
+
+

Thu, 28 Nov 2019 20:40:03 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + z
+ + i
+ + m
+ + b
+ + a
+ + b
+ + w
+ + e
+ + -
+ + f
+ + a
+ + c
+ + i
+ + n
+ + g
+ + -
+ + m
+ + a
+ + n
+ + -
+ + m
+ + a
+ + d
+ + e
+ + -
+ + s
+ + t
+ + a
+ + r
+ + v
+ + a
+ + t
+ + i
+ + o
+ + n
+ + -
+ + u
+ + n
+ + -
+ + e
+ + x
+ + p
+ + e
+ + r
+ + t
+ + -
+ + w
+ + a
+ + r
+ + n
+ + s
+ + -
+ + 1
+ + 9
+ + 0
+ + 5
+ + 0
+ + 8
+ + 6
+ + 9
+ + 9
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/zimbabwe-facing-man-made-starvation-un-expert-warns-190508699.html +
+
+
+ + +
+

2 victims were killed and police fatally shot a man wearing a hoax explosive vest in a terrorist attack at London Bridge

+
+
+ # +
+
+

Fri, 29 Nov 2019 20:47:00 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + p
+ + o
+ + l
+ + i
+ + c
+ + e
+ + -
+ + n
+ + u
+ + m
+ + b
+ + e
+ + r
+ + -
+ + p
+ + e
+ + o
+ + p
+ + l
+ + e
+ + -
+ + i
+ + n
+ + j
+ + u
+ + r
+ + e
+ + d
+ + -
+ + s
+ + t
+ + a
+ + b
+ + b
+ + i
+ + n
+ + g
+ + -
+ + 1
+ + 4
+ + 4
+ + 5
+ + 5
+ + 4
+ + 5
+ + 0
+ + 2
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/police-number-people-injured-stabbing-144554502.html +
+
+
+ + +
+

Expectant mother gives birth on American Airlines jetway; gives daughter appropriate name

+
+
+ # +
+
+

Fri, 29 Nov 2019 19:29:08 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + e
+ + x
+ + p
+ + e
+ + c
+ + t
+ + a
+ + n
+ + t
+ + -
+ + m
+ + o
+ + t
+ + h
+ + e
+ + r
+ + -
+ + g
+ + i
+ + v
+ + e
+ + s
+ + -
+ + b
+ + i
+ + r
+ + t
+ + h
+ + -
+ + a
+ + m
+ + e
+ + r
+ + i
+ + c
+ + a
+ + n
+ + -
+ + 0
+ + 0
+ + 2
+ + 9
+ + 0
+ + 8
+ + 6
+ + 3
+ + 6
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/expectant-mother-gives-birth-american-002908636.html +
+
+
+ + +
+

We Aid the Growth of Chinese Tyranny to Our Eternal Shame

+
+
+ # +
+
+

Fri, 29 Nov 2019 06:30:03 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + a
+ + i
+ + d
+ + -
+ + g
+ + r
+ + o
+ + w
+ + t
+ + h
+ + -
+ + c
+ + h
+ + i
+ + n
+ + e
+ + s
+ + e
+ + -
+ + t
+ + y
+ + r
+ + a
+ + n
+ + n
+ + y
+ + -
+ + e
+ + t
+ + e
+ + r
+ + n
+ + a
+ + l
+ + -
+ + 1
+ + 1
+ + 3
+ + 0
+ + 0
+ + 3
+ + 5
+ + 6
+ + 6
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/aid-growth-chinese-tyranny-eternal-113003566.html +
+
+
+ + +
+

World-famous free solo climber Brad Gobright falls 1,000 feet to his death

+
+
+ # +
+
+

Fri, 29 Nov 2019 08:45:06 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + w
+ + o
+ + r
+ + l
+ + d
+ + -
+ + f
+ + a
+ + m
+ + o
+ + u
+ + s
+ + -
+ + f
+ + r
+ + e
+ + e
+ + -
+ + s
+ + o
+ + l
+ + o
+ + -
+ + c
+ + l
+ + i
+ + m
+ + b
+ + e
+ + r
+ + -
+ + 1
+ + 3
+ + 4
+ + 5
+ + 0
+ + 6
+ + 8
+ + 0
+ + 8
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/world-famous-free-solo-climber-134506808.html +
+
+
+ + +
+

Indonesian gymnast dropped after told 'she's no longer a virgin'

+
+
+ # +
+
+

Fri, 29 Nov 2019 06:13:51 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + i
+ + n
+ + d
+ + o
+ + n
+ + e
+ + s
+ + i
+ + a
+ + n
+ + -
+ + g
+ + y
+ + m
+ + n
+ + a
+ + s
+ + t
+ + -
+ + d
+ + r
+ + o
+ + p
+ + p
+ + e
+ + d
+ + -
+ + t
+ + o
+ + l
+ + d
+ + -
+ + s
+ + h
+ + e
+ + s
+ + -
+ + 1
+ + 1
+ + 1
+ + 3
+ + 5
+ + 1
+ + 2
+ + 5
+ + 0
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/indonesian-gymnast-dropped-told-shes-111351250.html +
+
+
+ + +
+

Donald Trump Sees Another Opportunity to Teach Cuba a Lesson

+
+
+ # +
+
+

Fri, 29 Nov 2019 08:30:00 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + d
+ + o
+ + n
+ + a
+ + l
+ + d
+ + -
+ + t
+ + r
+ + u
+ + m
+ + p
+ + -
+ + s
+ + e
+ + e
+ + s
+ + -
+ + a
+ + n
+ + o
+ + t
+ + h
+ + e
+ + r
+ + -
+ + o
+ + p
+ + p
+ + o
+ + r
+ + t
+ + u
+ + n
+ + i
+ + t
+ + y
+ + -
+ + 1
+ + 3
+ + 3
+ + 0
+ + 0
+ + 0
+ + 2
+ + 4
+ + 2
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/donald-trump-sees-another-opportunity-133000242.html +
+
+
+ + +
+

Japan Won’t Sign China-Backed Trade Deal If India Doesn’t Join

+
+
+ # +
+
+

Thu, 28 Nov 2019 23:15:10 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + j
+ + a
+ + p
+ + a
+ + n
+ + -
+ + w
+ + o
+ + n
+ + -
+ + t
+ + -
+ + s
+ + i
+ + g
+ + n
+ + -
+ + c
+ + h
+ + i
+ + n
+ + a
+ + -
+ + 0
+ + 4
+ + 1
+ + 5
+ + 1
+ + 0
+ + 3
+ + 6
+ + 9
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/japan-won-t-sign-china-041510369.html +
+
+
+ + +
+

U.S. planned to separate 26,000 migrant families in 2018

+
+
+ # +
+
+

Fri, 29 Nov 2019 06:41:19 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + u
+ + -
+ + p
+ + l
+ + a
+ + n
+ + n
+ + e
+ + d
+ + -
+ + s
+ + e
+ + p
+ + a
+ + r
+ + a
+ + t
+ + e
+ + -
+ + 2
+ + 6
+ + -
+ + 0
+ + 0
+ + 0
+ + -
+ + 1
+ + 2
+ + 0
+ + 0
+ + 2
+ + 9
+ + 3
+ + 6
+ + 1
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/u-planned-separate-26-000-120029361.html +
+
+
+ + +
+

Albanians hold mass funeral for earthquake victims

+
+
+ # +
+
+

Fri, 29 Nov 2019 10:28:07 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + a
+ + l
+ + m
+ + o
+ + s
+ + t
+ + -
+ + 5
+ + 0
+ + -
+ + d
+ + e
+ + a
+ + d
+ + -
+ + m
+ + o
+ + r
+ + e
+ + -
+ + 5
+ + -
+ + 0
+ + 0
+ + 0
+ + -
+ + d
+ + i
+ + s
+ + p
+ + l
+ + a
+ + c
+ + e
+ + d
+ + -
+ + a
+ + l
+ + b
+ + a
+ + n
+ + i
+ + a
+ + -
+ + 1
+ + 0
+ + 4
+ + 5
+ + 2
+ + 3
+ + 4
+ + 7
+ + 5
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/almost-50-dead-more-5-000-displaced-albania-104523475.html +
+
+
+ + +
+

Germany to make anti-Semitism a specific hate crime as Jews 'no longer feel safe'

+
+
+ # +
+
+

Fri, 29 Nov 2019 12:02:48 -0500

+

+ +

+ Links

+ + [
+ + 1
+ + ]
+ + :
+ +
+ + h
+ + t
+ + t
+ + p
+ + s
+ + :
+ + /
+ + /
+ + n
+ + e
+ + w
+ + s
+ + .
+ + y
+ + a
+ + h
+ + o
+ + o
+ + .
+ + c
+ + o
+ + m
+ + /
+ + g
+ + e
+ + r
+ + m
+ + a
+ + n
+ + y
+ + -
+ + c
+ + r
+ + a
+ + c
+ + k
+ + -
+ + d
+ + o
+ + w
+ + n
+ + -
+ + a
+ + n
+ + t
+ + i
+ + -
+ + s
+ + e
+ + m
+ + i
+ + t
+ + i
+ + c
+ + -
+ + 1
+ + 7
+ + 0
+ + 2
+ + 4
+ + 8
+ + 8
+ + 8
+ + 5
+ + .
+ + h
+ + t
+ + m
+ + l
+ +
+ + (
+ + t
+ + e
+ + x
+ + t
+ + /
+ + h
+ + t
+ + m
+ + l
+ + )
+ + +
+ +
+ Source: https://news.yahoo.com/germany-crack-down-anti-semitic-170248885.html +
+
+
+ +
+ + \ No newline at end of file From 02e1a575383c97922ea39e3a2a3e78ee19f885b4 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 1 Dec 2019 04:13:11 +0300 Subject: [PATCH 14/26] modifying path in project and setup file. change db cache structurture and cache mechanism. fix html template in converter --- setup.py | 2 +- src/__init__.py | 1 + src/components/__init__.py | 6 + src/components/cache/cache.py | 134 ++++++++++++------ src/components/cache/cache_entry.py | 1 + src/components/cache/db/sqlite.py | 30 ++-- src/components/cache/db/sqlite_scripts.py | 50 ++++--- src/components/converter/__init__.py | 1 + .../converter/converter_abstract.py | 13 +- .../converter/html/html_converter.py | 54 ++++--- .../converter/html/templates/__init__.py | 8 +- .../converter/html/templates/entry.py | 8 +- .../converter/html/templates/layout.py | 3 + src/components/feed/feed.py | 56 +++++--- src/components/feed/feed_entry.py | 26 ++-- src/components/feed/feed_formatter.py | 87 +++++++++--- src/components/helper/map.py | 4 +- src/components/logger/logger.py | 2 +- .../parser/arguments/optional/to_html.py | 5 - src/rss_reader.py | 7 +- 20 files changed, 330 insertions(+), 168 deletions(-) create mode 100644 src/components/__init__.py create mode 100644 src/components/converter/__init__.py diff --git a/setup.py b/setup.py index 82a510e..3f05740 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import conf from pathlib import Path -here = Path(__file__).resolve() +here = Path(__file__).parent def get_install_requirements(): diff --git a/src/__init__.py b/src/__init__.py index e69de29..41b878c 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1 @@ +from . import components \ No newline at end of file diff --git a/src/components/__init__.py b/src/components/__init__.py new file mode 100644 index 0000000..703b232 --- /dev/null +++ b/src/components/__init__.py @@ -0,0 +1,6 @@ +# from . import cache +# from . import converter +# from . import feed +# from . import helper +# from . import logger +# from . import parser \ No newline at end of file diff --git a/src/components/cache/cache.py b/src/components/cache/cache.py index 5a99eba..9a12c06 100644 --- a/src/components/cache/cache.py +++ b/src/components/cache/cache.py @@ -3,7 +3,7 @@ from src.components.helper import Singleton from src.components.helper import Map from src.components.feed.feed_entry import FeedEntry -from .db.sqlite_scripts import scripts +from fabulous import color from datetime import timedelta from datetime import datetime @@ -19,8 +19,7 @@ class Cache(Singleton): def __init__(self) -> None : self._cache_db_file = self._storage_initialize() - - self.db = Sqlite(str(self._cache_db_file)) + self._db = Sqlite(str(self._cache_db_file)) def _storage_initialize(self): @@ -40,11 +39,11 @@ def _storage_initialize(self): return cache_file - def append_feeds(self, feed: dict, feed_entities_list: list) -> None: + def append_feeds(self, feed: Map, feed_entities_list: list) -> None: - Logger.log(f'Check on feed cache exist on url: {feed.get("url")}') + Logger.log(f'Check on feed cache exist on url: {feed.url}') - feed_id = self.db.find_where('feeds', 'url', feed.get("url")) + feed_id = self._db.find_where('feeds', 'url', feed.url, 'like') if not feed_id: feed_id = self._insert_feed_data(feed) @@ -52,55 +51,108 @@ def append_feeds(self, feed: dict, feed_entities_list: list) -> None: Logger.log('Start caching feeds: \n') for feed_entry in feed_entities_list: - if not self.db.find_where('feeds_entries', 'link', feed_entry.link): - Logger.log(f'Caching feed [[{feed_entry.title}]] INSERTED') + if not self._db.find_where('feeds_entries', 'link', feed_entry.link, 'like'): + Logger.log(f'Caching feed {color.blue(feed_entry.title)} INSERTED') else: - Logger.log(f'Caching feed [[{feed_entry.title}]] UPDATED') + Logger.log(f'Caching feed {color.blue(feed_entry.title)} UPDATED') - self._insert_feed_entry_into_cache(feed_entry, feed_id) + self._insert_feed_entry_into_cache(feed_id, feed_entry) print("\n") Logger.log('Cached feeds was updated') - self.db.close() - - def _insert_feed_entry_into_cache(self, entry: FeedEntry, feed_id): - return self.db.write('feeds_entries', [ - 'feed_id', - 'title', - 'description', - 'link', - 'links', - 'date', - 'published' + self._db.close() + + def _insert_feed_entry_into_cache(self, feed_id, entry: FeedEntry): + + self._write_feed_entry_general(entry, feed_id) + + feed_entry_id = self._db.cursor.lastrowid + + self._write_feed_entry_links(feed_entry_id, entry) + self._write_feed_entry_media(feed_entry_id, entry) + + def _insert_feed_data(self, feed: Map): + Logger.log(f'Add feed cache exist on url: {feed.url}') + + self._db.write('feeds', [ + 'url', + 'encoding', + 'image' ], [ - feed_id, - html.escape(entry.title), - html.escape(entry.description), - entry.link, - entry.links, - entry.date, - entry.published, + feed.url, + feed.encoding, + feed.image ]) - def _insert_feed_data(self, feed): - Logger.log(f'Add feed cache exist on url: {feed.get("url")}') + return self._db.cursor.lastrowid + + def _write_feed_entry_general(self, entry: FeedEntry, feed_id): + return self._db.write( + 'feeds_entries', + ['feed_id','title','description','link','published'], + [feed_id,html.escape(entry.title),html.escape(entry.description),entry.link,entry.published,] + ) + + def _write_feed_entry_links(self, feed_entry_id, entry: FeedEntry): - self.db.write('feeds', ['url', 'encoding'], [feed.get('url'), feed.get("encoding")]) + for link in entry.links: + return self._db.write( + 'feed_entry_links', + ['feed_entry_id','href','type',], + [feed_entry_id, link.href,link.type,] + ) - return self.db.cursor.lastrowid + def _write_feed_entry_media(self, feed_entry_id, entry: FeedEntry): - def load_feeds_entries(self, date: str, limit=100) -> list: - Logger.log(f'Load file from cache storage ' - f'{date.strftime("from %d, %b %Y")}' - f'{(date + timedelta(days=1)).strftime(" to %d, %b %Y")}') + for media in entry.media: + return self._db.write('feed_entry_media', + ['feed_entry_id', 'url','additional',], + [feed_entry_id,media.url,html.escape(media.alt),] + ) + + def load_feeds_entries(self, url: str, date: str, limit=100) -> list: + Logger.log( + f'Load file from cache storage ' + f'{date.strftime("from %d, %b %Y")}' + f'{(date + timedelta(days=1)).strftime(" to %d, %b %Y")}' + ) date = datetime.combine(date, datetime.min.time()) - cache_list = self.get_specify_by_date(date, limit) + cache_list = self._get_specify_by_date(url, date, limit) + + if not cache_list: + raise Exception( + f'Cache retrive nothing. Storage for specified data is empty ' + f'{date.strftime("from %d, %b %Y")}' + f'{(date + timedelta(days=1)).strftime(" to %d, %b %Y")}' + ) + + return self._db.map_data(cache_list) + + def _get_specify_by_date(self, url, date, limit=100): + + feed_id = self._db.find_where('feeds', 'url', url, 'like') + + cache_general_data = self._db.where('feeds_entries', + ['feed_id', '=', feed_id], + ['published','>=', date], + ['published','<=', date + timedelta(days=1)], + limit=limit + ) + + output_cache = [] + + for cache_entry in self._db.map_data(cache_general_data): + cache_entry['links'] = self._db.map_data( + self._db.where('feed_entry_links', ['feed_entry_id', '=', cache_entry['id']]) + ) + + cache_entry['media'] = self._db.map_data( + self._db.where('feed_entry_media', ['feed_entry_id', '=', cache_entry['id']]) + ) - return [Map(row) for row in cache_list] + output_cache.append(cache_entry) - def get_specify_by_date(self, date, limit=100): - cache_list = self.db.query(scripts.get('load_news'), date, date + timedelta(days=1), limit) - return cache_list.fetchall() + return output_cache diff --git a/src/components/cache/cache_entry.py b/src/components/cache/cache_entry.py index 84ce6a9..c51d8f4 100644 --- a/src/components/cache/cache_entry.py +++ b/src/components/cache/cache_entry.py @@ -3,3 +3,4 @@ class CacheEntry(FeedEntry): pass + diff --git a/src/components/cache/db/sqlite.py b/src/components/cache/db/sqlite.py index a4e1b9c..dd42157 100644 --- a/src/components/cache/db/sqlite.py +++ b/src/components/cache/db/sqlite.py @@ -1,6 +1,7 @@ import sqlite3 import sys from .sqlite_scripts import scripts +from src.components.helper import Map class Sqlite: @@ -28,14 +29,24 @@ def close(self): self.cursor.close() self.conn.close() + def map_data(self, data): + if isinstance(data, sqlite3.Cursor): + return [Map(row) for row in data.fetchall()] + + return [Map(row) for row in data] + + @classmethod def create_database(self, path: str) -> str: try: self.conn = sqlite3.connect(path, isolation_level=None) + self.conn.row_factory = sqlite3.Row cursor = self.conn.cursor() - cursor.executescript(scripts['create_db_tables']['feeds']) - cursor.executescript(scripts['create_db_tables']['feeds_entries']) + cursor.executescript(scripts.create_db_tables['feeds']) + cursor.executescript(scripts.create_db_tables['feeds_entries']) + cursor.executescript(scripts.create_db_tables['feed_entry_links']) + cursor.executescript(scripts.create_db_tables['feed_entry_media']) cursor.close() @@ -44,7 +55,7 @@ def create_database(self, path: str) -> str: def get(self, table, columns, limit=100): - query = scripts.get('get').format(columns, table, limit) + query = scripts.get.format(columns, table, limit) self.cursor.execute(query) return self.cursor.fetchall() @@ -52,16 +63,19 @@ def get(self, table, columns, limit=100): def get_last(self, table, columns): return self.get(table, columns, limit=1)[0] - def where(self, table, column, value, type='=', limit=100): + def where(self, table: str, *where: list, limit: int=100): + + where = ' AND '.join('{} {} "{}" '.format(item[0], item[1], item[2]) for item in where) + + query = scripts.where.format(table, where, limit) - query = scripts.get('where').format(table, value, type, limit) self.cursor.execute(query) return self.cursor.fetchall() def find_where(self, table, column, value, type='='): - query = scripts.get('find_where').format(table, column, type,value) + query = scripts.find_where.format(table, column, type, value) self.cursor.execute(query) row = self.cursor.fetchone() @@ -70,14 +84,12 @@ def find_where(self, table, column, value, type='='): def write(self, table, columns, data): - query = scripts.get('write').format( + query = scripts.write.format( table, ', '.join(column for column in columns) , ', '.join( "'" + str(item) + "'" for item in data) ) self.cursor.execute(query) - return self.cursor.fetchall() or False - def query(self, sql, *args): self.cursor = self.conn.cursor() diff --git a/src/components/cache/db/sqlite_scripts.py b/src/components/cache/db/sqlite_scripts.py index 17da536..8c69efe 100644 --- a/src/components/cache/db/sqlite_scripts.py +++ b/src/components/cache/db/sqlite_scripts.py @@ -1,14 +1,18 @@ -scripts = { +from src.components.helper import Map + + +scripts = Map({ 'write': 'INSERT OR REPLACE INTO {0} ({1}) VALUES ({2});', - 'find_where': 'SELECT id FROM {0} WHERE {1}{2}\'{3}\';', - 'where': 'SELECT * FROM {0} WHERE {1}{2}\'{3}\' LIMIT {4};', + 'find_where': 'SELECT id FROM {0} WHERE {1} {2} \'{3}\';', + 'where': 'SELECT * FROM {0} WHERE {1} LIMIT {2};', 'get': 'SELECT {1} FROM {0} LIMIT {2};', - + 'create_db_tables': { 'feeds': ''' CREATE TABLE feeds( id integer PRIMARY KEY autoincrement, url text UNIQUE NOT NULL, + image text UNIQUE NOT NULL, encoding text NOT NULL ); CREATE UNIQUE index unique_feeds_url on feeds (url); @@ -20,9 +24,6 @@ title text NOT NULL, description text, link text UNIQUE NOT NULL, - - links text, - date text NOT NULL, published timestamp NOT NULL, FOREIGN KEY(feed_id) REFERENCES feeds ( id ) @@ -31,15 +32,30 @@ ); CREATE UNIQUE index unique_feeds_entries_link ON feeds_entries (link); ''', + 'feed_entry_links': ''' + CREATE TABLE feed_entry_links( + id integer PRIMARY KEY autoincrement, + feed_entry_id integer NOT NULL, + href text NOT NULL, + type text DEFAULT NULL, + FOREIGN KEY(feed_entry_id) + REFERENCES feeds_entries ( id ) + ON UPDATE CASCADE + ON DELETE CASCADE + ); + ''', + 'feed_entry_media': ''' + CREATE TABLE feed_entry_media( + id integer PRIMARY KEY autoincrement, + feed_entry_id integer NOT NULL, + url text NOT NULL, + additional text DEFAULT NULL , + FOREIGN KEY(feed_entry_id) + REFERENCES feeds_entries ( id ) + ON UPDATE CASCADE + ON DELETE CASCADE + ); + ''', }, - - 'load_news': ''' - SELECT fe.* - FROM feeds as f - JOIN feeds_entries as fe ON f.id = fe.feed_id - WHERE fe.published >= ? AND fe.published <= ? - ORDER BY fe.published DESC - LIMIT ? - ''' -} +}) diff --git a/src/components/converter/__init__.py b/src/components/converter/__init__.py new file mode 100644 index 0000000..12a2419 --- /dev/null +++ b/src/components/converter/__init__.py @@ -0,0 +1 @@ +from . import html \ No newline at end of file diff --git a/src/components/converter/converter_abstract.py b/src/components/converter/converter_abstract.py index 8c9a172..808acc2 100644 --- a/src/components/converter/converter_abstract.py +++ b/src/components/converter/converter_abstract.py @@ -8,12 +8,12 @@ class ConverterAbstract(ABC): - def __init__(self, path: str) -> None: + def __init__(self, path: str, limit: int) -> None: self._path_initialize(Path(path)) - + self._limit = limit @abstractmethod - def render(self, feeds_entries: list, title: str) -> str: + def render(self, feeds_entries: list, url: str, title: str) -> str: pass @abstractmethod @@ -29,6 +29,8 @@ def _path_initialize(self, path: Path): parents=True, exist_ok=True ) + else: + Logger.log(f'Caution - file {self._path} would be overriding') self._media_path = Path.home()\ .joinpath('.' + conf.__package__)\ @@ -41,9 +43,8 @@ def _path_initialize(self, path: Path): def _download_media(self, media_url: str) -> bool: - media_file = self._media_path.joinpath( - hash(media_url) - ) + exit(print(self._media_path.joinpath(hash(media_url)))) + media_file = self._media_path.joinpath(hash(media_url)) try: data = request.urlretrieve(media_url) diff --git a/src/components/converter/html/html_converter.py b/src/components/converter/html/html_converter.py index b261857..325cfe2 100644 --- a/src/components/converter/html/html_converter.py +++ b/src/components/converter/html/html_converter.py @@ -1,48 +1,56 @@ from src.components.converter.converter_abstract import ConverterAbstract from src.components.logger import Logger from src.components.feed import FeedEntry -from .templates import * +from src.components.converter.html import templates as template class HtmlConverter(ConverterAbstract): - def render(self, feeds_entries: list, title: str, encoding: str='UTF-8') -> str: + _extensions = ['html'] + + def render(self, feeds_entries: list, url: str, title: str, encoding: str = 'UTF-8') -> str: + + self._template = template render_feeds_entries = [] - for entry in feeds_entries: + for index, entry in zip(range(self._limit), feeds_entries): render_feeds_entries.append( - entry_templ.render( - # images=images_html, - title=entry.title, - date=entry.date, - text=entry.description, - link=entry.link, - links=entry.links - ) + self._entry_render(entry) ) self._save_render_file( - layout_templ.render( + self._template.layout.render( feeds_entries=render_feeds_entries, + url=url, title=title, encoding=encoding ) ) - def _media_render(self, entry: FeedEntry): - #make loop!! - media = self._download_media(entry) + def _media_render(self, media: list): - if not media: - return empty_media_templ.render() + media_output = [] - return media_templ.render(src=media, alt=entry.title) + for item in media: + media_file = self._download_media(item.url) - def _entry_render(self, entry: FeedEntry): - media = self._download_media(entry) + if not media_file: + return self._template.empty_media.render() + + media_output.append(self._template.media.render( + src=media['url'], alt=media['alt']) + ) + + return media_output - if not media: - return empty_media_templ.render() + def _entry_render(self, entry: FeedEntry): - return media_templ.render(src=media, alt=entry.title) + return self._template.entry.render( + media=self._media_render(entry.media), + title=entry.title, + date=entry.published, + text=entry.description, + link=entry.link, + links=entry.links + ) diff --git a/src/components/converter/html/templates/__init__.py b/src/components/converter/html/templates/__init__.py index 09e0ec3..1601d7f 100644 --- a/src/components/converter/html/templates/__init__.py +++ b/src/components/converter/html/templates/__init__.py @@ -1,4 +1,4 @@ -from src.components.converter.html.templates.layout import layout as layout_templ -from src.components.converter.html.templates.entry import entry as entry_templ -from src.components.converter.html.templates.media import media as media_templ -from src.components.converter.html.templates.empty_media import empty_media as empty_media_templ +from src.components.converter.html.templates.layout import layout +from src.components.converter.html.templates.entry import entry +from src.components.converter.html.templates.media import media +from src.components.converter.html.templates.empty_media import empty_media diff --git a/src/components/converter/html/templates/entry.py b/src/components/converter/html/templates/entry.py index 178ff90..ef4bff5 100644 --- a/src/components/converter/html/templates/entry.py +++ b/src/components/converter/html/templates/entry.py @@ -5,9 +5,9 @@

{{title}}

- # {% for img in images %} - # {{img}} - # {% endfor %} + {% for item in media %} + {{item}} + {% endfor %}

{{date}}

@@ -16,7 +16,7 @@

Links {% for link in links %} - {{link}} + {{link['href']}}
{% endfor %}
Source: {{link}} diff --git a/src/components/converter/html/templates/layout.py b/src/components/converter/html/templates/layout.py index c70863b..a12e8c1 100644 --- a/src/components/converter/html/templates/layout.py +++ b/src/components/converter/html/templates/layout.py @@ -8,6 +8,9 @@

{{title}}

+

+ {{url}} +

{% for entry in feeds_entries %} {{entry}} diff --git a/src/components/feed/feed.py b/src/components/feed/feed.py index 95e0ea4..8d8ffb8 100644 --- a/src/components/feed/feed.py +++ b/src/components/feed/feed.py @@ -1,5 +1,6 @@ import feedparser from datetime import timedelta +from src.components.helper import Map from src.components.feed.feed_entry import FeedEntry from src.components.feed.feed_formatter import FeedFormatter @@ -17,6 +18,10 @@ def entities_list(self): def feeds_title(self): return self._feeds_title + @property + def feeds_encoding(self): + return self._feeds_encoding + def __init__(self, args): self._is_json = args.json self._is_colorize = args.colorize @@ -27,28 +32,27 @@ def __init__(self, args): Logger.log('Initialize console variables') - self._pre_validate_params() - self._parse_feeds() - def _pre_validate_params(self): - - if self._cache_date and not Cache().get_specify_by_date(self._cache_date, self._limit): - raise Exception(f'There is no cached news ' - f'{self._cache_date.strftime("from %d, %b %Y")}' - f'{(self._cache_date + timedelta(days=1)).strftime(" to %d, %b %Y")}') - def show_feeds(self) -> object: - Logger.log(f'Preparation for output feeds. ' - f'Output type: {"JSON" if self._is_json else "DEFAULT"}. ' - f'Feeds choosen: {self._limit}') + Logger.log( + f'Preparation for output feeds. ' + f'Output type: {"JSON" if self._is_json else "DEFAULT"}. ' + f'Feeds choosen: {self._limit}' + ) FeedFormatter.is_json = self._is_json + top_data_output = Map({ + 'url': self._url, + 'title': self._feeds_title, + 'image': self._feeds_image + }) + output = FeedFormatter.generate_output( self._decide_output(), self._limit, - self._feeds_title, + top_data_output, self._is_colorize ) @@ -56,7 +60,7 @@ def show_feeds(self) -> object: def _decide_output(self): if self._cache_date: - return Cache().load_feeds_entries(self._cache_date, self._limit) + return Cache().load_feeds_entries(self._url, self._cache_date, self._limit) return self._entities_list @@ -64,29 +68,37 @@ def _parse_feeds(self): Logger.log(f'Start parsing data from url: {self._url}') - feed = feedparser.parse(self._url) + parse_data = feedparser.parse(self._url) + + if parse_data['bozo']: + raise ValueError("Bozo Exception. Wrong validate or no access to the Internet") - self._set_global_feed_data(feed) + self._set_global_feed_data(parse_data) Logger.log('Generate feeds instances') - for item in feed.entries: + for item in parse_data.entries: self._append_feed_entry(item) if self._entities_list: self._store_cache_instances() - def _set_global_feed_data(self, feed): + def _set_global_feed_data(self, parse_data): Logger.log('Setting global feed data') - self._feeds_title = feed.feed.title - self._feeds_encoding = feed.encoding + self._feeds_title = parse_data.feed.title + self._feeds_encoding = parse_data.encoding + self._feeds_image = parse_data.feed.image.href def _append_feed_entry(self, item): self._entities_list.append(FeedEntry(item)) def _store_cache_instances(self): - Cache().append_feeds({ + + cache_params = Map({ 'url': self._url, 'encoding': self._feeds_encoding, - }, self._entities_list) + 'image' : self._feeds_image + }) + + Cache().append_feeds(cache_params, self._entities_list) diff --git a/src/components/feed/feed_entry.py b/src/components/feed/feed_entry.py index c69972c..8d18c51 100644 --- a/src/components/feed/feed_entry.py +++ b/src/components/feed/feed_entry.py @@ -1,29 +1,35 @@ import html from bs4 import BeautifulSoup from datetime import datetime +from src.components.helper import Map class FeedEntry: + _soup: BeautifulSoup = BeautifulSoup + def __init__(self, entry): - self.title = html.unescape(entry.title) - self.description = self._process_description(entry.description) + self.link = entry.link - self.links: list= self._process_links(entry.links) - self.date = entry.published + self.title = html.unescape(entry.title) + self.description = self._process_description(entry.summary) self.published = self._process_published(entry) + self.links: list= self._process_links(entry.links) + self.media: list= self._process_media(entry.summary) + def _process_links(self, links): - def format_links(link, count): - return f'[{count}]: {link["href"]} ({link["type"]})\n' + return [link for link in links if link.get('href', False)] - return ''.join( - format_links(link, count) for count, link in enumerate(links, start=1) - ) + def _process_media(self, summary): + return [Map({ + 'url': media.get('src'), + 'alt': html.escape(media.get('alt', '')) + }) for media in self._soup(summary, 'lxml').find_all(['img']) if media.get('src', False)] def _process_description(self, description): return html.unescape( - BeautifulSoup(description, 'html.parser').get_text() + self._soup(description, 'lxml').get_text() ) def _process_published(self, entry): diff --git a/src/components/feed/feed_formatter.py b/src/components/feed/feed_formatter.py index 81fde27..546f825 100644 --- a/src/components/feed/feed_formatter.py +++ b/src/components/feed/feed_formatter.py @@ -1,6 +1,7 @@ import json from fabulous import image, color from fabulous.text import Text +from datetime import time, datetime class FeedFormatter: @@ -8,15 +9,15 @@ class FeedFormatter: is_json = False @classmethod - def generate_output(cls, feeds, limit, title, is_colorize=False): + def generate_output(cls, feeds, limit, top_data_output, is_colorize=False): if not cls.is_json: - return cls._default_output(feeds, limit, title, is_colorize) + return cls._default_output(feeds, limit, top_data_output, is_colorize) - return cls._json_output(feeds, limit, title) + return cls._json_output(feeds, limit, top_data_output) @classmethod - def _default_output(cls, feeds, limit, title, is_colorize): + def _default_output(cls, feeds, limit, top_data_output, is_colorize): if is_colorize: print(Text("Console Rss Reader!", fsize=19, color='#f44a41', shadow=False, skew=4)) @@ -24,40 +25,52 @@ def _default_output(cls, feeds, limit, title, is_colorize): else: formatted_feeds = ''.join(cls._single_feed_format_default(feed) for feed in feeds[:limit]) - return 'Feed: {0}\n\n{1}'.format(title, formatted_feeds) + if is_colorize: + return 'Feed: {0}\nUrl: {1}\n\n{2}'.format( + color.highlight_black(top_data_output.title), + color.highlight_red(top_data_output.url), + formatted_feeds + ) + + return 'Feed: {0}\nUrl: {1}\n\n{2}'.format( + f'——— {top_data_output.title} ———', + f'——— {top_data_output.url} ———', + formatted_feeds + ) @classmethod - def _json_output(cls, feeds, limit, title): + def _json_output(cls, feeds, limit, top_data_output): formatted_feeds = ',\n'.join(cls._single_feed_format_json(feed) for feed in feeds[:limit]) #tmp output = json.dumps({ - "title" : title, + "title" : top_data_output.title, "items" : formatted_feeds }, indent=4, sort_keys=True) return formatted_feeds @classmethod - def _single_feed_format_default(self,feed): + def _single_feed_format_default(cls,feed): return f'\ - \r{self._delimiter()}\n\n\ + \r{cls._delimiter()}\n\n\ \rTitle: {feed.title}\n\ - \rDate: {feed.date}\n\ - \rLink: {feed.link}\n\n\ + \rDate: {cls.human_date(feed.published)}\n\ + \rLink:{feed.link}\n\n\ \r{feed.description}\n\n\ - \rLinks:\n\r{feed.links}\n' + \rMedia: {cls.format_media(feed.media)}\n\ + \rLinks: {cls.format_links(feed.links)}\n' @classmethod - def _colorize_single_feed_format_default(self, feed): + def _colorize_single_feed_format_default(cls, feed): return f'\ - \r{color.highlight_red(self._delimiter())}\n\n\ + \r{color.highlight_red(cls._delimiter())}\n\n\ \r{color.italic(color.magenta("Title"))}: {color.highlight_magenta(feed.title)}\n\ - \r{color.bold(color.yellow("Date"))}: {color.highlight_yellow(feed.date)}\n\ + \r{color.bold(color.yellow("Date"))}: {color.highlight_yellow(cls.human_date(feed.published))}\n\ \r{color.bold(color.blue("Link"))}: {color.highlight_blue(feed.link)}\n\n\ \r{color.highlight_green(feed.description)}\n\n\ - \r{color.bold("Links")}:\n\r{color.bold(feed.links)}\n' - + \r{color.bold(color.blue("Media"))}: {color.bold(cls.format_media(feed.media))}\n\ + \r{color.bold(color.blue("Links"))}: {color.bold(cls.format_links(feed.links))}\n' @classmethod def _single_feed_format_json(cls, feed): @@ -66,13 +79,49 @@ def _single_feed_format_json(cls, feed): "link": feed.link, "body": { "title": feed.title, - "date": feed.date, - "links": feed.links, + "date": cls.human_date(feed.published), + "links": cls.format_links(feed.links), "description": feed.description } } }, indent=4) + + @staticmethod + def format_links(links: list) -> str: + + if not links: + return '———— No data ————' + + def formatted(link, count): + return f'[{count}] {link["href"]} ({link["type"]})\n' + + return ''.join( + formatted(link, count) for count, link in enumerate(links, start=1) + ) + + @staticmethod + def format_media(media: list) -> str: + + if not media: + return '———— No data ————' + + def formatted(media): + return f' {media["url"]}\n' + + return ''.join(formatted(item) for item in media) + + @staticmethod + def human_date(date): + if isinstance(date, type('str')): + return datetime.strptime(date, "%Y-%m-%d %H:%M:%S") + + return date.strftime("%a, %d %b %Y %H:%M:%S %z") + @staticmethod def _delimiter(): return ''.join('#' * 100) + + @staticmethod + def _delimiter_seondary(): + return ''.join('—' * 50) diff --git a/src/components/helper/map.py b/src/components/helper/map.py index c9649b6..89bdc30 100644 --- a/src/components/helper/map.py +++ b/src/components/helper/map.py @@ -3,11 +3,11 @@ def __init__(self, *args, **kwargs): super(Map, self).__init__(*args, **kwargs) for arg in args: if isinstance(arg, dict): - for k, v in arg.iteritems(): + for k, v in arg.items(): self[k] = v if kwargs: - for k, v in kwargs.iteritems(): + for k, v in kwargs.items(): self[k] = v def __getattr__(self, attr): diff --git a/src/components/logger/logger.py b/src/components/logger/logger.py index c01fc04..61421b7 100644 --- a/src/components/logger/logger.py +++ b/src/components/logger/logger.py @@ -12,7 +12,7 @@ class Logger(Singleton): @classmethod def initialize(cls, is_colorize): - with open(Path.cwd().joinpath('conf.yml'), 'r') as file: + with open(Path(__file__).parent.joinpath('conf.yml'), 'r') as file: config = yaml.safe_load(file.read()) logging.config.dictConfig(config) diff --git a/src/components/parser/arguments/optional/to_html.py b/src/components/parser/arguments/optional/to_html.py index a0050af..b362946 100644 --- a/src/components/parser/arguments/optional/to_html.py +++ b/src/components/parser/arguments/optional/to_html.py @@ -11,12 +11,7 @@ def add_argument(self): ) def _validate_path(self, path): - try: - - if Path(path).exists(): - print(f'Caution - file {path} would be overriding') - return Path(path) except argparse.ArgumentTypeError: diff --git a/src/rss_reader.py b/src/rss_reader.py index 0ddc7e2..d157860 100644 --- a/src/rss_reader.py +++ b/src/rss_reader.py @@ -6,7 +6,6 @@ import conf - class App(Singleton): def __init__(self) -> None: @@ -23,8 +22,8 @@ def __init__(self) -> None: self._feed = Feed(self._console_args) if self._console_args.to_html: - HtmlConverter(self._console_args.to_html).render( - self._feed.entities_list, self._feed.feeds_title + HtmlConverter(self._console_args.to_html, self._console_args.limit).render( + self._feed.entities_list, self._console_args.source, self._feed.feeds_title, self._feed.feeds_encoding ) @classmethod @@ -36,7 +35,7 @@ def main(): try: App.start() except KeyboardInterrupt: - Logger.log_error('Stop reader') + Logger.log_error('\nStop Rss-Reader') if __name__ == "__main__": From 9d92b67d7e4aaaac38979f960dc44a058d4d0d18 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 1 Dec 2019 09:53:06 +0300 Subject: [PATCH 15/26] complete converter pdf and improve converter by jinja templates. add --to-pdf parametr. create default onverter templates --- requirements.txt | 3 +- src/components/cache/cache.py | 4 +- src/components/converter/__init__.py | 3 +- .../converter/converter_abstract.py | 33 ++++++++--- .../converter/html/html_converter.py | 39 ++++++++----- .../converter/html/templates/__init__.py | 4 -- .../html/templates/empty_media.html.jinja2 | 3 + .../converter/html/templates/empty_media.py | 7 --- .../html/templates/entry.html.jinja2 | 30 ++++++++++ .../converter/html/templates/entry.py | 26 --------- .../html/templates/layout.html.jinja2 | 26 +++++++++ .../converter/html/templates/layout.py | 21 ------- .../html/templates/media.html.jinja2 | 3 + .../converter/html/templates/media.py | 7 --- .../converter/html/templates/style.css.jinja2 | 57 +++++++++++++++++++ src/components/converter/pdf/__init__.py | 1 + src/components/converter/pdf/pdf_converter.py | 15 +++++ src/components/feed/__init__.py | 1 + src/components/feed/feed.py | 26 +++++++-- src/components/parser/arguments/__init__.py | 1 + .../parser/arguments/arguments_abstract.py | 15 +++++ .../parser/arguments/optional/to_html.py | 12 +--- .../parser/arguments/optional/to_pdf.py | 13 +++++ src/components/parser/parser.py | 1 + src/rss_reader.py | 8 ++- 25 files changed, 251 insertions(+), 108 deletions(-) delete mode 100644 src/components/converter/html/templates/__init__.py create mode 100644 src/components/converter/html/templates/empty_media.html.jinja2 delete mode 100644 src/components/converter/html/templates/empty_media.py create mode 100644 src/components/converter/html/templates/entry.html.jinja2 delete mode 100644 src/components/converter/html/templates/entry.py create mode 100644 src/components/converter/html/templates/layout.html.jinja2 delete mode 100644 src/components/converter/html/templates/layout.py create mode 100644 src/components/converter/html/templates/media.html.jinja2 delete mode 100644 src/components/converter/html/templates/media.py create mode 100644 src/components/converter/html/templates/style.css.jinja2 create mode 100644 src/components/converter/pdf/__init__.py create mode 100644 src/components/converter/pdf/pdf_converter.py create mode 100644 src/components/parser/arguments/optional/to_pdf.py diff --git a/requirements.txt b/requirements.txt index bfb48ec..30f94df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ feedparser==5.2.1 bs4==0.0.1 coloredlogs==10.0.0 fabulous==0.3.0 -jinja2==2.10.3 \ No newline at end of file +jinja2==2.10.3 +WeasyPrint==50 \ No newline at end of file diff --git a/src/components/cache/cache.py b/src/components/cache/cache.py index 9a12c06..3adb9cb 100644 --- a/src/components/cache/cache.py +++ b/src/components/cache/cache.py @@ -52,9 +52,9 @@ def append_feeds(self, feed: Map, feed_entities_list: list) -> None: for feed_entry in feed_entities_list: if not self._db.find_where('feeds_entries', 'link', feed_entry.link, 'like'): - Logger.log(f'Caching feed {color.blue(feed_entry.title)} INSERTED') + Logger.log(f'Caching feed {color.blue(feed_entry.link)} INSERTED') else: - Logger.log(f'Caching feed {color.blue(feed_entry.title)} UPDATED') + Logger.log(f'Caching feed {color.blue(feed_entry.link)} UPDATED') self._insert_feed_entry_into_cache(feed_id, feed_entry) diff --git a/src/components/converter/__init__.py b/src/components/converter/__init__.py index 12a2419..c2ca1c0 100644 --- a/src/components/converter/__init__.py +++ b/src/components/converter/__init__.py @@ -1 +1,2 @@ -from . import html \ No newline at end of file +from . import html +from . import pdf \ No newline at end of file diff --git a/src/components/converter/converter_abstract.py b/src/components/converter/converter_abstract.py index 808acc2..9cf489d 100644 --- a/src/components/converter/converter_abstract.py +++ b/src/components/converter/converter_abstract.py @@ -3,23 +3,38 @@ from pathlib import Path import conf import urllib.request as request +from src.components.feed import Feed from src.components.feed import FeedEntry +import jinja2 class ConverterAbstract(ABC): + _media_img_ext = '.jpg' + def __init__(self, path: str, limit: int) -> None: self._path_initialize(Path(path)) self._limit = limit @abstractmethod - def render(self, feeds_entries: list, url: str, title: str) -> str: + def render(self, feed: Feed) -> str: pass @abstractmethod def _entry_render(self, entry: FeedEntry): pass + @abstractmethod + def _media_render(self, entry: FeedEntry): + pass + + def _init_template_processor(self, template_path: str): + path = str(Path(__file__).parent.joinpath(template_path).as_posix()) + + self._template_processor = jinja2.Environment( + loader=jinja2.FileSystemLoader(path), trim_blocks=True + ) + def _path_initialize(self, path: Path): self._path = path @@ -43,20 +58,24 @@ def _path_initialize(self, path: Path): def _download_media(self, media_url: str) -> bool: - exit(print(self._media_path.joinpath(hash(media_url)))) - media_file = self._media_path.joinpath(hash(media_url)) + media_file_name = str(abs(hash(media_url)) % 10 ** 10) + + media_file = self._media_path.joinpath(media_file_name + self._media_img_ext) try: - data = request.urlretrieve(media_url) + request.urlretrieve(media_url, media_file) + media_file.chmod(0o755) + except(request.HTTPError, request.URLError): Logger.log(f'Image with url {media_url} did not download') return False - with open(media_file, 'wb') as file: - file.write(data.content) - return media_file def _save_render_file(self, output, encoding: str='UTF-8') -> None: + with open(self._path, 'w', encoding=encoding) as file: file.write(output) + + Path(self._path).chmod(0o755) + diff --git a/src/components/converter/html/html_converter.py b/src/components/converter/html/html_converter.py index 325cfe2..73b6605 100644 --- a/src/components/converter/html/html_converter.py +++ b/src/components/converter/html/html_converter.py @@ -1,33 +1,42 @@ from src.components.converter.converter_abstract import ConverterAbstract from src.components.logger import Logger +from src.components.feed import Feed from src.components.feed import FeedEntry -from src.components.converter.html import templates as template +from pathlib import Path +import sys class HtmlConverter(ConverterAbstract): - _extensions = ['html'] + _log_Converter = 'HTML' + _template_path = Path(__file__).parent.joinpath('templates') - def render(self, feeds_entries: list, url: str, title: str, encoding: str = 'UTF-8') -> str: - - self._template = template + def render(self, feed: Feed) -> str: + Logger.log(f'Initialize {self._log_Converter} converter render') + self._init_template_processor(self._template_path); render_feeds_entries = [] - for index, entry in zip(range(self._limit), feeds_entries): + for index, entry in zip(range(self._limit), feed.entities_list): render_feeds_entries.append( self._entry_render(entry) ) + Logger.log('Process all render entries') + self._save_render_file( - self._template.layout.render( + self._template_processor.get_template('layout.html.jinja2').render( feeds_entries=render_feeds_entries, - url=url, - title=title, - encoding=encoding + url=feed.feeds_url, + logo=self._download_media(feed.feeds_image), + title=feed.feeds_title, + encoding=feed.feeds_encoding, ) ) + Logger.log(f'{self._log_Converter} render complete. You can check it in: {self._path}') + sys.exit(1) + def _media_render(self, media: list): media_output = [] @@ -36,21 +45,21 @@ def _media_render(self, media: list): media_file = self._download_media(item.url) if not media_file: - return self._template.empty_media.render() + return self._template_processor.get_template('empty_media.html.jinja2').render() - media_output.append(self._template.media.render( - src=media['url'], alt=media['alt']) + media_output.append(self._template_processor.get_template('media.html.jinja2').render( + src=item.get('url', ''), alt=item.get('alt', '')) ) return media_output def _entry_render(self, entry: FeedEntry): - return self._template.entry.render( + return self._template_processor.get_template('entry.html.jinja2').render( media=self._media_render(entry.media), title=entry.title, date=entry.published, - text=entry.description, + description=entry.description, link=entry.link, links=entry.links ) diff --git a/src/components/converter/html/templates/__init__.py b/src/components/converter/html/templates/__init__.py deleted file mode 100644 index 1601d7f..0000000 --- a/src/components/converter/html/templates/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from src.components.converter.html.templates.layout import layout -from src.components.converter.html.templates.entry import entry -from src.components.converter.html.templates.media import media -from src.components.converter.html.templates.empty_media import empty_media diff --git a/src/components/converter/html/templates/empty_media.html.jinja2 b/src/components/converter/html/templates/empty_media.html.jinja2 new file mode 100644 index 0000000..3db6313 --- /dev/null +++ b/src/components/converter/html/templates/empty_media.html.jinja2 @@ -0,0 +1,3 @@ +
+ Image for this block did not download! +
diff --git a/src/components/converter/html/templates/empty_media.py b/src/components/converter/html/templates/empty_media.py deleted file mode 100644 index 067664d..0000000 --- a/src/components/converter/html/templates/empty_media.py +++ /dev/null @@ -1,7 +0,0 @@ -from jinja2 import Template - -empty_media = Template(''' -
- Image for this block did not download! -
+

+ {{ title }} +

+
+
+ {% for item in media %} + {{ item }} + {% endfor %} +
+
+

+ {{ description }} +

+ links + {% for link in links %} + {{ link['href'] }}
+ {% endfor %} +
+ source: {{ link }} +
+

+ {{ date }} +

+
+
+
+
+
+
diff --git a/src/components/converter/html/templates/entry.py b/src/components/converter/html/templates/entry.py deleted file mode 100644 index ef4bff5..0000000 --- a/src/components/converter/html/templates/entry.py +++ /dev/null @@ -1,26 +0,0 @@ -from jinja2 import Template - -entry = Template(''' -
-

{{title}}

-
-
- {% for item in media %} - {{item}} - {% endfor %} -
-
-

{{date}}

-

- {{description}} -

- Links - {% for link in links %} - {{link['href']}}
- {% endfor %} -
- Source: {{link}} -
-
-
-''') diff --git a/src/components/converter/html/templates/layout.html.jinja2 b/src/components/converter/html/templates/layout.html.jinja2 new file mode 100644 index 0000000..bee515a --- /dev/null +++ b/src/components/converter/html/templates/layout.html.jinja2 @@ -0,0 +1,26 @@ + + + + {{ title }} + + +
+
+

{{ title }}

+

+ {{ url }} +

+ {% if logo %} + + {% endif %} +
+
+ {% for entry in feeds_entries %} + {{ entry }} + {% endfor %} +
+
+ + diff --git a/src/components/converter/html/templates/layout.py b/src/components/converter/html/templates/layout.py deleted file mode 100644 index a12e8c1..0000000 --- a/src/components/converter/html/templates/layout.py +++ /dev/null @@ -1,21 +0,0 @@ -from jinja2 import Template - -layout = Template(''' - - - - {{title}} - - -

{{title}}

-

- {{url}} -

-
- {% for entry in feeds_entries %} - {{entry}} - {% endfor %} -
- - -''') diff --git a/src/components/converter/html/templates/media.html.jinja2 b/src/components/converter/html/templates/media.html.jinja2 new file mode 100644 index 0000000..71664a5 --- /dev/null +++ b/src/components/converter/html/templates/media.html.jinja2 @@ -0,0 +1,3 @@ +
+ {{alt}} +
diff --git a/src/components/converter/html/templates/media.py b/src/components/converter/html/templates/media.py deleted file mode 100644 index 7c65525..0000000 --- a/src/components/converter/html/templates/media.py +++ /dev/null @@ -1,7 +0,0 @@ -from jinja2 import Template - -media = Template(''' -
- {{alt}} -
None: self._feed = Feed(self._console_args) if self._console_args.to_html: - HtmlConverter(self._console_args.to_html, self._console_args.limit).render( - self._feed.entities_list, self._console_args.source, self._feed.feeds_title, self._feed.feeds_encoding - ) + HtmlConverter(self._console_args.to_html, self._console_args.limit).render(self._feed) + + if self._console_args.to_pdf: + PdfConverter(self._console_args.to_pdf, self._console_args.limit).render(self._feed) @classmethod def start(cls) -> object: From ebf3f5f676117dabf582b17a8940acd2e6ac7551 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 1 Dec 2019 19:54:59 +0300 Subject: [PATCH 16/26] fixing json output parser. Add docstrings to several modules --- .../converter/html/html_converter.py | 5 +- .../html/templates/entry.html.jinja2 | 13 +- .../converter/html/templates/style.css.jinja2 | 6 +- src/components/feed/feed.py | 76 +++++++-- src/components/feed/feed_entry.py | 44 ++++- src/components/feed/feed_formatter.py | 159 +++++++++++++----- src/components/logger/logger.py | 34 +++- .../parser/arguments/optional/to_html.py | 2 +- src/rss_reader.py | 9 +- ~/test/test3.html | 111 ++++++++++++ 10 files changed, 373 insertions(+), 86 deletions(-) create mode 100755 ~/test/test3.html diff --git a/src/components/converter/html/html_converter.py b/src/components/converter/html/html_converter.py index 73b6605..e02d475 100644 --- a/src/components/converter/html/html_converter.py +++ b/src/components/converter/html/html_converter.py @@ -12,7 +12,10 @@ class HtmlConverter(ConverterAbstract): _template_path = Path(__file__).parent.joinpath('templates') def render(self, feed: Feed) -> str: - Logger.log(f'Initialize {self._log_Converter} converter render') + Logger.log( + f'Converter option choosen. Default output was declined.\n' + f'Initialize {self._log_Converter} converter render' + ) self._init_template_processor(self._template_path); render_feeds_entries = [] diff --git a/src/components/converter/html/templates/entry.html.jinja2 b/src/components/converter/html/templates/entry.html.jinja2 index 812bd2d..9b3e5e2 100644 --- a/src/components/converter/html/templates/entry.html.jinja2 +++ b/src/components/converter/html/templates/entry.html.jinja2 @@ -12,11 +12,14 @@

{{ description }}

- links - {% for link in links %} - {{ link['href'] }}
- {% endfor %} -
+ links: + +
+ {% for link in links %} + {{ link['href'] }}
+ {% endfor %} +
+ source: {{ link }}

diff --git a/src/components/converter/html/templates/style.css.jinja2 b/src/components/converter/html/templates/style.css.jinja2 index 17536df..6f9e9b1 100644 --- a/src/components/converter/html/templates/style.css.jinja2 +++ b/src/components/converter/html/templates/style.css.jinja2 @@ -10,10 +10,10 @@ font-size: 6pt; } @bottom-center { - color: #b9a; - content: 'rss-reader '; + color: #333; + content: "Page " counter(page) " of " counter(pages); font-family: Pacifico; - font-size: 6pt; + font-size: 13pt; } @bottom-right { color: #c1a; diff --git a/src/components/feed/feed.py b/src/components/feed/feed.py index 3faaa6b..85a22ff 100644 --- a/src/components/feed/feed.py +++ b/src/components/feed/feed.py @@ -1,7 +1,9 @@ +"""Module contain classes for feed parsing logic and formatting feeds into appropriate mods""" + import feedparser +from argparse import ArgumentParser from abc import ABC from src.components.helper import Map - from src.components.feed.feed_entry import FeedEntry from src.components.feed.feed_formatter import FeedFormatter from src.components.logger.logger import Logger @@ -9,30 +11,42 @@ class FeedProperty(ABC): + """Trait for Feed class.Contain all properties, witch Feed use out of class """ + @property - def entities_list(self): + def entities_list(self) -> list: + """Property provide value of feed parsed or cached entities""" return self._decide_output() @property - def feeds_title(self): + def feeds_title(self) -> str: + """Property provide value of feed general title""" return self._feeds_title @property - def feeds_url(self): + def feeds_url(self) -> str: + """Property provide value of feed origin url""" return self._url @property - def feeds_image(self): + def feeds_image(self) -> str: + """Property provide value of feed image url""" return self._feeds_image @property - def feeds_encoding(self): + def feeds_encoding(self)-> str: + """Property provide value of feed encoding""" return self._feeds_encoding class Feed(FeedProperty): + """Feed class """ - def __init__(self, args): + def __init__(self, args: ArgumentParser) -> None: + """ + This method initialize start required data for Feed class and call parser to parse feeds + :param args: ArgumentParser + """ self._is_json = args.json self._is_colorize = args.colorize self._cache_date = args.date @@ -44,7 +58,11 @@ def __init__(self, args): self._parse_feeds() - def show_feeds(self) -> object: + def show_feeds(self) -> None: + """ + This method using for output processed data into console into appropriate way + :return: None + """ Logger.log( f'Preparation for output feeds. ' f'Output type: {"JSON" if self._is_json else "DEFAULT"}. ' @@ -56,7 +74,8 @@ def show_feeds(self) -> object: top_data_output = Map({ 'url': self._url, 'title': self._feeds_title, - 'image': self._feeds_image + 'image': self._feeds_image, + 'encoding': self._feeds_encoding }) output = FeedFormatter.generate_output( @@ -68,14 +87,22 @@ def show_feeds(self) -> object: print(output) - def _decide_output(self): + def _decide_output(self) -> list: + """ + This method realize which data will be ensure to use - cache or just parsed + :return: List + """ if self._cache_date: return Cache().load_feeds_entries(self._url, self._cache_date, self._limit) return self._entities_list - def _parse_feeds(self): - + def _parse_feeds(self) -> None: + """ + This method parsing feeds from provided url and process calls + append entries to entries list and store to cache + :return: None + """ Logger.log(f'Start parsing data from url: {self._url}') parse_data = feedparser.parse(self._url) @@ -93,7 +120,12 @@ def _parse_feeds(self): if self._entities_list: self._store_cache_instances() - def _set_global_feed_data(self, parse_data): + def _set_global_feed_data(self, parse_data: feedparser.FeedParserDict) -> None: + """ + This method set all global feed data to Feed instatance + :param parse_data: feedparser.FeedParserDict + :return: None + """ Logger.log('Setting global feed data') self._feeds_title = parse_data.feed.title @@ -106,11 +138,19 @@ def _set_global_feed_data(self, parse_data): self._feeds_image = '' Logger.log('Cannot find feed image.') - def _append_feed_entry(self, item): - self._entities_list.append(FeedEntry(item)) - - def _store_cache_instances(self): - + def _append_feed_entry(self, entry: feedparser.FeedParserDict) -> None: + """ + This method wrap feed parser entry into FeedEntry class and append it to Feed list + :param entry: feedparser.FeedParserDict + :return: None + """ + self._entities_list.append(FeedEntry(entry)) + + def _store_cache_instances(self) -> None: + """ + This method initialize Cache module and provide data to store in cache storage + :return: None + """ cache_params = Map({ 'url': self._url, 'encoding': self._feeds_encoding, diff --git a/src/components/feed/feed_entry.py b/src/components/feed/feed_entry.py index 8d18c51..eced41c 100644 --- a/src/components/feed/feed_entry.py +++ b/src/components/feed/feed_entry.py @@ -1,15 +1,27 @@ +"""This module contain classe for structuring feeds entries""" + import html +import feedparser from bs4 import BeautifulSoup from datetime import datetime from src.components.helper import Map class FeedEntry: + """ + This class represents feed entry structure and preprocess some entry values - _soup: BeautifulSoup = BeautifulSoup + Attributes: + _soup attribute provide access for work with BeautifulSoup interface + """ - def __init__(self, entry): + _soup: BeautifulSoup = BeautifulSoup + def __init__(self, entry: feedparser.FeedParserDict) -> None: + """ + This constructor init demand data for feed entry and formatting it + :param entry: feedparser.FeedParserDict + """ self.link = entry.link self.title = html.unescape(entry.title) self.description = self._process_description(entry.summary) @@ -18,19 +30,39 @@ def __init__(self, entry): self.links: list= self._process_links(entry.links) self.media: list= self._process_media(entry.summary) - def _process_links(self, links): + def _process_links(self, links) -> list: + """ + Getting entry links and processing to check links + :param links: + :return: + """ return [link for link in links if link.get('href', False)] - def _process_media(self, summary): + def _process_media(self, summary: str) -> list: + """ + Getting entry text and retrieve media data from it + :param summary: + :return: + """ return [Map({ 'url': media.get('src'), 'alt': html.escape(media.get('alt', '')) }) for media in self._soup(summary, 'lxml').find_all(['img']) if media.get('src', False)] - def _process_description(self, description): + def _process_description(self, description:str) -> str: + """ + Getting entry text and formatting it into more readable format + :param description: + :return: str + """ return html.unescape( self._soup(description, 'lxml').get_text() ) - def _process_published(self, entry): + def _process_published(self, entry: feedparser.FeedParserDict) -> str: + """ + Retrieve tuple of published data and process it into readable format + :param entry: feedparser.FeedParserDict + :return: str + """ return datetime(*entry.published_parsed[:6]) diff --git a/src/components/feed/feed_formatter.py b/src/components/feed/feed_formatter.py index 546f825..322be32 100644 --- a/src/components/feed/feed_formatter.py +++ b/src/components/feed/feed_formatter.py @@ -1,29 +1,51 @@ +"""this module contain class for various formatter output parsing data in console """ + import json from fabulous import image, color from fabulous.text import Text from datetime import time, datetime +from src.components.helper import Map class FeedFormatter: + """ + This class provide a way for formatting data into console depending on the selected options + Attributes: + is_json store state of output case + """ is_json = False @classmethod - def generate_output(cls, feeds, limit, top_data_output, is_colorize=False): - + def generate_output(cls, entries: list, limit: int, top_data_output: Map, is_colorize: bool=False) -> str: + """ + This method decide witch way rss feed should be printed + :param entries: list + :param limit: int + :param top_data_output: Map + :param is_colorize: bool + :return: + """ if not cls.is_json: - return cls._default_output(feeds, limit, top_data_output, is_colorize) + return cls._default_output(entries, limit, top_data_output, is_colorize) - return cls._json_output(feeds, limit, top_data_output) + return cls._json_output(entries, limit, top_data_output) @classmethod - def _default_output(cls, feeds, limit, top_data_output, is_colorize): - + def _default_output(cls, entries: list, limit: int, top_data_output: Map, is_colorize) -> str: + """ + This method render data for default output case + :param entries: list + :param limit: int + :param top_data_output: Map + :param is_colorize: bool + :return: + """ if is_colorize: print(Text("Console Rss Reader!", fsize=19, color='#f44a41', shadow=False, skew=4)) - formatted_feeds = ''.join(cls._colorize_single_feed_format_default(feed) for feed in feeds[:limit]) + formatted_feeds = ''.join(cls._colorize_single_feed_format_default(feed) for feed in entries[:limit]) else: - formatted_feeds = ''.join(cls._single_feed_format_default(feed) for feed in feeds[:limit]) + formatted_feeds = ''.join(cls._single_feed_format_default(feed) for feed in entries[:limit]) if is_colorize: return 'Feed: {0}\nUrl: {1}\n\n{2}'.format( @@ -39,57 +61,84 @@ def _default_output(cls, feeds, limit, top_data_output, is_colorize): ) @classmethod - def _json_output(cls, feeds, limit, top_data_output): - formatted_feeds = ',\n'.join(cls._single_feed_format_json(feed) for feed in feeds[:limit]) - - #tmp - output = json.dumps({ - "title" : top_data_output.title, - "items" : formatted_feeds - }, indent=4, sort_keys=True) - - return formatted_feeds + def _json_output(cls, entries: list, limit: int, top_data_output: Map) -> str: + """ + This method render data for json output case + :param entries: list + :param limit: int + :param top_data_output: Map + :return: + """ + formatted_feeds = [cls._single_feed_format_json(feed) for feed in entries[:limit]] + + output = json.dumps({ + "title": top_data_output.title, + "url": top_data_output.url, + "image": top_data_output.image, + "entries" : formatted_feeds, + }, indent=2, sort_keys=False) + + return output.encode(top_data_output.encoding).decode() @classmethod - def _single_feed_format_default(cls,feed): + def _single_feed_format_default(cls, entry: object) ->str: + """ + This method render single entry for default output + :param entry: object + :return: str + """ return f'\ \r{cls._delimiter()}\n\n\ - \rTitle: {feed.title}\n\ - \rDate: {cls.human_date(feed.published)}\n\ - \rLink:{feed.link}\n\n\ - \r{feed.description}\n\n\ - \rMedia: {cls.format_media(feed.media)}\n\ - \rLinks: {cls.format_links(feed.links)}\n' + \rTitle: {entry.title}\n\ + \rDate: {cls.human_date(entry.published)}\n\ + \rLink:{entry.link}\n\n\ + \r{entry.description}\n\n\ + \rMedia: {cls.format_media(entry.media)}\n\ + \rLinks: {cls.format_links(entry.links)}\n' @classmethod - def _colorize_single_feed_format_default(cls, feed): + def _colorize_single_feed_format_default(cls, entry: object) -> str: + """ + This method render single entry for default output with colorizee option + :param entry: object + :return: str + """ return f'\ \r{color.highlight_red(cls._delimiter())}\n\n\ - \r{color.italic(color.magenta("Title"))}: {color.highlight_magenta(feed.title)}\n\ - \r{color.bold(color.yellow("Date"))}: {color.highlight_yellow(cls.human_date(feed.published))}\n\ - \r{color.bold(color.blue("Link"))}: {color.highlight_blue(feed.link)}\n\n\ - \r{color.highlight_green(feed.description)}\n\n\ - \r{color.bold(color.blue("Media"))}: {color.bold(cls.format_media(feed.media))}\n\ - \r{color.bold(color.blue("Links"))}: {color.bold(cls.format_links(feed.links))}\n' + \r{color.italic(color.magenta("Title"))}: {color.highlight_magenta(entry.title)}\n\ + \r{color.bold(color.yellow("Date"))}: {color.highlight_yellow(cls.human_date(entry.published))}\n\ + \r{color.bold(color.blue("Link"))}: {color.highlight_blue(entry.link)}\n\n\ + \r{color.highlight_green(entry.description)}\n\n\ + \r{color.bold(color.blue("Media"))}: {color.bold(cls.format_media(entry.media))}\n\ + \r{color.bold(color.blue("Links"))}: {color.bold(cls.format_links(entry.links))}\n' @classmethod - def _single_feed_format_json(cls, feed): - return json.dumps({ - "item": { - "link": feed.link, + def _single_feed_format_json(cls, entry: object) -> str: + """ + This method render single entry for json output + :param entry: object + :return: str + """ + return { + "entry": { + "link": entry.link, "body": { - "title": feed.title, - "date": cls.human_date(feed.published), - "links": cls.format_links(feed.links), - "description": feed.description + "title": entry.title, + "date": str(cls.human_date(entry.published)), + "links": cls.format_links(entry.links), + "description": entry.description } } - }, indent=4) + } @staticmethod def format_links(links: list) -> str: - + """ + This static method beautifying provided links + :param entry: object + :return: str + """ if not links: return '———— No data ————' @@ -102,6 +151,11 @@ def formatted(link, count): @staticmethod def format_media(media: list) -> str: + """ + This static method beautifying provided media urls + :param media:list + :return: str + """ if not media: return '———— No data ————' @@ -112,16 +166,29 @@ def formatted(media): return ''.join(formatted(item) for item in media) @staticmethod - def human_date(date): + def human_date(date) -> datetime: + """ + This static method provide more readable for human date format + :param date: + :return: datetime + """ if isinstance(date, type('str')): return datetime.strptime(date, "%Y-%m-%d %H:%M:%S") - return date.strftime("%a, %d %b %Y %H:%M:%S %z") + return date.strftime("%a, %d %b %Y - [%H:%M:%S]") @staticmethod - def _delimiter(): + def _delimiter() -> str: + """ + This static method provide simple delimiter between feeds entries + :return: str + """ return ''.join('#' * 100) @staticmethod - def _delimiter_seondary(): + def _delimiter_secondary() -> str: + """ + This static method provide second variant of simple delimiter between feeds entries + :return: str + """ return ''.join('—' * 50) diff --git a/src/components/logger/logger.py b/src/components/logger/logger.py index 61421b7..2c591c5 100644 --- a/src/components/logger/logger.py +++ b/src/components/logger/logger.py @@ -1,3 +1,5 @@ +"""this module contain logger module for logging in console""" + import logging import logging.config import yaml @@ -5,13 +7,25 @@ import coloredlogs from pathlib import Path + class Logger(Singleton): - logger_name = 'standard' + """ + Logger class using for wrap logger and provide more convinient approach for logging - @classmethod - def initialize(cls, is_colorize): + Attributes: + logger_name logger_name containt logger settings default name + """ + logger_name: str='standard' + + @classmethod + def initialize(cls, is_colorize: bool) -> None : + """ + This method initalize logger module for logging in project. Is_colorize using for decide is color cli output + :param is_colorize: bool + :return: None + """ with open(Path(__file__).parent.joinpath('conf.yml'), 'r') as file: config = yaml.safe_load(file.read()) logging.config.dictConfig(config) @@ -30,11 +44,21 @@ def initialize(cls, is_colorize): ) @classmethod - def log(cls, message: str): + def log(cls, message: str) -> None: + """ + This method wrap Logger info method + :param message: str + :return: None + """ if getattr(cls, '_logger', None) is not None: cls._logger.info(message) @classmethod - def log_error(cls, message: str): + def log_error(cls, message: str) -> None: + """ + This method wrap Logger error method + :param message: str + :return: None + """ if getattr(cls, '_logger', None) is not None: cls()._logger.error(message) diff --git a/src/components/parser/arguments/optional/to_html.py b/src/components/parser/arguments/optional/to_html.py index 1a4175a..ea9231c 100644 --- a/src/components/parser/arguments/optional/to_html.py +++ b/src/components/parser/arguments/optional/to_html.py @@ -1,5 +1,5 @@ from src.components.parser.arguments.arguments_abstract import ArgumentsAbstract - +import argparse class ToHtml(ArgumentsAbstract): diff --git a/src/rss_reader.py b/src/rss_reader.py index d366320..111edb9 100644 --- a/src/rss_reader.py +++ b/src/rss_reader.py @@ -8,8 +8,12 @@ class App(Singleton): + """General class of rss-reader. Implements all classes and run utility""" def __init__(self) -> None: + """ + This constructor parse program arguments, initialize all module params decide witch logic to run + """ console = Parser( 'Pure Python command-line RSS reader.', conf.__description__ @@ -29,11 +33,14 @@ def __init__(self) -> None: PdfConverter(self._console_args.to_pdf, self._console_args.limit).render(self._feed) @classmethod - def start(cls) -> object: + def start(cls) -> None: return cls()._feed.show_feeds() def main(): + """ + Entry point of rss-reader. + """ try: App.start() except KeyboardInterrupt: diff --git a/~/test/test3.html b/~/test/test3.html new file mode 100755 index 0000000..58fad51 --- /dev/null +++ b/~/test/test3.html @@ -0,0 +1,111 @@ + + + + Yahoo News - Latest News & Headlines + + +
+
+

Yahoo News - Latest News & Headlines

+

+ https://news.yahoo.com/rss/ +

+ +
+
+
+

+ At least 14 killed in bloody gunfight in northern Mexico +

+
+
+
+
+

+ Ten suspected cartel gunmen and four police were killed during a shootout on Saturday in a Mexican town near the U.S. border, days after U.S. President Donald Trump raised bilateral tensions by saying he would designate the gangs as terrorists. The government of the northern state of Coahuila said state police clashed at midday with a group of heavily armed gunmen riding in pickup trucks in the small town of Villa Union, about 40 miles (65 km) southwest of the border city of Piedras Negras. Standing outside the Villa Union mayor's bullet-ridden offices, Coahuila Governor Miguel Angel Riquelme told reporters the state had acted "decisively" to tackle the cartel henchmen. +

+ links: + + + + source: https://news.yahoo.com/least-14-killed-during-gunfight-022233104.html +
+

+ 2019-12-01 02:22:33 +

+
+
+
+
+
+
+
+

+ Plane crash kills nine, injures three in South Dakota +

+
+
+
+ +
+
+
+

+ A plane crash in the US state of South Dakota killed nine people, including two children, and injured three others on Saturday while a winter storm warning was in place, officials said. The Pilatus PC-12, a single-engine turboprop plane, crashed shortly after take-off approximately a mile from the Chamberlain airport, the Federal Aviation Administration (FAA) said. Among the dead was the plane's pilot, Brule County state's attorney Theresa Maule Rossow said, adding that a total of 12 people had been on board. +

+ links: + + + + source: https://news.yahoo.com/plane-crash-kills-nine-injures-three-south-dakota-042236507.html +
+

+ 2019-12-01 04:22:36 +

+
+
+
+
+
+
+
+

+ Trump won't lose his job – but the impeachment inquiry is still essential +

+
+
+
+ +
+
+
+

+ The process is required by the constitution, seems to be shifting voters’ opinions, and will render the president unpardonable‘A failure by Congress to respond to these abuses would effectively render the constitution meaningless.’ Photograph: J Scott Applewhite/APNot even overwhelming evidence that Trump sought to bribe a foreign power to dig up dirt on his leading political opponent in 202o – and did so with American taxpayer dollars, while compromising American foreign policy – will cause Trump to be removed from office.That’s because there’s zero chance that 2o Republican senators – the number needed to convict Trump, if every Democratic senator votes to do so – have enough integrity to do what the constitution requires them to do.These Republican senators will put their jobs and their political party ahead of the constitution and the country. They will tell themselves that 88% of Republican voters still support Trump, and that their duty is to them.It does not matter that these voters inhabit a parallel political universe consisting of Trump tweets, Fox News, rightwing radio, and Trump-Russian social media, all propounding the absurd counter-narrative that Democrats, the “deep state”, coastal elites, and mainstream media are conspiring to remove the Chosen One from office.So if there’s no chance of getting the 20 Republican votes needed to send Trump packing, is there any reason for this impeachment proceeding to continue?Yes. There are three reasons.The first is the constitution itself. Donald Trump has openly abused his power – not only seeking electoral help from foreign nations but making money off his presidency in violation of the emoluments clause, spending funds never appropriated by Congress in violation of the separation of powers, obstructing justice, and violating his oath to faithfully execute the law.A failure by Congress to respond to these abuses would effectively render the constitution meaningless. Congress has no alternative but to respond.The second reason is political. While the impeachment hearings don’t appear to have moved Republican voters, only 29% of Americans still identify as Republican.The hearings do seem to have affected Democrats and independents, as well as many people who sat out the 2016 election. National polls by Morning Consult/Politico and SSRS/CNN show that 50% of respondents now support both impeaching Trump and removing him from office, an increase from Morning Consult/Politico’s mid-November poll.Presumably anyone who now favors removing Trump from office will be inclined to vote against him next November. The House’s impeachment could therefore swing the 2020 election against him.The third reason for the House to impeach Trump even if the Senate won’t convict him concerns the pardoning power of the president.Assume that Trump is impeached on grounds that include a raft of federal crimes – bribery, treason, obstruction of justice, election fraud, money laundering, conspiracy to defraud the United States, making false statements to the federal government, serving as an agent of a foreign government without registering with the justice department, donating funds from foreign nationals, and so on.Regardless of whether a sitting president can be indicted and convicted on such criminal charges, Trump will become liable to them at some point. But could he be pardoned, as Gerald Ford pardoned Richard Nixon 45 years ago?Article II, section 2 of the constitution gives a president the power to pardon anyone who has been convicted of offenses against the United States, with one exception: “in Cases of Impeachment.”If Trump is impeached by the House, he can never be pardoned for these crimes. He cannot pardon himself (it’s dubious that a president has this self-pardoning power in any event), and he cannot be pardoned by a future president.Even if a subsequent president wanted to pardon Trump in the interest of, say, domestic tranquility, she could not.Gerald Ford wrote in his pardon of Nixon that if Nixon were indicted and subject to a criminal trial, “the tranquility to which this nation has been restored by the events of recent weeks could be irreparably lost”.Had the House impeached Nixon, Ford’s hands would have been tied.Trump is not going to be so lucky. The House will probably impeach him before Christmas and then his chance of getting a pardon for his many crimes will be gone. +

+ links: + + + + source: https://news.yahoo.com/trump-wont-lose-job-impeachment-060002657.html +
+

+ 2019-12-01 06:00:02 +

+
+
+
+
+
+
+
+
+ + \ No newline at end of file From 97aeefd00b52bec5f5c9731fa127e1b3052a714e Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 1 Dec 2019 21:05:48 +0300 Subject: [PATCH 17/26] docstring parser component --- conf.py | 2 +- src/components/cache/cache_entry.py | 7 ++++ src/components/feed/feed_entry.py | 2 +- src/components/logger/logger.py | 5 +-- .../parser/arguments/arguments_abstract.py | 24 ++++++++++--- .../parser/arguments/optional/colorize.py | 2 ++ .../parser/arguments/optional/date.py | 19 +++++++++-- .../parser/arguments/optional/json.py | 12 ++++++- .../parser/arguments/optional/limit.py | 22 +++++++++--- .../parser/arguments/optional/to_html.py | 22 ++++++++++-- .../parser/arguments/optional/to_pdf.py | 22 +++++++++--- .../parser/arguments/optional/verbose.py | 12 ++++++- .../parser/arguments/optional/version.py | 12 ++++++- .../parser/arguments/positional/source.py | 22 +++++++++--- src/components/parser/parser.py | 34 ++++++++++++++++--- src/rss_reader.py | 5 ++- 16 files changed, 190 insertions(+), 34 deletions(-) diff --git a/conf.py b/conf.py index 233f056..650414c 100644 --- a/conf.py +++ b/conf.py @@ -1,6 +1,6 @@ __author__ = 'Mikhan Victor' __email__ = 'victormikhan@gmail.com' __package__ = 'rss-reader' -__version__ = '2.0.0' +__version__ = '1.4.0' __description__ = 'RSS Reader' __url__ = 'https://github.com/victormikhan/PythonHomework' diff --git a/src/components/cache/cache_entry.py b/src/components/cache/cache_entry.py index c51d8f4..ce397ee 100644 --- a/src/components/cache/cache_entry.py +++ b/src/components/cache/cache_entry.py @@ -1,6 +1,13 @@ +"""This module contain class for structuring feeds cache entries""" + from src.components.feed.feed_entry import FeedEntry class CacheEntry(FeedEntry): + """ + This class implementing FeedEntry class. + This is done because the class contains similar data and can + be extended for cached entries + """ pass diff --git a/src/components/feed/feed_entry.py b/src/components/feed/feed_entry.py index eced41c..1d78440 100644 --- a/src/components/feed/feed_entry.py +++ b/src/components/feed/feed_entry.py @@ -1,4 +1,4 @@ -"""This module contain classe for structuring feeds entries""" +"""This module contain class for structuring feeds entries""" import html import feedparser diff --git a/src/components/logger/logger.py b/src/components/logger/logger.py index 2c591c5..47bcf3c 100644 --- a/src/components/logger/logger.py +++ b/src/components/logger/logger.py @@ -11,7 +11,7 @@ class Logger(Singleton): """ - Logger class using for wrap logger and provide more convinient approach for logging + Logger class using for wrap logger and provide more convenient approach for logging Attributes: logger_name logger_name containt logger settings default name @@ -22,7 +22,8 @@ class Logger(Singleton): @classmethod def initialize(cls, is_colorize: bool) -> None : """ - This method initalize logger module for logging in project. Is_colorize using for decide is color cli output + This method initalize logger module for logging in project. Is_colorize using + for decide is color cli output. Also logger config store in conf.yml :param is_colorize: bool :return: None """ diff --git a/src/components/parser/arguments/arguments_abstract.py b/src/components/parser/arguments/arguments_abstract.py index 29a38c5..cb049b2 100644 --- a/src/components/parser/arguments/arguments_abstract.py +++ b/src/components/parser/arguments/arguments_abstract.py @@ -1,19 +1,33 @@ +"""This module contain interface for implementation by cli utility params""" + from abc import ABC, abstractmethod from pathlib import Path import argparse class ArgumentsAbstract(ABC): - - def __init__(self, parser): + """ + This interface provided general data for implemented by argparse params + """ + def __init__(self, parser: argparse.ArgumentParser) -> None: + """ + This interface constructor init argparse parser instance for further usage in options implementations + :param parser: argparse.ArgumentParser + """ self._parser = parser @abstractmethod - def add_argument(self): + def add_argument(self) -> argparse: + """This abstract method should be implemented for adding represented options""" pass - def _validate_converter_path(self, path): - + def _validate_converter_path(self, path: str) -> Path: + """ + This method validate incoming path for converter module on + path extension and path valid + :param path: str + :return: Path + """ if not Path(path).suffix in self._extensions: raise argparse.ArgumentTypeError( f'Wrong extension type. Proper extension\\s: {", ".join(self._extensions)}' diff --git a/src/components/parser/arguments/optional/colorize.py b/src/components/parser/arguments/optional/colorize.py index c2260c9..695bbf9 100644 --- a/src/components/parser/arguments/optional/colorize.py +++ b/src/components/parser/arguments/optional/colorize.py @@ -1,3 +1,5 @@ +"""This module contain class representing cli optional argument""" + from src.components.parser.arguments import ArgumentsAbstract diff --git a/src/components/parser/arguments/optional/date.py b/src/components/parser/arguments/optional/date.py index ab49710..3513f9f 100644 --- a/src/components/parser/arguments/optional/date.py +++ b/src/components/parser/arguments/optional/date.py @@ -1,17 +1,32 @@ +"""This module contain class representing cli optional argument""" + from src.components.parser.arguments import ArgumentsAbstract from datetime import datetime import argparse class Date(ArgumentsAbstract): + """This class representing implementation of ArgumentsAbstract + interface and init a optional Date for cache parameter""" - def add_argument(self): + def add_argument(self) -> argparse: + """ + This method is implementation of add_argument abstract method + add Date parameter from console for retrieving cache + :return: argparse + """ self._parser.add_argument( '--date', type=self._validate_caching_date, help='Cached news from the specified date. YYYYMMDD is proper date format.' ) - def _validate_caching_date(self, date: str): + def _validate_caching_date(self, date: str) -> datetime: + """ + This method validate incoming optional date parameter on + date format type + :param date: str + :return: datetime + """ try: return datetime.strptime(date, '%Y%m%d').date() except ValueError: diff --git a/src/components/parser/arguments/optional/json.py b/src/components/parser/arguments/optional/json.py index d56bdba..5252ed7 100644 --- a/src/components/parser/arguments/optional/json.py +++ b/src/components/parser/arguments/optional/json.py @@ -1,9 +1,19 @@ +"""This module contain class representing cli optional argument""" + from src.components.parser.arguments import ArgumentsAbstract +import argparse class Json(ArgumentsAbstract): + """This class representing implementation of ArgumentsAbstract + interface and init a optional Json for json output parameter""" - def add_argument(self): + def add_argument(self) -> argparse: + """ + This method is implementation of add_argument abstract method + add Json parameter from console for json output + :return: argparse + """ self._parser.add_argument( '--json', action='store_true', help='Print result as JSON in stdout' ) diff --git a/src/components/parser/arguments/optional/limit.py b/src/components/parser/arguments/optional/limit.py index 0f5adf3..79e4261 100644 --- a/src/components/parser/arguments/optional/limit.py +++ b/src/components/parser/arguments/optional/limit.py @@ -1,20 +1,34 @@ +"""This module contain class representing cli optional argument""" + from src.components.parser.arguments import ArgumentsAbstract import argparse class Limit(ArgumentsAbstract): + """This class representing implementation of ArgumentsAbstract + interface and init a optional Json for json output parameter""" - def add_argument(self): + def add_argument(self) -> argparse: + """ + This method is implementation of add_argument abstract method + add Limit parameter from console for limit feed entries + :return: argparse + """ self._parser.add_argument( '--limit', type=self._validate_limit, default=3, help='Limit news topics if this parameter provided' ) - def _validate_limit(self, limit): + def _validate_limit(self, limit: int) -> int: + """ + This method validate incoming optional limit parameter on equals to zero or less + :param limit: int + :return: int + """ try: - if not int(limit) > 0: + if not limit > 0: raise argparse.ArgumentTypeError - return int(limit) + return limit except argparse.ArgumentTypeError: raise argparse.ArgumentTypeError('Argument limit equal or less 0') diff --git a/src/components/parser/arguments/optional/to_html.py b/src/components/parser/arguments/optional/to_html.py index ea9231c..4f4aaa0 100644 --- a/src/components/parser/arguments/optional/to_html.py +++ b/src/components/parser/arguments/optional/to_html.py @@ -1,12 +1,28 @@ +"""This module contain class representing cli optional argument""" + from src.components.parser.arguments.arguments_abstract import ArgumentsAbstract import argparse + class ToHtml(ArgumentsAbstract): + """ + This class representing implementation of ArgumentsAbstract interface + and init a optional ToHtml parameter + + Attributes: + _extensions attribute contains all permitted extension for this parameter + """ - _extensions = ['.html', '.htm'] + _extensions: list=['.html', '.htm'] - def add_argument(self): + def add_argument(self) -> argparse: + """ + This method is implementation of add_argument abstract method + add ToHtml parameter from console for converter feeds entities into html + :return: argparse + """ self._parser.add_argument( - '--to-html', type=self._validate_converter_path, help='Convert to HTML format. Please provide path to file create' + '--to-html', type=self._validate_converter_path, + help='Convert to HTML format. Please provide path to file create' ) diff --git a/src/components/parser/arguments/optional/to_pdf.py b/src/components/parser/arguments/optional/to_pdf.py index 56341cb..bdb5527 100644 --- a/src/components/parser/arguments/optional/to_pdf.py +++ b/src/components/parser/arguments/optional/to_pdf.py @@ -1,13 +1,27 @@ +"""This module contain class representing cli optional argument""" + from src.components.parser.arguments.arguments_abstract import ArgumentsAbstract import argparse -from pathlib import Path class ToPdf(ArgumentsAbstract): + """ + This class representing implementation of ArgumentsAbstract interface + and init a optional Pdf parameter + + Attributes: + _extensions attribute contains all permitted extension for this parameter + """ - _extensions = ['.pdf'] + _extensions: list=['.pdf'] - def add_argument(self): + def add_argument(self) -> argparse: + """ + This method is implementation of add_argument abstract method + add ToPdf parameter from console for converter feeds entities into pdf + :return: argparse + """ self._parser.add_argument( - '--to-pdf', type=self._validate_converter_path, help='Convert to Pdf format. Please provide path to file create' + '--to-pdf', type=self._validate_converter_path, + help='Convert to Pdf format. Please provide path to file create' ) diff --git a/src/components/parser/arguments/optional/verbose.py b/src/components/parser/arguments/optional/verbose.py index 4c40682..6e041d3 100644 --- a/src/components/parser/arguments/optional/verbose.py +++ b/src/components/parser/arguments/optional/verbose.py @@ -1,9 +1,19 @@ +"""This module contain class representing cli optional argument""" + from src.components.parser.arguments import ArgumentsAbstract +import argparse class Verbose(ArgumentsAbstract): + """This class representing implementation of ArgumentsAbstract + interface and init a optional Verbose parameter""" - def add_argument(self): + def add_argument(self) -> argparse: + """ + This method is implementation of add_argument abstract method + add Verbose parameter from console for output verbose data + :return: argparse + """ self._parser.add_argument( '--verbose', default=False, action='store_true', help='Outputs verbose status messages' ) diff --git a/src/components/parser/arguments/optional/version.py b/src/components/parser/arguments/optional/version.py index a287efa..c8b4bfd 100644 --- a/src/components/parser/arguments/optional/version.py +++ b/src/components/parser/arguments/optional/version.py @@ -1,10 +1,20 @@ +"""This module contain class representing cli optional argument""" + from .. import ArgumentsAbstract import conf +import argparse class Version(ArgumentsAbstract): + """This class representing implementation of ArgumentsAbstract + interface and init a optional Version parameter""" - def add_argument(self): + def add_argument(self) -> argparse: + """ + This method is implementation of add_argument abstract method + add Version parameter from console for output version of rss-reader + :return: argparse + """ self._parser.add_argument( '-v', '--version', action='version', version=conf.__version__, help='Print version info' ) diff --git a/src/components/parser/arguments/positional/source.py b/src/components/parser/arguments/positional/source.py index 077815f..c2a1fdf 100644 --- a/src/components/parser/arguments/positional/source.py +++ b/src/components/parser/arguments/positional/source.py @@ -1,17 +1,31 @@ +"""This module contain class representing cli positional argument""" + from src.components.parser.arguments.arguments_abstract import ArgumentsAbstract import argparse import urllib.request as url class Source(ArgumentsAbstract): - - def add_argument(self): + """This class representing implementation of ArgumentsAbstract + interface and init a positional Source parameter""" + + def add_argument(self) -> argparse: + """ + This method is implementation of add_argument abstract method + add Source parameter from console for load feed + :return: argparse + """ self._parser.add_argument( 'source', type=self._validate_source, help='RSS URL' ) - def _validate_source(self, source): - + def _validate_source(self, source: str) -> str: + """ + This method validate incoming required source parameter on feed + existence by url and another url checker exception + :param source: str + :return: str + """ try: if url.urlopen(source).getcode() is not 200: raise argparse.ArgumentError diff --git a/src/components/parser/parser.py b/src/components/parser/parser.py index 5e6d0c9..eaa31eb 100644 --- a/src/components/parser/parser.py +++ b/src/components/parser/parser.py @@ -1,10 +1,18 @@ +"""This module contain class for wrap Argparse""" + import argparse import importlib class Parser: + """ + This class represents wrap on argparse for more convenient way to parse params and validate them + + Attributes: + _arguments_list attribute contains all presenting cli options in utility + """ - _arguments_list = ( + _arguments_list: tuple=( 'source', 'version', 'json', @@ -16,14 +24,27 @@ class Parser: 'to_pdf', ) - def __init__(self, description, usage): + def __init__(self, description: str, usage: str) -> None : + """ + This constructor implements, argparse module and init param from console + :param description: str + :param usage: str + """ self._parser = argparse.ArgumentParser(description=description, usage=usage) self._init_arguments() - def get_args(self): + def get_args(self) -> argparse: + """ + This method retrieve cli parameters and return them + :return: argparse + """ return self._parser.parse_args() - def _init_arguments(self): + def _init_arguments(self) -> None: + """ + This method load arparse parameters classes bound with _arguments_list list + :return: None + """ module = importlib.import_module('src.components.parser.arguments') for argument in self._arguments_list: @@ -32,5 +53,10 @@ def _init_arguments(self): @staticmethod def to_camel_case(string: str) -> str: + """ + This staticmethod help convert snake_case parameters to CamelCase for load classes + :param string: str + :return: str + """ parts = string.split('_') return parts[0].capitalize() + ''.join(part.title() for part in parts[1:]) diff --git a/src/rss_reader.py b/src/rss_reader.py index 111edb9..61e2253 100644 --- a/src/rss_reader.py +++ b/src/rss_reader.py @@ -1,3 +1,5 @@ +"""This module contain main rss-reader class App and entry point to utility""" + from .components.helper.singleton import Singleton from .components.parser.parser import Parser from .components.feed import * @@ -12,7 +14,8 @@ class App(Singleton): def __init__(self) -> None: """ - This constructor parse program arguments, initialize all module params decide witch logic to run + This constructor parse program arguments, + initialize all module params decide witch logic to run """ console = Parser( 'Pure Python command-line RSS reader.', From f0d5996611846d02778d9607b7de2c37d98f30be Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 1 Dec 2019 22:01:25 +0300 Subject: [PATCH 18/26] docstring helper and converter modules --- .../converter/converter_abstract.py | 60 ++++++++++++++++--- .../converter/html/html_converter.py | 38 ++++++++++-- src/components/converter/pdf/pdf_converter.py | 22 ++++++- src/components/feed/feed.py | 5 +- src/components/feed/feed_formatter.py | 2 +- src/components/helper/map.py | 5 ++ src/components/helper/singleton.py | 10 ++++ src/components/logger/logger.py | 2 +- .../parser/arguments/optional/limit.py | 4 +- src/components/parser/parser.py | 2 +- src/rss_reader.py | 2 +- 11 files changed, 129 insertions(+), 23 deletions(-) diff --git a/src/components/converter/converter_abstract.py b/src/components/converter/converter_abstract.py index 9cf489d..9c7d941 100644 --- a/src/components/converter/converter_abstract.py +++ b/src/components/converter/converter_abstract.py @@ -1,3 +1,5 @@ +"""This module contain interface for converters of utility""" + from src.components.logger import Logger from abc import ABC, abstractmethod from pathlib import Path @@ -9,34 +11,65 @@ class ConverterAbstract(ABC): + """ + This interface provided for implementing by utility converter + for render data into appropriate format + + Attributes: + _media_img_ext attribute contain default media img parse extension + """ - _media_img_ext = '.jpg' + _media_img_ext: str = '.jpg' def __init__(self, path: str, limit: int) -> None: + """ + This interface constructor represent creating path from path initialization + and limit of entries to render + :param path: str + :param limit: int + """ self._path_initialize(Path(path)) self._limit = limit @abstractmethod def render(self, feed: Feed) -> str: + """This abstract method should be implemented for render + entry point of utility converter and bundle all render parts in one """ pass @abstractmethod def _entry_render(self, entry: FeedEntry): + """This abstract method should be implemented for render + single feed entry""" pass @abstractmethod def _media_render(self, entry: FeedEntry): + """This abstract method should be implemented for render + media of single feed entry""" pass - def _init_template_processor(self, template_path: str): + def _init_template_processor(self, template_path: str) -> None: + """ + This method create converter template processor which + provide access to work with stored templates. Should be + calling in render method to provide converter template path + :param template_path: str + :return: None + """ path = str(Path(__file__).parent.joinpath(template_path).as_posix()) self._template_processor = jinja2.Environment( loader=jinja2.FileSystemLoader(path), trim_blocks=True ) - def _path_initialize(self, path: Path): - + def _path_initialize(self, path: Path) -> None: + """ + This method initialize converter storing path + for render file and media data + :param path: Path + :return: + """ self._path = path if not self._path.parent.exists(): @@ -56,8 +89,13 @@ def _path_initialize(self, path: Path): parents=True, exist_ok=True ) - def _download_media(self, media_url: str) -> bool: - + def _download_media(self, media_url: str) -> str: + """ + This method storing remote media in local + storage and provide path to downloaded files + :param media_url: str + :return: str + """ media_file_name = str(abs(hash(media_url)) % 10 ** 10) media_file = self._media_path.joinpath(media_file_name + self._media_img_ext) @@ -72,8 +110,14 @@ def _download_media(self, media_url: str) -> bool: return media_file - def _save_render_file(self, output, encoding: str='UTF-8') -> None: - + def _save_render_file(self, output: str, encoding: str='UTF-8') -> None: + """ + This method store converted file. Called when render was + completed and all output data can be saved + :param output: str + :param encoding: str + :return: None + """ with open(self._path, 'w', encoding=encoding) as file: file.write(output) diff --git a/src/components/converter/html/html_converter.py b/src/components/converter/html/html_converter.py index e02d475..9eaf701 100644 --- a/src/components/converter/html/html_converter.py +++ b/src/components/converter/html/html_converter.py @@ -1,3 +1,5 @@ +"""This module contain class representing html utility converter """ + from src.components.converter.converter_abstract import ConverterAbstract from src.components.logger import Logger from src.components.feed import Feed @@ -7,11 +9,25 @@ class HtmlConverter(ConverterAbstract): + """ + This class implements ConverterAbstract interface and convert + rss data into html format + + Attributes: + _log_Converter attribute contain log name converter + _template_path attribute contain templates path + """ - _log_Converter = 'HTML' - _template_path = Path(__file__).parent.joinpath('templates') + _log_Converter: str = 'HTML' + _template_path: Path = Path(__file__).parent.joinpath('templates') def render(self, feed: Feed) -> str: + """ + This method is implementation of render abstract method + render all templates and save them into file + :param feed: Feed + :return: + """ Logger.log( f'Converter option choosen. Default output was declined.\n' f'Initialize {self._log_Converter} converter render' @@ -40,8 +56,13 @@ def render(self, feed: Feed) -> str: Logger.log(f'{self._log_Converter} render complete. You can check it in: {self._path}') sys.exit(1) - def _media_render(self, media: list): - + def _media_render(self, media: list) -> str: + """ + This method is implementation of _media_render abstract method + render media for single feed entry + :param media: list + :return: str + """ media_output = [] for item in media: @@ -56,8 +77,13 @@ def _media_render(self, media: list): return media_output - def _entry_render(self, entry: FeedEntry): - + def _entry_render(self, entry: FeedEntry) -> str: + """ + This method is implementation of _entry_render abstract method + render single feed entry + :param entry: FeedEntry + :return: str + """ return self._template_processor.get_template('entry.html.jinja2').render( media=self._media_render(entry.media), title=entry.title, diff --git a/src/components/converter/pdf/pdf_converter.py b/src/components/converter/pdf/pdf_converter.py index d5d2837..3ae5d47 100644 --- a/src/components/converter/pdf/pdf_converter.py +++ b/src/components/converter/pdf/pdf_converter.py @@ -1,13 +1,33 @@ +"""This module contain class representing html utility converter """ + from src.components.converter.html.html_converter import HtmlConverter from pathlib import Path from weasyprint import HTML, CSS class PdfConverter(HtmlConverter): + """ + This class implements HtmlConverter class and convert + rss data into html format. + + Using weasyprint and jinja2 allows to using HtmlConverter + methods instead of writing own logic for pdf converter + + Attributes: + _log_Converter attribute contain log name converter + + """ _log_Converter = 'PDF' - def _save_render_file(self, output, encoding: str='UTF-8'): + def _save_render_file(self, output: str, encoding: str = 'UTF-8'): + """ + This method overriding _save_render_file method and provide saving pdf data + by weasyprint and jinja2 from html templating + :param output: str + :param encoding: ste + :return: None + """ HTML(string=output, encoding=encoding).write_pdf( stylesheets=[CSS(string=self._template_processor.get_template('style.css.jinja2').render())], target=self._path ) diff --git a/src/components/feed/feed.py b/src/components/feed/feed.py index 85a22ff..d6b637c 100644 --- a/src/components/feed/feed.py +++ b/src/components/feed/feed.py @@ -11,7 +11,7 @@ class FeedProperty(ABC): - """Trait for Feed class.Contain all properties, witch Feed use out of class """ + """Trait for Feed class.Contain all properties, which Feed use out of class """ @property def entities_list(self) -> list: @@ -40,7 +40,8 @@ def feeds_encoding(self)-> str: class Feed(FeedProperty): - """Feed class """ + """This class represent parsing feed process, + manage caching module, output data in proper way to console output""" def __init__(self, args: ArgumentParser) -> None: """ diff --git a/src/components/feed/feed_formatter.py b/src/components/feed/feed_formatter.py index 322be32..c97ce68 100644 --- a/src/components/feed/feed_formatter.py +++ b/src/components/feed/feed_formatter.py @@ -19,7 +19,7 @@ class FeedFormatter: @classmethod def generate_output(cls, entries: list, limit: int, top_data_output: Map, is_colorize: bool=False) -> str: """ - This method decide witch way rss feed should be printed + This method decide which way rss feed should be printed :param entries: list :param limit: int :param top_data_output: Map diff --git a/src/components/helper/map.py b/src/components/helper/map.py index 89bdc30..08c1ff9 100644 --- a/src/components/helper/map.py +++ b/src/components/helper/map.py @@ -1,4 +1,9 @@ +"""This module contain class representing map dictionaries pattern""" + + class Map(dict): + """This class wrap dictionary to proper work with them""" + def __init__(self, *args, **kwargs): super(Map, self).__init__(*args, **kwargs) for arg in args: diff --git a/src/components/helper/singleton.py b/src/components/helper/singleton.py index 4a71422..ada2968 100644 --- a/src/components/helper/singleton.py +++ b/src/components/helper/singleton.py @@ -1,8 +1,18 @@ +"""This module contain class representing singleton pattern for further implementation""" + + class Singleton(object): + """ + This module contain class representing singleton pattern for further implementation + + Attributes: + _instance attribute contains sole instance of class + """ _instance = None def __new__(class_, *args, **kwargs): + """rewrite __new__ for preventing creating new instances of class""" if not isinstance(class_._instance, class_): class_._instance = object.__new__(class_, *args, **kwargs) diff --git a/src/components/logger/logger.py b/src/components/logger/logger.py index 47bcf3c..ef69efe 100644 --- a/src/components/logger/logger.py +++ b/src/components/logger/logger.py @@ -17,7 +17,7 @@ class Logger(Singleton): logger_name logger_name containt logger settings default name """ - logger_name: str='standard' + logger_name: str = 'standard' @classmethod def initialize(cls, is_colorize: bool) -> None : diff --git a/src/components/parser/arguments/optional/limit.py b/src/components/parser/arguments/optional/limit.py index 79e4261..f337d59 100644 --- a/src/components/parser/arguments/optional/limit.py +++ b/src/components/parser/arguments/optional/limit.py @@ -25,10 +25,10 @@ def _validate_limit(self, limit: int) -> int: :return: int """ try: - if not limit > 0: + if not int(limit) > 0: raise argparse.ArgumentTypeError - return limit + return int(limit) except argparse.ArgumentTypeError: raise argparse.ArgumentTypeError('Argument limit equal or less 0') diff --git a/src/components/parser/parser.py b/src/components/parser/parser.py index eaa31eb..e7f5b0f 100644 --- a/src/components/parser/parser.py +++ b/src/components/parser/parser.py @@ -12,7 +12,7 @@ class Parser: _arguments_list attribute contains all presenting cli options in utility """ - _arguments_list: tuple=( + _arguments_list: tuple = ( 'source', 'version', 'json', diff --git a/src/rss_reader.py b/src/rss_reader.py index 61e2253..5bde730 100644 --- a/src/rss_reader.py +++ b/src/rss_reader.py @@ -15,7 +15,7 @@ class App(Singleton): def __init__(self) -> None: """ This constructor parse program arguments, - initialize all module params decide witch logic to run + initialize all module params decide which logic to run """ console = Parser( 'Pure Python command-line RSS reader.', From 7267b1b6047e6b39e55cde1fcd6323b7f163dc72 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 1 Dec 2019 23:14:01 +0300 Subject: [PATCH 19/26] Remove the ~/test directory --- ~/test/test.html | 10283 -------------------------------------------- ~/test/test3.html | 111 - 2 files changed, 10394 deletions(-) delete mode 100644 ~/test/test.html delete mode 100755 ~/test/test3.html diff --git a/~/test/test.html b/~/test/test.html deleted file mode 100644 index 06e9235..0000000 --- a/~/test/test.html +++ /dev/null @@ -1,10283 +0,0 @@ - - - - - Yahoo News - Latest News & Headlines - - -

{title}

-
- - -
-

The Next Trump Bombshell To Drop: The Justice Department's 2016 Trump Campaign Report

-
-
- # -
-
-

Sat, 30 Nov 2019 02:32:00 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - n
- - e
- - x
- - t
- - -
- - t
- - r
- - u
- - m
- - p
- - -
- - b
- - o
- - m
- - b
- - s
- - h
- - e
- - l
- - l
- - -
- - d
- - r
- - o
- - p
- - -
- - j
- - u
- - s
- - t
- - i
- - c
- - e
- - -
- - 0
- - 7
- - 3
- - 2
- - 0
- - 0
- - 2
- - 4
- - 9
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/next-trump-bombshell-drop-justice-073200249.html -
-
-
- - -
-

Hotpot vs bread: the culinary symbols of Hong Kong's political divide

-
-
- # -
-
-

Fri, 29 Nov 2019 04:40:51 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - h
- - o
- - t
- - p
- - o
- - t
- - -
- - v
- - s
- - -
- - b
- - r
- - e
- - a
- - d
- - -
- - c
- - u
- - l
- - i
- - n
- - a
- - r
- - y
- - -
- - s
- - y
- - m
- - b
- - o
- - l
- - s
- - -
- - h
- - o
- - n
- - g
- - -
- - k
- - o
- - n
- - g
- - s
- - -
- - p
- - o
- - l
- - i
- - t
- - i
- - c
- - a
- - l
- - -
- - 0
- - 9
- - 4
- - 0
- - 5
- - 1
- - 4
- - 1
- - 8
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/hotpot-vs-bread-culinary-symbols-hong-kongs-political-094051418.html -
-
-
- - -
-

'Very disturbing': Chicago officer under investigation for body-slamming man to the ground

-
-
- # -
-
-

Fri, 29 Nov 2019 15:14:16 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - v
- - e
- - r
- - y
- - -
- - d
- - i
- - s
- - t
- - u
- - r
- - b
- - i
- - n
- - g
- - -
- - c
- - h
- - i
- - c
- - a
- - g
- - o
- - -
- - o
- - f
- - f
- - i
- - c
- - e
- - r
- - -
- - u
- - n
- - d
- - e
- - r
- - -
- - 1
- - 8
- - 5
- - 3
- - 2
- - 4
- - 5
- - 9
- - 8
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/very-disturbing-chicago-officer-under-185324598.html -
-
-
- - -
-

Millions Around The World Strike on Black Friday for Action on Climate Change

-
-
- # -
-
-

Fri, 29 Nov 2019 17:16:38 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - m
- - i
- - l
- - l
- - i
- - o
- - n
- - s
- - -
- - a
- - r
- - o
- - u
- - n
- - d
- - -
- - w
- - o
- - r
- - l
- - d
- - -
- - s
- - t
- - r
- - i
- - k
- - e
- - -
- - b
- - l
- - a
- - c
- - k
- - -
- - 2
- - 2
- - 1
- - 6
- - 3
- - 8
- - 3
- - 3
- - 4
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/millions-around-world-strike-black-221638334.html -
-
-
- - -
-

Italy’s ‘Miss Hitler’ Among 19 Investigated for Starting New Nazi Party in Italy

-
-
- # -
-
-

Fri, 29 Nov 2019 11:03:52 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - i
- - t
- - a
- - l
- - y
- - -
- - m
- - i
- - s
- - s
- - -
- - h
- - i
- - t
- - l
- - e
- - r
- - -
- - a
- - m
- - o
- - n
- - g
- - -
- - 1
- - 9
- - -
- - 1
- - 6
- - 0
- - 3
- - 5
- - 2
- - 7
- - 3
- - 4
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/italy-miss-hitler-among-19-160352734.html -
-
-
- - -
-

U.K. Police Shoot Man After Potential Terrorist Attack in London

-
-
- # -
-
-

Fri, 29 Nov 2019 10:52:14 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - a
- - r
- - m
- - e
- - d
- - -
- - p
- - o
- - l
- - i
- - c
- - e
- - -
- - c
- - l
- - o
- - s
- - e
- - -
- - l
- - o
- - n
- - d
- - o
- - n
- - -
- - b
- - r
- - i
- - d
- - g
- - e
- - -
- - 1
- - 4
- - 3
- - 6
- - 4
- - 2
- - 3
- - 1
- - 2
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/armed-police-close-london-bridge-143642312.html -
-
-
- - -
-

Behind in polls, Taiwan president contender tells supporters to lie to pollsters

-
-
- # -
-
-

Fri, 29 Nov 2019 03:32:02 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - b
- - e
- - h
- - i
- - n
- - d
- - -
- - p
- - o
- - l
- - l
- - s
- - -
- - t
- - a
- - i
- - w
- - a
- - n
- - -
- - p
- - r
- - e
- - s
- - i
- - d
- - e
- - n
- - t
- - -
- - c
- - o
- - n
- - t
- - e
- - n
- - d
- - e
- - r
- - -
- - 0
- - 8
- - 3
- - 2
- - 0
- - 2
- - 4
- - 9
- - 8
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/behind-polls-taiwan-president-contender-083202498.html -
-
-
- - -
-

Who made the new drapes? It’s among high court’s mysteries

-
-
- # -
-
-

Fri, 29 Nov 2019 08:05:16 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - m
- - a
- - d
- - e
- - -
- - d
- - r
- - a
- - p
- - e
- - s
- - -
- - a
- - m
- - o
- - n
- - g
- - -
- - h
- - i
- - g
- - h
- - -
- - c
- - o
- - u
- - r
- - t
- - -
- - 1
- - 2
- - 5
- - 6
- - 3
- - 3
- - 0
- - 5
- - 0
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/made-drapes-among-high-court-125633050.html -
-
-
- - -
-

DR Congo buries 27 massacre victims as anger mounts

-
-
- # -
-
-

Fri, 29 Nov 2019 13:16:48 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - d
- - r
- - -
- - c
- - o
- - n
- - g
- - o
- - -
- - b
- - u
- - r
- - i
- - e
- - s
- - -
- - 2
- - 7
- - -
- - m
- - a
- - s
- - s
- - a
- - c
- - r
- - e
- - -
- - v
- - i
- - c
- - t
- - i
- - m
- - s
- - -
- - a
- - n
- - g
- - e
- - r
- - -
- - m
- - o
- - u
- - n
- - t
- - s
- - -
- - 1
- - 7
- - 0
- - 2
- - 3
- - 6
- - 1
- - 9
- - 3
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/dr-congo-buries-27-massacre-victims-anger-mounts-170236193.html -
-
-
- - -
-

Nuclear Nightmare? Russia’s Avangard Hypersonic Missile Is About to Go Operational.

-
-
- # -
-
-

Sat, 30 Nov 2019 01:00:00 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - n
- - u
- - c
- - l
- - e
- - a
- - r
- - -
- - n
- - i
- - g
- - h
- - t
- - m
- - a
- - r
- - e
- - -
- - r
- - u
- - s
- - s
- - i
- - a
- - -
- - a
- - v
- - a
- - n
- - g
- - a
- - r
- - d
- - -
- - h
- - y
- - p
- - e
- - r
- - s
- - o
- - n
- - i
- - c
- - -
- - 0
- - 6
- - 0
- - 0
- - 0
- - 0
- - 2
- - 8
- - 6
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/nuclear-nightmare-russia-avangard-hypersonic-060000286.html -
-
-
- - -
-

Iraqi PM offers resignation after security forces carry out 'bloodbath' killing of protesters

-
-
- # -
-
-

Fri, 29 Nov 2019 08:12:28 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - i
- - r
- - a
- - q
- - i
- - -
- - p
- - m
- - -
- - o
- - f
- - f
- - e
- - r
- - s
- - -
- - r
- - e
- - s
- - i
- - g
- - n
- - a
- - t
- - i
- - o
- - n
- - -
- - s
- - e
- - c
- - u
- - r
- - i
- - t
- - y
- - -
- - 1
- - 3
- - 1
- - 2
- - 2
- - 8
- - 1
- - 0
- - 5
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/iraqi-pm-offers-resignation-security-131228105.html -
-
-
- - -
-

Inmate wanted by ICE released on bail. He was arrested weeks later for attempted murder

-
-
- # -
-
-

Fri, 29 Nov 2019 19:01:47 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - i
- - n
- - m
- - a
- - t
- - e
- - -
- - w
- - a
- - n
- - t
- - e
- - d
- - -
- - i
- - c
- - e
- - -
- - r
- - e
- - l
- - e
- - a
- - s
- - e
- - d
- - -
- - b
- - a
- - i
- - l
- - -
- - 0
- - 0
- - 0
- - 1
- - 4
- - 7
- - 1
- - 8
- - 8
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/inmate-wanted-ice-released-bail-000147188.html -
-
-
- - -
-

Tens of thousands rally in Europe, Asia before UN climate summit

-
-
- # -
-
-

Fri, 29 Nov 2019 17:40:30 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - g
- - l
- - o
- - b
- - a
- - l
- - -
- - c
- - l
- - i
- - m
- - a
- - t
- - e
- - -
- - p
- - r
- - o
- - t
- - e
- - s
- - t
- - s
- - -
- - k
- - i
- - c
- - k
- - -
- - o
- - f
- - f
- - -
- - s
- - m
- - o
- - k
- - e
- - -
- - c
- - o
- - v
- - e
- - r
- - e
- - d
- - -
- - s
- - y
- - d
- - n
- - e
- - y
- - -
- - 0
- - 5
- - 1
- - 0
- - 2
- - 0
- - 0
- - 9
- - 0
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/global-climate-protests-kick-off-smoke-covered-sydney-051020090.html -
-
-
- - -
-

Airlines are joining in on Black Friday with major flight sales — here's how you can save

-
-
- # -
-
-

Thu, 28 Nov 2019 10:54:00 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - a
- - i
- - r
- - l
- - i
- - n
- - e
- - s
- - -
- - j
- - o
- - i
- - n
- - i
- - n
- - g
- - -
- - b
- - l
- - a
- - c
- - k
- - -
- - f
- - r
- - i
- - d
- - a
- - y
- - -
- - m
- - a
- - j
- - o
- - r
- - -
- - 1
- - 5
- - 5
- - 4
- - 0
- - 0
- - 2
- - 3
- - 9
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/airlines-joining-black-friday-major-155400239.html -
-
-
- - -
-

7 Homes for Sale in the Most Secluded Parts of the World

-
-
- # -
-
-

Fri, 29 Nov 2019 08:00:00 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - 7
- - -
- - h
- - o
- - m
- - e
- - s
- - -
- - s
- - a
- - l
- - e
- - -
- - m
- - o
- - s
- - t
- - -
- - s
- - e
- - c
- - l
- - u
- - d
- - e
- - d
- - -
- - 1
- - 3
- - 0
- - 0
- - 0
- - 0
- - 8
- - 4
- - 4
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/7-homes-sale-most-secluded-130000844.html -
-
-
- - -
-

U.S. panel sets deadline for Trump to decide participation in impeachment hearings

-
-
- # -
-
-

Fri, 29 Nov 2019 14:53:11 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - u
- - -
- - p
- - a
- - n
- - e
- - l
- - -
- - g
- - i
- - v
- - e
- - s
- - -
- - t
- - r
- - u
- - m
- - p
- - -
- - d
- - e
- - a
- - d
- - l
- - i
- - n
- - e
- - -
- - 1
- - 9
- - 5
- - 3
- - 1
- - 1
- - 4
- - 3
- - 1
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/u-panel-gives-trump-deadline-195311431.html -
-
-
- - -
-

Families of Mexico massacre victims face backlash after cartel shooting

-
-
- # -
-
-

Fri, 29 Nov 2019 12:46:50 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - f
- - a
- - m
- - i
- - l
- - i
- - e
- - s
- - -
- - m
- - e
- - x
- - i
- - c
- - o
- - -
- - m
- - a
- - s
- - s
- - a
- - c
- - r
- - e
- - -
- - v
- - i
- - c
- - t
- - i
- - m
- - s
- - -
- - f
- - a
- - c
- - e
- - -
- - 1
- - 7
- - 4
- - 6
- - 5
- - 0
- - 3
- - 8
- - 2
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/families-mexico-massacre-victims-face-174650382.html -
-
-
- - -
-

U.S. Rebukes Zambia for Jailing Two Men for Homosexuality

-
-
- # -
-
-

Sat, 30 Nov 2019 07:27:13 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - u
- - -
- - r
- - e
- - b
- - u
- - k
- - e
- - s
- - -
- - z
- - a
- - m
- - b
- - i
- - a
- - -
- - j
- - a
- - i
- - l
- - i
- - n
- - g
- - -
- - t
- - w
- - o
- - -
- - 1
- - 5
- - 3
- - 2
- - 1
- - 4
- - 2
- - 1
- - 4
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/u-rebukes-zambia-jailing-two-153214214.html -
-
-
- - -
-

Is Israel Taking Advantage of Regional Confusion to Expand Its Territory?

-
-
- # -
-
-

Fri, 29 Nov 2019 20:00:00 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - i
- - s
- - r
- - a
- - e
- - l
- - -
- - t
- - a
- - k
- - i
- - n
- - g
- - -
- - a
- - d
- - v
- - a
- - n
- - t
- - a
- - g
- - e
- - -
- - r
- - e
- - g
- - i
- - o
- - n
- - a
- - l
- - -
- - c
- - o
- - n
- - f
- - u
- - s
- - i
- - o
- - n
- - -
- - 0
- - 1
- - 0
- - 0
- - 0
- - 0
- - 8
- - 5
- - 9
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/israel-taking-advantage-regional-confusion-010000859.html -
-
-
- - -
-

Why the LDS Church Joined LGBTQ Advocates in Supporting Utah's Conversion Therapy Ban

-
-
- # -
-
-

Fri, 29 Nov 2019 19:24:22 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - w
- - h
- - y
- - -
- - l
- - d
- - s
- - -
- - c
- - h
- - u
- - r
- - c
- - h
- - -
- - j
- - o
- - i
- - n
- - e
- - d
- - -
- - l
- - g
- - b
- - t
- - q
- - -
- - 0
- - 0
- - 2
- - 4
- - 2
- - 2
- - 4
- - 1
- - 7
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/why-lds-church-joined-lgbtq-002422417.html -
-
-
- - -
-

Thanksgiving photo Bill O'Reilly posted to Twitter freaks people out

-
-
- # -
-
-

Fri, 29 Nov 2019 10:54:49 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - t
- - h
- - a
- - n
- - k
- - s
- - g
- - i
- - v
- - i
- - n
- - g
- - -
- - p
- - h
- - o
- - t
- - o
- - -
- - b
- - i
- - l
- - l
- - -
- - o
- - r
- - e
- - i
- - l
- - l
- - y
- - -
- - p
- - o
- - s
- - t
- - e
- - d
- - -
- - 1
- - 5
- - 5
- - 4
- - 4
- - 9
- - 0
- - 5
- - 6
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/thanksgiving-photo-bill-oreilly-posted-155449056.html -
-
-
- - -
-

Third occupant of Spain 'narco-sub' arrested: police

-
-
- # -
-
-

Fri, 29 Nov 2019 15:54:19 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - t
- - h
- - i
- - r
- - d
- - -
- - o
- - c
- - c
- - u
- - p
- - a
- - n
- - t
- - -
- - s
- - p
- - a
- - i
- - n
- - -
- - n
- - a
- - r
- - c
- - o
- - -
- - s
- - u
- - b
- - -
- - a
- - r
- - r
- - e
- - s
- - t
- - e
- - d
- - -
- - p
- - o
- - l
- - i
- - c
- - e
- - -
- - 2
- - 0
- - 5
- - 4
- - 1
- - 9
- - 2
- - 4
- - 1
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/third-occupant-spain-narco-sub-arrested-police-205419241.html -
-
-
- - -
-

Giraffes among 10 animals killed in 'tragic' Ohio safari wildlife park fire

-
-
- # -
-
-

Fri, 29 Nov 2019 15:12:05 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - f
- - i
- - r
- - e
- - -
- - k
- - i
- - l
- - l
- - s
- - -
- - u
- - n
- - k
- - n
- - o
- - w
- - n
- - -
- - n
- - u
- - m
- - b
- - e
- - r
- - -
- - a
- - n
- - i
- - m
- - a
- - l
- - s
- - -
- - 0
- - 3
- - 1
- - 9
- - 0
- - 7
- - 0
- - 2
- - 2
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/fire-kills-unknown-number-animals-031907022.html -
-
-
- - -
-

Worker who survived New Orleans hotel collapse deported

-
-
- # -
-
-

Fri, 29 Nov 2019 17:52:21 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - w
- - o
- - r
- - k
- - e
- - r
- - -
- - s
- - u
- - r
- - v
- - i
- - v
- - e
- - d
- - -
- - o
- - r
- - l
- - e
- - a
- - n
- - s
- - -
- - h
- - o
- - t
- - e
- - l
- - -
- - c
- - o
- - l
- - l
- - a
- - p
- - s
- - e
- - -
- - 2
- - 2
- - 5
- - 2
- - 2
- - 1
- - 3
- - 0
- - 6
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/worker-survived-orleans-hotel-collapse-225221306.html -
-
-
- - -
-

Trump Pledges to Restart Taliban Peace Talks in Surprise Visit to Afghanistan

-
-
- # -
-
-

Thu, 28 Nov 2019 15:16:28 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - t
- - r
- - u
- - m
- - p
- - -
- - p
- - l
- - e
- - d
- - g
- - e
- - s
- - -
- - r
- - e
- - s
- - t
- - a
- - r
- - t
- - -
- - t
- - a
- - l
- - i
- - b
- - a
- - n
- - -
- - p
- - e
- - a
- - c
- - e
- - -
- - 2
- - 0
- - 1
- - 6
- - 2
- - 8
- - 9
- - 7
- - 1
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/trump-pledges-restart-taliban-peace-201628971.html -
-
-
- - -
-

Russia and China deepen ties with River Amur bridge

-
-
- # -
-
-

Fri, 29 Nov 2019 09:06:47 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - r
- - u
- - s
- - s
- - i
- - a
- - -
- - c
- - h
- - i
- - n
- - a
- - -
- - d
- - e
- - e
- - p
- - e
- - n
- - -
- - t
- - i
- - e
- - s
- - -
- - r
- - i
- - v
- - e
- - r
- - -
- - 1
- - 4
- - 0
- - 6
- - 4
- - 7
- - 7
- - 9
- - 6
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/russia-china-deepen-ties-river-140647796.html -
-
-
- - -
-

Hong Kong Police End Campus Siege After Finding 3,989 Petrol Bombs

-
-
- # -
-
-

Fri, 29 Nov 2019 04:37:38 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - h
- - o
- - n
- - g
- - -
- - k
- - o
- - n
- - g
- - -
- - p
- - o
- - l
- - i
- - c
- - e
- - -
- - m
- - o
- - v
- - e
- - -
- - e
- - n
- - d
- - -
- - 1
- - 5
- - 1
- - 8
- - 1
- - 2
- - 6
- - 6
- - 9
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/hong-kong-police-move-end-151812669.html -
-
-
- - -
-

The China Challenge Continues to Mount

-
-
- # -
-
-

Fri, 29 Nov 2019 16:00:00 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - c
- - h
- - i
- - n
- - a
- - -
- - c
- - h
- - a
- - l
- - l
- - e
- - n
- - g
- - e
- - -
- - c
- - o
- - n
- - t
- - i
- - n
- - u
- - e
- - s
- - -
- - m
- - o
- - u
- - n
- - t
- - -
- - 2
- - 1
- - 0
- - 0
- - 0
- - 0
- - 3
- - 3
- - 6
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/china-challenge-continues-mount-210000336.html -
-
-
- - -
-

Transgender paedophile sues NHS for refusing her reassignment surgery while she serves prison sentence

-
-
- # -
-
-

Thu, 28 Nov 2019 15:11:27 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - t
- - r
- - a
- - n
- - s
- - g
- - e
- - n
- - d
- - e
- - r
- - -
- - p
- - a
- - e
- - d
- - o
- - p
- - h
- - i
- - l
- - e
- - -
- - s
- - u
- - e
- - s
- - -
- - n
- - h
- - s
- - -
- - r
- - e
- - f
- - u
- - s
- - i
- - n
- - g
- - -
- - 2
- - 0
- - 1
- - 1
- - 2
- - 7
- - 4
- - 1
- - 4
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/transgender-paedophile-sues-nhs-refusing-201127414.html -
-
-
- - -
-

The US fertility rate has dropped for the fourth year in a row, and it might forecast a 'demographic time bomb'

-
-
- # -
-
-

Fri, 29 Nov 2019 15:45:03 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - u
- - s
- - -
- - f
- - e
- - r
- - t
- - i
- - l
- - i
- - t
- - y
- - -
- - r
- - a
- - t
- - e
- - -
- - d
- - r
- - o
- - p
- - p
- - e
- - d
- - -
- - f
- - o
- - u
- - r
- - t
- - h
- - -
- - 2
- - 0
- - 4
- - 5
- - 0
- - 3
- - 2
- - 2
- - 5
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/us-fertility-rate-dropped-fourth-204503225.html -
-
-
- - -
-

Romania's 1989 generation relive pain at ex-president's trial

-
-
- # -
-
-

Fri, 29 Nov 2019 10:21:02 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - r
- - o
- - m
- - a
- - n
- - i
- - a
- - s
- - -
- - 1
- - 9
- - 8
- - 9
- - -
- - r
- - e
- - v
- - o
- - l
- - u
- - t
- - i
- - o
- - n
- - -
- - g
- - e
- - n
- - e
- - r
- - a
- - t
- - i
- - o
- - n
- - -
- - a
- - w
- - a
- - i
- - t
- - -
- - e
- - x
- - -
- - p
- - r
- - e
- - s
- - i
- - d
- - e
- - n
- - t
- - s
- - -
- - t
- - r
- - i
- - a
- - l
- - -
- - 0
- - 4
- - 4
- - 0
- - 0
- - 9
- - 8
- - 8
- - 0
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/romanias-1989-revolution-generation-await-ex-presidents-trial-044009880.html -
-
-
- - -
-

UPS workers allegedly trafficked 1,000s of pounds of drugs and fake vape pens across the country

-
-
- # -
-
-

Thu, 28 Nov 2019 09:56:24 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - u
- - p
- - s
- - -
- - w
- - o
- - r
- - k
- - e
- - r
- - s
- - -
- - a
- - l
- - l
- - e
- - g
- - e
- - d
- - l
- - y
- - -
- - t
- - r
- - a
- - f
- - f
- - i
- - c
- - k
- - e
- - d
- - -
- - 1
- - -
- - 1
- - 4
- - 5
- - 6
- - 2
- - 4
- - 4
- - 5
- - 1
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/ups-workers-allegedly-trafficked-1-145624451.html -
-
-
- - -
-

Clemson University students mentor elementary school kids through nonprofit work

-
-
- # -
-
-

Fri, 29 Nov 2019 10:32:02 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - c
- - l
- - e
- - m
- - s
- - o
- - n
- - -
- - u
- - n
- - i
- - v
- - e
- - r
- - s
- - i
- - t
- - y
- - -
- - s
- - t
- - u
- - d
- - e
- - n
- - t
- - s
- - -
- - m
- - e
- - n
- - t
- - o
- - r
- - -
- - e
- - l
- - e
- - m
- - e
- - n
- - t
- - a
- - r
- - y
- - -
- - 1
- - 5
- - 3
- - 2
- - 0
- - 2
- - 2
- - 9
- - 1
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/clemson-university-students-mentor-elementary-153202291.html -
-
-
- - -
-

OK, Mayor: Why 37-Year-Old Pete Buttigieg Is Attracting Boomers

-
-
- # -
-
-

Thu, 28 Nov 2019 15:00:49 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - o
- - k
- - -
- - m
- - a
- - y
- - o
- - r
- - -
- - w
- - h
- - y
- - -
- - 3
- - 7
- - -
- - o
- - l
- - d
- - -
- - 2
- - 0
- - 0
- - 0
- - 4
- - 9
- - 2
- - 2
- - 8
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/ok-mayor-why-37-old-200049228.html -
-
-
- - -
-

Pakistani man aims to bring shade to Iraq's Arbaeen pilgrims

-
-
- # -
-
-

Fri, 29 Nov 2019 06:16:34 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - p
- - a
- - k
- - i
- - s
- - t
- - a
- - n
- - i
- - -
- - m
- - a
- - n
- - -
- - a
- - i
- - m
- - s
- - -
- - b
- - r
- - i
- - n
- - g
- - -
- - s
- - h
- - a
- - d
- - e
- - -
- - 1
- - 1
- - 1
- - 6
- - 3
- - 4
- - 4
- - 3
- - 3
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/pakistani-man-aims-bring-shade-111634433.html -
-
-
- - -
-

Malaysia Drains Crowdsourced ‘Hope Fund’ to Repay 1MDB Debt

-
-
- # -
-
-

Sat, 30 Nov 2019 00:15:40 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - m
- - a
- - l
- - a
- - y
- - s
- - i
- - a
- - -
- - d
- - r
- - a
- - i
- - n
- - s
- - -
- - c
- - r
- - o
- - w
- - d
- - s
- - o
- - u
- - r
- - c
- - e
- - d
- - -
- - h
- - o
- - p
- - e
- - -
- - f
- - u
- - n
- - d
- - -
- - 0
- - 5
- - 1
- - 5
- - 4
- - 0
- - 4
- - 0
- - 0
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/malaysia-drains-crowdsourced-hope-fund-051540400.html -
-
-
- - -
-

This Is America's Role in Saudi Arabia's Power Struggle

-
-
- # -
-
-

Fri, 29 Nov 2019 15:00:00 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - a
- - m
- - e
- - r
- - i
- - c
- - a
- - s
- - -
- - r
- - o
- - l
- - e
- - -
- - s
- - a
- - u
- - d
- - i
- - -
- - a
- - r
- - a
- - b
- - i
- - a
- - s
- - -
- - p
- - o
- - w
- - e
- - r
- - -
- - 2
- - 0
- - 0
- - 0
- - 0
- - 0
- - 9
- - 0
- - 0
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/americas-role-saudi-arabias-power-200000900.html -
-
-
- - -
-

River watchers already wary about 2020 spring flooding

-
-
- # -
-
-

Fri, 29 Nov 2019 14:03:28 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - r
- - i
- - v
- - e
- - r
- - -
- - w
- - a
- - t
- - c
- - h
- - e
- - r
- - s
- - -
- - a
- - l
- - r
- - e
- - a
- - d
- - y
- - -
- - w
- - a
- - r
- - y
- - -
- - 2
- - 0
- - 2
- - 0
- - -
- - 1
- - 9
- - 0
- - 3
- - 2
- - 8
- - 1
- - 4
- - 4
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/river-watchers-already-wary-2020-190328144.html -
-
-
- - -
-

Brother of convicted terrorist faces deportation despite US citizenship

-
-
- # -
-
-

Sat, 30 Nov 2019 02:30:29 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - b
- - r
- - o
- - t
- - h
- - e
- - r
- - -
- - c
- - o
- - n
- - v
- - i
- - c
- - t
- - e
- - d
- - -
- - t
- - e
- - r
- - r
- - o
- - r
- - i
- - s
- - t
- - -
- - f
- - a
- - c
- - e
- - s
- - -
- - d
- - e
- - p
- - o
- - r
- - t
- - a
- - t
- - i
- - o
- - n
- - -
- - 0
- - 7
- - 3
- - 0
- - 2
- - 9
- - 0
- - 1
- - 6
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/brother-convicted-terrorist-faces-deportation-073029016.html -
-
-
- - -
-

Zimbabwe facing 'man-made' starvation, UN expert warns

-
-
- # -
-
-

Thu, 28 Nov 2019 20:40:03 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - z
- - i
- - m
- - b
- - a
- - b
- - w
- - e
- - -
- - f
- - a
- - c
- - i
- - n
- - g
- - -
- - m
- - a
- - n
- - -
- - m
- - a
- - d
- - e
- - -
- - s
- - t
- - a
- - r
- - v
- - a
- - t
- - i
- - o
- - n
- - -
- - u
- - n
- - -
- - e
- - x
- - p
- - e
- - r
- - t
- - -
- - w
- - a
- - r
- - n
- - s
- - -
- - 1
- - 9
- - 0
- - 5
- - 0
- - 8
- - 6
- - 9
- - 9
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/zimbabwe-facing-man-made-starvation-un-expert-warns-190508699.html -
-
-
- - -
-

2 victims were killed and police fatally shot a man wearing a hoax explosive vest in a terrorist attack at London Bridge

-
-
- # -
-
-

Fri, 29 Nov 2019 20:47:00 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - p
- - o
- - l
- - i
- - c
- - e
- - -
- - n
- - u
- - m
- - b
- - e
- - r
- - -
- - p
- - e
- - o
- - p
- - l
- - e
- - -
- - i
- - n
- - j
- - u
- - r
- - e
- - d
- - -
- - s
- - t
- - a
- - b
- - b
- - i
- - n
- - g
- - -
- - 1
- - 4
- - 4
- - 5
- - 5
- - 4
- - 5
- - 0
- - 2
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/police-number-people-injured-stabbing-144554502.html -
-
-
- - -
-

Expectant mother gives birth on American Airlines jetway; gives daughter appropriate name

-
-
- # -
-
-

Fri, 29 Nov 2019 19:29:08 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - e
- - x
- - p
- - e
- - c
- - t
- - a
- - n
- - t
- - -
- - m
- - o
- - t
- - h
- - e
- - r
- - -
- - g
- - i
- - v
- - e
- - s
- - -
- - b
- - i
- - r
- - t
- - h
- - -
- - a
- - m
- - e
- - r
- - i
- - c
- - a
- - n
- - -
- - 0
- - 0
- - 2
- - 9
- - 0
- - 8
- - 6
- - 3
- - 6
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/expectant-mother-gives-birth-american-002908636.html -
-
-
- - -
-

We Aid the Growth of Chinese Tyranny to Our Eternal Shame

-
-
- # -
-
-

Fri, 29 Nov 2019 06:30:03 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - a
- - i
- - d
- - -
- - g
- - r
- - o
- - w
- - t
- - h
- - -
- - c
- - h
- - i
- - n
- - e
- - s
- - e
- - -
- - t
- - y
- - r
- - a
- - n
- - n
- - y
- - -
- - e
- - t
- - e
- - r
- - n
- - a
- - l
- - -
- - 1
- - 1
- - 3
- - 0
- - 0
- - 3
- - 5
- - 6
- - 6
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/aid-growth-chinese-tyranny-eternal-113003566.html -
-
-
- - -
-

World-famous free solo climber Brad Gobright falls 1,000 feet to his death

-
-
- # -
-
-

Fri, 29 Nov 2019 08:45:06 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - w
- - o
- - r
- - l
- - d
- - -
- - f
- - a
- - m
- - o
- - u
- - s
- - -
- - f
- - r
- - e
- - e
- - -
- - s
- - o
- - l
- - o
- - -
- - c
- - l
- - i
- - m
- - b
- - e
- - r
- - -
- - 1
- - 3
- - 4
- - 5
- - 0
- - 6
- - 8
- - 0
- - 8
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/world-famous-free-solo-climber-134506808.html -
-
-
- - -
-

Indonesian gymnast dropped after told 'she's no longer a virgin'

-
-
- # -
-
-

Fri, 29 Nov 2019 06:13:51 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - i
- - n
- - d
- - o
- - n
- - e
- - s
- - i
- - a
- - n
- - -
- - g
- - y
- - m
- - n
- - a
- - s
- - t
- - -
- - d
- - r
- - o
- - p
- - p
- - e
- - d
- - -
- - t
- - o
- - l
- - d
- - -
- - s
- - h
- - e
- - s
- - -
- - 1
- - 1
- - 1
- - 3
- - 5
- - 1
- - 2
- - 5
- - 0
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/indonesian-gymnast-dropped-told-shes-111351250.html -
-
-
- - -
-

Donald Trump Sees Another Opportunity to Teach Cuba a Lesson

-
-
- # -
-
-

Fri, 29 Nov 2019 08:30:00 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - d
- - o
- - n
- - a
- - l
- - d
- - -
- - t
- - r
- - u
- - m
- - p
- - -
- - s
- - e
- - e
- - s
- - -
- - a
- - n
- - o
- - t
- - h
- - e
- - r
- - -
- - o
- - p
- - p
- - o
- - r
- - t
- - u
- - n
- - i
- - t
- - y
- - -
- - 1
- - 3
- - 3
- - 0
- - 0
- - 0
- - 2
- - 4
- - 2
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/donald-trump-sees-another-opportunity-133000242.html -
-
-
- - -
-

Japan Won’t Sign China-Backed Trade Deal If India Doesn’t Join

-
-
- # -
-
-

Thu, 28 Nov 2019 23:15:10 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - j
- - a
- - p
- - a
- - n
- - -
- - w
- - o
- - n
- - -
- - t
- - -
- - s
- - i
- - g
- - n
- - -
- - c
- - h
- - i
- - n
- - a
- - -
- - 0
- - 4
- - 1
- - 5
- - 1
- - 0
- - 3
- - 6
- - 9
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/japan-won-t-sign-china-041510369.html -
-
-
- - -
-

U.S. planned to separate 26,000 migrant families in 2018

-
-
- # -
-
-

Fri, 29 Nov 2019 06:41:19 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - u
- - -
- - p
- - l
- - a
- - n
- - n
- - e
- - d
- - -
- - s
- - e
- - p
- - a
- - r
- - a
- - t
- - e
- - -
- - 2
- - 6
- - -
- - 0
- - 0
- - 0
- - -
- - 1
- - 2
- - 0
- - 0
- - 2
- - 9
- - 3
- - 6
- - 1
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/u-planned-separate-26-000-120029361.html -
-
-
- - -
-

Albanians hold mass funeral for earthquake victims

-
-
- # -
-
-

Fri, 29 Nov 2019 10:28:07 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - a
- - l
- - m
- - o
- - s
- - t
- - -
- - 5
- - 0
- - -
- - d
- - e
- - a
- - d
- - -
- - m
- - o
- - r
- - e
- - -
- - 5
- - -
- - 0
- - 0
- - 0
- - -
- - d
- - i
- - s
- - p
- - l
- - a
- - c
- - e
- - d
- - -
- - a
- - l
- - b
- - a
- - n
- - i
- - a
- - -
- - 1
- - 0
- - 4
- - 5
- - 2
- - 3
- - 4
- - 7
- - 5
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/almost-50-dead-more-5-000-displaced-albania-104523475.html -
-
-
- - -
-

Germany to make anti-Semitism a specific hate crime as Jews 'no longer feel safe'

-
-
- # -
-
-

Fri, 29 Nov 2019 12:02:48 -0500

-

- -

- Links

- - [
- - 1
- - ]
- - :
- -
- - h
- - t
- - t
- - p
- - s
- - :
- - /
- - /
- - n
- - e
- - w
- - s
- - .
- - y
- - a
- - h
- - o
- - o
- - .
- - c
- - o
- - m
- - /
- - g
- - e
- - r
- - m
- - a
- - n
- - y
- - -
- - c
- - r
- - a
- - c
- - k
- - -
- - d
- - o
- - w
- - n
- - -
- - a
- - n
- - t
- - i
- - -
- - s
- - e
- - m
- - i
- - t
- - i
- - c
- - -
- - 1
- - 7
- - 0
- - 2
- - 4
- - 8
- - 8
- - 8
- - 5
- - .
- - h
- - t
- - m
- - l
- -
- - (
- - t
- - e
- - x
- - t
- - /
- - h
- - t
- - m
- - l
- - )
- - -
- -
- Source: https://news.yahoo.com/germany-crack-down-anti-semitic-170248885.html -
-
-
- -
- - \ No newline at end of file diff --git a/~/test/test3.html b/~/test/test3.html deleted file mode 100755 index 58fad51..0000000 --- a/~/test/test3.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - Yahoo News - Latest News & Headlines - - -
-
-

Yahoo News - Latest News & Headlines

-

- https://news.yahoo.com/rss/ -

- -
-
-
-

- At least 14 killed in bloody gunfight in northern Mexico -

-
-
-
-
-

- Ten suspected cartel gunmen and four police were killed during a shootout on Saturday in a Mexican town near the U.S. border, days after U.S. President Donald Trump raised bilateral tensions by saying he would designate the gangs as terrorists. The government of the northern state of Coahuila said state police clashed at midday with a group of heavily armed gunmen riding in pickup trucks in the small town of Villa Union, about 40 miles (65 km) southwest of the border city of Piedras Negras. Standing outside the Villa Union mayor's bullet-ridden offices, Coahuila Governor Miguel Angel Riquelme told reporters the state had acted "decisively" to tackle the cartel henchmen. -

- links: - - - - source: https://news.yahoo.com/least-14-killed-during-gunfight-022233104.html -
-

- 2019-12-01 02:22:33 -

-
-
-
-
-
-
-
-

- Plane crash kills nine, injures three in South Dakota -

-
-
-
- -
-
-
-

- A plane crash in the US state of South Dakota killed nine people, including two children, and injured three others on Saturday while a winter storm warning was in place, officials said. The Pilatus PC-12, a single-engine turboprop plane, crashed shortly after take-off approximately a mile from the Chamberlain airport, the Federal Aviation Administration (FAA) said. Among the dead was the plane's pilot, Brule County state's attorney Theresa Maule Rossow said, adding that a total of 12 people had been on board. -

- links: - - - - source: https://news.yahoo.com/plane-crash-kills-nine-injures-three-south-dakota-042236507.html -
-

- 2019-12-01 04:22:36 -

-
-
-
-
-
-
-
-

- Trump won't lose his job – but the impeachment inquiry is still essential -

-
-
-
- -
-
-
-

- The process is required by the constitution, seems to be shifting voters’ opinions, and will render the president unpardonable‘A failure by Congress to respond to these abuses would effectively render the constitution meaningless.’ Photograph: J Scott Applewhite/APNot even overwhelming evidence that Trump sought to bribe a foreign power to dig up dirt on his leading political opponent in 202o – and did so with American taxpayer dollars, while compromising American foreign policy – will cause Trump to be removed from office.That’s because there’s zero chance that 2o Republican senators – the number needed to convict Trump, if every Democratic senator votes to do so – have enough integrity to do what the constitution requires them to do.These Republican senators will put their jobs and their political party ahead of the constitution and the country. They will tell themselves that 88% of Republican voters still support Trump, and that their duty is to them.It does not matter that these voters inhabit a parallel political universe consisting of Trump tweets, Fox News, rightwing radio, and Trump-Russian social media, all propounding the absurd counter-narrative that Democrats, the “deep state”, coastal elites, and mainstream media are conspiring to remove the Chosen One from office.So if there’s no chance of getting the 20 Republican votes needed to send Trump packing, is there any reason for this impeachment proceeding to continue?Yes. There are three reasons.The first is the constitution itself. Donald Trump has openly abused his power – not only seeking electoral help from foreign nations but making money off his presidency in violation of the emoluments clause, spending funds never appropriated by Congress in violation of the separation of powers, obstructing justice, and violating his oath to faithfully execute the law.A failure by Congress to respond to these abuses would effectively render the constitution meaningless. Congress has no alternative but to respond.The second reason is political. While the impeachment hearings don’t appear to have moved Republican voters, only 29% of Americans still identify as Republican.The hearings do seem to have affected Democrats and independents, as well as many people who sat out the 2016 election. National polls by Morning Consult/Politico and SSRS/CNN show that 50% of respondents now support both impeaching Trump and removing him from office, an increase from Morning Consult/Politico’s mid-November poll.Presumably anyone who now favors removing Trump from office will be inclined to vote against him next November. The House’s impeachment could therefore swing the 2020 election against him.The third reason for the House to impeach Trump even if the Senate won’t convict him concerns the pardoning power of the president.Assume that Trump is impeached on grounds that include a raft of federal crimes – bribery, treason, obstruction of justice, election fraud, money laundering, conspiracy to defraud the United States, making false statements to the federal government, serving as an agent of a foreign government without registering with the justice department, donating funds from foreign nationals, and so on.Regardless of whether a sitting president can be indicted and convicted on such criminal charges, Trump will become liable to them at some point. But could he be pardoned, as Gerald Ford pardoned Richard Nixon 45 years ago?Article II, section 2 of the constitution gives a president the power to pardon anyone who has been convicted of offenses against the United States, with one exception: “in Cases of Impeachment.”If Trump is impeached by the House, he can never be pardoned for these crimes. He cannot pardon himself (it’s dubious that a president has this self-pardoning power in any event), and he cannot be pardoned by a future president.Even if a subsequent president wanted to pardon Trump in the interest of, say, domestic tranquility, she could not.Gerald Ford wrote in his pardon of Nixon that if Nixon were indicted and subject to a criminal trial, “the tranquility to which this nation has been restored by the events of recent weeks could be irreparably lost”.Had the House impeached Nixon, Ford’s hands would have been tied.Trump is not going to be so lucky. The House will probably impeach him before Christmas and then his chance of getting a pardon for his many crimes will be gone. -

- links: - - - - source: https://news.yahoo.com/trump-wont-lose-job-impeachment-060002657.html -
-

- 2019-12-01 06:00:02 -

-
-
-
-
-
-
-
-
- - \ No newline at end of file From ed06aa8fbaf35676702fe45670e9297f79def0bc Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 1 Dec 2019 23:16:18 +0300 Subject: [PATCH 20/26] add docstring to cache module --- src/components/cache/cache.py | 95 +++++++++++++++++++++----- src/components/cache/db/sqlite.py | 96 +++++++++++++++++++++++---- src/components/feed/feed_formatter.py | 2 + 3 files changed, 163 insertions(+), 30 deletions(-) diff --git a/src/components/cache/cache.py b/src/components/cache/cache.py index 3adb9cb..129f9ba 100644 --- a/src/components/cache/cache.py +++ b/src/components/cache/cache.py @@ -1,3 +1,5 @@ +"""this module contain class for caching feeds""" + from src.components.cache.db.sqlite import Sqlite from src.components.logger import Logger from src.components.helper import Singleton @@ -13,16 +15,29 @@ class Cache(Singleton): + """ + This class represent store, update cache data, access to storing cache feed entries - _db_name = 'cache.db' + Attributes: + _db_name attribute contain default name of database + """ - def __init__(self) -> None : + _db_name = 'cache.db' + def __init__(self) -> None: + """ + This constructor provide represent sqlite3 db layer instance + for work with database + """ self._cache_db_file = self._storage_initialize() self._db = Sqlite(str(self._cache_db_file)) - def _storage_initialize(self): - + def _storage_initialize(self) -> Path: + """ + Ths method check on existence of cache database and init + it in case database not found. Return path to database + :return: Path + """ cache_path = Path.home().joinpath('.' + conf.__package__) if not cache_path.exists(): @@ -40,7 +55,12 @@ def _storage_initialize(self): return cache_file def append_feeds(self, feed: Map, feed_entities_list: list) -> None: - + """ + This method append or update feeds entries from Feed to cache storage + :param feed: Map + :param feed_entities_list:list + :return: None + """ Logger.log(f'Check on feed cache exist on url: {feed.url}') feed_id = self._db.find_where('feeds', 'url', feed.url, 'like') @@ -63,8 +83,14 @@ def append_feeds(self, feed: Map, feed_entities_list: list) -> None: self._db.close() - def _insert_feed_entry_into_cache(self, feed_id, entry: FeedEntry): - + def _insert_feed_entry_into_cache(self, feed_id: int, entry: FeedEntry) -> None: + """ + This method insert feed entry of rss feed into cache storage. Also creating + feed entry general data, entry links, entry media + :param feed_id: int + :param entry: FeedEntry + :return: None + """ self._write_feed_entry_general(entry, feed_id) feed_entry_id = self._db.cursor.lastrowid @@ -72,7 +98,12 @@ def _insert_feed_entry_into_cache(self, feed_id, entry: FeedEntry): self._write_feed_entry_links(feed_entry_id, entry) self._write_feed_entry_media(feed_entry_id, entry) - def _insert_feed_data(self, feed: Map): + def _insert_feed_data(self, feed: Map) -> int: + """ + This method store rss feed data into cache storage + :param feed: Map + :return: int + """ Logger.log(f'Add feed cache exist on url: {feed.url}') self._db.write('feeds', [ @@ -87,15 +118,26 @@ def _insert_feed_data(self, feed: Map): return self._db.cursor.lastrowid - def _write_feed_entry_general(self, entry: FeedEntry, feed_id): + def _write_feed_entry_general(self, entry: FeedEntry, feed_id: int) -> None: + """ + Insert feed entry general data into cache driver + :param entry: FeedEntry + :param feed_id: int + :return: None + """ return self._db.write( 'feeds_entries', ['feed_id','title','description','link','published'], [feed_id,html.escape(entry.title),html.escape(entry.description),entry.link,entry.published,] ) - def _write_feed_entry_links(self, feed_entry_id, entry: FeedEntry): - + def _write_feed_entry_links(self, feed_entry_id: int, entry: FeedEntry) -> None: + """ + Insert feed entry links data into cache driver + :param feed_entry_id: int + :param entry: FeedEntry + :return: None + """ for link in entry.links: return self._db.write( 'feed_entry_links', @@ -103,15 +145,28 @@ def _write_feed_entry_links(self, feed_entry_id, entry: FeedEntry): [feed_entry_id, link.href,link.type,] ) - def _write_feed_entry_media(self, feed_entry_id, entry: FeedEntry): - + def _write_feed_entry_media(self, feed_entry_id: int, entry: FeedEntry) -> None: + """ + Insert feed entry media data into cache driver + :param feed_entry_id: int + :param entry: FeedEntry + :return: None + """ for media in entry.media: return self._db.write('feed_entry_media', ['feed_entry_id', 'url','additional',], [feed_entry_id,media.url,html.escape(media.alt),] ) - def load_feeds_entries(self, url: str, date: str, limit=100) -> list: + def load_feeds_entries(self, url: str, date: str, limit: int = 100) -> list: + """ + This method load feed entries from cache storage to Feed. + If cache entries not found raised Exception + :param url: str + :param date: str + :param limit: int + :return: list + """ Logger.log( f'Load file from cache storage ' f'{date.strftime("from %d, %b %Y")}' @@ -124,15 +179,21 @@ def load_feeds_entries(self, url: str, date: str, limit=100) -> list: if not cache_list: raise Exception( - f'Cache retrive nothing. Storage for specified data is empty ' + f'Cache retrieve nothing. Storage for specified data is empty ' f'{date.strftime("from %d, %b %Y")}' f'{(date + timedelta(days=1)).strftime(" to %d, %b %Y")}' ) return self._db.map_data(cache_list) - def _get_specify_by_date(self, url, date, limit=100): - + def _get_specify_by_date(self, url: str, date, limit: int = 100) -> list: + """ + Retrieve cache data from storage by specified date from console + :param url: str + :param date: str + :param limit: int + :return: list + """ feed_id = self._db.find_where('feeds', 'url', url, 'like') cache_general_data = self._db.where('feeds_entries', diff --git a/src/components/cache/db/sqlite.py b/src/components/cache/db/sqlite.py index dd42157..4c1499e 100644 --- a/src/components/cache/db/sqlite.py +++ b/src/components/cache/db/sqlite.py @@ -1,3 +1,5 @@ +"""this module contain class layer for sqllite3""" + import sqlite3 import sys from .sqlite_scripts import scripts @@ -5,14 +7,26 @@ class Sqlite: - def __init__(self, path): - + """This class provided layer over sqllite3 for standard crud operation + and help store cache into database""" + + def __init__(self, path: str) -> None: + """ + This constructor start open connection to sqllite database + :param path: str + """ self.conn = None self.cursor = None self.open(path) def open(self, path: str) -> None: + """ + This method try to open sqlite connection and set current connection cursor + Otherwise raised exceptions + :param path: str + :return: None + """ try: self.conn = sqlite3.connect(path, isolation_level=None) @@ -22,14 +36,23 @@ def open(self, path: str) -> None: except sqlite3.Error as e: sys.exit(e) - def close(self): - + def close(self) -> None: + """ + This method commit changes and close connection + :return: None + """ if self.conn: self.conn.commit() self.cursor.close() self.conn.close() - def map_data(self, data): + @classmethod + def map_data(self, data: dict) -> list: + """ + This method wrap retrieving data to Map object for proper usage + :param data: dict + :return: list + """ if isinstance(data, sqlite3.Cursor): return [Map(row) for row in data.fetchall()] @@ -37,7 +60,13 @@ def map_data(self, data): @classmethod - def create_database(self, path: str) -> str: + def create_database(self, path: str) -> None: + """ + This method create cache storage database from sqllite_scripts + Otherwise raised exceptions + :param path: str + :return: None + """ try: self.conn = sqlite3.connect(path, isolation_level=None) self.conn.row_factory = sqlite3.Row @@ -53,17 +82,36 @@ def create_database(self, path: str) -> str: except sqlite3.Error as e: sys.exit(e) - def get(self, table, columns, limit=100): - + def get(self, table: str, columns: str, limit: int=100) -> list: + """ + This method retrieve data from specific table + :param table: str + :param columns: str + :param limit: int + :return: list + """ query = scripts.get.format(columns, table, limit) self.cursor.execute(query) return self.cursor.fetchall() - def get_last(self, table, columns): + def get_last(self, table: str, columns: str): + """ + This method retrieve last entry from specific table + :param table: str + :param columns: str + :return: + """ return self.get(table, columns, limit=1)[0] - def where(self, table: str, *where: list, limit: int=100): + def where(self, table: str, *where: list, limit: int=100) -> list: + """ + This method retrieve data with specific where statements + :param table: str + :param where: list + :param limit: int + :return: list + """ where = ' AND '.join('{} {} "{}" '.format(item[0], item[1], item[2]) for item in where) @@ -73,7 +121,15 @@ def where(self, table: str, *where: list, limit: int=100): return self.cursor.fetchall() - def find_where(self, table, column, value, type='='): + def find_where(self, table: str, column: str, value, type: str='=') -> int: + """ + This method retrieve id from single entry found by specific statement + :param table: str + :param column: str + :param value: Union[int, str] + :param type: str + :return: int + """ query = scripts.find_where.format(table, column, type, value) @@ -82,7 +138,14 @@ def find_where(self, table, column, value, type='='): return row[0] if row is not None else False - def write(self, table, columns, data): + def write(self, table: str, columns: list, data: list) -> None: + """ + This method write provided data + :param table: str + :param columns: list + :param data: list + :return: None + """ query = scripts.write.format( table, ', '.join(column for column in columns) , ', '.join( "'" + str(item) + "'" for item in data) @@ -90,10 +153,17 @@ def write(self, table, columns, data): self.cursor.execute(query) - def query(self, sql, *args): + def query(self, sql: str, *args): + """ + This method provide wrap on query for further methods usage + :param sql: str + :param args: * + :return: + """ self.cursor = self.conn.cursor() return self.cursor.execute(sql, args) def __exit__(self, exc_type, exc_value, traceback): + """ Close connection on exit""" self.close() diff --git a/src/components/feed/feed_formatter.py b/src/components/feed/feed_formatter.py index c97ce68..c53d28d 100644 --- a/src/components/feed/feed_formatter.py +++ b/src/components/feed/feed_formatter.py @@ -5,6 +5,7 @@ from fabulous.text import Text from datetime import time, datetime from src.components.helper import Map +import conf class FeedFormatter: @@ -43,6 +44,7 @@ def _default_output(cls, entries: list, limit: int, top_data_output: Map, is_col """ if is_colorize: print(Text("Console Rss Reader!", fsize=19, color='#f44a41', shadow=False, skew=4)) + formatted_feeds = ''.join(cls._colorize_single_feed_format_default(feed) for feed in entries[:limit]) else: formatted_feeds = ''.join(cls._single_feed_format_default(feed) for feed in entries[:limit]) From bc21b9720395b54541dc159b629fe8c430f15ee0 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 1 Dec 2019 23:45:57 +0300 Subject: [PATCH 21/26] modify Redme. add Manifest. change json structure --- MANIFEST.in | 1 + README.md | 52 +++++++++++++++++++++++++++ conf.py | 2 +- src/components/feed/feed_formatter.py | 3 +- 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..540b720 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include requirements.txt \ No newline at end of file diff --git a/README.md b/README.md index bc1afc4..48ee4ca 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,55 @@ # RSS reader Python RSS reader - command-line utility. + +## [Usage]: + * positional\required arguments: + * source .. RSS URL + * optional arguments: + * -h, --help .. Show help message and exit. + * --version .. Print version info. + * --json .. Print result as JSON in stdout. + * --verbose .. Outputs verbose status messages. + * --limit .. Limit news topics if this parameter is provided. + * --date .. Return cached news from the specified day. Format is YYYYMMDD. + * --to-html .. Convert news into html format and save a file to the specified path. + * --to-pdf .. Convert news into pdf format and save a file to the specified path. + * --colorize .. Output in colorized mode + + ## [Cache] + Cached Rss feeds are stored in `~/.rss-reader` folder in `cache.db` file. Cache use sqllite3 for storing Rss feeds. + When you run utility cache module always storing or updating [if news already exists] parsing news from Rss feed. + +## [Converter] + News can be converted into `HTML` and `PDF` formats. If the file already exists at the specified path, it will be overwritten. + + + ## [JSON structure] +
+{
+  "title": "Yahoo News - Latest News & Headlines",
+  "url": "https://news.yahoo.com/rss/",
+  "image": "http://l.yimg.com/rz/d/yahoo_news_en-US_s_f_p_168x21_news.png",
+  "entries": [
+    {
+      "entry": {
+        "link": "https://news.yahoo.com/1-protesters-burn-tyres-southern-113205795.html",
+        "body": {
+          "title": "UPDATE 3-Iraq protesters burn shrine entrance in holy city, PM quitting 'not enough'",
+          "date": "Sat, 30 Nov 2019 - [11:32:05]",
+          "links": [
+            {
+              "rel": "alternate",
+              "type": "text/html",
+              "href": "https://news.yahoo.com/1-protesters-burn-tyres-southern-113205795.html"
+            }
+          ],
+          "media": [],
+          "description": "Iraqi protesters set fire to the entrance of a shrine in the southern holy city of Najaf on Saturday and security forces fired tear gas to disperse them, police and a demonstrator at the scene said, risking more bloodshed after a rare day of calm.  The demonstrator sent a video to Reuters of a doorway to the Hakim shrine blazing as protesters cheered and filmed it on their mobile phones.  The incident took place during one of the bloodiest weeks of Iraq\u2019s anti-government unrest, which erupted last month."
+        }
+      }
+    }
+  ]
+}
+
+
diff --git a/conf.py b/conf.py index 650414c..48481ef 100644 --- a/conf.py +++ b/conf.py @@ -1,6 +1,6 @@ __author__ = 'Mikhan Victor' __email__ = 'victormikhan@gmail.com' __package__ = 'rss-reader' -__version__ = '1.4.0' +__version__ = '1.6.0' __description__ = 'RSS Reader' __url__ = 'https://github.com/victormikhan/PythonHomework' diff --git a/src/components/feed/feed_formatter.py b/src/components/feed/feed_formatter.py index c53d28d..be338bb 100644 --- a/src/components/feed/feed_formatter.py +++ b/src/components/feed/feed_formatter.py @@ -127,7 +127,8 @@ def _single_feed_format_json(cls, entry: object) -> str: "body": { "title": entry.title, "date": str(cls.human_date(entry.published)), - "links": cls.format_links(entry.links), + "links": [link for link in entry.links], + "media": [media for media in entry.media], "description": entry.description } } From 32b3b2614eae67d302893d5f45a58eb4b5ca8fc8 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Mon, 2 Dec 2019 00:10:16 +0300 Subject: [PATCH 22/26] just change some small tip in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3f05740..41be054 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ def get_install_requirements(): python_requires='>=3.6', entry_points={ 'console_scripts': - ['%s = __main__:main' % conf.__package__] + ['%s = src.__main__:main' % conf.__package__] } ) From 5d4928234845392ab19f8e861d05bdc54bbecd67 Mon Sep 17 00:00:00 2001 From: victormikhan <45571039+victormikhan@users.noreply.github.com> Date: Mon, 2 Dec 2019 00:27:48 +0300 Subject: [PATCH 23/26] another tip in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 41be054..3f05740 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ def get_install_requirements(): python_requires='>=3.6', entry_points={ 'console_scripts': - ['%s = src.__main__:main' % conf.__package__] + ['%s = __main__:main' % conf.__package__] } ) From 9dd95d0e00a86f49b1ca0c0ec02ae9693110e937 Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 8 Dec 2019 12:14:28 +0300 Subject: [PATCH 24/26] fix install bug --- setup.py | 8 ++++---- src/__init__.py | 2 +- src/components/cache/cache.py | 4 ++-- src/components/converter/converter_abstract.py | 2 +- src/components/feed/feed_formatter.py | 5 ++--- src/components/parser/arguments/optional/version.py | 2 +- conf.py => src/conf.py | 0 src/rss_reader.py | 2 +- 8 files changed, 12 insertions(+), 13 deletions(-) rename conf.py => src/conf.py (100%) diff --git a/setup.py b/setup.py index 3f05740..a037e8f 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ import setuptools -import conf +from src import conf from pathlib import Path here = Path(__file__).parent @@ -24,12 +24,12 @@ def get_install_requirements(): long_description=conf.__description__, long_description_content_type='text/markdown', url=conf.__url__, - packages=setuptools.find_packages(where='src'), - package_dir={'': 'src'}, + packages=setuptools.find_packages(), + # package_dir={'': 'src'}, install_requires=get_install_requirements(), python_requires='>=3.6', entry_points={ 'console_scripts': - ['%s = __main__:main' % conf.__package__] + ['%s = src.rss_reader:main' % conf.__package__] } ) diff --git a/src/__init__.py b/src/__init__.py index 41b878c..1377f57 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1 +1 @@ -from . import components \ No newline at end of file +from . import components diff --git a/src/components/cache/cache.py b/src/components/cache/cache.py index 129f9ba..32f1556 100644 --- a/src/components/cache/cache.py +++ b/src/components/cache/cache.py @@ -10,7 +10,7 @@ from datetime import timedelta from datetime import datetime from pathlib import Path -import conf +from src import conf import html @@ -183,7 +183,7 @@ def load_feeds_entries(self, url: str, date: str, limit: int = 100) -> list: f'{date.strftime("from %d, %b %Y")}' f'{(date + timedelta(days=1)).strftime(" to %d, %b %Y")}' ) - + #@TODO:wrap into CacheEntry return self._db.map_data(cache_list) def _get_specify_by_date(self, url: str, date, limit: int = 100) -> list: diff --git a/src/components/converter/converter_abstract.py b/src/components/converter/converter_abstract.py index 9c7d941..6682eda 100644 --- a/src/components/converter/converter_abstract.py +++ b/src/components/converter/converter_abstract.py @@ -3,7 +3,7 @@ from src.components.logger import Logger from abc import ABC, abstractmethod from pathlib import Path -import conf +from src import conf import urllib.request as request from src.components.feed import Feed from src.components.feed import FeedEntry diff --git a/src/components/feed/feed_formatter.py b/src/components/feed/feed_formatter.py index be338bb..e762c7a 100644 --- a/src/components/feed/feed_formatter.py +++ b/src/components/feed/feed_formatter.py @@ -1,11 +1,10 @@ """this module contain class for various formatter output parsing data in console """ import json -from fabulous import image, color +from fabulous import color from fabulous.text import Text -from datetime import time, datetime +from datetime import datetime from src.components.helper import Map -import conf class FeedFormatter: diff --git a/src/components/parser/arguments/optional/version.py b/src/components/parser/arguments/optional/version.py index c8b4bfd..0a88a78 100644 --- a/src/components/parser/arguments/optional/version.py +++ b/src/components/parser/arguments/optional/version.py @@ -1,7 +1,7 @@ """This module contain class representing cli optional argument""" from .. import ArgumentsAbstract -import conf +from src import conf import argparse diff --git a/conf.py b/src/conf.py similarity index 100% rename from conf.py rename to src/conf.py diff --git a/src/rss_reader.py b/src/rss_reader.py index 5bde730..367f6c7 100644 --- a/src/rss_reader.py +++ b/src/rss_reader.py @@ -6,7 +6,7 @@ from .components.logger.logger import Logger from .components.converter.html import HtmlConverter from .components.converter.pdf import PdfConverter -import conf +from src import conf class App(Singleton): From aa1789a9179e74b6521a3c474e7310b83add999b Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 8 Dec 2019 19:42:33 +0300 Subject: [PATCH 25/26] add yaml and lxml libraries to requirements to avoiding errors after installing module. add package_data in setup.py to preserve non-py files in module --- requirements.txt | 4 +++- setup.py | 5 ++++- src/components/converter/html/templates/__init__.py | 0 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/components/converter/html/templates/__init__.py diff --git a/requirements.txt b/requirements.txt index 30f94df..456bb53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,6 @@ bs4==0.0.1 coloredlogs==10.0.0 fabulous==0.3.0 jinja2==2.10.3 -WeasyPrint==50 \ No newline at end of file +WeasyPrint==50 +PyYAML==3.13 +lxml==4.2.1 \ No newline at end of file diff --git a/setup.py b/setup.py index a037e8f..4d2a2dc 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,10 @@ def get_install_requirements(): long_description_content_type='text/markdown', url=conf.__url__, packages=setuptools.find_packages(), - # package_dir={'': 'src'}, + include_package_data=True, + package_data={ + '': ['*.jinja2', '*.yaml', '*.yml'], + }, install_requires=get_install_requirements(), python_requires='>=3.6', entry_points={ diff --git a/src/components/converter/html/templates/__init__.py b/src/components/converter/html/templates/__init__.py new file mode 100644 index 0000000..e69de29 From e91eaa77867cc5281ba22c6d661f067ce89c3a8d Mon Sep 17 00:00:00 2001 From: victormikhan Date: Sun, 8 Dec 2019 22:08:43 +0300 Subject: [PATCH 26/26] add feature to read cache rss data without internet connection. set readable json encoding from cyrilic source --- src/components/cache/cache.py | 17 +++++++++++ src/components/feed/feed.py | 29 ++++++++++++++++++- src/components/feed/feed_formatter.py | 13 ++++++--- .../parser/arguments/positional/source.py | 7 ++--- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/components/cache/cache.py b/src/components/cache/cache.py index 32f1556..4f763c9 100644 --- a/src/components/cache/cache.py +++ b/src/components/cache/cache.py @@ -20,10 +20,13 @@ class Cache(Singleton): Attributes: _db_name attribute contain default name of database + cache_default attribute contain default cache date to retrieve """ _db_name = 'cache.db' + cache_default = datetime.today().strftime('%Y%m%d') + def __init__(self) -> None: """ This constructor provide represent sqlite3 db layer instance @@ -186,6 +189,20 @@ def load_feeds_entries(self, url: str, date: str, limit: int = 100) -> list: #@TODO:wrap into CacheEntry return self._db.map_data(cache_list) + def load_feed_general(self, url: str) -> list: + """ + This method load feed general data by url + :param url: str + :return: list + """ + return self._db.map_data( + self._db.where( + 'feeds', + ['url', '=', url], + limit=1 + ) + )[0] + def _get_specify_by_date(self, url: str, date, limit: int = 100) -> list: """ Retrieve cache data from storage by specified date from console diff --git a/src/components/feed/feed.py b/src/components/feed/feed.py index d6b637c..bc03c47 100644 --- a/src/components/feed/feed.py +++ b/src/components/feed/feed.py @@ -8,6 +8,7 @@ from src.components.feed.feed_formatter import FeedFormatter from src.components.logger.logger import Logger from src.components.cache.cache import Cache +import urllib.request as url class FeedProperty(ABC): @@ -83,7 +84,7 @@ def show_feeds(self) -> None: self._decide_output(), self._limit, top_data_output, - self._is_colorize + self._is_colorize, ) print(output) @@ -104,6 +105,12 @@ def _parse_feeds(self) -> None: append entries to entries list and store to cache :return: None """ + + try: + url.urlopen(self._url) + except (url.HTTPError, url.URLError) as e: + return self._setup_cache_on_unavailable_source() + Logger.log(f'Start parsing data from url: {self._url}') parse_data = feedparser.parse(self._url) @@ -121,6 +128,26 @@ def _parse_feeds(self) -> None: if self._entities_list: self._store_cache_instances() + def _setup_cache_on_unavailable_source(self) -> None: + """ + This method check cache date on source unavailable + and set data of relevant cache url + :return: None + """ + Logger.log('Something wrong with your source. Only cache available') + + if not self._cache_date: + self._cache_date = Cache.cache_default + Logger.log(f'Cache set to: {self._cache_date}') + + feed_general = Cache().load_feed_general(self._url) + + self._feeds_title = feed_general.url + self._feeds_image = feed_general.image + self._feeds_encoding = feed_general.encoding + + Logger.log('Cache feed data setup') + def _set_global_feed_data(self, parse_data: feedparser.FeedParserDict) -> None: """ This method set all global feed data to Feed instatance diff --git a/src/components/feed/feed_formatter.py b/src/components/feed/feed_formatter.py index e762c7a..6a4bcbe 100644 --- a/src/components/feed/feed_formatter.py +++ b/src/components/feed/feed_formatter.py @@ -77,7 +77,7 @@ def _json_output(cls, entries: list, limit: int, top_data_output: Map) -> str: "url": top_data_output.url, "image": top_data_output.image, "entries" : formatted_feeds, - }, indent=2, sort_keys=False) + }, indent=2, sort_keys=False, ensure_ascii=False) return output.encode(top_data_output.encoding).decode() @@ -126,14 +126,19 @@ def _single_feed_format_json(cls, entry: object) -> str: "body": { "title": entry.title, "date": str(cls.human_date(entry.published)), - "links": [link for link in entry.links], - "media": [media for media in entry.media], + "links": [{ + 'href':link.href, + 'type': link.type, + } for link in entry.links], + "media": [{ + 'url':media.url, + 'additional': media.additional, + } for media in entry.media], "description": entry.description } } } - @staticmethod def format_links(links: list) -> str: """ diff --git a/src/components/parser/arguments/positional/source.py b/src/components/parser/arguments/positional/source.py index c2a1fdf..5e4dbeb 100644 --- a/src/components/parser/arguments/positional/source.py +++ b/src/components/parser/arguments/positional/source.py @@ -16,13 +16,12 @@ def add_argument(self) -> argparse: :return: argparse """ self._parser.add_argument( - 'source', type=self._validate_source, help='RSS URL' + 'source', type=str, help='RSS URL' ) def _validate_source(self, source: str) -> str: """ - This method validate incoming required source parameter on feed - existence by url and another url checker exception + This method validate incoming required source parameter url checker exception :param source: str :return: str """ @@ -35,5 +34,3 @@ def _validate_source(self, source: str) -> str: except argparse.ArgumentError: raise argparse.ArgumentError('Server answer code is not 200') - except (url.HTTPError, url.URLError) as e: - raise url.URLError(f'Something wrong with your source. Please try another rss feed: {e}')