From a23b9ba6eab816d571f54c36a602eb043cd39a6c Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 10 Nov 2019 19:22:16 +0300 Subject: [PATCH 01/28] initial commit --- .gitignore | 3 +++ rss_reader.py | 0 2 files changed, 3 insertions(+) create mode 100644 rss_reader.py diff --git a/.gitignore b/.gitignore index 894a44c..933cf5b 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ venv.bak/ # mypy .mypy_cache/ + +# idea +.idea diff --git a/rss_reader.py b/rss_reader.py new file mode 100644 index 0000000..e69de29 From 1501ada8f6bf4738cec1f76c7455d08737949718 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 10 Nov 2019 22:14:38 +0300 Subject: [PATCH 02/28] feat: pretty-printing of rss --- requirements.txt | 3 +++ rss_reader.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed05cb9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +feedparser # rss parsing +requests # http requests +# colorama # colored output \ No newline at end of file diff --git a/rss_reader.py b/rss_reader.py index e69de29..d720c1c 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -0,0 +1,70 @@ +""" +Module Docstring +""" + +__author__ = "DiSonDS" +__version__ = "0.1.0" +__license__ = "MIT" + + +import feedparser +import argparse +import requests +import time + +# from colorama import init # for colorizing https://pypi.org/project/colorama/ +# init(autoreset=True) + + +def get_rss(source): + """ Gets rss feed by source """ + response = requests.get(source) + rss = feedparser.parse(response.text) + return rss + + +def print_rss(rss): + """ Prints rss feed """ + print(f"Feed: {rss['feed']['title']}\n") + for entry in rss.entries: + print(f"{entry.title}\n" + f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed)}\n" + f"{entry.link}\n\n" + f"{entry.summary}\n\n") + + +def main(args): + """ Main entry point of the app """ + source = args.source + rss = get_rss(source) + print_rss(rss) + + +if __name__ == "__main__": + """ This is executed when run from the command line """ + parser = argparse.ArgumentParser() + + # Required positional argument + parser.add_argument("source", help="rss url") + + # Specify output of "--version" + parser.add_argument( + "--version", + action="version", + version="%(prog)s (version {version})".format(version=__version__)) + + # Optional argument flag which defaults to False + parser.add_argument("-j", "--json", action="store_true", help="Print result as JSON in stdout") + + # Optional verbosity counter + parser.add_argument( + "--verbose", + action="count", + default=0, + help="Outputs verbose status messages") + + # Optional argument which requires a parameter (eg. -d test) + parser.add_argument("-l", "--limit", action="store", type=int, dest="limit") + + args = parser.parse_args() + main(args) From d12703c2c62174feb93a67d0346bbaa4dc4f6c3a Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 10 Nov 2019 22:18:45 +0300 Subject: [PATCH 03/28] feat: limit parameter for printing Now you can use "--limit N" for limiting output --- rss_reader.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/rss_reader.py b/rss_reader.py index d720c1c..889d3c3 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -23,10 +23,14 @@ def get_rss(source): return rss -def print_rss(rss): +def print_rss(rss, limit): """ Prints rss feed """ print(f"Feed: {rss['feed']['title']}\n") - for entry in rss.entries: + if limit: + entries = rss.entries[:limit] + else: + entries = rss.entries + for entry in entries: print(f"{entry.title}\n" f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed)}\n" f"{entry.link}\n\n" @@ -37,7 +41,7 @@ def main(args): """ Main entry point of the app """ source = args.source rss = get_rss(source) - print_rss(rss) + print_rss(rss, args.limit) if __name__ == "__main__": From c12a74079f074d7e14c50bf5efff8f750e0424d7 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Mon, 11 Nov 2019 00:45:38 +0300 Subject: [PATCH 04/28] feat: json parameter for printing rss in json Now you can use "--json" for printing rss feed in json --- requirements.txt | 2 +- rss_reader.py | 86 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/requirements.txt b/requirements.txt index ed05cb9..d0503ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ feedparser # rss parsing requests # http requests -# colorama # colored output \ No newline at end of file +# colorama # colored output diff --git a/rss_reader.py b/rss_reader.py index 889d3c3..237ced9 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -11,42 +11,82 @@ import argparse import requests import time +import logging +import json # from colorama import init # for colorizing https://pypi.org/project/colorama/ # init(autoreset=True) -def get_rss(source): - """ Gets rss feed by source """ - response = requests.get(source) - rss = feedparser.parse(response.text) - return rss - - -def print_rss(rss, limit): - """ Prints rss feed """ - print(f"Feed: {rss['feed']['title']}\n") - if limit: - entries = rss.entries[:limit] - else: - entries = rss.entries - for entry in entries: - print(f"{entry.title}\n" - f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed)}\n" - f"{entry.link}\n\n" - f"{entry.summary}\n\n") +class RSSFeed: + """ Class for rss feed""" + def __init__(self, source): + self.source = source + self.title = None + self.entries = None + self.raw_rss = None + + def _get_rss_in_json(self, entries=False): + """ Converts rss feed to json """ + logging.info("Converting rss feed to json") + if entries: + return json.dumps({"title": self.title, "entries": entries}) + else: + return json.dumps({"title": self.title, "entries": self.entries}) + + def get_rss(self): + """ Gets rss feed by source """ + logging.info("Getting rss feed") + response = requests.get(self.source).text + + rss = feedparser.parse(response) + self.title = rss['feed']['title'] + self.raw_rss = response + self.entries = [] + for entry in rss.entries: + self.entries.append({ + "title": entry.title, + "date": time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed), + "link": entry.link, + "summary": entry.summary + }) + + def print_rss(self, limit, is_json=False): + """ Prints rss feed """ + logging.info("Printing rss feed") + if not self.entries: + print("error") + + if limit: + entries = self.entries[:limit] + else: + entries = self.entries + + if is_json: + entries = self._get_rss_in_json(entries) + print(entries) + else: + print(self.title + "\n") + for entry in entries: + print(f"{entry['title']}\n" + f"{entry['date']}\n" + f"{entry['link']}\n\n" + f"{entry['summary']}\n\n") def main(args): """ Main entry point of the app """ - source = args.source - rss = get_rss(source) - print_rss(rss, args.limit) + + feed = RSSFeed(source=args.source) + feed.get_rss() + feed.print_rss(limit=args.limit, is_json=args.json) + + logging.info("Exiting") if __name__ == "__main__": """ This is executed when run from the command line """ - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(description="Pure Python command-line RSS reader.") # Required positional argument parser.add_argument("source", help="rss url") From 82f498ee7d7f11df75e5fb555dbf9729aeb0c826 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Mon, 11 Nov 2019 00:53:03 +0300 Subject: [PATCH 05/28] feat: verbose parameter for output verbose log messages Now you can use "--verbose" for output verbose status messages --- rss_reader.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rss_reader.py b/rss_reader.py index 237ced9..6bae23c 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -77,6 +77,12 @@ def print_rss(self, limit, is_json=False): def main(args): """ Main entry point of the app """ + if args.verbose: + logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.DEBUG) + logging.info("Verbose output.") + else: + logging.basicConfig(format="%(levelname)s: %(message)s") + feed = RSSFeed(source=args.source) feed.get_rss() feed.print_rss(limit=args.limit, is_json=args.json) From 0bc60c0e3bc7d19e5281d418f21cf9d2711cfc94 Mon Sep 17 00:00:00 2001 From: Dmitry Skorobogaty Date: Mon, 11 Nov 2019 01:25:22 +0300 Subject: [PATCH 06/28] Create README.md --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..9953016 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# PythonHomework +[Introduction to Python] Homework Repository for EPAM courses + +# How to use +1. `pip3 install -r requirements.txt` +2. `python3.8 rss_reader.py "https://www.androidpolice.com/feed/" --limit 10 --json` + +# Parameters +- **--help** (help text) +- **--json** (print rss feed in json format) +- **--verbose** (print verbose log messages) +- **--limit** (limit printed entries) + +## JSON structure +`{"title": "rss_title", "entries": [{"title": "title", "date": "date", "link": "link", "summary": "summary"}, ...]}` + +# TODO +- [x] [Iteration 1] One-shot command-line RSS reader. +- [ ] [Iteration 2] Distribution +- [ ] [Iteration 3] News caching +- [ ] [Iteration 4] Format converter +- [ ] * [Iteration 5] Output colorization +- [ ] * [Iteration 6] Web-server From 0c18404cc0723f6c903735a04eea317119016d2a Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Wed, 13 Nov 2019 17:50:04 +0300 Subject: [PATCH 07/28] feat: python3 shebang --- rss_reader.py => rss-reader.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename rss_reader.py => rss-reader.py (98%) diff --git a/rss_reader.py b/rss-reader.py similarity index 98% rename from rss_reader.py rename to rss-reader.py index 6bae23c..88f1143 100644 --- a/rss_reader.py +++ b/rss-reader.py @@ -1,5 +1,7 @@ +#!/usr/bin/env python3 + """ -Module Docstring +Simple RSS reader """ __author__ = "DiSonDS" From aeaad8a785b0962581ee1c6144ba2be837886111 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Wed, 13 Nov 2019 18:11:21 +0300 Subject: [PATCH 08/28] feat: setup.py setup script --- setup.py | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 setup.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..efbf40c --- /dev/null +++ b/setup.py @@ -0,0 +1,189 @@ +from setuptools import setup, find_packages +from os import path + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +# Arguments marked as "Required" below must be included for upload to PyPI. +# Fields marked as "Optional" may be commented out. + +setup( + # This is the name of your project. The first time you publish this + # package, this name will be registered for you. It will determine how + # users can install this project, e.g.: + # + # $ pip install sampleproject + # + # And where it will live on PyPI: https://pypi.org/project/sampleproject/ + # + # There are some restrictions on what makes a valid project name + # specification here: + # https://packaging.python.org/specifications/core-metadata/#name + name='rss-reader', # Required + + # Versions should comply with PEP 440: + # https://www.python.org/dev/peps/pep-0440/ + # + # For a discussion on single-sourcing the version across setup.py and the + # project code, see + # https://packaging.python.org/en/latest/single_source_version.html + version='0.1.0', # Required + + # This is a one-line description or tagline of what your project does. This + # corresponds to the "Summary" metadata field: + # https://packaging.python.org/specifications/core-metadata/#summary + description='A simple Python3.8 rss reader', # Optional + + # This is an optional longer description of your project that represents + # the body of text which users will see when they visit PyPI. + # + # Often, this is the same as your README, so you can just read it in from + # that file directly (as we have already done above) + # + # This field corresponds to the "Description" metadata field: + # https://packaging.python.org/specifications/core-metadata/#description-optional + long_description=long_description, # Optional + + # Denotes that our long_description is in Markdown; valid values are + # text/plain, text/x-rst, and text/markdown + # + # Optional if long_description is written in reStructuredText (rst) but + # required for plain-text or Markdown; if unspecified, "applications should + # attempt to render [the long_description] as text/x-rst; charset=UTF-8 and + # fall back to text/plain if it is not valid rst" (see link below) + # + # This field corresponds to the "Description-Content-Type" metadata field: + # https://packaging.python.org/specifications/core-metadata/#description-content-type-optional + long_description_content_type='text/markdown', # Optional (see note above) + + # This should be a valid link to your project's main homepage. + # + # This field corresponds to the "Home-Page" metadata field: + # https://packaging.python.org/specifications/core-metadata/#home-page-optional + url='https://github.com/introduction-to-python-bsuir-2019/PythonHomework', # Optional + + # This should be your name or the name of the organization which owns the + # project. + author='DiSonDS', # Optional + + # This should be a valid email address corresponding to the author listed + # above. + author_email='dison.ds@gmail.com', # Optional + + # Classifiers help users find your project by categorizing it. + # + # For a list of valid classifiers, see https://pypi.org/classifiers/ + classifiers=[ # Optional + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 3 - Alpha', + + # Indicate who your project is intended for + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Build Tools', + + # Pick your license as you wish + 'License :: OSI Approved :: MIT License', + + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + # These classifiers are *not* checked by 'pip install'. See instead + # 'python_requires' below. + 'Programming Language :: Python :: 3.8', + ], + + # This field adds keywords for your project which will appear on the + # project page. What does your project relate to? + # + # Note that this is a string of words separated by whitespace, not a list. + keywords='simple rss reader', # Optional + + # You can just specify package directories manually here if your project is + # simple. Or you can use find_packages(). + # + # Alternatively, if you just want to distribute a single Python file, use + # the `py_modules` argument instead as follows, which will expect a file + # called `my_module.py` to exist: + # + # py_modules=["my_module"], + # + packages=find_packages(exclude=['contrib', 'docs', 'tests']), # Required + + # Specify which Python versions you support. In contrast to the + # 'Programming Language' classifiers above, 'pip install' will check this + # and refuse to install the project if the version does not match. If you + # do not support Python 2, you can simplify this to '>=3.5' or similar, see + # https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires + python_requires='>=3.8, <4', + + # This field lists other packages that your project depends on to run. + # Any package you put here will be installed by pip when your project is + # installed, so they must be valid existing projects. + # + # For an analysis of "install_requires" vs pip's requirements files see: + # https://packaging.python.org/en/latest/requirements.html + install_requires=['feedparser==6.0.0b1', 'requests'], # Optional + + # List additional groups of dependencies here (e.g. development + # dependencies). Users will be able to install these using the "extras" + # syntax, for example: + # + # $ pip install sampleproject[dev] + # + # Similar to `install_requires` above, these must be valid existing + # projects. + # extras_require={ # Optional + # 'dev': ['check-manifest'], + # 'test': ['coverage'], + # }, + + # If there are data files included in your packages that need to be + # installed, specify them here. + # + # If using Python 2.6 or earlier, then these have to be included in + # MANIFEST.in as well. + # package_data={ # Optional + # 'sample': ['package_data.dat'], + # }, + + # Although 'package_data' is the preferred approach, in some case you may + # need to place data files outside of your packages. See: + # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files + # + # In this case, 'data_file' will be installed into '/my_data' + # data_files=[('my_data', ['data/data_file'])], # Optional + + # To provide executable scripts, use entry points in preference to the + # "scripts" keyword. Entry points provide cross-platform support and allow + # `pip` to create the appropriate form of executable for the target + # platform. + # + # For example, the following would provide a command called `sample` which + # executes the function `main` from this package when invoked: + entry_points={ # Optional + 'console_scripts': [ + 'rss-reader=rss-reader:main', + ], + }, + + scripts=['rss-reader.py'], + + # List additional URLs that are relevant to your project as a dict. + # + # This field corresponds to the "Project-URL" metadata fields: + # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use + # + # Examples listed include a pattern for specifying where the package tracks + # issues, where the source is hosted, where to say thanks to the package + # maintainers, and where to support the project financially. The key is + # what's used to render the link text on PyPI. + project_urls={ # Optional + 'Bug Reports': 'https://github.com/introduction-to-python-bsuir-2019/PythonHomework/issues', + 'Source': 'https://github.com/introduction-to-python-bsuir-2019/PythonHomework', + }, +) From ae819c7e77dc9c5b13a82d4aa5374f9545f6ea0d Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Wed, 13 Nov 2019 18:29:28 +0300 Subject: [PATCH 09/28] fix: html tags in pretty-printing of rss --- README.md | 2 +- requirements.txt | 1 + rss-reader.py | 15 ++++++++------- setup.py | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9953016..770d851 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - **--limit** (limit printed entries) ## JSON structure -`{"title": "rss_title", "entries": [{"title": "title", "date": "date", "link": "link", "summary": "summary"}, ...]}` +`{"feed": "rss_title", "entries": [{"title": "title", "date": "date", "link": "link", "summary": "summary"}, ...]}` # TODO - [x] [Iteration 1] One-shot command-line RSS reader. diff --git a/requirements.txt b/requirements.txt index d0503ef..f9f6c4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ feedparser # rss parsing requests # http requests +bs4 # for xml and html # colorama # colored output diff --git a/rss-reader.py b/rss-reader.py index 88f1143..59bd024 100644 --- a/rss-reader.py +++ b/rss-reader.py @@ -15,6 +15,7 @@ import time import logging import json +import bs4 # from colorama import init # for colorizing https://pypi.org/project/colorama/ # init(autoreset=True) @@ -32,9 +33,9 @@ def _get_rss_in_json(self, entries=False): """ Converts rss feed to json """ logging.info("Converting rss feed to json") if entries: - return json.dumps({"title": self.title, "entries": entries}) + return json.dumps({"feed": self.title, "entries": entries}) else: - return json.dumps({"title": self.title, "entries": self.entries}) + return json.dumps({"feed": self.title, "entries": self.entries}) def get_rss(self): """ Gets rss feed by source """ @@ -50,7 +51,7 @@ def get_rss(self): "title": entry.title, "date": time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed), "link": entry.link, - "summary": entry.summary + "summary": bs4.BeautifulSoup(entry.summary, "html.parser").text }) def print_rss(self, limit, is_json=False): @@ -68,11 +69,11 @@ def print_rss(self, limit, is_json=False): entries = self._get_rss_in_json(entries) print(entries) else: - print(self.title + "\n") + print(f"Feed: {self.title}\n") for entry in entries: - print(f"{entry['title']}\n" - f"{entry['date']}\n" - f"{entry['link']}\n\n" + print(f"Title: {entry['title']}\n" + f"Date: {entry['date']}\n" + f"Link: {entry['link']}\n\n" f"{entry['summary']}\n\n") diff --git a/setup.py b/setup.py index efbf40c..066246f 100644 --- a/setup.py +++ b/setup.py @@ -127,7 +127,7 @@ # # For an analysis of "install_requires" vs pip's requirements files see: # https://packaging.python.org/en/latest/requirements.html - install_requires=['feedparser==6.0.0b1', 'requests'], # Optional + install_requires=['feedparser==6.0.0b1', 'requests', 'bs4'], # Optional # List additional groups of dependencies here (e.g. development # dependencies). Users will be able to install these using the "extras" From 736d777342283234bb32d2babc36f55e6053026d Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 17 Nov 2019 14:23:26 +0300 Subject: [PATCH 10/28] chore: rename "rss-reader.py" to "rss_reader.py" --- rss-reader.py => rss_reader.py | 0 setup.py | 12 ++++++------ 2 files changed, 6 insertions(+), 6 deletions(-) rename rss-reader.py => rss_reader.py (100%) diff --git a/rss-reader.py b/rss_reader.py similarity index 100% rename from rss-reader.py rename to rss_reader.py diff --git a/setup.py b/setup.py index 066246f..0341a31 100644 --- a/setup.py +++ b/setup.py @@ -165,13 +165,13 @@ # # For example, the following would provide a command called `sample` which # executes the function `main` from this package when invoked: - entry_points={ # Optional - 'console_scripts': [ - 'rss-reader=rss-reader:main', - ], - }, + # entry_points={ # Optional + # 'console_scripts': [ + # 'rss-reader=rss-reader:main', + # ], + # }, - scripts=['rss-reader.py'], + scripts=['rss_reader.py'], # List additional URLs that are relevant to your project as a dict. # From 343405e0962772411c57bdc7cf08833be203c553 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 17 Nov 2019 14:29:38 +0300 Subject: [PATCH 11/28] feat: custom exception class "RSSFeedException" --- rss_reader.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/rss_reader.py b/rss_reader.py index 59bd024..e76d87d 100644 --- a/rss_reader.py +++ b/rss_reader.py @@ -9,6 +9,7 @@ __license__ = "MIT" +import sys import feedparser import argparse import requests @@ -21,6 +22,11 @@ # init(autoreset=True) +class RSSFeedException(Exception): + def __init__(self, message): + self.message = message + + class RSSFeed: """ Class for rss feed""" def __init__(self, source): @@ -43,6 +49,8 @@ def get_rss(self): response = requests.get(self.source).text rss = feedparser.parse(response) + if rss['bozo']: + raise RSSFeedException(message="Incorrect url") self.title = rss['feed']['title'] self.raw_rss = response self.entries = [] @@ -120,4 +128,9 @@ def main(args): parser.add_argument("-l", "--limit", action="store", type=int, dest="limit") args = parser.parse_args() - main(args) + + try: + main(args) + except RSSFeedException as e: + print(f"{e.message}") + sys.exit(0) From 75367081e2004a14829079fe1b464aa89155105e Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 17 Nov 2019 15:53:58 +0300 Subject: [PATCH 12/28] feat: package export CLI utility named "rss-reader" --- requirements.txt | 2 +- rss_reader/__init__.py | 0 rss_reader/__main__.py | 10 ++ rss_reader.py => rss_reader/rss_reader.py | 33 ++-- setup.py | 192 ++-------------------- 5 files changed, 45 insertions(+), 192 deletions(-) create mode 100644 rss_reader/__init__.py create mode 100644 rss_reader/__main__.py rename rss_reader.py => rss_reader/rss_reader.py (94%) diff --git a/requirements.txt b/requirements.txt index f9f6c4f..c9eb762 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -feedparser # rss parsing +feedparser>=6.0.0b1 # rss parsing requests # http requests bs4 # for xml and html # colorama # colored output diff --git a/rss_reader/__init__.py b/rss_reader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rss_reader/__main__.py b/rss_reader/__main__.py new file mode 100644 index 0000000..b6fdcea --- /dev/null +++ b/rss_reader/__main__.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python + +"""Package entry point.""" + + +from rss_reader.rss_reader import main + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/rss_reader.py b/rss_reader/rss_reader.py similarity index 94% rename from rss_reader.py rename to rss_reader/rss_reader.py index e76d87d..4d9a9ea 100644 --- a/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -85,24 +85,9 @@ def print_rss(self, limit, is_json=False): f"{entry['summary']}\n\n") -def main(args): +def main(): """ Main entry point of the app """ - if args.verbose: - logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.DEBUG) - logging.info("Verbose output.") - else: - logging.basicConfig(format="%(levelname)s: %(message)s") - - feed = RSSFeed(source=args.source) - feed.get_rss() - feed.print_rss(limit=args.limit, is_json=args.json) - - logging.info("Exiting") - - -if __name__ == "__main__": - """ This is executed when run from the command line """ parser = argparse.ArgumentParser(description="Pure Python command-line RSS reader.") # Required positional argument @@ -129,8 +114,22 @@ def main(args): args = parser.parse_args() + if args.verbose: + logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.DEBUG) + logging.info("Verbose output.") + else: + logging.basicConfig(format="%(levelname)s: %(message)s") + try: - main(args) + feed = RSSFeed(source=args.source) + feed.get_rss() + feed.print_rss(limit=args.limit, is_json=args.json) except RSSFeedException as e: print(f"{e.message}") sys.exit(0) + + logging.info("Exiting") + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 0341a31..32ea332 100644 --- a/setup.py +++ b/setup.py @@ -7,182 +7,26 @@ with open(path.join(here, 'README.md'), encoding='utf-8') as f: long_description = f.read() -# Arguments marked as "Required" below must be included for upload to PyPI. -# Fields marked as "Optional" may be commented out. setup( - # This is the name of your project. The first time you publish this - # package, this name will be registered for you. It will determine how - # users can install this project, e.g.: - # - # $ pip install sampleproject - # - # And where it will live on PyPI: https://pypi.org/project/sampleproject/ - # - # There are some restrictions on what makes a valid project name - # specification here: - # https://packaging.python.org/specifications/core-metadata/#name - name='rss-reader', # Required - - # Versions should comply with PEP 440: - # https://www.python.org/dev/peps/pep-0440/ - # - # For a discussion on single-sourcing the version across setup.py and the - # project code, see - # https://packaging.python.org/en/latest/single_source_version.html - version='0.1.0', # Required - - # This is a one-line description or tagline of what your project does. This - # corresponds to the "Summary" metadata field: - # https://packaging.python.org/specifications/core-metadata/#summary - description='A simple Python3.8 rss reader', # Optional - - # This is an optional longer description of your project that represents - # the body of text which users will see when they visit PyPI. - # - # Often, this is the same as your README, so you can just read it in from - # that file directly (as we have already done above) - # - # This field corresponds to the "Description" metadata field: - # https://packaging.python.org/specifications/core-metadata/#description-optional - long_description=long_description, # Optional - - # Denotes that our long_description is in Markdown; valid values are - # text/plain, text/x-rst, and text/markdown - # - # Optional if long_description is written in reStructuredText (rst) but - # required for plain-text or Markdown; if unspecified, "applications should - # attempt to render [the long_description] as text/x-rst; charset=UTF-8 and - # fall back to text/plain if it is not valid rst" (see link below) - # - # This field corresponds to the "Description-Content-Type" metadata field: - # https://packaging.python.org/specifications/core-metadata/#description-content-type-optional - long_description_content_type='text/markdown', # Optional (see note above) - - # This should be a valid link to your project's main homepage. - # - # This field corresponds to the "Home-Page" metadata field: - # https://packaging.python.org/specifications/core-metadata/#home-page-optional - url='https://github.com/introduction-to-python-bsuir-2019/PythonHomework', # Optional - - # This should be your name or the name of the organization which owns the - # project. - author='DiSonDS', # Optional - - # This should be a valid email address corresponding to the author listed - # above. - author_email='dison.ds@gmail.com', # Optional - - # Classifiers help users find your project by categorizing it. - # - # For a list of valid classifiers, see https://pypi.org/classifiers/ - classifiers=[ # Optional - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - 'Development Status :: 3 - Alpha', - - # Indicate who your project is intended for - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Build Tools', - - # Pick your license as you wish - 'License :: OSI Approved :: MIT License', - - # Specify the Python versions you support here. In particular, ensure - # that you indicate whether you support Python 2, Python 3 or both. - # These classifiers are *not* checked by 'pip install'. See instead - # 'python_requires' below. - 'Programming Language :: Python :: 3.8', - ], - - # This field adds keywords for your project which will appear on the - # project page. What does your project relate to? - # - # Note that this is a string of words separated by whitespace, not a list. - keywords='simple rss reader', # Optional - - # You can just specify package directories manually here if your project is - # simple. Or you can use find_packages(). - # - # Alternatively, if you just want to distribute a single Python file, use - # the `py_modules` argument instead as follows, which will expect a file - # called `my_module.py` to exist: - # - # py_modules=["my_module"], - # - packages=find_packages(exclude=['contrib', 'docs', 'tests']), # Required - - # Specify which Python versions you support. In contrast to the - # 'Programming Language' classifiers above, 'pip install' will check this - # and refuse to install the project if the version does not match. If you - # do not support Python 2, you can simplify this to '>=3.5' or similar, see - # https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires - python_requires='>=3.8, <4', - - # This field lists other packages that your project depends on to run. - # Any package you put here will be installed by pip when your project is - # installed, so they must be valid existing projects. - # - # For an analysis of "install_requires" vs pip's requirements files see: - # https://packaging.python.org/en/latest/requirements.html - install_requires=['feedparser==6.0.0b1', 'requests', 'bs4'], # Optional - - # List additional groups of dependencies here (e.g. development - # dependencies). Users will be able to install these using the "extras" - # syntax, for example: - # - # $ pip install sampleproject[dev] - # - # Similar to `install_requires` above, these must be valid existing - # projects. - # extras_require={ # Optional - # 'dev': ['check-manifest'], - # 'test': ['coverage'], - # }, - - # If there are data files included in your packages that need to be - # installed, specify them here. - # - # If using Python 2.6 or earlier, then these have to be included in - # MANIFEST.in as well. - # package_data={ # Optional - # 'sample': ['package_data.dat'], - # }, - - # Although 'package_data' is the preferred approach, in some case you may - # need to place data files outside of your packages. See: - # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files - # - # In this case, 'data_file' will be installed into '/my_data' - # data_files=[('my_data', ['data/data_file'])], # Optional - - # To provide executable scripts, use entry points in preference to the - # "scripts" keyword. Entry points provide cross-platform support and allow - # `pip` to create the appropriate form of executable for the target - # platform. - # - # For example, the following would provide a command called `sample` which - # executes the function `main` from this package when invoked: - # entry_points={ # Optional - # 'console_scripts': [ - # 'rss-reader=rss-reader:main', - # ], - # }, - - scripts=['rss_reader.py'], - - # List additional URLs that are relevant to your project as a dict. - # - # This field corresponds to the "Project-URL" metadata fields: - # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use - # - # Examples listed include a pattern for specifying where the package tracks - # issues, where the source is hosted, where to say thanks to the package - # maintainers, and where to support the project financially. The key is - # what's used to render the link text on PyPI. - project_urls={ # Optional + name='rss-reader', + version='0.1.0', + description='A simple Python3.8 rss reader', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/introduction-to-python-bsuir-2019/PythonHomework', + author='DiSonDS', + author_email='dison.ds@gmail.com', + keywords='simple rss reader', + packages=find_packages(), + python_requires='>=3.8', + install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4'], + entry_points={ + 'console_scripts': [ + 'rss-reader=rss_reader.__main__:main', + ], + }, + project_urls={ 'Bug Reports': 'https://github.com/introduction-to-python-bsuir-2019/PythonHomework/issues', 'Source': 'https://github.com/introduction-to-python-bsuir-2019/PythonHomework', }, From 682992c10257a8c0283474d2704b6a7dd5dbec93 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 17 Nov 2019 16:02:18 +0300 Subject: [PATCH 13/28] fix: pretty-printing in json --- rss_reader/rss_reader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 4d9a9ea..5d560cd 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -39,9 +39,9 @@ def _get_rss_in_json(self, entries=False): """ Converts rss feed to json """ logging.info("Converting rss feed to json") if entries: - return json.dumps({"feed": self.title, "entries": entries}) + return json.dumps({"feed": self.title, "entries": entries}, indent=2) else: - return json.dumps({"feed": self.title, "entries": self.entries}) + return json.dumps({"feed": self.title, "entries": self.entries}, indent=2) def get_rss(self): """ Gets rss feed by source """ From 098f40ccc2919bf754988fc832a70ae207ef8f40 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 17 Nov 2019 16:10:43 +0300 Subject: [PATCH 14/28] fix: cyrillic letters in pretty-printing of json --- rss_reader/rss_reader.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 5d560cd..2ebd851 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -39,9 +39,11 @@ def _get_rss_in_json(self, entries=False): """ Converts rss feed to json """ logging.info("Converting rss feed to json") if entries: - return json.dumps({"feed": self.title, "entries": entries}, indent=2) + return json.dumps({"feed": self.title, "entries": entries}, + indent=2, ensure_ascii=False) else: - return json.dumps({"feed": self.title, "entries": self.entries}, indent=2) + return json.dumps({"feed": self.title, "entries": self.entries}, + indent=2, ensure_ascii=False) def get_rss(self): """ Gets rss feed by source """ From b481630f81429a3a354cdfa5947ff298220c2d1d Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 17 Nov 2019 16:53:00 +0300 Subject: [PATCH 15/28] chore: version increase (0.2.0) --- README.md | 2 +- rss_reader/rss_reader.py | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 770d851..56d359e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ # TODO - [x] [Iteration 1] One-shot command-line RSS reader. -- [ ] [Iteration 2] Distribution +- [x] [Iteration 2] Distribution - [ ] [Iteration 3] News caching - [ ] [Iteration 4] Format converter - [ ] * [Iteration 5] Output colorization diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 2ebd851..516116a 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -5,7 +5,7 @@ """ __author__ = "DiSonDS" -__version__ = "0.1.0" +__version__ = "0.2.0" __license__ = "MIT" diff --git a/setup.py b/setup.py index 32ea332..c657cff 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='rss-reader', - version='0.1.0', + version='0.2.0', description='A simple Python3.8 rss reader', long_description=long_description, long_description_content_type='text/markdown', From d5162bdcc4134be1f568ef2604b51fa8946b3100 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 17 Nov 2019 18:54:06 +0300 Subject: [PATCH 16/28] feat: date parameter for reading cached entries Now you can use "--date" for reading cached entries --- README.md | 10 ++++++- rss_reader/rss_reader.py | 58 +++++++++++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 56d359e..67601c7 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,25 @@ # How to use 1. `pip3 install -r requirements.txt` -2. `python3.8 rss_reader.py "https://www.androidpolice.com/feed/" --limit 10 --json` +2. `python3.8 rss_reader.py "https://www.androidpolice.com/feed/" --limit 3 --json --verbose --date` # Parameters - **--help** (help text) - **--json** (print rss feed in json format) - **--verbose** (print verbose log messages) - **--limit** (limit printed entries) +- **--date** (print cached entries if exist) ## JSON structure `{"feed": "rss_title", "entries": [{"title": "title", "date": "date", "link": "link", "summary": "summary"}, ...]}` +## Storage +Used [Pickle](https://docs.python.org/3/library/pickle.html) for storage + +Entries cached in `cache/date/domain.rss` + +Example: `cache/20191117/www.androidpolice.com.rss` + # TODO - [x] [Iteration 1] One-shot command-line RSS reader. - [x] [Iteration 2] Distribution diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 516116a..560b2b4 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -17,6 +17,11 @@ import logging import json import bs4 +from datetime import datetime +import os +from urllib.parse import urlparse +import pickle + # from colorama import init # for colorizing https://pypi.org/project/colorama/ # init(autoreset=True) @@ -35,6 +40,33 @@ def __init__(self, source): self.entries = None self.raw_rss = None + def _save_rss_in_file(self): + """ Saving rss feed to cache/date/domain.rss """ + logging.info("Saving rss feed") + directory = f"{datetime.now().strftime('%Y%m%d')}" + if not os.path.exists(f"cache/{directory}"): + logging.info(f"Creating directory /{directory}") + os.makedirs(f"cache/{directory}") + + uri = urlparse(self.source) + domain_name = f"{uri.netloc}" + with open(f"cache/{directory}/{domain_name}.rss", "wb") as f: + logging.info(f"Saving entries in file {directory}/{domain_name}.rss") + pickle.dump((self.title, self.entries), f) + + def _load_rss_from_file(self, date): + """ Loading rss feed from cache/date/domain.rss """ + logging.info("Loading rss feed") + directory = f"{date}" + uri = urlparse(self.source) + domain_name = f"{uri.netloc}" + if not os.path.exists(f"cache/{directory}/{domain_name}.rss"): + raise RSSFeedException(message=f"There is no entries for {date}") + + with open(f"cache/{directory}/{domain_name}.rss", "rb") as f: + logging.info(f"Loading entries from file {directory}/{domain_name}.rss") + self.title, self.entries = pickle.load(f) + def _get_rss_in_json(self, entries=False): """ Converts rss feed to json """ logging.info("Converting rss feed to json") @@ -45,7 +77,7 @@ def _get_rss_in_json(self, entries=False): return json.dumps({"feed": self.title, "entries": self.entries}, indent=2, ensure_ascii=False) - def get_rss(self): + def _get_rss(self): """ Gets rss feed by source """ logging.info("Getting rss feed") response = requests.get(self.source).text @@ -64,17 +96,23 @@ def get_rss(self): "summary": bs4.BeautifulSoup(entry.summary, "html.parser").text }) - def print_rss(self, limit, is_json=False): + self._save_rss_in_file() + + def print_rss(self, limit, is_json=False, date=False): """ Prints rss feed """ - logging.info("Printing rss feed") - if not self.entries: - print("error") + + if date: + self._load_rss_from_file(date) + else: + self._get_rss() if limit: entries = self.entries[:limit] else: entries = self.entries + logging.info("Printing rss feed") + if is_json: entries = self._get_rss_in_json(entries) print(entries) @@ -108,12 +146,15 @@ def main(): parser.add_argument( "--verbose", action="count", - default=0, + default=False, help="Outputs verbose status messages") - # Optional argument which requires a parameter (eg. -d test) + # Optional argument which requires a parameter parser.add_argument("-l", "--limit", action="store", type=int, dest="limit") + # Optional argument which requires a parameter + parser.add_argument("-d", "--date", action="store", type=int, dest="date") + args = parser.parse_args() if args.verbose: @@ -124,8 +165,7 @@ def main(): try: feed = RSSFeed(source=args.source) - feed.get_rss() - feed.print_rss(limit=args.limit, is_json=args.json) + feed.print_rss(limit=args.limit, is_json=args.json, date=args.date) except RSSFeedException as e: print(f"{e.message}") sys.exit(0) From eb7a2975af39ec1446538f8c66f0c1de33b62e1c Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 17 Nov 2019 20:03:26 +0300 Subject: [PATCH 17/28] chore: update code style according to "pep 8" --- rss_reader/rss_reader.py | 76 +++++++++++++++++----------------------- setup.py | 13 +++---- 2 files changed, 37 insertions(+), 52 deletions(-) diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 560b2b4..c72cbea 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -9,18 +9,19 @@ __license__ = "MIT" +import os import sys -import feedparser -import argparse -import requests import time -import logging import json -import bs4 +import pickle +import logging +import argparse from datetime import datetime -import os from urllib.parse import urlparse -import pickle + +import feedparser +import requests +import bs4 # from colorama import init # for colorizing https://pypi.org/project/colorama/ @@ -28,12 +29,14 @@ class RSSFeedException(Exception): + """ Custom exception class for RSSFeed errors """ def __init__(self, message): + super(RSSFeedException, self).__init__(message) self.message = message class RSSFeed: - """ Class for rss feed""" + """ Class for rss feed """ def __init__(self, source): self.source = source self.title = None @@ -45,14 +48,14 @@ def _save_rss_in_file(self): logging.info("Saving rss feed") directory = f"{datetime.now().strftime('%Y%m%d')}" if not os.path.exists(f"cache/{directory}"): - logging.info(f"Creating directory /{directory}") + logging.info("Creating directory /%s", directory) os.makedirs(f"cache/{directory}") uri = urlparse(self.source) domain_name = f"{uri.netloc}" - with open(f"cache/{directory}/{domain_name}.rss", "wb") as f: - logging.info(f"Saving entries in file {directory}/{domain_name}.rss") - pickle.dump((self.title, self.entries), f) + with open(f"cache/{directory}/{domain_name}.rss", "wb") as file: + logging.info("Saving entries in file %s/%s.rss", directory, domain_name) + pickle.dump((self.title, self.entries), file) def _load_rss_from_file(self, date): """ Loading rss feed from cache/date/domain.rss """ @@ -63,25 +66,25 @@ def _load_rss_from_file(self, date): if not os.path.exists(f"cache/{directory}/{domain_name}.rss"): raise RSSFeedException(message=f"There is no entries for {date}") - with open(f"cache/{directory}/{domain_name}.rss", "rb") as f: - logging.info(f"Loading entries from file {directory}/{domain_name}.rss") - self.title, self.entries = pickle.load(f) + with open(f"cache/{directory}/{domain_name}.rss", "rb") as file: + logging.info("Loading entries from file %s/%s.rss", directory, domain_name) + self.title, self.entries = pickle.load(file) - def _get_rss_in_json(self, entries=False): + def _get_rss_in_json(self, entries): """ Converts rss feed to json """ logging.info("Converting rss feed to json") - if entries: - return json.dumps({"feed": self.title, "entries": entries}, - indent=2, ensure_ascii=False) - else: - return json.dumps({"feed": self.title, "entries": self.entries}, - indent=2, ensure_ascii=False) + return json.dumps({"feed": self.title, "entries": entries}, + indent=2, ensure_ascii=False) - def _get_rss(self): + def get_rss(self, date): """ Gets rss feed by source """ logging.info("Getting rss feed") - response = requests.get(self.source).text + if date: + self._load_rss_from_file(date) + return + + response = requests.get(self.source).text rss = feedparser.parse(response) if rss['bozo']: raise RSSFeedException(message="Incorrect url") @@ -98,14 +101,9 @@ def _get_rss(self): self._save_rss_in_file() - def print_rss(self, limit, is_json=False, date=False): + def print_rss(self, limit, is_json=False): """ Prints rss feed """ - if date: - self._load_rss_from_file(date) - else: - self._get_rss() - if limit: entries = self.entries[:limit] else: @@ -130,29 +128,18 @@ def main(): parser = argparse.ArgumentParser(description="Pure Python command-line RSS reader.") - # Required positional argument parser.add_argument("source", help="rss url") - - # Specify output of "--version" parser.add_argument( "--version", action="version", version="%(prog)s (version {version})".format(version=__version__)) - - # Optional argument flag which defaults to False parser.add_argument("-j", "--json", action="store_true", help="Print result as JSON in stdout") - - # Optional verbosity counter parser.add_argument( "--verbose", action="count", default=False, help="Outputs verbose status messages") - - # Optional argument which requires a parameter parser.add_argument("-l", "--limit", action="store", type=int, dest="limit") - - # Optional argument which requires a parameter parser.add_argument("-d", "--date", action="store", type=int, dest="date") args = parser.parse_args() @@ -165,9 +152,10 @@ def main(): try: feed = RSSFeed(source=args.source) - feed.print_rss(limit=args.limit, is_json=args.json, date=args.date) - except RSSFeedException as e: - print(f"{e.message}") + feed.get_rss(date=args.date) + feed.print_rss(limit=args.limit, is_json=args.json) + except RSSFeedException as ex: + print(f"{ex.message}") sys.exit(0) logging.info("Exiting") diff --git a/setup.py b/setup.py index c657cff..db65164 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,15 @@ -from setuptools import setup, find_packages from os import path +from setuptools import setup, find_packages -here = path.abspath(path.dirname(__file__)) - -# Get the long description from the README file -with open(path.join(here, 'README.md'), encoding='utf-8') as f: - long_description = f.read() - +HERE = path.abspath(path.dirname(__file__)) +with open(path.join(HERE, 'README.md'), encoding='utf-8') as f: + LONG_DESCRIPTION = f.read() setup( name='rss-reader', version='0.2.0', description='A simple Python3.8 rss reader', - long_description=long_description, + long_description=LONG_DESCRIPTION, long_description_content_type='text/markdown', url='https://github.com/introduction-to-python-bsuir-2019/PythonHomework', author='DiSonDS', From f02dd33ceea773a7cf509e8cc315c9ae031b4535 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 17 Nov 2019 20:07:38 +0300 Subject: [PATCH 18/28] chore: version increase (0.3.0) --- README.md | 2 +- rss_reader/rss_reader.py | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 67601c7..2ac8532 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Example: `cache/20191117/www.androidpolice.com.rss` # TODO - [x] [Iteration 1] One-shot command-line RSS reader. - [x] [Iteration 2] Distribution -- [ ] [Iteration 3] News caching +- [x] [Iteration 3] News caching - [ ] [Iteration 4] Format converter - [ ] * [Iteration 5] Output colorization - [ ] * [Iteration 6] Web-server diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index c72cbea..1c9b66c 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -5,7 +5,7 @@ """ __author__ = "DiSonDS" -__version__ = "0.2.0" +__version__ = "0.3.0" __license__ = "MIT" diff --git a/setup.py b/setup.py index db65164..8579813 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='rss-reader', - version='0.2.0', + version='0.3.0', description='A simple Python3.8 rss reader', long_description=LONG_DESCRIPTION, long_description_content_type='text/markdown', From 996f0b6e897b953e4b6ed865c003aba89c922f4b Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Thu, 21 Nov 2019 23:46:59 +0300 Subject: [PATCH 19/28] feat: colorize parameter for colorized output Now you can use "--colorize" for colorized output --- .gitignore | 3 +++ requirements.txt | 2 +- rss_reader/rss_reader.py | 30 ++++++++++++++++++------------ setup.py | 2 +- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 933cf5b..ea87a12 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,6 @@ venv.bak/ # idea .idea + +# cached rss +/cache diff --git a/requirements.txt b/requirements.txt index c9eb762..7eb49b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ feedparser>=6.0.0b1 # rss parsing requests # http requests bs4 # for xml and html -# colorama # colored output +colorama # colored output https://pypi.org/project/colorama/ diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 1c9b66c..c5d1085 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -22,10 +22,7 @@ import feedparser import requests import bs4 - - -# from colorama import init # for colorizing https://pypi.org/project/colorama/ -# init(autoreset=True) +from colorama import Fore class RSSFeedException(Exception): @@ -101,7 +98,7 @@ def get_rss(self, date): self._save_rss_in_file() - def print_rss(self, limit, is_json=False): + def print_rss(self, limit=None, is_json=False, colorize=False): """ Prints rss feed """ if limit: @@ -115,12 +112,20 @@ def print_rss(self, limit, is_json=False): entries = self._get_rss_in_json(entries) print(entries) else: - print(f"Feed: {self.title}\n") - for entry in entries: - print(f"Title: {entry['title']}\n" - f"Date: {entry['date']}\n" - f"Link: {entry['link']}\n\n" - f"{entry['summary']}\n\n") + if colorize: + print(f"{Fore.RED}Feed:{Fore.RESET} {self.title}\n") + for entry in entries: + print(f"{Fore.GREEN}Title:{Fore.RESET} {entry['title']}\n" + f"{Fore.MAGENTA}Date:{Fore.RESET} {entry['date']}\n" + f"{Fore.BLUE}Link:{Fore.RESET} {entry['link']}\n\n" + f"{entry['summary']}\n\n") + else: + print(f"Feed: {self.title}\n") + for entry in entries: + print(f"Title: {entry['title']}\n" + f"Date: {entry['date']}\n" + f"Link: {entry['link']}\n\n" + f"{entry['summary']}\n\n") def main(): @@ -141,6 +146,7 @@ def main(): help="Outputs verbose status messages") parser.add_argument("-l", "--limit", action="store", type=int, dest="limit") parser.add_argument("-d", "--date", action="store", type=int, dest="date") + parser.add_argument("-c", "--colorize", action="store_true", help="Print colorized result in stdout") args = parser.parse_args() @@ -153,7 +159,7 @@ def main(): try: feed = RSSFeed(source=args.source) feed.get_rss(date=args.date) - feed.print_rss(limit=args.limit, is_json=args.json) + feed.print_rss(limit=args.limit, is_json=args.json, colorize=args.colorize) except RSSFeedException as ex: print(f"{ex.message}") sys.exit(0) diff --git a/setup.py b/setup.py index 8579813..2395258 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ keywords='simple rss reader', packages=find_packages(), python_requires='>=3.8', - install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4'], + install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama'], entry_points={ 'console_scripts': [ 'rss-reader=rss_reader.__main__:main', From 58d6eec544796016c5ab464c2e0557be07bc4276 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Thu, 28 Nov 2019 16:27:04 +0300 Subject: [PATCH 20/28] feat: parameters for converting to html/pdf; cache raw_entries Now you can use "--to-html" and "--to-pdf" to convert the RSS feed to the appropriate format. Examples: "--to-html folder_name" create out.html in foldername "--to-pdf folder_name" create out.pdf in foldername Now rss-reader caches "raw_entries" instead of pretty "entries" ones --- README.md | 21 ++++- requirements.txt | 2 + rss_reader/configuration.py | 12 +++ rss_reader/converter.py | 151 ++++++++++++++++++++++++++++++++++++ rss_reader/exceptions.py | 13 ++++ rss_reader/rss_reader.py | 124 ++++++++++++++++++++--------- setup.py | 2 +- 7 files changed, 282 insertions(+), 43 deletions(-) create mode 100644 rss_reader/configuration.py create mode 100644 rss_reader/converter.py create mode 100644 rss_reader/exceptions.py diff --git a/README.md b/README.md index 2ac8532..997194c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ [Introduction to Python] Homework Repository for EPAM courses # How to use -1. `pip3 install -r requirements.txt` -2. `python3.8 rss_reader.py "https://www.androidpolice.com/feed/" --limit 3 --json --verbose --date` +1. `pip3 install .` +2. install [wkhtmltopdf](https://github.com/JazzCore/python-pdfkit/wiki/Installing-wkhtmltopdf) +3. `rss-reader "https://www.androidpolice.com/feed/" --limit 3 --json --verbose --date` # Parameters - **--help** (help text) @@ -11,6 +12,9 @@ - **--verbose** (print verbose log messages) - **--limit** (limit printed entries) - **--date** (print cached entries if exist) +- **--colorize** (colorize output) +- **--to-html** (convert rss feed to html document) +- **--to-pdf** (convert rss feed to pdf document) ## JSON structure `{"feed": "rss_title", "entries": [{"title": "title", "date": "date", "link": "link", "summary": "summary"}, ...]}` @@ -19,13 +23,22 @@ Used [Pickle](https://docs.python.org/3/library/pickle.html) for storage Entries cached in `cache/date/domain.rss` +- cache - name of cache folder, default "cache" (you can change in configuration.py) +- date - script execution date +- domain - domain of rss feed Example: `cache/20191117/www.androidpolice.com.rss` +## Convertation + +Examples: +- `--to-html folder_name` will create "out.html" and "images" folder in folder_name, +- `--to-pdf folder_name` will create "out.pdf" in folder_name + # TODO - [x] [Iteration 1] One-shot command-line RSS reader. - [x] [Iteration 2] Distribution - [x] [Iteration 3] News caching -- [ ] [Iteration 4] Format converter -- [ ] * [Iteration 5] Output colorization +- [x] [Iteration 4] Format converter +- [x] * [Iteration 5] Output colorization - [ ] * [Iteration 6] Web-server diff --git a/requirements.txt b/requirements.txt index 7eb49b8..103c9f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ feedparser>=6.0.0b1 # rss parsing requests # http requests bs4 # for xml and html colorama # colored output https://pypi.org/project/colorama/ +jinja2 # for generating html +pdfkit # for generating pdf \ No newline at end of file diff --git a/rss_reader/configuration.py b/rss_reader/configuration.py new file mode 100644 index 0000000..7ac4932 --- /dev/null +++ b/rss_reader/configuration.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 + +""" +Constants for rss-reader +""" + +from pathlib import Path + +CACHE_DIR = Path("cache") + +IMAGE_DIR = Path("images") +TEMP_IMAGE_DIR = Path("_temp_images") diff --git a/rss_reader/converter.py b/rss_reader/converter.py new file mode 100644 index 0000000..ca6716c --- /dev/null +++ b/rss_reader/converter.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 + +""" +Convert RSS feed to HTML/PDF +""" + +import logging +import shutil +from pathlib import Path + +import requests +import pdfkit +from jinja2 import Template +from bs4 import BeautifulSoup + +from rss_reader.configuration import IMAGE_DIR, TEMP_IMAGE_DIR +# from rss_reader.exceptions import RSSFeedException + + +class Converter: + """ Class for conversion RSS feed + + Attributes: + title (str): Title of RSS feed + entries (list): List of RSS news + directory (str): Directory where output will be saved + """ + + def __init__(self, title, entries, directory): + self.title = title + self.entries = entries + self.directory = directory + + def _download_img(self, url, img_dir): + """ Download image in DIRECTORY/images + + Returns: + filename: image name + """ + + logging.info("Starting image download") + + filename = url.split('/')[-1] + response = requests.get(url, allow_redirects=True) + + out_dir = Path(self.directory) + + if not out_dir.is_dir(): + logging.info("Creating directory /%s", out_dir) + out_dir.mkdir() + + image_dir = out_dir / img_dir + if not image_dir.is_dir(): + logging.info("Creating directory /%s", image_dir) + image_dir.mkdir() + + with open(image_dir / filename, 'wb') as handler: + handler.write(response.content) + + return filename + + def _replace_urls_to_local(self, entry): + """ Replace img URLs in entry to local file path + + Args: + entry (dict): News dict + + """ + soup = BeautifulSoup(entry.summary, "html.parser") + imgs = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] + for img in imgs: + filename = self._download_img(img, IMAGE_DIR) + downloaded_img_local_path = Path(IMAGE_DIR / filename) + entry.summary = entry.summary.replace(img, str(downloaded_img_local_path)) + + return entry + + def _replace_urls_to_absolute(self, entry): + """ Replace img URLs in entry to local absolute file path + + Special for pdfkit (pdfkit support only absolute file path) + + Args: + entry (dict): News dict + """ + soup = BeautifulSoup(entry.summary, "html.parser") + imgs = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] + for img in imgs: + filename = self._download_img(img, TEMP_IMAGE_DIR) + downloaded_img_absolute_path = Path(self.directory / TEMP_IMAGE_DIR / filename).absolute() + entry.summary = entry.summary.replace(img, str(downloaded_img_absolute_path)) + + return entry + + def _gen_html(self, absolute_urls=False): + """ Generates HTML + + Args: + absolute_urls (bool): Should we generate HTML with absolute URLs (to convert to PDF)? + + Returns: + html: String with HTML code + """ + template = ''' + + + {{title}} + + + + + {% for entry in entries %} +
+

{{entry.title}}

+

{{entry.published}}

+ {{entry.title}} +
+
{{entry.summary}}
+
+ {% endfor %} + + ''' + if absolute_urls: + entries = [self._replace_urls_to_absolute(entry) for entry in self.entries] + else: + entries = [self._replace_urls_to_local(entry) for entry in self.entries] + + html = Template(template).render(title=self.title, entries=entries) + return html + + def rss_to_html(self): + """ Generate HTML file in DIRECTORY """ + html = self._gen_html() + with open(Path(self.directory) / 'out.html', 'w') as file_object: + file_object.write(html) + + def rss_to_pdf(self): + """ Generate PDF file in DIRECTORY """ + html = self._gen_html(absolute_urls=True) + options = {'quiet': ''} + pdfkit.from_string(html, Path(self.directory) / 'out.pdf', options=options) + # Delete temp DIRECTORY/TEMP_IMAGE_DIR + temp_img_dir = Path(self.directory / TEMP_IMAGE_DIR) + logging.info("Cleaning up %s", temp_img_dir) + shutil.rmtree(temp_img_dir) diff --git a/rss_reader/exceptions.py b/rss_reader/exceptions.py new file mode 100644 index 0000000..8f60089 --- /dev/null +++ b/rss_reader/exceptions.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 + +""" +Exceptions for rss-reader +""" + + +class RSSFeedException(Exception): + """ Custom exception class for RSSFeed errors """ + + def __init__(self, message): + super(RSSFeedException, self).__init__(message) + self.message = message diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index c5d1085..38b175a 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -8,14 +8,15 @@ __version__ = "0.3.0" __license__ = "MIT" - import os import sys import time +import copy import json import pickle import logging import argparse +from pathlib import Path from datetime import datetime from urllib.parse import urlparse @@ -24,48 +25,61 @@ import bs4 from colorama import Fore - -class RSSFeedException(Exception): - """ Custom exception class for RSSFeed errors """ - def __init__(self, message): - super(RSSFeedException, self).__init__(message) - self.message = message +from rss_reader.exceptions import RSSFeedException +from rss_reader.converter import Converter +from rss_reader.configuration import CACHE_DIR class RSSFeed: - """ Class for rss feed """ + """ Class for RSS feed + + Attributes: + source (str): URL of RSS feed + title (str): Title of RSS feed + entries (list): List of pretty RSS news + raw_entries (list): List of raw RSS news + """ + def __init__(self, source): self.source = source self.title = None self.entries = None - self.raw_rss = None + self.raw_entries = None def _save_rss_in_file(self): """ Saving rss feed to cache/date/domain.rss """ logging.info("Saving rss feed") - directory = f"{datetime.now().strftime('%Y%m%d')}" - if not os.path.exists(f"cache/{directory}"): - logging.info("Creating directory /%s", directory) - os.makedirs(f"cache/{directory}") + + date_dir = Path(datetime.now().strftime('%Y%m%d')) + cache_file_dir = CACHE_DIR / date_dir + if not cache_file_dir.is_dir(): + logging.info("Creating directory /%s", cache_file_dir) + os.makedirs(cache_file_dir) uri = urlparse(self.source) - domain_name = f"{uri.netloc}" - with open(f"cache/{directory}/{domain_name}.rss", "wb") as file: - logging.info("Saving entries in file %s/%s.rss", directory, domain_name) - pickle.dump((self.title, self.entries), file) + file_name = f"{uri.netloc}.rss" + + cache_file_path = cache_file_dir / file_name + with open(cache_file_path, "wb") as file: + logging.info("Saving entries in file %s", cache_file_path) + pickle.dump((self.title, self.raw_entries), file) def _load_rss_from_file(self, date): """ Loading rss feed from cache/date/domain.rss """ logging.info("Loading rss feed") - directory = f"{date}" + + date_dir = Path(str(date)) uri = urlparse(self.source) - domain_name = f"{uri.netloc}" - if not os.path.exists(f"cache/{directory}/{domain_name}.rss"): + file_name = f"{uri.netloc}.rss" + + cache_file_path = CACHE_DIR / date_dir / file_name + if not cache_file_path.is_file(): raise RSSFeedException(message=f"There is no entries for {date}") - with open(f"cache/{directory}/{domain_name}.rss", "rb") as file: - logging.info("Loading entries from file %s/%s.rss", directory, domain_name) - self.title, self.entries = pickle.load(file) + with open(cache_file_path, "rb") as file: + logging.info("Loading entries from file %s", cache_file_path) + self.title, self.raw_entries = pickle.load(file) + self.entries = self._get_pretty_entries() def _get_rss_in_json(self, entries): """ Converts rss feed to json """ @@ -73,8 +87,23 @@ def _get_rss_in_json(self, entries): return json.dumps({"feed": self.title, "entries": entries}, indent=2, ensure_ascii=False) + def _get_pretty_entries(self): + """ Prettify entries + + Remove HTML code from summary, parse date + """ + pretty_entries = [] + for entry in self.raw_entries: + pretty_entries.append({ + "title": entry.title, + "date": time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed), + "link": entry.link, + "summary": bs4.BeautifulSoup(entry.summary, "html.parser").text.strip() + }) + return pretty_entries + def get_rss(self, date): - """ Gets rss feed by source """ + """ Gets rss feed from source or cache""" logging.info("Getting rss feed") if date: @@ -85,16 +114,10 @@ def get_rss(self, date): rss = feedparser.parse(response) if rss['bozo']: raise RSSFeedException(message="Incorrect url") + self.title = rss['feed']['title'] - self.raw_rss = response - self.entries = [] - for entry in rss.entries: - self.entries.append({ - "title": entry.title, - "date": time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed), - "link": entry.link, - "summary": bs4.BeautifulSoup(entry.summary, "html.parser").text - }) + self.raw_entries = rss.entries + self.entries = self._get_pretty_entries() self._save_rss_in_file() @@ -127,10 +150,23 @@ def print_rss(self, limit=None, is_json=False, colorize=False): f"Link: {entry['link']}\n\n" f"{entry['summary']}\n\n") + def convert_to_html(self, directory, limit): + """ Create html file with rss news in DIR """ + logging.info("Converting RSS to HTML") + converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), directory=directory) + converter.rss_to_html() + logging.info("Done.") -def main(): - """ Main entry point of the app """ + def convert_to_pdf(self, directory, limit): + """ Create pdf file with rss news in DIR """ + logging.info("Converting RSS to PDF") + converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), directory=directory) + converter.rss_to_pdf() + logging.info("Done.") + +def get_args(): + """ Parse and return provided args """ parser = argparse.ArgumentParser(description="Pure Python command-line RSS reader.") parser.add_argument("source", help="rss url") @@ -147,8 +183,16 @@ def main(): parser.add_argument("-l", "--limit", action="store", type=int, dest="limit") parser.add_argument("-d", "--date", action="store", type=int, dest="date") parser.add_argument("-c", "--colorize", action="store_true", help="Print colorized result in stdout") + parser.add_argument("--to-html", action="store", type=str) + parser.add_argument("--to-pdf", action="store", type=str) + + return parser.parse_args() - args = parser.parse_args() + +def main(): + """ Main entry point of the app """ + + args = get_args() if args.verbose: logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.DEBUG) @@ -160,11 +204,15 @@ def main(): feed = RSSFeed(source=args.source) feed.get_rss(date=args.date) feed.print_rss(limit=args.limit, is_json=args.json, colorize=args.colorize) + if args.to_html: + feed.convert_to_html(directory=args.to_html, limit=args.limit) + if args.to_pdf: + feed.convert_to_pdf(directory=args.to_pdf, limit=args.limit) except RSSFeedException as ex: print(f"{ex.message}") sys.exit(0) - - logging.info("Exiting") + finally: + logging.info("Exiting") if __name__ == "__main__": diff --git a/setup.py b/setup.py index 2395258..8c7858c 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ keywords='simple rss reader', packages=find_packages(), python_requires='>=3.8', - install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama'], + install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama', 'jinja2', 'pdfkit'], entry_points={ 'console_scripts': [ 'rss-reader=rss_reader.__main__:main', From 3c0dc4139bbd208a87708ac4ec4b99afe4a8ac06 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sat, 30 Nov 2019 01:58:12 +0300 Subject: [PATCH 21/28] feat: replaced package for generating pdf Replaced "pdfkit" with "xhtml2pdf", because second one is "pure python" --- README.md | 6 +- requirements.txt | 2 +- rss_reader/configuration.py | 12 ---- rss_reader/converter.py | 99 ++++++++++++++++------------ rss_reader/fonts/Roboto-Regular.ttf | Bin 0 -> 171272 bytes rss_reader/rss_reader.py | 26 ++++---- setup.py | 4 +- 7 files changed, 77 insertions(+), 72 deletions(-) delete mode 100644 rss_reader/configuration.py create mode 100644 rss_reader/fonts/Roboto-Regular.ttf diff --git a/README.md b/README.md index 997194c..beb535d 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [Introduction to Python] Homework Repository for EPAM courses # How to use -1. `pip3 install .` -2. install [wkhtmltopdf](https://github.com/JazzCore/python-pdfkit/wiki/Installing-wkhtmltopdf) +1. install git `apt-get install git` +2. `pip3 install .` 3. `rss-reader "https://www.androidpolice.com/feed/" --limit 3 --json --verbose --date` # Parameters @@ -23,7 +23,7 @@ Used [Pickle](https://docs.python.org/3/library/pickle.html) for storage Entries cached in `cache/date/domain.rss` -- cache - name of cache folder, default "cache" (you can change in configuration.py) +- cache - name of cache folder, default "cache" - date - script execution date - domain - domain of rss feed diff --git a/requirements.txt b/requirements.txt index 103c9f6..668a38b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ requests # http requests bs4 # for xml and html colorama # colored output https://pypi.org/project/colorama/ jinja2 # for generating html -pdfkit # for generating pdf \ No newline at end of file +git+https://github.com/xhtml2pdf/xhtml2pdf.git \ No newline at end of file diff --git a/rss_reader/configuration.py b/rss_reader/configuration.py deleted file mode 100644 index 7ac4932..0000000 --- a/rss_reader/configuration.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python3 - -""" -Constants for rss-reader -""" - -from pathlib import Path - -CACHE_DIR = Path("cache") - -IMAGE_DIR = Path("images") -TEMP_IMAGE_DIR = Path("_temp_images") diff --git a/rss_reader/converter.py b/rss_reader/converter.py index ca6716c..1bdd07f 100644 --- a/rss_reader/converter.py +++ b/rss_reader/converter.py @@ -9,12 +9,11 @@ from pathlib import Path import requests -import pdfkit +from xhtml2pdf import pisa from jinja2 import Template from bs4 import BeautifulSoup -from rss_reader.configuration import IMAGE_DIR, TEMP_IMAGE_DIR -# from rss_reader.exceptions import RSSFeedException +from rss_reader.exceptions import RSSFeedException class Converter: @@ -23,16 +22,21 @@ class Converter: Attributes: title (str): Title of RSS feed entries (list): List of RSS news - directory (str): Directory where output will be saved + out_dir (str): Directory where output will be saved """ - def __init__(self, title, entries, directory): + def __init__(self, title, entries, out_dir, image_dir="images", temp_image_dir="_temp_images"): self.title = title self.entries = entries - self.directory = directory + self.out_dir = Path(out_dir) - def _download_img(self, url, img_dir): - """ Download image in DIRECTORY/images + self.image_dir = Path(image_dir) + self.temp_image_dir = Path(temp_image_dir) + + self.font_path = Path(__file__).resolve().parent / 'fonts/Roboto-Regular.ttf' + + def _download_img(self, url, image_dir): + """ Download image in self.out_dir/image_dir Returns: filename: image name @@ -43,13 +47,11 @@ def _download_img(self, url, img_dir): filename = url.split('/')[-1] response = requests.get(url, allow_redirects=True) - out_dir = Path(self.directory) + if not self.out_dir.is_dir(): + logging.info("Creating directory /%s", self.out_dir) + self.out_dir.mkdir() - if not out_dir.is_dir(): - logging.info("Creating directory /%s", out_dir) - out_dir.mkdir() - - image_dir = out_dir / img_dir + image_dir = self.out_dir / image_dir if not image_dir.is_dir(): logging.info("Creating directory /%s", image_dir) image_dir.mkdir() @@ -67,11 +69,11 @@ def _replace_urls_to_local(self, entry): """ soup = BeautifulSoup(entry.summary, "html.parser") - imgs = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] - for img in imgs: - filename = self._download_img(img, IMAGE_DIR) - downloaded_img_local_path = Path(IMAGE_DIR / filename) - entry.summary = entry.summary.replace(img, str(downloaded_img_local_path)) + images = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] + for image in images: + filename = self._download_img(image, self.image_dir) + downloaded_img_local_path = Path(self.image_dir / filename) + entry.summary = entry.summary.replace(image, str(downloaded_img_local_path)) return entry @@ -84,19 +86,20 @@ def _replace_urls_to_absolute(self, entry): entry (dict): News dict """ soup = BeautifulSoup(entry.summary, "html.parser") - imgs = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] - for img in imgs: - filename = self._download_img(img, TEMP_IMAGE_DIR) - downloaded_img_absolute_path = Path(self.directory / TEMP_IMAGE_DIR / filename).absolute() - entry.summary = entry.summary.replace(img, str(downloaded_img_absolute_path)) + images = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] + for image in images: + filename = self._download_img(image, self.temp_image_dir) + downloaded_img_absolute_path = Path(self.out_dir / self.temp_image_dir / filename).absolute() + entry.summary = entry.summary.replace(image, str(downloaded_img_absolute_path)) return entry - def _gen_html(self, absolute_urls=False): + def _gen_html(self, is_cyrillic_font=False, is_absolute_urls=False): """ Generates HTML Args: - absolute_urls (bool): Should we generate HTML with absolute URLs (to convert to PDF)? + is_cyrillic_font (bool) Should we generate HTML with cyrillic_font (to convert to PDF)? + is_absolute_urls (bool): Should we generate HTML with absolute URLs (to convert to PDF)? Returns: html: String with HTML code @@ -106,12 +109,18 @@ def _gen_html(self, absolute_urls=False): {{title}} - @@ -119,33 +128,39 @@ def _gen_html(self, absolute_urls=False):

{{entry.title}}

{{entry.published}}

- {{entry.title}} -
+

{{entry.link}}

{{entry.summary}}
{% endfor %} ''' - if absolute_urls: - entries = [self._replace_urls_to_absolute(entry) for entry in self.entries] + if is_absolute_urls: + self.entries = [self._replace_urls_to_absolute(entry) for entry in self.entries] else: - entries = [self._replace_urls_to_local(entry) for entry in self.entries] + self.entries = [self._replace_urls_to_local(entry) for entry in self.entries] - html = Template(template).render(title=self.title, entries=entries) + html = Template(template).render(title=self.title, entries=self.entries, + is_cyrillic_font=is_cyrillic_font, font_path=self.font_path) return html def rss_to_html(self): - """ Generate HTML file in DIRECTORY """ + """ Generate HTML file in self.out_dir """ html = self._gen_html() - with open(Path(self.directory) / 'out.html', 'w') as file_object: + + with open(Path(self.out_dir) / 'out.html', 'w') as file_object: file_object.write(html) def rss_to_pdf(self): - """ Generate PDF file in DIRECTORY """ - html = self._gen_html(absolute_urls=True) - options = {'quiet': ''} - pdfkit.from_string(html, Path(self.directory) / 'out.pdf', options=options) + """ Generate PDF file in self.out_dir """ + html = self._gen_html(is_cyrillic_font=True, is_absolute_urls=True) + + with open(Path(self.out_dir) / 'out.pdf', "w+b") as file: + pdf = pisa.CreatePDF(html, dest=file, encoding='UTF-8') + # Delete temp DIRECTORY/TEMP_IMAGE_DIR - temp_img_dir = Path(self.directory / TEMP_IMAGE_DIR) + temp_img_dir = Path(self.out_dir / self.temp_image_dir) logging.info("Cleaning up %s", temp_img_dir) shutil.rmtree(temp_img_dir) + + if pdf.err: + raise RSSFeedException(message="Error during PDF generation") diff --git a/rss_reader/fonts/Roboto-Regular.ttf b/rss_reader/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2b6392ffe8712b9c5450733320cd220d6c0f4bce GIT binary patch literal 171272 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 literal 0 HcmV?d00001 diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 38b175a..3fb98ea 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -27,7 +27,6 @@ from rss_reader.exceptions import RSSFeedException from rss_reader.converter import Converter -from rss_reader.configuration import CACHE_DIR class RSSFeed: @@ -40,18 +39,19 @@ class RSSFeed: raw_entries (list): List of raw RSS news """ - def __init__(self, source): + def __init__(self, source, cache_dir="cache"): self.source = source self.title = None self.entries = None self.raw_entries = None + self.cache_dir = Path(cache_dir) def _save_rss_in_file(self): """ Saving rss feed to cache/date/domain.rss """ logging.info("Saving rss feed") date_dir = Path(datetime.now().strftime('%Y%m%d')) - cache_file_dir = CACHE_DIR / date_dir + cache_file_dir = self.cache_dir / date_dir if not cache_file_dir.is_dir(): logging.info("Creating directory /%s", cache_file_dir) os.makedirs(cache_file_dir) @@ -72,7 +72,7 @@ def _load_rss_from_file(self, date): uri = urlparse(self.source) file_name = f"{uri.netloc}.rss" - cache_file_path = CACHE_DIR / date_dir / file_name + cache_file_path = self.cache_dir / date_dir / file_name if not cache_file_path.is_file(): raise RSSFeedException(message=f"There is no entries for {date}") @@ -81,8 +81,8 @@ def _load_rss_from_file(self, date): self.title, self.raw_entries = pickle.load(file) self.entries = self._get_pretty_entries() - def _get_rss_in_json(self, entries): - """ Converts rss feed to json """ + def _get_entries_in_json(self, entries): + """ Convert entries to json """ logging.info("Converting rss feed to json") return json.dumps({"feed": self.title, "entries": entries}, indent=2, ensure_ascii=False) @@ -132,7 +132,7 @@ def print_rss(self, limit=None, is_json=False, colorize=False): logging.info("Printing rss feed") if is_json: - entries = self._get_rss_in_json(entries) + entries = self._get_entries_in_json(entries) print(entries) else: if colorize: @@ -150,17 +150,17 @@ def print_rss(self, limit=None, is_json=False, colorize=False): f"Link: {entry['link']}\n\n" f"{entry['summary']}\n\n") - def convert_to_html(self, directory, limit): + def convert_to_html(self, out_dir, limit): """ Create html file with rss news in DIR """ logging.info("Converting RSS to HTML") - converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), directory=directory) + converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) converter.rss_to_html() logging.info("Done.") - def convert_to_pdf(self, directory, limit): + def convert_to_pdf(self, out_dir, limit): """ Create pdf file with rss news in DIR """ logging.info("Converting RSS to PDF") - converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), directory=directory) + converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) converter.rss_to_pdf() logging.info("Done.") @@ -205,9 +205,9 @@ def main(): feed.get_rss(date=args.date) feed.print_rss(limit=args.limit, is_json=args.json, colorize=args.colorize) if args.to_html: - feed.convert_to_html(directory=args.to_html, limit=args.limit) + feed.convert_to_html(out_dir=args.to_html, limit=args.limit) if args.to_pdf: - feed.convert_to_pdf(directory=args.to_pdf, limit=args.limit) + feed.convert_to_pdf(out_dir=args.to_pdf, limit=args.limit) except RSSFeedException as ex: print(f"{ex.message}") sys.exit(0) diff --git a/setup.py b/setup.py index 8c7858c..585ecc1 100644 --- a/setup.py +++ b/setup.py @@ -16,8 +16,10 @@ author_email='dison.ds@gmail.com', keywords='simple rss reader', packages=find_packages(), + package_data={'rss_reader': ['fonts/*.ttf']}, python_requires='>=3.8', - install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama', 'jinja2', 'pdfkit'], + install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama', 'jinja2', + 'xhtml2pdf @ git+https://github.com/xhtml2pdf/xhtml2pdf/'], entry_points={ 'console_scripts': [ 'rss-reader=rss_reader.__main__:main', From 8bbfcfdf6f8dcc14f846c9c133a213c15dc1be33 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sat, 30 Nov 2019 03:21:14 +0300 Subject: [PATCH 22/28] chore: version increase (0.5.0) --- rss_reader/converter.py | 39 +++++++++++++++++++++++---------------- rss_reader/rss_reader.py | 21 +++++++++++++-------- setup.py | 2 +- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/rss_reader/converter.py b/rss_reader/converter.py index 1bdd07f..cf3ced3 100644 --- a/rss_reader/converter.py +++ b/rss_reader/converter.py @@ -35,26 +35,33 @@ def __init__(self, title, entries, out_dir, image_dir="images", temp_image_dir=" self.font_path = Path(__file__).resolve().parent / 'fonts/Roboto-Regular.ttf' - def _download_img(self, url, image_dir): + def _create_directories(self, image_dir): + """ Create directories if not exist (self.out_dir and self.out_dir/image_dir) """ + if not self.out_dir.is_dir(): + logging.info("Creating directory /%s", self.out_dir) + self.out_dir.mkdir(parents=True, exist_ok=True) + + if not image_dir.is_dir(): + logging.info("Creating directory /%s", image_dir) + image_dir.mkdir(parents=True, exist_ok=True) + + def _download_image(self, url, image_dir): """ Download image in self.out_dir/image_dir Returns: filename: image name """ - logging.info("Starting image download") - filename = url.split('/')[-1] - response = requests.get(url, allow_redirects=True) + image_dir = self.out_dir / image_dir - if not self.out_dir.is_dir(): - logging.info("Creating directory /%s", self.out_dir) - self.out_dir.mkdir() + try: + self._create_directories(image_dir) + except OSError: + raise RSSFeedException(message="Сan not create directory") - image_dir = self.out_dir / image_dir - if not image_dir.is_dir(): - logging.info("Creating directory /%s", image_dir) - image_dir.mkdir() + filename = url.split('/')[-1] + response = requests.get(url, allow_redirects=True) with open(image_dir / filename, 'wb') as handler: handler.write(response.content) @@ -71,7 +78,7 @@ def _replace_urls_to_local(self, entry): soup = BeautifulSoup(entry.summary, "html.parser") images = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] for image in images: - filename = self._download_img(image, self.image_dir) + filename = self._download_image(image, self.image_dir) downloaded_img_local_path = Path(self.image_dir / filename) entry.summary = entry.summary.replace(image, str(downloaded_img_local_path)) @@ -80,7 +87,7 @@ def _replace_urls_to_local(self, entry): def _replace_urls_to_absolute(self, entry): """ Replace img URLs in entry to local absolute file path - Special for pdfkit (pdfkit support only absolute file path) + Special for xhtml2pdf (xhtml2pdf support only absolute file path) Args: entry (dict): News dict @@ -88,7 +95,7 @@ def _replace_urls_to_absolute(self, entry): soup = BeautifulSoup(entry.summary, "html.parser") images = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] for image in images: - filename = self._download_img(image, self.temp_image_dir) + filename = self._download_image(image, self.temp_image_dir) downloaded_img_absolute_path = Path(self.out_dir / self.temp_image_dir / filename).absolute() entry.summary = entry.summary.replace(image, str(downloaded_img_absolute_path)) @@ -154,10 +161,10 @@ def rss_to_pdf(self): """ Generate PDF file in self.out_dir """ html = self._gen_html(is_cyrillic_font=True, is_absolute_urls=True) - with open(Path(self.out_dir) / 'out.pdf', "w+b") as file: + with open(Path(self.out_dir) / 'out.pdf', 'w+b') as file: pdf = pisa.CreatePDF(html, dest=file, encoding='UTF-8') - # Delete temp DIRECTORY/TEMP_IMAGE_DIR + # Delete temp folder (self.out_dir/self.temp_image_dir) temp_img_dir = Path(self.out_dir / self.temp_image_dir) logging.info("Cleaning up %s", temp_img_dir) shutil.rmtree(temp_img_dir) diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 3fb98ea..79e71ac 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -5,7 +5,7 @@ """ __author__ = "DiSonDS" -__version__ = "0.3.0" +__version__ = "0.5.0" __license__ = "MIT" import os @@ -151,14 +151,14 @@ def print_rss(self, limit=None, is_json=False, colorize=False): f"{entry['summary']}\n\n") def convert_to_html(self, out_dir, limit): - """ Create html file with rss news in DIR """ + """ Create html file with rss news in out_dir """ logging.info("Converting RSS to HTML") converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) converter.rss_to_html() logging.info("Done.") def convert_to_pdf(self, out_dir, limit): - """ Create pdf file with rss news in DIR """ + """ Create pdf file with rss news in out_dir """ logging.info("Converting RSS to PDF") converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) converter.rss_to_pdf() @@ -180,11 +180,16 @@ def get_args(): action="count", default=False, help="Outputs verbose status messages") - parser.add_argument("-l", "--limit", action="store", type=int, dest="limit") - parser.add_argument("-d", "--date", action="store", type=int, dest="date") - parser.add_argument("-c", "--colorize", action="store_true", help="Print colorized result in stdout") - parser.add_argument("--to-html", action="store", type=str) - parser.add_argument("--to-pdf", action="store", type=str) + parser.add_argument("-l", "--limit", action="store", type=int, dest="limit", + help="Limit news topics if this parameter is provided") + parser.add_argument("-d", "--date", action="store", type=int, dest="date", + help="Trying to get cached news for DATE if this parameter is provided.") + parser.add_argument("-c", "--colorize", action="store_true", + help="Print colorized result in stdout") + parser.add_argument("--to-html", action="store", type=str, + help="Generate TO_HTML/out.html with news") + parser.add_argument("--to-pdf", action="store", type=str, + help="Generate TO_HTML/out.pdf with news") return parser.parse_args() diff --git a/setup.py b/setup.py index 585ecc1..9aa0409 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='rss-reader', - version='0.3.0', + version='0.5.0', description='A simple Python3.8 rss reader', long_description=LONG_DESCRIPTION, long_description_content_type='text/markdown', From c1e850d02e25a1a8e921d1d7e3f9b7ef2359f882 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sat, 30 Nov 2019 18:36:18 +0300 Subject: [PATCH 23/28] feat: --to-epub parameter for convertation in epub Now you can use "--to-epub" to convert the RSS feed to the appropriate format. Examples: "--to-epub folder_name" create out.epub in foldername --- README.md | 6 ++-- requirements.txt | 4 ++- rss_reader/converter.py | 78 ++++++++++++++++++++++++++++++++++++++-- rss_reader/rss_reader.py | 15 ++++++-- setup.py | 2 +- 5 files changed, 97 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index beb535d..6117ecf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [Introduction to Python] Homework Repository for EPAM courses # How to use -1. install git `apt-get install git` +1. install git `apt-get install git` (necessary to get a package with git) 2. `pip3 install .` 3. `rss-reader "https://www.androidpolice.com/feed/" --limit 3 --json --verbose --date` @@ -12,9 +12,10 @@ - **--verbose** (print verbose log messages) - **--limit** (limit printed entries) - **--date** (print cached entries if exist) -- **--colorize** (colorize output) - **--to-html** (convert rss feed to html document) - **--to-pdf** (convert rss feed to pdf document) +- **--to-epub** (convert rss feed to epub document) +- **--colorize** (colorize output) ## JSON structure `{"feed": "rss_title", "entries": [{"title": "title", "date": "date", "link": "link", "summary": "summary"}, ...]}` @@ -34,6 +35,7 @@ Example: `cache/20191117/www.androidpolice.com.rss` Examples: - `--to-html folder_name` will create "out.html" and "images" folder in folder_name, - `--to-pdf folder_name` will create "out.pdf" in folder_name +- `--to-epub folder_name` will create "out.epub" in folder_name # TODO - [x] [Iteration 1] One-shot command-line RSS reader. diff --git a/requirements.txt b/requirements.txt index 668a38b..417e797 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,6 @@ requests # http requests bs4 # for xml and html colorama # colored output https://pypi.org/project/colorama/ jinja2 # for generating html -git+https://github.com/xhtml2pdf/xhtml2pdf.git \ No newline at end of file +git+https://github.com/xhtml2pdf/xhtml2pdf.git +ebooklib +lxml \ No newline at end of file diff --git a/rss_reader/converter.py b/rss_reader/converter.py index cf3ced3..8381c48 100644 --- a/rss_reader/converter.py +++ b/rss_reader/converter.py @@ -9,9 +9,12 @@ from pathlib import Path import requests +from lxml import etree from xhtml2pdf import pisa from jinja2 import Template from bs4 import BeautifulSoup +from ebooklib import epub +from ebooklib.utils import parse_html_string from rss_reader.exceptions import RSSFeedException @@ -101,6 +104,17 @@ def _replace_urls_to_absolute(self, entry): return entry + def _get_entry_html(self, entry): + template = """""" + temp_entry = self._replace_urls_to_local(entry) + html = Template(template).render(title=self.title, entry=temp_entry) + return html + def _gen_html(self, is_cyrillic_font=False, is_absolute_urls=False): """ Generates HTML @@ -150,14 +164,14 @@ def _gen_html(self, is_cyrillic_font=False, is_absolute_urls=False): is_cyrillic_font=is_cyrillic_font, font_path=self.font_path) return html - def rss_to_html(self): + def entries_to_html(self): """ Generate HTML file in self.out_dir """ html = self._gen_html() with open(Path(self.out_dir) / 'out.html', 'w') as file_object: file_object.write(html) - def rss_to_pdf(self): + def entries_to_pdf(self): """ Generate PDF file in self.out_dir """ html = self._gen_html(is_cyrillic_font=True, is_absolute_urls=True) @@ -171,3 +185,63 @@ def rss_to_pdf(self): if pdf.err: raise RSSFeedException(message="Error during PDF generation") + + def entries_to_epub(self): + """ Generate EPUB file in self.out_dir """ + html = self._gen_html() + + def add_images_to_book(): + html_tree = parse_html_string(chapter.content) + + for img_elem in html_tree.iterfind('.//img'): + href = img_elem.attrib['src'] + img_local_filename = self.out_dir / href + + with open(img_local_filename, 'br') as file_object: + epimg = epub.EpubImage() + epimg.file_name = href + epimg.set_content(file_object.read()) + + book.add_item(epimg) + + chapter.content = etree.tostring(html_tree, pretty_print=True, encoding='utf-8') + + book = epub.EpubBook() + + # set metadata + book.set_identifier('id1337') + book.set_title(self.title) + book.set_language('en, ru') + + book.add_author('DiSonDS') + + # create chapter + chapter = epub.EpubHtml(title='Intro', file_name=f'chap_01.xhtml', lang='en, ru') + chapter.content = html + + add_images_to_book() + + # add chapter + book.add_item(chapter) + + # define Table Of Contents + book.toc = (epub.Link('chap_01.xhtml', 'Introduction', 'intro'), + (epub.Section(self.title), + (chapter,)) + ) + + # add default NCX and Nav file + book.add_item(epub.EpubNcx()) + book.add_item(epub.EpubNav()) + + # define CSS style + style = 'BODY {color: white;}' + nav_css = epub.EpubItem(uid="style_nav", file_name="style/nav.css", media_type="text/css", content=style) + + # add CSS file + book.add_item(nav_css) + # basic spine + book.spine = ['nav', chapter] + + # write to the file + epub.write_epub(Path(self.out_dir) / 'out.epub', book, {}) diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 79e71ac..84f1191 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -154,14 +154,21 @@ def convert_to_html(self, out_dir, limit): """ Create html file with rss news in out_dir """ logging.info("Converting RSS to HTML") converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) - converter.rss_to_html() + converter.entries_to_html() logging.info("Done.") def convert_to_pdf(self, out_dir, limit): """ Create pdf file with rss news in out_dir """ logging.info("Converting RSS to PDF") converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) - converter.rss_to_pdf() + converter.entries_to_pdf() + logging.info("Done.") + + def convert_to_epub(self, out_dir, limit): + """ Create epub file with rss news in out_dir """ + logging.info("Converting RSS to EPUB") + converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) + converter.entries_to_epub() logging.info("Done.") @@ -190,6 +197,8 @@ def get_args(): help="Generate TO_HTML/out.html with news") parser.add_argument("--to-pdf", action="store", type=str, help="Generate TO_HTML/out.pdf with news") + parser.add_argument("--to-epub", action="store", type=str, + help="Generate TO_EPUB/out.epub with news") return parser.parse_args() @@ -213,6 +222,8 @@ def main(): feed.convert_to_html(out_dir=args.to_html, limit=args.limit) if args.to_pdf: feed.convert_to_pdf(out_dir=args.to_pdf, limit=args.limit) + if args.to_epub: + feed.convert_to_epub(out_dir=args.to_epub, limit=args.limit) except RSSFeedException as ex: print(f"{ex.message}") sys.exit(0) diff --git a/setup.py b/setup.py index 9aa0409..a744f2e 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ packages=find_packages(), package_data={'rss_reader': ['fonts/*.ttf']}, python_requires='>=3.8', - install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama', 'jinja2', + install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama', 'jinja2', 'ebooklib', 'lxml' 'xhtml2pdf @ git+https://github.com/xhtml2pdf/xhtml2pdf/'], entry_points={ 'console_scripts': [ From 62bbcfaa1f279134593d43f0c71259c86a842ca4 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 1 Dec 2019 16:14:05 +0300 Subject: [PATCH 24/28] fix: requirements (setup.py) --- README.md | 9 ++++++--- rss_reader/converter.py | 45 ++++++++++++++++++++++------------------- setup.py | 2 +- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6117ecf..5f0b189 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ 2. `pip3 install .` 3. `rss-reader "https://www.androidpolice.com/feed/" --limit 3 --json --verbose --date` +# Important information +"--to-pdf" convertation is unstable + # Parameters - **--help** (help text) - **--json** (print rss feed in json format) @@ -13,8 +16,8 @@ - **--limit** (limit printed entries) - **--date** (print cached entries if exist) - **--to-html** (convert rss feed to html document) -- **--to-pdf** (convert rss feed to pdf document) - **--to-epub** (convert rss feed to epub document) +- **--to-pdf** (convert rss feed to pdf document) - **--colorize** (colorize output) ## JSON structure @@ -33,9 +36,9 @@ Example: `cache/20191117/www.androidpolice.com.rss` ## Convertation Examples: -- `--to-html folder_name` will create "out.html" and "images" folder in folder_name, -- `--to-pdf folder_name` will create "out.pdf" in folder_name +- `--to-html folder_name` will create "out.html" and "images" folder in folder_name - `--to-epub folder_name` will create "out.epub" in folder_name +- `--to-pdf folder_name` will create "out.pdf" in folder_name (*UNSTABLE*) # TODO - [x] [Iteration 1] One-shot command-line RSS reader. diff --git a/rss_reader/converter.py b/rss_reader/converter.py index 8381c48..85b89fa 100644 --- a/rss_reader/converter.py +++ b/rss_reader/converter.py @@ -6,6 +6,7 @@ import logging import shutil +import random from pathlib import Path import requests @@ -71,7 +72,7 @@ def _download_image(self, url, image_dir): return filename - def _replace_urls_to_local(self, entry): + def _replace_urls_to_local_path(self, entry): """ Replace img URLs in entry to local file path Args: @@ -87,7 +88,7 @@ def _replace_urls_to_local(self, entry): return entry - def _replace_urls_to_absolute(self, entry): + def _replace_urls_to_absolute_path(self, entry): """ Replace img URLs in entry to local absolute file path Special for xhtml2pdf (xhtml2pdf support only absolute file path) @@ -111,16 +112,16 @@ def _get_entry_html(self, entry):

{{entry.link}}

{{entry.summary}}
""" - temp_entry = self._replace_urls_to_local(entry) + temp_entry = self._replace_urls_to_local_path(entry) html = Template(template).render(title=self.title, entry=temp_entry) return html - def _gen_html(self, is_cyrillic_font=False, is_absolute_urls=False): - """ Generates HTML + def _generate_html(self, is_cyrillic_font=False, is_absolute_path=False): + """ Generate HTML Args: is_cyrillic_font (bool) Should we generate HTML with cyrillic_font (to convert to PDF)? - is_absolute_urls (bool): Should we generate HTML with absolute URLs (to convert to PDF)? + is_absolute_path (bool): Should we generate HTML with absolute image PATH (to convert to PDF)? Returns: html: String with HTML code @@ -139,8 +140,13 @@ def _gen_html(self, is_cyrillic_font=False, is_absolute_urls=False): } div { - margin: 10px; + {% if is_cyrillic_font %} + margin: 2px; + font-size: 15px; + {% else %} + margin: 20px; font-size: 20px; + {% endif %} } @@ -155,10 +161,12 @@ def _gen_html(self, is_cyrillic_font=False, is_absolute_urls=False): {% endfor %} ''' - if is_absolute_urls: - self.entries = [self._replace_urls_to_absolute(entry) for entry in self.entries] + + # replacing image url to downloaded image path + if is_absolute_path: + self.entries = [self._replace_urls_to_absolute_path(entry) for entry in self.entries] else: - self.entries = [self._replace_urls_to_local(entry) for entry in self.entries] + self.entries = [self._replace_urls_to_local_path(entry) for entry in self.entries] html = Template(template).render(title=self.title, entries=self.entries, is_cyrillic_font=is_cyrillic_font, font_path=self.font_path) @@ -166,14 +174,14 @@ def _gen_html(self, is_cyrillic_font=False, is_absolute_urls=False): def entries_to_html(self): """ Generate HTML file in self.out_dir """ - html = self._gen_html() + html = self._generate_html() with open(Path(self.out_dir) / 'out.html', 'w') as file_object: file_object.write(html) def entries_to_pdf(self): """ Generate PDF file in self.out_dir """ - html = self._gen_html(is_cyrillic_font=True, is_absolute_urls=True) + html = self._generate_html(is_cyrillic_font=True, is_absolute_path=True) with open(Path(self.out_dir) / 'out.pdf', 'w+b') as file: pdf = pisa.CreatePDF(html, dest=file, encoding='UTF-8') @@ -188,7 +196,7 @@ def entries_to_pdf(self): def entries_to_epub(self): """ Generate EPUB file in self.out_dir """ - html = self._gen_html() + html = self._generate_html() def add_images_to_book(): html_tree = parse_html_string(chapter.content) @@ -209,18 +217,16 @@ def add_images_to_book(): book = epub.EpubBook() # set metadata - book.set_identifier('id1337') + book.set_identifier(f'id{random.randint(100000, 999999)}') book.set_title(self.title) book.set_language('en, ru') - - book.add_author('DiSonDS') + book.add_author('rss-reader') # create chapter chapter = epub.EpubHtml(title='Intro', file_name=f'chap_01.xhtml', lang='en, ru') chapter.content = html - + # add images add_images_to_book() - # add chapter book.add_item(chapter) @@ -229,15 +235,12 @@ def add_images_to_book(): (epub.Section(self.title), (chapter,)) ) - # add default NCX and Nav file book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNav()) - # define CSS style style = 'BODY {color: white;}' nav_css = epub.EpubItem(uid="style_nav", file_name="style/nav.css", media_type="text/css", content=style) - # add CSS file book.add_item(nav_css) # basic spine diff --git a/setup.py b/setup.py index a744f2e..0cabd0a 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ packages=find_packages(), package_data={'rss_reader': ['fonts/*.ttf']}, python_requires='>=3.8', - install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama', 'jinja2', 'ebooklib', 'lxml' + install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama', 'jinja2', 'ebooklib', 'lxml', 'xhtml2pdf @ git+https://github.com/xhtml2pdf/xhtml2pdf/'], entry_points={ 'console_scripts': [ From f886394d0808c7ec0cb0891f8a9fdb097397a58f Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 1 Dec 2019 16:53:09 +0300 Subject: [PATCH 25/28] fix: README.md layout --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5f0b189..7c6e04f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # PythonHomework [Introduction to Python] Homework Repository for EPAM courses -# How to use +## How to use 1. install git `apt-get install git` (necessary to get a package with git) 2. `pip3 install .` 3. `rss-reader "https://www.androidpolice.com/feed/" --limit 3 --json --verbose --date` -# Important information +## Important information "--to-pdf" convertation is unstable -# Parameters +## Parameters - **--help** (help text) - **--json** (print rss feed in json format) - **--verbose** (print verbose log messages) @@ -40,7 +40,7 @@ Examples: - `--to-epub folder_name` will create "out.epub" in folder_name - `--to-pdf folder_name` will create "out.pdf" in folder_name (*UNSTABLE*) -# TODO +## TODO - [x] [Iteration 1] One-shot command-line RSS reader. - [x] [Iteration 2] Distribution - [x] [Iteration 3] News caching From 7c9bb90a15616c3a6821eaa0f6a4ae778be65772 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 1 Dec 2019 17:15:24 +0300 Subject: [PATCH 26/28] feat: photos , links in rss feed pretty-printing --- README.md | 2 +- rss_reader/rss_reader.py | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7c6e04f..9b22182 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ - **--colorize** (colorize output) ## JSON structure -`{"feed": "rss_title", "entries": [{"title": "title", "date": "date", "link": "link", "summary": "summary"}, ...]}` +`{"feed": "rss_title", "entries": [{"title": "title", "date": "date", "link": "link", "summary": "summary", "photos": [...], "links": [...]}, ...]}` ## Storage Used [Pickle](https://docs.python.org/3/library/pickle.html) for storage diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index 84f1191..bbffa2a 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -90,15 +90,21 @@ def _get_entries_in_json(self, entries): def _get_pretty_entries(self): """ Prettify entries - Remove HTML code from summary, parse date + Remove HTML code from summary, parse date, photos, links """ pretty_entries = [] for entry in self.raw_entries: + summary_html = bs4.BeautifulSoup(entry.summary, "html.parser") + images = [img['src'] for img in summary_html.findAll('img') if img.has_attr('src')] + links = [link['href'] for link in entry.links] + pretty_entries.append({ "title": entry.title, "date": time.strftime('%Y-%m-%dT%H:%M:%SZ', entry.published_parsed), "link": entry.link, - "summary": bs4.BeautifulSoup(entry.summary, "html.parser").text.strip() + "summary": summary_html.text.strip(), + "photos": images, + "links": links }) return pretty_entries @@ -141,14 +147,18 @@ def print_rss(self, limit=None, is_json=False, colorize=False): print(f"{Fore.GREEN}Title:{Fore.RESET} {entry['title']}\n" f"{Fore.MAGENTA}Date:{Fore.RESET} {entry['date']}\n" f"{Fore.BLUE}Link:{Fore.RESET} {entry['link']}\n\n" - f"{entry['summary']}\n\n") + f"{entry['summary']}\n\n" + f"{Fore.YELLOW}Photos:{Fore.RESET} {', '.join(entry['photos'])}\n" + f"{Fore.CYAN}Links:{Fore.RESET} {', '.join(entry['links'])}\n\n") else: print(f"Feed: {self.title}\n") for entry in entries: print(f"Title: {entry['title']}\n" f"Date: {entry['date']}\n" f"Link: {entry['link']}\n\n" - f"{entry['summary']}\n\n") + f"{entry['summary']}\n\n" + f"Photos: {', '.join(entry['photos'])}\n" + f"Links: {', '.join(entry['links'])}\n\n") def convert_to_html(self, out_dir, limit): """ Create html file with rss news in out_dir """ From bea514287b8800b454670df450ade899609d5957 Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 1 Dec 2019 23:14:47 +0300 Subject: [PATCH 27/28] fix: converter crash when image "src" is None --- requirements.txt | 3 +- rss_reader/converter.py | 104 ++++++++++++++----------- rss_reader/placeholder/placeholder.jpg | Bin 0 -> 17441 bytes rss_reader/rss_reader.py | 47 +++++------ setup.py | 4 +- 5 files changed, 81 insertions(+), 77 deletions(-) create mode 100644 rss_reader/placeholder/placeholder.jpg diff --git a/requirements.txt b/requirements.txt index 417e797..121f9ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,4 @@ bs4 # for xml and html colorama # colored output https://pypi.org/project/colorama/ jinja2 # for generating html git+https://github.com/xhtml2pdf/xhtml2pdf.git -ebooklib -lxml \ No newline at end of file +ebooklib \ No newline at end of file diff --git a/rss_reader/converter.py b/rss_reader/converter.py index 85b89fa..747be88 100644 --- a/rss_reader/converter.py +++ b/rss_reader/converter.py @@ -3,19 +3,17 @@ """ Convert RSS feed to HTML/PDF """ - +import copy import logging import shutil import random from pathlib import Path import requests -from lxml import etree from xhtml2pdf import pisa from jinja2 import Template from bs4 import BeautifulSoup from ebooklib import epub -from ebooklib.utils import parse_html_string from rss_reader.exceptions import RSSFeedException @@ -29,21 +27,21 @@ class Converter: out_dir (str): Directory where output will be saved """ - def __init__(self, title, entries, out_dir, image_dir="images", temp_image_dir="_temp_images"): + def __init__(self, title, entries, out_dir="out", image_dir="images", temp_image_dir="_temp_images"): self.title = title self.entries = entries - self.out_dir = Path(out_dir) + self.out_dir = out_dir - self.image_dir = Path(image_dir) - self.temp_image_dir = Path(temp_image_dir) + self.image_dir = image_dir + self.temp_image_dir = temp_image_dir self.font_path = Path(__file__).resolve().parent / 'fonts/Roboto-Regular.ttf' def _create_directories(self, image_dir): """ Create directories if not exist (self.out_dir and self.out_dir/image_dir) """ - if not self.out_dir.is_dir(): - logging.info("Creating directory /%s", self.out_dir) - self.out_dir.mkdir(parents=True, exist_ok=True) + if not Path(self.out_dir).is_dir(): + logging.info("Creating directory /%s", Path(self.out_dir)) + Path(self.out_dir).mkdir(parents=True, exist_ok=True) if not image_dir.is_dir(): logging.info("Creating directory /%s", image_dir) @@ -57,7 +55,7 @@ def _download_image(self, url, image_dir): """ logging.info("Starting image download") - image_dir = self.out_dir / image_dir + image_dir = Path(self.out_dir) / image_dir try: self._create_directories(image_dir) @@ -73,23 +71,34 @@ def _download_image(self, url, image_dir): return filename def _replace_urls_to_local_path(self, entry): - """ Replace img URLs in entry to local file path + """ Replace img URLs in entry.summary to local file path Args: entry (dict): News dict """ soup = BeautifulSoup(entry.summary, "html.parser") - images = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] - for image in images: - filename = self._download_image(image, self.image_dir) - downloaded_img_local_path = Path(self.image_dir / filename) - entry.summary = entry.summary.replace(image, str(downloaded_img_local_path)) + + for img in soup.findAll('img'): + # use placeholder + if not img['src']: + # copy placeholder to self.out_dir/self.image_dir + filename = Path(__file__).resolve().parent / 'placeholder/placeholder.jpg' + shutil.copyfile(filename, Path(self.out_dir) / self.image_dir / 'placeholder.jpg') + img['src'] = str(Path(self.image_dir) / 'placeholder.jpg') + entry.summary = str(soup) + return entry + + filename = self._download_image(img['src'], self.image_dir) + downloaded_img_local_path = Path(self.image_dir) / filename + + img['src'] = str(downloaded_img_local_path) + entry.summary = str(soup) return entry def _replace_urls_to_absolute_path(self, entry): - """ Replace img URLs in entry to local absolute file path + """ Replace img URLs in entry.summary to local absolute file path Special for xhtml2pdf (xhtml2pdf support only absolute file path) @@ -97,24 +106,22 @@ def _replace_urls_to_absolute_path(self, entry): entry (dict): News dict """ soup = BeautifulSoup(entry.summary, "html.parser") - images = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] - for image in images: - filename = self._download_image(image, self.temp_image_dir) - downloaded_img_absolute_path = Path(self.out_dir / self.temp_image_dir / filename).absolute() - entry.summary = entry.summary.replace(image, str(downloaded_img_absolute_path)) - return entry + for img in soup.findAll('img'): + # use placeholder + if not img['src']: + filename = Path(__file__).resolve().parent / 'placeholder/placeholder.jpg' + img['src'] = str(filename.absolute()) + entry.summary = str(soup) + return entry - def _get_entry_html(self, entry): - template = """
-

{{entry.title}}

-

{{entry.published}}

-

{{entry.link}}

-
{{entry.summary}}
-
""" - temp_entry = self._replace_urls_to_local_path(entry) - html = Template(template).render(title=self.title, entry=temp_entry) - return html + filename = self._download_image(img['src'], self.temp_image_dir) + downloaded_img_absolute_path = (Path(self.out_dir) / self.temp_image_dir / filename).absolute() + + img['src'] = str(downloaded_img_absolute_path) + entry.summary = str(soup) + + return entry def _generate_html(self, is_cyrillic_font=False, is_absolute_path=False): """ Generate HTML @@ -145,7 +152,7 @@ def _generate_html(self, is_cyrillic_font=False, is_absolute_path=False): font-size: 15px; {% else %} margin: 20px; - font-size: 20px; + font-size: 18px; {% endif %} } @@ -163,12 +170,13 @@ def _generate_html(self, is_cyrillic_font=False, is_absolute_path=False): ''' # replacing image url to downloaded image path + temp_entries = copy.deepcopy(self.entries) if is_absolute_path: - self.entries = [self._replace_urls_to_absolute_path(entry) for entry in self.entries] + entries = [self._replace_urls_to_absolute_path(entry) for entry in temp_entries] else: - self.entries = [self._replace_urls_to_local_path(entry) for entry in self.entries] + entries = [self._replace_urls_to_local_path(entry) for entry in temp_entries] - html = Template(template).render(title=self.title, entries=self.entries, + html = Template(template).render(title=self.title, entries=entries, is_cyrillic_font=is_cyrillic_font, font_path=self.font_path) return html @@ -187,7 +195,7 @@ def entries_to_pdf(self): pdf = pisa.CreatePDF(html, dest=file, encoding='UTF-8') # Delete temp folder (self.out_dir/self.temp_image_dir) - temp_img_dir = Path(self.out_dir / self.temp_image_dir) + temp_img_dir = Path(self.out_dir) / self.temp_image_dir logging.info("Cleaning up %s", temp_img_dir) shutil.rmtree(temp_img_dir) @@ -199,21 +207,25 @@ def entries_to_epub(self): html = self._generate_html() def add_images_to_book(): - html_tree = parse_html_string(chapter.content) + soup = BeautifulSoup(chapter.content, "html.parser") + image_urls = [img['src'] for img in soup.findAll('img') if img.has_attr('src')] - for img_elem in html_tree.iterfind('.//img'): - href = img_elem.attrib['src'] - img_local_filename = self.out_dir / href + added_images = [] + for image_url in image_urls: + # Images can repeat, check + if image_url in added_images: + continue + + added_images.append(image_url) + img_local_filename = Path(self.out_dir) / image_url with open(img_local_filename, 'br') as file_object: epimg = epub.EpubImage() - epimg.file_name = href + epimg.file_name = image_url epimg.set_content(file_object.read()) book.add_item(epimg) - chapter.content = etree.tostring(html_tree, pretty_print=True, encoding='utf-8') - book = epub.EpubBook() # set metadata diff --git a/rss_reader/placeholder/placeholder.jpg b/rss_reader/placeholder/placeholder.jpg new file mode 100644 index 0000000000000000000000000000000000000000..05a274f8553d1ca96614f3831883240d0127e65f GIT binary patch literal 17441 zcmch4YLygw3!H}?Y@43xf*Y$b7Kfiyz-`n@~Pi}f0p3legalAk74>$jGv{zZM za)E+^g3A7Vdz=*%egi-LrZ9gV_-72&Bv4S;{cHc8U58OWhP#v>uJwvwjpo&jjd>kg zHeNQgbSq-9iSHKQ)2ElLn?>KFaTon_fpUq*vOSE4y$?UVzpt$kyLRoG4Rw26yXP?W zA6~QR+N{`=PY;*(IO+MmH!S(9Iylt37*m|t01dxfd8^Q`I47qhYj9Z5!oAaVyP1$O z+3L<>y&NcBvf!ZXDoV|bys7~YPkv@D)(P!o^w6c3S8LGJLaaT6%x9ymq!;M|CZKk1 zj(SW6&Z@VnLUz@aF*7~hYZbChVz#qoS0XjY#gMeTfL&xGUOxTky0Ng83UibRcid1J zINNz@(hI3n`t`C#!?`+#jox(L1MEUw=|%mHsddU^t@l8gd(+>V_-y?tTT$#MW%#NK zD%zb<$~Z*8qh%HyRWKJNI~YoKIrO;2)w8S==6m1O4S97C_xbJ^5_M-@tnG4q5~-Le;}v>Mn=2%Dcaz%E+eSz3Pr>AhKP&3!*f^4T5qyq=}W*JGU#c^t%qOs|x zW#U}H%sy}P^KF+&wTjIn+hy+wmdjpqWGk~9ocz%c?v9VKSC|(}JbhFq1Vjkw$7%FP z<x$geX`+GGOp4$D?lSfV8r zHO0fIS>ej`Vv>j5mMBVpZG^?|y(C)(hru&Zcr^WJkGgn3^~18yAEi>Li_OyZ5`>gT zyKb1?{V1Ik1-;WSB;|gwe?2OFLA^58Y@q#w_ZGP%dMe1zF#$2j`trq%BDVc}v!2%p zGo$a@wfXW5xo8*(zv(T+@`hmW(o1K`X|Zm6!@tGvhY=4nD>J|06n1V?Cd%I}lXCN8 z=-sR<;j6X?2j$z`2|vVDC#tJJ(X)QP_>25#Gs)lkR&0#q@`+;lS#tf+y1KKzYTDTI zrw@kJysR$B%$q{f6UsJnXZ%6q=$uHA-S2Y2Pa;|!CMxt4B4O|)a^c=UAAO9wDHmw} zGhKy0r!^p`^zdv2`8VJBIB82yizGzPh%>3Rapo*y?fE&D3hwgrFvABv=&Q@V_0oICF8LNaf^QITb1-F5iJ3N`=YLsSg`&;)XLz12R-JDI^=(NKz z;*NO=oU8BIM3?Dp_E?#Iu_xJ?2sP&^e0&UDd&hp4EI(;=JaM?bkby?c`Q4MRO~=XP zPjYoPq;x&B#^TsA?_vDI#j#3*&(oTvE|#fmALfKnml{riA7SP6ms~&k+@fvP%eRo3 z6sGXIT;+JqDhhIyZ2w!6nKSC`iGa_Xg_$m$IHGVtejc4D;AD{c9;j&fcVJ>iKGO1I zdEJk2xl~B(&L=JZM1p~~JF(-N6yr>{oKn64NP3`{fUjT<`3Bx!-!C>d;Q-Kb6zv24aVP_F;a7r}b2o3$pVeJM_#l z8wa`IIJH0UAmGwi5I9Gp94QojWmq+AC*RGcH?Q^(3n=lIwk0EAP9A zJ^u==N+Vh>+*AL@j=H8a-7>Vy1A)*LAPtj(5uE^?{cnk;2v>3?S|X z^n+TLyW1ly>E#AdSV3HiFnp>?a9${}QS^9|`drS2V7}3vPeaouxWiHMupz3~2}x(! zS(nt+uNxQ2qEtC;<{Zmy)3a3JlVz03yTBiq%`w_}>7Lbrk!kyR4eY0HV@M!8eYjS3 zgEsMaddhm-VvN5%=QHPTGaBl}4+-59Kg;e~2)8)K*^unoGA?Y+(QL@T&XsyEe%5$f zo5Y{n4KYk`|HFl8h-HRCN2axI^7@bS*5~%J$&|{QV3qcK+*j_~^vQQtz$yJwCg=$p zF$3d?7t$o6h{DeMJ4R!%h!l)_!D~rWQ5Vl1lg5zJ;wWgo)s7HJ1SrRTpdK_cl3Uy% zsU50|Z=hdp%4#3XYLhQ1FDdMc(*>#B;@D=b1siur^~j#jq!esa{ck@T;V#_TEqz^b zFB%bVHDHWu(z$YC=68qSuotxs=~f3AZXX-SQkTcpmj=81?#VK^?lU#4zOXR$B%M*- zNZ$0QP49y6i#OMg!+FN0#lWn3>zCrg*t>u3_us?iQlnUdYTL_!Y7J82%UGex+dPfc zP+NED@|KAMKDmV}EUNc7O|cEah&_K4vhbn#HsB2Mhx|L=^EQ8{9a=0-R)S8SG_+dx zb$BaxZ7fg;yYC$1p^9_4Y7!N~>>h-!DdxSlxsUn$3r|h~`xsW$n=8uxV~OQs{g`jJ zq*9{(DIovx*246QVBgSNof^%$e^`DzX28;e#Qp0>TH6t>eXw`qx4OT;%ak>Ty z`rdTYmDt3UWaBEq2Vf+A{H)}_VjGE;+_4c-Vx<{{iMZZ0lEAwf6O$B-W5bH1n(_-S z4!*8-EJfYY__Jf5PTA!fl_0n!dE&&#-szZk?8t34thc)L{8zq2|9(N+yZ_EGrnuJ} z4xf`6(ik>_BUcjJSc#j)i7Da8%+$h*?jLOM8}_`G8Qd6lMny~_C5j}l4JvKQdf{?m zr8F-T5sL7KX6~Kj0$u2lgx3tHj^N{>J$;j>xFzP$CE1tP^RW2Vvk_xOHvd$U?@;#n zFu|Wk5Cgfm!}z5`!YN6@cTU)TP6*t~Wjl%Ot6&*(MXLt&PVg<22Up7?CFW@?E%lKx zPzfN%!KppDIK14In}~B`!`&qLdHckCs6HC&G|{PQ4FAq=L=UhZ2&Dqzh^+*V$FLt~ zw;qGll%D={{t!q!L(1dItv-)<^$sqTx)Bn%Bic9EH-#71=4W}|C^3urejA-tX&9b( zT7RPHRM`Kc1JAA)lbiv`3Nwb)n+W&!Dcek`cyT-mT4i76cwM3jG4@c>EaJOk_>yfH zu`ZoP(DZon$MktaxK_OJi;)+h?b;IztJZzNpLv$ z)}iV57)zYO^t~W~mmljG_Fsoj6TI4}D!q&HR7~Nf1k;~w@K#h zkfEI;Xy>}%^p<#wC=Gno>mX)>eXn0SvW5&DYN>O@hjT`aU{?HI6Z(~y!uKGgbowQ1 z|7kj=4;6n@H2m^cdh%&~?%|NHeEY$K1B{=ymgXS5+{XQMs?7Hfm@QSAK&)4aH2>oy zbU%D3sds+;=ei`~^SUcPRo&ob>6j6?FkO4~!&4F6bnC7RNULbN$T`})MyP1c30`R; z{)SpCa5uZq#)*9mF@YRw|X!vndGsS zOWl0^%Als!{8wN(-=NK}rxl(zAG?ygpIXGkTfRP1b(a?ZmFylFvj5Bash6H9yi+i- zZD9?wOz-*?TXOd`<;*q^G=4#LzrI}Nmd7iIrk$bQXoJnv5VLNA3rj4d=o}UaPQbPk zijQzvErYz)n%}$AFe8&xoeRLXoUmEs5fR<(?W}@U_onmbqD!=g!Z*Y zg_3|25xBH`ZHJ<+uE?{+%FN$>Ke#!A-Sn7OR8&;T8Gn0w4y(GtcA$o7vjbzaRobe@ui8VvA1tM0 z?lg(iurja1W<)`n&m4`mZWQWK?Iw#H(hENbK4(C{S6`F(YHW+O(idLf4Jh3?%V+sY zF@5lWIg-u|Y%ULSBZFOmn1O)p`22X(r#-$-c=rLDd}!cuS`8gGIpM_N7I7Oak;;Rb zII&UJcpv&ui#=g*H!!Zuol)4MrClYVCC-JonhwIs7&h*ppB1I#$lVLtA0>-yoieeG zcwQ^mH|$0)zz#(z6aO5#U=mq0Vr>S0))G$fYaH0q*8L4y z$G#SMj~BN_s2oEOU{L>Z5$NXx4<`0WRBuP+Hlbc4vX+M4a_Tye$B#1+V<1~-3M#4h zcrxna-)kx6mHc{gJF(gKty(OaA5-b&lOEhMJT=}GWOVAfnIqyNY+<>rCEau3o#?DN zKO}|jXco7LzR|9%zce|yKeUcka;me!mVeLcf~eqBkPN$wOWT2= ziC}bERx59ylx#Tp29Xi}X9A@k~=USVW#$u;R(2pNqtL<3|^ZUsHYGnSnFq z|8MD3zR$T>7g6t@6WhzlUGY599Uww6^4surUYw0EJ(_n9Wa58JxkPUmuU<(u+vdoN zS1H=ZuL|2MeQloZU`C-FmWZ|W)1!^P@XTVa@7*b^`0=`wzG6^aoUAMdgk*Ly`FK>H z{nSxq#hgXr6#b*=!zz@E$a5X`uX;WR_KobtZFsjH?7)(~?-c&@#C8tvRupzSd`EB9 zeCQZU9VpJW(RpPNhjUySb8(*#9dY5W|A8M_7GP325GT|mz+ioS?EB=6A>XCSug@JRVJQxjjDX#~gT|3y)pNI` zuNO{D_}*p|=@c+&YlTx>ixVA=J=kJqao&#((Iz_droRL;J~X+lLklQ8e}cX-t4B!~ z7HnNks3bSxSrpoSzOBDNTdwqGjbD`Ezl}|yo-F#DIXb~GT^UO`x~Vx-L%i!!1a$PB z8p!H7=h_fCwdcnLAgo~jr0!PJ;b%6{X-ZJb*mEHSeUL^I(T(Av^OjQOe_9yC9Br+e z((JVoHv0q=kN@yvwoxl9EJb6Dx9bu&Pn|b6`ZZ^gSlUA3uE*MUR5KT*ID z*LVjxrcYdggd}XC6-#Uji{w9MRS#;6Vc(EXp|^lF`g}}HOCyyX%_(*F-J0CWc8#C& zsrdrZ8`DvUq;1eI6u--@b9t&GOrvII46h0&{( zh5x30q9!M=*w$z$*}z!c{Lt!UtZP;pj$49^S>{EdEO`-aJ;7f1YXIumSH#8lx^J(+(Oj{;(ho>)Fm%)HVffv7!qeG@#hk>= zRfObd%i=BH#_cd8o8_1^Gwy91i{Hw+@37^3EiLSZu&L5qczxD~6)^q>MP0qzf_|M# zpW^Bmfh6+%3K2nF(C&uB22P%IoA(6 zCWicWq#|{g?4*7;Bh$7}qosXp2S{!Q7oEw9o% z)Au6(dyjCG`yFjg6{qI7i8RN^m(iiL|Yq3mLT+SoMpi zAWX`zo+WjB*eCBnsW_Ekc{$juHsQ=-@wZ8;@WX7iaN*C}Mq5&K#pg5A0%W;{{$D?& zU8>jyJ!!6C;5yioU$-SSfjK8_6I9m4WbqsuHlG;gzJwG#HwJKRB3qBaMclNK{d;fUSL z7ecQVy4NxWe0rZ&HAg{hVYU4zRRZD!j-54{+Fi4%TXph4_Z+3RLE|5j)Gg!=?ph)8 z>{Rs)eV=k-oWoBW1Ef&`&%OQ_#w`%;i0WMB>5y%|5M~dVB_djLCdwBS<|(fQkimMi ztH-6!dK1(s(J(Jebhya#Lt(+!$yy&)5`lJqR_*@9;)z!gXDj0GVG?pHkk#LcTp8UT zol8+#y-ddy%l)4LL`afYb!W8!WCo?E+6mzlRfjOyR=PZ^Y^Ivsx5;^)_N2Wd2>!vM z5}JK8?eqAyFnC@>=(y3*cybSocCWdC~daQTP$5})iHqk5nNcQ1(wFt_Wv>D z%30Ks$uk_)@REsg9z@q1ZnswBy4=MT^ZjZoso9tN{>o*bgb_J$I0y#dxh+&Jf&l9Bc$?4;?4r<@nm~|+&nV}6=BsG3e@SdpP%C(ysMw9 zgcXp>WNj+q7ErHMRcL+2Lk8=F&`nUv^+WUef=g4*(BEB=c3~Raw1RjKmxzbMOAVQNGea$=-wTr8S0N+8y6X+e(cKBghQfUKD4e4}#vwB!}v z!v-266;-wQT1sLUCD(F-7-@ockOJ2Z{WUz%;8PLy0JZGO#dTl>cFZD>jp1Sn4^!KH z2qG5_=lpq=mh7oF(}<_T8-Omn;ThGrmlN1oP~C@Vz!Rxd4jnuX5fWL#B=x)~ESXFI zrb7LvsodzV@rpzyCE4~jQI!UVP@J9IgA-Wa6uiyhgd~d&_f3=qA>s%#(Ehp{0Pyd? z(l>kCx|6%5Zh*t6BN`e8x_g#eq*8~5atBz1YG1yA(J#ez>i1}}#`1LdZ+EQVZJBLu z=Y`Zcr>-Xj!vhB0;~WJUobl$U95;NfnPrK{Gqq~G#hqWG?lT&sa4p&4a)6uGBOyr&My8Y%w?bx?OIX_1D| zoOH;jwoa4bV@>yc=ei}yCtq7!8>F2yZ1(RYrJ!C0x}w}Ty-sCWVfYDibV=GpQ(C;u z`bH_PI<6JULr8E{ODV4U&CNS!RFM|(+J?QIUSlfmU@%xu{S%d1!^3XB)s$i!ruMtB z9HLJ6@7QHoDcO!b$qgT{ZY>?nw>tU$wtGNBcaJ_{ROB`h%pM6CJ_uFnDIwz-qnndIf zGGZlmWemnA>Fm7N9nCrZ-4;eY;k3ovg|^Ff6C^f68~!p&`O=pp$uY4h>FL?u#Pe9d zA-=RQHs&gwPtsp}Bg$4pBD^6iiu%GjXa1JWayFcF#?)u>g;XAvT`?9$&Ipdo1cS{VHWR(PL zI_bNm;8#LUPP1OaJu**)Q!vS<`KvsMTvue-R>wFZBEj!23g3%BUNBjSEeK^%tIlIf zF@gDnm1ncHgrs|0*8)SYqy&L31$HBeH)w|MF4jQaFBN(wVKrkqi`+iy4jNUf-ylEu z_(6YN^|(jS7gOkWjALIqRy>)*$Vu#A3squ3vfeYSAHI0DT`h&e|Y#V>r=0=!|>q)n=98D^3mY7gv&J}x>UF1!kB z|9U67V}1p6K7QeEcs*MdAvLu_|N2$i6QyCp%YJYhONT)nY^WXiTn&`}KJTFTcx+9^ zOoIj56_!7NN+Xf%#Z+4LMV}&%vc-H+U{ToU4Hfr*hsow+t~eV(PXroC*?z9W5XUWb zp$4p5$Q`WdsWWHK1cL?Q!{R;>`T`|z&)FPp9Va?Dd24;4e#h&-JylJ{Wjbq)9h0cAhNAIq zW5`i}GsV3nyEZJNtxvBEUH3cH_fNjj!MwrR*c-XZz285MD$&Dkf z`#9=7i77MRLMMtsgXeUAKmKxDr%1hl2zvRe>4X;|mEj3x-S>97>m~r^2SD%PtwpWNo8rqZ+D5yNhP9 zi|_v3Jbbqp@7dtygT629q8(0B1^CX=%tEFFly|sTPO~dt1SixoI0q9eJkf!za0zJv zly_{HG_UAeNta^8n|U-$U$Rf)`t|F#xLV%*M%MR9_i{R6@!q#NrL`tv4mGg*Lz&UC zdQu^@-n@OZfxV+XYBhc&8@aVWTT-#O6l zetTW*7yOKqakCO6L6>b+V?D>0d9MBlnWCq{EgXjbOzt5?2Ih|uoHIO4A6wTwOYu8< z;zh@lE*wSQycha|?^ByINQYVfb~0FNsX^Yk*P(U*m}hr;jJ_+Hf#HQRQrY>1nsLA8LuGw4BV;ppe>9g zeZH&k=TO>?$lzGAP`7a3xzcM5ffw;jK`%X!*b=J%{uhTXv2G;)R4|ih3K$gS1gn*N zKrO`C_qbwi1;#>OqM(abyo4)h+We|O%9TNbglvxUTe1O>dyVtNRZP5@+8k1@WFZ- z`HZRl(CWGni@J4Oyn+2}@sYg6sDAJXwTNH4)#`4h>({WxR)LR*)r@$ukEjJ44^m2J z+{ffJy+nDjgZseUgLx$*Hg0_zIVC!$Bf4W9ASU_{=lL|0mL`l1_ac6MT%aAR5BdLr zurUOU!s9Q-Lm?3)iY+RtY(NhuosH=J?2gEsk~OvO+h@VS%CR zy7f$~pAU_)n>I%=GItUFOgg<~FNO-(E#$;;Zegw(X$##~tBHKizBHPw#T(jUoYcmp zfZ7L?vG94bvZ|jwB$)TyG1D$uGVC@!(r~{O0<;sYf7L>3g%V1{;Y2O6#nl@Pn_Z}N z>(}2Rc$Nu&WyEyhk;VNPiVW?0w8Q#yL5g@oJ8$=t2+(BW>}#ewTQc&L5$J0@WW)w} zdlw(@Nj7Ii+iSlbYGKs5=4yvv5)EdT8R-0)1nb>f9{g1#`T$WSlnSc@lGG0{70>@i zn~S7T2fk1CsAZ5FZRhBBOx~dQsaHFp1N48tRRmBtM?PpV7`iQrh^BZO z?;$}F@LZkE+S85;kRR>%KGM8UU3_M!#v&3=BA;o^J7KR~4;)m}GO; z8O=O4PIuO;;@Bk{LVyIb;uw{l*@v_GJM&pJSnSO-;-!Mff=9awnV>CPSXb8tKI$}{ z6$O2jkiP<|h9=FFATh}#ravivG|L->c4{!k3bN=l0JLl@EedLM3RFBgvSu3QX!;3K!adL@-G;$GXn{8nio- z<_EqO1)(sKR&}FW3bO_wcn8>uc-!3ZpF8gsn|;^?utGQ=ht!p{;^02#Gxe`uAt&zo zpd9lJVZ;BZCf*nD$5;TkiSSvx*ZO;!)c`W$xurtKk{CAlVv9lTs_x2DU0e8n>D_&Q zFcK%q#f76m5m!H9Btg!5oqQ<^2%cLpS&GJpUCbJc$Arn*?q^xt$!{RvnU#7W6q+nmB1<6VGr=tzWfjPg!~}Z)A5m;9did2Px=w)_GrOi z{M31)R5RGb!SfWP`rzf&`n}c?%Q35%w(iAZU_W)+Mtf=G^%Nz3GNQxb_0IFi&i9Po zm+pj1Im#gdp!+dv#_)&>M_GERSig!yTdfe&BGRz;g{-j9hR-gp7}FJYzO z(ZOY?DrZ~3BPEp{O=ukAxqw?K^I3-{N5ol{Z!lc||uLbU)RprR<0SIkH zP~#4%#V6BiRtpPj>X7}zUR}j1%n1+>o-5kZgr{HcG`OBOWp zoZo*PxJiifRTR| zxxwDz>2)uFng5Tu2@~-&<_$L9%7z>NcZP+Z|6h1 z?uMt&lhave|2?@vwl#XcCwiI!Yqm)?z>?#@mw?Fd_BbDNBUTkK^ZjbvEDL=0LhJW{BIo@AKf2ALye$8DJ=^?3Q)+_L@-JkwVx zan)4Fi&)cQ50{Tj0QR3Afzu9l69)N2c z3x;nt0x-Wi>O$vitz?OeRO;M^{Gv>*$AcsuOhvj95j%0>rPA3ZI^l=b0V!{}LJwDr zN_fad+QGz3JDWwC;$YwgBusTZVJ!FfvpXmiQ4KkOJxkFbPZxTN*yCLaI+fB0FyN{RBMm{OQh%ZF}_MQ-oC$r=5|bydA9Urw8;ic+}P8&nq)3&Xi`HuBo{>< zrF%A67tX+A_hHfj6Vhtbzg>-t&ZR%m$1Gi=WeG+|sTWCq7kvL?Xd<6$H2bjZJ*QCL zKi;?hkAaDD+qW_ynw>VvLLa`V1bL6qu>rlaFVQ{qg_wz_bgF^9LfOfVyUeQegHEF*?Z5;(0RHY-f2(i`epR%D~1}ly?Gv?;{?-@-5YEVX& zmyeAB+8&!0axFUP?WBCb3oSQm`S@D6JkgLpL^ZHWYk7*F>RGS7@p*KEe0(&6qW}n{lFC*N2bLXZHL0W|C~BDeL^R1RhX|T z(tHzL_z!7M&oAgH2+EoA>9wBH|Cl!HA^fE-o^tZq5W^n6NdNM8rX%IddAM&Wnm3QPUFpqoBkS>h^)y&~6p=`1~% zz!yq~4P+;@kemAlOhbN%jSdpTQ?~EEu_rS*(l52f>W=_l!u1Yue#2>D2jeEJk7Pc`KaP25t?GRdX@pxb7viV9T}r0zUxdYhGK-xM4w_8i}6p~AJ@ z&|H7Izp;G$ThGd~BNI)V` z9F!m8K`Bx^t#X4!5mDsG_^Xjf%{x-h5mUGapAZOU6;J;Cn<#w3Y5FDH{5;;|qu*q% zpR`WmRxYFVT(o99yDmF6l*3STW^+aP?QP0P@W&yh|N6t`(yY7d`A5r>dvw)1htAOD z_p;2d(k+#IS~N#^MHi=E`VIAx5?ZqGtE&A1I`-aNO!SzB?9Vk&f0cg5DSWbCvvWB4 i#`GJKIYLNHai8(hH^#ns;7@-j?BDCSC-0Aw*Zx1bJ)Cs_ literal 0 HcmV?d00001 diff --git a/rss_reader/rss_reader.py b/rss_reader/rss_reader.py index bbffa2a..12f01c3 100644 --- a/rss_reader/rss_reader.py +++ b/rss_reader/rss_reader.py @@ -37,6 +37,7 @@ class RSSFeed: title (str): Title of RSS feed entries (list): List of pretty RSS news raw_entries (list): List of raw RSS news + cache_dir (str): Main directory of cache files """ def __init__(self, source, cache_dir="cache"): @@ -90,7 +91,7 @@ def _get_entries_in_json(self, entries): def _get_pretty_entries(self): """ Prettify entries - Remove HTML code from summary, parse date, photos, links + Remove HTML code from summary; parse date, photos, links """ pretty_entries = [] for entry in self.raw_entries: @@ -160,26 +161,23 @@ def print_rss(self, limit=None, is_json=False, colorize=False): f"Photos: {', '.join(entry['photos'])}\n" f"Links: {', '.join(entry['links'])}\n\n") - def convert_to_html(self, out_dir, limit): - """ Create html file with rss news in out_dir """ - logging.info("Converting RSS to HTML") - converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) - converter.entries_to_html() - logging.info("Done.") - - def convert_to_pdf(self, out_dir, limit): - """ Create pdf file with rss news in out_dir """ - logging.info("Converting RSS to PDF") - converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) - converter.entries_to_pdf() - logging.info("Done.") - - def convert_to_epub(self, out_dir, limit): - """ Create epub file with rss news in out_dir """ - logging.info("Converting RSS to EPUB") - converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit]), out_dir=out_dir) - converter.entries_to_epub() - logging.info("Done.") + def convert_to(self, to_html, to_pdf, to_epub, limit): + """ Сonvert rss_feed to the appropriate format (HTML/PDF/EPUB) """ + converter = Converter(title=self.title, entries=copy.deepcopy(self.raw_entries[:limit])) + if to_html: + logging.info("Converting RSS to HTML") + converter.out_dir = to_html + converter.entries_to_html() + if to_pdf: + logging.info("Converting RSS to PDF") + converter.out_dir = to_pdf + converter.entries_to_pdf() + if to_epub: + logging.info("Converting RSS to EPUB") + converter.out_dir = to_epub + converter.entries_to_epub() + + logging.info("Convertation successful.") def get_args(): @@ -228,12 +226,7 @@ def main(): feed = RSSFeed(source=args.source) feed.get_rss(date=args.date) feed.print_rss(limit=args.limit, is_json=args.json, colorize=args.colorize) - if args.to_html: - feed.convert_to_html(out_dir=args.to_html, limit=args.limit) - if args.to_pdf: - feed.convert_to_pdf(out_dir=args.to_pdf, limit=args.limit) - if args.to_epub: - feed.convert_to_epub(out_dir=args.to_epub, limit=args.limit) + feed.convert_to(to_html=args.to_html, to_pdf=args.to_pdf, to_epub=args.to_epub, limit=args.limit) except RSSFeedException as ex: print(f"{ex.message}") sys.exit(0) diff --git a/setup.py b/setup.py index 0cabd0a..5c60bcb 100644 --- a/setup.py +++ b/setup.py @@ -16,9 +16,9 @@ author_email='dison.ds@gmail.com', keywords='simple rss reader', packages=find_packages(), - package_data={'rss_reader': ['fonts/*.ttf']}, + package_data={'rss_reader': ['fonts/*.ttf', 'placeholder/*.jpg']}, python_requires='>=3.8', - install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama', 'jinja2', 'ebooklib', 'lxml', + install_requires=['feedparser>=6.0.0b1', 'requests', 'bs4', 'colorama', 'jinja2', 'ebooklib', 'xhtml2pdf @ git+https://github.com/xhtml2pdf/xhtml2pdf/'], entry_points={ 'console_scripts': [ From 8da41ee737030eef28a5a0794e724207bbd334bc Mon Sep 17 00:00:00 2001 From: DiSonDS Date: Sun, 1 Dec 2019 23:26:21 +0300 Subject: [PATCH 28/28] feat: unittest (test/test_converter) --- test/__init__.py | 0 test/test_converter.py | 66 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 test/__init__.py create mode 100644 test/test_converter.py diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_converter.py b/test/test_converter.py new file mode 100644 index 0000000..038c189 --- /dev/null +++ b/test/test_converter.py @@ -0,0 +1,66 @@ +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock + +from rss_reader.converter import Converter + + +class TestConverter(TestCase): + + def setUp(self): + title = "test" + entries = MagicMock() + out_dir = "_test" + self.converter = Converter(title, entries, out_dir) + + # def test__create_directories(self): + # image_dir = "test" + # self.converter._create_directories(image_dir) + # self.fail() + + # def test__download_image(self): + # self.fail() + + def test__replace_urls_to_local_path(self): + self.converter._download_image = MagicMock(return_value="test.jpg") + mock_entry = MagicMock + mock_entry.summary = 'Фото: Дарья Бурякина' \ + 'Первый этап Кубка мира по биатлону продолжится женской спринтерской гонкой на 7,5 км.' \ + '
' + mock_entry_replaced = MagicMock() + mock_entry_replaced.summary = f'Фото: Дарья БурякинаПервый этап Кубка мира по биатлону продолжится женской спринтерской гонкой' \ + f' на 7,5 км.
' + entry = self.converter._replace_urls_to_local_path(mock_entry) + self.assertEqual(mock_entry_replaced.summary, entry.summary) + + def test__replace_urls_to_absolute_path(self): + self.converter._download_image = MagicMock(return_value="test.jpg") + mock_entry = MagicMock + mock_entry.summary = 'Фото: Дарья Бурякина' \ + 'Первый этап Кубка мира по биатлону продолжится женской спринтерской гонкой на 7,5 км.' \ + '
' + mock_entry_replaced = MagicMock() + image_path = (Path(self.converter.out_dir) / self.converter.temp_image_dir / 'test.jpg').absolute() + mock_entry_replaced.summary = f'Фото: Дарья БурякинаПервый этап Кубка мира по биатлону продолжится женской спринтерской гонкой' \ + f' на 7,5 км.
' + entry = self.converter._replace_urls_to_absolute_path(mock_entry) + self.assertEqual(mock_entry_replaced.summary, entry.summary) + + def test__generate_html(self): + self.fail() + + # def test_entries_to_html(self): + # self.fail() + # + # def test_entries_to_pdf(self): + # self.fail() + # + # def test_entries_to_epub(self): + # self.fail()
+

{{entry.title}}

+

{{entry.published}}

+

{{entry.link}}

+
{{entry.summary}}
+