diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..64baab8a --- /dev/null +++ b/.coveragerc @@ -0,0 +1,45 @@ +# .coveragerc to control coverage.py + +[run] + +data_file = .coverage +branch = True + +source = + nichtparasoup + +omit = + # Don't complain if non-runnable code isn't run: + */__main__.py + + +[paths] + +source = + ./nichtparasoup + .tox/*/lib/python*/site-packages/nichtparasoup + .tox/pypy/site-packages/nichtparasoup + + +[report] + +# TODO set proper value +fail_under = 0 + +exclude_lines = + # Have to re-enable the standard pragma + \#\s*pragma: no cover + + # Don't complain if non-runnable code isn't run: + ^if __name__ == ['"]__main__['"]:$ + ^\s*if False: + + +[html] + +directory = htmlcov + + +[xml] + +output = coverage.xml diff --git a/.editorconfig b/.editorconfig index abe2f6ad..c2f0a632 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,28 +3,44 @@ root = true [*] end_of_line = LF +[.editorconfig] +trim_trailing_whitespace = true +insert_final_newline = true + [*.py] indent_style = space indent_size = 4 -tab_width = 4 trim_trailing_whitespace = true insert_final_newline = true +[*.html] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + + [*.css] indent_style = space -indent_size = 2 -tab_width = 2 +indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true [*.js] indent_style = space -indent_size = 2 -tab_width = 2 +indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true [*.ini] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{yml, yaml}] +indent_style = space +indent_size = 2 trim_trailing_whitespace = true insert_final_newline = true @@ -32,3 +48,16 @@ insert_final_newline = true indent_style = space indent_size = 3 trim_trailing_whitespace = false +insert_final_newline = true + +[*.{rst, txt}] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +[{*.cfg, .pypirc, .coveragerc, .flake8}] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..6b244ce2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,16 @@ +[flake8] + +show-source = True + +# ignore self markers of https://github.com/python-discord/flake8-annotations +ignore = TYP101,TYP102 + +exclude = .git,__pycache__,_OLD,_TEST,build,dist,.tox,*_cache,.eggs + setup.py + docs + +max-complexity = 5 + +# allow up to 119 characters as this is the width of GitHub code review +max-line-length = 119 + diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml new file mode 100644 index 00000000..8c60fed0 --- /dev/null +++ b/.github/workflows/pythonapp.yml @@ -0,0 +1,52 @@ +name: "Test & Build" + +on: + push: + pull_request: + release: + types: [created] + +jobs: + test: + name: Testing + strategy: + matrix: + os: ['ubuntu-latest'] + pyhon: ['3.5', '3.6', '3.7', '3.8'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.pyhon_version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.pyhon }} + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade setuptools tox + - name: Test with tox + run: | + python3 -m tox + publish: + name: Publishing + if: github.event_name == 'release' && github.event.action == 'created' + needs: [test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade setuptools setuptools_scm wheel twine + - name: Build and publish + env: + TWINE_USERNAME: "__token__" + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* + diff --git a/.gitignore b/.gitignore index abbacd5a..89574ab4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,43 @@ - -## ignore our own log files -*.log -logs/* - -## ignore our own custom config -./config.ini +_OLD/ +_TEST/ - -## ignore python runtime files - based on https://github.com/github/gitignore/blob/master/Python.gitignore +## https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] +*$py.class # C extensions *.so # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ +downloads/ eggs/ +.eggs/ lib/ lib64/ parts/ sdist/ var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec # Installer logs pip-log.txt @@ -40,10 +46,16 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage +.coverage.* .cache nosetests.xml coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ # Translations *.mo @@ -51,6 +63,69 @@ coverage.xml # Django stuff: *.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy # Sphinx documentation docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 8cbf57b4..00000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "templates_raw/_bundler"] - path = templates_raw/_bundler - url = https://github.com/jkowalleck/HTML_bundler.git -[submodule "templates_raw/_foreign/normalize.css"] - path = templates_raw/_foreign/normalize.css - url = https://github.com/necolas/normalize.css.git diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000..61e491b7 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,9 @@ +[isort] +## read the docs: https://github.com/timothycrosley/isort/wiki/isort-Settings +# config shamelessly plugged from django +combine_as_imports = true +default_section = THIRDPARTY +include_trailing_comma = true +known_first_party = nichtparasoup +line_length = 119 +multi_line_output = 5 diff --git a/.travis.disabled.yml b/.travis.disabled.yml new file mode 100644 index 00000000..443121c0 --- /dev/null +++ b/.travis.disabled.yml @@ -0,0 +1,53 @@ +## not using travisCI for this project anymore +## switched to github actions + +version: ~> 1.0 + +os: "linux" +dist: "xenial" + +language: "python" + +cache: "pip" + +install: + - "python3 -m pip install --upgrade pip setuptools" + - "python3 -m pip install --upgrade tox" + +script: "python3 -m tox" + +stages: + - name: "Testing" + - name: "Publishing" + if: "tag IS present" + +jobs: + include: + - stage: "Testing" + dist: "trusty" + python: "3.5" + env: "TOXENV=py35-codecov" + - stage: "Testing" + python: "3.6" + env: "TOXENV=py36-codecov" + - stage: "Testing" + python: "3.7" + env: "TOXENV=py37-codecov" + - stage: "Testing" + python: "3.8" + env: "TOXENV=py38-codecov" + - stage: "Publishing" + name: "PYPI" + install: + - "python3 -m pip install --upgrade pip setuptools" + - "python3 -m pip install --upgrade twine wheel" + script: skip + deploy: + provider: "pypi" + username: "__token__" + password: + secure: "TODO" + distributions: "sdist bdist_wheel" + on: + branch: master + tags: true diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 00000000..cef3a7eb --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,72 @@ +# Changelog + + +## Unreleased + + +## 2.0.0 + +Rewrite from scratch. + +### Breaking changes + +* removed support for python2.7 and lower. +* removed support for python3.4 and lower. + +#### For users + +the config format completely changed. Read the `docs/` for more. + +#### For developers + +Yes, everything changed ... due to a complete rewrite. Read the `docs/dev/` for more. + +### Added + +* publishing to PyPI +* image crawler for "picsum" +* image crawler "dummy" +* documentation in `docs/` +* `setup.py`-based packaging support - for `PIP` +* testing support via `pytest` and test coverage report via `coverage` +* code style tests via `flake8`, `mypy` and extensions for those - also added them to `tox`-based automatisation +* `tox`-based automatisation for testing +* CI tests for `tox`-based tests on `py35`, `py36`, `py37`, `py38` - via github actions +* version history file `HISTORY.md` + +### Modified + +* `README.md` to match current implementation +* web UI to match latest web serve specs### rewrote from scratch + +* config system - now using `YAML` file format +* core image crawler architecture +* core server +* web server +* command line interface +* reddit crawler + +### Removed + +Some image crawlers were removed, so they can be rewritten from scratch. + +* image crawler for "giphy" +* image crawler for "soup.io" +* image crawler for "pr0gramm" +* image crawler for "4chan" +* image crawler for "9gag" + + +## 1.x.x + +basic feature complete implementation + +* supports: python2.6 and later +* supports: python3.4 and later +* implemented: config system - using `INI` file format +* implemented: commandline interface +* implemented: web UI +* implemented: web server +* implemented: core server architecture to draw a random crawled image +* implemented: image crawler for giphy, soup.io, pr0gramm, 4chan, 9gag, reddit +* implemented: image crawler architecture diff --git a/INSTALL b/INSTALL new file mode 100644 index 00000000..7e2cc7fe --- /dev/null +++ b/INSTALL @@ -0,0 +1,5 @@ +just run + + python -m pip install . + +for more details, see docs/install.md \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..75584ef9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,34 @@ +## https://packaging.python.org/guides/using-manifest-in/ + + +# this file +include MANIFEST.in + + +# minimum files + +include setup.py +include README.md +graft nichtparasoup + + +# additional files and folders + +include AUTHORS +include HISTORY.md +include INSTALL +include LICENSE + +graft docs +graft examples +graft images +graft testing + + +# excludes + +global-exclude *_cache/* +global-exclude __pycache__ +global-exclude *.py[cod] + +global-exclude .gitignore diff --git a/README.md b/README.md index 22bf244a..0f82e446 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,58 @@ # nichtparasoup -nichtparasoup is a hackspaces entertainment system. -It randomly displays images/gifs from -[giphy](https://giphy.com), -[soup.io](http://soup.io), -[pr0gramm](https://pr0gramm.com), -[4chan](https://4chan.org), -[9gag](https://9gag.com) and -[reddit](https://reddit.com). - -![logo](images/logo.png) - -At our hackspace [k4cg](https://k4cg.org) we -use it since 2 years now. It turns out to be a very non-invasive way of -entertaining a crowd of nerds without having the noise and interruptions of -videos or other stuff. +_nichtparasoup_ is a hackspaces entertainment system. +It randomly displays images from +[reddit](https://reddit.com). +Other crawlers are currently removed, but will be back soon. -Here is what it looks like in your browser -![screenshot](images/screenshot.png) - -and even better, on a beamer in your local hackspace! -![hackspace](images/hackspace.jpg) - -## demo - -Visit [nicht.parasoup.de/demo/](http://nicht.parasoup.de/demo/) to try it! - -## setup - -```sh -git clone https://github.com/k4cg/nichtparasoup -cd nichtparasoup -pip install -r requirements.txt -``` - -after that you can just run - -```sh -./nichtparasoup.py -c configs/sfw.ini -``` - -## configuration - -configuration takes place in `config.ini` - edit the file to your needs or -you may write a derived config `myCustom.ini` and start the server via -`nichtparasoup.py -c myCustom.ini`. if you do so, you may edit some of the -sections. not all is needed since most things are already defined in the -[`config.defaults.ini`](config.defaults.ini) which may be overwritten by your -custom config file. - -**Important: a configuration file is required** - -Some example config files are included in the [`configs`](configs) directory. - -### general - -specify port, bind address and user agent that nichtparasoup uses for -visiting the sites on crawler - -```ini -[General] -Port: 5000 -IP: 0.0.0.0 -Useragent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10) AppleWebKit/600.1.25 (KHTML, like Gecko) Version/8.0 Safari/600.1.25 -Urlpath: /sfw -``` - -the `Urlpath` parameter specifies where the application should listen on. -For example the value `/sfw` would configure the webserver listening on -`http://localhost:5000/sfw/` -### cache +![logo](https://raw.githubusercontent.com/k4cg/nichtparasoup/master/images/logo.png) -* `Images` indicates how many images will be loaded per crawler on each crawler - run -* `Images_min_limit` configures at how many images the crawler starts again - collecting new images from the sites. -```ini -[Cache] -Images: 30 -Images_min_limit: 15 -``` +--- -### logging -logging section is mostly self-explaining +At our hackspace [k4cg](https://k4cg.org) we use it since 2 years now. +It turns out to be a very non-invasive way of entertaining a crowd of nerds +without having the noise and interruptions of videos or other stuff. -```ini -[Logging] -Log_name: nichtparasoup -File: nichtparasoup.log -Verbosity: debug -``` - -### sites - -Configuration of your source work like this. - -```ini -[Sites] -; set to false or remove a Crawler, to disable it -SoupIO: everyone -Pr0gramm: new,top -Reddit: nsfw,gifs,pics,nsfw_gifs,aww,aww_gifs,reactiongifs,wtf,FoodPorn,cats,ImGoingToHellForThis,EarthPorn,facepalm,fffffffuuuuuuuuuuuu,oddlysatisfying -NineGag: geeky,wtf,girl,hot,trending -Instagram: cats,animals,pornhub,nerdy_gaming_art,nature,wtf -Fourchan: b,sci -Giphy: feels, alcohol, fail, troll, diy, robot, stars, physics -``` - -For example Reddit: wtf,gifs will end up in `https://reddit.com/r/wtf` and -`https://reddit.com/r/gifs` end up being in the crawler. For 9gag you can -add any site that hits the scheme `https://9gag.com/`. - -Crawlers can be weighted against each other with optional factors ranging -from 0.1 to 10.0: - -```ini -[Sites] -SoupIO: everyone*2.5 -Pr0gramm: top*5.0,new*0.5 -``` - -The default factor is 1. In the configuration above the images from -SoupIO-everyone should be around the half of Pr0gramm-top as well as around -five times as much as Pr0gramm-new. - -## contribution - -/!\ attention: -keep the code compatible for py2 and py3. - -### server side & crawlers - -you find a crawler missing or not working? feel free to fill the gaps. - -writing a crawler will take less than half an hour. just grab one of the -existing implementations, copy it, modify it, test it. - -don't forget to write a test config to [`tests/configs`](tests/configs) and use -this for testing your work easily. - -if you like, you may also contribute a logo for the frontend. just follow -the instructions from the -[`templates_raw/root/css/sourceIcons.css`](templates_raw/root/css/sourceIcons.css) -file and compile the frontend afterwards. - -### frontend +Here is what it looks like in your browser +![screenshot](https://raw.githubusercontent.com/k4cg/nichtparasoup/master/images/screenshot.png) -/!\ see the [template_raw](template_raw) dir and the read the README there. +and even better, on a beamer in your local hackspace! +![hackspace](https://raw.githubusercontent.com/k4cg/nichtparasoup/master/images/hackspace.jpg) -basically, check out the repo and initialize the template bundler -As the template bundler is a separate repo you have to execute the -following commands if you want to use it (to create your own templates). +## How it works -```sh -git submodule update --init --recursive -cd templates_raw/_bundler -pip install -r requirements.txt -``` +Images are crawled from multiple public pre-configured sources. +No image is actually downloaded, just the URL to images are gathered. Found images are kept in a list, also it is assured that the same URL is never gathered twice. -## internals +To display the crawled images, _nichtparasoup_ starts a webserver display a web UI. +The web UI fetches a random image URL from the _nichtparasoup_ server one by one. In the web UI the images are downloaded from the original source. +Also the original source is linked and marked by an icon. Just hover or click the icon in the bottom right of each image. -### commands +Everytime _nichtparasoup_ serves an image URL it also removes it from its list. This means an image URL is served only once - unless the server was resetted. (This might change in the future) -You can interact with nichtparasoup in an "api" way very easy. -For example `curl localhost:5000/`. You can insert every command here -listed below. -* `/get` - gets a JSON with an image uri and other information -* `/imagelist` - prints out every image url in the cache -* `/blacklist` - prints out every image url that is blacklisted - (e.g. "already seen") -* `/status` - prints number of images in cache and blacklist and size in - memory of these two lists in a readable way. - pass parameter `t=json` to get a JSON response. -* `/flush` - will delete everything in cache but not in blacklist -* `/reset` - deletes everything in cache and blacklist +## Demo -### behavior +Visit [nicht.parasoup.de/demo/](http://nicht.parasoup.de/demo/) to try it! -when you start nichtparasoup -* system will fill up cache by startup - (30 image urls cached per defined crawler by default) -* system starts up the webserver -* point your browser to the configured `localhost:5000/` - or whatever is configured in the config -* start page will request single images randomly by `/get` and show it -* when system's cache is empty, it will be refilled by the crawler - automatically -* you will (hopefully) get new results. +## Install, Usage, Config, etc -keep in mind: every time you restart nichtparasoup, the cache forgets about its -previous shown images. So is not persistent. +see the [docs](https://github.com/k4cg/nichtparasoup/tree/master/docs) -### cache_fill in threads -once you start up nichtparasoup the crawler will initially fill the cache up -`Images`. this happens in a separate thread in the background. when your -configured `Images_min_limit` get hit, the crawler starts choosing -a new random image provider (see at the top) and refills your cache. the -crawler thread wakes up every `1.337` seconds and checks the status of the -current image map. +## License -## license +MIT - see the [`LICENSE`](https://github.com/k4cg/nichtparasoup/blob/master/LICENSE) file for details. -MIT - see the [`LICENSE`](LICENSE) file for details. -## credits +## Credits -* see the [`AUTHORS`](AUTHORS) file for a list of essential contributors +* see the [`AUTHORS`](https://github.com/k4cg/nichtparasoup/blob/master/AUTHORS) file for a list of essential contributors * parts of the logo are taken from [Smashicons](https://www.flaticon.com/authors/smashicons) on [www.flaticon.com](https://www.flaticon.com/) diff --git a/RELEASEPROCESS.md b/RELEASEPROCESS.md new file mode 100644 index 00000000..49c003eb --- /dev/null +++ b/RELEASEPROCESS.md @@ -0,0 +1,17 @@ +# Release process + +1. get the latest + 1. run: `git checkout master` + 1. run: `git pull` +1. mark all "Unreleased" changes to be in the version + 1. modify `HISTORY.md` by just adding a new headline right under the `## Unreleased` + example: + ``` + ## Unreleased + + ## 2.X.Y + ``` + 1. commit and push the changed `HISTORY.md` to master + 1. run ala `git commmit -m 'preparing 2.X.Y' HISTORY.md` + 1. run ala `git push origin master` +1. draft and publish a release on `master` branch in github diff --git a/config.defaults.ini b/config.defaults.ini deleted file mode 100644 index c7883c90..00000000 --- a/config.defaults.ini +++ /dev/null @@ -1,34 +0,0 @@ -;; DEFAULT config for nichtparasoup -;; see README file for more - -;; DO -;; NOT -;; TOUCH - -[General] -Port: 5000 -IP: 0.0.0.0 -Useragent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10) AppleWebKit/600.1.25 (KHTML, like Gecko) Version/8.0 Safari/600.1.25 - -[Cache] -Images_min_limit: 15 - -[Logging] -;; possible destinations: file, syslog -Destination: file -File: nichtparasoup.log -;; verbosity levels: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET -Verbosity: ERROR - -[Sites] -;; no sites default configured. -;; more examples: see the configs dir -;; more examples: see the tests/configs dir -;; just an example: -; SoupIO: everyone -; Pr0gramm: new,top -; Reddit: nsfw,gifs,pics,nsfw_gifs,aww,aww_gifs,reactiongifs,wtf,FoodPorn,cats,StarWars -; NineGag: geeky,wtf,girl,hot,trending -; Instagram: cats,animals,pornhub,nerdy_gaming_art,nature,wtf -; Fourchan: b,sci - diff --git a/configs/demo.ini b/configs/demo.ini deleted file mode 100644 index d53e096d..00000000 --- a/configs/demo.ini +++ /dev/null @@ -1,15 +0,0 @@ -[General] -Port: 5006 - -[Logging] -Destination: syslog -Verbosity: WARNING - -[Sites] -SoupIO: false -Pr0gramm: false -Reddit: gifs*6.0,pics*4.0,aww_gifs,FoodPorn*1.0,cats,EarthPorn -NineGag: false -Instagram: false -Fourchan: false -Giphy: false diff --git a/configs/kadsen.ini b/configs/kadsen.ini deleted file mode 100644 index c0c3bb31..00000000 --- a/configs/kadsen.ini +++ /dev/null @@ -1,9 +0,0 @@ -[General] -Port: 5003 - -[Logging] -Destination: syslog -Verbosity: WARNING - -[Sites] -Reddit: aww_gifs,aww,cats diff --git a/configs/nsfw.ini b/configs/nsfw.ini deleted file mode 100644 index 65aeb57d..00000000 --- a/configs/nsfw.ini +++ /dev/null @@ -1,15 +0,0 @@ -[General] -Port: 5002 - -[Logging] -Destination: Syslog -Verbosity: WARNING - -[Sites] -SoupIO: everyone*5.0 -Pr0gramm: top*9.0,new*0.5 -Reddit: nsfw,gifs,pics,nsfw_gifs,aww_gifs*0.3,reactiongifs,wtf*3.0 -NineGag: false -Instagram: false -Fourchan: b*6.0 -Giphy: false diff --git a/configs/pr0.ini b/configs/pr0.ini deleted file mode 100644 index dd2b7b1a..00000000 --- a/configs/pr0.ini +++ /dev/null @@ -1,9 +0,0 @@ -[General] -Port: 5004 - -[Logging] -Destination: syslog -Verbosity: WARNING - -[Sites] -Pr0gramm: top*9.0,new*0.8 diff --git a/configs/sfw.ini b/configs/sfw.ini deleted file mode 100644 index eb5da651..00000000 --- a/configs/sfw.ini +++ /dev/null @@ -1,14 +0,0 @@ -[General] -Port: 5000 - -[Logging] -Destination: File -File: /tmp/foo.log -Verbosity: debug - -[Sites] -SoupIO: false -Pr0gramm: false -Reddit: gifs*9.0,pics,aww_gifs,mildlyinteresting*3.0,reactiongifs,Oddlysatisfying,Spaceflightporn,Therewasanattempt*5.0,HistoryMemes*3.0,Unexpected,Whatcouldgowrong,Weird,photoshopbattles,NichtDerPostillon,Nonononoyes,earthporn*0.5,Bettereveryloop,assholedesign,machineporn,Shittyfoodporn,Instant_regret,Mildlyinfuriating,somememes,Crappydesign*5.0,Accidentalwesanderson,engineeringporn,hmmm*2.0,mechanical_gifs,cableporn,shittylifehacks*3.0,perfecttiming,AnimalPorn,CatGifs,ChildrenFallingOver,funny,geek,IdiotsInCars,interestingasfuck,itsaunixsystem*5.0,MadeMeSmile,insanepeoplefacebook,PeopleFuckingDying*3.0,spaceflightporn,woahdude*4.0,spaceporn*2.0,neckbeardnests,techsupportgore,perfectloops,wasletztepreis -Instagram: false -Fourchan: false diff --git a/crawler/__init__.py b/crawler/__init__.py deleted file mode 100644 index 76411ff9..00000000 --- a/crawler/__init__.py +++ /dev/null @@ -1,442 +0,0 @@ - -__all__ = ['Crawler', 'CrawlerError', 'ImageData'] - - -import sys -import random -import time -import re - -try: - from urllib.request import Request, urlopen # py3 -except ImportError: - from urllib2 import Request, urlopen # py2 - -try: - from urllib.request import URLError as HTTPError # py3 -except ImportError: - from urllib2 import HTTPError # py2 - -try: - from urllib.parse import urljoin # py3 -except ImportError: - from urlparse import urljoin # py2 - -from bs4 import BeautifulSoup - - -class Crawler(object): - """ - abstract class Crawler - """ - - ## class constants - - __C_timeout_ = 'timeout' - __C_headers_ = 'headers' - __C_resetDelay_ = 'resetDelay' - - __refresh_uri_RE = re.compile(r'^(?:\d*;)?url=(.+)$', flags=re.IGNORECASE) - - __image_RE = re.compile(r'.*\.(?:jpeg|jpg|png|gif)(?:\?.*)?(?:#.*)?$', flags=re.IGNORECASE) - - ## class vars - - __configuration = { - __C_timeout_: 10, # needs to be greater 0 - __C_headers_: {}, # additional headers - __C_resetDelay_: 10800 # value in seconds - } - - __blacklist = [] - __images = {} - - __factors = None - __logger = None - - ## properties - - __crawlingStarted = 0.0 - - ## config methods - - @classmethod - def configure(cls, config=None, key=None, value=None): - if isinstance(config, dict): - cls.__configuration += config - elif key and value: - cls.__configuration[key] = value - - @classmethod - def __config_setter_and_getter(cls, key, value=None): - if value is None: - return cls.__configuration[key] - cls.configure(key=key, value=value) - - ## wellknown config accessors - - @classmethod - def request_headers(cls, value=None): - return cls.__config_setter_and_getter(cls.__C_headers_, value) - - @classmethod - def request_timeout(cls, value=None): - return cls.__config_setter_and_getter(cls.__C_timeout_, value) - - @classmethod - def reset_delay(cls, value=None): - return cls.__config_setter_and_getter(cls.__C_resetDelay_, value) - - ## basic document fetcher - - @classmethod - def __html_find_meta_refresh(cls, document): - """ - :type document: BeautifulSoup - :rtype: str | None - """ - refresh_uri = None - - # - meta_refresh = document.find("meta", {"http-equiv": "refresh", "content": cls.__refresh_uri_RE}) - if meta_refresh: - # @fixme html-decode ! - refresh_uri = cls.__refresh_uri_RE.match(meta_refresh["content"]).group(1) - - return refresh_uri - - @staticmethod - def __html_find_base(document): - """ - :type document: BeautifulSoup - :rtype: str | None - """ - base_uri = None - - # - base_tag = document.find("base", {"href": True}) - if base_tag: - # @fixme html-decode ! - base_uri = base_tag["href"] - - return base_uri - - @classmethod - def _fetch_remote(cls, uri, depth_indicator=1): - """ - return remote document and actual remote uri - :type uri: str - :type depth_indicator: int - :rtype: (document: str | None, uri) - """ - - cls._log("debug", "fetch remote(%d): %s" % (depth_indicator, uri)) - request = Request(uri, headers=cls.request_headers()) - try: - response = urlopen(request, timeout=cls.request_timeout()) - except HTTPError: - cls._log("warn", "Could not fetch: %s" % uri) - return None - - if not response: - return None - - uri = response.geturl() - - charset = 'utf8' - try: - charset = response.info().get_param('charset', charset) # py3 - except AttributeError: - pass - - return response.read().decode(charset), uri - - @classmethod - def _fetch_remote_html(cls, uri, follow_meta_refresh=True, follow_meta_refresh_max=5, bs4features="html5lib"): - """ - returns remote HTML document, actual remote uri and base uri - :type uri: str - :type follow_meta_refresh: bool - :type follow_meta_refresh_max: int - :rtype: ( document: BeautifulSoup | None , base: str, uri: str ) - """ - - document = None - follow_meta_refresh_depth = 1 - - while True: - (response, uri) = cls._fetch_remote(uri, follow_meta_refresh_depth) - - if not response: - break - - document = BeautifulSoup(response, features=bs4features) - - if not follow_meta_refresh: - break - - refresh_uri = cls.__html_find_meta_refresh(document) - if not refresh_uri: - break - refresh_uri = urljoin(uri, refresh_uri) - - if refresh_uri == uri: - break - - cls._log("debug", "fetch remote HTML(%d): %s meta-refreshes to %s" % - (follow_meta_refresh_depth, uri, refresh_uri)) - uri = refresh_uri - - follow_meta_refresh_depth += 1 - if follow_meta_refresh_depth >= follow_meta_refresh_max: - break - - if not document: - cls._log("debug", "fetch remote HTML: %s is empty" % uri) - - base = uri - - doc_base = cls.__html_find_base(document) - if doc_base: - base = urljoin(base, doc_base) - - return document, base, uri - - ## general functions - - @classmethod - def __blacklist_clear(cls): - cls.__blacklist = [] # be aware: list.clean() is not available in py2 - - @classmethod - def _blacklist(cls, uri): - cls.__blacklist.append(uri) - - @classmethod - def _is_blacklisted(cls, uri): - return uri in cls.__blacklist - - @classmethod - def _is_image(cls, uri): - return cls.__image_RE.match(uri) is not None - - @classmethod - def __images_clear(cls): - cls.__images = {} # be aware: dict.clean() is not available in py2 - - @classmethod - def __add_image(cls, uri, crawler, site, source, more): - """ - :type uri: str - :type crawler: str - :type site: str - :type source: str|None - :type more: dict - :rtype: bool - """ - if not cls._is_image(uri): - # self._log("info", uri + " is no image ") - return False - - if cls._is_blacklisted(uri): - return False - - if crawler not in cls.__images: - cls.__images[crawler] = {} - - if site not in cls.__images[crawler]: - cls.__images[crawler][site] = [] - - cls._blacklist(uri) # add it to the blacklist to detect duplicates - cls.__images[crawler][site].append(ImageData(uri, source, more)) - cls._log("debug", "added %s-%s-%s: %s" % (crawler, site, source, uri)) - return True - - @classmethod - def get_image(cls): - """ - :rtype: dict - """ - images = cls.__images - factors = cls.__factors - if images: - # return image respecting factors: - # Summing up the Factors till reaching a random number - - max = 0 - for crawler in images: - for site in images[crawler]: - factor = 1 - if crawler in factors and site in factors[crawler]: - factor = factors[crawler][site] - - max += factor - - rnd = random.uniform(0, max) - - count = 0 - for crawler in images: - for site in images[crawler]: - factor = 1 - if crawler in factors and site in factors[crawler]: - factor = factors[crawler][site] - count += factor - if count <= rnd: - continue - - image = random.choice(images[crawler][site]) - if not image: - continue - - images[crawler][site].remove(image) - cls._log("debug", "delivered %s-%s: %s - remaining: %d" - % (crawler, site, image.uri, len(images[crawler][site]))) - - result = image.asDict() - result['crawler'] = crawler - result['site'] = site - return result - return None - - @classmethod - def set_logger(cls, logger): - cls.__logger = logger - - @classmethod - def set_factors(cls, factors): - cls.__factors = factors - - @classmethod - def _log(cls, log_type, message): - if cls.__logger: - getattr(cls.__logger, log_type)(message) - - @classmethod - def _debug(cls): - return "" % (cls.__configuration, cls.info()) - - @classmethod - def info(cls): - images = cls.__images - blacklist = cls.__blacklist - - info = { - "blacklist": len(blacklist), - "blacklist_size": sys.getsizeof(blacklist, 0), - "images_per_site": {} - } - - images_count = 0 - images_size = 0 - for crawler in images: - for site in images[crawler]: - site_image_count = len(images[crawler][site]) - images_count += site_image_count - images_size += sys.getsizeof(images[crawler][site]) - info["images_per_site"][crawler + "_" + site] = site_image_count - - info["images_size"] = images_size - info["images"] = images_count - - return info - - @classmethod - def show_imagelist(cls): - imagelist = [] - for crawler in cls.__images: - for site in cls.__images[crawler]: - for imageData in cls.__images[crawler][site]: - imagelist.append(imageData.uri) - return imagelist - - @classmethod - def show_blacklist(cls): - return cls.__blacklist - - @classmethod - def flush(cls): - cls.__images_clear() - cls._log("info", "flushed. cache is now empty") - - @classmethod - def reset(cls): - cls.__images_clear() - cls.__blacklist_clear() - cls._log("info", "reset. cache and blacklist are now empty") - - def crawl(self): - now = time.time() - if not self.__crawlingStarted: - self.__crawlingStarted = now - elif self.__crawlingStarted <= now - self.__class__.reset_delay(): - self.__class__._log("debug", "instance %s starts at front" % (repr(self))) - self.__crawlingStarted = now - self._restart_at_front() - - self.__class__._log("debug", "instance %s starts crawling" % (repr(self))) - try: - self._crawl() - except TypeError: - # Catch empty returns from crawling (returns nonetype) - pass - except CrawlerError as e: - self.__class__._log("exception", "crawler error: %s" % (repr(e))) - except: - e = sys.exc_info()[0] - self.__class__._log("exception", "unexpected crawler error: %s" % (repr(e))) - - def _add_image(self, uri, site, source=None, **kwargs): - """ - :type uri: str - :type site: str - :type source: str|None - :type kwargs: dict - :rtype: bool - """ - return self.__class__.__add_image(uri, self.__class__.__name__, site, source, kwargs) - - ## abstract functions - - def __init__(self): - # call every abstract method to be sure it is there - self._restart_at_front() - self._crawl() - raise NotImplementedError("Should have implemented this") - - def _crawl(self): - raise NotImplementedError("Should have implemented this") - - def _restart_at_front(self): - raise NotImplementedError("Should have implemented this") - - -class CrawlerError(Exception): - pass - - -class ImageData(object): - """ class def: image data """ - - uri = "" - source = None - more = {} - - def __init__(self, uri, source=None, more={}): - """ - :type uri: str - :type source: str|None - :type more: dict - """ - self.uri = uri - self.source = source - self.more = more - - def asDict(self): - """ - :rtype: dict - """ - return { - "uri": self.uri , - "source": self.source , - "more": self.more , - } diff --git a/crawler/fourchan.py b/crawler/fourchan.py deleted file mode 100644 index e5818eed..00000000 --- a/crawler/fourchan.py +++ /dev/null @@ -1,59 +0,0 @@ - - -try: - from urllib.parse import urljoin # py3 -except ImportError: - from urlparse import urljoin # py2 - - -from . import Crawler, CrawlerError - - -class Fourchan(Crawler): - """ Fourchan image provider """ - - __uri = "" - __next = "" - __site = "" - - @staticmethod - def __build_uri(uri): - return uri - - def _restart_at_front(self): - self.__next = self.__uri - - def __init__(self, uri, site): - self.__site = site - self.__uri = self.__class__.__build_uri(uri) - self._restart_at_front() - - def _crawl(self): - uri = self.__next - if not uri: - return - - self.__class__._log("debug", "%s crawls url: %s" % (self.__class__.__name__, uri)) - - (page, base, _) = self.__class__._fetch_remote_html(uri) - if not page: - self.__class__._log("debug", "%s crawled EMPTY url: %s" % (self.__class__.__name__, uri)) - return - - # get more content ("scroll down") - # to know what page to parse next - # update new last URI when we're not on first run - _buttons = page.find_all("a", {"class": "button", "href": True}) - if _buttons: - self.__next = urljoin(base, _buttons[-1]["href"]) - else: - self.__class__._log("debug", "%s found no `next` on url: %s" % (self.__class__.__name__, uri)) - self.__next = "" - - images_added = 0 - for con in page.find_all("a", {"class": "fileThumb", "href": True}): - if self._add_image(urljoin(base, con["href"]), self.__site): - images_added += 1 - - if not images_added: - self.__class__._log("debug", "%s found no images on url: %s" % (self.__class__.__name__, uri)) diff --git a/crawler/giphy.py b/crawler/giphy.py deleted file mode 100644 index 5c0b9c59..00000000 --- a/crawler/giphy.py +++ /dev/null @@ -1,52 +0,0 @@ - - -import json - -from . import Crawler, CrawlerError - - -class Giphy(Crawler): - """ class def: a crawler for Giphy """ - - __uri = "" - __next = 0 - __site = "" - - __limit = 50 - - __api_key = "dc6zaTOxFJmzC" - - @classmethod - def _build_uri(cls, uri): - return uri + "&api_key=" + cls.__api_key + "&limit=" + str(cls.__limit) - - def _restart_at_front(self): - self.__next = 0 - - def __init__(self, uri, site): - self.__site = site - self.__uri = self.__class__._build_uri(uri) - self._restart_at_front() - - def _crawl(self): - uri = self.__uri + "&offset=" + str(self.__next) - self.__class__._log("debug", "%s crawls url: %s" % (self.__class__.__name__, uri)) - - (remote, uri) = self.__class__._fetch_remote(uri) - if not remote: - self.__class__._log("debug", "%s crawled EMPTY url: %s" % (self.__class__.__name__, uri)) - return - - data = json.loads(remote) - - self.__next += self.__limit - - images_added = 0 - for child in data['data']: - image = child['images']['original']['url'] - if image: - if self._add_image(image, self.__site): - images_added += 1 - - if not images_added: - self.__class__._log("debug", "%s found no images on url: %s" % (self.__class__.__name__, uri)) diff --git a/crawler/instagram.py b/crawler/instagram.py deleted file mode 100644 index e5454883..00000000 --- a/crawler/instagram.py +++ /dev/null @@ -1,65 +0,0 @@ - - -try: - from urllib.parse import urljoin # py3 -except ImportError: - from urlparse import urljoin # py2 - -import json - - -from . import Crawler, CrawlerError - - -class Instagram(Crawler): - """ instagram image provider """ - - __uri = "" - __last = "" - __site = "" - - ## class methods - - @staticmethod - def __build_uri(uri): - return urljoin(uri+"/", "./media/") - - ## instance methods - - def _restart_at_front(self): - self.__last = "" - - def __init__(self, uri, site): - self.__site = site - self.__uri = self.__class__.__build_uri(uri) - self._restart_at_front() - - def _crawl(self): - uri = urljoin(self.__uri, "?max_id="+self.__last) - self.__class__._log("debug", "%s crawls url: %s" % (self.__class__.__name__, uri)) - - (remote, uri) = self.__class__._fetch_remote(uri) - if not remote: - self.__class__._log("debug", "%s crawled EMPTY url: %s" % (self.__class__.__name__, uri)) - return - - data = json.loads(remote) - if "status" not in data or data["status"] != "ok": - raise CrawlerError() - - images_added = 0 - for item_image in [item for item in data['items'] if item["type"] == "image"]: - if 'id' in item_image: - self.__last = item_image['id'] - - if 'images' in item_image \ - and 'standard_resolution' in item_image['images']\ - and 'url' in item_image['images']['standard_resolution']: - image = item_image['images']['standard_resolution']['url'] - if image: - if self._add_image(image, self.__site): - images_added += 1 - - if not images_added: - self.__class__._log("debug", "%s found no images on url: %s" % (self.__class__.__name__, uri)) - diff --git a/crawler/ninegag.py b/crawler/ninegag.py deleted file mode 100644 index 32033e5f..00000000 --- a/crawler/ninegag.py +++ /dev/null @@ -1,78 +0,0 @@ - -try: - from urllib.parse import urljoin # py3 -except ImportError: - from urlparse import urljoin # py2 - -import re - - -from . import Crawler, CrawlerError - - - -class NineGag(Crawler): - """ 9gag image provider """ - - __uri = "" - __next = "" - __site = "" - - __RE_post_container = re.compile(r'badge-post-container') - - @staticmethod - def __build_uri(uri): - return uri - - def _restart_at_front(self): - self.__next = self.__uri - - def __init__(self, uri, site): - self.__site = site - self.__uri = self.__class__.__build_uri(uri) - self._restart_at_front() - - def _crawl(self): - uri = self.__next - self.__class__._log("debug", "%s crawls url: %s" % (self.__class__.__name__, uri)) - - (page, base, _) = self.__class__._fetch_remote_html(uri) - if not page: - self.__class__._log("debug", "%s crawled EMPTY url: %s" % (self.__class__.__name__, uri)) - return - - # get more content ("scroll down") - # to know what page to parse next - # update new last URI when we're not on first run - _more = page.find("div", {"class": "loading"}) - _next = None - if _more: - _more = _more.find("a", {"class": "btn badge-load-more-post", "href": True}) - if _more: - _next = urljoin(base, _more["href"]) - if _next: - self.__next = _next - else: - self.__class__._log("debug", "%s found no `next` on url: %s" % (self.__class__.__name__, uri)) - - - # for every found imageContainer - # add img-src to map if not blacklisted - images_added = 0 - for con in page.find_all("div", {"class": self.__class__.__RE_post_container}): - image = None - - image_inline = con.find("div", {"class": "badge-animated-container-animated", "data-image": True}) - if image_inline: - image = image_inline['data-image'] - - image_src = con.find('img', {"class": "badge-item-img", "src": True}) - if image_src: - image = image_src['src'] - - if image: - if self._add_image(urljoin(base, image), self.__site): - images_added += 1 - - if not images_added: - self.__class__._log("debug", "%s found no images on url: %s" % (self.__class__.__name__, uri)) diff --git a/crawler/pr0gramm.py b/crawler/pr0gramm.py deleted file mode 100644 index 03387588..00000000 --- a/crawler/pr0gramm.py +++ /dev/null @@ -1,105 +0,0 @@ - -import re, json - -try: - from urllib.parse import urljoin # py3 -except ImportError: - from urlparse import urljoin # py2 - -from . import Crawler, CrawlerError - - -class Pr0gramm(Crawler): - """ pr0gramm.com image provider""" - - ## class constants - - ## properties - - __uri = "" - __next = "" - __site = "" - __image_base_url = "https://img.pr0gramm.com/" - __api_base_url = "" - - __filter = re.compile(r'^/static/[\d]+') - __filterNextPage = "" - - ## functions - - """ - JSON From API - { - atEnd: - atStart: - error: ? - items: [ - ... - { - id: - promoted: - up: - down: - created: - image: z.B.: 2016/02/18/047b2e356d059074.jpg - thumb: - fullsize: - source: - flags: - user: - mark: - } - ... - ] - ts: - cache: - rt: - qc: - } - """ - - @staticmethod - def __build_uri(uri): - return uri - - def _restart_at_front(self): - self.__next = self.__uri - - def __init__(self, uri, site): - self.__site = site - self.__uri = self.__class__.__build_uri(uri) - self.__api_base_url = self.__uri - self.__filterNextPage = re.compile(r'^/static/' + re.escape(self.__site) + r'/[\d]+') - self._restart_at_front() - - def _crawl(self): - uri = self.__next - self.__class__._log("debug", "%s crawls url: %s" % (self.__class__.__name__, uri)) - - - (remote, uri) = self.__class__._fetch_remote(uri) - if not remote: - self.__class__._log("debug", "%s crawled EMPTY url: %s" % (self.__class__.__name__, uri)) - return - - data = json.loads(remote) - - last_id = 0 - images_added = 0 - for item in data["items"]: - - if item["promoted"] == 0: - continue - - if item["image"].lower().endswith(".webm"): - continue - - last_id = item["id"] - - if self._add_image(urljoin(self.__image_base_url, item["image"]), self.__site): - images_added += 1 - - - self.__next = self.__api_base_url + "?older=" + str(last_id) - - self.__class__._log("debug", "%s added %s images on url: %s" % (self.__class__.__name__, images_added, uri)) diff --git a/crawler/reddit.py b/crawler/reddit.py deleted file mode 100644 index 596495f6..00000000 --- a/crawler/reddit.py +++ /dev/null @@ -1,55 +0,0 @@ - - -try: - from urllib.parse import urljoin # py3 -except ImportError: - from urlparse import urljoin # py2 - -import json - -from . import Crawler, CrawlerError - - -class Reddit(Crawler): - """ class def: a crawler for Reddit image threads """ - - __uri = "" - __next = "" - __site = "" - - @staticmethod - def __build_uri(uri): - return uri + ".json" - - def _restart_at_front(self): - self.__next = "" - - def __init__(self, uri, site): - self.__site = site - self.__uri = self.__class__.__build_uri(uri) - self._restart_at_front() - - def _crawl(self): - uri = urljoin(self.__uri, "?after="+self.__next) - self.__class__._log("debug", "%s crawls url: %s" % (self.__class__.__name__, uri)) - - (remote, uri) = self.__class__._fetch_remote(uri) - if not remote: - self.__class__._log("debug", "%s crawled EMPTY url: %s" % (self.__class__.__name__, uri)) - return - - data = json.loads(remote) - - self.__next = data['data']['after'] - - images_added = 0 - for child in data['data']['children']: - image = child['data']['url'] - if image: - thread_uri = urljoin(self.__uri, child['data']['permalink']) - self.__class__._log("debug", thread_uri) - if self._add_image(image, self.__site, thread_uri): - images_added += 1 - - if not images_added: - self.__class__._log("debug", "%s found no images on url: %s" % (self.__class__.__name__, uri)) diff --git a/crawler/soupio.py b/crawler/soupio.py deleted file mode 100644 index 2d15e2eb..00000000 --- a/crawler/soupio.py +++ /dev/null @@ -1,63 +0,0 @@ - - -try: - from urllib.parse import urljoin # py3 -except ImportError: - from urlparse import urljoin # py2 - -from . import Crawler, CrawlerError - - -class SoupIO(Crawler): - """ soup.io image provider """ - - __uri = "" - __next = "" - __site = "" - - @staticmethod - def __build_uri(uri): - return urljoin(uri, "?type=image") - - def _restart_at_front(self): - self.__next = self.__uri - - def __init__(self, uri, site): - self.__site = site - self.__uri = self.__class__.__build_uri(uri) - self._restart_at_front() - - def _crawl(self): - uri = urljoin(self.__uri, self.__next) - self.__class__._log("debug", "%s crawls url: %s" % (self.__class__.__name__, uri)) - - (page, base, _) = self.__class__._fetch_remote_html(uri) - if not page: - self.__class__._log("debug", "%s crawled EMPTY url: %s" % (self.__class__.__name__, uri)) - return - - # get more content ("scroll down") - # to know what page to parse next - # update new last URI when we're not on first run - _next = None - _more = page.find("div", {"id": "more_loading"}) - if _more: - _more = _more.find("a", {"href": True}) - if _more: - _next = urljoin(base, _more["href"]) - if _next: - self.__next = _next - else: - self.__class__._log("debug", "%s found no `next` on url: %s" % (self.__class__.__name__, uri)) - - # for every found imageContainer - # add img-src to map if not blacklisted - images_added = 0 - for con in page.find_all("div", {"class": "imagecontainer"}): - image = con.find('img', {"src": True}) - if image: - if self._add_image(urljoin(base, image['src']), self.__site): - images_added += 1 - - if not images_added: - self.__class__._log("debug", "%s found no images on url: %s" % (self.__class__.__name__, uri)) diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 00000000..82affd07 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,104 @@ +# config + +_nichtparasoup_ uses configs in [YAML](https://yaml.org/) file format. +Supported is YAML v1.0 to v1.2. + +for a quick start run `nichtparasoup config --dump`. +to have your config checked, run `nichtparasoup config --check`. + +this document explains all the options and all you need to know to write your own config. + +here is a complete example config: + +```yaml +webserver: + hostname: "0.0.0.0" + port: 5000 + +imageserver: + crawler_upkeep: 30 + +crawlers: + - type: "Reddit" + weight: 3 + config: + subreddit: 'EarthPorn' + - type: "Picsum" + config: + width: 300 + height: 600 + +logging: + level: 'INFO' +``` + +## `webserver` + +- WebServer config +- type: map + +### `hostname` + +- hostname the web server recognizes. can also be a unix socket +- type: string + +### `port` + +- port the webserver uns on +- type: integer +- constraint: 1 <= port <= 65535 + +## `imageserver` + +- ImageServer config +- type: map + +### `crawler_upkeep` + +- number of images the server must keep at all time +- type: integer +- constraint: >= 10 + +## `crawlers` + +- list of ImageCrawlers to use. +- ATTENTION: crawlers are treated like a unique list. the combination of type and config makes them unique +- for a list of available types see the commandline help `nichtparasoup info --imagecrawler-list` +- for description of a crawler and how to configure, see commandline help `nichtparasoup info --imagecrawler-desc` + or read the [docs](imagecrawlers) +- type: list + +### `type` + +- name of the crawler +- for a list of available types see the commandline help: `nichtparasoup info --imagecrawler-list` +- type: string + +### `weight` + +- probability to be chosen randomly +- type: integer or float +- constraint: > 0 +- optional +- default: 1 + +### `config` + +- the crawler's own config +- for description of a crawler and how to configure, see commandline help `nichtparasoup info --imagecrawler-desc` + or read the [docs](imagecrawlers) +- type: map +- optional + +## `logging` + +- logging settings +- type: map +- optional + +### `level` + +- log level settings +- type: enum('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG') +- optional +- default: 'INFO' diff --git a/docs/dev/contribute.md b/docs/dev/contribute.md new file mode 100644 index 00000000..5d0319a9 --- /dev/null +++ b/docs/dev/contribute.md @@ -0,0 +1,11 @@ +# contribute + + +you find a crawler missing or not working? feel free to fill the gaps. + +writing a crawler will take less than half an hour. just grab one of the existing implementations, copy it, modify it, +test it. +see also: [development docu](development.md) + +if you like, you may also contribute a logo for the frontend. just follow the instructions from the +`nichtparasoup/webserver/htdocs/css/sourceIcons.css` file. diff --git a/docs/dev/development.md b/docs/dev/development.md new file mode 100644 index 00000000..46f6d7ad --- /dev/null +++ b/docs/dev/development.md @@ -0,0 +1,18 @@ +# development + +## image crawler + +TODO + + +## backend + +install the requirements: `ptyhon3 -m pip install -e .[development]`. + +then just do whatever you would like. +do not forget to [test](testing.md) the code. + + +## frontend + +TODO diff --git a/docs/dev/release.md b/docs/dev/release.md new file mode 100644 index 00000000..9e22dcf5 --- /dev/null +++ b/docs/dev/release.md @@ -0,0 +1,3 @@ +# release process + +TODO diff --git a/docs/dev/testing.md b/docs/dev/testing.md new file mode 100644 index 00000000..e14d587a --- /dev/null +++ b/docs/dev/testing.md @@ -0,0 +1,3 @@ +# testing + +see the [dedicated testing redme](../../testing/README.md). diff --git a/docs/imagecrawlers/dummy.md b/docs/imagecrawlers/dummy.md new file mode 100644 index 00000000..5f72ad86 --- /dev/null +++ b/docs/imagecrawlers/dummy.md @@ -0,0 +1,14 @@ +# ImageCrawler: Reddit + +## Purpose + +Not an actual crawler. +just generates the same URIs... again .. and again... + +## Config + +### `image_uri` + +- the URI to the image to show +- type: string +- example: https://media.giphy.com/media/qQx7B5z3hG19K/giphy.gif diff --git a/docs/imagecrawlers/picsum.md b/docs/imagecrawlers/picsum.md new file mode 100644 index 00000000..35ec8505 --- /dev/null +++ b/docs/imagecrawlers/picsum.md @@ -0,0 +1,18 @@ +# ImageCrawler: Reddit + +## Purpose + +Not an actual crawler. +returns generic URIs to [https://picsum.photos](https://picsum.photos). + +## Config + +### `with` + +- the width of the image in pixels. +- type: integer + +### `height` + +- the height of the image in pixels. +- type: integer diff --git a/docs/imagecrawlers/reddit.md b/docs/imagecrawlers/reddit.md new file mode 100644 index 00000000..3523e3b3 --- /dev/null +++ b/docs/imagecrawlers/reddit.md @@ -0,0 +1,14 @@ +# ImageCrawler: Reddit + +## Purpose + +A Crawler for an arbitrary SubReddit of [https://www.reddit.com](https://www.reddit.com) + + +## Config + +### `subreddit` + +- the SubReddit to crawl +- type: string +- example: "EarthPorn" diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 00000000..b13c73a2 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,20 @@ +# install + +## official + +`python -m pip install nichtparasoup` + +## nightly + +1. have git installed +1. `python -m pip install https://github.com/k4cg/nichtparasoup.git` + +## for development + +1. have git installed +1. clone the source from https://github.com/k4cg/nichtparasoup.git +1. run `python -m pip install -e .[development,testing]` from projects root dir + +## troubleshooting + +if you find yourself having issues on install, see [requirement docs](requirements.md) diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 00000000..36964237 --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,19 @@ +# requirements + +see [install docs](install.md) first. + +This package requires `Python>=3.5`! +Required dependencies are installed automatically during install via `pip`. + +If you find the installation breaking, this might be due to the following issues: + +## packages can not be installed globally + +solution: add the `--user` switch to your `pip install` command. + +## dependencies version conflict + +_nichtparasoup_ is using some dependencies in a certain minimal version. + +if you have issues with already installed package versions, +just use [python's venv](https://docs.python.org/3/library/venv.html). diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 00000000..baea84b4 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,94 @@ +# usage + +Via commandline, run `python3 -m nichtparasoup` +or just simply `nichtparasoup`. + +The switch `-h`/`--help` will display help. + +`nichtparasoup` uses the following sub-commands +* `run` - run a server +* `config` - config related functions +* `info` - get info for several topics + + +## run server + +`nichtparasoup run` +simply runs a server. + +optional parameter: `-c`/`--use-config` ``. +use a won config for the server. If omitted, the default is used. to write your own config, see the sections below. + +when you start _nichtparasoup_ +1. system will fill up cache by startup +1. system starts up the webserver +1. point your browser to the configured localhost:5000/ or whatever is configured in the config +1. start page will request single images randomly by /get and show it +1. when system's cache is empty, it will be refilled by the crawler automatically +1. you will (hopefully) get new results. + +keep in mind: every time you restart _nichtparasoup_, the cache forgets about its previous shown images. So is not persistent. + + + +## config related functions + +_nichtparasoup_ uses YAML config files. +for more details about config see the [config](config.md) documentation. + +for an eay handling this sub-command comes into play. + +`nichtparasoup config` + +the following switches are available: +* `--check` - validate a YAML config file +* `--dump` - dump YAML config into a file + + +### check a config + +check if a file is a valid config. +will prompt promt errors and warnings, if any. + +`nichtparasoup config --check ` + + +### dump a config + +for a quick start writing your own config, this command will dump the current default config into a file. +you may edit this config to your needs and then - after having it checked - run nichtparasoup using it. + +`nichtparasoup config --dump ` + + +## get info for several topics + +this sub-command gives information about several things... + +`nichtparasoup info` + +the following switches are available: +* `--imagecrawler-list` - list available image crawler types +* `--imagecrawler-desc` - describe an image crawler type and its config +* `--version` - show installed version number + + +### list available ImageCrawlers + +list available ImageCrawlers + +`nichtparasoup info --imagecrawler-list` + + +### describe ImageCrawler + +get info about an ImageCrawler, how to configure it and so on. + +`nichtparasoup info --imagecrawler-desc` + + +### version + +display installed version and exit. + +`nichtparasoup info --version` diff --git a/docs/web_api/get.md b/docs/web_api/get.md new file mode 100644 index 00000000..e4018803 --- /dev/null +++ b/docs/web_api/get.md @@ -0,0 +1,62 @@ +# web-api: get + +will pop a random image from the ImageServer. + +call via `/get`. + +example response: + +```json +{ + "uri": "https://i.redd.it/wybru584upx31.jpg", + "is_generic": false, + "source": "https://www.reddit.com/r/EarthPorn/comments/du0tmw/straight_out_of_a_fairytale_watkins_glen_new_york/", + "more": {}, + "crawler": { + "id": 140647222227296, + "type": "Reddit" + } +} +``` + + +## `uri` + +- thr URI of an image +- type: string + + +## `is_generic` + +- whether the image URI is generic or not +- type: boolean + + +## `source` + +- the image's source +- type: null or string + + +## `more` + +- additional relevant information the crawler found +- type: object + + +## `crawler` + +- information about the ImageCrawler that found this image +- type: object + + +### `id` + +- the ImageCrawler's id - see also [status docs](status.md) +- type: integer + + +### `type` + +- the ImageCrawler's type for easy access in the frontend +- type: string diff --git a/docs/web_api/reset.md b/docs/web_api/reset.md new file mode 100644 index 00000000..1b9c3ae1 --- /dev/null +++ b/docs/web_api/reset.md @@ -0,0 +1,25 @@ +# web-api: reset + +try to trigger a reset on the ImageServer. +this will flush the ImageServers blacklist and force all ImageCrawlers to start upfront. + +call via `/reset`. + +example response: + +```json +{ + "requested": false, + "timeout": 3107 +} +``` + +## `requested` + +- whether the reset was requested or not. +- type: boolean + +## `timeout` + +- amount of seconds to wait until the next reset can be done +- type: integer diff --git a/docs/web_api/status.md b/docs/web_api/status.md new file mode 100644 index 00000000..7318a6f1 --- /dev/null +++ b/docs/web_api/status.md @@ -0,0 +1,11 @@ +# web-api: status + +get several information about the server + +call via `/status` or `/status/`. + +available values for `` are: + +* `server` - read more about [server status](status_server.md) +* `crawlers` - read more about [crawlers status](status_crawlers.md) +* `blacklist` - read more about [blacklist status](status_blacklist.md) diff --git a/docs/web_api/status_blacklist.md b/docs/web_api/status_blacklist.md new file mode 100644 index 00000000..a453473a --- /dev/null +++ b/docs/web_api/status_blacklist.md @@ -0,0 +1,30 @@ +# web-api: blacklist status + +information about the blacklist. + +each crawled non-generic image is added into the blacklist right away to sircumvent duplications. +this means the blacklist is constantly growing. +when the web-api's `/reset` is triggered successfully, the blacklist gets flushed. + +call via `/status/blacklist`. + +example response: + +```json +{ + "len": 50, + "size": 2272 +} +``` + + +## `len` + +- how many images are currently in the blacklist? +- type: integer + + +## `size` + +- how many bythes is the blacklist large? +- type: integer diff --git a/docs/web_api/status_crawlers.md b/docs/web_api/status_crawlers.md new file mode 100644 index 00000000..da23af34 --- /dev/null +++ b/docs/web_api/status_crawlers.md @@ -0,0 +1,72 @@ +# web-api: crawlers status + +call via `/status/crawlers`. + +example response: + +```json +{ + "140647222227296": { + "type": "Reddit", + "weight": 3, + "config": { + "subreddit": "EarthPorn" + }, + "images": { + "len": 47, + "size": 2272 + } + }, + "140647222227912": { + "type": "Picsum", + "weight": 1, + "config": { + "width": 300, + "height": 600 + }, + "images": { + "len": 30, + "size": 1248 + } + } +} +``` + + +## `` + +- a crawlers id + + +## `type` + +- the ImageCrawler's type +- type: string + + +## `weight` + +- the probability an random image is used thom this crawler +- type: integer or float + +## `config + +- the ImageCrawler's current config +- type: object + +## `images` + +- information about the images currently held by this crawler since last crawl run. +- type: object + + +### `len` + +- amount of images +- type: integer + + +### `size` + +- mow many bytes takes the image list? +- type: integer diff --git a/docs/web_api/status_server.md b/docs/web_api/status_server.md new file mode 100644 index 00000000..61ca9e51 --- /dev/null +++ b/docs/web_api/status_server.md @@ -0,0 +1,66 @@ +# web-api: server status + +call via `/status/server`. + +example response: + +```json +{ + "version": "2.0.0", + "uptime": 689, + "reset": { + "count": 0, + "since": 689 + }, + "images": { + "served": 3, + "crawled": 50 + } +} +``` + + +## `version` + +- version of the server +- type: string + + +## `uptime` + +- server uptime in seconds +- type: integer + + +## `reset` + +- type: object + + +### `count` + +- who often was the server reset during it's life time +- type: integer + + +### `since` + +- how may seconds ago was the last reset +- type: integer + + +## `images` + +- type: object + + +### `served` + +- how many images were served via web-api's `/get` +- type: integer + + +### `crawled` + +- how many images were crawled during the servers life time? +- type: integer diff --git a/examples/aww.yaml b/examples/aww.yaml new file mode 100644 index 00000000..0e3bd8c4 --- /dev/null +++ b/examples/aww.yaml @@ -0,0 +1,20 @@ +## This is a config file for nichtprasoup (v2.x) +## This is an example file. For a better kickStart run: nichtparasoup config --help + +webserver: + hostname: "0.0.0.0" + port: 8080 + +imageserver: + crawler_upkeep: 10 + +crawlers: + - type: "Reddit" + config: + subreddit: "aww" + - type: "Reddit" + config: + subreddit: "awww" + - type: "Reddit" + config: + subreddit: "awwww" diff --git a/examples/sfw.yaml b/examples/sfw.yaml new file mode 100644 index 00000000..512b5c23 --- /dev/null +++ b/examples/sfw.yaml @@ -0,0 +1,206 @@ +## This is a config file for nichtprasoup (v2.x) +## This is an example file. For a better kickStart run: nichtparasoup config --help + +webserver: + hostname: "0.0.0.0" + port: 5000 + +imageserver: + crawler_upkeep: 10 + +logging: + level: 'INFO' + +crawlers: + - type: "Reddit" + weight: 3 + config: + subreddit: 'EarthPorn' + - type: "Reddit" + weight: 3 + config: + subreddit: 'gifs' + - type: "Reddit" + weight: 9 + config: + subreddit: 'pics' + - type: "Reddit" + weight: 3 + config: + subreddit: 'aww_gifs' + - type: "Reddit" + weight: 3 + config: + subreddit: 'mildlyinteresting' + - type: "Reddit" + weight: 3 + config: + subreddit: 'reactiongifs' + - type: "Reddit" + weight: 3 + config: + subreddit: 'Oddlysatisfying' + - type: "Reddit" + weight: 3 + config: + subreddit: 'Spaceflightporn' + - type: "Reddit" + weight: 3 + config: + subreddit: 'Therewasanattempt' + - type: "Reddit" + weight: 6 + config: + subreddit: 'HistoryMemes' + - type: "Reddit" + weight: 3 + config: + subreddit: 'Unexpected' + - type: "Reddit" + weight: 8 + config: + subreddit: 'Whatcouldgowrong' + - type: "Reddit" + weight: 9 + config: + subreddit: 'Weird' + - type: "Reddit" + weight: 3 + config: + subreddit: 'photoshopbattles' + - type: "Reddit" + weight: 3 + config: + subreddit: 'Nonononoyes' + - type: "Reddit" + weight: 8 + config: + subreddit: 'earthporn' + - type: "Reddit" + weight: 5 + config: + subreddit: 'Bettereveryloop' + - type: "Reddit" + weight: 8 + config: + subreddit: 'assholedesign' + - type: "Reddit" + weight: 9 + config: + subreddit: 'machineporn' + - type: "Reddit" + weight: 4 + config: + subreddit: 'Shittyfoodporn' + - type: "Reddit" + weight: 2 + config: + subreddit: 'Instant_regret' + - type: "Reddit" + weight: 3 + config: + subreddit: 'Mildlyinfuriating' + - type: "Reddit" + weight: 3 + config: + subreddit: 'somememes' + - type: "Reddit" + weight: 3 + config: + subreddit: 'Crappydesign' + - type: "Reddit" + weight: 3 + config: + subreddit: 'engineeringporn' + - type: "Reddit" + weight: 3 + config: + subreddit: 'hmmm' + - type: "Reddit" + weight: 5 + config: + subreddit: 'mechanical_gifs' + - type: "Reddit" + weight: 3 + config: + subreddit: 'cableporn' + - type: "Reddit" + weight: 3 + config: + subreddit: 'shittylifehacks' + - type: "Reddit" + weight: 4 + config: + subreddit: 'perfecttiming' + - type: "Reddit" + weight: 3 + config: + subreddit: 'AnimalPorn' + - type: "Reddit" + weight: 3 + config: + subreddit: 'CatGifs' + - type: "Reddit" + weight: 3 + config: + subreddit: 'ChildrenFallingOver' + - type: "Reddit" + weight: 3 + config: + subreddit: 'funny' + - type: "Reddit" + weight: 3 + config: + subreddit: 'geek' + - type: "Reddit" + weight: 3 + config: + subreddit: 'IdiotsInCars' + - type: "Reddit" + weight: 3 + config: + subreddit: 'interestingasfuck' + - type: "Reddit" + weight: 3 + config: + subreddit: 'itsaunixsystem' + - type: "Reddit" + weight: 4 + config: + subreddit: 'MadeMeSmile' + - type: "Reddit" + weight: 5 + config: + subreddit: 'insanepeoplefacebook' + - type: "Reddit" + weight: 1 + config: + subreddit: 'PeopleFuckingDying' + - type: "Reddit" + weight: 3 + config: + subreddit: 'spaceflightporn' + - type: "Reddit" + weight: 3 + config: + subreddit: 'woahdude' + - type: "Reddit" + weight: 4 + config: + subreddit: 'spaceporn' + - type: "Reddit" + weight: 2 + config: + subreddit: 'neckbeardnests' + - type: "Reddit" + weight: 1 + config: + subreddit: 'techsupportgore' + - type: "Reddit" + weight: 5 + config: + subreddit: 'perfectloops' + - type: "Reddit" + weight: 3 + config: + subreddit: 'wasletztepreis' diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..0989be0c --- /dev/null +++ b/mypy.ini @@ -0,0 +1,30 @@ +[mypy] + +files = nichtparasoup,testing/**/*.py + +show_error_codes = True +pretty = True + +warn_unreachable = True +allow_redefinition = False + +# ignore_missing_imports = False +# follow_imports = normal +# follow_imports_for_stubs = True + +### Strict mode ### +warn_unused_configs = True +disallow_subclassing_any = True +disallow_any_generics = True +disallow_untyped_calls = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_decorators = True +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_return_any = True +no_implicit_reexport = True + + diff --git a/nichtparasoup.py b/nichtparasoup.py deleted file mode 100755 index 39d06804..00000000 --- a/nichtparasoup.py +++ /dev/null @@ -1,402 +0,0 @@ -#!/usr/bin/env python - -### import libraries -from crawler.giphy import Giphy -from crawler.fourchan import Fourchan -from crawler.instagram import Instagram -from crawler.ninegag import NineGag -from crawler.pr0gramm import Pr0gramm -from crawler.soupio import SoupIO -from crawler.reddit import Reddit -from os import path -import math -import random -import logging -import logging.handlers -import time -import threading -import argparse -import json - -try: - from configparser import RawConfigParser # py3 -except ImportError: - from ConfigParser import RawConfigParser # py2 - -try: - from urllib.parse import quote_plus as url_quote_plus # py3 -except ImportError: - from urllib import quote_plus as url_quote_plus # py2 - -from werkzeug.wrappers import Request, Response -from werkzeug.routing import Map, Rule -from werkzeug.exceptions import HTTPException, NotFound -from werkzeug.serving import run_simple - - -## import templates -import templates as tmpl - -## import crawler -from crawler import Crawler - -_file_path = path.dirname(path.realpath(__file__)) - -# argument parser -arg_parser = argparse.ArgumentParser() -arg_parser.add_argument('-c', '--config-file', metavar='', - type=argparse.FileType('r'), - required=True, - help='a file path to the config ini', - dest="config_file") -args = arg_parser.parse_args() - -# configuration -# init config parser -config = RawConfigParser() - -# read defaults -config.read(path.join(_file_path, 'config.defaults.ini')) -try: - config.read_file(args.config_file) # py3 -except AttributeError: - config.readfp(args.config_file) # py2 -args.config_file.close() - -# get actual config items -nps_port = config.getint("General", "Port") -nps_bindip = config.get("General", "IP") - -min_cache_imgs_before_refill = config.getint("Cache", "Images_min_limit") -user_agent = config.get("General", "Useragent") - -# crawler logging config -logverbosity = config.get("Logging", "Verbosity") -logger = logging.getLogger("nichtparasoup") - -if config.get("Logging", "Destination").lower() == 'syslog': - hdlr = logging.handlers.SysLogHandler() -else: - hdlr = logging.FileHandler(config.get("Logging", "File")) - hdlr.setFormatter(logging.Formatter( - '%(asctime)s %(levelname)s %(message)s')) - -logger.addHandler(hdlr) -logger.setLevel(logverbosity.upper()) - -# werkzeug logging config -log = logging.getLogger('werkzeug') -log.setLevel(logging.CRITICAL) - -try: - urlpath = config.get("General", "Urlpath") -except: - urlpath = '' - -factor_separator = "*" -call_flush_timeout = 10 # value in seconds -call_flush_last = time.time() - call_flush_timeout - -call_reset_timeout = 10 # value in seconds -call_reset_last = time.time() - call_reset_timeout - -Crawler.request_headers({'User-Agent': user_agent}) -Crawler.set_logger(logger) - -# config the crawlers - - -def get_crawlers(configuration, section): - """ - parse the config section for crawlers - * does recognize (by name) known and implemented crawlers only - * a robust config reading and more freedom for users - - :param configuration: RawConfigParser - :param section: string - :return: crawler, factors - """ - crawlers = {} - factors = {} - - for crawler_class in Crawler.__subclasses__(): - crawler_class_name = crawler_class.__name__ - if not configuration.has_option(section, crawler_class_name): - continue # skip crawler if not configured - - crawler_config = configuration.get(section, crawler_class_name) - if not crawler_config or crawler_config.lower() == "false": - continue # skip crawler if not configured or disabled - - crawler_uris = {} - - # mimic old behaviours for bool values - if crawler_config.lower() == "true": - if crawler_class == SoupIO: - crawler_config = "everyone" - - crawler_sites_and_factors = [site_stripped for site_stripped in [site.strip() for site in crawler_config.split(",")] # trim sites - if site_stripped] # filter stripped list for valid values - - if not crawler_sites_and_factors: - continue # skip crawler if no valid sites configured - - crawler_sites = [] - factors[crawler_class_name] = {} - - # Separate Site and Factor - for factorPair in crawler_sites_and_factors: - if factor_separator not in factorPair: - # No Factor configured - crawler_sites.append(url_quote_plus(factorPair)) - continue - - factorPair_parts = [factorPairPart.strip( - ) for factorPairPart in factorPair.split(factor_separator)] - - if not factorPair_parts or not len(factorPair_parts) == 2: - continue - - site = url_quote_plus(factorPair_parts[0]) - factor = float(factorPair_parts[1]) - - crawler_sites.append(site) - - if site not in factors[crawler_class_name] and 0 < factor <= 10: - factors[crawler_class_name][site] = factor - - logger.info("found configured Crawler: %s = %s Factors: %s" % ( - crawler_class_name, repr(crawler_sites), repr(factors[crawler_class_name]))) - - if crawler_class == Reddit: - crawler_uris = {site: "https://www.reddit.com/r/%s" % - site for site in crawler_sites} - elif crawler_class == NineGag: - crawler_uris = {site: "https://9gag.com/%s" % - site for site in crawler_sites} - elif crawler_class == Pr0gramm: - crawler_uris = {crawler_sites[0] : "https://pr0gramm.com/api/items/get"} - elif crawler_class == SoupIO: - crawler_uris = {site: ("http://www.soup.io/%s" if site in ["everyone"] # public site - else "http://%s.soup.io") % site # user site - for site in crawler_sites} - elif crawler_class == Instagram: - crawler_uris = {site: "https://instagram.com/%s" % - site for site in crawler_sites} - elif crawler_class == Fourchan: - crawler_uris = {site: "https://boards.4chan.org/%s/" % - site for site in crawler_sites} - elif crawler_class == Giphy: - crawler_uris = { - site: "https://api.giphy.com/v1/gifs/search?q=%s" % site for site in crawler_sites} - - if crawler_class_name not in crawlers: - crawlers[crawler_class_name] = {} - - crawlers[crawler_class_name] = {site: crawler_class( - crawler_uris[site], site) for site in crawler_uris} - - return crawlers, factors - - -(sources, factors) = get_crawlers(config, "Sites") -if not sources: - raise Exception("no sources configured") -if factors: - Crawler.set_factors(factors) - - -# wrapper function for cache filling -def cache_fill_loop(): - global sources - while True: # fill cache up to min_cache_imgs per site - - info = Crawler.info() - for crawler in sources: - for site in sources[crawler]: - key = crawler + "_" + site - - if key not in info["images_per_site"] or info["images_per_site"][key] < min_cache_imgs_before_refill: - try: - sources[crawler][site].crawl() - info = Crawler.info() - except Exception as e: - logger.error("Error in crawler %s - %s: %s" % - (crawler, site, e)) - break - - # sleep for non-invasive threading ;) - time.sleep(1.337) - - -# return image data from map -def cache_get(): - return Crawler.get_image() - -# get status of cache - - -def cache_status_dict(): - info = Crawler.info() - return { - "crawler": Crawler.info(), - "factors": factors, - "min_cache_imgs_before_refill": min_cache_imgs_before_refill, - } - -# print status of cache - - -def cache_status_text(): - status = cache_status_dict() - info = status['crawler'] - - bar_reps = 5 - bar_repr_refill = status['min_cache_imgs_before_refill'] / bar_reps - - msg = "images cached: %d (%d bytes) - already crawled: %d (%d bytes)" % \ - (info["images"], info["images_size"], - info["blacklist"], info["blacklist_size"]) - logger.info(msg) - - for crawler in sources: - for site in sources[crawler]: - key = crawler + "_" + site - if key in info["images_per_site"]: - - factor = 1 - if crawler in factors and site in factors[crawler]: - factor = factors[crawler][site] - - count = info["images_per_site"][key] - - bar = "|" - for i in range(0, int(math.ceil(count / bar_reps))): - if i < bar_repr_refill: - bar += "#" - else: - bar += "*" - - sitestats = ("%15s - %-15s with factor %4.1f: %2d Images " + - bar) % (crawler, site, factor, count) - logger.info(sitestats) - msg += "\r\n" + sitestats - return msg - -# print imagelist - - -def show_imagelist(): - return "\n".join(Crawler.show_imagelist()) - - -# print blacklist -def show_blacklist(): - return "\n".join(Crawler.show_blacklist()) - - -# flush blacklist -def flush(): - global call_flush_last - time_since_last_call = time.time() - call_flush_last - if time_since_last_call >= call_flush_timeout: - Crawler.flush() - call_flush_last = time.time() - time_since_last_call = 0 - return "%i000" % (call_flush_timeout - time_since_last_call) - - -# reset the crawler -def reset(): - global call_reset_last - time_since_last_call = time.time() - call_reset_last - if time_since_last_call >= call_reset_timeout: - Crawler.reset() - call_reset_last = time.time() - time_since_last_call = 0 - return "%i000" % (call_reset_timeout - time_since_last_call) - - -# werkzeug webserver -# class with mapping to cache_* functions above -class NichtParasoup(object): - # init webserver with routing - def __init__(self): - self.url_map = Map([ - Rule(urlpath + '/', endpoint='root'), - Rule(urlpath + '/status', endpoint='cache_status'), - Rule(urlpath + '/get', endpoint='cache_get'), - Rule(urlpath + '/imagelist', endpoint='show_imagelist'), - Rule(urlpath + '/blacklist', endpoint='show_blacklist'), - Rule(urlpath + '/flush', endpoint='flush'), - Rule(urlpath + '/reset', endpoint='reset'), - ]) - - # proxy call to the wsgi_app - def __call__(self, environ, start_response): - return self.wsgi_app(environ, start_response) - - # calculate the request and use the defined map to route - def dispatch_request(self, request): - adapter = self.url_map.bind_to_environ(request.environ) - try: - endpoint, values = adapter.match() - return getattr(self, 'on_' + endpoint)(request, **values) - except HTTPException as e: - return e - - # the wsgi app itself - def wsgi_app(self, environ, start_response): - request = Request(environ) - response = self.dispatch_request(request) - return response(environ, start_response) - - # start page with js and scroll - def on_root(self, request): - return Response(tmpl.root, mimetype='text/html') - - # map function for print the status - def on_cache_status(self, request): - if request.values.get('t') == 'json': - return Response(json.dumps(cache_status_dict()), mimetype='application/json') - return Response(cache_status_text()) - - # map function for getting an image url - def on_cache_get(self, request): - return Response(json.dumps(cache_get()), mimetype='application/json') - - # map function for showing blacklist - def on_show_blacklist(self, request): - return Response(show_blacklist()) - - # map function for showing imagelist - def on_show_imagelist(self, request): - return Response(show_imagelist()) - - # map function for flushing (deleting everything in cache) - def on_flush(self, request): - return Response(flush()) - - # map function for resetting (deleting everything in cache and blacklist) - def on_reset(self, request): - return Response(reset()) - - -# runtime -# main function how to run -# on start-up, fill the cache and get up the webserver -if __name__ == "__main__": - try: - # start the cache filler tread - cache_fill_thread = threading.Thread(target=cache_fill_loop) - cache_fill_thread.daemon = True - cache_fill_thread.start() - except (KeyboardInterrupt, SystemExit): - # end the cache filler thread properly - min_cache_imgs_before_refill - 1 - - # give the cache_fill some time in advance - time.sleep(1.337) - - # start webserver after a bit of delay - run_simple(nps_bindip, nps_port, NichtParasoup(), use_debugger=False) diff --git a/nichtparasoup/.gitignore b/nichtparasoup/.gitignore new file mode 100644 index 00000000..f7085092 --- /dev/null +++ b/nichtparasoup/.gitignore @@ -0,0 +1,3 @@ + +# file generated by setuptools_scm +/__version__.py diff --git a/nichtparasoup/__init__.py b/nichtparasoup/__init__.py new file mode 100644 index 00000000..fa0d01aa --- /dev/null +++ b/nichtparasoup/__init__.py @@ -0,0 +1,7 @@ +__all__ = ["__version__"] + +try: + # file generated by setuptools_scm + from nichtparasoup.__version__ import __version__ # type: ignore +except ImportError: + __version__ = 'UNKNOWN' diff --git a/nichtparasoup/__main__.py b/nichtparasoup/__main__.py new file mode 100644 index 00000000..6d5a35cc --- /dev/null +++ b/nichtparasoup/__main__.py @@ -0,0 +1,6 @@ +from sys import exit + +from nichtparasoup.cmdline import main + +if __name__ == '__main__': + exit(main()) diff --git a/nichtparasoup/_internals/__init__.py b/nichtparasoup/_internals/__init__.py new file mode 100644 index 00000000..8cdc130e --- /dev/null +++ b/nichtparasoup/_internals/__init__.py @@ -0,0 +1,67 @@ +__all__ = ['_logger', '_log', '_message', '_message_exception', '_confirm'] +""" +yes, everything is underscored. +its internal foo that is not for public use. +""" + +import logging +import sys +from typing import Any, Optional, TextIO + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +try: + from termcolor import colored +except ImportError: + colored = None # type: ignore + +_logger = logging.getLogger('nichtparasoup') + +_LOG_LEVEL = Literal['debug', 'info', 'warning', 'error', 'critical', 'log', 'exception'] + + +def _log(level: _LOG_LEVEL, message: str, *args: Any, **kwargs: Any) -> None: + if not logging.root.handlers and _logger.level == logging.NOTSET: + _logger.setLevel(logging.INFO) + _logger.addHandler(logging.StreamHandler()) + getattr(_logger, level)(message.rstrip(), *args, **kwargs) + + +def _message(message: str, color: Optional[str] = None, file: Optional[TextIO] = None) -> None: + from sys import stdout + newline = '\r\n' + if not file: + file = stdout + if color and colored: + message = colored(message, color=color) + file.write('{}{}'.format(message.rstrip(), newline)) + + +def _message_exception(exception: BaseException, file: Optional[TextIO] = None) -> None: + if not file: + from sys import stderr + file = stderr + exception_name = type(exception).__name__ + if colored: + color = 'yellow' if isinstance(exception, Warning) else 'red' + exception_name = colored(exception_name, color) + _message('{}: {}'.format(exception_name, exception), file=file) + + +def _confirm(prompt: str, default: bool = False) -> Optional[bool]: + rv = { + 'y': True, + 'yes': True, + '': default, + 'n': False, + 'no': False, + } + options = 'Y/n' if default else 'y/N' + try: + value = input('{!s} [{}]: '.format(prompt, options)).lower().strip() + return rv[value] + except (KeyboardInterrupt, EOFError, KeyError): + return None diff --git a/nichtparasoup/cmdline/__init__.py b/nichtparasoup/cmdline/__init__.py new file mode 100644 index 00000000..2ba412f9 --- /dev/null +++ b/nichtparasoup/cmdline/__init__.py @@ -0,0 +1,133 @@ +from typing import Any, List, Optional + +from nichtparasoup._internals import _message, _message_exception + + +class Commands(object): + + @staticmethod + def run(config_file: Optional[str] = None) -> int: + import logging + from os.path import abspath + from nichtparasoup.config import get_config, get_imagecrawler + from nichtparasoup.core import NPCore + from nichtparasoup.core.server import Server as ImageServer + from nichtparasoup.webserver import WebServer + try: + config = get_config(abspath(config_file) if config_file else None) + logging.root.setLevel(getattr(logging, config['logging']['level'])) + logging.root.addHandler(logging.StreamHandler()) + imageserver = ImageServer(NPCore(), **config['imageserver']) + for crawler_config in config['crawlers']: + imagecrawler = get_imagecrawler(crawler_config) + if not imageserver.core.has_imagecrawler(imagecrawler): + imageserver.core.add_imagecrawler(imagecrawler, crawler_config['weight']) + webserver = WebServer(imageserver, **config['webserver']) + webserver.run() + return 0 + except Exception as e: + _message_exception(e) + return 1 + + @classmethod + def config(cls, action: str, config_file: str) -> int: + return dict( + check=cls.config_check_file, + dump=cls.config_dump_file, + )[action](config_file) + + @staticmethod + def config_dump_file(config_file: str) -> int: + from os.path import abspath, isfile + from nichtparasoup.config import dump_defaults + config_file = abspath(config_file) + if isfile(config_file): + from nichtparasoup._internals import _confirm + overwrite = _confirm('File already exists, overwrite?') + if overwrite is not True: + _message('Abort.') + return 1 + try: + dump_defaults(config_file) + return 0 + except Exception as e: + _message_exception(e) + return 255 + + @staticmethod + def config_check_file(config_file: str) -> int: + from os.path import abspath + from nichtparasoup.config import get_imagecrawler, parse_yaml_file + config_file = abspath(config_file) + try: + config = parse_yaml_file(config_file) + except Exception as e: + _message_exception(e) + return 1 + imagecrawlers = list() # type: List[Any] # actually list of BaseImageCrawler + for crawler_config in config['crawlers']: + imagecrawler = get_imagecrawler(crawler_config) + if imagecrawler in imagecrawlers: + _message_exception(Warning( + 'duplicate crawler of type {type.__name__!r}\r\n\twith config {config!r}' + .format(type=type(imagecrawler), config=imagecrawler.get_config()))) + continue + imagecrawlers.append(imagecrawler) + return 0 + + @classmethod + def info(cls, **actions: Any) -> int: + active_actions = dict((k, v) for k, v in actions.items() if v) + if len(active_actions) != 1: + _message_exception(ValueError('exactly one action required')) + return 255 + action, action_value = active_actions.popitem() + return dict( + version=cls.info_version, + imagecrawler_list=cls.info_imagecrawler_list, + imagecrawler_desc=cls.info_imagecrawler_desc, + )[action](action_value) + + @staticmethod + def info_version(_: Any) -> int: + from nichtparasoup import __version__ + _message(__version__) + return 0 + + @staticmethod + def info_imagecrawler_list(_: Any) -> int: + from nichtparasoup.imagecrawler import get_classes as get_imagegrawler_classes + imagecrawlers = list(get_imagegrawler_classes().keys()) + if not imagecrawlers: + _message_exception(Warning('no ImageCrawler found')) + else: + _message("\r\n".join(imagecrawlers)) + return 0 + + @staticmethod + def info_imagecrawler_desc(imagecrawler: str) -> int: + from nichtparasoup.imagecrawler import get_class as get_imagegrawler_class + imagecrawler_class = get_imagegrawler_class(imagecrawler) + if not imagecrawler_class: + _message_exception(ValueError('unknown ImageCrawler {!r}'.format(imagecrawler))) + return 1 + info = imagecrawler_class.info() + if info.config: + info_bull = '\r\n * ' + mlen = max(len(k) for k in info.config.keys()) + info_config = info_bull + info_bull.join([ + '{key:{mlen}}: {desc}'.format(mlen=mlen, key=key, desc=desc) + for key, desc in info.config.items()]) + else: + info_config = 'none' + _message('Purpose: {}\r\n' + 'Config : {}\r\n' + 'Version: {}'.format(info.desc, info_config, info.version)) + return 0 + + +def main(args: Optional[List[str]] = None) -> int: + from nichtparasoup.cmdline.argparse import parser as argparser + options = dict(argparser.parse_args(args=args).__dict__) + command = options.pop('command') + return getattr(Commands, command)(**options) # type: ignore diff --git a/nichtparasoup/cmdline/argparse.py b/nichtparasoup/cmdline/argparse.py new file mode 100644 index 00000000..ec525a99 --- /dev/null +++ b/nichtparasoup/cmdline/argparse.py @@ -0,0 +1,77 @@ +__all__ = ["parser"] + +from argparse import ArgumentParser + +parser = ArgumentParser( + add_help=True, + allow_abbrev=False, +) + +commands = parser.add_subparsers( + title='Commands', + metavar='', + dest='command', +) +commands.required = True + +command_run = commands.add_parser( + 'run', + help='run a server', + description='start a webserver to display random images', + add_help=True, + allow_abbrev=False, +) +command_run.add_argument( + '-c', '--use-config', + help='use a YAML config file instead of the defaults', + metavar='', + action='store', dest="config_file", type=str, +) + +command_config = commands.add_parser( + 'config', + description='Get config related things done', + help='config related functions', + add_help=True, + allow_abbrev=False, +) +command_config_actions = command_config.add_mutually_exclusive_group(required=True) +command_config_actions.add_argument( + '--check', + help='validate a YAML config file', + action='store_const', dest='action', const='check', +) +command_config_actions.add_argument( + '--dump', + help='dump YAML config into a file', + action='store_const', dest='action', const='dump', +) +command_config.add_argument( + metavar='', + action='store', dest='config_file', type=str, +) + +command_info = commands.add_parser( + 'info', + description='Get info for several topics', + help='get info for several topics', + add_help=True, + allow_abbrev=False, +) +command_info_actions = command_info.add_mutually_exclusive_group(required=True) +command_info_actions.add_argument( + '--imagecrawler-list', + help='list available image crawler types', + action='store_true', dest='imagecrawler_list', +) +command_info_actions.add_argument( + '--imagecrawler-desc', + help='describe an image crawler type and its config', + metavar='', + action='store', dest='imagecrawler_desc', type=str, +) +command_info_actions.add_argument( + '--version', + help="show program's version number", + action='store_true', dest='version', +) diff --git a/nichtparasoup/config/__init__.py b/nichtparasoup/config/__init__.py new file mode 100644 index 00000000..f5876450 --- /dev/null +++ b/nichtparasoup/config/__init__.py @@ -0,0 +1,62 @@ +__all__ = ["get_config", "get_defaults", "dump_defaults", "get_imagecrawler", "parse_yaml_file"] + +from os.path import dirname, join as path_join +from typing import Any, Dict, Optional + +from nichtparasoup.core.imagecrawler import BaseImageCrawler + +_schema_file = path_join(dirname(__file__), "schema.yaml") +_schema = None # type: Optional[Any] + +_defaults_file = path_join(dirname(__file__), "defaults.yaml") +_defaults = None # type: Optional[Dict[str, Any]] + + +def get_imagecrawler(config_crawler: Dict[str, Any]) -> BaseImageCrawler: + from nichtparasoup.imagecrawler import get_class as get_crawler_class + imagecrawler_class = get_crawler_class(config_crawler['type']) + if not imagecrawler_class: + raise ValueError('unknown crawler type {!r}'.format(config_crawler['type'])) + try: + imagecrawler_obj = imagecrawler_class(**config_crawler['config']) + except Exception as e: + raise Exception('failed setup of {type!r} with config {config!r}'.format( + type=config_crawler['type'], config=config_crawler['config'])) from e + return imagecrawler_obj + + +def parse_yaml_file(file_path: str) -> Dict[str, Any]: + import yamale # type: ignore + global _schema + if not _schema: + _schema = yamale.make_schema(_schema_file, parser='ruamel') + _data = yamale.make_data(file_path, parser='ruamel') + config = yamale.validate(_schema, _data, strict=True)[0][0] # type: Dict[str, Any] + config.setdefault('logging', dict()) + config['logging'].setdefault('level', 'INFO') + for config_crawler in config['crawlers']: + config_crawler.setdefault("weight", 1) + config_crawler.setdefault('config', dict()) + return config + + +def dump_defaults(file_path: str) -> None: + from shutil import copyfile + copyfile(_defaults_file, file_path) + + +def get_defaults() -> Dict[str, Any]: + global _defaults + if not _defaults: + _defaults = parse_yaml_file(_defaults_file) + from copy import deepcopy + return deepcopy(_defaults) + + +def get_config(config_file: Optional[str] = None) -> Dict[str, Any]: + if not config_file: + return get_defaults() + try: + return parse_yaml_file(config_file) + except Exception as e: + raise ValueError('invalid config file {!r}'.format(config_file)) from e diff --git a/nichtparasoup/config/defaults.yaml b/nichtparasoup/config/defaults.yaml new file mode 100644 index 00000000..18a1a415 --- /dev/null +++ b/nichtparasoup/config/defaults.yaml @@ -0,0 +1,62 @@ +## This is a config file for nichtprasoup (v2.x) + + +## WebServer config +## type: map +webserver: + ## hostname the WebServer recognizes. can also be a unix socket + ## type: string + hostname: "0.0.0.0" + ## port the webserver runs on + ## type: integer + ## constraint: 1 <= port <= 65535 + port: 5000 + +## ImageServer config +## type: map +imageserver: + ## number of images the server must keep at all time + ## type: integer + ## constraint: >= 10 + crawler_upkeep: 30 + +## list of ImageCrawlers to use. +## ATTENTION: crawlers are treated like a unique list. the combination of type and config makes them unique +## for a list of available types see the commandline help: nichtparasoup info --imagecrawler-list +## for description of a crawler and how to configure, see commandline help: nichtparasoup info --imagecrawler-desc +## type: list +crawlers: + - + ## name of the crawler + ## for a list of available types see the commandline help: nichtparasoup info --imagecrawler-list + ## type: string + type: "Reddit" + ## probability to be chosen randomly + ## type: integer or float + ## constraint: > 0 + ## optional + ## default: 1 + weight: 3 + ## the crawler's own config + ## for description of a crawler and how to configure, see commandline help: nichtparasoup info --imagecrawler-desc + ## type: map + ## optional + config: + subreddit: 'EarthPorn' + - + type: "Picsum" + weight: 1 + config: + width: 300 + height: 600 + + +## logging settings +## type: map +## optional +logging: + ## log level settings + ## type: enum('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG') + ## optional + ## default: 'INFO' + level: 'INFO' diff --git a/nichtparasoup/config/schema.yaml b/nichtparasoup/config/schema.yaml new file mode 100644 index 00000000..099329d9 --- /dev/null +++ b/nichtparasoup/config/schema.yaml @@ -0,0 +1,23 @@ +## schema for yamale - see https://github.com/23andMe/Yamale + +webserver: include('Webserver', strict=True) +imageserver: include('ImageServer', strict=True) +crawlers: list(include('Crawler', strict=True), min=1) +logging: include('Logging', strict=True, required=False, none=False) + + +--- +Logging: + level: enum('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', required=False, none=False) +--- +Webserver: + hostname: str(min=1) + port: int(min=1, max=65535) +--- +ImageServer: + crawler_upkeep: int(min=10) +--- +Crawler: + type: str(min=1) + weight: num(min=0.0001, required=False, none=False) + config: map(required=False, none=False) diff --git a/nichtparasoup/core/__init__.py b/nichtparasoup/core/__init__.py new file mode 100644 index 00000000..23911fb4 --- /dev/null +++ b/nichtparasoup/core/__init__.py @@ -0,0 +1,172 @@ +__all__ = ["Crawler", "CrawlerCollection", "NPCore"] + +from random import choice as random_choice, uniform as random_float +from threading import Thread +from types import MethodType +from typing import Callable, List, Optional, Set, Union +from weakref import ReferenceType, WeakMethod + +from nichtparasoup.core.image import Image, ImageCollection, ImageUri +from nichtparasoup.core.imagecrawler import BaseImageCrawler + +_CrawlerWeight = Union[int, float] # constraint: > 0 + + +class _Blacklist(Set[ImageUri]): + pass + + +_IsImageAddable = Callable[[Image], bool] + +_OnImageAdded = Callable[[Image], None] + +_OnFill = Callable[["Crawler", int], None] + +_FILLUP_TIMEOUT_DEFAULT = 1.0 + + +class Crawler(object): + + def __init__(self, imagecrawler: BaseImageCrawler, weight: _CrawlerWeight, + is_image_addable: Optional[_IsImageAddable] = None, + on_image_added: Optional[_OnImageAdded] = None) -> None: # pragma: no cover + if weight <= 0: + raise ValueError('weight <= 0') + self.imagecrawler = imagecrawler + self.weight = weight + self.images = ImageCollection() + self._is_image_addable_wr = None # type: Optional[ReferenceType[_IsImageAddable]] + self._image_added_wr = None # type: Optional[ReferenceType[_OnImageAdded]] + self.set_is_image_addable(is_image_addable) + self.set_image_added(on_image_added) + + def set_is_image_addable(self, is_image_addable: Optional[_IsImageAddable]) -> None: + t_is_image_addable = type(is_image_addable) + if None is is_image_addable: + self._is_image_addable_wr = None + elif MethodType is t_is_image_addable: + self._is_image_addable_wr = WeakMethod(is_image_addable) # type: ignore + else: + raise Exception('type {} not supported, yet'.format(t_is_image_addable)) + # TODO: add function and other types - and write proper tests for it + + def get_is_image_addable(self) -> Optional[_IsImageAddable]: + return self._is_image_addable_wr() if self._is_image_addable_wr else None + + def set_image_added(self, image_added: Optional[_OnImageAdded]) -> None: + t_image_added = type(image_added) + if None is image_added: + self._image_added_wr = None + elif MethodType is t_image_added: + self._image_added_wr = WeakMethod(image_added) # type: ignore + else: + raise Exception('type {} not supported, yet'.format(t_image_added)) + # TODO: add function and other types - and write proper tests for it + + def get_image_added(self) -> Optional[_OnImageAdded]: + return self._image_added_wr() if self._image_added_wr else None + + def reset(self) -> None: + self.images.clear() + self.imagecrawler.reset() + + def crawl(self) -> int: + is_image_addable = self.get_is_image_addable() + image_added = self.get_image_added() + images_crawled = self.imagecrawler.crawl() + for image_crawled in images_crawled: + addable = is_image_addable(image_crawled) if is_image_addable else True + if not addable: + continue # for + self.images.add(image_crawled) + if image_added: + image_added(image_crawled) + return len(images_crawled) + + def fill_up_to(self, to: int, + filled_by: Optional[_OnFill] = None, + timeout: float = _FILLUP_TIMEOUT_DEFAULT) -> None: + from time import sleep + while len(self.images) < to: + refilled = self.crawl() + if filled_by: + filled_by(self, refilled) + if 0 == refilled: + break # while + if len(self.images) < to and timeout > 0: + # be nice, give the site some rest after crawling + sleep(timeout) + + def get_random_image(self) -> Optional[Image]: + if not self.images: + return None + image = random_choice(list(self.images)) + return image + + def pop_random_image(self) -> Optional[Image]: + image = self.get_random_image() + if image: + self.images.discard(image) + return image + + +class CrawlerCollection(List[Crawler]): + + def _random_weight(self) -> _CrawlerWeight: + return random_float(0, sum(crawler.weight for crawler in self)) + + def get_random(self) -> Optional[Crawler]: + cum_weight_goal = self._random_weight() + # IDEA: cum_weight_goal == 0 is an edge case and could be handled if needed ... + cum_weight = 0 # type: _CrawlerWeight + for crawler in self: + cum_weight += crawler.weight + if cum_weight >= cum_weight_goal: + return crawler + return None + + +class NPCore(object): + + def __init__(self) -> None: # pragma: no cover + self.crawlers = CrawlerCollection() + self.blacklist = _Blacklist() + + def _is_image_not_in_blacklist(self, image: Image) -> bool: + # must be compatible to: _IsImageAddable + return image.uri not in self.blacklist + + def _add_image_to_blacklist(self, image: Image) -> None: + # must be compatible to: _OnImageAdded + if not image.is_generic: + self.blacklist.add(image.uri) + + def has_imagecrawler(self, imagecrawler: BaseImageCrawler) -> bool: + return imagecrawler in [crawler.imagecrawler for crawler in self.crawlers] + + def add_imagecrawler(self, imagecrawler: BaseImageCrawler, weight: _CrawlerWeight) -> None: + self.crawlers.append(Crawler( + imagecrawler, weight, + self._is_image_not_in_blacklist, self._add_image_to_blacklist + )) + + def fill_up_to(self, to: int, on_refill: Optional[_OnFill], timeout: float = _FILLUP_TIMEOUT_DEFAULT) -> None: + fill_treads = list() # type: List[Thread] + for crawler in self.crawlers: + fill_tread = Thread(target=crawler.fill_up_to, args=(to, on_refill, timeout), daemon=True) + fill_treads.append(fill_tread) + fill_tread.start() + for fill_tread in fill_treads: + fill_tread.join() + + def reset(self) -> int: + reset_treads = list() # type: List[Thread] + for crawler in self.crawlers.copy(): + reset_tread = Thread(target=crawler.reset, daemon=True) + reset_treads.append(reset_tread) + reset_tread.start() + blacklist_len = len(self.blacklist) + self.blacklist.clear() + for reset_tread in reset_treads: + reset_tread.join() + return blacklist_len diff --git a/nichtparasoup/core/image.py b/nichtparasoup/core/image.py new file mode 100644 index 00000000..c98a7949 --- /dev/null +++ b/nichtparasoup/core/image.py @@ -0,0 +1,73 @@ +__all__ = ["Image", "ImageCollection"] + +from typing import Any, Set +from uuid import uuid4 + +ImageUri = str + +SourceUri = str + + +class Image(object): + """Describe an image + + `uri` + The absolute URI of the image. This basically identifies the Image and makes it unique. + + This absolute URI must include: ``scheme``, ``host``. + ``schema`` must be either 'http' or 'https' - the last one is preferred. + Optional are: ``port``, ``path``, ``query``, ``fragment``. + + `source` + The URI where did the image is originally found? + This URI can point to a ImageBoardThread, or a comment section in a Forum, or a news article... + + In the idea of fair use, it is encouraged to point to the source as good as possible. + + This absolute URI must include: ``scheme``, ``host``. + ``schema`` must be either 'http' or 'https' - the last one is preferred. + Optional are: ``port``, ``path``, ``query``, ``fragment``. + + Good examples are: + * https://www.reddit.com/r/Awww/comments/e1er0c/say_hi_to_loki_hes_just_contemplating/ + * https://giphy.com/gifs/10kABVanhwykJW + + `is_generic` + If a generic image crawler is used, its common that each image URI looks exactly the same. + To make this known, use this flag. + + `more` + A dictionary of additional information an image crawler might want to deliver. + + This dictionary's data types are intended to the basic ones: string, int, float, list, set, dict, bool, None + + Good examples are: + * image-dimensions + * author, copyright information + * valid-until + + """ + + def __init__(self, uri: ImageUri, source: SourceUri, + is_generic: bool = False, + **more: Any) -> None: # pragma: no cover + self.uri = uri + self.source = source + self.more = more + self.is_generic = is_generic + self.__hash = hash(uuid4()) if self.is_generic else hash(self.uri) + + def __hash__(self) -> int: + return self.__hash + + def __eq__(self, other: Any) -> bool: + if type(other) is type(self): + return hash(self) == hash(other) + return False + + def __repr__(self) -> str: + return '<{0.__module__}.{0.__name__} object at {1:#x} {2.uri!r}>'.format(type(self), id(self), self) + + +class ImageCollection(Set[Image]): + pass diff --git a/nichtparasoup/core/imagecrawler.py b/nichtparasoup/core/imagecrawler.py new file mode 100644 index 00000000..8ddfb793 --- /dev/null +++ b/nichtparasoup/core/imagecrawler.py @@ -0,0 +1,173 @@ +__all__ = ["ImageCrawlerConfig", "BaseImageCrawler", "ImageCrawlerInfo", "RemoteFetcher", "ImageRecognizer"] + +from abc import ABC, abstractmethod +from http.client import HTTPResponse +from re import IGNORECASE as RE_IGNORECASE, compile as re_compile +from threading import Lock +from typing import Any, Dict, Optional, Pattern, Tuple +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +from nichtparasoup._internals import _log +from nichtparasoup.core.image import ImageCollection + +_ImageCrawlerConfigKey = str + + +class ImageCrawlerInfo(object): + + def __init__(self, desc: str, config: Dict[_ImageCrawlerConfigKey, str], version: str) -> None: # pragma: no cover + self.desc = desc + self.config = config + self.version = version + + +class ImageCrawlerConfig(Dict[_ImageCrawlerConfigKey, Any]): + pass + + +class BaseImageCrawler(ABC): + + def __init__(self, **config: Any) -> None: # pragma: no cover + self._config = self.check_config(config) # intended to be immutable from now on + self._reset_before_next_crawl = True + self._crawl_lock = Lock() + _log('debug', 'crawler initialized: {!r}'.format(self)) + + def __repr__(self) -> str: + return '<{0.__module__}.{0.__name__} {1!r}>'.format(type(self), self.get_config()) + + def __eq__(self, other: Any) -> bool: + if type(self) is type(other): + other_imagecrawler = other # type: BaseImageCrawler + return self._config == other_imagecrawler._config + return False + + def get_config(self) -> ImageCrawlerConfig: + """ + Get all *public* information from the config + + For internal access to the config using `self._config` is encouraged + """ + return ImageCrawlerConfig({k: v for (k, v) in self._config.items() if not k.startswith('_')}) + + def reset(self) -> None: + self._reset_before_next_crawl = True + _log('debug', 'crawler reset planned for {!r}'.format(self)) + + def crawl(self) -> ImageCollection: # pragma: no cover + with self._crawl_lock: + try: + if self._reset_before_next_crawl: + _log('debug', 'crawler resetting {!r}'.format(self)) + self._reset() + self._reset_before_next_crawl = False + _log('debug', 'crawling started {!r}'.format(self)) + crawled = self._crawl() + _log('debug', 'crawling finished {!r}'.format(self)) + return crawled + except Exception: + _log('exception', 'caught an error during crawling {!r}'.format(self)) + return ImageCollection() + + @classmethod + @abstractmethod + def info(cls) -> ImageCrawlerInfo: # pragma: no cover + """ + Get info of the crawler + + example implementation: + return ImageCrawlerInfo( + desc="Some textual description about what this ImageCrawler does.", + config=dict( + # leave the dict empty, if there is nothing to configure + param1="meaning of param1", + paramN="meaning of paramN", + ), + version='0.0.dev1', + ) + """ + raise NotImplementedError() + + @classmethod + @abstractmethod + def check_config(cls, config: Dict[Any, Any]) -> ImageCrawlerConfig: # pragma: no cover + """ + This function is intended to check if a config is valid and to strip unused config. + + When implementing: + Check if any config is viable. if not raise ValueError or TypeError or KeyError + or whatever Error. + Return the viable config for this crawler instance. + + Example implementation: + height = config["height"] # will raise KeyError automatically + if type(height) is not int: + raise TypeError("height {} is not int".format(height)) + if height <= 0: + raise ValueError("height {} <= 0".format(width)) + """ + raise NotImplementedError() + + @abstractmethod + def _reset(self) -> None: # pragma: no cover + """ + This function is intended to reset the crawler to restart at front + """ + raise NotImplementedError() + + @abstractmethod + def _crawl(self) -> ImageCollection: # pragma: no cover + """ + This function is intended to find and fetch ImageURIs + """ + raise NotImplementedError() + + +class RemoteFetcher(object): + + _HEADERS_DEFAULT = { + 'User-Agent': 'NichtParasoup', + } + + def __init__(self, timeout: float = 10.0, headers: Optional[Dict[str, str]] = None) -> None: # pragma: no cover + self._timeout = timeout + self._headers = self.__class__._HEADERS_DEFAULT.copy() + if headers: + self._headers.update(headers) + + @staticmethod + def _valid_uri(uri: str) -> bool: + (scheme, _, _, _, _, _) = urlparse(uri) + return scheme in {'http', 'https'} + + def get_stream(self, uri: str) -> Tuple[HTTPResponse, str]: + if not self._valid_uri(uri): + raise ValueError('not remote: ' + uri) + _log('debug', 'fetch remote {!r} in {}s with {!r}'.format( + uri, self._timeout, self._headers)) + request = Request(uri, headers=self._headers) + try: + response = urlopen(request, timeout=self._timeout) # type: HTTPResponse + except BaseException as e: + _log('debug', 'caught error on fetch remote {!r}'.format(uri), exc_info=True) + raise e + actual_uri = response.geturl() # after following redirects ... + return response, actual_uri + + def get_bytes(self, uri: str) -> Tuple[bytes, str]: + response, actual_uri = self.get_stream(uri) + return response.read(), actual_uri + + def get_string(self, uri: str, charset_fallback: str = 'UTF-8') -> Tuple[str, str]: + response, actual_uri = self.get_stream(uri) + charset = str(response.info().get_param('charset', charset_fallback)) + return response.read().decode(charset), actual_uri + + +class ImageRecognizer(object): + + _PATH_RE = re_compile(r'.+\.(?:jpeg|jpg|png|gif|svg)(?:[?#].*)?$', flags=RE_IGNORECASE) # type: Pattern[str] + + def path_is_image(self, uri: str) -> bool: + return self._PATH_RE.match(uri) is not None diff --git a/nichtparasoup/core/server.py b/nichtparasoup/core/server.py new file mode 100644 index 00000000..81d8682e --- /dev/null +++ b/nichtparasoup/core/server.py @@ -0,0 +1,226 @@ +__all__ = ["Server", "ServerStatistics", "ServerStatus", "ServerRefiller"] + +from abc import ABC +from copy import copy +from sys import getsizeof +from threading import Lock, Thread +from time import time +from typing import Any, Dict, Optional, Union +from weakref import ref as weak_ref + +from nichtparasoup import __version__ +from nichtparasoup._internals import _log +from nichtparasoup.core import Crawler, NPCore + + +class Server(object): + """ + this class is intended to be thread save. + this class intended to be a stable interface. + its public methods return base types only. + """ + + def __init__(self, core: NPCore, crawler_upkeep: int = 30, + reset_timeout: int = 60 * 60) -> None: # pragma: no cover + self.core = core + self.keep = crawler_upkeep + self.reset_timeout = reset_timeout + self._stats = ServerStatistics() + self._refiller = None # type: Optional[ServerRefiller] + self._trigger_reset = False + self._locks = _ServerLocks() + self.__running = False + + def get_image(self) -> Optional[Dict[str, Any]]: + crawler = self.core.crawlers.get_random() + if not crawler: + return None + image = copy(crawler.pop_random_image()) + if not image: + return None + with self._locks.stats_get_image: + self._stats.count_images_served += 1 + return dict( + uri=image.uri, + is_generic=image.is_generic, + source=image.source, + more=image.more, + crawler=dict( + id=id(crawler), + type=type(crawler.imagecrawler).__name__, + ), + ) + + @staticmethod + def _log_refill_crawler(crawler: Crawler, refilled: int) -> None: + # must be compatible to nichtparasoup.core._OnFill + if refilled > 0: + _log('info', "refilled by {} via {!r}".format(refilled, crawler.imagecrawler)) + + def refill(self) -> Dict[str, bool]: + with self._locks.refill: + self.core.fill_up_to(self.keep, self._log_refill_crawler) + return dict(refilled=True) + + def _reset(self) -> None: + with self._locks.reset: + self._stats.cum_blacklist_on_flush += self.core.reset() + self._stats.count_reset += 1 + self._stats.time_last_reset = int(time()) + + def request_reset(self) -> Dict[str, Any]: + if not self.is_alive(): + request_valid = True + timeout = 0 + else: + now = int(time()) + time_started = self._stats.time_started or now + timeout_base = self.reset_timeout + time_last_reset = self._stats.time_last_reset + reset_after = timeout_base + (time_last_reset or time_started) + request_valid = now > reset_after + timeout = timeout_base if request_valid else (reset_after - now) + if request_valid: + self._reset() + return dict( + requested=request_valid, + timeout=timeout, + ) + + def start(self) -> None: + with self._locks.run: + if self.__running: + raise RuntimeError('already running') + _log('info', " * starting {}".format(type(self).__name__)) + _log('info', ' * fill all crawlers up to {}'.format(self.keep)) + self.refill() # initial fill + if not self._refiller: + self._refiller = ServerRefiller(self, 1) + self._refiller.start() # start threaded periodical refill + self._stats.time_started = int(time()) + self.__running = True + + def is_alive(self) -> bool: + return self.__running + + def stop(self) -> None: + with self._locks.run: + if not self.__running: + raise RuntimeError('not running') + _log('info', "\r\n * stopping {}".format(type(self).__name__)) + if self._refiller: + self._refiller.stop() + self._refiller = None + self.__running = False + + +class ServerStatus(ABC): + """ + this class intended to be a stable interface. + all methods are like this: Callable[[Server], Union[List[SomeBaseType], Dict[str, SomeBaseType]]] + all methods must be associated with stat(u)s! + """ + + @staticmethod + def server(server: Server) -> Dict[str, Any]: + stats = copy(server._stats) + now = int(time()) + uptime = (now - stats.time_started) if server.is_alive() and stats.time_started else 0 + return dict( + version=__version__, + uptime=uptime, + reset=dict( + count=stats.count_reset, + since=(now - stats.time_last_reset) if stats.time_last_reset else uptime, + ), + images=dict( + served=stats.count_images_served, + crawled=stats.cum_blacklist_on_flush + len(server.core.blacklist), + ), + ) + + @staticmethod + def blacklist(server: Server) -> Dict[str, Any]: + blacklist = server.core.blacklist.copy() + return dict( + len=len(blacklist), + size=getsizeof(blacklist), + ) + + @staticmethod + def crawlers(server: Server) -> Dict[int, Dict[str, Any]]: + status = dict() + for crawler in server.core.crawlers.copy(): + crawler_id = id(crawler) + crawler = copy(crawler) + images = crawler.images.copy() + status[crawler_id] = dict( + type=type(crawler.imagecrawler).__name__, + weight=crawler.weight, + config=crawler.imagecrawler.get_config(), # just a dict + images=dict( + len=len(images), + size=getsizeof(images), + ), + ) + return status + + +class ServerRefiller(Thread): + def __init__(self, server: Server, sleep: Union[int, float]) -> None: # pragma: no cover + from threading import Event + super().__init__(daemon=True) + self._server_wr = weak_ref(server) + self._sleep = sleep + self._stop_event = Event() + self._run_lock = Lock() + + def run(self) -> None: + from time import sleep + while not self._stop_event.is_set(): + server = self._server_wr() # type: Optional[Server] + if server: + server.refill() + else: + _log('info', " * server gone. stopping {}".format(type(self).__name__)) + self._stop_event.set() + if not self._stop_event.is_set(): + sleep(self._sleep) + + def start(self) -> None: + self._run_lock.acquire() + try: + if self.is_alive(): + raise RuntimeError('already running') + _log('info', " * starting {}".format(type(self).__name__)) + self._stop_event.clear() + super().start() + finally: + self._run_lock.release() + + def stop(self) -> None: + self._run_lock.acquire() + try: + if not self.is_alive(): + raise RuntimeError('not running') + _log('info', " * stopping {}".format(type(self).__name__)) + self._stop_event.set() + finally: + self._run_lock.release() + + +class ServerStatistics(object): + def __init__(self) -> None: # pragma: no cover + self.time_started = None # type: Optional[int] + self.count_images_served = 0 # type: int + self.count_reset = 0 # type: int + self.time_last_reset = None # type: Optional[int] + self.cum_blacklist_on_flush = 0 # type: int + + +class _ServerLocks(object): + def __init__(self) -> None: # pragma: no cover + self.stats_get_image = Lock() + self.reset = Lock() + self.refill = Lock() + self.run = Lock() diff --git a/nichtparasoup/imagecrawler/__init__.py b/nichtparasoup/imagecrawler/__init__.py new file mode 100644 index 00000000..bfefd7a7 --- /dev/null +++ b/nichtparasoup/imagecrawler/__init__.py @@ -0,0 +1,68 @@ +__all__ = ["get_class", "get_classes"] + +from typing import Dict, Optional, Type + +from nichtparasoup.core.imagecrawler import BaseImageCrawler + +from .dummy import Dummy +from .picsum import Picsum +from .reddit import Reddit + +_imagecrawlers = dict( + Dummy=Dummy, + Picsum=Picsum, + Reddit=Reddit, +) + + +def get_classes() -> Dict[str, Type[BaseImageCrawler]]: + return _imagecrawlers.copy() + + +def get_class(class_name: str) -> Optional[Type[BaseImageCrawler]]: + return _imagecrawlers.get(class_name) + +# TODO: write the whole thing testable. no write it as a util or use lib. or just write crawlers plugable +# +# import pkgutil +# from importlib import import_module +# from os.path import dirname +# from types import ModuleType +# from typing import Dict, Optional, Type +# +# from nichtparasoup.core.imagecrawler import BaseImageCrawler +# +# +# def _get_subclasses_from_module(module_name: str, baseclass: type) -> Dict[str, type]: +# subclasses = dict() +# module = import_module('.{}'.format(module_name), __name__) # type: ModuleType +# if hasattr(module, '__all__'): +# index = module.__all__ # type: ignore +# else: +# index = [attrib for attrib in dir(module) if '_' != attrib[:1]] +# for imported_name in index: +# imported_var = getattr(module, imported_name) +# if issubclass(imported_var, baseclass): +# subclasses[imported_name] = imported_var +# return subclasses +# +# +# def _get_subclasses_from_package(package_path: str, baseclass: type) -> Dict[str, type]: +# subclasses = dict() # type: Dict[str, type] +# for (_, module_name, _) in pkgutil.iter_modules([package_path]): +# module_subclasses = _get_subclasses_from_module(module_name, baseclass) +# for (module_subclass_name, module_subclass) in module_subclasses.items(): +# if subclasses.get(module_subclass_name): +# raise Exception('duplicate subclass defined: {}'.format(module_subclass_name)) +# subclasses[module_subclass_name] = module_subclass +# return subclasses +# +# +# _imagecrawlers_builtin = None # type: Optional[Dict[str, type]] +# +# +# def get_class(class_name: str) -> Optional[Type[BaseImageCrawler]]: +# global _imagecrawlers_builtin +# if not _imagecrawlers_builtin: +# _imagecrawlers_builtin = _get_subclasses_from_package(dirname(__file__), BaseImageCrawler) +# return _imagecrawlers_builtin.get(class_name) diff --git a/nichtparasoup/imagecrawler/dummy.py b/nichtparasoup/imagecrawler/dummy.py new file mode 100644 index 00000000..e39b8d27 --- /dev/null +++ b/nichtparasoup/imagecrawler/dummy.py @@ -0,0 +1,44 @@ +__all__ = ["Dummy"] + +from typing import Any, Dict + +from nichtparasoup.core.image import Image, ImageCollection +from nichtparasoup.core.imagecrawler import BaseImageCrawler, ImageCrawlerConfig, ImageCrawlerInfo + + +class Dummy(BaseImageCrawler): + + @classmethod + def info(cls) -> ImageCrawlerInfo: + from nichtparasoup import __version__ + return ImageCrawlerInfo( + desc='"Finds" the same image ... again ... and again.', + config=dict( + image_uri='the URI of the image to "find"', + ), + version=__version__, + ) + + @classmethod + def check_config(cls, config: Dict[Any, Any]) -> ImageCrawlerConfig: + image_uri = config["image_uri"] + if type(image_uri) is not str: + raise TypeError("image_uri {!r} is not str".format(image_uri)) + if 0 == len(image_uri): + raise ValueError("image_uri {!r} is empty".format(image_uri)) + return ImageCrawlerConfig( + image_uri=image_uri, + ) + + def _reset(self) -> None: # pragma: no cover + pass + + def _crawl(self) -> ImageCollection: + images = ImageCollection() + config = self.get_config() + images.add(Image( + config["image_uri"], config["image_uri"], + is_generic=True, + this_is_a_dummy=True, + )) + return images diff --git a/nichtparasoup/imagecrawler/picsum.py b/nichtparasoup/imagecrawler/picsum.py new file mode 100644 index 00000000..6951d852 --- /dev/null +++ b/nichtparasoup/imagecrawler/picsum.py @@ -0,0 +1,57 @@ +from typing import Any, Dict + +from nichtparasoup.core.image import Image, ImageCollection +from nichtparasoup.core.imagecrawler import BaseImageCrawler, ImageCrawlerConfig, ImageCrawlerInfo + +__all__ = ["Picsum"] + + +class Picsum(BaseImageCrawler): + _bunch = 10 + + @classmethod + def info(cls) -> ImageCrawlerInfo: + from nichtparasoup import __version__ + return ImageCrawlerInfo( + desc='Find images from https://picsum.photos', + config=dict( + width='how many pixels of the image to find should be wide', + height='how many pixels of the image to find should be high', + ), + version=__version__, + ) + + @classmethod + def check_config(cls, config: Dict[Any, Any]) -> ImageCrawlerConfig: + width = config["width"] + height = config["height"] + if type(width) is not int: + raise TypeError("width {!r} is not int".format(width)) + if type(height) is not int: + raise TypeError("height {!r} is not int".format(height)) + if width <= 0: + raise ValueError("width {!r} <= 0".format(width)) + if height <= 0: + raise ValueError("height {!r} <= 0".format(height)) + return ImageCrawlerConfig( + width=width, + height=height, + ) + + @staticmethod + def _get_image_uri(width: int, height: int) -> str: + return "https://picsum.photos/{}/{}".format(width, height) + + def _reset(self) -> None: # pragma: no cover + pass + + def _crawl(self) -> ImageCollection: + images = ImageCollection() + config = self.get_config() + for _ in range(0, self._bunch): + uri = self._get_image_uri(**config) + images.add(Image( + uri, uri, + is_generic=True, + )) + return images diff --git a/nichtparasoup/imagecrawler/reddit.py b/nichtparasoup/imagecrawler/reddit.py new file mode 100644 index 00000000..7e0d4a64 --- /dev/null +++ b/nichtparasoup/imagecrawler/reddit.py @@ -0,0 +1,70 @@ +__all__ = ["Reddit"] + +from json import loads as json_loads +from typing import Any, Dict, Optional +from urllib.parse import quote_plus as url_quote, urljoin + +from nichtparasoup.core.image import Image, ImageCollection +from nichtparasoup.core.imagecrawler import ( + BaseImageCrawler, ImageCrawlerConfig, ImageCrawlerInfo, ImageRecognizer, RemoteFetcher, +) + + +class Reddit(BaseImageCrawler): + + def __init__(self, **config: Any) -> None: # pragma: no cover + super().__init__(**config) + self._uri_base = 'https://www.reddit.com/r/{}.json?after='.format( + url_quote(self._config['subreddit'])) + self._after = None # type: Optional[str] + self._remote_fetcher = RemoteFetcher() + self._image_recognizer = ImageRecognizer() + + @classmethod + def info(cls) -> ImageCrawlerInfo: + from nichtparasoup import __version__ + return ImageCrawlerInfo( + desc='A Crawler for an arbitrary SubReddit of https://www.reddit.com/', + config=dict( + subreddit='the SubReddit to crawl', + ), + version=__version__, + ) + + @classmethod + def check_config(cls, config: Dict[Any, Any]) -> ImageCrawlerConfig: + subreddit = config["subreddit"] + if type(subreddit) is not str: + raise TypeError("subreddit {!r} is not str".format(subreddit)) + if 0 == len(subreddit): + raise ValueError("subreddit {!r} is empty".format(subreddit)) + return ImageCrawlerConfig( + subreddit=subreddit, + ) + + def _reset(self) -> None: + self._after = None + pass + + def _crawl(self) -> ImageCollection: + images = ImageCollection() + listing_string, uri = self._remote_fetcher.get_string(self._get_uri(self._after)) + listing = json_loads(listing_string) + del listing_string # free up some ram + for child in listing['data']['children']: + image = self._get_image(child['data']) + if image: + images.add(Image( + uri=image, + source=urljoin(uri, child['data']['permalink']), + )) + # don't care if `after` is `None` after the crawl ... why not restarting at front when the end is reached?! + self._after = listing['data']['after'] + return images + + def _get_uri(self, after: Optional[str]) -> str: + return self._uri_base + (url_quote(after) if after else '') + + def _get_image(self, data: Dict[str, Any]) -> Optional[str]: + uri = data.get('url') # type: Optional[str] + return uri if uri and self._image_recognizer.path_is_image(uri) else None diff --git a/nichtparasoup/webserver/__init__.py b/nichtparasoup/webserver/__init__.py new file mode 100644 index 00000000..e2eb58fa --- /dev/null +++ b/nichtparasoup/webserver/__init__.py @@ -0,0 +1,101 @@ +__all__ = ["WebServer"] + +from json import dumps as json_encode +from os.path import dirname, join as path_join +from typing import Any, Dict, Union + +from werkzeug.exceptions import HTTPException, NotFound +from werkzeug.routing import Map, Rule +from werkzeug.utils import redirect +from werkzeug.wrappers import Request, Response + +from nichtparasoup.core.server import Server, ServerStatus + + +class WebServer(object): + _STATIC_FILES = path_join(dirname(__file__), 'htdocs') + _STATIC_INDEX = 'index.html' # relative to cls._STATIC_FILES + + def __init__(self, imageserver: Server, hostname: str, port: int) -> None: # pragma: no cover + self.imageserver = imageserver + self.hostname = hostname + self.port = port + self.url_map = Map([ + Rule('/', endpoint='root'), + Rule('/get', endpoint='get'), + Rule('/status', endpoint='status'), + Rule('/status/', endpoint='status_what'), + Rule('/reset', endpoint='reset'), + ]) + + def __call__(self, environ: Dict[str, Any], start_response: Any) -> Any: + return self.wsgi_app(environ, start_response) + + def dispatch_request(self, request: Request) -> Union[Response, HTTPException]: + adapter = self.url_map.bind_to_environ(request.environ) + try: + endpoint, values = adapter.match() + response = getattr(self, 'on_{}'.format(endpoint))(request, **values) # type: Response + return response + except HTTPException as e: + return e + + def wsgi_app(self, environ: Dict[str, Any], start_response: Any) -> Any: + request = Request(environ) + response = self.dispatch_request(request) + if isinstance(response, Response): + response.cache_control.no_cache = True + response.cache_control.no_store = True + return response(environ, start_response) + + def on_root(self, _: Request) -> Response: + # relative-path is valid via https://tools.ietf.org/html/rfc3986#section-4.2 + forward = redirect(self._STATIC_INDEX, code=302, Response=Response) + # to prevent extensive (reverse proxy) header parsing, it is kept as a relative-path + forward.autocorrect_location_header = False + return forward + + def on_get(self, _: Request) -> Response: + image = self.imageserver.get_image() + return Response(json_encode(image), mimetype='application/json') + + _STATUS_WHATS = dict( + server=ServerStatus.server, + blacklist=ServerStatus.blacklist, + crawlers=ServerStatus.crawlers, + ) + + def on_status(self, _: Request) -> Response: + status = {what: getter(self.imageserver) for what, getter in self._STATUS_WHATS.items()} + return Response(json_encode(status), mimetype='application/json') + + def on_status_what(self, _: Request, what: str) -> Response: + status_what = self._STATUS_WHATS.get(what) + if not status_what: + raise NotFound() + status = status_what(self.imageserver) + return Response(json_encode(status), mimetype='application/json') + + def on_reset(self, _: Request) -> Response: + reset = self.imageserver.request_reset() + return Response(json_encode(reset), mimetype='application/json') + + def run(self) -> None: + from werkzeug.serving import run_simple + from nichtparasoup._internals import _log + self.imageserver.start() + try: + _log('info', ' * starting {0} bound to {1.hostname} on port {1.port}'.format(type(self).__name__, self)) + run_simple( + self.hostname, self.port, + application=self, + static_files={'/': self._STATIC_FILES}, + processes=1, threaded=True, + use_reloader=False, + use_debugger=False) + _log('info', ' * stopped {0} bound to {1.hostname} on port {1.port}'.format(type(self).__name__, self)) + except Exception as e: + _log('exception', ' * Error occurred. stopping everything') + raise e + finally: + self.imageserver.stop() diff --git a/nichtparasoup/webserver/htdocs/_features.html b/nichtparasoup/webserver/htdocs/_features.html new file mode 100644 index 00000000..b432ccd5 --- /dev/null +++ b/nichtparasoup/webserver/htdocs/_features.html @@ -0,0 +1,15 @@ +

FEATURE LIST

+ +features that work already: + diff --git a/nichtparasoup/webserver/htdocs/css/feel.css b/nichtparasoup/webserver/htdocs/css/feel.css new file mode 100644 index 00000000..12aec3d7 --- /dev/null +++ b/nichtparasoup/webserver/htdocs/css/feel.css @@ -0,0 +1,11 @@ + + +input[type="range"] { + position: relative; + top: 5px; + direction: rtl; +} + + + + diff --git a/templates_raw/root/css/look.css b/nichtparasoup/webserver/htdocs/css/look.css similarity index 84% rename from templates_raw/root/css/look.css rename to nichtparasoup/webserver/htdocs/css/look.css index b612160b..48f0375e 100644 --- a/templates_raw/root/css/look.css +++ b/nichtparasoup/webserver/htdocs/css/look.css @@ -1,4 +1,4 @@ -/* remember for development: lines that include the string "@striponbuild" will be stripped on build ;-) */ + * { cursor: default; } a { cursor: pointer; } @@ -31,7 +31,7 @@ header { background-color: transparent; cursor: default; white-space: nowrap; - background-color: rgba(255, 255, 0, 0.5); /* @stripOnBuild */ + } header #burger { @@ -103,23 +103,23 @@ footer { color: #ccc; font-size: smaller; height: 4ex; - background-color: rgba(255, 255, 0, 0.5); /* @stripOnBuild */ + } -#wall article { +#wall figure { position: relative; margin: 1ex 1ex; - border: 1px solid #999; + border: 1px solid #7f7f7f; display: inline; display: inline-block; float: left; } -#wall article img { +#wall figure img { display: none; } -#wall article { +#wall figure { animation: scaleIn linear 0.5s; -webkit-animation: scaleIn linear 0.5s; -moz-animation: scaleIn linear 0.5s; @@ -159,11 +159,11 @@ footer { to { -ms-transform: scale(1); } } -#wall article img { +#wall figure img { display: block; /* needed for alignment of the image to its container box ... */ } -#wall article section[role="source"] { +#wall figure figcaption { display: block; position: absolute; bottom: 0ex; @@ -180,7 +180,7 @@ footer { text-align: initial; /* fall back to native */ } -#wall article section[role="source"] a { +#wall figure figcaption a { display: none; word-wrap: break-word; text-decoration: none; @@ -188,13 +188,28 @@ footer { margin: 0.1ex 0.1em; } -#wall article section[role="source"]:hover { +#wall figure figcaption:hover { opacity: 1; background-color: #606060; background-color: rgba(100, 100, 100, 0.7); left: 0em; } -#wall article section[role="source"]:hover a { +#wall figure figcaption:hover a { display: block; } + +#wall figure figcaption[data-is_generic="true"] a::after { + content: "generic"; + vertical-align: text-top; + font-size: small; + font-variant: small-caps; + line-height: 1.5ex; + color: #f06030; + background-color: #333333; + display: inline-block; + padding: 0.1ex 0.1em; + margin: 0ex 1ex; + border-radius: 0.5ex; + border: 1px solid #303030; +} diff --git a/templates_raw/root/css/look_bossMode.css b/nichtparasoup/webserver/htdocs/css/look_bossMode.css similarity index 51% rename from templates_raw/root/css/look_bossMode.css rename to nichtparasoup/webserver/htdocs/css/look_bossMode.css index f20bfd6a..f7d785d0 100644 --- a/templates_raw/root/css/look_bossMode.css +++ b/nichtparasoup/webserver/htdocs/css/look_bossMode.css @@ -1,4 +1,4 @@ -/* remember for development: lines that include the string "@stripOnBuild" will be stripped on build ;-) */ + /* @TODO write a good looking bos mode that is not totally suspicious */ diff --git a/templates_raw/root/css/look_buttons.css b/nichtparasoup/webserver/htdocs/css/look_buttons.css similarity index 87% rename from templates_raw/root/css/look_buttons.css rename to nichtparasoup/webserver/htdocs/css/look_buttons.css index 784cfc79..740349d2 100644 --- a/templates_raw/root/css/look_buttons.css +++ b/nichtparasoup/webserver/htdocs/css/look_buttons.css @@ -7,11 +7,9 @@ button , color: #eee; border: 1px solid #ccc; border-radius: 0.5ex; - display: inline; display: inline-block; padding: 0.2ex 0.4ex; - line-height: 2.8ex; } @@ -23,8 +21,8 @@ label.button:hover , label.button:active , label.button:focus { } -button:hover , button:active -a.button:hover , a.button:active +button:hover , button:active , +a.button:hover , a.button:active , label.button:hover , label.button:active { /* invert colors */ background-color: #ccc; @@ -38,6 +36,5 @@ button[disabled] , .button[disabled] { opacity: 0.4; filter: alpha(opacity=40); /* For IE8 and earlier */ - - cursor: default !important; + cursor: not-allowed !important; } diff --git a/templates_raw/root/css/look_hotkeys.css b/nichtparasoup/webserver/htdocs/css/look_hotkeys.css similarity index 87% rename from templates_raw/root/css/look_hotkeys.css rename to nichtparasoup/webserver/htdocs/css/look_hotkeys.css index 8541f973..a5ec86ce 100644 --- a/templates_raw/root/css/look_hotkeys.css +++ b/nichtparasoup/webserver/htdocs/css/look_hotkeys.css @@ -1,5 +1,5 @@ -/* remember for development: lines that include the string "@stripOnBuild" will be stripped on build ;-) */ + #keys { position: absolute; diff --git a/templates_raw/root/css/look_stateSwitch.css b/nichtparasoup/webserver/htdocs/css/look_stateSwitch.css similarity index 72% rename from templates_raw/root/css/look_stateSwitch.css rename to nichtparasoup/webserver/htdocs/css/look_stateSwitch.css index 2eba86b1..2b70a1a4 100644 --- a/templates_raw/root/css/look_stateSwitch.css +++ b/nichtparasoup/webserver/htdocs/css/look_stateSwitch.css @@ -1,5 +1,5 @@ -/* remember for development: lines that include the string "@stripOnBuild" will be stripped on build ;-) */ + .stateSwitchContainer > input { display: none; diff --git a/nichtparasoup/webserver/htdocs/css/normalize.css b/nichtparasoup/webserver/htdocs/css/normalize.css new file mode 100644 index 00000000..192eb9ce --- /dev/null +++ b/nichtparasoup/webserver/htdocs/css/normalize.css @@ -0,0 +1,349 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/nichtparasoup/webserver/htdocs/css/sourceIcons.css b/nichtparasoup/webserver/htdocs/css/sourceIcons.css new file mode 100644 index 00000000..0b85fe5e --- /dev/null +++ b/nichtparasoup/webserver/htdocs/css/sourceIcons.css @@ -0,0 +1,44 @@ +/* here are the source favicons defined */ + +/* naming conventions: lowercase the names of the crawler classes */ + +#wall figure figcaption { /* FALLBACK SOURCE: https://commons.wikimedia.org/wiki/File:Question_Mark_Icon_-_Blue_Box.svg */ + background-image: url('data:image/png;base64,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'); +} + +#wall figure figcaption[data-crawler='pr0gramm'] { + background-image: url('https://pr0gramm.com/media/pr0gramm-favicon.png'); +} + +#wall figure figcaption[data-crawler='reddit'] { + background-image: url('https://www.redditstatic.com/desktop2x/img/favicon/apple-icon-120x120.png'); +} + +#wall figure figcaption[data-crawler='imgur'] { + background-image: url('https://s.imgur.com/images/favicon-96x96.png'); +} + +#wall figure figcaption[data-crawler='soupio'] { + background-image: url('http://static.soup.io/images/favicon.png'); +} + +#wall figure figcaption[data-crawler='ninegag'] { + background-image: url('https://assets-9gag-fun.9cache.com/s/fab0aa49/a221eed4bcd7a347dc71f9348923eacaac0db5ff/static/dist/core/img/favicon.ico'); +} + +#wall figure figcaption[data-crawler='instagram'] { + background-image: url('https://www.instagram.com/static/images/ico/favicon-192.png/68d99ba29cc8.png'); +} + +#wall figure figcaption[data-crawler='fourchan'] { + background-image: url('https://s.4cdn.org/image/favicon.ico'); +} + +#wall figure figcaption[data-crawler='giphy'] { + background-image: url('https://giphy.com/static/img/icons/apple-touch-icon-120px.png'); +} + +#wall figure figcaption[data-crawler='picsum'] { + background-image: url('https://picsum.photos/assets/images/favicon/favicon-32x32.png'); + background-color: #ccc; +} diff --git a/templates_raw/root/root.html b/nichtparasoup/webserver/htdocs/index.html similarity index 62% rename from templates_raw/root/root.html rename to nichtparasoup/webserver/htdocs/index.html index e6f04e2f..9f7508d2 100644 --- a/templates_raw/root/root.html +++ b/nichtparasoup/webserver/htdocs/index.html @@ -1,12 +1,5 @@ - @@ -28,25 +21,12 @@ - - - - - -nichtparasoup - Hackspace Entertainment System - - - - -
- -  - -
- 
esc
boss mode
-
space
play/pause
+
faster -
-
slower
j
next image
-
k
next-to-last image
hot keys
-""" diff --git a/templates_raw/README.md b/templates_raw/README.md deleted file mode 100644 index 0dc89ebe..00000000 --- a/templates_raw/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# template builder for UI - -This is just the template builder - not needed to run __nichtparasoup__. - -## idea - -To have proper IDE support when it comes to writing the UI, the thing should be written in several raw files and be -yuiCompressed and put together at the end. -Furthermore it would be great to be able to develop the UI without running __nichtparasoup__. - -## requirements - -```sh -git submodule update --init --recursive -``` - -for further information: see the `README` of the submodules - -## how to use - -just run the `build.sh` so the `templates.py` of __nichtparasoup__ will be built automatically - -## attention: symlinks - -some files are symlinked - so be sure your environment supports symlinks, otherwise the outcome may be broken. -this is the case on most windows systems. diff --git a/templates_raw/_bundler b/templates_raw/_bundler deleted file mode 160000 index 5acc19fa..00000000 --- a/templates_raw/_bundler +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5acc19fa3c2803be416a7941acb6f9fa65aa81ee diff --git a/templates_raw/_foreign/normalize.css b/templates_raw/_foreign/normalize.css deleted file mode 160000 index d8313280..00000000 --- a/templates_raw/_foreign/normalize.css +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d83132802e8bc10ece0c0afbe88fce83674ec252 diff --git a/templates_raw/build.sh b/templates_raw/build.sh deleted file mode 100755 index aabd2f2c..00000000 --- a/templates_raw/build.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh - - -oldDir=$(pwd) - -cd $(dirname $0) - - -###################### - -target="../templates.py" - -###################### - -printf '\n' > $target - -for builder in */build.sh -do - echo "running $builder ... " - - printf $(basename $(dirname $builder)) >> $target - printf ' = """' >> $target - - $builder 1>> $target - - printf '"""' >> $target - -done - -printf '\n' >> $target - -##################### - -cd $oldDir diff --git a/templates_raw/root/.editorconfig b/templates_raw/root/.editorconfig deleted file mode 100644 index f059efd6..00000000 --- a/templates_raw/root/.editorconfig +++ /dev/null @@ -1,36 +0,0 @@ -root = true - -[*] -end_of_line = LF - -[flush,reset,get,get_local,get_remote] -end_of_line = LF -trim_trailing_whitespace = false -insert_final_newline = false - -[*.html] -indent_style = space -indent_size = 2 -tab_width = 2 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.css] -indent_style = tab -indent_size = 2 -tab_width = 2 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.js] -indent_style = tab -indent_size = 2 -tab_width = 2 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -indent_style = space -indent_size = 3 -trim_trailing_whitespace = false -insert_final_newline = true diff --git a/templates_raw/root/README.md b/templates_raw/root/README.md deleted file mode 100644 index b0e24219..00000000 --- a/templates_raw/root/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# ROOT LAYOUT dev env - -~ this is a local runnable UI builder environment that needs no web server and no internet connection (just `file://`). -Ideal for development on long train rides ;-) - -~ since this development may require XHttpRequests (= AJAX) you want to use a web browser that supports such requests via `file://` - like FireFox - or start a local web server via -`python -m SimpleHTTPServer `. - -## how to develop - -The file `root.html` is the main UI file for _nichtparasoup_. -It may include any known HTML, JS and CSS you need. - -The UI file(s) will be bundled. For details see the `build.sh`. -Therefore some specialties are needed to know: - -* the HTML tag `striponbuild` will be removed on build and may be used for debug/develop purposes -* each line of CSS or JS that contains the string `@striponbuild` is stripped on build, so this may be used for development purposes also - -/!\ attention: -the `normalize.css` is symlinked. symlinks may not work properly on windows systems. - -## how to test - -The ready made UI will normally do a AJAX request to `./get` which should return the URI of an image. -For testing purposes and local dev, a file `test/get_local` was prepared which will respond a locally stored image. Since this Image will have the same Size every time, there is also a file `test/get_remote` which used to respond a random (resolution/content) image. I guess you know how to handle them. diff --git a/templates_raw/root/build.sh b/templates_raw/root/build.sh deleted file mode 100755 index bd858395..00000000 --- a/templates_raw/root/build.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh - -dir=$(dirname $0) - -bundler="$dir/../_bundler//bundler.py --compress --strip-comments \ ---strip-tags striponbuild \ ---strip-markers @striponbuild" - -$bundler -i $dir/root.html - diff --git a/templates_raw/root/css/feel.css b/templates_raw/root/css/feel.css deleted file mode 100644 index ec21b5e0..00000000 --- a/templates_raw/root/css/feel.css +++ /dev/null @@ -1,11 +0,0 @@ -/* remember for development: lines that include the string "@stripOnBuild" will be stripped on build ;-) */ - -input[type="range"] { - position: relative; - top: 5px; - direction: rtl; -} - - - - diff --git a/templates_raw/root/css/normalize.css b/templates_raw/root/css/normalize.css deleted file mode 120000 index 976d81f2..00000000 --- a/templates_raw/root/css/normalize.css +++ /dev/null @@ -1 +0,0 @@ -../../_foreign/normalize.css/normalize.css \ No newline at end of file diff --git a/templates_raw/root/css/sourceIcons.css b/templates_raw/root/css/sourceIcons.css deleted file mode 100644 index 64ece209..00000000 --- a/templates_raw/root/css/sourceIcons.css +++ /dev/null @@ -1,43 +0,0 @@ -/* here are the source favicons defined */ - - -/* resolution is as given, no transformation needed */ -/* used http://www.base64-image.de/ */ - -/* naming conventions: lowercase the names of the crawler classes */ - -#wall article section[role="source"] { /* fallback */ - background-image: url(data:image/png;base64,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); -} - -#wall article section[role="source"][data-crawler='pr0gramm'] { - background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAGcUlEQVR42u2d229UVRTG5x/gjXfSekONxruRqYAjYIqoBARBG5AoCqEmKCISAzrerUFQEZQoRVAeUCJGIWKUiDZixEA702mn7XSmnd7vl+llOrflWQckTVCknXXaWXO+lXwvfThp9vebfc7ea629HY5RUeKcOsXjzHV7nDmlnrzciCGCskIR01PDW/bY8W9RNmOaq8yZU4fBym6xx+z1ReYbhKQwQDaR4fUFCHhKwC/fnjOB+TowaHgVA2LXmSDX7Tj/wYfBsOeroNSBr317rw4cGAR7CwAAAAwCAIAAAAQAIAAAAQAIAEAAAAIAEACAAAAEACAAAAEACABAAAACABAAGIPqtxRS/6lfKN7dQclolIZqKqj1sx1UuSgPBmQ1ALOupu6jX9F/RaK/j4IbVsGEbAWgedfb9H+RSiSo4Z3NMCLbAKh46E5KDg/R5Ubbvg9hRjYBUL9lHY01uo99bb42YEoWAMAfeeOJ/j9/o/K5N8AY7QDwlD7eGKqpxApB/yugkNKJWGc71axeCIO0AuBf5qJ0IxkdNkGCSUqXgX2//pg2BKlUilo+eRdGaQSg6tE5lIrHSSJ4QwkrBIVbwc073ySpiJw5Rb78m2CatlxA+LXnjJkgJgJBtD5I/qWzYZy2bGBwfQElBvpFIIj3dlNg7VKYpy0dXFVwH420NIpAkByJUti9HgZqqweoeOB2Giw/K/ZdwDuOMFFZQYjXNZ16TxwVg6D7+BHy3jMdZmqrCGr7/CMxCAbKTpNv/i0wVFtJWPj15ykZG5FZITSEyL/cBVO11QTWFi6jeF+PCASJ/l7jecthrLaiUM4dRMNBEQh4zyH8xkaYq60q2Jd/s7Hb94fYd0Hbvp0wWFtZuHf2NZcsJB1r8GrD67oWRmvrC2j5uMjMBErEYGWZWaMIs5U1hnBdIdcESESsrZmqVy2A4do6g7gqKNbVIbN9PDRIoReehOnaWsMqF+fRcK1fZoWQTFLT9ldgvLbewPJ5N5rtZVLRcWgvee6+AgCoag6deSV1Ht4vt0I4+QNWCBq7g5t2uM2WMpEVQvkZ8t1/KwDQptDGJygxOCBUZVRLFQ/eAQC0qXrlfHN5JxHDAb9tU8qqD4jgDZ4hv0emuKT4AwCgUd57r6O+kp9Fag3tWHqeHUfE8Arhmy/ShiDw9GIAoFnpdiPx9jMA0ChjU6dt/660Z4DghscBgDbxLqHENwBvE3PVMgBQJK4BHA7ViKwC+n4/gVWAJvEJY4mITLcRl5FVr8wHAFrUsrvInLKlws4ZQlUAcPKm56fvxIznX37jtq3IBWhQ5SKnsevnFTOfC03QZKoEgMCah8Uqg85lAc9SxcK7UA+gAYCGtzaJdQ9xdH1/yKw+hvmZDsCsq4zKnWLB932cmt57GaZrAMBsEjldImZ+vKcLLWNaAOCDJKKNdWLm8/H0XFgKsxUAENq02qj0iQh2Bh0j75zrYbQGAPg8QKluIH5O66fbYbAGAKQ3d7hWsG7zGpirAQDpzZ2RpjBVrciHsRoACKxdYt4jJHqQpM1LvNUAIL2503n4gLlvAEMzHQDDJMnuHjOZU/QSjNQAgG/BbaIngMS7Oymw7hGRJpP2g3syUm0HdlPVY3P1A8C9+SOtTXKbO9U+sc2dsVxuNRnRe/K4bgDqtz4jOsjmsS9GL4DU/5fpwfco6QSAK3UFD4M0L4/Ys038/wQAFj2469uDgps7EQq9+JQl/ycAsKhaV6pmjxNDnCCyagAAgAUP5UsgRTZ3jJQwp4atHAAAYMFDY+0tAse4FJs9f1Z/pAIA6U4d4+bPtE7z4ssgJvCoVwAgneBZMnP8lbrGzDHRF0YCAAuWf+M54HHA89ekHNUCACx4KJs5pmTOkS8nrVJ30Fea0QBYfei1JQBww8XlVPfw+54zg5PdWs5dwZmoibgFxbKNID5z55Lve+OAJ1wQneXZwOCzK8zr4Eef68dJoda975vZQRhgk3Qw3xRWVTAPFzuhIggCABAAgAAABAAgAAABAAgAQAAAAgAQAIAAAAQAIAAAAQAIAEAAAAIAEACAAAAEACAAAAEACABAwgBEMBC2VcThceaUYiBsKsN7A4BcNwbDrgDkuh0lzqlTypw5dRgQe4k9Z+8dHGUzprmM6SCFgbHN1J9izx2jg/+AmcAev/yLzP8neEowvwnOfRhidZBFX/ump4a3F6b98/E374R7V338CnIAAAAASUVORK5CYII=); -} - -#wall article section[role="source"][data-crawler='reddit'] { - background-image: url(data:image/x-icon;base64,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); -} - -#wall article section[role="source"][data-crawler='imgur'] { - background-image: url(data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAQAQAABMLAAATCwAAAAAAAAAAAAArKysSKysroisrK+YrKyvtKysr7SsrK+0rKyvtKysr7SsrK+0rKyvtKysr7SsrK+0rKyvtKysr5isrK6IrKysSKysroysrK/8rKyv/Kysr/ysrK/8rKyv/Kysr/ysqKv8rKir/Kysr/ysrK/8rKyv/Kysr/ysrK/8rKyv/KysroysrK+wrKyv/Kysr/ysrK/8rKyv/KyYo/yskJ/8sKyv/LCor/yskJ/8rJyj/Kysr/ysrK/8rKyv/Kysr/ysrK+wrKyvuKysr/ysrK/8rKyv/KyUn/yxGPP8rfF3/KZNr/ymRav8rd1r/LD84/yslJ/8rKyv/Kysr/ysrK/8rKyvuKysr7SsrK/8rKyv/KyUn/yxgTf8qrnz/KLR//yixff8osX7/KLWA/yqqev8tVEX/KyUn/ysrK/8rKyv/Kysr7SsrK+0rKyv/KyUo/y1KP/8tsoD/LLF//yyuff8srn3/LK59/yyuff8ss4D/Lap7/yw9N/8rJyj/Kysr/ysrK+0rKyvtKysr/yslJ/8whmb/MbqG/zGxgf8xsYH/MbGB/zGxgf8xsYH/MbGB/zG7h/8wdVv/KyMm/ysrK/8rKyvtKysr7SspKv8sLi3/NqB4/ze5iP83tYb/N7WG/ze1hv83tYb/N7WG/ze1hv83u4r/NZNw/ysnKf8rKiv/Kysr7SsrK+0rKSr/LC4t/zykff8/vY7/P7mL/z+5i/8/uYv/P7mL/z+5i/8/uYv/QMCP/zqWc/8rJyj/Kyor/ysrK+0rKyvtKysr/yokJ/8+jG//SceX/0e9kf9HvZH/R72R/0e9kf9HvZH/R72Q/0nImP86e2L/KSMl/ysrK/8rKyvtKysr7SsrK/8qJSf/M01D/1DFmf9Rxpr/UMGX/1DCl/9Qwpf/UMGX/1HHm/9OvJP/Lz85/yonKP8rKyv/Kysr7SsrK+0rKyv/Kysr/ykjJv87aFf/WMed/1vQpP9ay6H/Wsyh/1vQpf9VwZj/N1lM/ykkJv8rKyv/Kysr/ysrK+0rKyvuKysr/ysrK/8rKyv/KSMl/zRKQv9JjHP/UqmJ/1Knh/9GhW7/MUI8/ykjJv8rKyv/Kysr/ysrK/8rKyvuKysr7CsrK/8rKyv/Kysr/ysrK/8pJSf/KCMl/yoqKv8qKSn/KCIl/yomJ/8rKyv/Kysr/ysrK/8rKyv/Kysr7CsrK6QrKyv/Kysr/ysrK/8rKyv/Kysr/ysrK/8rKir/Kyoq/ysrK/8rKyv/Kysr/ysrK/8rKyv/Kysr/ysrK6QrKysSKysroisrK+YrKyvtKysr7SsrK+0rKyvtKysr7SsrK+0rKyvtKysr7SsrK+0rKyvtKysr5isrK6IrKysSAAAgRgAAaWwAADYgAABkZQAAMjYAAHRlAABpbgAANSwAAGVuAABuZQAAdGUAAFBSAABFUwAAUl8AAFZFAAA2AA==); -} - -#wall article section[role="source"][data-crawler='soupio'] { - background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAATVJREFUeNpiZMACzsnqGgCpADThDUaPL19AV8uIReN8IDZgwA5ABiQiG8SIpDkBqpkYADJkAdwAqM3n0VWx8PEycOnqMHy7fIXhz6fP6NKGIJewQDkoNnNpazLI9DYxcGupw8W+XrvJcMsjFFkZSI8hI7rtIFu1Tu1hYObihGv8cvwsA4+lMboBYFewoIc2yMkwzSDwcetehrcLlzI8a/yMLSwCmAiFllRpFthFUvUVWOUxDPh09DjD+z2HUMRALhJPjmIQiYkgbAAIPEjKYXiQWcbw6807FHE2ZQWsBmxAFuCztmQQz81g+HbxMsPLvhkoir/sOYiufwMsHZyHpT5QFKpvX4lh0+OqNoY3S1agpEpgOjDEmpBAhgiE+MNVvp27mOHnk6dYExJ1kjJVMhO52RkgwABgs3aktxfFvgAAAABJRU5ErkJggg==); -} - -#wall article section[role="source"][data-crawler='ninegag'] { - background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJcAAACXCAMAAAAvQTlLAAAAZlBMVEUAAAD///+zs7O2trZ5eXn8/Pz4+Pjg4OCcnJzd3d1hYWGoqKgsLCxlZWXExMTQ0NBtbW3o6OhbW1sbGxvx8fEICAiRkZExMTHKyspRUVGIiIgnJyeioqJycnJWVlY9PT1ISEgUFBR9OlkuAAADwUlEQVR4nO2b6ZKiMBCAA8gliAeHyDGu7/+SC+ooR9IdZ3NM1fb3Fym/mrSd7k6GMYIgCIIgCIIgCIIgCEIzW29rW4FD7gVO4LW2NRbcosAZCaKbbZUph6PzzfFgW+bFqXam1L8jzFIvcOYEXmpbipXV0mrEryyH2S7kWI2EO4tWX7XAamT/ZUvL5S3hNMysWEWw1d0sKk1bbWPUaiQ2G2anTMpqJDsZs7ol+BJOFtPNjViVG/8DqzuNgWx2EWUsiFB3zvgDZSyI/UWnVlL8UMtxikSbFXcrfBPUyPNKSzbDlnDYd7726EeUW3nwV8aPcvCApNtMcZhFSGBF34V9G8EfLDp1UrceDhx/vkGv6sQ5wUZVnkX+BKs9ENs7Vf3JwNA69pw3Jn0IB1X1D+QV8Uv5tjLg5Yq+wM/EfWzuCbdR3V5IQyYshnR7hbzQerMTZVnt6+hkV+FLqTgq9XsNv3m+2bkD3jHxe3SODeeNxkieSKAvGcJsGf5bpHRU5dU2SD2fTZNYi/Yk6vrKHGlh/e78/GQq0Vaq7Hexzix8hFkjU/2r7cN3yG4cD3WhXLOrej6ALJGfSTZwyucWN6S0kkTDPOXy0z5tiqvea0hOYMq058VKLJutWPYGeryGzc/9ZEJRbDaGvIbFlJ8zJWdmzmuo4OXGJ/dO1qQXYx0+q3huAma9WCuu4O8E3bNRNOyFZDPvVTEa92LlQbSY4WQQYd5roOKZzYtYK17suspmwasas+m1Oo7J/iye2/Iawuy9aXIOrKx5sdeArIg4z2x6sfPYzbln3iOrXkM2cwVDSsteQn6rV/9LvQ4LLzvnpWt2Zrz6jPurE3JdzsG0eN2HzZX8PZx0Pc3W4FVmj6ZD+rZLz2mflHu1k4lTLHPbhT/JV+01n7ZJXEMQtOdqvdZHUn4EnajkkajMVumVcociwDUE4GRNnZd4jLvnX0M4QSeRyrygMW7A21Xg8WKmSgtp+5t5mOUN/HG/UuTFLsj5cDhdTGwarfQG1gn5svr75Bo74lZ9/RBbnOf5MHaRQXAs+C+U2Ky8SZFA9DVdb91iYQY/jvXdIO3lBuE8uOdIysihczKI5LOa7QfITukn+LWJ+3ySV/jeHE1d5oOvISytNoasGLc4FtFpD6wZZ2SI+cD/sEVRQY9ks4EYvjCgiw0cZnozFkTbAYuZ2Lzs3tZ8M7+2/Z8e3GNcjVuhPKswM5mxINr5pmk1sOZcX5umD1zZscGzArKUsSCawimsZSyItDK/6RAEQRAEQRAEQRAEQfx3/AVIvysz/+uSlwAAAABJRU5ErkJggg=='); -} - -#wall article section[role="source"][data-crawler='instagram'] { - background-image: url('data:image/png;base64,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'); -} - -#wall article section[role="source"][data-crawler='fourchan'] { - background-image: url('data:image/png;base64,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'); -} - -#wall article section[role="source"][data-crawler='giphy'] { - background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADkAAAA5CAIAAAADehTSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDozQUREQ0NFRUE2OTQxMUUyQkYxQ0E0NzQyMzFDQUUxNSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDozQUREQ0NFRkE2OTQxMUUyQkYxQ0E0NzQyMzFDQUUxNSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjNBRERDQ0VDQTY5NDExRTJCRjFDQTQ3NDIzMUNBRTE1IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjNBRERDQ0VEQTY5NDExRTJCRjFDQTQ3NDIzMUNBRTE1Ii8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+hIGO9wAAAptJREFUeNrsWTtuFEEQrTdM5nATZ8hyQrAn4A5IRuwhiJG4AxGnIGMDI0IO4MCBA1IL5Axy24ktnmeqP9PeXaGpdjdopR57dzzt2ZrXr199uhaLxUL25Ohkf46GtWFtWBvWhrVh7eff+uvqAzldAhjeSeoZ1P+Ng/R36fh4vn13LsPfOrr8cnp7f18dq4OAAMED0XFgQDzCZTob8ZPRDydD/0YDmHBT3yI0pCihE/JM62u81t+bXFJtvHp2HjHnFnoE5niN7DoxeD4pDvVw3483Kzfj4epo/bkW1vcXv6EPHDB8ev1x/gd/rlYMKhonI+GnqgYyHpAoVyQ6HiprQNdXHnvPXOUg4kMUkxz0/TN0f4TXd3fl9QpkcsIkFIRp8/vJiTN3tF4X1oAPPnkqoOwIWkaDnZFUicHIHOkQsgNCBoFtjbpsgszioaR+5VRhmnWf81SjZHfK8eXhWxoNGXMBnf3MyLXD21BTA4AUOaCmaJl3lyXVInjHaA2LB1jqLE36pahdLl+IqxkgZ99q5FhSJCd1/SUXVvKtMl61NfcqvkUAxfRqt2HJW+oNBUOBT1yzzZl8qyBKn2pNqdBUEzrjLKMBF7Esxmx1lg9Zpdg1JsHOymsxoNFQPV5JKRNeGaJKjbxVuB6wG7LF1ydtDba0at0l9lYmGNfu6XHAuEz/rx7wYmUVXouGq5zaImMfW6Z8ydBSlxEByqC1LxHmf8d5/PVqWjtuJN6NlOGacFN1ltwTkpXuYBzgy1fPy/uWbw5w6lHRb0SSDZ/WONHL04X2HQa3Ew5eyhq5AAGHO0norgamkjIkUsqwA0S4xReW9P3Yar7F2Fbf4mljULvFgGsa+0aGxHYh84putO/kG9aGtWFtWBvWPcL6IMAAUeQiiCnMC5IAAAAASUVORK5CYII='); -} diff --git a/templates_raw/root/flush b/templates_raw/root/flush deleted file mode 100644 index 8da85a22..00000000 --- a/templates_raw/root/flush +++ /dev/null @@ -1 +0,0 @@ -3000 \ No newline at end of file diff --git a/templates_raw/root/get b/templates_raw/root/get deleted file mode 100644 index 29d80455..00000000 --- a/templates_raw/root/get +++ /dev/null @@ -1,7 +0,0 @@ -{ - "uri": "./test/testimg.png", - "crawler": "noCrawler", - "site": "get_local", - "source": "http://nicht.parasoup.de/demo/", - "more": {} -} \ No newline at end of file diff --git a/templates_raw/root/js/snowstorm.js b/templates_raw/root/js/snowstorm.js deleted file mode 100644 index 63481c67..00000000 --- a/templates_raw/root/js/snowstorm.js +++ /dev/null @@ -1,668 +0,0 @@ -/** @license - * DHTML Snowstorm! JavaScript-based snow for web pages - * Making it snow on the internets since 2003. You're welcome. - * ----------------------------------------------------------- - * Version 1.44.20131208 (Previous rev: 1.44.20131125) - * Copyright (c) 2007, Scott Schiller. All rights reserved. - * Code provided under the BSD License - * http://schillmania.com/projects/snowstorm/license.txt - */ - -/*jslint nomen: true, plusplus: true, sloppy: true, vars: true, white: true */ -/*global window, document, navigator, clearInterval, setInterval */ - -var snowStorm = (function(window, document) { - - // --- common properties --- - - this.autoStart = false; // Whether the snow should start automatically or not. - this.excludeMobile = true; // Snow is likely to be bad news for mobile phones' CPUs (and batteries.) Enable at your own risk. - this.flakesMax = 256; // Limit total amount of snow made (falling + sticking) - this.flakesMaxActive = 128; // Limit amount of snow falling at once (less = lower CPU use) - this.animationInterval = 33; // Theoretical "miliseconds per frame" measurement. 20 = fast + smooth, but high CPU use. 50 = more conservative, but slower - this.useGPU = true; // Enable transform-based hardware acceleration, reduce CPU load. - this.className = null; // CSS class name for further customization on snow elements - this.excludeMobile = true; // Snow is likely to be bad news for mobile phones' CPUs (and batteries.) By default, be nice. - this.flakeBottom = null; // Integer for Y axis snow limit, 0 or null for "full-screen" snow effect - this.followMouse = true; // Snow movement can respond to the user's mouse - this.snowColor = '#fff'; // Don't eat (or use?) yellow snow. - this.snowCharacter = '•'; // • = bullet, · is square on some systems etc. - this.snowStick = true; // Whether or not snow should "stick" at the bottom. When off, will never collect. - this.targetElement = null; // element which snow will be appended to (null = document.body) - can be an element ID eg. 'myDiv', or a DOM node reference - this.useMeltEffect = true; // When recycling fallen snow (or rarely, when falling), have it "melt" and fade out if browser supports it - this.useTwinkleEffect = false; // Allow snow to randomly "flicker" in and out of view while falling - this.usePositionFixed = false; // true = snow does not shift vertically when scrolling. May increase CPU load, disabled by default - if enabled, used only where supported - this.usePixelPosition = false; // Whether to use pixel values for snow top/left vs. percentages. Auto-enabled if body is position:relative or targetElement is specified. - - // --- less-used bits --- - - this.freezeOnBlur = true; // Only snow when the window is in focus (foreground.) Saves CPU. - this.flakeLeftOffset = 0; // Left margin/gutter space on edge of container (eg. browser window.) Bump up these values if seeing horizontal scrollbars. - this.flakeRightOffset = 0; // Right margin/gutter space on edge of container - this.flakeWidth = 8; // Max pixel width reserved for snow element - this.flakeHeight = 8; // Max pixel height reserved for snow element - this.vMaxX = 5; // Maximum X velocity range for snow - this.vMaxY = 4; // Maximum Y velocity range for snow - this.zIndex = 0; // CSS stacking order applied to each snowflake - - // --- "No user-serviceable parts inside" past this point, yadda yadda --- - - var storm = this, - features, - // UA sniffing and backCompat rendering mode checks for fixed position, etc. - isIE = navigator.userAgent.match(/msie/i), - isIE6 = navigator.userAgent.match(/msie 6/i), - isMobile = navigator.userAgent.match(/mobile|opera m(ob|in)/i), - isBackCompatIE = (isIE && document.compatMode === 'BackCompat'), - noFixed = (isBackCompatIE || isIE6), - screenX = null, screenX2 = null, screenY = null, scrollY = null, docHeight = null, vRndX = null, vRndY = null, - windOffset = 1, - windMultiplier = 2, - flakeTypes = 6, - fixedForEverything = false, - targetElementIsRelative = false, - opacitySupported = (function(){ - try { - document.createElement('div').style.opacity = '0.5'; - } catch(e) { - return false; - } - return true; - }()), - didInit = false, - docFrag = document.createDocumentFragment(); - - features = (function() { - - var getAnimationFrame; - - /** - * hat tip: paul irish - * http://paulirish.com/2011/requestanimationframe-for-smart-animating/ - * https://gist.github.com/838785 - */ - - function timeoutShim(callback) { - window.setTimeout(callback, 1000/(storm.animationInterval || 20)); - } - - var _animationFrame = (window.requestAnimationFrame || - window.webkitRequestAnimationFrame || - window.mozRequestAnimationFrame || - window.oRequestAnimationFrame || - window.msRequestAnimationFrame || - timeoutShim); - - // apply to window, avoid "illegal invocation" errors in Chrome - getAnimationFrame = _animationFrame ? function() { - return _animationFrame.apply(window, arguments); - } : null; - - var testDiv; - - testDiv = document.createElement('div'); - - function has(prop) { - - // test for feature support - var result = testDiv.style[prop]; - return (result !== undefined ? prop : null); - - } - - // note local scope. - var localFeatures = { - - transform: { - ie: has('-ms-transform'), - moz: has('MozTransform'), - opera: has('OTransform'), - webkit: has('webkitTransform'), - w3: has('transform'), - prop: null // the normalized property value - }, - - getAnimationFrame: getAnimationFrame - - }; - - localFeatures.transform.prop = ( - localFeatures.transform.w3 || - localFeatures.transform.moz || - localFeatures.transform.webkit || - localFeatures.transform.ie || - localFeatures.transform.opera - ); - - testDiv = null; - - return localFeatures; - - }()); - - this.timer = null; - this.flakes = []; - this.disabled = false; - this.active = false; - this.meltFrameCount = 20; - this.meltFrames = []; - - this.setXY = function(o, x, y) { - - if (!o) { - return false; - } - - if (storm.usePixelPosition || targetElementIsRelative) { - - o.style.left = (x - storm.flakeWidth) + 'px'; - o.style.top = (y - storm.flakeHeight) + 'px'; - - } else if (noFixed) { - - o.style.right = (100-(x/screenX*100)) + '%'; - // avoid creating vertical scrollbars - o.style.top = (Math.min(y, docHeight-storm.flakeHeight)) + 'px'; - - } else { - - if (!storm.flakeBottom) { - - // if not using a fixed bottom coordinate... - o.style.right = (100-(x/screenX*100)) + '%'; - o.style.bottom = (100-(y/screenY*100)) + '%'; - - } else { - - // absolute top. - o.style.right = (100-(x/screenX*100)) + '%'; - o.style.top = (Math.min(y, docHeight-storm.flakeHeight)) + 'px'; - - } - - } - - }; - - this.events = (function() { - - var old = (!window.addEventListener && window.attachEvent), slice = Array.prototype.slice, - evt = { - add: (old?'attachEvent':'addEventListener'), - remove: (old?'detachEvent':'removeEventListener') - }; - - function getArgs(oArgs) { - var args = slice.call(oArgs), len = args.length; - if (old) { - args[1] = 'on' + args[1]; // prefix - if (len > 3) { - args.pop(); // no capture - } - } else if (len === 3) { - args.push(false); - } - return args; - } - - function apply(args, sType) { - var element = args.shift(), - method = [evt[sType]]; - if (old) { - element[method](args[0], args[1]); - } else { - element[method].apply(element, args); - } - } - - function addEvent() { - apply(getArgs(arguments), 'add'); - } - - function removeEvent() { - apply(getArgs(arguments), 'remove'); - } - - return { - add: addEvent, - remove: removeEvent - }; - - }()); - - function rnd(n,min) { - if (isNaN(min)) { - min = 0; - } - return (Math.random()*n)+min; - } - - function plusMinus(n) { - return (parseInt(rnd(2),10)===1?n*-1:n); - } - - this.randomizeWind = function() { - var i; - vRndX = plusMinus(rnd(storm.vMaxX,0.2)); - vRndY = rnd(storm.vMaxY,0.2); - if (this.flakes) { - for (i=0; i=0 && s.vX<0.2) { - s.vX = 0.2; - } else if (s.vX<0 && s.vX>-0.2) { - s.vX = -0.2; - } - if (s.vY>=0 && s.vY<0.2) { - s.vY = 0.2; - } - }; - - this.move = function() { - var vX = s.vX*windOffset, yDiff; - s.x += vX; - s.y += (s.vY*s.vAmp); - if (s.x >= screenX || screenX-s.x < storm.flakeWidth) { // X-axis scroll check - s.x = 0; - } else if (vX < 0 && s.x-storm.flakeLeftOffset < -storm.flakeWidth) { - s.x = screenX-storm.flakeWidth-1; // flakeWidth; - } - s.refresh(); - yDiff = screenY+scrollY-s.y+storm.flakeHeight; - if (yDiff0.998) { - // ~1/1000 chance of melting mid-air, with each frame - s.melting = true; - s.melt(); - // only incrementally melt one frame - // s.melting = false; - } - if (storm.useTwinkleEffect) { - if (s.twinkleFrame < 0) { - if (Math.random() > 0.97) { - s.twinkleFrame = parseInt(Math.random() * 8, 10); - } - } else { - s.twinkleFrame--; - if (!opacitySupported) { - s.o.style.visibility = (s.twinkleFrame && s.twinkleFrame % 2 === 0 ? 'hidden' : 'visible'); - } else { - s.o.style.opacity = (s.twinkleFrame && s.twinkleFrame % 2 === 0 ? 0 : 1); - } - } - } - } - }; - - this.animate = function() { - // main animation loop - // move, check status, die etc. - s.move(); - }; - - this.setVelocities = function() { - s.vX = vRndX+rnd(storm.vMaxX*0.12,0.1); - s.vY = vRndY+rnd(storm.vMaxY*0.12,0.1); - }; - - this.setOpacity = function(o,opacity) { - if (!opacitySupported) { - return false; - } - o.style.opacity = opacity; - }; - - this.melt = function() { - if (!storm.useMeltEffect || !s.melting) { - s.recycle(); - } else { - if (s.meltFrame < s.meltFrameCount) { - s.setOpacity(s.o,s.meltFrames[s.meltFrame]); - s.o.style.fontSize = s.fontSize-(s.fontSize*(s.meltFrame/s.meltFrameCount))+'px'; - s.o.style.lineHeight = storm.flakeHeight+2+(storm.flakeHeight*0.75*(s.meltFrame/s.meltFrameCount))+'px'; - s.meltFrame++; - } else { - s.recycle(); - } - } - }; - - this.recycle = function() { - s.o.style.display = 'none'; - s.o.style.position = (fixedForEverything?'fixed':'absolute'); - s.o.style.bottom = 'auto'; - s.setVelocities(); - s.vCheck(); - s.meltFrame = 0; - s.melting = false; - s.setOpacity(s.o,1); - s.o.style.padding = '0px'; - s.o.style.margin = '0px'; - s.o.style.fontSize = s.fontSize+'px'; - s.o.style.lineHeight = (storm.flakeHeight+2)+'px'; - s.o.style.textAlign = 'center'; - s.o.style.verticalAlign = 'baseline'; - s.x = parseInt(rnd(screenX-storm.flakeWidth-20),10); - s.y = parseInt(rnd(screenY)*-1,10)-storm.flakeHeight; - s.refresh(); - s.o.style.display = 'block'; - s.active = 1; - }; - - this.recycle(); // set up x/y coords etc. - this.refresh(); - - }; - - this.snow = function() { - var active = 0, flake = null, i, j; - for (i=0, j=storm.flakes.length; istorm.flakesMaxActive) { - storm.flakes[storm.flakes.length-1].active = -1; - } - } - storm.targetElement.appendChild(docFrag); - }; - - this.timerInit = function() { - storm.timer = true; - storm.snow(); - }; - - this.init = function() { - var i; - for (i=0; i None: + from nichtparasoup.cmdline.argparse import parser + self.parser = parser + + def tearDown(self) -> None: + del self.parser + + def test_parser(self) -> None: + self.skipTest("TODO: write the test") # TODO: write the test diff --git a/testing/test_config/configs/positive/crawler_config_missing.yaml b/testing/test_config/configs/positive/crawler_config_missing.yaml new file mode 100644 index 00000000..f24650a6 --- /dev/null +++ b/testing/test_config/configs/positive/crawler_config_missing.yaml @@ -0,0 +1,14 @@ +## this is a test config file for nichtprasoup (v2.x) +## purpose: check if optional weight is set a default value + +webserver: + hostname: "0.0.0.0" + port: 5000 + +imageserver: + crawler_upkeep: 30 + +crawlers: + - type: "MockableImageCrawler" + weight: 1 + # !!! config: left away, to see if it gets its default value of dict() diff --git a/testing/test_config/configs/positive/crawler_weight_missing.yaml b/testing/test_config/configs/positive/crawler_weight_missing.yaml new file mode 100644 index 00000000..eec4d581 --- /dev/null +++ b/testing/test_config/configs/positive/crawler_weight_missing.yaml @@ -0,0 +1,15 @@ +## this is a test config file for nichtprasoup (v2.x) +## purpose: check if optional weight is set a default value + +webserver: + hostname: "0.0.0.0" + port: 5000 + +imageserver: + crawler_upkeep: 30 + +crawlers: + - type: "MockableImageCrawler" + # weight: !!! left away, to see if it gets its default value of 1 + config: + foo: "bar" diff --git a/testing/test_config/configs/positive/logging_level_missing.yaml b/testing/test_config/configs/positive/logging_level_missing.yaml new file mode 100644 index 00000000..65bad1a2 --- /dev/null +++ b/testing/test_config/configs/positive/logging_level_missing.yaml @@ -0,0 +1,18 @@ +## this is a test config file for nichtprasoup (v2.x) +## purpose: check if optional weight is set a default value + +# logging: + # level: !!! left away, to see if it gets its default value of 'INFO' + +webserver: + hostname: "0.0.0.0" + port: 5000 + +imageserver: + crawler_upkeep: 30 + +crawlers: + - type: "MockableImageCrawler" + weight: 1 + config: + foo: "bar" diff --git a/testing/test_config/test_config_parser.py b/testing/test_config/test_config_parser.py new file mode 100644 index 00000000..f94bfe4b --- /dev/null +++ b/testing/test_config/test_config_parser.py @@ -0,0 +1,33 @@ +import unittest +from os.path import dirname, join as path_join, realpath + +from nichtparasoup.config import parse_yaml_file + + +class ConfigParserDefaultsTest(unittest.TestCase): + + def test_set_optional_loglevel(self) -> None: + # arrange + file = realpath(path_join(dirname(__file__), 'configs', 'positive', 'logging_level_missing.yaml')) + # act + config = parse_yaml_file(file) + # assert + self.assertEqual(config['logging']['level'], 'INFO') + + def test_set_optional_weight(self) -> None: + # arrange + file = realpath(path_join(dirname(__file__), 'configs', 'positive', 'crawler_weight_missing.yaml')) + # act + config = parse_yaml_file(file) + # assert + self.assertEqual(len(config["crawlers"]), 1) + self.assertEqual(config["crawlers"][0]["weight"], 1) + + def test_set_optional_config(self) -> None: + # arrange + file = realpath(path_join(dirname(__file__), 'configs', 'positive', 'crawler_config_missing.yaml')) + # act + config = parse_yaml_file(file) + # assert + self.assertEqual(len(config["crawlers"]), 1) + self.assertDictEqual(config["crawlers"][0]["config"], dict()) diff --git a/testing/test_config/test_shipped_config_files.py b/testing/test_config/test_shipped_config_files.py new file mode 100644 index 00000000..f6e731bd --- /dev/null +++ b/testing/test_config/test_shipped_config_files.py @@ -0,0 +1,63 @@ +import unittest +from glob import glob +from itertools import chain as iter_chain +from os.path import dirname, join as path_join, realpath +from typing import List + +from yamale import YamaleTestCase # type: ignore + +from nichtparasoup.config import _defaults_file as config_defaults_file, get_imagecrawler, parse_yaml_file +from nichtparasoup.core.imagecrawler import BaseImageCrawler + + +class ParseShippedConfigFilesTest(YamaleTestCase): # type: ignore + + def setUp(self) -> None: + self.base_dir = realpath(path_join(dirname(__file__), "..", "..")) + self.schema = path_join("nichtparasoup", "config", 'schema.yaml') + self.yaml = [] # type: List[str] # override this in your test + + def tearDown(self) -> None: + del self.base_dir + del self.schema + del self.yaml + + def test_default(self) -> None: + # arrange + self.yaml = [config_defaults_file] + # act & assert + self.assertTrue(self.validate()) + + def test_examples(self) -> None: + # arrange + self.yaml = [path_join("examples", "*.yml"), path_join("examples", '**', "*.yml"), + path_join("examples", "*.yaml"), path_join("examples", '**', "*.yaml")] + # act & assert + self.assertTrue(self.validate()) + + +class ConfigParserDefaultsTest(unittest.TestCase): + + def validate(self, file: str) -> None: + # act + config = parse_yaml_file(file) + # assert + self.assertIsInstance(config, dict, file) + for crawler_config in config['crawlers']: + self.assertIsInstance(get_imagecrawler(crawler_config), BaseImageCrawler, file) + + def test_defaults(self) -> None: + # arrange + file = config_defaults_file + # act & assert + self.validate(file) + + def test_examples(self) -> None: + # arrange + examples_base = realpath(path_join(dirname(__file__), "..", "..", 'examples')) + files_glob = [path_join(examples_base, '*.yml'), path_join(examples_base, '**', '*.yml'), + path_join(examples_base, '*.yaml'), path_join(examples_base, '**', '*.yaml')] + files = iter_chain(*map(glob, files_glob)) # type: ignore + # act & assert + for file in files: + self.validate(file) # type: ignore diff --git a/testing/test_core/__init__.py b/testing/test_core/__init__.py new file mode 100644 index 00000000..106511fa --- /dev/null +++ b/testing/test_core/__init__.py @@ -0,0 +1,2 @@ + +# file needed to import mockable_imagecrawler diff --git a/testing/test_core/mockable_crawler.py b/testing/test_core/mockable_crawler.py new file mode 100644 index 00000000..dc1621c1 --- /dev/null +++ b/testing/test_core/mockable_crawler.py @@ -0,0 +1,37 @@ +__all__ = ["NullCrawler", "Random3Crawler"] + +from typing import Optional + +from nichtparasoup.core import Crawler, _CrawlerWeight, _IsImageAddable, _OnImageAdded +from nichtparasoup.core.image import Image +from nichtparasoup.core.imagecrawler import BaseImageCrawler + + +class _LoggingCrawler(Crawler): + def __init__(self, + imagecrawler: BaseImageCrawler, weight: _CrawlerWeight, + is_image_addable: Optional[_IsImageAddable] = None, + on_image_added: Optional[_OnImageAdded] = None) -> None: + super().__init__(imagecrawler, weight, is_image_addable, on_image_added) + self.crawl_call_count = 0 + self.reset_call_count = 0 + + def crawl(self) -> int: + self.crawl_call_count += 1 + return 0 + + def reset(self) -> None: + self.reset_call_count += 1 + + +class NullCrawler(_LoggingCrawler): + pass + + +class Random3Crawler(_LoggingCrawler): + + def crawl(self) -> int: + super().crawl() + for _ in range(3): + self.images.add(Image('test', 'test', is_generic=True)) + return 3 diff --git a/testing/test_core/mockable_imagecrawler.py b/testing/test_core/mockable_imagecrawler.py new file mode 100644 index 00000000..dcc9827f --- /dev/null +++ b/testing/test_core/mockable_imagecrawler.py @@ -0,0 +1,33 @@ +__all__ = ["MockableImageCrawler", "YetAnotherImageCrawler"] + +from typing import Any, Dict + +from nichtparasoup.core.image import ImageCollection +from nichtparasoup.core.imagecrawler import BaseImageCrawler, ImageCrawlerConfig, ImageCrawlerInfo + + +class MockableImageCrawler(BaseImageCrawler): + """ imagecrawler that does nothing. use it for mocking ... """ + + @staticmethod + def info() -> ImageCrawlerInfo: + return ImageCrawlerInfo( + desc='a mock', + config=dict(), + version='mock', + ) + + @staticmethod + def check_config(config: Dict[Any, Any]) -> ImageCrawlerConfig: + return ImageCrawlerConfig(config) + + def _reset(self) -> None: + pass + + def _crawl(self) -> ImageCollection: + return ImageCollection() + + +class YetAnotherImageCrawler(MockableImageCrawler): + """ another implementation, to see if type matters """ + pass diff --git a/testing/test_core/test_baseimagecrawler.py b/testing/test_core/test_baseimagecrawler.py new file mode 100644 index 00000000..c99e371a --- /dev/null +++ b/testing/test_core/test_baseimagecrawler.py @@ -0,0 +1,95 @@ +import unittest + +from .mockable_imagecrawler import MockableImageCrawler, YetAnotherImageCrawler + + +class BaseImageCrawlerEqualTest(unittest.TestCase): + + def test_equal(self) -> None: + # arrange + c01 = MockableImageCrawler() + c02 = MockableImageCrawler() + c11 = MockableImageCrawler(foo='bar', baz=1) + c12 = MockableImageCrawler(foo='bar', baz=1) + # assert + self.assertEqual(c01, c01) + self.assertEqual(c02, c02) + self.assertEqual(c01, c02) + self.assertEqual(c11, c11) + self.assertEqual(c12, c12) + self.assertEqual(c11, c12) + + def test_unequal(self) -> None: + # arrange + c1 = MockableImageCrawler() + c2 = YetAnotherImageCrawler() + c3 = MockableImageCrawler(foo='bar', baz=1) + c4 = YetAnotherImageCrawler(foo='b4r', baz=1) + c5 = MockableImageCrawler(baz=1) + # assert + self.assertNotEqual(c1, c2) + self.assertNotEqual(c1, c3) + self.assertNotEqual(c1, c4) + self.assertNotEqual(c1, c5) + self.assertNotEqual(c2, c3) + self.assertNotEqual(c2, c4) + self.assertNotEqual(c2, c5) + self.assertNotEqual(c3, c4) + self.assertNotEqual(c3, c5) + self.assertNotEqual(c4, c5) + + +class BaseImageCrawlerResetTest(unittest.TestCase): + + def test_reset_set(self) -> None: + # arrange + c = MockableImageCrawler() + c._reset_before_next_crawl = False + # act + c.reset() + # assert + self.assertTrue(c._reset_before_next_crawl) + + def test_reset_released(self) -> None: + # arrange + c = MockableImageCrawler() + c._reset_before_next_crawl = True + # act + c.crawl() + # assert + self.assertFalse(c._reset_before_next_crawl) + + +class BaseImageCrawlerGetConfigTest(unittest.TestCase): + + def test_public(self) -> None: + # arrange + c = MockableImageCrawler(foo='bar') + # act + config = c.get_config() + # assert + self.assertDictEqual(dict(foo='bar'), config) + + def test_empty_key(self) -> None: + # arrange + c = MockableImageCrawler(**{'': 'bar'}) + # act + config = c.get_config() + # assert + self.assertDictEqual({'': 'bar'}, config) + + def test_protected(self) -> None: + # arrange + c = MockableImageCrawler(_foo='bar') + # act + config = c.get_config() + # assert + self.assertDictEqual(dict(), config) + + def test_private(self) -> None: + # arrange + c = MockableImageCrawler(__foo='bar') + # act + config = c.get_config() + # assert + self.assertDictEqual(dict(), config) diff --git a/testing/test_core/test_crawler.py b/testing/test_core/test_crawler.py new file mode 100644 index 00000000..9aec2242 --- /dev/null +++ b/testing/test_core/test_crawler.py @@ -0,0 +1,221 @@ +import unittest +from unittest.mock import MagicMock +from weakref import ref as weak_ref + +from nichtparasoup.core import Crawler +from nichtparasoup.core.image import Image, ImageCollection + +from .mockable_imagecrawler import MockableImageCrawler + + +class _C(object): + + def m_b(self, _: Image) -> bool: + return True + + def m(self, _: Image) -> None: + pass + + +def _f_b(_: Image) -> bool: + return True + + +def _f(_: Image) -> None: + pass + + +class CrawlerIsImageAddableTestCase(unittest.TestCase): + """ + test if the weakref is working as expected + """ + + def setUp(self) -> None: + self.crawler = Crawler(MockableImageCrawler(), 1) + + def tearDown(self) -> None: + del self.crawler + + def test_default(self) -> None: + # assert + self.assertIsNone(self.crawler.get_is_image_addable()) + + def test_none(self) -> None: + # arrange + obj = _C() + self.crawler.set_is_image_addable(obj.m_b) + # act + self.crawler.set_is_image_addable(None) + # assert + self.assertIsNone(self.crawler.get_is_image_addable()) + + def test_object_bound_method_stay(self) -> None: + # arrange + obj = _C() + # act + self.crawler.set_is_image_addable(obj.m_b) + # assert + self.assertEqual(self.crawler.get_is_image_addable(), obj.m_b) + + def test_object_bound_method_lambda(self) -> None: + # arrange + obj = _C() + self.crawler.set_is_image_addable(obj.m_b) + # act + self.crawler.set_is_image_addable(_C().m_b) + # assert + self.assertIsNone(self.crawler.get_is_image_addable()) + + def test_object_bound_method_deleted(self) -> None: + # arrange + obj = _C() + obj_wr = weak_ref(obj) + # act + self.crawler.set_is_image_addable(obj.m_b) + self.assertEqual(self.crawler.get_is_image_addable(), obj.m_b) + del obj + # assert + self.assertIsNone(obj_wr(), 'obj is intended to be deleted') + self.assertIsNone(self.crawler.get_is_image_addable()) + + def test_function(self) -> None: + """ Remember: FunctionType is LambdaType """ + # assert + with self.assertRaises(Exception): + # currently not supporting `function`. write the test, when writing it is supported + self.crawler.set_is_image_addable(_f_b) + + +class CrawlerImageAddedTestCase(unittest.TestCase): + """ + test if the weakref is working as expected + """ + + def setUp(self) -> None: + self.crawler = Crawler(MockableImageCrawler(), 1) + + def tearDown(self) -> None: + del self.crawler + + def test_default(self) -> None: + # assert + self.assertIsNone(self.crawler.get_image_added()) + + def test_none(self) -> None: + # arrange + obj = _C() + self.crawler.set_image_added(obj.m) + # act + self.crawler.set_image_added(None) + # assert + self.assertIsNone(self.crawler.get_image_added()) + + def test_object_bound_method_stay(self) -> None: + # arrange + obj = _C() + # act + self.crawler.set_image_added(obj.m) + # assert + self.assertEqual(self.crawler.get_image_added(), obj.m) + + def test_object_bound_method_lambda(self) -> None: + # arrange + obj = _C() + self.crawler.set_image_added(obj.m) + # act + self.crawler.set_image_added(_C().m) + # assert + self.assertIsNone(self.crawler.get_image_added()) + + def test_object_bound_method_deleted(self) -> None: + # arrange + obj = _C() + obj_wr = weak_ref(obj) + # act + self.crawler.set_image_added(obj.m) + self.assertEqual(self.crawler.get_image_added(), obj.m) + del obj + # assert + self.assertIsNone(obj_wr(), 'obj is intended to be deleted') + self.assertIsNone(self.crawler.get_image_added()) + + def test_function(self) -> None: + """ Remember: FunctionType is LambdaType """ + # assert + with self.assertRaises(Exception): + # currently not supporting `function`. write the test, when writing it is supported + self.crawler.set_image_added(_f) + + +class CrawlerCrawlCase(unittest.TestCase): + + def setUp(self) -> None: + self.images = ImageCollection({Image('1', 'test'), Image('2', 'test')}) + self.imagecrawler = MockableImageCrawler() + self.imagecrawler.crawl = MagicMock(return_value=self.images) # type: ignore + self.crawler = Crawler(self.imagecrawler, 1) + + def tearDown(self) -> None: + del self.images + del self.imagecrawler + del self.crawler + + def test_crawl_default(self) -> None: + # act + crawled = self.crawler.crawl() + # assert + self.assertSetEqual(self.images, self.crawler.images) + self.assertEqual(len(self.images), crawled) + + def test_crawl_is_addable_true(self) -> None: + called_is_image_addable_with = ImageCollection() + + def on_is_addable_true(image: Image) -> bool: + called_is_image_addable_with.add(image) + return False + + self.crawler.get_is_image_addable = MagicMock(return_value=on_is_addable_true) # type: ignore + # act + crawled = self.crawler.crawl() + # assert + self.assertSetEqual(self.images, called_is_image_addable_with) + self.assertEqual(len(self.images), crawled) + + def test_crawl_is_addable_false(self) -> None: + # arrange + called_is_image_addable_with = ImageCollection() + + def on_is_addable_false(image: Image) -> bool: + called_is_image_addable_with.add(image) + return False + + self.crawler.get_is_image_addable = MagicMock(return_value=on_is_addable_false) # type: ignore + # act + crawled = self.crawler.crawl() + # assert + self.assertSetEqual(self.images, called_is_image_addable_with) + self.assertEqual(len(self.images), crawled) + + def test_crawl_image_added_called(self) -> None: + # arrange + called_image_added_with = ImageCollection() + + def on_get_image_added(image: Image) -> bool: + called_image_added_with.add(image) + return False + + self.crawler.get_image_added = MagicMock(return_value=on_get_image_added) # type: ignore + # act + crawled = self.crawler.crawl() + # assert + self.assertSetEqual(self.images, called_image_added_with) + self.assertEqual(len(self.images), crawled) + + +class ServerRefillTest(unittest.TestCase): + + def test_fill_up_to(self) -> None: + self.skipTest("write the test - use Random3Crawler") + + def test_refill_null_crawler(self) -> None: + self.skipTest("write the test - use NullCrawler") diff --git a/testing/test_core/test_crawlers.py b/testing/test_core/test_crawlers.py new file mode 100644 index 00000000..6ccca146 --- /dev/null +++ b/testing/test_core/test_crawlers.py @@ -0,0 +1,38 @@ +import unittest +from typing import List, Optional +from unittest.mock import MagicMock + +from ddt import data as ddt_data, ddt, unpack as ddt_unpack # type: ignore + +from nichtparasoup.core import Crawler, CrawlerCollection, _CrawlerWeight + +from .mockable_imagecrawler import MockableImageCrawler + + +@ddt +class CrawlersTest(unittest.TestCase): + + # TODO: remove ddt here, also from setup.py - write proper tests with a reasonable case + @ddt_data( # type: ignore + ([], 0, None), ([], 1, None), + ([1], 0, 0), ([1], 0.5, 0), ([1], 1, 0), + ([1, 2], 0, 0), ([0.7, 2], 0.5, 0), ([1, 2], 1.001, 1), ([1, 2], 3, 1), + ) + @ddt_unpack # type: ignore + def test_get_random(self, weights: List[_CrawlerWeight], + weight_rnd: _CrawlerWeight, expected_choice: Optional[int] + ) -> None: + # arrange + crawlers = CrawlerCollection() + crawlers._random_weight = MagicMock(return_value=weight_rnd) # type: ignore + for weight in weights: + crawlers.append(Crawler(MockableImageCrawler(), weight)) + + # act + random_crawler = crawlers.get_random() + + # assert + if expected_choice is None: + self.assertIsNone(random_crawler, "did not expect choice") + else: + self.assertIs(random_crawler, crawlers[expected_choice], "did expect different coice") diff --git a/testing/test_core/test_image.py b/testing/test_core/test_image.py new file mode 100644 index 00000000..35763310 --- /dev/null +++ b/testing/test_core/test_image.py @@ -0,0 +1,53 @@ +import unittest + +from nichtparasoup.core.image import Image, ImageCollection + + +class ImageTest(unittest.TestCase): + + def test_uri_is_hash(self) -> None: + # arrange + uri = 'test' + image = Image(uri, 'test') + # assert + self.assertEqual(hash(uri), hash(image)) + + def test_uri_makes_equal(self) -> None: + # arrange + uri = 'test' + image1 = Image(uri, 'test1') + image2 = Image(uri, 'test2') + # assert + self.assertEqual(image1, image2) + + def test_unequal_other_types(self) -> None: + # arrange + image = Image("testA", 'test') + other_types = [None, True, 23, 4.2, "", [], (), {}, self] # type: ignore + # assert + for other_type in other_types: + self.assertNotEqual(image, other_type) + + def test_equal(self) -> None: + # arrange + image1 = Image("testA", 'testA') + image2 = Image("testA", 'testA') + image3 = Image("testB", 'testB', is_generic=True) + image4 = Image("testB", 'testB', is_generic=True) + # assert + self.assertEqual(image1, image1) + self.assertEqual(image1, image2) + self.assertEqual(image3, image3) + self.assertNotEqual(image3, image4) + + def test_remove_nongeneric_from_container(self) -> None: + # arrange + image1 = Image("testA", 'testA', is_generic=True) + image2 = Image("testA", 'testA', is_generic=True) + images = ImageCollection() + images.add(image1) + # act + images.discard(image2) + # assert + self.assertEqual(1, len(images)) + self.assertIn(image1, images) diff --git a/testing/test_core/test_imagerecognizer.py b/testing/test_core/test_imagerecognizer.py new file mode 100644 index 00000000..178a7a6b --- /dev/null +++ b/testing/test_core/test_imagerecognizer.py @@ -0,0 +1,30 @@ +import unittest + +from nichtparasoup.core.imagecrawler import ImageRecognizer + + +class BaseImageCrawlerPathIsImageTest(unittest.TestCase): + + def test_path_is_image(self) -> None: + # arrange + recognizer = ImageRecognizer() + image_file_extensions = ('jpg', 'jpeg', 'gif', 'png', 'svg') + # act & assert + for image_file_extension in image_file_extensions: + image_file_path = 'foo.' + image_file_extension + self.assertTrue(recognizer.path_is_image(image_file_path), image_file_path) + self.assertTrue(recognizer.path_is_image(image_file_path + '?foo'), image_file_path) + self.assertTrue(recognizer.path_is_image(image_file_path + '#bar'), image_file_path) + self.assertTrue(recognizer.path_is_image(image_file_path + '?foo#bar'), image_file_path) + + def test_path_is_not_image(self) -> None: + # arrange + recognizer = ImageRecognizer() + not_image_file_extensions = ('', '/', '.html', '.js', '.css') + # act & assert + for not_image_file_extension in not_image_file_extensions: + not_image_file_path = 'foo' + not_image_file_extension + self.assertFalse(recognizer.path_is_image(not_image_file_path), not_image_file_path) + self.assertFalse(recognizer.path_is_image(not_image_file_path + '?foo'), not_image_file_path) + self.assertFalse(recognizer.path_is_image(not_image_file_path + '#bar'), not_image_file_path) + self.assertFalse(recognizer.path_is_image(not_image_file_path + '?foo#bar'), not_image_file_path) diff --git a/testing/test_core/test_npcore.py b/testing/test_core/test_npcore.py new file mode 100644 index 00000000..1e8c1654 --- /dev/null +++ b/testing/test_core/test_npcore.py @@ -0,0 +1,48 @@ +import unittest + +from nichtparasoup.core import NPCore +from nichtparasoup.core.image import Image + +from .mockable_imagecrawler import MockableImageCrawler + + +class NPCoreTest(unittest.TestCase): + + def test__is_image_not_in_blacklist(self) -> None: + # arrange + image1 = Image("test1", 'test') + image2 = Image("test2", 'test') + core = NPCore() + # act + core.blacklist.add(image1.uri) + # assert + self.assertTrue(core._is_image_not_in_blacklist(image2), 'expected image in blacklist') + self.assertFalse(core._is_image_not_in_blacklist(image1), 'expected image not in blacklist') + + def test__add_image_to_blacklist(self) -> None: + # arrange + image1 = Image("test1", 'test') + image2 = Image("test2", 'test') + core = NPCore() + # act + core._add_image_to_blacklist(image1) + # assert + self.assertIn(image1.uri, core.blacklist) + self.assertNotIn(image2.uri, core.blacklist) + + def test_add_and_has_imagecrawler(self) -> None: + # arrange + core = NPCore() + imagecrawler1 = MockableImageCrawler(foo='bar') + imagecrawler2 = MockableImageCrawler(bar='bazz') + imagecrawler3 = MockableImageCrawler(bar='bazz') + # act + core.add_imagecrawler(imagecrawler1, 1) + core.add_imagecrawler(imagecrawler2, 1) + # assert + self.assertEqual(2, len(core.crawlers)) + self.assertListEqual( + [imagecrawler1, imagecrawler2], + [crawler.imagecrawler for crawler in core.crawlers]) + self.assertTrue(core.has_imagecrawler(imagecrawler1)) + self.assertTrue(core.has_imagecrawler(imagecrawler3)) diff --git a/testing/test_core/test_server.py b/testing/test_core/test_server.py new file mode 100644 index 00000000..a2233afb --- /dev/null +++ b/testing/test_core/test_server.py @@ -0,0 +1,161 @@ +import unittest +from typing import Any, Dict +from unittest.mock import MagicMock + +from nichtparasoup.core import NPCore +from nichtparasoup.core.image import Image, ImageCollection +from nichtparasoup.core.server import Server + +from .mockable_imagecrawler import MockableImageCrawler + + +class ServerGetImageTest(unittest.TestCase): + + def setUp(self) -> None: + self.server = Server(NPCore()) + + def tearDown(self) -> None: + del self.server + + def test_get_image_no_crawler(self) -> None: + # act + image = self.server.get_image() + # assert + self.assertIsNone(image) + + def test_get_image_no_images(self) -> None: + # arrange + self.server.core.add_imagecrawler(MockableImageCrawler(), 1) + # act + image = self.server.get_image() + # assert + self.assertIsNone(image) + + def test_get_image_some_images(self) -> None: + # arrange + image_crawled = Image('testURI', is_generic=True, source='testSource', bla=1, foo="bar") + imagecrawler = MockableImageCrawler() + imagecrawler.crawl = MagicMock(return_value=ImageCollection([image_crawled])) # type: ignore + self.server.core.add_imagecrawler(imagecrawler, 1) + crawler = self.server.core.crawlers[0] + # act + self.server.core.crawlers[0].crawl() + image_got = self.server.get_image() + # assert + self.assertIsInstance(image_got, dict) + if image_got: # needed to trick mypy + self.assertEqual(image_got.get("uri"), image_crawled.uri) + self.assertEqual(image_got.get("is_generic"), image_crawled.is_generic) + self.assertEqual(image_got.get("source"), image_crawled.source) + self.assertEqual(image_got.get("more"), image_crawled.more) + self.assertIsInstance(image_got.get("crawler"), dict) + image_got_crawler = image_got["crawler"] # type: Dict[str, Any] + self.assertEqual(image_got_crawler.get("id"), id(crawler)) + self.assertEqual(image_got_crawler.get("type"), type(imagecrawler).__name__) + + +class ServerResetTest(unittest.TestCase): + def setUp(self) -> None: + self.server = Server(NPCore(), reset_timeout=7357) + self.reset_called = False + self.server._reset = self.mock__reset # type: ignore + + def mock__reset(self) -> None: + self.reset_called = True + + def tearDown(self) -> None: + del self.reset_called + del self.server + + def test_not_running(self) -> None: + # act + res = self.server.request_reset() + # assert + self.assertTrue(self.reset_called) + self.assertDictEqual(res, dict( + requested=True, + timeout=0, + )) + + def test_running_under_timeout(self) -> None: + from time import time + # arrange + self.server.is_alive = lambda: True # type: ignore + self.server._stats.time_started = int(time()) + # act + res = self.server.request_reset() + # assert + self.assertFalse(self.reset_called) + self.assertDictEqual(res, dict( + requested=False, + timeout=self.server.reset_timeout, + )) + + def test_running_over_timeout(self) -> None: + from time import time + # arrange + self.server.is_alive = lambda: True # type: ignore + self.server._stats.time_started = int(time()) - 2 * self.server.reset_timeout + # act + res = self.server.request_reset() + # assert + self.assertTrue(self.reset_called) + self.assertDictEqual(res, dict( + requested=True, + timeout=self.server.reset_timeout, + )) + + +class ServerStartStopTest(unittest.TestCase): + + def setUp(self) -> None: + self.server = Server(NPCore()) + + def tearDown(self) -> None: + try: + self.server.stop() + except BaseException: + pass + finally: + del self.server + + def test_start_unlocked(self) -> None: + # act + self.server.start() + # assert + self.assertFalse(self.server._locks.run.locked()) + + def test_start_while_running_unlocked(self) -> None: + # arrange + self.server.start() + # act + with self.assertRaises(RuntimeError): + self.server.start() + # assert + self.assertFalse(self.server._locks.run.locked()) + + def test_stop_unlocked(self) -> None: + # arrange + self.server.start() + # act + self.server.stop() + # assert + self.assertFalse(self.server._locks.run.locked()) + + def test_stop_while_not_running_unlocked(self) -> None: + # act + with self.assertRaises(RuntimeError): + self.server.stop() + # assert + self.assertFalse(self.server._locks.run.locked()) + + def test_start_stop_repeat(self) -> None: + self.assertFalse(self.server.is_alive()) + self.server.start() + self.assertTrue(self.server.is_alive()) + self.server.stop() + self.assertFalse(self.server.is_alive()) + self.server.start() + self.assertTrue(self.server.is_alive()) + self.server.stop() + self.assertFalse(self.server.is_alive()) diff --git a/testing/test_core/test_serverrefiller.py b/testing/test_core/test_serverrefiller.py new file mode 100644 index 00000000..bf2f0d20 --- /dev/null +++ b/testing/test_core/test_serverrefiller.py @@ -0,0 +1,2 @@ + +# TODO: write tests diff --git a/testing/test_core/test_serverstatus.py b/testing/test_core/test_serverstatus.py new file mode 100644 index 00000000..d67cfebc --- /dev/null +++ b/testing/test_core/test_serverstatus.py @@ -0,0 +1,63 @@ +import unittest +from typing import Any, Dict + +from nichtparasoup.core import NPCore +from nichtparasoup.core.server import Server, ServerStatus + +from .mockable_imagecrawler import MockableImageCrawler + + +class ServerStatusStableTest(unittest.TestCase): + + def setUp(self) -> None: + core = NPCore() + self.imagecrawlers = ((MockableImageCrawler(t=1), 1), (MockableImageCrawler(t=2), 1)) + for (imagecrawler, weight) in self.imagecrawlers: + core.add_imagecrawler(imagecrawler, weight) + self.server = Server(core) + + def tearDown(self) -> None: + del self.server + + def test_server(self) -> None: + # act + status = ServerStatus.server(self.server) + # assert + self.assertIsInstance(status, dict) + self.assertIsInstance(status.get("version"), str) + self.assertIsInstance(status.get("uptime"), int) + self.assertIsInstance(status.get("reset"), dict) + status_reser = status["reset"] # type: Dict[Any, Any] + self.assertIsInstance(status_reser.get("since"), int) + self.assertIsInstance(status_reser.get("count"), int) + self.assertIsInstance(status.get("images"), dict) + status_images = status["images"] # type: Dict[Any, Any] + self.assertIsInstance(status_images.get("served"), int) + self.assertIsInstance(status_images.get("crawled"), int) + + def test_blacklist(self) -> None: + # act + status = ServerStatus.blacklist(self.server) + # assert + self.assertIsInstance(status, dict) + self.assertIsInstance(status.get("len"), int) + self.assertIsInstance(status.get("size"), int) + + def test_crawlers(self) -> None: + # act + status = ServerStatus.crawlers(self.server) + # assert + self.assertIsInstance(status, dict) + self.assertEqual(len(self.imagecrawlers), len(status)) + for crawler in self.server.core.crawlers: + crawler_id = id(crawler) + self.assertIsInstance(status.get(crawler_id), dict) + crawler_status = status[crawler_id] # type: Dict[Any, Any] + self.assertIsInstance(crawler_status, dict) + self.assertIsInstance(crawler_status.get("type"), str) + self.assertIsInstance(crawler_status.get("weight"), (int, float)) + self.assertIsInstance(crawler_status.get("config"), dict) + self.assertIsInstance(crawler_status.get("images"), dict) + crawler_status_images = crawler_status["images"] # type: Dict[Any, Any] + self.assertIsInstance(crawler_status_images.get("len"), int) + self.assertIsInstance(crawler_status_images.get("size"), int) diff --git a/testing/test_imagecrawler/__init__.py b/testing/test_imagecrawler/__init__.py new file mode 100644 index 00000000..5a33673b --- /dev/null +++ b/testing/test_imagecrawler/__init__.py @@ -0,0 +1,47 @@ +from collections import OrderedDict +from http.client import HTTPResponse +from os.path import join as path_join +from typing import Dict, Optional, Tuple +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +from nichtparasoup.core.imagecrawler import RemoteFetcher + + +class _FileFetcher(RemoteFetcher): + + def __init__(self, known_files: Dict[str, str], base_dir: Optional[str] = None) -> None: + super().__init__() + self._known_files = {self.__class__._uri_sort_query(k): v for k, v in known_files.items()} + self._dir = base_dir + + @classmethod + def _uri_sort_query(cls, uri: str) -> str: + scheme, netloc, path, params, query, fragment = urlparse(uri) + if query == '': + query_sorted = query + else: + query_dict = parse_qs(query, keep_blank_values=True) + query_dict_sorted = OrderedDict((k, query_dict[k]) for k in sorted(query_dict)) + query_sorted = urlencode(query_dict_sorted, doseq=True) + uri_sorted = urlunparse((scheme, netloc, path, params, query_sorted, fragment)) + return uri_sorted + + def _get_file_uri(self, uri: str) -> str: + _, _, url, params, query, fragment = urlparse(uri) + uri_abs = urlunparse(('', '', url, params, query, fragment)) + uri_sorted = self.__class__._uri_sort_query(uri_abs) + file_known = self._known_files.get(uri_sorted) + if not file_known: + raise FileNotFoundError('uri unexpected: {}'.format(uri_sorted)) + if self._dir: + file_known = path_join(self._dir, file_known) + return 'file://' + file_known + + @staticmethod + def _valid_uri(uri: str) -> bool: + scheme, _, _, _, _, _ = urlparse(uri) + return scheme == 'file' + + def get_stream(self, uri: str) -> Tuple[HTTPResponse, str]: + stream, _ = super().get_stream(self._get_file_uri(uri)) + return stream, uri diff --git a/testing/test_imagecrawler/test__filefetcher.py b/testing/test_imagecrawler/test__filefetcher.py new file mode 100644 index 00000000..03e82190 --- /dev/null +++ b/testing/test_imagecrawler/test__filefetcher.py @@ -0,0 +1,54 @@ +import unittest + +from . import _FileFetcher + + +class FileFetcherTest(unittest.TestCase): + + def test__uri_sort_query__dings(self) -> None: + self.assertEqual( + 'https://asdf', + _FileFetcher._uri_sort_query('https://asdf') + ) + + def test__uri_sort_query__path(self) -> None: + self.assertEqual( + 'https://as/df', + _FileFetcher._uri_sort_query('https://as/df') + ) + + def test__uri_sort_query__fragment(self) -> None: + self.assertEqual( + 'https://asdf#foo', + _FileFetcher._uri_sort_query('https://asdf#foo') + ) + + def test__uri_sort_query__no_query(self) -> None: + self.assertEqual( + 'https://asdf', + _FileFetcher._uri_sort_query('https://asdf?') + ) + + def test__uri_sort_query__empty_query(self) -> None: + self.assertEqual( + 'https://asdf?foo=', + _FileFetcher._uri_sort_query('https://asdf?foo=') + ) + + def test__uri_sort_query__query(self) -> None: + self.assertEqual( + 'https://asdf?foo=1', + _FileFetcher._uri_sort_query('https://asdf?foo=1') + ) + + def test__uri_sort_query__query_sorted(self) -> None: + self.assertEqual( + 'https://asdf?bar=1&foo=2', + _FileFetcher._uri_sort_query('https://asdf?bar=1&foo=2') + ) + + def test__uri_sort_query__unsorted(self) -> None: + self.assertEqual( + 'https://asdf?bar=2&foo=1', + _FileFetcher._uri_sort_query('https://asdf?foo=1&bar=2') + ) diff --git a/testing/test_imagecrawler/test_dummy.py b/testing/test_imagecrawler/test_dummy.py new file mode 100644 index 00000000..eac61688 --- /dev/null +++ b/testing/test_imagecrawler/test_dummy.py @@ -0,0 +1,66 @@ +import unittest + +from nichtparasoup.imagecrawler import get_class as get_imagecrawler_class +from nichtparasoup.imagecrawler.dummy import Dummy + + +class DummyConfigImageUriTest(unittest.TestCase): + + def test__check_config_right_value(self) -> None: + # arrange + config_in = dict(image_uri="test") + # act + config_out = Dummy.check_config(config_in) + # assert + self.assertDictEqual(config_in, config_out) + + def test__check_config_missing_value(self) -> None: + # assert + with self.assertRaises(KeyError): + Dummy.check_config(dict()) + + def test__check_config_wrong_type(self) -> None: + wrong_types = [None, True, 23, 4.2, [], (), {}, self] # type: ignore + for wrong_type in wrong_types: + # assert + with self.assertRaises(TypeError): + Dummy.check_config(dict(image_uri=wrong_type)) + + def test__check_config_wrong_value(self) -> None: + wrong_values = [""] + for wrong_value in wrong_values: + # assert + with self.assertRaises(ValueError): + Dummy.check_config(dict(image_uri=wrong_value)) + + +class DummyCrawlTest(unittest.TestCase): + + def test_crawl(self) -> None: + # arrange + crawler = Dummy(image_uri="test") + # act + images_crawled = crawler.crawl() + images_crawled_len = len(images_crawled) + image_crawled = images_crawled.pop() if images_crawled_len else None + # assert + self.assertEqual(images_crawled_len, 1, "no images crawled") + if image_crawled: + self.assertTrue(image_crawled.is_generic, "this is not a generic") + self.assertTrue(image_crawled.more.get("this_is_a_dummy"), "this is not a dummy") + + +class DummyDescriptionTest(unittest.TestCase): + def test_description_config(self) -> None: + # act + description = Dummy.info() + # assert + self.assertTrue('image_uri' in description.config) + + +class DummyLoaderTest(unittest.TestCase): + def test_get_imagecrawler_class(self) -> None: + # act + imagecrawler_class = get_imagecrawler_class("Dummy") + # assert + self.assertIs(imagecrawler_class, Dummy) diff --git a/testing/test_imagecrawler/test_picsum.py b/testing/test_imagecrawler/test_picsum.py new file mode 100644 index 00000000..2bb7bfb9 --- /dev/null +++ b/testing/test_imagecrawler/test_picsum.py @@ -0,0 +1,119 @@ +import unittest + +from nichtparasoup.imagecrawler import get_class as get_imagecrawler_class +from nichtparasoup.imagecrawler.picsum import Picsum + +_picsum_right_config = dict(width=800, height=600) + + +class PicsumConfigCorrect(unittest.TestCase): + + def test__check_config_right_value(self) -> None: + # arrange + config_in = _picsum_right_config.copy() + # act + config_out = Picsum.check_config(config_in) + # assert + self.assertDictEqual(config_in, config_out) + + +class PicsumConfigWidthTest(unittest.TestCase): + + def setUp(self) -> None: + self._picsum_right_config_wo_width = _picsum_right_config.copy() + del self._picsum_right_config_wo_width["width"] + + def tearDown(self) -> None: + del self._picsum_right_config_wo_width + + def test__check_config_missing_value(self) -> None: + # assert + with self.assertRaises(KeyError): + Picsum.check_config(self._picsum_right_config_wo_width) + + def test__check_config_wrong_type(self) -> None: + wrong_types = [None, True, "", [], (), {}, self] # type: ignore + for wrong_type in wrong_types: + # arrange + config_in = self._picsum_right_config_wo_width + config_in["width"] = wrong_type # type: ignore + # assert + with self.assertRaises(TypeError): + Picsum.check_config(config_in) + + def test__check_config_wrong_value(self) -> None: + wrong_values = [0, -1] + for wrong_value in wrong_values: + # arrange + config_in = self._picsum_right_config_wo_width + config_in["width"] = wrong_value + # assert + with self.assertRaises(ValueError): + Picsum.check_config(config_in) + + +class PicsumConfigHeightTest(unittest.TestCase): + + def setUp(self) -> None: + self._picsum_right_config_wo_height = _picsum_right_config.copy() + del self._picsum_right_config_wo_height["height"] + + def tearDown(self) -> None: + del self._picsum_right_config_wo_height + + def test__check_config_missing_value(self) -> None: + # assert + with self.assertRaises(KeyError): + Picsum.check_config(self._picsum_right_config_wo_height) + + def test__check_config_wrong_type(self) -> None: + wrong_types = [None, True, "", [], (), {}, self] # type: ignore + for wrong_type in wrong_types: + # arrange + config_in = self._picsum_right_config_wo_height + config_in["height"] = wrong_type # type: ignore + # assert + with self.assertRaises(TypeError): + Picsum.check_config(config_in) + + def test__check_config_wrong_value(self) -> None: + wrong_values = [0, -1] + for wrong_value in wrong_values: + # arrange + config_in = self._picsum_right_config_wo_height + config_in["height"] = wrong_value + # assert + with self.assertRaises(ValueError): + Picsum.check_config(config_in) + + +class PicsumCrawlTest(unittest.TestCase): + + def test_crawl(self) -> None: + # arrange + crawler = Picsum(**_picsum_right_config) + # act + images_crawled = crawler.crawl() + images_crawled_len = len(images_crawled) + image_crawled = images_crawled.pop() if images_crawled_len else None + # assert + self.assertEqual(images_crawled_len, crawler._bunch, "crawler did not finish") + if image_crawled: + self.assertTrue(image_crawled.is_generic, 'this is not generic') + + +class DummyDescriptionTest(unittest.TestCase): + def test_description_config(self) -> None: + # act + description = Picsum.info() + # assert + for config_key in _picsum_right_config.keys(): + self.assertTrue(config_key in description.config) + + +class PicsumLoaderTest(unittest.TestCase): + def test_get_imagecrawler_class(self) -> None: + # act + imagecrawler_class = get_imagecrawler_class("Picsum") + # assert + self.assertIs(imagecrawler_class, Picsum) diff --git a/testing/test_imagecrawler/test_reddit.py b/testing/test_imagecrawler/test_reddit.py new file mode 100644 index 00000000..aff1958a --- /dev/null +++ b/testing/test_imagecrawler/test_reddit.py @@ -0,0 +1,180 @@ +import unittest +from os.path import dirname, join as path_join + +from nichtparasoup.core.image import Image, ImageCollection +from nichtparasoup.imagecrawler import get_class as get_imagecrawler_class +from nichtparasoup.imagecrawler.reddit import Reddit + +from . import _FileFetcher + +_reddit_right_config = dict(subreddit='aww') + + +class RedditConfigCorrect(unittest.TestCase): + + def test__check_config_right_value(self) -> None: + # arrange + config_in = _reddit_right_config.copy() + # act + config_out = Reddit.check_config(config_in) + # assert + self.assertDictEqual(config_in, config_out) + + +class RedditConfigSubredditTest(unittest.TestCase): + + def setUp(self) -> None: + self._reddit_right_config_wo_subreddit = _reddit_right_config.copy() + del self._reddit_right_config_wo_subreddit["subreddit"] + + def tearDown(self) -> None: + del self._reddit_right_config_wo_subreddit + + def test_check_config_missing_value(self) -> None: + # assert + with self.assertRaises(KeyError): + Reddit.check_config(self._reddit_right_config_wo_subreddit) + + def test_check_config_wrong_type(self) -> None: + wrong_types = [None, True, 1, 0.1, [], (), {}, self] # type: ignore + for wrong_type in wrong_types: + # arrange + config_in = self._reddit_right_config_wo_subreddit + config_in["subreddit"] = wrong_type # type: ignore + # assert + with self.assertRaises(TypeError): + Reddit.check_config(config_in) + + def test_check_config_wrong_value(self) -> None: + wrong_values = [""] + for wrong_value in wrong_values: + # arrange + config_in = self._reddit_right_config_wo_subreddit + config_in["subreddit"] = wrong_value + # assert + with self.assertRaises(ValueError): + Reddit.check_config(config_in) + + def test_check_config_not_existing(self) -> None: + self.skipTest('TODO do we need this tested or even coded?') + pass + + +class RedditBuildUriTest(unittest.TestCase): + + def test__build_uri_at_front(self) -> None: + # arrange + crawler = Reddit(subreddit='foo') + # act + uri = crawler._get_uri(None) + # assert + self.assertEqual(uri, 'https://www.reddit.com/r/foo.json?after=') + + def test__build_uri_at_front__escape(self) -> None: + # arrange + crawler = Reddit(subreddit='foo/bar bazz') + # act + uri = crawler._get_uri(None) + # assert + self.assertEqual(uri, 'https://www.reddit.com/r/foo%2Fbar+bazz.json?after=') + + def test__build_uri_at_after(self) -> None: + # arrange + crawler = Reddit(subreddit='test') + # act + uri = crawler._get_uri('foobar') + # assert + self.assertEqual(uri, 'https://www.reddit.com/r/test.json?after=foobar') + + def test__build_uri_at_after__escape(self) -> None: + # arrange + crawler = Reddit(subreddit='test') + # act + uri = crawler._get_uri('foo/bar bazz') + # assert + self.assertEqual(uri, 'https://www.reddit.com/r/test.json?after=foo%2Fbar+bazz') + + +class RedditResetTest(unittest.TestCase): + + def test_reset_done(self) -> None: + # arrange + crawler = Reddit(subreddit='test') + crawler._after = 'foo' + # act + crawler._reset() + # assert + self.assertIsNone(crawler._after) + + +_FILE_FETCHER = _FileFetcher({ # relative to "../testdata_instagram" + '/r/aww.json?after=': 'aww.json', +}, base_dir=path_join(dirname(__file__), 'testdata_reddit')) + + +class RedditCrawlTest(unittest.TestCase): + + def setUp(self) -> None: + self.crawler = Reddit(subreddit='aww') + self.crawler._remote_fetcher = _FILE_FETCHER + + def tearDown(self) -> None: + del self.crawler + + def test_crawl(self) -> None: + # arrange + expected_after = 't3_dqx42l' + expected_images = ImageCollection() + expected_images.add(Image( + uri='https://i.redd.it/kl3dp9sy5fw31.jpg', + source='https://www.reddit.com/r/aww/comments/dqx0z4/a_very_photogenic_noodle/')) + expected_images.add(Image( + uri='https://i.redd.it/4ltnvj5irdw31.jpg', + source='https://www.reddit.com/r/aww/comments/dqud6w/3/')) + expected_images.add(Image( + uri='https://i.redd.it/nkfjoej8yew31.png', + source='https://www.reddit.com/r/aww/comments/dqwp8l/left_the_house_for_10_minutes_and_came_back_to/')) + expected_images.add(Image( + uri='https://i.redd.it/gcxqswv8igw31.png', + source='https://www.reddit.com/r/aww/comments/dqz6iz/blind_cutie/')) + expected_images.add(Image( + uri='https://i.redd.it/hywobahj9ew31.png', + source='https://www.reddit.com/r/aww/comments/dqvgm9/i_asked_this_guy_if_he_knocked_over_the_treats/')) + expected_images.add(Image( + uri='https://i.redd.it/j4qda3c9scw31.jpg', + source='https://www.reddit.com/r/aww/comments/dqrxiq/admiral_anchovies_is_two_weeks_old_and_has/')) + expected_images.add(Image( + uri='https://i.imgur.com/O2bVClA.jpg', + source='https://www.reddit.com/r/aww/comments/dqsk7y/meet_our_new_3_month_old_baby_bucko_the_beagle/', + )) + expected_images.add(Image( + uri='https://imgur.com/82LxoWj.jpg', + source='https://www.reddit.com/r/aww/comments/dqtdo7/im_one_of_a_kind/', + )) + # act + images = self.crawler._crawl() + # assert + self.assertEqual(self.crawler._after, expected_after) + self.assertSetEqual(images, expected_images) + for expected_image in expected_images: + for image in images: + if image == expected_image: + # sources are irrelevant for equality, need to be checked manually + self.assertEqual(image.source, expected_image.source) + + +class RedditDescriptionTest(unittest.TestCase): + def test_description_config(self) -> None: + # act + description = Reddit.info() + # assert + for config_key in _reddit_right_config.keys(): + self.assertTrue(config_key in description.config) + + +class RedditLoaderTest(unittest.TestCase): + def test_get_imagecrawler_class(self) -> None: + # act + imagecrawler_class = get_imagecrawler_class("Reddit") + # assert + self.assertIs(imagecrawler_class, Reddit) diff --git a/testing/test_imagecrawler/testdata_reddit/aww.json b/testing/test_imagecrawler/testdata_reddit/aww.json new file mode 100644 index 00000000..6726780d --- /dev/null +++ b/testing/test_imagecrawler/testdata_reddit/aww.json @@ -0,0 +1 @@ +{"kind": "Listing","data": {"modhash": "","dist": 25,"children": [{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_49ejg7hj","saved": false,"mod_reason_title": null,"gilded": 1,"clicked": false,"title": "When the massage is a bit too relaxing.","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": "lc","downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqxuda","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 48448,"total_awards_received": 4,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/useugfheqfw31/DASH_720?source=fallback","height": 720,"width": 576,"scrubber_media_url": "https://v.redd.it/useugfheqfw31/DASH_96","dash_url": "https://v.redd.it/useugfheqfw31/DASHPlaylist.mpd","duration": 9,"hls_url": "https://v.redd.it/useugfheqfw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 48448,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/6B6WCMoGSzLhaaLi11wbS6Wg7GtjplErHlsPwlLtszg.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {"gid_1": 2,"gid_2": 1},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572799891.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/Qd6621HHCo_7ie6qVX9sHznY4di81zuTXL9B9wBg_EY.png?format=pjpg&auto=webp&s=ffc00a8e692f5750118a5b4b727a48cd241bf97d","width": 576,"height": 720},"resolutions": [{"url": "https://external-preview.redd.it/Qd6621HHCo_7ie6qVX9sHznY4di81zuTXL9B9wBg_EY.png?width=108&crop=smart&format=pjpg&auto=webp&s=712fb5af3372ee1163e90c1a65d4131c96de99f7","width": 108,"height": 135},{"url": "https://external-preview.redd.it/Qd6621HHCo_7ie6qVX9sHznY4di81zuTXL9B9wBg_EY.png?width=216&crop=smart&format=pjpg&auto=webp&s=d1beb2ae7a1041e40be4732ba7f5370c5f9f5336","width": 216,"height": 270},{"url": "https://external-preview.redd.it/Qd6621HHCo_7ie6qVX9sHznY4di81zuTXL9B9wBg_EY.png?width=320&crop=smart&format=pjpg&auto=webp&s=af12aa2878bd8208d0e119c8df7253b66ffd8a58","width": 320,"height": 400}],"variants": {},"id": "810F91I4JHTOc-JGgLA-pZiW-64HwrNxIJJeC57j_DM"}],"enabled": false},"all_awardings": [{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Gives the author a week of Reddit Premium, %{coin_symbol}100 Coins to do with as they please, and shows a Gold Award.","end_date": null,"coin_reward": 100,"icon_url": "https://www.redditstatic.com/gold/awards/icon/gold_512.png","days_of_premium": 7,"id": "gid_2","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/gold_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 500,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Gold"},{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Spreads the word about Extra Life, gives the author a week of Reddit Premium and %{coin_symbol}100 Coins. You may even get a trophy!","end_date": 1572796800,"coin_reward": 100,"icon_url": "https://i.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png","days_of_premium": 7,"id": "award_ae374fe8-b6a4-4c1f-9f69-4c2b46fd5a44","icon_height": 512,"resized_icons": [{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=16&height=16&auto=webp&s=0b8f5d074b32bd5f1360af878f9954c690a911f5","width": 16,"height": 16},{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=32&height=32&auto=webp&s=c77d9387bf574eeeaf3d770470375baa66672ff7","width": 32,"height": 32},{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=48&height=48&auto=webp&s=af005548e65f4cd93214e0af6f313427b7a59b5c","width": 48,"height": 48},{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=64&height=64&auto=webp&s=5a5bb17914f227300bca6386be11f0d1531e31ab","width": 64,"height": 64},{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=128&height=128&auto=webp&s=6aa1daa52e6fa10e3c73e7ffe378f36ba989ebea","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": 1572537600,"coin_price": 500,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Extra Life"},{"count": 2,"is_enabled": true,"subreddit_id": null,"description": "Shows the Silver Award... and that's it.","end_date": null,"coin_reward": 0,"icon_url": "https://www.redditstatic.com/gold/awards/icon/silver_512.png","days_of_premium": 0,"id": "gid_1","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/silver_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 100,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Silver"}],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqxuda","is_robot_indexable": true,"report_reasons": null,"author": "ulirathex","discussion_type": null,"num_comments": 335,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqxuda/when_the_massage_is_a_bit_too_relaxing/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/useugfheqfw31","subreddit_subscribers": 22330694,"created_utc": 1572771091.0,"num_crossposts": 24,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/useugfheqfw31/DASH_720?source=fallback","height": 720,"width": 576,"scrubber_media_url": "https://v.redd.it/useugfheqfw31/DASH_96","dash_url": "https://v.redd.it/useugfheqfw31/DASHPlaylist.mpd","duration": 9,"hls_url": "https://v.redd.it/useugfheqfw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_video": true}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_1t6v","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "he finally caught it","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": "lc","downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqxvik","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 6117,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/pstmhxzcqfw31/DASH_720?source=fallback","height": 720,"width": 720,"scrubber_media_url": "https://v.redd.it/pstmhxzcqfw31/DASH_96","dash_url": "https://v.redd.it/pstmhxzcqfw31/DASHPlaylist.mpd","duration": 10,"hls_url": "https://v.redd.it/pstmhxzcqfw31/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 6117,"approved_by": null,"thumbnail": "https://a.thumbs.redditmedia.com/bKZBaSKUQSTMOaaUehIs9zmoqeZ099_MRR_W6z0tKL0.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572800162.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/vL5R7J3_lthXTKTdXNY9KCVcVQy_Mh13ecz45nzjliM.png?format=pjpg&auto=webp&s=83fc8b63f1b85da697a52bcea582f8ccbb1934ea","width": 720,"height": 720},"resolutions": [{"url": "https://external-preview.redd.it/vL5R7J3_lthXTKTdXNY9KCVcVQy_Mh13ecz45nzjliM.png?width=108&crop=smart&format=pjpg&auto=webp&s=ed2432db2b877a7626dbc78a94d706a62d5882bd","width": 108,"height": 108},{"url": "https://external-preview.redd.it/vL5R7J3_lthXTKTdXNY9KCVcVQy_Mh13ecz45nzjliM.png?width=216&crop=smart&format=pjpg&auto=webp&s=94a176902333c62b6d780c60992103c906a6dec6","width": 216,"height": 216},{"url": "https://external-preview.redd.it/vL5R7J3_lthXTKTdXNY9KCVcVQy_Mh13ecz45nzjliM.png?width=320&crop=smart&format=pjpg&auto=webp&s=91ccca06d662cc444b87d18c4a347ea4a6b8f820","width": 320,"height": 320},{"url": "https://external-preview.redd.it/vL5R7J3_lthXTKTdXNY9KCVcVQy_Mh13ecz45nzjliM.png?width=640&crop=smart&format=pjpg&auto=webp&s=923fe7832d92a89d11b410e1311cecc0e32f4322","width": 640,"height": 640}],"variants": {},"id": "CC94fjwTTf1lFGer8iyOgLN0eRJo7Tn5Z54bIocyBhA"}],"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqxvik","is_robot_indexable": true,"report_reasons": null,"author": "nora","discussion_type": null,"num_comments": 37,"send_replies": false,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqxvik/he_finally_caught_it/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/pstmhxzcqfw31","subreddit_subscribers": 22330694,"created_utc": 1572771362.0,"num_crossposts": 1,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/pstmhxzcqfw31/DASH_720?source=fallback","height": 720,"width": 720,"scrubber_media_url": "https://v.redd.it/pstmhxzcqfw31/DASH_96","dash_url": "https://v.redd.it/pstmhxzcqfw31/DASHPlaylist.mpd","duration": 10,"hls_url": "https://v.redd.it/pstmhxzcqfw31/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_video": true}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_17cp3z","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "A very photogenic noodle","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqx0z4","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 8890,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 8890,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/D4bqPHzg9WBia_6_zYjcGm8YB3_I_EJuB2ww083jn2I.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "image","content_categories": null,"is_self": false,"mod_note": null,"created": 1572793010.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.redd.it","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://preview.redd.it/kl3dp9sy5fw31.jpg?auto=webp&s=a7d762ede000a0fe3905629c4840253f0cf61e0a","width": 768,"height": 960},"resolutions": [{"url": "https://preview.redd.it/kl3dp9sy5fw31.jpg?width=108&crop=smart&auto=webp&s=9bb01d93954247c5dea81c330893efccd850446f","width": 108,"height": 135},{"url": "https://preview.redd.it/kl3dp9sy5fw31.jpg?width=216&crop=smart&auto=webp&s=b13a556ecbd7e0c50d9aaa4d38c6ef580de6dc94","width": 216,"height": 270},{"url": "https://preview.redd.it/kl3dp9sy5fw31.jpg?width=320&crop=smart&auto=webp&s=78da617fcfddfaffe782d03b821cc25cb6559123","width": 320,"height": 400},{"url": "https://preview.redd.it/kl3dp9sy5fw31.jpg?width=640&crop=smart&auto=webp&s=459bf9743140409db4f77de540fea25b64ac6887","width": 640,"height": 800}],"variants": {},"id": "tSsqzaxNT2HRl5rGkB8wB_C0frBx24m6WBkslzeHkgI"}],"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqx0z4","is_robot_indexable": true,"report_reasons": null,"author": "haylakess","discussion_type": null,"num_comments": 86,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqx0z4/a_very_photogenic_noodle/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.redd.it/kl3dp9sy5fw31.jpg","subreddit_subscribers": 22330694,"created_utc": 1572764210.0,"num_crossposts": 5,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_z1hqrxh","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Man gets chosen by lost kitten","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqwinv","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 6405,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 6405,"approved_by": null,"thumbnail": "https://a.thumbs.redditmedia.com/UfKWSlaoZA3aCaZCdvo4NwumF6mZUJoeD5TUmTwMC24.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "link","content_categories": null,"is_self": false,"mod_note": null,"crosspost_parent_list": [{"approved_at_utc": null,"subreddit": "Eyebleach","selftext": "","author_fullname": "t2_1pta4tzg","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Man gets chosen by lost kitten","link_flair_richtext": [],"subreddit_name_prefixed": "r/Eyebleach","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqjvoj","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 6932,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 6932,"approved_by": null,"thumbnail": "https://a.thumbs.redditmedia.com/UfKWSlaoZA3aCaZCdvo4NwumF6mZUJoeD5TUmTwMC24.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "link","content_categories": null,"is_self": false,"mod_note": null,"created": 1572728695.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.imgur.com","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/QmMuoeQSJl0zVX4n5GDImEIhY5pDp0kPZ916wrLHkDo.jpg?auto=webp&s=2a287acc97800bcdbb519f48004b8f4caba0f4aa","width": 608,"height": 854},"resolutions": [{"url": "https://external-preview.redd.it/QmMuoeQSJl0zVX4n5GDImEIhY5pDp0kPZ916wrLHkDo.jpg?width=108&crop=smart&auto=webp&s=3fbc4873716d90a4d5dd09c19957659995ec0194","width": 108,"height": 151},{"url": "https://external-preview.redd.it/QmMuoeQSJl0zVX4n5GDImEIhY5pDp0kPZ916wrLHkDo.jpg?width=216&crop=smart&auto=webp&s=0b015da728b8d5a3769053f119842805fbe61b38","width": 216,"height": 303},{"url": "https://external-preview.redd.it/QmMuoeQSJl0zVX4n5GDImEIhY5pDp0kPZ916wrLHkDo.jpg?width=320&crop=smart&auto=webp&s=6cead1654c438bfed53c35bbe72e490a66c160d9","width": 320,"height": 449}],"variants": {},"id": "fV2tqQ1P1skn9qlxkvGJINFvgQHtJ3yq7OFCkw6hFwk"}],"reddit_video_preview": {"fallback_url": "https://v.redd.it/um7xuw9qu9w31/DASH_720","height": 720,"width": 512,"scrubber_media_url": "https://v.redd.it/um7xuw9qu9w31/DASH_96","dash_url": "https://v.redd.it/um7xuw9qu9w31/DASHPlaylist.mpd","duration": 17,"hls_url": "https://v.redd.it/um7xuw9qu9w31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"},"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2s427","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqjvoj","is_robot_indexable": true,"report_reasons": null,"author": "St0pX","discussion_type": null,"num_comments": 31,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/Eyebleach/comments/dqjvoj/man_gets_chosen_by_lost_kitten/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.imgur.com/gmF5iIy.gifv","subreddit_subscribers": 1653510,"created_utc": 1572699895.0,"num_crossposts": 3,"media": null,"is_video": false}],"created": 1572789061.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.imgur.com","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/QmMuoeQSJl0zVX4n5GDImEIhY5pDp0kPZ916wrLHkDo.jpg?auto=webp&s=2a287acc97800bcdbb519f48004b8f4caba0f4aa","width": 608,"height": 854},"resolutions": [{"url": "https://external-preview.redd.it/QmMuoeQSJl0zVX4n5GDImEIhY5pDp0kPZ916wrLHkDo.jpg?width=108&crop=smart&auto=webp&s=3fbc4873716d90a4d5dd09c19957659995ec0194","width": 108,"height": 151},{"url": "https://external-preview.redd.it/QmMuoeQSJl0zVX4n5GDImEIhY5pDp0kPZ916wrLHkDo.jpg?width=216&crop=smart&auto=webp&s=0b015da728b8d5a3769053f119842805fbe61b38","width": 216,"height": 303},{"url": "https://external-preview.redd.it/QmMuoeQSJl0zVX4n5GDImEIhY5pDp0kPZ916wrLHkDo.jpg?width=320&crop=smart&auto=webp&s=6cead1654c438bfed53c35bbe72e490a66c160d9","width": 320,"height": 449}],"variants": {},"id": "fV2tqQ1P1skn9qlxkvGJINFvgQHtJ3yq7OFCkw6hFwk"}],"reddit_video_preview": {"fallback_url": "https://v.redd.it/um7xuw9qu9w31/DASH_720","height": 720,"width": 512,"scrubber_media_url": "https://v.redd.it/um7xuw9qu9w31/DASH_96","dash_url": "https://v.redd.it/um7xuw9qu9w31/DASHPlaylist.mpd","duration": 17,"hls_url": "https://v.redd.it/um7xuw9qu9w31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"},"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqwinv","is_robot_indexable": true,"stickied": false,"author": "chrisrobles","discussion_type": null,"num_comments": 45,"send_replies": false,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"crosspost_parent": "t3_dqjvoj","author_flair_text_color": null,"permalink": "/r/aww/comments/dqwinv/man_gets_chosen_by_lost_kitten/","parent_whitelist_status": "all_ads","report_reasons": null,"url": "https://i.imgur.com/gmF5iIy.gifv","subreddit_subscribers": 22330694,"created_utc": 1572760261.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_g1k87","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "This is Lily, the 10 year old we rescued yesterday. See if you can guess why we rescued her.","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqxd81","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 3580,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/f0uyfgy5efw31/DASH_720?source=fallback","height": 720,"width": 404,"scrubber_media_url": "https://v.redd.it/f0uyfgy5efw31/DASH_96","dash_url": "https://v.redd.it/f0uyfgy5efw31/DASHPlaylist.mpd","duration": 9,"hls_url": "https://v.redd.it/f0uyfgy5efw31/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 3580,"approved_by": null,"thumbnail": "https://a.thumbs.redditmedia.com/ySY64HqN3NwA48gjPwo33uPQ-Y6F3p9woqdajjrt7P4.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572795777.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/L2Kv92TcxMN5VnFRkn5CeXd6Fy2gHGCJL0oR_fTMHBU.png?format=pjpg&auto=webp&s=b5f839c6f23338eb0b18b0decbef6d03cd9cd0df","width": 720,"height": 1280},"resolutions": [{"url": "https://external-preview.redd.it/L2Kv92TcxMN5VnFRkn5CeXd6Fy2gHGCJL0oR_fTMHBU.png?width=108&crop=smart&format=pjpg&auto=webp&s=03816ab344f59ed3ed6ed2582b6d51da972c1192","width": 108,"height": 192},{"url": "https://external-preview.redd.it/L2Kv92TcxMN5VnFRkn5CeXd6Fy2gHGCJL0oR_fTMHBU.png?width=216&crop=smart&format=pjpg&auto=webp&s=8ee1f76b9c2297ee2f944867d7e1c36f82194f4c","width": 216,"height": 384},{"url": "https://external-preview.redd.it/L2Kv92TcxMN5VnFRkn5CeXd6Fy2gHGCJL0oR_fTMHBU.png?width=320&crop=smart&format=pjpg&auto=webp&s=912c60d8aea65ad7e3c3bda35c2dc7663026f545","width": 320,"height": 568},{"url": "https://external-preview.redd.it/L2Kv92TcxMN5VnFRkn5CeXd6Fy2gHGCJL0oR_fTMHBU.png?width=640&crop=smart&format=pjpg&auto=webp&s=f42ffc645205d709dc78e62d8c80d9b5a1491446","width": 640,"height": 1137}],"variants": {},"id": "hMhPa_PPQZ1lfHbGGnT-gpNUg7w3gLnms9EDlotr0Pk"}],"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqxd81","is_robot_indexable": true,"report_reasons": null,"author": "StevenAU","discussion_type": null,"num_comments": 74,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqxd81/this_is_lily_the_10_year_old_we_rescued_yesterday/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/f0uyfgy5efw31","subreddit_subscribers": 22330694,"created_utc": 1572766977.0,"num_crossposts": 0,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/f0uyfgy5efw31/DASH_720?source=fallback","height": 720,"width": 404,"scrubber_media_url": "https://v.redd.it/f0uyfgy5efw31/DASH_96","dash_url": "https://v.redd.it/f0uyfgy5efw31/DASHPlaylist.mpd","duration": 9,"hls_url": "https://v.redd.it/f0uyfgy5efw31/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_video": true}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_1bz7ss1u","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Little surprises","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dquuo0","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": "","subreddit_type": "public","ups": 21868,"total_awards_received": 0,"media_embed": {"content": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Falarmingdopeychickadee&url=https%3A%2F%2Fgfycat.com%2Falarmingdopeychickadee&image=https%3A%2F%2Fthumbs.gfycat.com%2FAlarmingDopeyChickadee-size_restricted.gif&key=2aa3c4d5f3de4f5b9120b660ad850dc9&type=text%2Fhtml&schema=gfycat\" width=\"480\" height=\"480\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","width": 480,"scrolling": false,"height": 480},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"type": "gfycat.com","oembed": {"provider_url": "https://gfycat.com","description": "Watch and share Pups Videos GIFs and Teacuppuppy GIFs by vani on Gfycat","title": "surprise","type": "video","author_name": "Gfycat","height": 480,"width": 480,"html": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Falarmingdopeychickadee&url=https%3A%2F%2Fgfycat.com%2Falarmingdopeychickadee&image=https%3A%2F%2Fthumbs.gfycat.com%2FAlarmingDopeyChickadee-size_restricted.gif&key=2aa3c4d5f3de4f5b9120b660ad850dc9&type=text%2Fhtml&schema=gfycat\" width=\"480\" height=\"480\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","thumbnail_width": 320,"version": "1.0","provider_name": "Gfycat","thumbnail_url": "https://thumbs.gfycat.com/AlarmingDopeyChickadee-size_restricted.gif","thumbnail_height": 320}},"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {"content": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Falarmingdopeychickadee&url=https%3A%2F%2Fgfycat.com%2Falarmingdopeychickadee&image=https%3A%2F%2Fthumbs.gfycat.com%2FAlarmingDopeyChickadee-size_restricted.gif&key=2aa3c4d5f3de4f5b9120b660ad850dc9&type=text%2Fhtml&schema=gfycat\" width=\"480\" height=\"480\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","width": 480,"scrolling": false,"media_domain_url": "https://www.redditmedia.com/mediaembed/dquuo0","height": 480},"link_flair_text": null,"can_mod_post": false,"score": 21868,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/IZzntWz5TnB1PqmcYXpZfFUIBIIIWSxMZ3khAnyIhWQ.jpg","edited": false,"author_flair_css_class": "k","steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "rich:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572778595.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "gfycat.com","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": "confidence","banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?format=png8&s=6da89a7e4bb7034e2f00f14a40a02342bf474ec7","width": 320,"height": 320},"resolutions": [{"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?width=108&crop=smart&format=png8&s=b99f0c9198e00a06d3787ae3a601e99d7776a08f","width": 108,"height": 108},{"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?width=216&crop=smart&format=png8&s=76d906c6ae82d5dd7632b46fae1ed187ba8ae480","width": 216,"height": 216},{"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?width=320&crop=smart&format=png8&s=f9b260e9fc029cca75b648c74c29617a7de54838","width": 320,"height": 320}],"variants": {"gif": {"source": {"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?s=e70f7a44add19977d6aa6f20c6b2f0f7cbe3df74","width": 320,"height": 320},"resolutions": [{"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?width=108&crop=smart&s=7989e03a77c7f0ef85f75720f98ff9ce59ab2770","width": 108,"height": 108},{"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?width=216&crop=smart&s=a9ae4f7a889e19c68b5edc5fea2ffd4738bc682a","width": 216,"height": 216},{"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?width=320&crop=smart&s=6e8f8fc67b65414633e490e79c3bb3bd1c07beae","width": 320,"height": 320}]},"mp4": {"source": {"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?format=mp4&s=eddab8a6b23125a576ac73d80323ec5900abfa46","width": 320,"height": 320},"resolutions": [{"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?width=108&format=mp4&s=c9c8090c9ab1861f27965d3c79fb09ade2a31518","width": 108,"height": 108},{"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?width=216&format=mp4&s=b45dda3cbe9f8c32400a2c6450568bd393e26c6f","width": 216,"height": 216},{"url": "https://external-preview.redd.it/ZcF2Im3x1B5MDOB_jIhYX9aBrZw0DUmIrkM1ZUBB9QA.gif?width=320&format=mp4&s=aca6674c8a566ff5163ebc60e86912dadcaafae8","width": 320,"height": 320}]}},"id": "EAfJ_g0LqRZLkVWheQLtFRCJGG8K16gx71y-FKI5V64"}],"reddit_video_preview": {"fallback_url": "https://v.redd.it/l5wfxsb4zdw31/DASH_480","height": 480,"width": 480,"scrubber_media_url": "https://v.redd.it/l5wfxsb4zdw31/DASH_96","dash_url": "https://v.redd.it/l5wfxsb4zdw31/DASHPlaylist.mpd","duration": 9,"hls_url": "https://v.redd.it/l5wfxsb4zdw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"},"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dquuo0","is_robot_indexable": true,"report_reasons": null,"author": "commonvanilla","discussion_type": null,"num_comments": 137,"send_replies": false,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": "dark","permalink": "/r/aww/comments/dquuo0/little_surprises/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://gfycat.com/alarmingdopeychickadee","subreddit_subscribers": 22330694,"created_utc": 1572749795.0,"num_crossposts": 12,"media": {"type": "gfycat.com","oembed": {"provider_url": "https://gfycat.com","description": "Watch and share Pups Videos GIFs and Teacuppuppy GIFs by vani on Gfycat","title": "surprise","type": "video","author_name": "Gfycat","height": 480,"width": 480,"html": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Falarmingdopeychickadee&url=https%3A%2F%2Fgfycat.com%2Falarmingdopeychickadee&image=https%3A%2F%2Fthumbs.gfycat.com%2FAlarmingDopeyChickadee-size_restricted.gif&key=2aa3c4d5f3de4f5b9120b660ad850dc9&type=text%2Fhtml&schema=gfycat\" width=\"480\" height=\"480\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","thumbnail_width": 320,"version": "1.0","provider_name": "Gfycat","thumbnail_url": "https://thumbs.gfycat.com/AlarmingDopeyChickadee-size_restricted.gif","thumbnail_height": 320}},"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_p8gjay9","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Heckin\u2019 good tiger","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqyvpt","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": "","subreddit_type": "public","ups": 1230,"total_awards_received": 0,"media_embed": {"content": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fhopefulrareabyssiniancat&url=https%3A%2F%2Fgfycat.com%2Fhopefulrareabyssiniancat&image=https%3A%2F%2Fthumbs.gfycat.com%2FHopefulRareAbyssiniancat-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat\" width=\"600\" height=\"821\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","width": 600,"scrolling": false,"height": 821},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"type": "gfycat.com","oembed": {"provider_url": "https://gfycat.com","description": "Watch and share more GIFs by Sezar4321 on Gfycat","title": "Heckin' good tiger","type": "video","author_name": "Gfycat","height": 821,"width": 600,"html": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fhopefulrareabyssiniancat&url=https%3A%2F%2Fgfycat.com%2Fhopefulrareabyssiniancat&image=https%3A%2F%2Fthumbs.gfycat.com%2FHopefulRareAbyssiniancat-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat\" width=\"600\" height=\"821\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","thumbnail_width": 624,"version": "1.0","provider_name": "Gfycat","thumbnail_url": "https://thumbs.gfycat.com/HopefulRareAbyssiniancat-size_restricted.gif","thumbnail_height": 854}},"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {"content": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fhopefulrareabyssiniancat&url=https%3A%2F%2Fgfycat.com%2Fhopefulrareabyssiniancat&image=https%3A%2F%2Fthumbs.gfycat.com%2FHopefulRareAbyssiniancat-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat\" width=\"600\" height=\"821\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","width": 600,"scrolling": false,"media_domain_url": "https://www.redditmedia.com/mediaembed/dqyvpt","height": 821},"link_flair_text": null,"can_mod_post": false,"score": 1230,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/jKqjQy7AzfluUGKgh5biBr888sKXfFjhEsxbwhGMf_Q.jpg","edited": false,"author_flair_css_class": "k","steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "rich:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572807387.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "gfycat.com","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": "confidence","banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/_y_QYvsKLH_XCmVNOIAerRN4WDBxElQJbrpG9pMO4Us.gif?format=png8&s=dc40880bbbf8c2af919febe10927fed0a7a6c505","width": 250,"height": 342},"resolutions": [{"url": "https://external-preview.redd.it/_y_QYvsKLH_XCmVNOIAerRN4WDBxElQJbrpG9pMO4Us.gif?width=108&crop=smart&format=png8&s=9865de03ed0def13f1bfba4d2b6ba5eb68506abc","width": 108,"height": 147},{"url": "https://external-preview.redd.it/_y_QYvsKLH_XCmVNOIAerRN4WDBxElQJbrpG9pMO4Us.gif?width=216&crop=smart&format=png8&s=733537eeb9fed5933efa1777da770388e8ed682d","width": 216,"height": 295}],"variants": {"gif": {"source": {"url": "https://external-preview.redd.it/_y_QYvsKLH_XCmVNOIAerRN4WDBxElQJbrpG9pMO4Us.gif?s=34eddbc0eff6ee8ec960c112af015c2caf091464","width": 250,"height": 342},"resolutions": [{"url": "https://external-preview.redd.it/_y_QYvsKLH_XCmVNOIAerRN4WDBxElQJbrpG9pMO4Us.gif?width=108&crop=smart&s=877ec4f8a3603a0521a6de69b4ca0e44e5db8f6b","width": 108,"height": 147},{"url": "https://external-preview.redd.it/_y_QYvsKLH_XCmVNOIAerRN4WDBxElQJbrpG9pMO4Us.gif?width=216&crop=smart&s=c220bbe04e9638f810f05367cd462b5f2bb6586f","width": 216,"height": 295}]},"mp4": {"source": {"url": "https://external-preview.redd.it/_y_QYvsKLH_XCmVNOIAerRN4WDBxElQJbrpG9pMO4Us.gif?format=mp4&s=11d49917f9e8ef07df1eb1e882b0ff1e45280bcb","width": 250,"height": 342},"resolutions": [{"url": "https://external-preview.redd.it/_y_QYvsKLH_XCmVNOIAerRN4WDBxElQJbrpG9pMO4Us.gif?width=108&format=mp4&s=c782e8cb791388abe24f0ed0843911cc3eb9eb17","width": 108,"height": 147},{"url": "https://external-preview.redd.it/_y_QYvsKLH_XCmVNOIAerRN4WDBxElQJbrpG9pMO4Us.gif?width=216&format=mp4&s=38e30cec28898478a76c057a5fed10d09bfd35d8","width": 216,"height": 295}]}},"id": "VPf2IOWIc6MtZDnkTwhBK0u8wRHtwli4FGEWR37SXRw"}],"reddit_video_preview": {"fallback_url": "https://v.redd.it/dwkn5nbkcgw31/DASH_720","height": 720,"width": 526,"scrubber_media_url": "https://v.redd.it/dwkn5nbkcgw31/DASH_96","dash_url": "https://v.redd.it/dwkn5nbkcgw31/DASHPlaylist.mpd","duration": 7,"hls_url": "https://v.redd.it/dwkn5nbkcgw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"},"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqyvpt","is_robot_indexable": true,"report_reasons": null,"author": "sezar4321","discussion_type": null,"num_comments": 23,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": "dark","permalink": "/r/aww/comments/dqyvpt/heckin_good_tiger/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://gfycat.com/hopefulrareabyssiniancat","subreddit_subscribers": 22330694,"created_utc": 1572778587.0,"num_crossposts": 0,"media": {"type": "gfycat.com","oembed": {"provider_url": "https://gfycat.com","description": "Watch and share more GIFs by Sezar4321 on Gfycat","title": "Heckin' good tiger","type": "video","author_name": "Gfycat","height": 821,"width": 600,"html": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fhopefulrareabyssiniancat&url=https%3A%2F%2Fgfycat.com%2Fhopefulrareabyssiniancat&image=https%3A%2F%2Fthumbs.gfycat.com%2FHopefulRareAbyssiniancat-size_restricted.gif&key=ed8fa8699ce04833838e66ce79ba05f1&type=text%2Fhtml&schema=gfycat\" width=\"600\" height=\"821\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","thumbnail_width": 624,"version": "1.0","provider_name": "Gfycat","thumbnail_url": "https://thumbs.gfycat.com/HopefulRareAbyssiniancat-size_restricted.gif","thumbnail_height": 854}},"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_4dh5jqlb","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "I am small, but I know when I need tummy rubs.","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": "lc","downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqsnec","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 57738,"total_awards_received": 2,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/doijssfb2dw31/DASH_480?source=fallback","height": 480,"width": 480,"scrubber_media_url": "https://v.redd.it/doijssfb2dw31/DASH_96","dash_url": "https://v.redd.it/doijssfb2dw31/DASHPlaylist.mpd","duration": 15,"hls_url": "https://v.redd.it/doijssfb2dw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 57738,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/N5HxzOajdTnWbPCqVI6z-JDwfwO7ZgygYjWSavFI83g.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {"gid_1": 1},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572767572.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/lnFJYM2CR9V4UpnuZxsGKu4WWtrO93d3hAFWm0UP93E.png?format=pjpg&auto=webp&s=45a830e407fe7066d3733e655ae4820ead109d85","width": 640,"height": 640},"resolutions": [{"url": "https://external-preview.redd.it/lnFJYM2CR9V4UpnuZxsGKu4WWtrO93d3hAFWm0UP93E.png?width=108&crop=smart&format=pjpg&auto=webp&s=1d47e6befde8f10269a75016af7fb17c0dea5ec0","width": 108,"height": 108},{"url": "https://external-preview.redd.it/lnFJYM2CR9V4UpnuZxsGKu4WWtrO93d3hAFWm0UP93E.png?width=216&crop=smart&format=pjpg&auto=webp&s=a5481739baa93a92f548b6636bdbec9ff0b641d1","width": 216,"height": 216},{"url": "https://external-preview.redd.it/lnFJYM2CR9V4UpnuZxsGKu4WWtrO93d3hAFWm0UP93E.png?width=320&crop=smart&format=pjpg&auto=webp&s=0c40801cfb49e57b507af389be36eacb5c834882","width": 320,"height": 320},{"url": "https://external-preview.redd.it/lnFJYM2CR9V4UpnuZxsGKu4WWtrO93d3hAFWm0UP93E.png?width=640&crop=smart&format=pjpg&auto=webp&s=4f29025688f15bde2f12432122a81a71efece39d","width": 640,"height": 640}],"variants": {},"id": "5E9LfleOFYYBNYypaELB50LFubORknuXolqEYEWpBNY"}],"enabled": false},"all_awardings": [{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Spreads the word about Extra Life, gives the author a week of Reddit Premium and %{coin_symbol}100 Coins. You may even get a trophy!","end_date": 1572796800,"coin_reward": 100,"icon_url": "https://i.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png","days_of_premium": 7,"id": "award_ae374fe8-b6a4-4c1f-9f69-4c2b46fd5a44","icon_height": 512,"resized_icons": [{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=16&height=16&auto=webp&s=0b8f5d074b32bd5f1360af878f9954c690a911f5","width": 16,"height": 16},{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=32&height=32&auto=webp&s=c77d9387bf574eeeaf3d770470375baa66672ff7","width": 32,"height": 32},{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=48&height=48&auto=webp&s=af005548e65f4cd93214e0af6f313427b7a59b5c","width": 48,"height": 48},{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=64&height=64&auto=webp&s=5a5bb17914f227300bca6386be11f0d1531e31ab","width": 64,"height": 64},{"url": "https://preview.redd.it/award_images/t5_22cerq/xcig59ep4qv31_ExtraLife.png?width=128&height=128&auto=webp&s=6aa1daa52e6fa10e3c73e7ffe378f36ba989ebea","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": 1572537600,"coin_price": 500,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Extra Life"},{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Shows the Silver Award... and that's it.","end_date": null,"coin_reward": 0,"icon_url": "https://www.redditstatic.com/gold/awards/icon/silver_512.png","days_of_premium": 0,"id": "gid_1","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/silver_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 100,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Silver"}],"awarders": ["LongEZE"],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqsnec","is_robot_indexable": true,"report_reasons": null,"author": "Drown_In_The_Void","discussion_type": null,"num_comments": 299,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqsnec/i_am_small_but_i_know_when_i_need_tummy_rubs/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/doijssfb2dw31","subreddit_subscribers": 22330694,"created_utc": 1572738772.0,"num_crossposts": 18,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/doijssfb2dw31/DASH_480?source=fallback","height": 480,"width": 480,"scrubber_media_url": "https://v.redd.it/doijssfb2dw31/DASH_96","dash_url": "https://v.redd.it/doijssfb2dw31/DASHPlaylist.mpd","duration": 15,"hls_url": "https://v.redd.it/doijssfb2dw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_video": true}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_11uvtk","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "I'm one of a kind \ud83d\ude01","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqtdo7","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 24818,"total_awards_received": 1,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 24818,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/L_NxyxSCM8Zt9mVDIH9qimxRUFF6eQLoV6PfiFTiNcs.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {"gid_1": 1},"post_hint": "image","content_categories": null,"is_self": false,"mod_note": null,"created": 1572771082.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "imgur.com","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/u5HcdXWE4MHy1Ou4m7x1gsq8OEMtH2S8BATnmQUi2go.jpg?auto=webp&s=59780c218aa04a2eabb5535f859ffd56de68637f","width": 744,"height": 744},"resolutions": [{"url": "https://external-preview.redd.it/u5HcdXWE4MHy1Ou4m7x1gsq8OEMtH2S8BATnmQUi2go.jpg?width=108&crop=smart&auto=webp&s=7bda46b99ce9cac3a573bf99b1368e11a8f86657","width": 108,"height": 108},{"url": "https://external-preview.redd.it/u5HcdXWE4MHy1Ou4m7x1gsq8OEMtH2S8BATnmQUi2go.jpg?width=216&crop=smart&auto=webp&s=dcd96983bb0b8296a64138f675a5ac7fca49b19c","width": 216,"height": 216},{"url": "https://external-preview.redd.it/u5HcdXWE4MHy1Ou4m7x1gsq8OEMtH2S8BATnmQUi2go.jpg?width=320&crop=smart&auto=webp&s=bc8dc6ab5e73bf03a373e7c40621da271011c43f","width": 320,"height": 320},{"url": "https://external-preview.redd.it/u5HcdXWE4MHy1Ou4m7x1gsq8OEMtH2S8BATnmQUi2go.jpg?width=640&crop=smart&auto=webp&s=267b276b6cae287ba58543fe417e9d24d3a2636c","width": 640,"height": 640}],"variants": {},"id": "_nFbYeRh7AVwhv5vkmv7kAYApiwrD-dib4HiIlFPBRQ"}],"enabled": true},"all_awardings": [{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Shows the Silver Award... and that's it.","end_date": null,"coin_reward": 0,"icon_url": "https://www.redditstatic.com/gold/awards/icon/silver_512.png","days_of_premium": 0,"id": "gid_1","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/silver_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 100,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Silver"}],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqtdo7","is_robot_indexable": true,"report_reasons": null,"author": "Jcb2016","discussion_type": null,"num_comments": 206,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqtdo7/im_one_of_a_kind/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://imgur.com/82LxoWj.jpg","subreddit_subscribers": 22330694,"created_utc": 1572742282.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_3cfo9v63","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Dog has a condition that makes it hard to get food down so he eats in a special high chair.","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": "lc","downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqvsro","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 5050,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 5050,"approved_by": null,"thumbnail": "https://a.thumbs.redditmedia.com/xBsQPBtoGEk2mpb0idGVihfdIKLpTzpAK8Ot2-2eQa0.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "link","content_categories": null,"is_self": false,"mod_note": null,"crosspost_parent_list": [{"approved_at_utc": null,"subreddit": "interestingasfuck","selftext": "","author_fullname": "t2_lzzcv","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Dog has a condition that makes it hard to get food down so he eats in a special high chair.","link_flair_richtext": [],"subreddit_name_prefixed": "r/interestingasfuck","hidden": false,"pwls": 6,"link_flair_css_class": "approve","downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_85dm7e","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 60895,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": "/r/ALL","can_mod_post": false,"score": 60895,"approved_by": null,"thumbnail": "https://a.thumbs.redditmedia.com/xBsQPBtoGEk2mpb0idGVihfdIKLpTzpAK8Ot2-2eQa0.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "link","content_categories": null,"is_self": false,"mod_note": null,"created": 1521431122.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.imgur.com","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": true,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?auto=webp&s=9cf346723d666438d0ccb68b1d19ac9087c218f4","width": 720,"height": 720},"resolutions": [{"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?width=108&crop=smart&auto=webp&s=aabfe7305ffebb96c407c32fb70d027ab421e3d6","width": 108,"height": 108},{"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?width=216&crop=smart&auto=webp&s=0d1e52edced927fcc0dba064bf92112ae1f9c20a","width": 216,"height": 216},{"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?width=320&crop=smart&auto=webp&s=52081f9c9f7b6c1cacda1ef643753431de485c16","width": 320,"height": 320},{"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?width=640&crop=smart&auto=webp&s=2b61dc957bda3db945aa32f526435c805dcc2c63","width": 640,"height": 640}],"variants": {},"id": "U8VKApRUHr_8mIeoSVOwqyv-cjIqATwuOdtSw0M_TxU"}],"reddit_video_preview": {"fallback_url": "https://v.redd.it/4yroiy5ernh31/DASH_720","height": 720,"width": 720,"scrubber_media_url": "https://v.redd.it/4yroiy5ernh31/DASH_96","dash_url": "https://v.redd.it/4yroiy5ernh31/DASHPlaylist.mpd","duration": 12,"hls_url": "https://v.redd.it/4yroiy5ernh31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"},"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qhsa","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "85dm7e","is_robot_indexable": true,"report_reasons": null,"author": "natsdorf","discussion_type": null,"num_comments": 690,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/interestingasfuck/comments/85dm7e/dog_has_a_condition_that_makes_it_hard_to_get/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.imgur.com/qjjtZMz.gifv","subreddit_subscribers": 4429808,"created_utc": 1521402322.0,"num_crossposts": 48,"media": null,"is_video": false}],"created": 1572784177.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.imgur.com","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?auto=webp&s=9cf346723d666438d0ccb68b1d19ac9087c218f4","width": 720,"height": 720},"resolutions": [{"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?width=108&crop=smart&auto=webp&s=aabfe7305ffebb96c407c32fb70d027ab421e3d6","width": 108,"height": 108},{"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?width=216&crop=smart&auto=webp&s=0d1e52edced927fcc0dba064bf92112ae1f9c20a","width": 216,"height": 216},{"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?width=320&crop=smart&auto=webp&s=52081f9c9f7b6c1cacda1ef643753431de485c16","width": 320,"height": 320},{"url": "https://external-preview.redd.it/38IpRMSq1SlijLMSyfdZcefQIwb8DHNfFMDY8SQ8ZMs.jpg?width=640&crop=smart&auto=webp&s=2b61dc957bda3db945aa32f526435c805dcc2c63","width": 640,"height": 640}],"variants": {},"id": "U8VKApRUHr_8mIeoSVOwqyv-cjIqATwuOdtSw0M_TxU"}],"reddit_video_preview": {"fallback_url": "https://v.redd.it/4yroiy5ernh31/DASH_720","height": 720,"width": 720,"scrubber_media_url": "https://v.redd.it/4yroiy5ernh31/DASH_96","dash_url": "https://v.redd.it/4yroiy5ernh31/DASHPlaylist.mpd","duration": 12,"hls_url": "https://v.redd.it/4yroiy5ernh31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"},"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqvsro","is_robot_indexable": true,"stickied": false,"author": "paolols","discussion_type": null,"num_comments": 60,"send_replies": false,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"crosspost_parent": "t3_85dm7e","author_flair_text_color": null,"permalink": "/r/aww/comments/dqvsro/dog_has_a_condition_that_makes_it_hard_to_get/","parent_whitelist_status": "all_ads","report_reasons": null,"url": "https://i.imgur.com/qjjtZMz.gifv","subreddit_subscribers": 22330694,"created_utc": 1572755377.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_11lnnz","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "<3","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqud6w","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 10757,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 10757,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/sh5TYWy92TQ9BK29r7DRhAoxu5gGp0NI2-0RsdsiASw.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "image","content_categories": null,"is_self": false,"mod_note": null,"created": 1572776048.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://preview.redd.it/4ltnvj5irdw31.jpg?auto=webp&s=96886bab7625c720ef0ec9ddef1eae925a526529","width": 720,"height": 899},"resolutions": [{"url": "https://preview.redd.it/4ltnvj5irdw31.jpg?width=108&crop=smart&auto=webp&s=ba8559af8ac09ef0fa05ba6d1c2967d1ccfcb622","width": 108,"height": 134},{"url": "https://preview.redd.it/4ltnvj5irdw31.jpg?width=216&crop=smart&auto=webp&s=c81c711308f512c241efe96d9a12542ea54531d3","width": 216,"height": 269},{"url": "https://preview.redd.it/4ltnvj5irdw31.jpg?width=320&crop=smart&auto=webp&s=238730776627f3070005c0175ed6e0ba6f6aab77","width": 320,"height": 399},{"url": "https://preview.redd.it/4ltnvj5irdw31.jpg?width=640&crop=smart&auto=webp&s=81d970236d363611303fd1a1f943368b4d01d9e0","width": 640,"height": 799}],"variants": {},"id": "DtIBH6hK8y_k9MHZlUjzcdiYwKcOIzc8zkX9z7xnEoI"}],"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqud6w","is_robot_indexable": true,"report_reasons": null,"author": "Der_Ist","discussion_type": null,"num_comments": 41,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqud6w/3/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.redd.it/4ltnvj5irdw31.jpg","subreddit_subscribers": 22330694,"created_utc": 1572747248.0,"num_crossposts": 1,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_g5pmt","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Dad seeing his children\u2019s red hair with enChroma glasses for the first time","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": "lc","downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqukpd","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 7602,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 7602,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/C5k_fGbzuqWRZHVuf3UCNJHFSAkUAhs3f1k3NwSly_k.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "link","content_categories": null,"is_self": false,"mod_note": null,"crosspost_parent_list": [{"approved_at_utc": null,"subreddit": "happycryingdads","selftext": "","author_fullname": "t2_ox5vo","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Dad seeing his children\u2019s red hair with enChroma glasses for the first time","link_flair_richtext": [],"subreddit_name_prefixed": "r/happycryingdads","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqpcdk","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 19671,"total_awards_received": 2,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/mpjsz7z5ubw31/DASH_360?source=fallback","height": 360,"width": 288,"scrubber_media_url": "https://v.redd.it/mpjsz7z5ubw31/DASH_96","dash_url": "https://v.redd.it/mpjsz7z5ubw31/DASHPlaylist.mpd","duration": 61,"hls_url": "https://v.redd.it/mpjsz7z5ubw31/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 19671,"approved_by": null,"thumbnail": "default","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {"gid_1": 1,"gid_3": 1},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572752819.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/l2da8UHRZoAW7gCO2Qg-EXT49YD8CCXTxGSO1YRPqPA.png?format=pjpg&auto=webp&s=3fbc086428bbe94472a32a050a31b68c5b4c8253","width": 288,"height": 360},"resolutions": [{"url": "https://external-preview.redd.it/l2da8UHRZoAW7gCO2Qg-EXT49YD8CCXTxGSO1YRPqPA.png?width=108&crop=smart&format=pjpg&auto=webp&s=f020135427d942f7b0f9ca8f322ea3b05b5a30f5","width": 108,"height": 135},{"url": "https://external-preview.redd.it/l2da8UHRZoAW7gCO2Qg-EXT49YD8CCXTxGSO1YRPqPA.png?width=216&crop=smart&format=pjpg&auto=webp&s=f8dd0065eb5e20026cbb42e6948180fda8e8ee77","width": 216,"height": 270}],"variants": {},"id": "xf05MxjV0w4iCLMy9LVTq9hTY4ois1cE7Mg7JnmLDiI"}],"enabled": false},"all_awardings": [{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Gives the author a month of Reddit Premium, which includes %{coin_symbol}700 Coins for that month, and shows a Platinum Award.","end_date": null,"coin_reward": 0,"icon_url": "https://www.redditstatic.com/gold/awards/icon/platinum_512.png","days_of_premium": 31,"id": "gid_3","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_128.png","width": 128,"height": 128}],"days_of_drip_extension": 31,"award_type": "global","start_date": null,"coin_price": 1800,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Platinum"},{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Shows the Silver Award... and that's it.","end_date": null,"coin_reward": 0,"icon_url": "https://www.redditstatic.com/gold/awards/icon/silver_512.png","days_of_premium": 0,"id": "gid_1","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/silver_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 100,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Silver"}],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_3232g","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqpcdk","is_robot_indexable": true,"report_reasons": null,"author": "ax_colleen","discussion_type": null,"num_comments": 599,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/happycryingdads/comments/dqpcdk/dad_seeing_his_childrens_red_hair_with_enchroma/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/mpjsz7z5ubw31","subreddit_subscribers": 237505,"created_utc": 1572724019.0,"num_crossposts": 9,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/mpjsz7z5ubw31/DASH_360?source=fallback","height": 360,"width": 288,"scrubber_media_url": "https://v.redd.it/mpjsz7z5ubw31/DASH_96","dash_url": "https://v.redd.it/mpjsz7z5ubw31/DASHPlaylist.mpd","duration": 61,"hls_url": "https://v.redd.it/mpjsz7z5ubw31/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_video": true}],"created": 1572777135.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/l2da8UHRZoAW7gCO2Qg-EXT49YD8CCXTxGSO1YRPqPA.png?auto=webp&s=f148c956155aaed8630aac2d51e8facd9c32cfcd","width": 288,"height": 360},"resolutions": [{"url": "https://external-preview.redd.it/l2da8UHRZoAW7gCO2Qg-EXT49YD8CCXTxGSO1YRPqPA.png?width=108&crop=smart&auto=webp&s=308c6684906978be422775a8056d9110fb3ce0f7","width": 108,"height": 135},{"url": "https://external-preview.redd.it/l2da8UHRZoAW7gCO2Qg-EXT49YD8CCXTxGSO1YRPqPA.png?width=216&crop=smart&auto=webp&s=3d33f5f3e6dcd3072c85187457e77b75cf3dca74","width": 216,"height": 270}],"variants": {},"id": "xf05MxjV0w4iCLMy9LVTq9hTY4ois1cE7Mg7JnmLDiI"}],"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqukpd","is_robot_indexable": true,"stickied": false,"author": "epicshenanigans","discussion_type": null,"num_comments": 275,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"crosspost_parent": "t3_dqpcdk","author_flair_text_color": null,"permalink": "/r/aww/comments/dqukpd/dad_seeing_his_childrens_red_hair_with_enchroma/","parent_whitelist_status": "all_ads","report_reasons": null,"url": "https://v.redd.it/mpjsz7z5ubw31","subreddit_subscribers": 22330694,"created_utc": 1572748335.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_647er","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Left the house for 10 minutes and came back to every seat taken. It was cute, so I didn\u2019t care.","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 94,"hide_score": false,"name": "t3_dqwp8l","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 2343,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 2343,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/qTup4DJIsFjkOmstzqOt2Q8Nzwuh3IKyOJWN3ghksmQ.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "image","content_categories": null,"is_self": false,"mod_note": null,"created": 1572790422.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://preview.redd.it/nkfjoej8yew31.png?auto=webp&s=c1a5179b8a58a2d40e631412cfeb20b8c8d14e30","width": 3954,"height": 2658},"resolutions": [{"url": "https://preview.redd.it/nkfjoej8yew31.png?width=108&crop=smart&auto=webp&s=3c47ad872766cce93cc6f69a66c16eb5476523f0","width": 108,"height": 72},{"url": "https://preview.redd.it/nkfjoej8yew31.png?width=216&crop=smart&auto=webp&s=1877eab99454b231bbe172a4e512198638d3bf1c","width": 216,"height": 145},{"url": "https://preview.redd.it/nkfjoej8yew31.png?width=320&crop=smart&auto=webp&s=41690d9487ad69db89a1be25f5512ac25af82674","width": 320,"height": 215},{"url": "https://preview.redd.it/nkfjoej8yew31.png?width=640&crop=smart&auto=webp&s=770eb8cd6b567ff36dcb8f8831557f3805fee8ef","width": 640,"height": 430},{"url": "https://preview.redd.it/nkfjoej8yew31.png?width=960&crop=smart&auto=webp&s=7af1cc1979e3d50d14120e6c33874b6d2e8bea04","width": 960,"height": 645},{"url": "https://preview.redd.it/nkfjoej8yew31.png?width=1080&crop=smart&auto=webp&s=132c4b4ad7b6df799d4a44edba86c0f3983d1ec5","width": 1080,"height": 726}],"variants": {},"id": "uyGlAuypl_WMhjA9g8G5VcWkJ4ynNX61TCOmKPDd7J4"}],"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqwp8l","is_robot_indexable": true,"report_reasons": null,"author": "Avii_Jade","discussion_type": null,"num_comments": 48,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqwp8l/left_the_house_for_10_minutes_and_came_back_to/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.redd.it/nkfjoej8yew31.png","subreddit_subscribers": 22330694,"created_utc": 1572761622.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_27x0kfva","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Cleft Lip Cutie \u2764\ufe0f","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dquwsm","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 4837,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/fe3qwx3xzdw31/DASH_480?source=fallback","height": 480,"width": 480,"scrubber_media_url": "https://v.redd.it/fe3qwx3xzdw31/DASH_96","dash_url": "https://v.redd.it/fe3qwx3xzdw31/DASHPlaylist.mpd","duration": 4,"hls_url": "https://v.redd.it/fe3qwx3xzdw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 4837,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/T8fk4uXemz5LnFr0YRb6fV--r9QjfJPxoz7eG1o-RSM.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572778937.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/5tRmQB_ZqI6C4lvaTJXQbHs3tqyiydLHp8Udc6d9hVs.png?format=pjpg&auto=webp&s=0c527f5a4535b81b6b70d52b8021cf89bfbfb7b3","width": 640,"height": 640},"resolutions": [{"url": "https://external-preview.redd.it/5tRmQB_ZqI6C4lvaTJXQbHs3tqyiydLHp8Udc6d9hVs.png?width=108&crop=smart&format=pjpg&auto=webp&s=a97ec2de24e99b2026e3a6470bfcf5e310abc6e7","width": 108,"height": 108},{"url": "https://external-preview.redd.it/5tRmQB_ZqI6C4lvaTJXQbHs3tqyiydLHp8Udc6d9hVs.png?width=216&crop=smart&format=pjpg&auto=webp&s=131f90b0126d47695b04711d3b02b9d4c61392c6","width": 216,"height": 216},{"url": "https://external-preview.redd.it/5tRmQB_ZqI6C4lvaTJXQbHs3tqyiydLHp8Udc6d9hVs.png?width=320&crop=smart&format=pjpg&auto=webp&s=088a7b41709edf35c932f125a9e2ec8d41c75813","width": 320,"height": 320},{"url": "https://external-preview.redd.it/5tRmQB_ZqI6C4lvaTJXQbHs3tqyiydLHp8Udc6d9hVs.png?width=640&crop=smart&format=pjpg&auto=webp&s=bc1e6e331ab6d3451684f1254b8c55b2eb3f8ae0","width": 640,"height": 640}],"variants": {},"id": "-QKrkuR7GdvKISCeWipFPBPVuhDjrZDFDXQ7GHAMy00"}],"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dquwsm","is_robot_indexable": true,"report_reasons": null,"author": "LineLogicWeb","discussion_type": null,"num_comments": 45,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dquwsm/cleft_lip_cutie/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/fe3qwx3xzdw31","subreddit_subscribers": 22330694,"created_utc": 1572750137.0,"num_crossposts": 0,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/fe3qwx3xzdw31/DASH_480?source=fallback","height": 480,"width": 480,"scrubber_media_url": "https://v.redd.it/fe3qwx3xzdw31/DASH_96","dash_url": "https://v.redd.it/fe3qwx3xzdw31/DASHPlaylist.mpd","duration": 4,"hls_url": "https://v.redd.it/fe3qwx3xzdw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_video": true}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_a5fhh2o","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "So pleased","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqtsm7","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 6307,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 6307,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/-4Pxdk5eFn9sKRy0gEL0FH3nZxvZeiVzBAFzCq78lQo.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "link","content_categories": null,"is_self": false,"mod_note": null,"crosspost_parent_list": [{"approved_at_utc": null,"subreddit": "maybemaybemaybe","selftext": "","author_fullname": "t2_4g3g1oiq","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Maybe maybe maybe","link_flair_richtext": [],"subreddit_name_prefixed": "r/maybemaybemaybe","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqqmc2","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 5390,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 5390,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/-4Pxdk5eFn9sKRy0gEL0FH3nZxvZeiVzBAFzCq78lQo.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "link","content_categories": null,"is_self": false,"mod_note": null,"created": 1572758222.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.imgur.com","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?format=png8&s=1f9849ee12008d6490e118ce5258193d31266c94","width": 518,"height": 518},"resolutions": [{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=108&crop=smart&format=png8&s=356221b4cc36ce3caff386e1cd2c618d2ea9e8f1","width": 108,"height": 108},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=216&crop=smart&format=png8&s=351d5afe0c01f86c51dc48d78166fbc5eb3ceec6","width": 216,"height": 216},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=320&crop=smart&format=png8&s=fe9c6df564f42f9aa3beb8bd118689a5b9127d77","width": 320,"height": 320}],"variants": {"gif": {"source": {"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?s=216a5083abd1d595edb490dc5b7597a18be2e4c0","width": 518,"height": 518},"resolutions": [{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=108&crop=smart&s=8bf1b98a05a9e6d53b5bf455fa29a4db54262670","width": 108,"height": 108},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=216&crop=smart&s=5522eb1c25cd01c06cb8e20f17c11b837b22a88a","width": 216,"height": 216},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=320&crop=smart&s=dc7428e518defff17a385281d1434ee82202dc8e","width": 320,"height": 320}]},"mp4": {"source": {"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?format=mp4&s=ceef30c202e2167055461ac5238e34474fa56770","width": 518,"height": 518},"resolutions": [{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=108&format=mp4&s=9e5f79756d662cc0af104a8ad0959ebd996cf462","width": 108,"height": 108},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=216&format=mp4&s=29780b24dea67f30a8c2cd9bac943545e6fb5067","width": 216,"height": 216},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=320&format=mp4&s=dd16537077d7c128153d29c55dfbbda95224c428","width": 320,"height": 320}]}},"id": "QoFZoXCmnT5bNPZSIj1EvbS7pUojmt9PjkDqrqA1ynE"}],"reddit_video_preview": {"fallback_url": "https://v.redd.it/5zjdcieeacw31/DASH_480","height": 480,"width": 480,"scrubber_media_url": "https://v.redd.it/5zjdcieeacw31/DASH_96","dash_url": "https://v.redd.it/5zjdcieeacw31/DASHPlaylist.mpd","duration": 61,"hls_url": "https://v.redd.it/5zjdcieeacw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"},"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_38e1l","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqqmc2","is_robot_indexable": true,"report_reasons": null,"author": "whatwouldgowrog","discussion_type": null,"num_comments": 133,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/maybemaybemaybe/comments/dqqmc2/maybe_maybe_maybe/","parent_whitelist_status": "all_ads","stickied": false,"url": "http://i.imgur.com/JAfc4ev.gifv","subreddit_subscribers": 687660,"created_utc": 1572729422.0,"num_crossposts": 17,"media": null,"is_video": false}],"created": 1572773125.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.imgur.com","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?format=png8&s=1f9849ee12008d6490e118ce5258193d31266c94","width": 518,"height": 518},"resolutions": [{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=108&crop=smart&format=png8&s=356221b4cc36ce3caff386e1cd2c618d2ea9e8f1","width": 108,"height": 108},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=216&crop=smart&format=png8&s=351d5afe0c01f86c51dc48d78166fbc5eb3ceec6","width": 216,"height": 216},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=320&crop=smart&format=png8&s=fe9c6df564f42f9aa3beb8bd118689a5b9127d77","width": 320,"height": 320}],"variants": {"gif": {"source": {"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?s=216a5083abd1d595edb490dc5b7597a18be2e4c0","width": 518,"height": 518},"resolutions": [{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=108&crop=smart&s=8bf1b98a05a9e6d53b5bf455fa29a4db54262670","width": 108,"height": 108},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=216&crop=smart&s=5522eb1c25cd01c06cb8e20f17c11b837b22a88a","width": 216,"height": 216},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=320&crop=smart&s=dc7428e518defff17a385281d1434ee82202dc8e","width": 320,"height": 320}]},"mp4": {"source": {"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?format=mp4&s=ceef30c202e2167055461ac5238e34474fa56770","width": 518,"height": 518},"resolutions": [{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=108&format=mp4&s=9e5f79756d662cc0af104a8ad0959ebd996cf462","width": 108,"height": 108},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=216&format=mp4&s=29780b24dea67f30a8c2cd9bac943545e6fb5067","width": 216,"height": 216},{"url": "https://external-preview.redd.it/ANYBSa4ODfsoVwHQSkHnIGbgmYaD1j4fc6L3_8YErxw.gif?width=320&format=mp4&s=dd16537077d7c128153d29c55dfbbda95224c428","width": 320,"height": 320}]}},"id": "QoFZoXCmnT5bNPZSIj1EvbS7pUojmt9PjkDqrqA1ynE"}],"reddit_video_preview": {"fallback_url": "https://v.redd.it/5zjdcieeacw31/DASH_480","height": 480,"width": 480,"scrubber_media_url": "https://v.redd.it/5zjdcieeacw31/DASH_96","dash_url": "https://v.redd.it/5zjdcieeacw31/DASHPlaylist.mpd","duration": 61,"hls_url": "https://v.redd.it/5zjdcieeacw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"},"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqtsm7","is_robot_indexable": true,"stickied": false,"author": "rufusfsr","discussion_type": null,"num_comments": 114,"send_replies": false,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"crosspost_parent": "t3_dqqmc2","author_flair_text_color": null,"permalink": "/r/aww/comments/dqtsm7/so_pleased/","parent_whitelist_status": "all_ads","report_reasons": null,"url": "http://i.imgur.com/JAfc4ev.gifv","subreddit_subscribers": 22330694,"created_utc": 1572744325.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_2sbcyog9","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Little Kitty Going for a Ride","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqsti1","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 8988,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/hw8emt4r4dw31/DASH_720?source=fallback","height": 720,"width": 720,"scrubber_media_url": "https://v.redd.it/hw8emt4r4dw31/DASH_96","dash_url": "https://v.redd.it/hw8emt4r4dw31/DASHPlaylist.mpd","duration": 18,"hls_url": "https://v.redd.it/hw8emt4r4dw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 8988,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/Ta1yn3d8437M4N79aLDp8EShaQpKpLGJWZ6zt6_FY-Y.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572768397.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/3VzOQTuY8ejQa7GDwIxPps1SL3e8YNNdkSeSJ3CKzgs.png?format=pjpg&auto=webp&s=5236b35c98628918d87b02827d32dd64a1c32dde","width": 720,"height": 720},"resolutions": [{"url": "https://external-preview.redd.it/3VzOQTuY8ejQa7GDwIxPps1SL3e8YNNdkSeSJ3CKzgs.png?width=108&crop=smart&format=pjpg&auto=webp&s=877b5757537ce5e5f8a5abb8ad37eabe95112a8d","width": 108,"height": 108},{"url": "https://external-preview.redd.it/3VzOQTuY8ejQa7GDwIxPps1SL3e8YNNdkSeSJ3CKzgs.png?width=216&crop=smart&format=pjpg&auto=webp&s=ecb0c4064aaf87b0479578470db0311e7c5d25dc","width": 216,"height": 216},{"url": "https://external-preview.redd.it/3VzOQTuY8ejQa7GDwIxPps1SL3e8YNNdkSeSJ3CKzgs.png?width=320&crop=smart&format=pjpg&auto=webp&s=8ca8a7a26715764ec6c62f1fe1cb1957ae06c3d8","width": 320,"height": 320},{"url": "https://external-preview.redd.it/3VzOQTuY8ejQa7GDwIxPps1SL3e8YNNdkSeSJ3CKzgs.png?width=640&crop=smart&format=pjpg&auto=webp&s=f5ba953cfccb294a3a5c8cc0b70107329d2f6f3a","width": 640,"height": 640}],"variants": {},"id": "9TZ-Wnn2E0BBb946-fk2wxXxhGOtSqDU3yPO-RhIEK0"}],"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqsti1","is_robot_indexable": true,"report_reasons": null,"author": "emperor_michinomiya","discussion_type": null,"num_comments": 72,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqsti1/little_kitty_going_for_a_ride/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/hw8emt4r4dw31","subreddit_subscribers": 22330694,"created_utc": 1572739597.0,"num_crossposts": 2,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/hw8emt4r4dw31/DASH_720?source=fallback","height": 720,"width": 720,"scrubber_media_url": "https://v.redd.it/hw8emt4r4dw31/DASH_96","dash_url": "https://v.redd.it/hw8emt4r4dw31/DASHPlaylist.mpd","duration": 18,"hls_url": "https://v.redd.it/hw8emt4r4dw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_video": true}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_2ez1bs0f","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Guy teaching duckling how to get up the steps","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqroyu","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 12717,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 12717,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/4bojB31MdQDCj1hHjLdPdwilNzvU56ZBwDWFjN81sjo.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"content_categories": null,"is_self": false,"mod_note": null,"crosspost_parent_list": [{"approved_at_utc": null,"subreddit": "Eyebleach","selftext": "[deleted]","user_reports": [],"saved": false,"mod_reason_title": null,"gilded": 1,"clicked": false,"title": "This duck learns how to get up the steps by watching his human friend.","link_flair_richtext": [],"subreddit_name_prefixed": "r/Eyebleach","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqocim","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": "","subreddit_type": "public","ups": 35277,"total_awards_received": 2,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 35277,"approved_by": null,"thumbnail": "default","edited": false,"author_flair_css_class": null,"steward_reports": [],"gildings": {"gid_1": 1,"gid_2": 1},"content_categories": null,"is_self": false,"mod_note": null,"created": 1572748603.0,"link_flair_type": "text","wls": 6,"banned_by": null,"domain": "i.imgur.com","allow_live_comments": true,"selftext_html": "<!-- SC_OFF --><div class=\"md\"><p>[deleted]</p>\n</div><!-- SC_ON -->","likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"all_awardings": [{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Gives the author a week of Reddit Premium, %{coin_symbol}100 Coins to do with as they please, and shows a Gold Award.","end_date": null,"coin_reward": 100,"icon_url": "https://www.redditstatic.com/gold/awards/icon/gold_512.png","days_of_premium": 7,"id": "gid_2","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/gold_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 500,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Gold"},{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Shows the Silver Award... and that's it.","end_date": null,"coin_reward": 0,"icon_url": "https://www.redditstatic.com/gold/awards/icon/silver_512.png","days_of_premium": 0,"id": "gid_1","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/silver_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 100,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Silver"}],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2s427","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqocim","is_robot_indexable": false,"report_reasons": null,"author": "[deleted]","discussion_type": null,"media": null,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_flair_text_color": "dark","permalink": "/r/Eyebleach/comments/dqocim/this_duck_learns_how_to_get_up_the_steps_by/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.imgur.com/EWvC5Zs.gifv","subreddit_subscribers": 1653510,"created_utc": 1572719803.0,"num_crossposts": 16,"num_comments": 162,"is_video": false}],"created": 1572763090.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.imgur.com","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqroyu","is_robot_indexable": true,"stickied": false,"author": "sosogusto","discussion_type": null,"num_comments": 60,"send_replies": false,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"crosspost_parent": "t3_dqocim","author_flair_text_color": null,"permalink": "/r/aww/comments/dqroyu/guy_teaching_duckling_how_to_get_up_the_steps/","parent_whitelist_status": "all_ads","report_reasons": null,"url": "https://i.imgur.com/EWvC5Zs.gifv","subreddit_subscribers": 22330694,"created_utc": 1572734290.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_11wgll","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "I'm here to help!!","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqyowy","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 589,"total_awards_received": 0,"media_embed": {"content": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fwelldocumentedblushinglcont&url=https%3A%2F%2Fgfycat.com%2Fwelldocumentedblushinglcont-dog&image=https%3A%2F%2Fthumbs.gfycat.com%2FWelldocumentedBlushingLcont-size_restricted.gif&key=2aa3c4d5f3de4f5b9120b660ad850dc9&type=text%2Fhtml&schema=gfycat\" width=\"480\" height=\"852\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","width": 480,"scrolling": false,"height": 852},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"oembed": {"provider_url": "https://gfycat.com","description": "Watch and share Dog GIFs by ItsMeTaylor on Gfycat","title": "Doggy Helper","author_name": "Gfycat","height": 852,"width": 480,"html": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fwelldocumentedblushinglcont&url=https%3A%2F%2Fgfycat.com%2Fwelldocumentedblushinglcont-dog&image=https%3A%2F%2Fthumbs.gfycat.com%2FWelldocumentedBlushingLcont-size_restricted.gif&key=2aa3c4d5f3de4f5b9120b660ad850dc9&type=text%2Fhtml&schema=gfycat\" width=\"480\" height=\"852\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","thumbnail_width": 250,"version": "1.0","provider_name": "Gfycat","thumbnail_url": "https://thumbs.gfycat.com/WelldocumentedBlushingLcont-size_restricted.gif","type": "video","thumbnail_height": 443},"type": "gfycat.com"},"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {"content": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fwelldocumentedblushinglcont&url=https%3A%2F%2Fgfycat.com%2Fwelldocumentedblushinglcont-dog&image=https%3A%2F%2Fthumbs.gfycat.com%2FWelldocumentedBlushingLcont-size_restricted.gif&key=2aa3c4d5f3de4f5b9120b660ad850dc9&type=text%2Fhtml&schema=gfycat\" width=\"480\" height=\"852\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","width": 480,"scrolling": false,"media_domain_url": "https://www.redditmedia.com/mediaembed/dqyowy","height": 852},"link_flair_text": null,"can_mod_post": false,"score": 589,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/sfwoaD4aCs3VheRdPeJ7cftOSAMEzloeMNXjJcKH5-g.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "rich:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572806139.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "gfycat.com","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/Qu5XlTmx5dCECWAtZ78_ppLqdK8-fg6AQIkwtrWWv_A.gif?format=png8&s=af62a3983cca6f7444c6fcd71a437a0367ef209d","width": 250,"height": 443},"resolutions": [{"url": "https://external-preview.redd.it/Qu5XlTmx5dCECWAtZ78_ppLqdK8-fg6AQIkwtrWWv_A.gif?width=108&crop=smart&format=png8&s=5fc7a8599c47d2e5fdb38025ec6e5505d0a32ded","width": 108,"height": 191},{"url": "https://external-preview.redd.it/Qu5XlTmx5dCECWAtZ78_ppLqdK8-fg6AQIkwtrWWv_A.gif?width=216&crop=smart&format=png8&s=5074882cb47e29453703eeff691fa9b8311187cb","width": 216,"height": 382}],"variants": {"gif": {"source": {"url": "https://external-preview.redd.it/Qu5XlTmx5dCECWAtZ78_ppLqdK8-fg6AQIkwtrWWv_A.gif?s=0d62fd307bd3460684be4107c4a085b0e1519bfa","width": 250,"height": 443},"resolutions": [{"url": "https://external-preview.redd.it/Qu5XlTmx5dCECWAtZ78_ppLqdK8-fg6AQIkwtrWWv_A.gif?width=108&crop=smart&s=00d732e62d3e7e3b502ec53de8f418e4c8dafdcd","width": 108,"height": 191},{"url": "https://external-preview.redd.it/Qu5XlTmx5dCECWAtZ78_ppLqdK8-fg6AQIkwtrWWv_A.gif?width=216&crop=smart&s=f3c8ba2f57a459162c87428d8a78c6542e3b97ee","width": 216,"height": 382}]},"mp4": {"source": {"url": "https://external-preview.redd.it/Qu5XlTmx5dCECWAtZ78_ppLqdK8-fg6AQIkwtrWWv_A.gif?format=mp4&s=6fcd0d532b6efedd43661833d7d1849c9ab48c7e","width": 250,"height": 443},"resolutions": [{"url": "https://external-preview.redd.it/Qu5XlTmx5dCECWAtZ78_ppLqdK8-fg6AQIkwtrWWv_A.gif?width=108&format=mp4&s=f396af40aa894b3c762d53ded9c2e580ddfe0221","width": 108,"height": 191},{"url": "https://external-preview.redd.it/Qu5XlTmx5dCECWAtZ78_ppLqdK8-fg6AQIkwtrWWv_A.gif?width=216&format=mp4&s=85eddba3576e79926d76d82adafa168fd4756bfe","width": 216,"height": 382}]}},"id": "irvwJtasLS7bu1Q-F-i-Y3V1DgxQKrbY3mmNhfz0BWA"}],"reddit_video_preview": {"fallback_url": "https://v.redd.it/txgxzcos8gw31/DASH_720","height": 720,"width": 404,"scrubber_media_url": "https://v.redd.it/txgxzcos8gw31/DASH_96","dash_url": "https://v.redd.it/txgxzcos8gw31/DASHPlaylist.mpd","duration": 13,"hls_url": "https://v.redd.it/txgxzcos8gw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"},"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqyowy","is_robot_indexable": true,"report_reasons": null,"author": "sdsanth","discussion_type": null,"num_comments": 8,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqyowy/im_here_to_help/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://gfycat.com/welldocumentedblushinglcont","subreddit_subscribers": 22330694,"created_utc": 1572777339.0,"num_crossposts": 0,"media": {"oembed": {"provider_url": "https://gfycat.com","description": "Watch and share Dog GIFs by ItsMeTaylor on Gfycat","title": "Doggy Helper","author_name": "Gfycat","height": 852,"width": 480,"html": "<iframe class=\"embedly-embed\" src=\"https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fgfycat.com%2Fifr%2Fwelldocumentedblushinglcont&url=https%3A%2F%2Fgfycat.com%2Fwelldocumentedblushinglcont-dog&image=https%3A%2F%2Fthumbs.gfycat.com%2FWelldocumentedBlushingLcont-size_restricted.gif&key=2aa3c4d5f3de4f5b9120b660ad850dc9&type=text%2Fhtml&schema=gfycat\" width=\"480\" height=\"852\" scrolling=\"no\" frameborder=\"0\" allow=\"autoplay; fullscreen\" allowfullscreen=\"true\"></iframe>","thumbnail_width": 250,"version": "1.0","provider_name": "Gfycat","thumbnail_url": "https://thumbs.gfycat.com/WelldocumentedBlushingLcont-size_restricted.gif","type": "video","thumbnail_height": 443},"type": "gfycat.com"},"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_2he2oij3","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Blind cutie","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqz6iz","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 496,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 496,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/-C8b07bRtApqmTJ6EpohrdlDIYVddhawrjoaKxpirrE.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "image","content_categories": null,"is_self": false,"mod_note": null,"created": 1572809250.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://preview.redd.it/gcxqswv8igw31.png?auto=webp&s=8a7803f0624e14a3498356ef615cac4d9f31ee65","width": 750,"height": 908},"resolutions": [{"url": "https://preview.redd.it/gcxqswv8igw31.png?width=108&crop=smart&auto=webp&s=79c67bda6ee782a81364cc7cad038aced9fbcd22","width": 108,"height": 130},{"url": "https://preview.redd.it/gcxqswv8igw31.png?width=216&crop=smart&auto=webp&s=147898704ebf7219da185f2d8046b6f2a80d3bda","width": 216,"height": 261},{"url": "https://preview.redd.it/gcxqswv8igw31.png?width=320&crop=smart&auto=webp&s=324d7b4e4be9c9e9b751b5f8c8e2347c1ab1db08","width": 320,"height": 387},{"url": "https://preview.redd.it/gcxqswv8igw31.png?width=640&crop=smart&auto=webp&s=a68c1d6d1cadfb9e290b9f7afd956c12db3a53b8","width": 640,"height": 774}],"variants": {},"id": "Ki9xeqex21Kvpe-2CU_CCqsge5CDp8w7QsBh5ETRLks"}],"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqz6iz","is_robot_indexable": true,"report_reasons": null,"author": "Wurslow","discussion_type": null,"num_comments": 22,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqz6iz/blind_cutie/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.redd.it/gcxqswv8igw31.png","subreddit_subscribers": 22330694,"created_utc": 1572780450.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_2rrcssrr","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Monsters Inc is my favourite film too","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqrcr5","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 10021,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/2rlm6vsekcw31/DASH_1080?source=fallback","height": 1080,"width": 608,"scrubber_media_url": "https://v.redd.it/2rlm6vsekcw31/DASH_96","dash_url": "https://v.redd.it/2rlm6vsekcw31/DASHPlaylist.mpd","duration": 9,"hls_url": "https://v.redd.it/2rlm6vsekcw31/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 10021,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/XvBRVzZCPWezfioQrUw0mVl5OJMXwi-25zK4aUlbhxg.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572761549.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/vsz3-xXuBEnRPCQ0lU-_JG9wSJY9jBjAPu-b2yg0v0k.png?format=pjpg&auto=webp&s=5d6dbc65ef53068f68ce0182c0cf641ba8002002","width": 720,"height": 1280},"resolutions": [{"url": "https://external-preview.redd.it/vsz3-xXuBEnRPCQ0lU-_JG9wSJY9jBjAPu-b2yg0v0k.png?width=108&crop=smart&format=pjpg&auto=webp&s=efd3dea223e94ed30c8eadae2434d36a10c7d31b","width": 108,"height": 192},{"url": "https://external-preview.redd.it/vsz3-xXuBEnRPCQ0lU-_JG9wSJY9jBjAPu-b2yg0v0k.png?width=216&crop=smart&format=pjpg&auto=webp&s=841e9e0b9b174a437b72a89534d045757401f804","width": 216,"height": 384},{"url": "https://external-preview.redd.it/vsz3-xXuBEnRPCQ0lU-_JG9wSJY9jBjAPu-b2yg0v0k.png?width=320&crop=smart&format=pjpg&auto=webp&s=58cd4425cf75b58db6ac5f445cbfd6ce1c4fee05","width": 320,"height": 568},{"url": "https://external-preview.redd.it/vsz3-xXuBEnRPCQ0lU-_JG9wSJY9jBjAPu-b2yg0v0k.png?width=640&crop=smart&format=pjpg&auto=webp&s=63d8bebee53b9ba35e3fc9aeddede5074e27072b","width": 640,"height": 1137}],"variants": {},"id": "pxH6ycyprqg98b9TGv42nj5Qsd1sgta8c4uFwXUALn8"}],"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqrcr5","is_robot_indexable": true,"report_reasons": null,"author": "iboughtachicken","discussion_type": null,"num_comments": 40,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqrcr5/monsters_inc_is_my_favourite_film_too/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/2rlm6vsekcw31","subreddit_subscribers": 22330694,"created_utc": 1572732749.0,"num_crossposts": 2,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/2rlm6vsekcw31/DASH_1080?source=fallback","height": 1080,"width": 608,"scrubber_media_url": "https://v.redd.it/2rlm6vsekcw31/DASH_96","dash_url": "https://v.redd.it/2rlm6vsekcw31/DASHPlaylist.mpd","duration": 9,"hls_url": "https://v.redd.it/2rlm6vsekcw31/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_video": true}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_o3a2y","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Meet our new 3 month old baby, Bucko the Beagle!","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqsk7y","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 6219,"total_awards_received": 1,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": false,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 6219,"approved_by": null,"thumbnail": "https://a.thumbs.redditmedia.com/gbrSieUgCpfzYG59r3uNT51N15l4f_-r3720pwgOSO4.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {"gid_1": 1},"post_hint": "image","content_categories": null,"is_self": false,"mod_note": null,"created": 1572767170.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.imgur.com","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/j-9X4u3QgiqyOBiU1aWSvNyPWM5hFmPr6IOLJiqjqp8.jpg?auto=webp&s=c47c7db6d44a20c6dea75f1f0ea777976dab6525","width": 3024,"height": 4032},"resolutions": [{"url": "https://external-preview.redd.it/j-9X4u3QgiqyOBiU1aWSvNyPWM5hFmPr6IOLJiqjqp8.jpg?width=108&crop=smart&auto=webp&s=4300fff29463182c55c484593d9610f9ebd3ad36","width": 108,"height": 144},{"url": "https://external-preview.redd.it/j-9X4u3QgiqyOBiU1aWSvNyPWM5hFmPr6IOLJiqjqp8.jpg?width=216&crop=smart&auto=webp&s=165ace7db91674d4ecb1a62c3eaade5d29cc4ac6","width": 216,"height": 288},{"url": "https://external-preview.redd.it/j-9X4u3QgiqyOBiU1aWSvNyPWM5hFmPr6IOLJiqjqp8.jpg?width=320&crop=smart&auto=webp&s=7a68e614d5fdba3656487e9ac42021bf8178ff23","width": 320,"height": 426},{"url": "https://external-preview.redd.it/j-9X4u3QgiqyOBiU1aWSvNyPWM5hFmPr6IOLJiqjqp8.jpg?width=640&crop=smart&auto=webp&s=a62f251abda2fa5cfde9d73f7c502b538bd2dea8","width": 640,"height": 853},{"url": "https://external-preview.redd.it/j-9X4u3QgiqyOBiU1aWSvNyPWM5hFmPr6IOLJiqjqp8.jpg?width=960&crop=smart&auto=webp&s=976bf8afe09363aeb7bce85b36cfdacf44821402","width": 960,"height": 1280},{"url": "https://external-preview.redd.it/j-9X4u3QgiqyOBiU1aWSvNyPWM5hFmPr6IOLJiqjqp8.jpg?width=1080&crop=smart&auto=webp&s=d37b057dbbf4a79b3aa251b756f3ee9376c79eaf","width": 1080,"height": 1440}],"variants": {},"id": "oSLbAuhtYC2pa4rtwH6EYS_mWKD3ljPuYgN1nEDU_MM"}],"enabled": true},"all_awardings": [{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Shows the Silver Award... and that's it.","end_date": null,"coin_reward": 0,"icon_url": "https://www.redditstatic.com/gold/awards/icon/silver_512.png","days_of_premium": 0,"id": "gid_1","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/silver_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 100,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Silver"}],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqsk7y","is_robot_indexable": true,"report_reasons": null,"author": "PykeTheIke","discussion_type": null,"num_comments": 67,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqsk7y/meet_our_new_3_month_old_baby_bucko_the_beagle/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.imgur.com/O2bVClA.jpg","subreddit_subscribers": 22330694,"created_utc": 1572738370.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_1opytfgs","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "I asked this guy if he knocked over the treats and ate them all then he gave me this face. Now I\u2019m on my way to buy him all the treats.","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": "lc","downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqvgm9","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 2001,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 2001,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/9qvSY3LOsreGgWD6eVrSZ0wZW593BaLI9l3Jo2sZaAg.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "image","content_categories": null,"is_self": false,"mod_note": null,"created": 1572782107.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://preview.redd.it/hywobahj9ew31.png?auto=webp&s=bd8d1f68840f5095c1c8e833413fe4cba4e276f1","width": 1112,"height": 2208},"resolutions": [{"url": "https://preview.redd.it/hywobahj9ew31.png?width=108&crop=smart&auto=webp&s=18eb53f92879de852a46620971ad672e64f004a6","width": 108,"height": 214},{"url": "https://preview.redd.it/hywobahj9ew31.png?width=216&crop=smart&auto=webp&s=36f79169169c7723a3a3e821444783353a3fd596","width": 216,"height": 428},{"url": "https://preview.redd.it/hywobahj9ew31.png?width=320&crop=smart&auto=webp&s=727070e6f6ce1968490eceac297b151fc8df12bb","width": 320,"height": 635},{"url": "https://preview.redd.it/hywobahj9ew31.png?width=640&crop=smart&auto=webp&s=1ac33aabf7003a4588fdec048b39e8cd2d344c48","width": 640,"height": 1270},{"url": "https://preview.redd.it/hywobahj9ew31.png?width=960&crop=smart&auto=webp&s=945c14b2b8e52e59c099517198577a7936f9e893","width": 960,"height": 1906},{"url": "https://preview.redd.it/hywobahj9ew31.png?width=1080&crop=smart&auto=webp&s=83339f9e883b412d4fc3e17b6575ca2ea35c4231","width": 1080,"height": 2144}],"variants": {},"id": "v0pYZqniHPnbNLvVwqKDHkJMEavSdOdE0jZi0OAC5Zo"}],"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqvgm9","is_robot_indexable": true,"report_reasons": null,"author": "lenalama","discussion_type": null,"num_comments": 24,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqvgm9/i_asked_this_guy_if_he_knocked_over_the_treats/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.redd.it/hywobahj9ew31.png","subreddit_subscribers": 22330694,"created_utc": 1572753307.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_g03o8","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Admiral Anchovies is two weeks old and has already mastered the judgemental cat stare.","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 105,"hide_score": false,"name": "t3_dqrxiq","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 6990,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 6990,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/QNo5vRA5Bzjv4jX3p7U8i-Fxp9sU9GYwnpNdpnugWwk.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "image","content_categories": null,"is_self": false,"mod_note": null,"created": 1572764189.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "i.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://preview.redd.it/j4qda3c9scw31.jpg?auto=webp&s=566ab19d5ff7c3d053a201b02d53f793b96edc5d","width": 4032,"height": 3024},"resolutions": [{"url": "https://preview.redd.it/j4qda3c9scw31.jpg?width=108&crop=smart&auto=webp&s=f11292ac5919334569e42109c62705d13c06d8d9","width": 108,"height": 81},{"url": "https://preview.redd.it/j4qda3c9scw31.jpg?width=216&crop=smart&auto=webp&s=805e26dc48e0020a2450c20bdbc254fb4941909e","width": 216,"height": 162},{"url": "https://preview.redd.it/j4qda3c9scw31.jpg?width=320&crop=smart&auto=webp&s=dc4f628013bc922ffe7caa61d9f7f2c3d4edec38","width": 320,"height": 240},{"url": "https://preview.redd.it/j4qda3c9scw31.jpg?width=640&crop=smart&auto=webp&s=c92722a3b5f72027491a7c33cdc3b89355a0167a","width": 640,"height": 480},{"url": "https://preview.redd.it/j4qda3c9scw31.jpg?width=960&crop=smart&auto=webp&s=229796782ac73d5384590b4dd442589234613815","width": 960,"height": 720},{"url": "https://preview.redd.it/j4qda3c9scw31.jpg?width=1080&crop=smart&auto=webp&s=cdd724e1eb94a70c879af8f8705593c3fb833ecf","width": 1080,"height": 810}],"variants": {},"id": "oY2DRptAqQ5jniujN687Iv4IMD-K-mKn4rKsfhZEGao"}],"enabled": true},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqrxiq","is_robot_indexable": true,"report_reasons": null,"author": "lavalampdreams","discussion_type": null,"num_comments": 50,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqrxiq/admiral_anchovies_is_two_weeks_old_and_has/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://i.redd.it/j4qda3c9scw31.jpg","subreddit_subscribers": 22330694,"created_utc": 1572735389.0,"num_crossposts": 1,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_2et1k4mi","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Son uses the money he earned from developing his first app to surprise his parents by paying off their mortgage","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 78,"hide_score": false,"name": "t3_dqta36","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 3828,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": null,"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 3828,"approved_by": null,"thumbnail": "https://a.thumbs.redditmedia.com/FQwlGfCp5ni_LJqp8USb2eNmGgM5SsMZ0BKJSvGCD50.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "link","content_categories": null,"is_self": false,"mod_note": null,"crosspost_parent_list": [{"approved_at_utc": null,"subreddit": "happycryingdads","selftext": "","author_fullname": "t2_9aks07k","saved": false,"mod_reason_title": null,"gilded": 1,"clicked": false,"title": "Son uses the money he earned from developing his first app to surprise his parents by paying off their mortgage","link_flair_richtext": [],"subreddit_name_prefixed": "r/happycryingdads","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 78,"hide_score": false,"name": "t3_akujnm","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 26146,"total_awards_received": 7,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/2duzjr7fh9d21/DASH_4_8_M?source=fallback","height": 720,"width": 1280,"scrubber_media_url": "https://v.redd.it/2duzjr7fh9d21/DASH_600_K","dash_url": "https://v.redd.it/2duzjr7fh9d21/DASHPlaylist.mpd","duration": 75,"hls_url": "https://v.redd.it/2duzjr7fh9d21/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 26146,"approved_by": null,"thumbnail": "default","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {"gid_1": 4,"gid_2": 1,"gid_3": 2},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1548752799.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": true,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?format=pjpg&auto=webp&s=b7ba323c16549ca316c803c5dd39ccb54a73f287","width": 1280,"height": 720},"resolutions": [{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=108&crop=smart&format=pjpg&auto=webp&s=472d7f95a181744ec1f3758dba0e8075207502f9","width": 108,"height": 60},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=216&crop=smart&format=pjpg&auto=webp&s=039efc766a4364e6d08df75b8908fab15382b564","width": 216,"height": 121},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=320&crop=smart&format=pjpg&auto=webp&s=563ea5a09f334d3112a67fb0df8e3dd48956b28c","width": 320,"height": 180},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=640&crop=smart&format=pjpg&auto=webp&s=877bf6a30c7b2abc0b4e632b2f00450676f6ad32","width": 640,"height": 360},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=960&crop=smart&format=pjpg&auto=webp&s=eef082e577aa49df8fe4e5dcef18e51d5ebcb924","width": 960,"height": 540},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=1080&crop=smart&format=pjpg&auto=webp&s=cd9b8c1b2047311724a08f184f0723acbe643f88","width": 1080,"height": 607}],"variants": {},"id": "m39m820shI7YWdczpGlce2aLY0mSTNJx-sxNqi5yplE"}],"enabled": false},"all_awardings": [{"count": 2,"is_enabled": true,"subreddit_id": null,"description": "Gives the author a month of Reddit Premium, which includes %{coin_symbol}700 Coins for that month, and shows a Platinum Award.","end_date": null,"coin_reward": 0,"icon_url": "https://www.redditstatic.com/gold/awards/icon/platinum_512.png","days_of_premium": 31,"id": "gid_3","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/platinum_128.png","width": 128,"height": 128}],"days_of_drip_extension": 31,"award_type": "global","start_date": null,"coin_price": 1800,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Platinum"},{"count": 1,"is_enabled": true,"subreddit_id": null,"description": "Gives the author a week of Reddit Premium, %{coin_symbol}100 Coins to do with as they please, and shows a Gold Award.","end_date": null,"coin_reward": 100,"icon_url": "https://www.redditstatic.com/gold/awards/icon/gold_512.png","days_of_premium": 7,"id": "gid_2","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/gold_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/gold_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 500,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Gold"},{"count": 4,"is_enabled": true,"subreddit_id": null,"description": "Shows the Silver Award... and that's it.","end_date": null,"coin_reward": 0,"icon_url": "https://www.redditstatic.com/gold/awards/icon/silver_512.png","days_of_premium": 0,"id": "gid_1","icon_height": 512,"resized_icons": [{"url": "https://www.redditstatic.com/gold/awards/icon/silver_16.png","width": 16,"height": 16},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_32.png","width": 32,"height": 32},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_48.png","width": 48,"height": 48},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_64.png","width": 64,"height": 64},{"url": "https://www.redditstatic.com/gold/awards/icon/silver_128.png","width": 128,"height": 128}],"days_of_drip_extension": 0,"award_type": "global","start_date": null,"coin_price": 100,"icon_width": 512,"subreddit_coin_reward": 0,"name": "Silver"}],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_3232g","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "akujnm","is_robot_indexable": true,"report_reasons": null,"author": "BirdPlan","discussion_type": null,"num_comments": 714,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/happycryingdads/comments/akujnm/son_uses_the_money_he_earned_from_developing_his/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/2duzjr7fh9d21","subreddit_subscribers": 237505,"created_utc": 1548723999.0,"num_crossposts": 41,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/2duzjr7fh9d21/DASH_4_8_M?source=fallback","height": 720,"width": 1280,"scrubber_media_url": "https://v.redd.it/2duzjr7fh9d21/DASH_600_K","dash_url": "https://v.redd.it/2duzjr7fh9d21/DASHPlaylist.mpd","duration": 75,"hls_url": "https://v.redd.it/2duzjr7fh9d21/HLSPlaylist.m3u8","is_gif": false,"transcoding_status": "completed"}},"is_video": true}],"created": 1572770599.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": true,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?auto=webp&s=080dd6f933e21f42a494cba7aecfcb6e28a012c5","width": 1280,"height": 720},"resolutions": [{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=108&crop=smart&auto=webp&s=e4a539fe8dbaf86739e7be771559fd9603fa81f6","width": 108,"height": 60},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=216&crop=smart&auto=webp&s=ac890a5c7f87c0a984caf35f61322a8438d4169a","width": 216,"height": 121},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=320&crop=smart&auto=webp&s=6e4483d4f4d0968a257b404ae81bf7c031caab1e","width": 320,"height": 180},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=640&crop=smart&auto=webp&s=b3cd9eec90c18c48a95b584df18225543eb6223c","width": 640,"height": 360},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=960&crop=smart&auto=webp&s=7706540ee697b585560c520b16377997e8c10d28","width": 960,"height": 540},{"url": "https://external-preview.redd.it/M_SBhOwI-UDi3miOISxstldXI_8odg2Oz_JEY_bvzRE.png?width=1080&crop=smart&auto=webp&s=d61b3cec4796d6cef01216a76e84da55b9bcdcd8","width": 1080,"height": 607}],"variants": {},"id": "m39m820shI7YWdczpGlce2aLY0mSTNJx-sxNqi5yplE"}],"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqta36","is_robot_indexable": true,"stickied": false,"author": "aa987876765","discussion_type": null,"num_comments": 87,"send_replies": false,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"crosspost_parent": "t3_akujnm","author_flair_text_color": null,"permalink": "/r/aww/comments/dqta36/son_uses_the_money_he_earned_from_developing_his/","parent_whitelist_status": "all_ads","report_reasons": null,"url": "https://v.redd.it/2duzjr7fh9d21","subreddit_subscribers": 22330694,"created_utc": 1572741799.0,"num_crossposts": 0,"media": null,"is_video": false}},{"kind": "t3","data": {"approved_at_utc": null,"subreddit": "aww","selftext": "","author_fullname": "t2_1nq4b6zl","saved": false,"mod_reason_title": null,"gilded": 0,"clicked": false,"title": "Lil puppy annoying big puppy","link_flair_richtext": [],"subreddit_name_prefixed": "r/aww","hidden": false,"pwls": 6,"link_flair_css_class": null,"downs": 0,"thumbnail_height": 140,"hide_score": false,"name": "t3_dqx42l","quarantine": false,"link_flair_text_color": "dark","author_flair_background_color": null,"subreddit_type": "public","ups": 884,"total_awards_received": 0,"media_embed": {},"thumbnail_width": 140,"author_flair_template_id": null,"is_original_content": false,"user_reports": [],"secure_media": {"reddit_video": {"fallback_url": "https://v.redd.it/ri81oyoy7fw31/DASH_480?source=fallback","height": 480,"width": 480,"scrubber_media_url": "https://v.redd.it/ri81oyoy7fw31/DASH_96","dash_url": "https://v.redd.it/ri81oyoy7fw31/DASHPlaylist.mpd","duration": 6,"hls_url": "https://v.redd.it/ri81oyoy7fw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_reddit_media_domain": true,"is_meta": false,"category": null,"secure_media_embed": {},"link_flair_text": null,"can_mod_post": false,"score": 884,"approved_by": null,"thumbnail": "https://b.thumbs.redditmedia.com/NYFTdgxLOEt97-2QWoj08h8ohu9-oWgBwEVHvQRG0lk.jpg","edited": false,"author_flair_css_class": null,"steward_reports": [],"author_flair_richtext": [],"gildings": {},"post_hint": "hosted:video","content_categories": null,"is_self": false,"mod_note": null,"created": 1572793691.0,"link_flair_type": "text","wls": 6,"banned_by": null,"author_flair_type": "text","domain": "v.redd.it","allow_live_comments": false,"selftext_html": null,"likes": null,"suggested_sort": null,"banned_at_utc": null,"view_count": null,"archived": false,"no_follow": false,"is_crosspostable": false,"pinned": false,"over_18": false,"preview": {"images": [{"source": {"url": "https://external-preview.redd.it/F5VWB7kZHYHGf3tGYG-iPXHarWsNjceBPHoTh9z0rII.png?format=pjpg&auto=webp&s=3320233e989d6bea3a7bba83de75a2b9c3010291","width": 480,"height": 480},"resolutions": [{"url": "https://external-preview.redd.it/F5VWB7kZHYHGf3tGYG-iPXHarWsNjceBPHoTh9z0rII.png?width=108&crop=smart&format=pjpg&auto=webp&s=8abfd8c7aa3b8454ca4f0ff425e59f4fdd360f52","width": 108,"height": 108},{"url": "https://external-preview.redd.it/F5VWB7kZHYHGf3tGYG-iPXHarWsNjceBPHoTh9z0rII.png?width=216&crop=smart&format=pjpg&auto=webp&s=fc91be58fe28b67386b3501db654db9a9d92f104","width": 216,"height": 216},{"url": "https://external-preview.redd.it/F5VWB7kZHYHGf3tGYG-iPXHarWsNjceBPHoTh9z0rII.png?width=320&crop=smart&format=pjpg&auto=webp&s=325afe1bd112df2ec3305dfd83a24df6f716dedd","width": 320,"height": 320}],"variants": {},"id": "GzzIS_mCMEt9exLnOpZd7DF68Xki3jhcbvjXZL1zAcw"}],"enabled": false},"all_awardings": [],"awarders": [],"media_only": false,"can_gild": false,"spoiler": false,"locked": false,"author_flair_text": null,"visited": false,"num_reports": null,"distinguished": null,"subreddit_id": "t5_2qh1o","mod_reason_by": null,"removal_reason": null,"link_flair_background_color": "","id": "dqx42l","is_robot_indexable": true,"report_reasons": null,"author": "zandinami","discussion_type": null,"num_comments": 12,"send_replies": true,"whitelist_status": "all_ads","contest_mode": false,"mod_reports": [],"author_patreon_flair": false,"author_flair_text_color": null,"permalink": "/r/aww/comments/dqx42l/lil_puppy_annoying_big_puppy/","parent_whitelist_status": "all_ads","stickied": false,"url": "https://v.redd.it/ri81oyoy7fw31","subreddit_subscribers": 22330694,"created_utc": 1572764891.0,"num_crossposts": 0,"media": {"reddit_video": {"fallback_url": "https://v.redd.it/ri81oyoy7fw31/DASH_480?source=fallback","height": 480,"width": 480,"scrubber_media_url": "https://v.redd.it/ri81oyoy7fw31/DASH_96","dash_url": "https://v.redd.it/ri81oyoy7fw31/DASHPlaylist.mpd","duration": 6,"hls_url": "https://v.redd.it/ri81oyoy7fw31/HLSPlaylist.m3u8","is_gif": true,"transcoding_status": "completed"}},"is_video": true}}],"after": "t3_dqx42l","before": null}} \ No newline at end of file diff --git a/testing/test_webserver/test_webserver.py b/testing/test_webserver/test_webserver.py new file mode 100644 index 00000000..d5e55561 --- /dev/null +++ b/testing/test_webserver/test_webserver.py @@ -0,0 +1,18 @@ +import unittest + +from nichtparasoup.core import NPCore +from nichtparasoup.core.server import Server +from nichtparasoup.webserver import WebServer + + +class WebserverTest(unittest.TestCase): + + def setUp(self) -> None: + self.webserver = WebServer(Server(NPCore()), "", 0) + + def tearDown(self) -> None: + del self.webserver + + def test_webserver(self) -> None: + self.skipTest('TODO: write the test') # TODO: write tests + # maybe this helps? http://werkzeug.palletsprojects.com/en/0.16.x/test/ diff --git a/testing/test_www_ui/source_display.html b/testing/test_www_ui/source_display.html new file mode 100644 index 00000000..00f0c672 --- /dev/null +++ b/testing/test_www_ui/source_display.html @@ -0,0 +1,40 @@ + + + + + + + Source Display Test + + + + + + + + + + + + + + + + + + + diff --git a/testing/test_www_ui/source_icons_display.html b/testing/test_www_ui/source_icons_display.html new file mode 100644 index 00000000..91c4503f --- /dev/null +++ b/testing/test_www_ui/source_icons_display.html @@ -0,0 +1,82 @@ + + + + + + + SourceIcons Display Test + + + + + + + + + + + + + + + + + + + diff --git a/tests/configs/README.md b/tests/configs/README.md deleted file mode 100644 index 5740f3ea..00000000 --- a/tests/configs/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# nichtparasoup crawler tests - -here are prepared ini files for debugging and testing - -run from nichtparasoup root: -```bash -./nichtparasoup.py -c tests/configs/.ini -```` - -find detailed log in `tests/logs/` \ No newline at end of file diff --git a/tests/configs/factors.ini b/tests/configs/factors.ini deleted file mode 100644 index 03f5797c..00000000 --- a/tests/configs/factors.ini +++ /dev/null @@ -1,11 +0,0 @@ -; congig for nichtparasoup - -[Logging] -Log_name: nichtparasoup_test_factors -File: tests/logs/factors.log -Verbosity: DEBUG - -[Sites] -Pr0gramm:top*0.1 -Fourchan: vr*0.1 -Reddit: nsfw*0.1,gifs*0.1,pics*0.1,nsfw_gifs*0.1,aww*0.1,aww_gifs*0.1,reactiongifs*0.1,wtf*0.1,FoodPorn*0.1,cats*0.1,StarWars*10.0 diff --git a/tests/configs/fourchan.ini b/tests/configs/fourchan.ini deleted file mode 100644 index 4eac0693..00000000 --- a/tests/configs/fourchan.ini +++ /dev/null @@ -1,10 +0,0 @@ -; congig for nichtparasoup - Fourchan crawler - -[Logging] -Log_name: nichtparasoup_test_fourchan -File: tests/logs/fourchan.log -Verbosity: DEBUG - -[Sites] -Fourchan: vr - diff --git a/tests/configs/giphy.ini b/tests/configs/giphy.ini deleted file mode 100644 index da06797b..00000000 --- a/tests/configs/giphy.ini +++ /dev/null @@ -1,9 +0,0 @@ -; congig for nichtparasoup - Giphy crawler - -[Logging] -Log_name: nichtparasoup_test_giphy -File: tests/logs/giphy.log -Verbosity: DEBUG - -[Sites] -Giphy:cats diff --git a/tests/configs/instagram.ini b/tests/configs/instagram.ini deleted file mode 100644 index 95736f61..00000000 --- a/tests/configs/instagram.ini +++ /dev/null @@ -1,10 +0,0 @@ -; congig for nichtparasoup - Instagram crawler - -[Logging] -Log_name: nichtparasoup_test_instagram -File: tests/logs/instagram.log -Verbosity: DEBUG - -[Sites] -Instagram: cats - diff --git a/tests/configs/ninegag.ini b/tests/configs/ninegag.ini deleted file mode 100644 index 9c854ba0..00000000 --- a/tests/configs/ninegag.ini +++ /dev/null @@ -1,9 +0,0 @@ -; congig for nichtparasoup - 9gag crawler - -[Logging] -Log_name: nichtparasoup_test_9gag -File: tests/logs/ninegag.log -Verbosity: DEBUG - -[Sites] -NineGag: trending diff --git a/tests/configs/pr0gramm.ini b/tests/configs/pr0gramm.ini deleted file mode 100644 index 248af066..00000000 --- a/tests/configs/pr0gramm.ini +++ /dev/null @@ -1,9 +0,0 @@ -; congig for nichtparasoup - Pr0gramm crawler - -[Logging] -Log_name: nichtparasoup_test_pr0gram -File: tests/logs/pr0gram.log -Verbosity: DEBUG - -[Sites] -Pr0gramm: top diff --git a/tests/configs/reddit.ini b/tests/configs/reddit.ini deleted file mode 100644 index 22c92650..00000000 --- a/tests/configs/reddit.ini +++ /dev/null @@ -1,9 +0,0 @@ -; congig for nichtparasoup - Reddit crawler - -[Logging] -Log_name: nichtparasoup_test_reddit -File: tests/logs/reddit.log -Verbosity: DEBUG - -[Sites] -Reddit:cats diff --git a/tests/configs/soup.ini b/tests/configs/soup.ini deleted file mode 100644 index 108c6ff3..00000000 --- a/tests/configs/soup.ini +++ /dev/null @@ -1,9 +0,0 @@ -; congig for nichtparasoup - SoupIO crawler - -[Logging] -Log_name: nichtparasoup_test_soupio -File: tests/logs/soupio.log -Verbosity: DEBUG - -[Sites] -SoupIO: palamon, everyone diff --git a/tests/logs/.gitignore b/tests/logs/.gitignore deleted file mode 100644 index 40c0672f..00000000 --- a/tests/logs/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# this dir is the target of test logs -* -!.gitignore \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..949d0b6d --- /dev/null +++ b/tox.ini @@ -0,0 +1,28 @@ +# tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +minversion = 2.4 +envlist = py3 + + +[testenv] +# settings in this category apply to all other testenv, if not overwritten +usedevelop = false +extras = testing +deps = + codecov: codecov +passenv = + codecov: TOXENV CI TRAVIS TRAVIS_* +commands = + {envpython} -m flake8 + {envpython} -m mypy + {envpython} -m coverage run -m pytest -v + {envpython} -m coverage report -m + # codecov failed a lot on travis, so codecov is not called with `--required` + codecov: {envpython} -m codecov -e TOXENV + +[testenv:interactive] +commands = {envpython} -i