diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a4d2bbb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +logs/ +browsertrix-tmp/ +tmp*/ +temp/ +.DS_Store +__pycache__/ +local_archive/ +config*.json +config.json +*.env +credentials.json +secrets/ +instaloader/ +instaloader.session +vk_config*.json +anon* +geckodriver.log diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml new file mode 100644 index 0000000..b9916b8 --- /dev/null +++ b/.github/workflows/docker-publish.yaml @@ -0,0 +1,57 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + release: + types: [published] + push: + branches: [ "dockerize" ] + tags: [ "v*.*.*" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + # https://github.com/docker/setup-buildx-action + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Log in to Docker Hub + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: bellingcat/auto-archiver + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/python-publish.yaml b/.github/workflows/python-publish.yaml new file mode 100644 index 0000000..a4e1150 --- /dev/null +++ b/.github/workflows/python-publish.yaml @@ -0,0 +1,53 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Pypi + +on: + release: + types: [published] + push: + branches: [ "dockerize" ] + tags: [ "v*.*.*" ] + +permissions: + contents: read + +jobs: + deploy: + name: Publish python package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + python -m pip install --upgrade --upgrade-strategy=eager pip setuptools wheel twine pipenv + python -m pip install -e . --upgrade + python -m pipenv install --dev --python 3.10 + env: + PIPENV_DEFAULT_PYTHON_VERSION: "3.10" + + - name: Build wheels + run: | + python -m pipenv run python setup.py sdist bdist_wheel + + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + verbose: true + skip_existing: true + password: ${{ secrets.PYPI_API_TOKEN }} + packages_dir: dist/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4d19b9e..e31ad70 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,12 @@ local_archive/ vk_config*.json gd-token.json credentials.json -secrets/* +secrets* browsertrix/* -browsertrix-tmp/* \ No newline at end of file +browsertrix-tmp/* +instaloader/* +instaloader.session +orchestration.yaml +auto_archiver.egg-info* +logs* +*.csv \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c17e1a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM webrecorder/browsertrix-crawler:latest + +ENV RUNNING_IN_DOCKER=1 + +WORKDIR /app + +# TODO: use custom ffmpeg builds instead of apt-get install +RUN pip install --upgrade pip && \ + pip install pipenv && \ + add-apt-repository ppa:mozillateam/ppa && \ + apt-get update && \ + apt-get install -y gcc ffmpeg fonts-noto && \ + apt-get install -y --no-install-recommends firefox-esr && \ + ln -s /usr/bin/firefox-esr /usr/bin/firefox && \ + wget https://github.com/mozilla/geckodriver/releases/download/v0.33.0/geckodriver-v0.33.0-linux64.tar.gz && \ + tar -xvzf geckodriver* -C /usr/local/bin && \ + chmod +x /usr/local/bin/geckodriver && \ + rm geckodriver-v* + + +# TODO: avoid copying unnecessary files, including .git +COPY Pipfile* ./ +RUN pipenv install + +# doing this at the end helps during development, builds are quick +COPY ./src/ . + +# TODO: figure out how to make volumes not be root, does it depend on host or dockerfile? +# RUN useradd --system --groups sudo --shell /bin/bash archiver && chown -R archiver:sudo . +# USER archiver + + +ENTRYPOINT ["pipenv", "run", "python3", "-m", "auto_archiver"] + +# should be executed with 2 volumes (3 if local_storage is used) +# docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive aa pipenv run python3 -m auto_archiver --config secrets/orchestration.yaml \ No newline at end of file diff --git a/Pipfile b/Pipfile index 88fad6a..1a47a4f 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,6 @@ loguru = "*" ffmpeg-python = "*" selenium = "*" snscrape = "*" -yt-dlp = "*" telethon = "*" google-api-python-client = "*" google-auth-httplib2 = "*" @@ -23,8 +22,22 @@ oauth2client = "*" python-slugify = "*" pyyaml = "*" dateparser = "*" -vk-url-scraper = "*" python-twitter-v2 = "*" +instaloader = "*" +tqdm = "*" +jinja2 = "*" +cryptography = "==38.0.4" +dataclasses-json = "*" +yt-dlp = ">=2023.2.17" +vk-url-scraper = "*" +uwsgi = "*" +requests = {extras = ["socks"], version = "*"} +# wacz = "==0.4.8" +pywb = ">=2.7.3" [requires] -python_version = "3.9" +python_version = "3.10" + +[dev-packages] +autopep8 = "*" +setuptools-pipfile = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 271a661..bac63b2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "1ed953d08e31d891de0f887e520f12025d109a20718b27dd8f9b361f73c95651" + "sha256": "1d7402ecf980bb765b8b289b23ed4d8699dbe16edfcde7c8191a4abb225a02c0" }, "pipfile-spec": 6, "requires": { - "python_version": "3.9" + "python_version": "3.10" }, "sources": [ { @@ -16,6 +16,115 @@ ] }, "default": { + "aiohttp": { + "hashes": [ + "sha256:03543dcf98a6619254b409be2d22b51f21ec66272be4ebda7b04e6412e4b2e14", + "sha256:03baa76b730e4e15a45f81dfe29a8d910314143414e528737f8589ec60cf7391", + "sha256:0a63f03189a6fa7c900226e3ef5ba4d3bd047e18f445e69adbd65af433add5a2", + "sha256:10c8cefcff98fd9168cdd86c4da8b84baaa90bf2da2269c6161984e6737bf23e", + "sha256:147ae376f14b55f4f3c2b118b95be50a369b89b38a971e80a17c3fd623f280c9", + "sha256:176a64b24c0935869d5bbc4c96e82f89f643bcdf08ec947701b9dbb3c956b7dd", + "sha256:17b79c2963db82086229012cff93ea55196ed31f6493bb1ccd2c62f1724324e4", + "sha256:1a45865451439eb320784918617ba54b7a377e3501fb70402ab84d38c2cd891b", + "sha256:1b3ea7edd2d24538959c1c1abf97c744d879d4e541d38305f9bd7d9b10c9ec41", + "sha256:22f6eab15b6db242499a16de87939a342f5a950ad0abaf1532038e2ce7d31567", + "sha256:3032dcb1c35bc330134a5b8a5d4f68c1a87252dfc6e1262c65a7e30e62298275", + "sha256:33587f26dcee66efb2fff3c177547bd0449ab7edf1b73a7f5dea1e38609a0c54", + "sha256:34ce9f93a4a68d1272d26030655dd1b58ff727b3ed2a33d80ec433561b03d67a", + "sha256:3a80464982d41b1fbfe3154e440ba4904b71c1a53e9cd584098cd41efdb188ef", + "sha256:3b90467ebc3d9fa5b0f9b6489dfb2c304a1db7b9946fa92aa76a831b9d587e99", + "sha256:3d89efa095ca7d442a6d0cbc755f9e08190ba40069b235c9886a8763b03785da", + "sha256:3d8ef1a630519a26d6760bc695842579cb09e373c5f227a21b67dc3eb16cfea4", + "sha256:3f43255086fe25e36fd5ed8f2ee47477408a73ef00e804cb2b5cba4bf2ac7f5e", + "sha256:40653609b3bf50611356e6b6554e3a331f6879fa7116f3959b20e3528783e699", + "sha256:41a86a69bb63bb2fc3dc9ad5ea9f10f1c9c8e282b471931be0268ddd09430b04", + "sha256:493f5bc2f8307286b7799c6d899d388bbaa7dfa6c4caf4f97ef7521b9cb13719", + "sha256:4a6cadebe132e90cefa77e45f2d2f1a4b2ce5c6b1bfc1656c1ddafcfe4ba8131", + "sha256:4c745b109057e7e5f1848c689ee4fb3a016c8d4d92da52b312f8a509f83aa05e", + "sha256:4d347a172f866cd1d93126d9b239fcbe682acb39b48ee0873c73c933dd23bd0f", + "sha256:4dac314662f4e2aa5009977b652d9b8db7121b46c38f2073bfeed9f4049732cd", + "sha256:4ddaae3f3d32fc2cb4c53fab020b69a05c8ab1f02e0e59665c6f7a0d3a5be54f", + "sha256:5393fb786a9e23e4799fec788e7e735de18052f83682ce2dfcabaf1c00c2c08e", + "sha256:59f029a5f6e2d679296db7bee982bb3d20c088e52a2977e3175faf31d6fb75d1", + "sha256:5a7bdf9e57126dc345b683c3632e8ba317c31d2a41acd5800c10640387d193ed", + "sha256:5b3f2e06a512e94722886c0827bee9807c86a9f698fac6b3aee841fab49bbfb4", + "sha256:5ce45967538fb747370308d3145aa68a074bdecb4f3a300869590f725ced69c1", + "sha256:5e14f25765a578a0a634d5f0cd1e2c3f53964553a00347998dfdf96b8137f777", + "sha256:618c901dd3aad4ace71dfa0f5e82e88b46ef57e3239fc7027773cb6d4ed53531", + "sha256:652b1bff4f15f6287550b4670546a2947f2a4575b6c6dff7760eafb22eacbf0b", + "sha256:6c08e8ed6fa3d477e501ec9db169bfac8140e830aa372d77e4a43084d8dd91ab", + "sha256:6ddb2a2026c3f6a68c3998a6c47ab6795e4127315d2e35a09997da21865757f8", + "sha256:6e601588f2b502c93c30cd5a45bfc665faaf37bbe835b7cfd461753068232074", + "sha256:6e74dd54f7239fcffe07913ff8b964e28b712f09846e20de78676ce2a3dc0bfc", + "sha256:7235604476a76ef249bd64cb8274ed24ccf6995c4a8b51a237005ee7a57e8643", + "sha256:7ab43061a0c81198d88f39aaf90dae9a7744620978f7ef3e3708339b8ed2ef01", + "sha256:7c7837fe8037e96b6dd5cfcf47263c1620a9d332a87ec06a6ca4564e56bd0f36", + "sha256:80575ba9377c5171407a06d0196b2310b679dc752d02a1fcaa2bc20b235dbf24", + "sha256:80a37fe8f7c1e6ce8f2d9c411676e4bc633a8462844e38f46156d07a7d401654", + "sha256:8189c56eb0ddbb95bfadb8f60ea1b22fcfa659396ea36f6adcc521213cd7b44d", + "sha256:854f422ac44af92bfe172d8e73229c270dc09b96535e8a548f99c84f82dde241", + "sha256:880e15bb6dad90549b43f796b391cfffd7af373f4646784795e20d92606b7a51", + "sha256:8b631e26df63e52f7cce0cce6507b7a7f1bc9b0c501fcde69742130b32e8782f", + "sha256:8c29c77cc57e40f84acef9bfb904373a4e89a4e8b74e71aa8075c021ec9078c2", + "sha256:91f6d540163f90bbaef9387e65f18f73ffd7c79f5225ac3d3f61df7b0d01ad15", + "sha256:92c0cea74a2a81c4c76b62ea1cac163ecb20fb3ba3a75c909b9fa71b4ad493cf", + "sha256:9bcb89336efa095ea21b30f9e686763f2be4478f1b0a616969551982c4ee4c3b", + "sha256:a1f4689c9a1462f3df0a1f7e797791cd6b124ddbee2b570d34e7f38ade0e2c71", + "sha256:a3fec6a4cb5551721cdd70473eb009d90935b4063acc5f40905d40ecfea23e05", + "sha256:a5d794d1ae64e7753e405ba58e08fcfa73e3fad93ef9b7e31112ef3c9a0efb52", + "sha256:a86d42d7cba1cec432d47ab13b6637bee393a10f664c425ea7b305d1301ca1a3", + "sha256:adfbc22e87365a6e564c804c58fc44ff7727deea782d175c33602737b7feadb6", + "sha256:aeb29c84bb53a84b1a81c6c09d24cf33bb8432cc5c39979021cc0f98c1292a1a", + "sha256:aede4df4eeb926c8fa70de46c340a1bc2c6079e1c40ccf7b0eae1313ffd33519", + "sha256:b744c33b6f14ca26b7544e8d8aadff6b765a80ad6164fb1a430bbadd593dfb1a", + "sha256:b7a00a9ed8d6e725b55ef98b1b35c88013245f35f68b1b12c5cd4100dddac333", + "sha256:bb96fa6b56bb536c42d6a4a87dfca570ff8e52de2d63cabebfd6fb67049c34b6", + "sha256:bbcf1a76cf6f6dacf2c7f4d2ebd411438c275faa1dc0c68e46eb84eebd05dd7d", + "sha256:bca5f24726e2919de94f047739d0a4fc01372801a3672708260546aa2601bf57", + "sha256:bf2e1a9162c1e441bf805a1fd166e249d574ca04e03b34f97e2928769e91ab5c", + "sha256:c4eb3b82ca349cf6fadcdc7abcc8b3a50ab74a62e9113ab7a8ebc268aad35bb9", + "sha256:c6cc15d58053c76eacac5fa9152d7d84b8d67b3fde92709195cb984cfb3475ea", + "sha256:c6cd05ea06daca6ad6a4ca3ba7fe7dc5b5de063ff4daec6170ec0f9979f6c332", + "sha256:c844fd628851c0bc309f3c801b3a3d58ce430b2ce5b359cd918a5a76d0b20cb5", + "sha256:c9cb1565a7ad52e096a6988e2ee0397f72fe056dadf75d17fa6b5aebaea05622", + "sha256:cab9401de3ea52b4b4c6971db5fb5c999bd4260898af972bf23de1c6b5dd9d71", + "sha256:cd468460eefef601ece4428d3cf4562459157c0f6523db89365202c31b6daebb", + "sha256:d1e6a862b76f34395a985b3cd39a0d949ca80a70b6ebdea37d3ab39ceea6698a", + "sha256:d1f9282c5f2b5e241034a009779e7b2a1aa045f667ff521e7948ea9b56e0c5ff", + "sha256:d265f09a75a79a788237d7f9054f929ced2e69eb0bb79de3798c468d8a90f945", + "sha256:db3fc6120bce9f446d13b1b834ea5b15341ca9ff3f335e4a951a6ead31105480", + "sha256:dbf3a08a06b3f433013c143ebd72c15cac33d2914b8ea4bea7ac2c23578815d6", + "sha256:de04b491d0e5007ee1b63a309956eaed959a49f5bb4e84b26c8f5d49de140fa9", + "sha256:e4b09863aae0dc965c3ef36500d891a3ff495a2ea9ae9171e4519963c12ceefd", + "sha256:e595432ac259af2d4630008bf638873d69346372d38255774c0e286951e8b79f", + "sha256:e75b89ac3bd27d2d043b234aa7b734c38ba1b0e43f07787130a0ecac1e12228a", + "sha256:ea9eb976ffdd79d0e893869cfe179a8f60f152d42cb64622fca418cd9b18dc2a", + "sha256:eafb3e874816ebe2a92f5e155f17260034c8c341dad1df25672fb710627c6949", + "sha256:ee3c36df21b5714d49fc4580247947aa64bcbe2939d1b77b4c8dcb8f6c9faecc", + "sha256:f352b62b45dff37b55ddd7b9c0c8672c4dd2eb9c0f9c11d395075a84e2c40f75", + "sha256:fabb87dd8850ef0f7fe2b366d44b77d7e6fa2ea87861ab3844da99291e81e60f", + "sha256:fe11310ae1e4cd560035598c3f29d86cef39a83d244c7466f95c27ae04850f10", + "sha256:fe7ba4a51f33ab275515f66b0a236bcde4fb5561498fe8f898d4e549b2e4509f" + ], + "markers": "python_version >= '3.6'", + "version": "==3.8.4" + }, + "aiosignal": { + "hashes": [ + "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", + "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.1" + }, + "anyio": { + "hashes": [ + "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421", + "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.6.2" + }, "argparse": { "hashes": [ "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4", @@ -29,50 +138,60 @@ "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144" ], - "markers": "python_full_version >= '3.5.0'", + "markers": "python_version >= '3.5'", "version": "==1.10" }, + "async-timeout": { + "hashes": [ + "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", + "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" + ], + "markers": "python_version >= '3.6'", + "version": "==4.0.2" + }, "attrs": { "hashes": [ - "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6", - "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c" + "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", + "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" ], - "markers": "python_full_version >= '3.5.0'", - "version": "==22.1.0" + "markers": "python_version >= '3.7'", + "version": "==23.1.0" }, "authlib": { "hashes": [ - "sha256:b83cf6360c8e92b0e9df0d1f32d675790bcc4e3c03977499b1eed24dcdef4252", - "sha256:ecf4a7a9f2508c0bb07e93a752dd3c495cfaffc20e864ef0ffc95e3f40d2abaf" + "sha256:2988fdf7d0a5c416f5a37ca4b1e7cee360094940229bc97909aed25880326c72", + "sha256:6de4508ba8125e438a35bcd910d55df7087dccd3dd8517095c2bd9853c372ec1" ], - "version": "==0.15.5" + "version": "==0.15.6" }, "beautifulsoup4": { "hashes": [ - "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30", - "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693" + "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da", + "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a" ], "index": "pypi", - "version": "==4.11.1" + "version": "==4.12.2" }, "boto3": { "hashes": [ - "sha256:3c6cc4e9e38cf4523267f89eb90c0b6084fa415cb4f44e3bf0cad6199340cc92", - "sha256:d28bcb98aee4d333b163c55b98341627d933dbf088832f7fc050893617be7dac" + "sha256:061d3270472b9be09901bb08a45e9871ac8f86a9b1c9c615535ca0223acd7582", + "sha256:5b2b13d9f3430e3d5e768bf32097d5d6d16f47a4719f2656de67da49dd3e4de1" ], "index": "pypi", - "version": "==1.24.92" + "version": "==1.26.131" }, "botocore": { "hashes": [ - "sha256:70cf2cb04968794ed4688cc3b07874f6f4c932e325611be4e693a995fdb481be", - "sha256:b49c34b80c782625905be75e669da4b42a99f074e0aa3007e15ccc6955682a07" + "sha256:d0dea23bccdfd7c2f6d0cd3216cfbd7065bc3e9e7b1ef6fee0952b04f5d2cffd", + "sha256:ffbd85915b2624c545438a33c2624a809593720a10648f6e757fe50be4893188" ], "markers": "python_version >= '3.7'", - "version": "==1.27.92" + "version": "==1.29.131" }, "brotli": { "hashes": [ + "sha256:02177603aaca36e1fd21b091cb742bb3b305a569e2402f1ca38af471777fb019", + "sha256:11d3283d89af7033236fa4e73ec2cbe743d4f6a81d41bd234f24bf63dde979df", "sha256:12effe280b8ebfd389022aa65114e30407540ccb89b177d3fbc9a4f177c4bd5d", "sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8", "sha256:16d528a45c2e1909c2798f27f7bf0a3feec1dc9e50948e738b961618e38b6a7b", @@ -83,9 +202,15 @@ "sha256:26d168aac4aaec9a4394221240e8a5436b5634adc3cd1cdf637f6645cecbf181", "sha256:29d1d350178e5225397e28ea1b7aca3648fcbab546d20e7475805437bfb0a130", "sha256:2aad0e0baa04517741c9bb5b07586c642302e5fb3e75319cb62087bd0995ab19", + "sha256:3148362937217b7072cf80a2dcc007f09bb5ecb96dae4617316638194113d5be", + "sha256:330e3f10cd01da535c70d09c4283ba2df5fb78e915bea0a28becad6e2ac010be", + "sha256:336b40348269f9b91268378de5ff44dc6fbaa2268194f85177b53463d313842a", "sha256:3496fc835370da351d37cada4cf744039616a6db7d13c430035e901443a34daa", "sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429", "sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126", + "sha256:3b8b09a16a1950b9ef495a0f8b9d0a87599a9d1f179e2d4ac014b2ec831f87e7", + "sha256:3c1306004d49b84bd0c4f90457c6f57ad109f5cc6067a9664e12b7b79a9948ad", + "sha256:3ffaadcaeafe9d30a7e4e1e97ad727e4f5610b9fa2f7551998471e3736738679", "sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4", "sha256:44bb8ff420c1d19d91d79d8c3574b8954288bdff0273bf788954064d260d7ab0", "sha256:4688c1e42968ba52e57d8670ad2306fe92e0169c6f3af0089be75bbac0c64a3b", @@ -95,6 +220,7 @@ "sha256:56d027eace784738457437df7331965473f2c0da2c70e1a1f6fdbae5402e0389", "sha256:5913a1177fc36e30fcf6dc868ce23b0453952c78c04c266d3149b3d39e1410d6", "sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26", + "sha256:5bf37a08493232fbb0f8229f1824b366c2fc1d02d64e7e918af40acd15f3e337", "sha256:5cb1e18167792d7d21e21365d7650b72d5081ed476123ff7b8cac7f45189c0c7", "sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14", "sha256:622a231b08899c864eb87e85f81c75e7b9ce05b001e59bbfbf43d4a71f5f32b2", @@ -102,6 +228,7 @@ "sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296", "sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12", "sha256:6d847b14f7ea89f6ad3c9e3901d1bc4835f6b390a9c71df999b0162d9bb1e20f", + "sha256:73fd30d4ce0ea48010564ccee1a26bfe39323fde05cb34b5863455629db61dc7", "sha256:76ffebb907bec09ff511bb3acc077695e2c32bc2142819491579a695f77ffd4d", "sha256:7bbff90b63328013e1e8cb50650ae0b9bac54ffb4be6104378490193cd60f85a", "sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452", @@ -111,6 +238,7 @@ "sha256:87fdccbb6bb589095f413b1e05734ba492c962b4a45a13ff3408fa44ffe6479b", "sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea", "sha256:8a674ac10e0a87b683f4fa2b6fa41090edfd686a6524bd8dedbd6138b309175c", + "sha256:8ed6a5b3d23ecc00ea02e1ed8e0ff9a08f4fc87a1f58a2530e71c0f48adf882f", "sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a", "sha256:9744a863b489c79a73aba014df554b0e7a0fc44ef3f8a0ef2a52919c7d155031", "sha256:9749a124280a0ada4187a6cfd1ffd35c350fb3af79c706589d98e088c5044267", @@ -120,15 +248,24 @@ "sha256:9ed4c92a0665002ff8ea852353aeb60d9141eb04109e88928026d3c8a9e5433c", "sha256:a72661af47119a80d82fa583b554095308d6a4c356b2a554fdc2799bc19f2a43", "sha256:afde17ae04d90fbe53afb628f7f2d4ca022797aa093e809de5c3cf276f61bbfa", + "sha256:b1375b5d17d6145c798661b67e4ae9d5496920d9265e2f00f1c2c0b5ae91fbde", "sha256:b336c5e9cf03c7be40c47b5fd694c43c9f1358a80ba384a21969e0b4e66a9b17", + "sha256:b3523f51818e8f16599613edddb1ff924eeb4b53ab7e7197f85cbc321cdca32f", + "sha256:b43775532a5904bc938f9c15b77c613cb6ad6fb30990f3b0afaea82797a402d8", "sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb", "sha256:b83bb06a0192cccf1eb8d0a28672a1b79c74c3a8a5f2619625aeb6f28b3a82bb", + "sha256:ba72d37e2a924717990f4d7482e8ac88e2ef43fb95491eb6e0d124d77d2a150d", "sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b", "sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4", + "sha256:c8e521a0ce7cf690ca84b8cc2272ddaf9d8a50294fd086da67e517439614c755", + "sha256:cab1b5964b39607a66adbba01f1c12df2e55ac36c81ec6ed44f2fca44178bf1a", + "sha256:cb02ed34557afde2d2da68194d12f5719ee96cfb2eacc886352cb73e3808fc5d", + "sha256:cc0283a406774f465fb45ec7efb66857c09ffefbe49ec20b7882eff6d3c86d3a", "sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3", "sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7", "sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1", "sha256:e16eb9541f3dd1a3e92b89005e37b1257b157b7256df0e36bd7b33b50be73bcb", + "sha256:e1abbeef02962596548382e393f56e4c94acd286bd0c5afba756cffc33670e8a", "sha256:e23281b9a08ec338469268f98f194658abfb13658ee98e2b7f85ee9dd06caa91", "sha256:e2d9e1cbc1b25e22000328702b014227737756f4b5bf5c485ac1d8091ada078b", "sha256:e48f4234f2469ed012a98f4b7874e7f7e173c167bed4934912a29e03167cf6b1", @@ -136,9 +273,60 @@ "sha256:ec1947eabbaf8e0531e8e899fc1d9876c179fc518989461f5d24e2223395a9e3", "sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1" ], - "markers": "platform_python_implementation == 'CPython'", + "markers": "platform_python_implementation >= 'CPython'", "version": "==1.0.9" }, + "brotlipy": { + "hashes": [ + "sha256:07194f4768eb62a4f4ea76b6d0df6ade185e24ebd85877c351daa0a069f1111a", + "sha256:08a16ebe2ffc52f645c076f96b138f185e74e5d59b4a65e84af17d5997d82890", + "sha256:091b299bf36dd6ef7a06570dbc98c0f80a504a56c5b797f31934d2ad01ae7d17", + "sha256:09ec3e125d16749b31c74f021aba809541b3564e5359f8c265cbae442810b41a", + "sha256:0be698678a114addcf87a4b9496c552c68a2c99bf93cf8e08f5738b392e82057", + "sha256:0fa6088a9a87645d43d7e21e32b4a6bf8f7c3939015a50158c10972aa7f425b7", + "sha256:1379347337dc3d20b2d61456d44ccce13e0625db2611c368023b4194d5e2477f", + "sha256:1ea4e578241504b58f2456a6c69952c88866c794648bdc74baee74839da61d44", + "sha256:22a53ccebcce2425e19f99682c12be510bf27bd75c9b77a1720db63047a77554", + "sha256:2699945a0a992c04fc7dc7fa2f1d0575a2c8b4b769f2874a08e8eae46bef36ae", + "sha256:2a80319ae13ea8dd60ecdc4f5ccf6da3ae64787765923256b62c598c5bba4121", + "sha256:2e5c64522364a9ebcdf47c5744a5ddeb3f934742d31e61ebfbbc095460b47162", + "sha256:36def0b859beaf21910157b4c33eb3b06d8ce459c942102f16988cca6ea164df", + "sha256:382971a641125323e90486244d6266ffb0e1f4dd920fbdcf508d2a19acc7c3b3", + "sha256:3a3e56ced8b15fbbd363380344f70f3b438e0fd1fcf27b7526b6172ea950e867", + "sha256:3c1d5e2cf945a46975bdb11a19257fa057b67591eb232f393d260e7246d9e571", + "sha256:4864ac52c116ea3e3a844248a9c9fbebb8797891cbca55484ecb6eed3ebeba24", + "sha256:4bac11c1ffba9eaa2894ec958a44e7f17778b3303c2ee9f99c39fcc511c26668", + "sha256:4e4638b49835d567d447a2cfacec109f9a777f219f071312268b351b6839436d", + "sha256:50ca336374131cfad20612f26cc43c637ac0bfd2be3361495e99270883b52962", + "sha256:5664fe14f3a613431db622172bad923096a303d3adce55536f4409c8e2eafba4", + "sha256:5de6f7d010b7558f72f4b061a07395c5c3fd57f0285c5af7f126a677b976a868", + "sha256:637847560d671657f993313ecc6c6c6666a936b7a925779fd044065c7bc035b9", + "sha256:653faef61241bf8bf99d73ca7ec4baa63401ba7b2a2aa88958394869379d67c7", + "sha256:786afc8c9bd67de8d31f46e408a3386331e126829114e4db034f91eacb05396d", + "sha256:79aaf217072840f3e9a3b641cccc51f7fc23037496bd71e26211856b93f4b4cb", + "sha256:79ab3bca8dd12c17e092273484f2ac48b906de2b4828dcdf6a7d520f99646ab3", + "sha256:7b21341eab7c939214e457e24b265594067a6ad268305289148ebaf2dacef325", + "sha256:7e31f7adcc5851ca06134705fcf3478210da45d35ad75ec181e1ce9ce345bb38", + "sha256:7ff18e42f51ebc9d9d77a0db33f99ad95f01dd431e4491f0eca519b90e9415a9", + "sha256:82f61506d001e626ec3a1ac8a69df11eb3555a4878599befcb672c8178befac8", + "sha256:890b973039ba26c3ad2e86e8908ab527ed64f9b1357f81a676604da8088e4bf9", + "sha256:8b39abc3256c978f575df5cd7893153277216474f303e26f0e43ba3d3969ef96", + "sha256:8ef230ca9e168ce2b7dc173a48a0cc3d78bcdf0bd0ea7743472a317041a4768e", + "sha256:9448227b0df082e574c45c983fa5cd4bda7bfb11ea6b59def0940c1647be0c3c", + "sha256:96bc59ff9b5b5552843dc67999486a220e07a0522dddd3935da05dc194fa485c", + "sha256:a07647886e24e2fb2d68ca8bf3ada398eb56fd8eac46c733d4d95c64d17f743b", + "sha256:ac1d66c9774ee62e762750e399a0c95e93b180e96179b645f28b162b55ae8adc", + "sha256:af65d2699cb9f13b26ec3ba09e75e80d31ff422c03675fcb36ee4dabe588fdc2", + "sha256:b4c98b0d2c9c7020a524ca5bbff42027db1004c6571f8bc7b747f2b843128e7a", + "sha256:b7cf5bb69e767a59acc3da0d199d4b5d0c9fed7bef3ffa3efa80c6f39095686b", + "sha256:c6cc0036b1304dd0073eec416cb2f6b9e37ac8296afd9e481cac3b1f07f9db25", + "sha256:d2c1c724c4ac375feb2110f1af98ecdc0e5a8ea79d068efb5891f621a5b235cb", + "sha256:dc6c5ee0df9732a44d08edab32f8a616b769cc5a4155a12d2d010d248eb3fb07", + "sha256:e5c549ae5928dda952463196180445c24d6fad2d73cb13bd118293aced31b771", + "sha256:fd1d1c64214af5d90014d82cee5d8141b13d44c92ada7a0c0ec0679c6f15a471" + ], + "version": "==0.7.0" + }, "bs4": { "hashes": [ "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a" @@ -148,19 +336,26 @@ }, "cachetools": { "hashes": [ - "sha256:6a94c6402995a99c3970cc7e4884bb60b4a8639938157eeed436098bf9831757", - "sha256:f9f17d2aec496a9aa6b76f53e3b614c965223c061982d434d160f930c698a9db" + "sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14", + "sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4" ], "markers": "python_version ~= '3.7'", - "version": "==5.2.0" + "version": "==5.3.0" + }, + "certauth": { + "hashes": [ + "sha256:7862d5deff0b33d2fb28d36861ba63d91c82d700bfdfc4bd848a8711ca72b8fb", + "sha256:f84b8c7075d0e445614d5ec4662056511453f19228cf4fcf8278cccae17b316b" + ], + "version": "==1.3.0" }, "certifi": { "hashes": [ - "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", - "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" + "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", + "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" ], "markers": "python_version >= '3.6'", - "version": "==2022.6.15" + "version": "==2023.5.7" }, "cffi": { "hashes": [ @@ -233,89 +428,170 @@ }, "charset-normalizer": { "hashes": [ - "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", - "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", + "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", + "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", + "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", + "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", + "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", + "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", + "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", + "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", + "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", + "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", + "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", + "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", + "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", + "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", + "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", + "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", + "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", + "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", + "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", + "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", + "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", + "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", + "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", + "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", + "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", + "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", + "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", + "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", + "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", + "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", + "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", + "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", + "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", + "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", + "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", + "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", + "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", + "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", + "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", + "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", + "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", + "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", + "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", + "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", + "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", + "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", + "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", + "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", + "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", + "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", + "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", + "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", + "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", + "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", + "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", + "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", + "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", + "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", + "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", + "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", + "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", + "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", + "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", + "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", + "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", + "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", + "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", + "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", + "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", + "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", + "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", + "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", + "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", + "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" ], - "markers": "python_full_version >= '3.5.0'", - "version": "==2.0.12" + "markers": "python_version >= '3.6'", + "version": "==3.1.0" }, "click": { "hashes": [ - "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", - "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], - "markers": "python_version >= '3.7'", - "version": "==8.1.3" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==7.1.2" }, "cloudscraper": { "hashes": [ - "sha256:152fa9f9db5f19f4ada7e75623e93f45d05bfd3fb29d9cae84f29173a2591530", - "sha256:59d964acded1a63336b3ce4daf3f2dfed3de7c88f6bf4d904c661b0b4e1b5f5e" + "sha256:429c6e8aa6916d5bad5c8a5eac50f3ea53c9ac22616f6cb21b18dcc71517d0d3", + "sha256:76f50ca529ed2279e220837befdec892626f9511708e200d48d5bb76ded679b0" ], - "version": "==1.2.64" - }, - "commonmark": { - "hashes": [ - "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", - "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9" - ], - "version": "==0.9.1" + "version": "==1.2.71" }, "cryptography": { "hashes": [ - "sha256:0297ffc478bdd237f5ca3a7dc96fc0d315670bfa099c04dc3a4a2172008a405a", - "sha256:10d1f29d6292fc95acb597bacefd5b9e812099d75a6469004fd38ba5471a977f", - "sha256:16fa61e7481f4b77ef53991075de29fc5bacb582a1244046d2e8b4bb72ef66d0", - "sha256:194044c6b89a2f9f169df475cc167f6157eb9151cc69af8a2a163481d45cc407", - "sha256:1db3d807a14931fa317f96435695d9ec386be7b84b618cc61cfa5d08b0ae33d7", - "sha256:3261725c0ef84e7592597606f6583385fed2a5ec3909f43bc475ade9729a41d6", - "sha256:3b72c360427889b40f36dc214630e688c2fe03e16c162ef0aa41da7ab1455153", - "sha256:3e3a2599e640927089f932295a9a247fc40a5bdf69b0484532f530471a382750", - "sha256:3fc26e22840b77326a764ceb5f02ca2d342305fba08f002a8c1f139540cdfaad", - "sha256:5067ee7f2bce36b11d0e334abcd1ccf8c541fc0bbdaf57cdd511fdee53e879b6", - "sha256:52e7bee800ec869b4031093875279f1ff2ed12c1e2f74923e8f49c916afd1d3b", - "sha256:64760ba5331e3f1794d0bcaabc0d0c39e8c60bf67d09c93dc0e54189dfd7cfe5", - "sha256:765fa194a0f3372d83005ab83ab35d7c5526c4e22951e46059b8ac678b44fa5a", - "sha256:79473cf8a5cbc471979bd9378c9f425384980fcf2ab6534b18ed7d0d9843987d", - "sha256:896dd3a66959d3a5ddcfc140a53391f69ff1e8f25d93f0e2e7830c6de90ceb9d", - "sha256:89ed49784ba88c221756ff4d4755dbc03b3c8d2c5103f6d6b4f83a0fb1e85294", - "sha256:ac7e48f7e7261207d750fa7e55eac2d45f720027d5703cd9007e9b37bbb59ac0", - "sha256:ad7353f6ddf285aeadfaf79e5a6829110106ff8189391704c1d8801aa0bae45a", - "sha256:b0163a849b6f315bf52815e238bc2b2346604413fa7c1601eea84bcddb5fb9ac", - "sha256:b6c9b706316d7b5a137c35e14f4103e2115b088c412140fdbd5f87c73284df61", - "sha256:c2e5856248a416767322c8668ef1845ad46ee62629266f84a8f007a317141013", - "sha256:ca9f6784ea96b55ff41708b92c3f6aeaebde4c560308e5fbbd3173fbc466e94e", - "sha256:d1a5bd52d684e49a36582193e0b89ff267704cd4025abefb9e26803adeb3e5fb", - "sha256:d3971e2749a723e9084dd507584e2a2761f78ad2c638aa31e80bc7a15c9db4f9", - "sha256:d4ef6cc305394ed669d4d9eebf10d3a101059bdcf2669c366ec1d14e4fb227bd", - "sha256:d9e69ae01f99abe6ad646947bba8941e896cb3aa805be2597a0400e0764b5818" + "sha256:0e70da4bdff7601b0ef48e6348339e490ebfb0cbe638e083c9c41fb49f00c8bd", + "sha256:10652dd7282de17990b88679cb82f832752c4e8237f0c714be518044269415db", + "sha256:175c1a818b87c9ac80bb7377f5520b7f31b3ef2a0004e2420319beadedb67290", + "sha256:1d7e632804a248103b60b16fb145e8df0bc60eed790ece0d12efe8cd3f3e7744", + "sha256:1f13ddda26a04c06eb57119caf27a524ccae20533729f4b1e4a69b54e07035eb", + "sha256:2ec2a8714dd005949d4019195d72abed84198d877112abb5a27740e217e0ea8d", + "sha256:2fa36a7b2cc0998a3a4d5af26ccb6273f3df133d61da2ba13b3286261e7efb70", + "sha256:2fb481682873035600b5502f0015b664abc26466153fab5c6bc92c1ea69d478b", + "sha256:3178d46f363d4549b9a76264f41c6948752183b3f587666aff0555ac50fd7876", + "sha256:4367da5705922cf7070462e964f66e4ac24162e22ab0a2e9d31f1b270dd78083", + "sha256:4eb85075437f0b1fd8cd66c688469a0c4119e0ba855e3fef86691971b887caf6", + "sha256:50a1494ed0c3f5b4d07650a68cd6ca62efe8b596ce743a5c94403e6f11bf06c1", + "sha256:53049f3379ef05182864d13bb9686657659407148f901f3f1eee57a733fb4b00", + "sha256:6391e59ebe7c62d9902c24a4d8bcbc79a68e7c4ab65863536127c8a9cd94043b", + "sha256:67461b5ebca2e4c2ab991733f8ab637a7265bb582f07c7c88914b5afb88cb95b", + "sha256:78e47e28ddc4ace41dd38c42e6feecfdadf9c3be2af389abbfeef1ff06822285", + "sha256:80ca53981ceeb3241998443c4964a387771588c4e4a5d92735a493af868294f9", + "sha256:8a4b2bdb68a447fadebfd7d24855758fe2d6fecc7fed0b78d190b1af39a8e3b0", + "sha256:8e45653fb97eb2f20b8c96f9cd2b3a0654d742b47d638cf2897afbd97f80fa6d", + "sha256:998cd19189d8a747b226d24c0207fdaa1e6658a1d3f2494541cb9dfbf7dcb6d2", + "sha256:a10498349d4c8eab7357a8f9aa3463791292845b79597ad1b98a543686fb1ec8", + "sha256:b4cad0cea995af760f82820ab4ca54e5471fc782f70a007f31531957f43e9dee", + "sha256:bfe6472507986613dc6cc00b3d492b2f7564b02b3b3682d25ca7f40fa3fd321b", + "sha256:c9e0d79ee4c56d841bd4ac6e7697c8ff3c8d6da67379057f29e66acffcd1e9a7", + "sha256:ca57eb3ddaccd1112c18fc80abe41db443cc2e9dcb1917078e02dfa010a4f353", + "sha256:ce127dd0a6a0811c251a6cddd014d292728484e530d80e872ad9806cfb1c5b3c" ], - "markers": "python_version >= '3.6'", - "version": "==38.0.1" + "index": "pypi", + "version": "==38.0.4" }, "dataclasses-json": { "hashes": [ "sha256:bc285b5f892094c3a53d558858a88553dd6a61a11ab1a8128a0e554385dcc5dd", "sha256:c2c11bc8214fbf709ffc369d11446ff6945254a7f09128154a7620613d8fda90" ], - "markers": "python_version >= '3.6'", + "index": "pypi", "version": "==0.5.7" }, "dateparser": { "hashes": [ - "sha256:038196b1f12c7397e38aad3d61588833257f6f552baa63a1499e6987fa8d42d9", - "sha256:9600874312ff28a41f96ec7ccdc73be1d1c44435719da47fea3339d55ff5a628" + "sha256:070b29b5bbf4b1ec2cd51c96ea040dc68a614de703910a91ad1abba18f9f379f", + "sha256:86b8b7517efcc558f085a142cdb7620f0921543fcabdb538c8a4c4001d8178e3" ], "index": "pypi", - "version": "==1.1.1" + "version": "==1.1.8" + }, + "defusedxml": { + "hashes": [ + "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", + "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.7.1" }, "exceptiongroup": { "hashes": [ - "sha256:2e3c3fc1538a094aab74fad52d6c33fc94de3dfee3ee01f187c0e0c72aec5337", - "sha256:9086a4a21ef9b31c72181c77c040a074ba0889ee56a7b289ff0afb0d97655f96" + "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e", + "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785" ], "markers": "python_version < '3.11'", - "version": "==1.0.0rc9" + "version": "==1.1.1" + }, + "fakeredis": { + "hashes": [ + "sha256:0b03dfdced20169f945bde3cfd505ad5f7b8cf18ad0a124548563cbc587878da", + "sha256:ba8a820203ba5a7a7ef62e3619318d513b87f60328c5583634df398ae4b7af00" + ], + "version": "==0.16.0" }, "ffmpeg-python": { "hashes": [ @@ -327,50 +603,169 @@ }, "filelock": { "hashes": [ - "sha256:55447caa666f2198c5b6b13a26d2084d26fa5b115c00d065664b2124680c4edc", - "sha256:617eb4e5eedc82fc5f47b6d61e4d11cb837c56cb4544e39081099fa17ad109d4" + "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9", + "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718" ], "markers": "python_version >= '3.7'", - "version": "==3.8.0" + "version": "==3.12.0" }, "flask": { "hashes": [ - "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b", - "sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526" + "sha256:0fbeb6180d383a9186d0d6ed954e0042ad9f18e0e8de088b2b419d526927d196", + "sha256:c34f04500f2cbbea882b1acb02002ad6fe6b7ffa64a6164577995657f50aed22" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==1.1.4" + }, + "frozenlist": { + "hashes": [ + "sha256:008a054b75d77c995ea26629ab3a0c0d7281341f2fa7e1e85fa6153ae29ae99c", + "sha256:02c9ac843e3390826a265e331105efeab489ffaf4dd86384595ee8ce6d35ae7f", + "sha256:034a5c08d36649591be1cbb10e09da9f531034acfe29275fc5454a3b101ce41a", + "sha256:05cdb16d09a0832eedf770cb7bd1fe57d8cf4eaf5aced29c4e41e3f20b30a784", + "sha256:0693c609e9742c66ba4870bcee1ad5ff35462d5ffec18710b4ac89337ff16e27", + "sha256:0771aed7f596c7d73444c847a1c16288937ef988dc04fb9f7be4b2aa91db609d", + "sha256:0af2e7c87d35b38732e810befb9d797a99279cbb85374d42ea61c1e9d23094b3", + "sha256:14143ae966a6229350021384870458e4777d1eae4c28d1a7aa47f24d030e6678", + "sha256:180c00c66bde6146a860cbb81b54ee0df350d2daf13ca85b275123bbf85de18a", + "sha256:1841e200fdafc3d51f974d9d377c079a0694a8f06de2e67b48150328d66d5483", + "sha256:23d16d9f477bb55b6154654e0e74557040575d9d19fe78a161bd33d7d76808e8", + "sha256:2b07ae0c1edaa0a36339ec6cce700f51b14a3fc6545fdd32930d2c83917332cf", + "sha256:2c926450857408e42f0bbc295e84395722ce74bae69a3b2aa2a65fe22cb14b99", + "sha256:2e24900aa13212e75e5b366cb9065e78bbf3893d4baab6052d1aca10d46d944c", + "sha256:303e04d422e9b911a09ad499b0368dc551e8c3cd15293c99160c7f1f07b59a48", + "sha256:352bd4c8c72d508778cf05ab491f6ef36149f4d0cb3c56b1b4302852255d05d5", + "sha256:3843f84a6c465a36559161e6c59dce2f2ac10943040c2fd021cfb70d58c4ad56", + "sha256:394c9c242113bfb4b9aa36e2b80a05ffa163a30691c7b5a29eba82e937895d5e", + "sha256:3bbdf44855ed8f0fbcd102ef05ec3012d6a4fd7c7562403f76ce6a52aeffb2b1", + "sha256:40de71985e9042ca00b7953c4f41eabc3dc514a2d1ff534027f091bc74416401", + "sha256:41fe21dc74ad3a779c3d73a2786bdf622ea81234bdd4faf90b8b03cad0c2c0b4", + "sha256:47df36a9fe24054b950bbc2db630d508cca3aa27ed0566c0baf661225e52c18e", + "sha256:4ea42116ceb6bb16dbb7d526e242cb6747b08b7710d9782aa3d6732bd8d27649", + "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a", + "sha256:5c11e43016b9024240212d2a65043b70ed8dfd3b52678a1271972702d990ac6d", + "sha256:5cf820485f1b4c91e0417ea0afd41ce5cf5965011b3c22c400f6d144296ccbc0", + "sha256:5d8860749e813a6f65bad8285a0520607c9500caa23fea6ee407e63debcdbef6", + "sha256:6327eb8e419f7d9c38f333cde41b9ae348bec26d840927332f17e887a8dcb70d", + "sha256:65a5e4d3aa679610ac6e3569e865425b23b372277f89b5ef06cf2cdaf1ebf22b", + "sha256:66080ec69883597e4d026f2f71a231a1ee9887835902dbe6b6467d5a89216cf6", + "sha256:783263a4eaad7c49983fe4b2e7b53fa9770c136c270d2d4bbb6d2192bf4d9caf", + "sha256:7f44e24fa70f6fbc74aeec3e971f60a14dde85da364aa87f15d1be94ae75aeef", + "sha256:7fdfc24dcfce5b48109867c13b4cb15e4660e7bd7661741a391f821f23dfdca7", + "sha256:810860bb4bdce7557bc0febb84bbd88198b9dbc2022d8eebe5b3590b2ad6c842", + "sha256:841ea19b43d438a80b4de62ac6ab21cfe6827bb8a9dc62b896acc88eaf9cecba", + "sha256:84610c1502b2461255b4c9b7d5e9c48052601a8957cd0aea6ec7a7a1e1fb9420", + "sha256:899c5e1928eec13fd6f6d8dc51be23f0d09c5281e40d9cf4273d188d9feeaf9b", + "sha256:8bae29d60768bfa8fb92244b74502b18fae55a80eac13c88eb0b496d4268fd2d", + "sha256:8df3de3a9ab8325f94f646609a66cbeeede263910c5c0de0101079ad541af332", + "sha256:8fa3c6e3305aa1146b59a09b32b2e04074945ffcfb2f0931836d103a2c38f936", + "sha256:924620eef691990dfb56dc4709f280f40baee568c794b5c1885800c3ecc69816", + "sha256:9309869032abb23d196cb4e4db574232abe8b8be1339026f489eeb34a4acfd91", + "sha256:9545a33965d0d377b0bc823dcabf26980e77f1b6a7caa368a365a9497fb09420", + "sha256:9ac5995f2b408017b0be26d4a1d7c61bce106ff3d9e3324374d66b5964325448", + "sha256:9bbbcedd75acdfecf2159663b87f1bb5cfc80e7cd99f7ddd9d66eb98b14a8411", + "sha256:a4ae8135b11652b08a8baf07631d3ebfe65a4c87909dbef5fa0cdde440444ee4", + "sha256:a6394d7dadd3cfe3f4b3b186e54d5d8504d44f2d58dcc89d693698e8b7132b32", + "sha256:a97b4fe50b5890d36300820abd305694cb865ddb7885049587a5678215782a6b", + "sha256:ae4dc05c465a08a866b7a1baf360747078b362e6a6dbeb0c57f234db0ef88ae0", + "sha256:b1c63e8d377d039ac769cd0926558bb7068a1f7abb0f003e3717ee003ad85530", + "sha256:b1e2c1185858d7e10ff045c496bbf90ae752c28b365fef2c09cf0fa309291669", + "sha256:b4395e2f8d83fbe0c627b2b696acce67868793d7d9750e90e39592b3626691b7", + "sha256:b756072364347cb6aa5b60f9bc18e94b2f79632de3b0190253ad770c5df17db1", + "sha256:ba64dc2b3b7b158c6660d49cdb1d872d1d0bf4e42043ad8d5006099479a194e5", + "sha256:bed331fe18f58d844d39ceb398b77d6ac0b010d571cba8267c2e7165806b00ce", + "sha256:c188512b43542b1e91cadc3c6c915a82a5eb95929134faf7fd109f14f9892ce4", + "sha256:c21b9aa40e08e4f63a2f92ff3748e6b6c84d717d033c7b3438dd3123ee18f70e", + "sha256:ca713d4af15bae6e5d79b15c10c8522859a9a89d3b361a50b817c98c2fb402a2", + "sha256:cd4210baef299717db0a600d7a3cac81d46ef0e007f88c9335db79f8979c0d3d", + "sha256:cfe33efc9cb900a4c46f91a5ceba26d6df370ffddd9ca386eb1d4f0ad97b9ea9", + "sha256:d5cd3ab21acbdb414bb6c31958d7b06b85eeb40f66463c264a9b343a4e238642", + "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0", + "sha256:e235688f42b36be2b6b06fc37ac2126a73b75fb8d6bc66dd632aa35286238703", + "sha256:eb82dbba47a8318e75f679690190c10a5e1f447fbf9df41cbc4c3afd726d88cb", + "sha256:ebb86518203e12e96af765ee89034a1dbb0c3c65052d1b0c19bbbd6af8a145e1", + "sha256:ee78feb9d293c323b59a6f2dd441b63339a30edf35abcb51187d2fc26e696d13", + "sha256:eedab4c310c0299961ac285591acd53dc6723a1ebd90a57207c71f6e0c2153ab", + "sha256:efa568b885bca461f7c7b9e032655c0c143d305bf01c30caf6db2854a4532b38", + "sha256:efce6ae830831ab6a22b9b4091d411698145cb9b8fc869e1397ccf4b4b6455cb", + "sha256:f163d2fd041c630fed01bc48d28c3ed4a3b003c00acd396900e11ee5316b56bb", + "sha256:f20380df709d91525e4bee04746ba612a4df0972c1b8f8e1e8af997e678c7b81", + "sha256:f30f1928162e189091cf4d9da2eac617bfe78ef907a761614ff577ef4edfb3c8", + "sha256:f470c92737afa7d4c3aacc001e335062d582053d4dbe73cda126f2d7031068dd", + "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4" ], "markers": "python_version >= '3.7'", - "version": "==2.2.2" + "version": "==1.3.3" }, "future": { "hashes": [ - "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" + "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307" ], "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.18.2" + "version": "==0.18.3" + }, + "gevent": { + "hashes": [ + "sha256:0082d8a5d23c35812ce0e716a91ede597f6dd2c5ff508a02a998f73598c59397", + "sha256:01928770972181ad8866ee37ea3504f1824587b188fcab782ef1619ce7538766", + "sha256:05c5e8a50cd6868dd36536c92fb4468d18090e801bd63611593c0717bab63692", + "sha256:08b4c17064e28f4eb85604486abc89f442c7407d2aed249cf54544ce5c9baee6", + "sha256:177f93a3a90f46a5009e0841fef561601e5c637ba4332ab8572edd96af650101", + "sha256:22ce1f38fdfe2149ffe8ec2131ca45281791c1e464db34b3b4321ae9d8d2efbb", + "sha256:24d3550fbaeef5fddd794819c2853bca45a86c3d64a056a2c268d981518220d1", + "sha256:2afa3f3ad528155433f6ac8bd64fa5cc303855b97004416ec719a6b1ca179481", + "sha256:2bcec9f80196c751fdcf389ca9f7141e7b0db960d8465ed79be5e685bfcad682", + "sha256:2cfff82f05f14b7f5d9ed53ccb7a609ae8604df522bb05c971bca78ec9d8b2b9", + "sha256:3baeeccc4791ba3f8db27179dff11855a8f9210ddd754f6c9b48e0d2561c2aea", + "sha256:3c012c73e6c61f13c75e3a4869dbe6a2ffa025f103421a6de9c85e627e7477b1", + "sha256:3dad62f55fad839d498c801e139481348991cee6e1c7706041b5fe096cb6a279", + "sha256:542ae891e2aa217d2cf6d8446538fcd2f3263a40eec123b970b899bac391c47a", + "sha256:6a02a88723ed3f0fd92cbf1df3c4cd2fbd87d82b0a4bac3e36a8875923115214", + "sha256:74fc1ef16b86616cfddcc74f7292642b0f72dde4dd95aebf4c45bb236744be54", + "sha256:7909780f0cf18a1fc32aafd8c8e130cdd93c6e285b11263f7f2d1a0f3678bc50", + "sha256:7ccffcf708094564e442ac6fde46f0ae9e40015cb69d995f4b39cc29a7643881", + "sha256:8c21cb5c9f4e14d75b3fe0b143ec875d7dbd1495fad6d49704b00e57e781ee0f", + "sha256:973749bacb7bc4f4181a8fb2a7e0e2ff44038de56d08e856dd54a5ac1d7331b4", + "sha256:9d86438ede1cbe0fde6ef4cc3f72bf2f1ecc9630d8b633ff344a3aeeca272cdd", + "sha256:9f9652d1e4062d4b5b5a0a49ff679fa890430b5f76969d35dccb2df114c55e0f", + "sha256:a5ad4ed8afa0a71e1927623589f06a9b5e8b5e77810be3125cb4d93050d3fd1f", + "sha256:b7709c64afa8bb3000c28bb91ec42c79594a7cb0f322e20427d57f9762366a5b", + "sha256:bb5cb8db753469c7a9a0b8a972d2660fe851aa06eee699a1ca42988afb0aaa02", + "sha256:c43f081cbca41d27fd8fef9c6a32cf83cb979345b20abc07bf68df165cdadb24", + "sha256:cc2fef0f98ee180704cf95ec84f2bc2d86c6c3711bb6b6740d74e0afe708b62c", + "sha256:da8d2d51a49b2a5beb02ad619ca9ddbef806ef4870ba04e5ac7b8b41a5b61db3", + "sha256:e1899b921219fc8959ff9afb94dae36be82e0769ed13d330a393594d478a0b3a", + "sha256:eae3c46f9484eaacd67ffcdf4eaf6ca830f587edd543613b0f5c4eb3c11d052d", + "sha256:ec21f9eaaa6a7b1e62da786132d6788675b314f25f98d9541f1bf00584ed4749", + "sha256:f289fae643a3f1c3b909d6b033e6921b05234a4907e9c9c8c3f1fe403e6ac452", + "sha256:f48b64578c367b91fa793bf8eaaaf4995cb93c8bc45860e473bf868070ad094e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==21.12.0" }, "google-api-core": { "hashes": [ - "sha256:10c06f7739fe57781f87523375e8e1a3a4674bf6392cd6131a3222182b971320", - "sha256:34f24bd1d5f72a8c4519773d99ca6bf080a6c4e041b4e9f024fe230191dda62e" + "sha256:4b9bb5d5a380a0befa0573b302651b8a9a89262c1730e37bf423cec511804c22", + "sha256:ce222e27b0de0d7bc63eb043b956996d6dccab14cc3b690aaea91c9cc99dc16e" ], "markers": "python_version >= '3.7'", - "version": "==2.10.2" + "version": "==2.11.0" }, "google-api-python-client": { "hashes": [ - "sha256:0dc4c967a5c795e981af01340f1bd22173a986534de968b5456cb208ed6775a6", - "sha256:90545cd71969f8bcf15a6362c2a8c44c38b94ec35a88cfd60cf2c0df68a5eb74" + "sha256:0f320190ab9d5bd2fdb0cb894e8e53bb5e17d4888ee8dc4d26ba65ce378409e2", + "sha256:3ca4e93821f4e9ac29b91ab0d9df168b42c8ad0fb8bff65b8c2ccb2d462b0464" ], "index": "pypi", - "version": "==2.64.0" + "version": "==2.86.0" }, "google-auth": { "hashes": [ - "sha256:9352dd6394093169157e6971526bab9a2799244d68a94a4a609f0dd751ef6f5e", - "sha256:99510e664155f1a3c0396a076b5deb6367c52ea04d280152c85ac7f51f50eb42" + "sha256:ce311e2bc58b130fddf316df57c9b3943c2a7b4f6ec31de9663a9333e4064efc", + "sha256:f586b274d3eb7bd932ea424b1c702a30e0393a2e2bc4ca3eae8263ffd8be229f" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==2.13.0" + "version": "==2.17.3" }, "google-auth-httplib2": { "hashes": [ @@ -382,27 +777,99 @@ }, "google-auth-oauthlib": { "hashes": [ - "sha256:307d21918d61a0741882ad1fd001c67e68ad81206451d05fc4d26f79de56fc90", - "sha256:9e8ff4ed2b21c174a2d6cc2172c698dbf0b1f686509774c663a83c495091fe09" + "sha256:95880ca704928c300f48194d1770cf5b1462835b6e49db61445a520f793fd5fb", + "sha256:e375064964820b47221a7e1b7ee1fd77051b6323c3f9e3e19785f78ab67ecfc5" ], "index": "pypi", - "version": "==0.5.3" + "version": "==1.0.0" }, "googleapis-common-protos": { "hashes": [ - "sha256:8eb2cbc91b69feaf23e32452a7ae60e791e09967d81d4fcc7fc388182d1bd394", - "sha256:c25873c47279387cfdcbdafa36149887901d36202cb645a0e4f29686bf6e4417" + "sha256:4168fcb568a826a52f23510412da405abd93f4d23ba544bb68d943b14ba3cb44", + "sha256:b287dc48449d1d41af0c69f4ea26242b5ae4c3d7249a38b0984c86a4caffff1f" ], "markers": "python_version >= '3.7'", - "version": "==1.56.4" + "version": "==1.59.0" + }, + "greenlet": { + "hashes": [ + "sha256:0120a879aa2b1ac5118bce959ea2492ba18783f65ea15821680a256dfad04754", + "sha256:025b8de2273d2809f027d347aa2541651d2e15d593bbce0d5f502ca438c54136", + "sha256:05ae7383f968bba4211b1fbfc90158f8e3da86804878442b4fb6c16ccbcaa519", + "sha256:0914f02fcaa8f84f13b2df4a81645d9e82de21ed95633765dd5cc4d3af9d7403", + "sha256:0971d37ae0eaf42344e8610d340aa0ad3d06cd2eee381891a10fe771879791f9", + "sha256:0a954002064ee919b444b19c1185e8cce307a1f20600f47d6f4b6d336972c809", + "sha256:0aa1845944e62f358d63fcc911ad3b415f585612946b8edc824825929b40e59e", + "sha256:104f29dd822be678ef6b16bf0035dcd43206a8a48668a6cae4d2fe9c7a7abdeb", + "sha256:11fc7692d95cc7a6a8447bb160d98671ab291e0a8ea90572d582d57361360f05", + "sha256:17a69967561269b691747e7f436d75a4def47e5efcbc3c573180fc828e176d80", + "sha256:2794eef1b04b5ba8948c72cc606aab62ac4b0c538b14806d9c0d88afd0576d6b", + "sha256:2c6e942ca9835c0b97814d14f78da453241837419e0d26f7403058e8db3e38f8", + "sha256:2ccdc818cc106cc238ff7eba0d71b9c77be868fdca31d6c3b1347a54c9b187b2", + "sha256:325f272eb997916b4a3fc1fea7313a8adb760934c2140ce13a2117e1b0a8095d", + "sha256:39464518a2abe9c505a727af7c0b4efff2cf242aa168be5f0daa47649f4d7ca8", + "sha256:3a24f3213579dc8459e485e333330a921f579543a5214dbc935bc0763474ece3", + "sha256:3aeac044c324c1a4027dca0cde550bd83a0c0fbff7ef2c98df9e718a5086c194", + "sha256:3c22998bfef3fcc1b15694818fc9b1b87c6cc8398198b96b6d355a7bcb8c934e", + "sha256:467b73ce5dcd89e381292fb4314aede9b12906c18fab903f995b86034d96d5c8", + "sha256:4a8b58232f5b72973350c2b917ea3df0bebd07c3c82a0a0e34775fc2c1f857e9", + "sha256:4f74aa0092602da2069df0bc6553919a15169d77bcdab52a21f8c5242898f519", + "sha256:5662492df0588a51d5690f6578f3bbbd803e7f8d99a99f3bf6128a401be9c269", + "sha256:5c2d21c2b768d8c86ad935e404cc78c30d53dea009609c3ef3a9d49970c864b5", + "sha256:5edf75e7fcfa9725064ae0d8407c849456553a181ebefedb7606bac19aa1478b", + "sha256:60839ab4ea7de6139a3be35b77e22e0398c270020050458b3d25db4c7c394df5", + "sha256:62723e7eb85fa52e536e516ee2ac91433c7bb60d51099293671815ff49ed1c21", + "sha256:64e10f303ea354500c927da5b59c3802196a07468332d292aef9ddaca08d03dd", + "sha256:66aa4e9a726b70bcbfcc446b7ba89c8cec40f405e51422c39f42dfa206a96a05", + "sha256:695d0d8b5ae42c800f1763c9fce9d7b94ae3b878919379150ee5ba458a460d57", + "sha256:70048d7b2c07c5eadf8393e6398595591df5f59a2f26abc2f81abca09610492f", + "sha256:7afa706510ab079fd6d039cc6e369d4535a48e202d042c32e2097f030a16450f", + "sha256:7cf37343e43404699d58808e51f347f57efd3010cc7cee134cdb9141bd1ad9ea", + "sha256:8149a6865b14c33be7ae760bcdb73548bb01e8e47ae15e013bf7ef9290ca309a", + "sha256:814f26b864ed2230d3a7efe0336f5766ad012f94aad6ba43a7c54ca88dd77cba", + "sha256:82a38d7d2077128a017094aff334e67e26194f46bd709f9dcdacbf3835d47ef5", + "sha256:83a7a6560df073ec9de2b7cb685b199dfd12519bc0020c62db9d1bb522f989fa", + "sha256:8415239c68b2ec9de10a5adf1130ee9cb0ebd3e19573c55ba160ff0ca809e012", + "sha256:88720794390002b0c8fa29e9602b395093a9a766b229a847e8d88349e418b28a", + "sha256:890f633dc8cb307761ec566bc0b4e350a93ddd77dc172839be122be12bae3e10", + "sha256:8926a78192b8b73c936f3e87929931455a6a6c6c385448a07b9f7d1072c19ff3", + "sha256:8c0581077cf2734569f3e500fab09c0ff6a2ab99b1afcacbad09b3c2843ae743", + "sha256:8fda1139d87ce5f7bd80e80e54f9f2c6fe2f47983f1a6f128c47bf310197deb6", + "sha256:91a84faf718e6f8b888ca63d0b2d6d185c8e2a198d2a7322d75c303e7097c8b7", + "sha256:924df1e7e5db27d19b1359dc7d052a917529c95ba5b8b62f4af611176da7c8ad", + "sha256:949c9061b8c6d3e6e439466a9be1e787208dec6246f4ec5fffe9677b4c19fcc3", + "sha256:9649891ab4153f217f319914455ccf0b86986b55fc0573ce803eb998ad7d6854", + "sha256:96656c5f7c95fc02c36d4f6ef32f4e94bb0b6b36e6a002c21c39785a4eec5f5d", + "sha256:a812df7282a8fc717eafd487fccc5ba40ea83bb5b13eb3c90c446d88dbdfd2be", + "sha256:a8d24eb5cb67996fb84633fdc96dbc04f2d8b12bfcb20ab3222d6be271616b67", + "sha256:bef49c07fcb411c942da6ee7d7ea37430f830c482bf6e4b72d92fd506dd3a427", + "sha256:bffba15cff4802ff493d6edcf20d7f94ab1c2aee7cfc1e1c7627c05f1102eee8", + "sha256:c0643250dd0756f4960633f5359884f609a234d4066686754e834073d84e9b51", + "sha256:c6f90234e4438062d6d09f7d667f79edcc7c5e354ba3a145ff98176f974b8132", + "sha256:c8c9301e3274276d3d20ab6335aa7c5d9e5da2009cccb01127bddb5c951f8870", + "sha256:c8ece5d1a99a2adcb38f69af2f07d96fb615415d32820108cd340361f590d128", + "sha256:cb863057bed786f6622982fb8b2c122c68e6e9eddccaa9fa98fd937e45ee6c4f", + "sha256:ccbe7129a282ec5797df0451ca1802f11578be018a32979131065565da89b392", + "sha256:d25cdedd72aa2271b984af54294e9527306966ec18963fd032cc851a725ddc1b", + "sha256:d75afcbb214d429dacdf75e03a1d6d6c5bd1fa9c35e360df8ea5b6270fb2211c", + "sha256:d7815e1519a8361c5ea2a7a5864945906f8e386fa1bc26797b4d443ab11a4589", + "sha256:eb6ac495dccb1520667cfea50d89e26f9ffb49fa28496dea2b95720d8b45eb54", + "sha256:ec615d2912b9ad807afd3be80bf32711c0ff9c2b00aa004a45fd5d5dde7853d9", + "sha256:f5e09dc5c6e1796969fd4b775ea1417d70e49a5df29aaa8e5d10675d9e11872c", + "sha256:f6661b58412879a2aa099abb26d3c93e91dedaba55a6394d1fb1512a77e85de9", + "sha256:f7d20c3267385236b4ce54575cc8e9f43e7673fc761b069c820097092e318e3b", + "sha256:fe7c51f8a2ab616cb34bc33d810c887e89117771028e1e3d3b77ca25ddeace04" + ], + "markers": "platform_python_implementation == 'CPython'", + "version": "==1.1.3.post0" }, "gspread": { "hashes": [ - "sha256:0fe52bec73cc232abadfbc2a999e30201bc5cb0c2728ec00fcfdf38f6f669375", - "sha256:9fca855173fdb2e648b3da9e7bbffb83601bfd7c7131d44fa781df84c689e7fc" + "sha256:b6125120049fd92a3e1e7544ac395f336cec3ff8be04550cf737d90af007685d", + "sha256:f9739e2b839d7fa1f8a5f0cf0d4ee68c776e2fbf4bfe3167ad2e910bd588fb44" ], "index": "pypi", - "version": "==5.6.0" + "version": "==5.8.0" }, "h11": { "hashes": [ @@ -412,37 +879,60 @@ "markers": "python_version >= '3.7'", "version": "==0.14.0" }, + "httpcore": { + "hashes": [ + "sha256:0fdfea45e94f0c9fd96eab9286077f9ff788dd186635ae61b312693e4d943599", + "sha256:cc045a3241afbf60ce056202301b4d8b6af08845e3294055eb26b09913ef903c" + ], + "markers": "python_version >= '3.7'", + "version": "==0.17.0" + }, "httplib2": { "hashes": [ - "sha256:58a98e45b4b1a48273073f905d2961666ecf0fbac4250ea5b47aef259eb5c585", - "sha256:8b6a905cb1c79eefd03f8669fd993c36dc341f7c558f056cb5a33b5c2f458543" + "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc", + "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.20.4" + "version": "==0.22.0" + }, + "httpx": { + "hashes": [ + "sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e", + "sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e" + ], + "markers": "python_version >= '3.7'", + "version": "==0.24.0" }, "idna": { "hashes": [ - "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", - "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" ], - "markers": "python_full_version >= '3.5.0'", - "version": "==3.3" + "markers": "python_version >= '3.5'", + "version": "==3.4" + }, + "instaloader": { + "hashes": [ + "sha256:16040c170fb5230c1981a47e1990261e3c0ecffe0417be95fa265632244e7c01" + ], + "index": "pypi", + "version": "==4.9.6" }, "itsdangerous": { "hashes": [ - "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", - "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a" + "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", + "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" ], - "markers": "python_version >= '3.7'", - "version": "==2.1.2" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.1.0" }, "jinja2": { "hashes": [ - "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", - "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", + "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" ], - "markers": "python_version >= '3.7'", - "version": "==3.1.2" + "index": "pypi", + "version": "==2.11.3" }, "jmespath": { "hashes": [ @@ -454,141 +944,185 @@ }, "loguru": { "hashes": [ - "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c", - "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3" + "sha256:1612053ced6ae84d7959dd7d5e431a0532642237ec21f7fd83ac73fe539e03e1", + "sha256:b93aa30099fa6860d4727f1b81f8718e965bb96253fa190fab2077aaad6d15d3" ], "index": "pypi", - "version": "==0.6.0" + "version": "==0.7.0" }, "lxml": { "hashes": [ - "sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318", - "sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c", - "sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b", - "sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000", - "sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73", - "sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d", - "sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb", - "sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8", - "sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2", - "sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345", - "sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94", - "sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e", - "sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b", - "sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc", - "sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a", - "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9", - "sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc", - "sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387", - "sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb", - "sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7", - "sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4", - "sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97", - "sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67", - "sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627", - "sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7", - "sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd", - "sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3", - "sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7", - "sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130", - "sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b", - "sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036", - "sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785", - "sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca", - "sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91", - "sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc", - "sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536", - "sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391", - "sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3", - "sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d", - "sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21", - "sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3", - "sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d", - "sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29", - "sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715", - "sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed", - "sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25", - "sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c", - "sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785", - "sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837", - "sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4", - "sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b", - "sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2", - "sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067", - "sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448", - "sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d", - "sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2", - "sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc", - "sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c", - "sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5", - "sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84", - "sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8", - "sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf", - "sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7", - "sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e", - "sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb", - "sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b", - "sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3", - "sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad", - "sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8", - "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f" + "sha256:01d36c05f4afb8f7c20fd9ed5badca32a2029b93b1750f571ccc0b142531caf7", + "sha256:04876580c050a8c5341d706dd464ff04fd597095cc8c023252566a8826505726", + "sha256:05ca3f6abf5cf78fe053da9b1166e062ade3fa5d4f92b4ed688127ea7d7b1d03", + "sha256:090c6543d3696cbe15b4ac6e175e576bcc3f1ccfbba970061b7300b0c15a2140", + "sha256:0dc313ef231edf866912e9d8f5a042ddab56c752619e92dfd3a2c277e6a7299a", + "sha256:0f2b1e0d79180f344ff9f321327b005ca043a50ece8713de61d1cb383fb8ac05", + "sha256:13598ecfbd2e86ea7ae45ec28a2a54fb87ee9b9fdb0f6d343297d8e548392c03", + "sha256:16efd54337136e8cd72fb9485c368d91d77a47ee2d42b057564aae201257d419", + "sha256:1ab8f1f932e8f82355e75dda5413a57612c6ea448069d4fb2e217e9a4bed13d4", + "sha256:223f4232855ade399bd409331e6ca70fb5578efef22cf4069a6090acc0f53c0e", + "sha256:2455cfaeb7ac70338b3257f41e21f0724f4b5b0c0e7702da67ee6c3640835b67", + "sha256:2899456259589aa38bfb018c364d6ae7b53c5c22d8e27d0ec7609c2a1ff78b50", + "sha256:2a29ba94d065945944016b6b74e538bdb1751a1db6ffb80c9d3c2e40d6fa9894", + "sha256:2a87fa548561d2f4643c99cd13131acb607ddabb70682dcf1dff5f71f781a4bf", + "sha256:2e430cd2824f05f2d4f687701144556646bae8f249fd60aa1e4c768ba7018947", + "sha256:36c3c175d34652a35475a73762b545f4527aec044910a651d2bf50de9c3352b1", + "sha256:3818b8e2c4b5148567e1b09ce739006acfaa44ce3156f8cbbc11062994b8e8dd", + "sha256:3ab9fa9d6dc2a7f29d7affdf3edebf6ece6fb28a6d80b14c3b2fb9d39b9322c3", + "sha256:3efea981d956a6f7173b4659849f55081867cf897e719f57383698af6f618a92", + "sha256:4c8f293f14abc8fd3e8e01c5bd86e6ed0b6ef71936ded5bf10fe7a5efefbaca3", + "sha256:5344a43228767f53a9df6e5b253f8cdca7dfc7b7aeae52551958192f56d98457", + "sha256:58bfa3aa19ca4c0f28c5dde0ff56c520fbac6f0daf4fac66ed4c8d2fb7f22e74", + "sha256:5b4545b8a40478183ac06c073e81a5ce4cf01bf1734962577cf2bb569a5b3bbf", + "sha256:5f50a1c177e2fa3ee0667a5ab79fdc6b23086bc8b589d90b93b4bd17eb0e64d1", + "sha256:63da2ccc0857c311d764e7d3d90f429c252e83b52d1f8f1d1fe55be26827d1f4", + "sha256:6749649eecd6a9871cae297bffa4ee76f90b4504a2a2ab528d9ebe912b101975", + "sha256:6804daeb7ef69e7b36f76caddb85cccd63d0c56dedb47555d2fc969e2af6a1a5", + "sha256:689bb688a1db722485e4610a503e3e9210dcc20c520b45ac8f7533c837be76fe", + "sha256:699a9af7dffaf67deeae27b2112aa06b41c370d5e7633e0ee0aea2e0b6c211f7", + "sha256:6b418afe5df18233fc6b6093deb82a32895b6bb0b1155c2cdb05203f583053f1", + "sha256:76cf573e5a365e790396a5cc2b909812633409306c6531a6877c59061e42c4f2", + "sha256:7b515674acfdcadb0eb5d00d8a709868173acece5cb0be3dd165950cbfdf5409", + "sha256:7b770ed79542ed52c519119473898198761d78beb24b107acf3ad65deae61f1f", + "sha256:7d2278d59425777cfcb19735018d897ca8303abe67cc735f9f97177ceff8027f", + "sha256:7e91ee82f4199af8c43d8158024cbdff3d931df350252288f0d4ce656df7f3b5", + "sha256:821b7f59b99551c69c85a6039c65b75f5683bdc63270fec660f75da67469ca24", + "sha256:822068f85e12a6e292803e112ab876bc03ed1f03dddb80154c395f891ca6b31e", + "sha256:8340225bd5e7a701c0fa98284c849c9b9fc9238abf53a0ebd90900f25d39a4e4", + "sha256:85cabf64adec449132e55616e7ca3e1000ab449d1d0f9d7f83146ed5bdcb6d8a", + "sha256:880bbbcbe2fca64e2f4d8e04db47bcdf504936fa2b33933efd945e1b429bea8c", + "sha256:8d0b4612b66ff5d62d03bcaa043bb018f74dfea51184e53f067e6fdcba4bd8de", + "sha256:8e20cb5a47247e383cf4ff523205060991021233ebd6f924bca927fcf25cf86f", + "sha256:925073b2fe14ab9b87e73f9a5fde6ce6392da430f3004d8b72cc86f746f5163b", + "sha256:998c7c41910666d2976928c38ea96a70d1aa43be6fe502f21a651e17483a43c5", + "sha256:9b22c5c66f67ae00c0199f6055705bc3eb3fcb08d03d2ec4059a2b1b25ed48d7", + "sha256:9f102706d0ca011de571de32c3247c6476b55bb6bc65a20f682f000b07a4852a", + "sha256:a08cff61517ee26cb56f1e949cca38caabe9ea9fbb4b1e10a805dc39844b7d5c", + "sha256:a0a336d6d3e8b234a3aae3c674873d8f0e720b76bc1d9416866c41cd9500ffb9", + "sha256:a35f8b7fa99f90dd2f5dc5a9fa12332642f087a7641289ca6c40d6e1a2637d8e", + "sha256:a38486985ca49cfa574a507e7a2215c0c780fd1778bb6290c21193b7211702ab", + "sha256:a5da296eb617d18e497bcf0a5c528f5d3b18dadb3619fbdadf4ed2356ef8d941", + "sha256:a6e441a86553c310258aca15d1c05903aaf4965b23f3bc2d55f200804e005ee5", + "sha256:a82d05da00a58b8e4c0008edbc8a4b6ec5a4bc1e2ee0fb6ed157cf634ed7fa45", + "sha256:ab323679b8b3030000f2be63e22cdeea5b47ee0abd2d6a1dc0c8103ddaa56cd7", + "sha256:b1f42b6921d0e81b1bcb5e395bc091a70f41c4d4e55ba99c6da2b31626c44892", + "sha256:b23e19989c355ca854276178a0463951a653309fb8e57ce674497f2d9f208746", + "sha256:b264171e3143d842ded311b7dccd46ff9ef34247129ff5bf5066123c55c2431c", + "sha256:b26a29f0b7fc6f0897f043ca366142d2b609dc60756ee6e4e90b5f762c6adc53", + "sha256:b64d891da92e232c36976c80ed7ebb383e3f148489796d8d31a5b6a677825efe", + "sha256:b9cc34af337a97d470040f99ba4282f6e6bac88407d021688a5d585e44a23184", + "sha256:bc718cd47b765e790eecb74d044cc8d37d58562f6c314ee9484df26276d36a38", + "sha256:be7292c55101e22f2a3d4d8913944cbea71eea90792bf914add27454a13905df", + "sha256:c83203addf554215463b59f6399835201999b5e48019dc17f182ed5ad87205c9", + "sha256:c9ec3eaf616d67db0764b3bb983962b4f385a1f08304fd30c7283954e6a7869b", + "sha256:ca34efc80a29351897e18888c71c6aca4a359247c87e0b1c7ada14f0ab0c0fb2", + "sha256:ca989b91cf3a3ba28930a9fc1e9aeafc2a395448641df1f387a2d394638943b0", + "sha256:d02a5399126a53492415d4906ab0ad0375a5456cc05c3fc0fc4ca11771745cda", + "sha256:d17bc7c2ccf49c478c5bdd447594e82692c74222698cfc9b5daae7ae7e90743b", + "sha256:d5bf6545cd27aaa8a13033ce56354ed9e25ab0e4ac3b5392b763d8d04b08e0c5", + "sha256:d6b430a9938a5a5d85fc107d852262ddcd48602c120e3dbb02137c83d212b380", + "sha256:da248f93f0418a9e9d94b0080d7ebc407a9a5e6d0b57bb30db9b5cc28de1ad33", + "sha256:da4dd7c9c50c059aba52b3524f84d7de956f7fef88f0bafcf4ad7dde94a064e8", + "sha256:df0623dcf9668ad0445e0558a21211d4e9a149ea8f5666917c8eeec515f0a6d1", + "sha256:e5168986b90a8d1f2f9dc1b841467c74221bd752537b99761a93d2d981e04889", + "sha256:efa29c2fe6b4fdd32e8ef81c1528506895eca86e1d8c4657fda04c9b3786ddf9", + "sha256:f1496ea22ca2c830cbcbd473de8f114a320da308438ae65abad6bab7867fe38f", + "sha256:f49e52d174375a7def9915c9f06ec4e569d235ad428f70751765f48d5926678c" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.9.1" + "version": "==4.9.2" + }, + "markdown-it-py": { + "hashes": [ + "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30", + "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.2.0" }, "markupsafe": { "hashes": [ - "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", - "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", - "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", - "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", - "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", - "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", - "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", - "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", - "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", - "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", - "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", - "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", - "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", - "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", - "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", - "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", - "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", - "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", - "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", - "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", - "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", - "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", - "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", - "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", - "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", - "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", - "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", - "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", - "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", - "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", - "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", - "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", - "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", - "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", - "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", - "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", - "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", - "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", - "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", - "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" + "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", + "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", + "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", + "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", + "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", + "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", + "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", + "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", + "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", + "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", + "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", + "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", + "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", + "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", + "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", + "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", + "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", + "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", + "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", + "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", + "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", + "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", + "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", + "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", + "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", + "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", + "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", + "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", + "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", + "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", + "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", + "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", + "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", + "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", + "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", + "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", + "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", + "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", + "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", + "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", + "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", + "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", + "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", + "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", + "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", + "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", + "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", + "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", + "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", + "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", + "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", + "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", + "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", + "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", + "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", + "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", + "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", + "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", + "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", + "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", + "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", + "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", + "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", + "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", + "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", + "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", + "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", + "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", + "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" ], - "markers": "python_version >= '3.7'", - "version": "==2.1.1" + "markers": "python_version >= '3.6'", + "version": "==2.0.1" }, "marshmallow": { "hashes": [ - "sha256:35e02a3a06899c9119b785c12a22f4cda361745d66a71ab691fd7610202ae104", - "sha256:6804c16114f7fce1f5b4dadc31f4674af23317fcc7f075da21e35c1a35d781f7" + "sha256:90032c0fd650ce94b6ec6dc8dfeb0e3ff50c144586462c389b81a07205bedb78", + "sha256:93f0958568da045b0021ec6aeb7ac37c81bfcccbb9a0e7ed8559885070b3a19b" ], "markers": "python_version >= '3.7'", - "version": "==3.18.0" + "version": "==3.19.0" }, "marshmallow-enum": { "hashes": [ @@ -597,6 +1131,94 @@ ], "version": "==1.5.1" }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "multidict": { + "hashes": [ + "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9", + "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8", + "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03", + "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710", + "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161", + "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664", + "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569", + "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067", + "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313", + "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706", + "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2", + "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636", + "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49", + "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93", + "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603", + "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0", + "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60", + "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4", + "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e", + "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1", + "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60", + "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951", + "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc", + "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe", + "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95", + "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d", + "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8", + "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed", + "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2", + "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775", + "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87", + "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c", + "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2", + "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98", + "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3", + "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe", + "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78", + "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660", + "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176", + "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e", + "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988", + "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c", + "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c", + "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0", + "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449", + "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f", + "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde", + "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5", + "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d", + "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac", + "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a", + "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9", + "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca", + "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11", + "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35", + "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063", + "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b", + "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982", + "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258", + "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1", + "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52", + "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480", + "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7", + "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461", + "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d", + "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc", + "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779", + "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a", + "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547", + "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0", + "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171", + "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf", + "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d", + "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0.4" + }, "mutagen": { "hashes": [ "sha256:6e5f8ba84836b99fe60be5fb27f84be4ad919bbb6b49caa6ae81e70584b55e58", @@ -607,10 +1229,11 @@ }, "mypy-extensions": { "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" ], - "version": "==0.4.3" + "markers": "python_version >= '3.5'", + "version": "==1.0.0" }, "oauth2client": { "hashes": [ @@ -638,31 +1261,44 @@ }, "packaging": { "hashes": [ - "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", - "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" + "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", + "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" ], - "markers": "python_version >= '3.6'", - "version": "==21.3" + "markers": "python_version >= '3.7'", + "version": "==23.1" + }, + "portalocker": { + "hashes": [ + "sha256:032e81d534a88ec1736d03f780ba073f047a06c478b06e2937486f334e955c51", + "sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983" + ], + "markers": "python_version >= '3.5'", + "version": "==2.7.0" }, "protobuf": { "hashes": [ - "sha256:3ec85328a35a16463c6f419dbce3c0fc42b3e904d966f17f48bae39597c7a543", - "sha256:58b81358ec6c0b5d50df761460ae2db58405c063fd415e1101209221a0a810e1", - "sha256:71d9dba03ed3432c878a801e2ea51e034b0ea01cf3a4344fb60166cb5f6c8757", - "sha256:8066322588d4b499869bf9f665ebe448e793036b552f68c585a9b28f1e393f66", - "sha256:8e09d1916386eca1ef1353767b6efcebc0a6859ed7f73cb7fb974feba3184830", - "sha256:9643684232b6b340b5e63bb69c9b4904cdd39e4303d498d1a92abddc7e895b7f", - "sha256:9e355f2a839d9930d83971b9f562395e13493f0e9211520f8913bd11efa53c02", - "sha256:a74d96cd960b87b4b712797c741bb3ea3a913f5c2dc4b6cbe9c0f8360b75297d", - "sha256:b019c79e23a80735cc8a71b95f76a49a262f579d6b84fd20a0b82279f40e2cc1", - "sha256:c7cb105d69a87416bd9023e64324e1c089593e6dae64d2536f06bcbe49cd97d8", - "sha256:ca200645d6235ce0df3ccfdff1567acbab35c4db222a97357806e015f85b5744", - "sha256:d3f89ccf7182293feba2de2739c8bf34fed1ed7c65a5cf987be00311acac57c1", - "sha256:db9056b6a11cb5131036d734bcbf91ef3ef9235d6b681b2fc431cbfe5a7f2e56", - "sha256:f370c0a71712f8965023dd5b13277444d3cdfecc96b2c778b0e19acbfd60df6e" + "sha256:03eee35b60317112a72d19c54d0bff7bc58ff12fea4cd7b018232bd99758ffdf", + "sha256:2b94bd6df92d71bd1234a2ffe7ce96ddf6d10cf637a18d6b55ad0a89fbb7fc21", + "sha256:36f5370a930cb77c8ad2f4135590c672d0d2c72d4a707c7d0058dce4b4b4a598", + "sha256:5f1eba1da2a2f3f7df469fccddef3cc060b8a16cfe3cc65961ad36b4dbcf59c5", + "sha256:6c16657d6717a0c62d5d740cb354fbad1b0d8cb811669e06fc1caa0ff4799ddd", + "sha256:6fe180b56e1169d72ecc4acbd39186339aed20af5384531b8e8979b02bbee159", + "sha256:7cb5b9a05ce52c6a782bb97de52679bd3438ff2b7460eff5da348db65650f227", + "sha256:9744e934ea5855d12191040ea198eaf704ac78665d365a89d9572e3b627c2688", + "sha256:9f5a0fbfcdcc364f3986f9ed9f8bb1328fb84114fd790423ff3d7fdb0f85c2d1", + "sha256:baca40d067dddd62141a129f244703160d278648b569e90bb0e3753067644711", + "sha256:d5a35ff54e3f62e8fc7be02bb0d2fbc212bba1a5a9cc2748090690093996f07b", + "sha256:e62fb869762b4ba18666370e2f8a18f17f8ab92dd4467295c6d38be6f8fef60b", + "sha256:ebde3a023b8e11bfa6c890ef34cd6a8b47d586f26135e86c21344fe433daf2e2" ], "markers": "python_version >= '3.7'", - "version": "==4.21.7" + "version": "==4.23.0" + }, + "py3amf": { + "hashes": [ + "sha256:dd81e7f98640a70a2ecefc2ea39b11bb75887239dbb1a82cf4e753a294aafb68" + ], + "version": "==0.8.11" }, "pyaes": { "hashes": [ @@ -672,39 +1308,19 @@ }, "pyasn1": { "hashes": [ - "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", - "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", - "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", - "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" + "sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57", + "sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde" ], - "version": "==0.4.8" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==0.5.0" }, "pyasn1-modules": { "hashes": [ - "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", - "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", - "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", - "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", - "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", - "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", - "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", - "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", - "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", - "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", - "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", - "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", - "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" + "sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c", + "sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d" ], - "version": "==0.2.8" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==0.3.0" }, "pycparser": { "hashes": [ @@ -715,47 +1331,58 @@ }, "pycryptodomex": { "hashes": [ - "sha256:04cc393045a8f19dd110c975e30f38ed7ab3faf21ede415ea67afebd95a22380", - "sha256:0776bfaf2c48154ab54ea45392847c1283d2fcf64e232e85565f858baedfc1fa", - "sha256:0fadb9f7fa3150577800eef35f62a8a24b9ddf1563ff060d9bd3af22d3952c8c", - "sha256:18e2ab4813883ae63396c0ffe50b13554b32bb69ec56f0afaf052e7a7ae0d55b", - "sha256:191e73bc84a8064ad1874dba0ebadedd7cce4dedee998549518f2c74a003b2e1", - "sha256:35a8f7afe1867118330e2e0e0bf759c409e28557fb1fc2fbb1c6c937297dbe9a", - "sha256:3709f13ca3852b0b07fc04a2c03b379189232b24007c466be0f605dd4723e9d4", - "sha256:4540904c09704b6f831059c0dfb38584acb82cb97b0125cd52688c1f1e3fffa6", - "sha256:463119d7d22d0fc04a0f9122e9d3e6121c6648bcb12a052b51bd1eed1b996aa2", - "sha256:46b3f05f2f7ac7841053da4e0f69616929ca3c42f238c405f6c3df7759ad2780", - "sha256:48697790203909fab02a33226fda546604f4e2653f9d47bc5d3eb40879fa7c64", - "sha256:5676a132169a1c1a3712edf25250722ebc8c9102aa9abd814df063ca8362454f", - "sha256:65204412d0c6a8e3c41e21e93a5e6054a74fea501afa03046a388cf042e3377a", - "sha256:67e1e6a92151023ccdfcfbc0afb3314ad30080793b4c27956ea06ab1fb9bcd8a", - "sha256:6f5b6ba8aefd624834bc177a2ac292734996bb030f9d1b388e7504103b6fcddf", - "sha256:7341f1bb2dadb0d1a0047f34c3a58208a92423cdbd3244d998e4b28df5eac0ed", - "sha256:78d9621cf0ea35abf2d38fa2ca6d0634eab6c991a78373498ab149953787e5e5", - "sha256:8eecdf9cdc7343001d047f951b9cc805cd68cb6cd77b20ea46af5bffc5bd3dfb", - "sha256:94c7b60e1f52e1a87715571327baea0733708ab4723346598beca4a3b6879794", - "sha256:996e1ba717077ce1e6d4849af7a1426f38b07b3d173b879e27d5e26d2e958beb", - "sha256:a07a64709e366c2041cd5cfbca592b43998bf4df88f7b0ca73dca37071ccf1bd", - "sha256:b6306403228edde6e289f626a3908a2f7f67c344e712cf7c0a508bab3ad9e381", - "sha256:b9279adc16e4b0f590ceff581f53a80179b02cba9056010d733eb4196134a870", - "sha256:c4cb9cb492ea7dcdf222a8d19a1d09002798ea516aeae8877245206d27326d86", - "sha256:dd452a5af7014e866206d41751886c9b4bf379a339fdf2dbfc7dd16c0fb4f8e0", - "sha256:e2b12968522a0358b8917fc7b28865acac002f02f4c4c6020fcb264d76bfd06d", - "sha256:e3164a18348bd53c69b4435ebfb4ac8a4076291ffa2a70b54f0c4b80c7834b1d", - "sha256:e47bf8776a7e15576887f04314f5228c6527b99946e6638cf2f16da56d260cab", - "sha256:f8be976cec59b11f011f790b88aca67b4ea2bd286578d0bd3e31bcd19afcd3e4", - "sha256:fc9bc7a9b79fe5c750fc81a307052f8daabb709bdaabb0fb18fb136b66b653b5" + "sha256:0af93aad8d62e810247beedef0261c148790c52f3cd33643791cc6396dd217c1", + "sha256:12056c38e49d972f9c553a3d598425f8a1c1d35b2e4330f89d5ff1ffb70de041", + "sha256:23d83b610bd97704f0cd3acc48d99b76a15c8c1540d8665c94d514a49905bad7", + "sha256:2d4d395f109faba34067a08de36304e846c791808524614c731431ee048fe70a", + "sha256:32e764322e902bbfac49ca1446604d2839381bbbdd5a57920c9daaf2e0b778df", + "sha256:3c2516b42437ae6c7a29ef3ddc73c8d4714e7b6df995b76be4695bbe4b3b5cd2", + "sha256:40e8a11f578bd0851b02719c862d55d3ee18d906c8b68a9c09f8c564d6bb5b92", + "sha256:4b51e826f0a04d832eda0790bbd0665d9bfe73e5a4d8ea93b6a9b38beeebe935", + "sha256:4c4674f4b040321055c596aac926d12f7f6859dfe98cd12f4d9453b43ab6adc8", + "sha256:55eed98b4150a744920597c81b3965b632038781bab8a08a12ea1d004213c600", + "sha256:599bb4ae4bbd614ca05f49bd4e672b7a250b80b13ae1238f05fd0f09d87ed80a", + "sha256:5c23482860302d0d9883404eaaa54b0615eefa5274f70529703e2c43cc571827", + "sha256:64b876d57cb894b31056ad8dd6a6ae1099b117ae07a3d39707221133490e5715", + "sha256:67a3648025e4ddb72d43addab764336ba2e670c8377dba5dd752e42285440d31", + "sha256:6feedf4b0e36b395329b4186a805f60f900129cdf0170e120ecabbfcb763995d", + "sha256:78f0ddd4adc64baa39b416f3637aaf99f45acb0bcdc16706f0cc7ebfc6f10109", + "sha256:7a6651a07f67c28b6e978d63aa3a3fccea0feefed9a8453af3f7421a758461b7", + "sha256:7a8dc3ee7a99aae202a4db52de5a08aa4d01831eb403c4d21da04ec2f79810db", + "sha256:7cc28dd33f1f3662d6da28ead4f9891035f63f49d30267d3b41194c8778997c8", + "sha256:7fa0b52df90343fafe319257b31d909be1d2e8852277fb0376ba89d26d2921db", + "sha256:88b0d5bb87eaf2a31e8a759302b89cf30c97f2f8ca7d83b8c9208abe8acb447a", + "sha256:a4fa037078e92c7cc49f6789a8bac3de06856740bb2038d05f2d9a2e4b165d59", + "sha256:a57e3257bacd719769110f1f70dd901c5b6955e9596ad403af11a3e6e7e3311c", + "sha256:ab33c2d9f275e05e235dbca1063753b5346af4a5cac34a51fa0da0d4edfb21d7", + "sha256:c84689c73358dfc23f9fdcff2cb9e7856e65e2ce3b5ed8ff630d4c9bdeb1867b", + "sha256:c92537b596bd5bffb82f8964cabb9fef1bca8a28a9e0a69ffd3ec92a4a7ad41b", + "sha256:caa937ff29d07a665dfcfd7a84f0d4207b2ebf483362fa9054041d67fdfacc20", + "sha256:d38ab9e53b1c09608ba2d9b8b888f1e75d6f66e2787e437adb1fecbffec6b112", + "sha256:d4cf0128da167562c49b0e034f09e9cedd733997354f2314837c2fa461c87bb1", + "sha256:db23d7341e21b273d2440ec6faf6c8b1ca95c8894da612e165be0b89a8688340", + "sha256:ee8bf4fdcad7d66beb744957db8717afc12d176e3fd9c5d106835133881a049b", + "sha256:f854c8476512cebe6a8681cc4789e4fcff6019c17baa0fd72b459155dc605ab4", + "sha256:fd29d35ac80755e5c0a99d96b44fb9abbd7e871849581ea6a4cb826d24267537" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==3.15.0" + "version": "==3.17" }, "pygments": { "hashes": [ - "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1", - "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42" + "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c", + "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.15.1" + }, + "pyopenssl": { + "hashes": [ + "sha256:841498b9bec61623b1b6c47ebbc02367c07d60e0e195f19790817f10cc8db0b7", + "sha256:9e0c526404a210df9d2b18cd33364beadb0dc858a739b885677bc65e105d4a4c" ], "markers": "python_version >= '3.6'", - "version": "==2.13.0" + "version": "==23.1.1" }, "pyparsing": { "hashes": [ @@ -781,36 +1408,28 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.2" }, - "python-dotenv": { - "hashes": [ - "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f", - "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938" - ], - "markers": "python_full_version >= '3.5.0'", - "version": "==0.20.0" - }, "python-slugify": { "hashes": [ - "sha256:272d106cb31ab99b3496ba085e3fea0e9e76dcde967b5e9992500d1f785ce4e1", - "sha256:7b2c274c308b62f4269a9ba701aa69a797e9bca41aeee5b3a9e79e36b6656927" + "sha256:70ca6ea68fe63ecc8fa4fcf00ae651fc8a5d02d93dcd12ae6d4fc7ca46c4d395", + "sha256:ce0d46ddb668b3be82f4ed5e503dbc33dd815d83e2eb6824211310d3fb172a27" ], "index": "pypi", - "version": "==6.1.2" + "version": "==8.0.1" }, "python-twitter-v2": { "hashes": [ - "sha256:04349e74ec6ebaa3c71d02dc82610acd3b6b346a0060adf4bad2379fd3f46701", - "sha256:1b17b3243108a7d8d1af0b71a3e87f28d105b5fe61cfd09944e28a7903769c81" + "sha256:2397d518c17bfbc16a3d414b1cf6d3c231fd8d322f21c755ac2215c9ee675537", + "sha256:4e03a30b2570fa4f17fbc7293d850fb8276c66be106d55e460b9287de37e1dd2" ], "index": "pypi", - "version": "==0.7.9" + "version": "==0.8.1" }, "pytz": { "hashes": [ - "sha256:2c0784747071402c6e99f0bafdb7da0fa22645f06554c7ae06bf6358897e9c91", - "sha256:48ce799d83b6f8aab2020e369b627446696619e79645419610b9facd909b3174" + "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588", + "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb" ], - "version": "==2022.4" + "version": "==2023.3" }, "pytz-deprecation-shim": { "hashes": [ @@ -820,6 +1439,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==0.1.0.post0" }, + "pywb": { + "hashes": [ + "sha256:52463f09b89f9df7267af90c799b7d88c27906fad214d1361cab227923258d25", + "sha256:b94d85f3aff169aa22477281bda520716e307f9a2f8e2cd48a2105bdfb322753" + ], + "index": "pypi", + "version": "==2.7.3" + }, "pyyaml": { "hashes": [ "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", @@ -866,93 +1493,124 @@ "index": "pypi", "version": "==6.0" }, + "redis": { + "hashes": [ + "sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb", + "sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f" + ], + "version": "==2.10.6" + }, "regex": { "hashes": [ - "sha256:0008650041531d0eadecc96a73d37c2dc4821cf51b0766e374cb4f1ddc4e1c14", - "sha256:03299b0bcaa7824eb7c0ebd7ef1e3663302d1b533653bfe9dc7e595d453e2ae9", - "sha256:06b1df01cf2aef3a9790858af524ae2588762c8a90e784ba00d003f045306204", - "sha256:09b4b6ccc61d4119342b26246ddd5a04accdeebe36bdfe865ad87a0784efd77f", - "sha256:0be0c34a39e5d04a62fd5342f0886d0e57592a4f4993b3f9d257c1f688b19737", - "sha256:0d96eec8550fd2fd26f8e675f6d8b61b159482ad8ffa26991b894ed5ee19038b", - "sha256:0eb0e2845e81bdea92b8281a3969632686502565abf4a0b9e4ab1471c863d8f3", - "sha256:13bbf0c9453c6d16e5867bda7f6c0c7cff1decf96c5498318bb87f8136d2abd4", - "sha256:17e51ad1e6131c496b58d317bc9abec71f44eb1957d32629d06013a21bc99cac", - "sha256:1977bb64264815d3ef016625adc9df90e6d0e27e76260280c63eca993e3f455f", - "sha256:1e30762ddddb22f7f14c4f59c34d3addabc789216d813b0f3e2788d7bcf0cf29", - "sha256:1e73652057473ad3e6934944af090852a02590c349357b79182c1b681da2c772", - "sha256:20e6a27959f162f979165e496add0d7d56d7038237092d1aba20b46de79158f1", - "sha256:286ff9ec2709d56ae7517040be0d6c502642517ce9937ab6d89b1e7d0904f863", - "sha256:297c42ede2c81f0cb6f34ea60b5cf6dc965d97fa6936c11fc3286019231f0d66", - "sha256:320c2f4106962ecea0f33d8d31b985d3c185757c49c1fb735501515f963715ed", - "sha256:35ed2f3c918a00b109157428abfc4e8d1ffabc37c8f9abc5939ebd1e95dabc47", - "sha256:3d146e5591cb67c5e836229a04723a30af795ef9b70a0bbd913572e14b7b940f", - "sha256:42bb37e2b2d25d958c25903f6125a41aaaa1ed49ca62c103331f24b8a459142f", - "sha256:42d6007722d46bd2c95cce700181570b56edc0dcbadbfe7855ec26c3f2d7e008", - "sha256:43eba5c46208deedec833663201752e865feddc840433285fbadee07b84b464d", - "sha256:452519bc4c973e961b1620c815ea6dd8944a12d68e71002be5a7aff0a8361571", - "sha256:4b9c16a807b17b17c4fa3a1d8c242467237be67ba92ad24ff51425329e7ae3d0", - "sha256:5510932596a0f33399b7fff1bd61c59c977f2b8ee987b36539ba97eb3513584a", - "sha256:55820bc631684172b9b56a991d217ec7c2e580d956591dc2144985113980f5a3", - "sha256:57484d39447f94967e83e56db1b1108c68918c44ab519b8ecfc34b790ca52bf7", - "sha256:58ba41e462653eaf68fc4a84ec4d350b26a98d030be1ab24aba1adcc78ffe447", - "sha256:5bc5f921be39ccb65fdda741e04b2555917a4bced24b4df14eddc7569be3b493", - "sha256:5dcc4168536c8f68654f014a3db49b6b4a26b226f735708be2054314ed4964f4", - "sha256:5f92a7cdc6a0ae2abd184e8dfd6ef2279989d24c85d2c85d0423206284103ede", - "sha256:67250b36edfa714ba62dc62d3f238e86db1065fccb538278804790f578253640", - "sha256:6df070a986fc064d865c381aecf0aaff914178fdf6874da2f2387e82d93cc5bd", - "sha256:729aa8ca624c42f309397c5fc9e21db90bf7e2fdd872461aabdbada33de9063c", - "sha256:72bc3a5effa5974be6d965ed8301ac1e869bc18425c8a8fac179fbe7876e3aee", - "sha256:74d86e8924835f863c34e646392ef39039405f6ce52956d8af16497af4064a30", - "sha256:79e5af1ff258bc0fe0bdd6f69bc4ae33935a898e3cbefbbccf22e88a27fa053b", - "sha256:7b103dffb9f6a47ed7ffdf352b78cfe058b1777617371226c1894e1be443afec", - "sha256:83f03f0bd88c12e63ca2d024adeee75234d69808b341e88343b0232329e1f1a1", - "sha256:86d7a68fa53688e1f612c3246044157117403c7ce19ebab7d02daf45bd63913e", - "sha256:878c626cbca3b649e14e972c14539a01191d79e58934e3f3ef4a9e17f90277f8", - "sha256:878f5d649ba1db9f52cc4ef491f7dba2d061cdc48dd444c54260eebc0b1729b9", - "sha256:87bc01226cd288f0bd9a4f9f07bf6827134dc97a96c22e2d28628e824c8de231", - "sha256:8babb2b5751105dc0aef2a2e539f4ba391e738c62038d8cb331c710f6b0f3da7", - "sha256:91e0f7e7be77250b808a5f46d90bf0032527d3c032b2131b63dee54753a4d729", - "sha256:9557545c10d52c845f270b665b52a6a972884725aa5cf12777374e18f2ea8960", - "sha256:9ccb0a4ab926016867260c24c192d9df9586e834f5db83dfa2c8fffb3a6e5056", - "sha256:9d828c5987d543d052b53c579a01a52d96b86f937b1777bbfe11ef2728929357", - "sha256:9efa41d1527b366c88f265a227b20bcec65bda879962e3fc8a2aee11e81266d7", - "sha256:aaf5317c961d93c1a200b9370fb1c6b6836cc7144fef3e5a951326912bf1f5a3", - "sha256:ab69b4fe09e296261377d209068d52402fb85ef89dc78a9ac4a29a895f4e24a7", - "sha256:ad397bc7d51d69cb07ef89e44243f971a04ce1dca9bf24c992c362406c0c6573", - "sha256:ae17fc8103f3b63345709d3e9654a274eee1c6072592aec32b026efd401931d0", - "sha256:af4d8cc28e4c7a2f6a9fed544228c567340f8258b6d7ea815b62a72817bbd178", - "sha256:b22ff939a8856a44f4822da38ef4868bd3a9ade22bb6d9062b36957c850e404f", - "sha256:b549d851f91a4efb3e65498bd4249b1447ab6035a9972f7fc215eb1f59328834", - "sha256:be319f4eb400ee567b722e9ea63d5b2bb31464e3cf1b016502e3ee2de4f86f5c", - "sha256:c0446b2871335d5a5e9fcf1462f954586b09a845832263db95059dcd01442015", - "sha256:c68d2c04f7701a418ec2e5631b7f3552efc32f6bcc1739369c6eeb1af55f62e0", - "sha256:c87ac58b9baaf50b6c1b81a18d20eda7e2883aa9a4fb4f1ca70f2e443bfcdc57", - "sha256:caa2734ada16a44ae57b229d45091f06e30a9a52ace76d7574546ab23008c635", - "sha256:cb34c2d66355fb70ae47b5595aafd7218e59bb9c00ad8cc3abd1406ca5874f07", - "sha256:cb3652bbe6720786b9137862205986f3ae54a09dec8499a995ed58292bdf77c2", - "sha256:cf668f26604e9f7aee9f8eaae4ca07a948168af90b96be97a4b7fa902a6d2ac1", - "sha256:d326ff80ed531bf2507cba93011c30fff2dd51454c85f55df0f59f2030b1687b", - "sha256:d6c2441538e4fadd4291c8420853431a229fcbefc1bf521810fbc2629d8ae8c2", - "sha256:d6ecfd1970b3380a569d7b3ecc5dd70dba295897418ed9e31ec3c16a5ab099a5", - "sha256:e5602a9b5074dcacc113bba4d2f011d2748f50e3201c8139ac5b68cf2a76bd8b", - "sha256:ef806f684f17dbd6263d72a54ad4073af42b42effa3eb42b877e750c24c76f86", - "sha256:f3356afbb301ec34a500b8ba8b47cba0b44ed4641c306e1dd981a08b416170b5", - "sha256:f6f7ee2289176cb1d2c59a24f50900f8b9580259fa9f1a739432242e7d254f93", - "sha256:f7e8f1ee28e0a05831c92dc1c0c1c94af5289963b7cf09eca5b5e3ce4f8c91b0", - "sha256:f8169ec628880bdbca67082a9196e2106060a4a5cbd486ac51881a4df805a36f", - "sha256:fbc88d3ba402b5d041d204ec2449c4078898f89c4a6e6f0ed1c1a510ef1e221d", - "sha256:fbd3fe37353c62fd0eb19fb76f78aa693716262bcd5f9c14bb9e5aca4b3f0dc4" + "sha256:02f4541550459c08fdd6f97aa4e24c6f1932eec780d58a2faa2068253df7d6ff", + "sha256:0a69cf0c00c4d4a929c6c7717fd918414cab0d6132a49a6d8fc3ded1988ed2ea", + "sha256:0bbd5dcb19603ab8d2781fac60114fb89aee8494f4505ae7ad141a3314abb1f9", + "sha256:10250a093741ec7bf74bcd2039e697f519b028518f605ff2aa7ac1e9c9f97423", + "sha256:10374c84ee58c44575b667310d5bbfa89fb2e64e52349720a0182c0017512f6c", + "sha256:1189fbbb21e2c117fda5303653b61905aeeeea23de4a94d400b0487eb16d2d60", + "sha256:1307aa4daa1cbb23823d8238e1f61292fd07e4e5d8d38a6efff00b67a7cdb764", + "sha256:144b5b017646b5a9392a5554a1e5db0000ae637be4971c9747566775fc96e1b2", + "sha256:171c52e320fe29260da550d81c6b99f6f8402450dc7777ef5ced2e848f3b6f8f", + "sha256:18196c16a584619c7c1d843497c069955d7629ad4a3fdee240eb347f4a2c9dbe", + "sha256:18f05d14f14a812fe9723f13afafefe6b74ca042d99f8884e62dbd34dcccf3e2", + "sha256:1ecf3dcff71f0c0fe3e555201cbe749fa66aae8d18f80d2cc4de8e66df37390a", + "sha256:21e90a288e6ba4bf44c25c6a946cb9b0f00b73044d74308b5e0afd190338297c", + "sha256:23d86ad2121b3c4fc78c58f95e19173790e22ac05996df69b84e12da5816cb17", + "sha256:256f7f4c6ba145f62f7a441a003c94b8b1af78cee2cccacfc1e835f93bc09426", + "sha256:290fd35219486dfbc00b0de72f455ecdd63e59b528991a6aec9fdfc0ce85672e", + "sha256:2e9c4f778514a560a9c9aa8e5538bee759b55f6c1dcd35613ad72523fd9175b8", + "sha256:338994d3d4ca4cf12f09822e025731a5bdd3a37aaa571fa52659e85ca793fb67", + "sha256:33d430a23b661629661f1fe8395be2004006bc792bb9fc7c53911d661b69dd7e", + "sha256:385992d5ecf1a93cb85adff2f73e0402dd9ac29b71b7006d342cc920816e6f32", + "sha256:3d45864693351c15531f7e76f545ec35000d50848daa833cead96edae1665559", + "sha256:40005cbd383438aecf715a7b47fe1e3dcbc889a36461ed416bdec07e0ef1db66", + "sha256:4035d6945cb961c90c3e1c1ca2feb526175bcfed44dfb1cc77db4fdced060d3e", + "sha256:445d6f4fc3bd9fc2bf0416164454f90acab8858cd5a041403d7a11e3356980e8", + "sha256:48c9ec56579d4ba1c88f42302194b8ae2350265cb60c64b7b9a88dcb7fbde309", + "sha256:4a5059bd585e9e9504ef9c07e4bc15b0a621ba20504388875d66b8b30a5c4d18", + "sha256:4a6e4b0e0531223f53bad07ddf733af490ba2b8367f62342b92b39b29f72735a", + "sha256:4b870b6f632fc74941cadc2a0f3064ed8409e6f8ee226cdfd2a85ae50473aa94", + "sha256:50fd2d9b36938d4dcecbd684777dd12a407add4f9f934f235c66372e630772b0", + "sha256:53e22e4460f0245b468ee645156a4f84d0fc35a12d9ba79bd7d79bdcd2f9629d", + "sha256:586a011f77f8a2da4b888774174cd266e69e917a67ba072c7fc0e91878178a80", + "sha256:59597cd6315d3439ed4b074febe84a439c33928dd34396941b4d377692eca810", + "sha256:59e4b729eae1a0919f9e4c0fc635fbcc9db59c74ad98d684f4877be3d2607dd6", + "sha256:5a0f874ee8c0bc820e649c900243c6d1e6dc435b81da1492046716f14f1a2a96", + "sha256:5ac2b7d341dc1bd102be849d6dd33b09701223a851105b2754339e390be0627a", + "sha256:5e3f4468b8c6fd2fd33c218bbd0a1559e6a6fcf185af8bb0cc43f3b5bfb7d636", + "sha256:6164d4e2a82f9ebd7752a06bd6c504791bedc6418c0196cd0a23afb7f3e12b2d", + "sha256:6893544e06bae009916a5658ce7207e26ed17385149f35a3125f5259951f1bbe", + "sha256:690a17db524ee6ac4a27efc5406530dd90e7a7a69d8360235323d0e5dafb8f5b", + "sha256:6b8d0c153f07a953636b9cdb3011b733cadd4178123ef728ccc4d5969e67f3c2", + "sha256:72a28979cc667e5f82ef433db009184e7ac277844eea0f7f4d254b789517941d", + "sha256:72aa4746993a28c841e05889f3f1b1e5d14df8d3daa157d6001a34c98102b393", + "sha256:732176f5427e72fa2325b05c58ad0b45af341c459910d766f814b0584ac1f9ac", + "sha256:7918a1b83dd70dc04ab5ed24c78ae833ae8ea228cef84e08597c408286edc926", + "sha256:7923470d6056a9590247ff729c05e8e0f06bbd4efa6569c916943cb2d9b68b91", + "sha256:7d76a8a1fc9da08296462a18f16620ba73bcbf5909e42383b253ef34d9d5141e", + "sha256:811040d7f3dd9c55eb0d8b00b5dcb7fd9ae1761c454f444fd9f37fe5ec57143a", + "sha256:821a88b878b6589c5068f4cc2cfeb2c64e343a196bc9d7ac68ea8c2a776acd46", + "sha256:84397d3f750d153ebd7f958efaa92b45fea170200e2df5e0e1fd4d85b7e3f58a", + "sha256:844671c9c1150fcdac46d43198364034b961bd520f2c4fdaabfc7c7d7138a2dd", + "sha256:890a09cb0a62198bff92eda98b2b507305dd3abf974778bae3287f98b48907d3", + "sha256:8f08276466fedb9e36e5193a96cb944928301152879ec20c2d723d1031cd4ddd", + "sha256:8f5e06df94fff8c4c85f98c6487f6636848e1dc85ce17ab7d1931df4a081f657", + "sha256:921473a93bcea4d00295799ab929522fc650e85c6b9f27ae1e6bb32a790ea7d3", + "sha256:941b3f1b2392f0bcd6abf1bc7a322787d6db4e7457be6d1ffd3a693426a755f2", + "sha256:9b320677521aabf666cdd6e99baee4fb5ac3996349c3b7f8e7c4eee1c00dfe3a", + "sha256:9c3efee9bb53cbe7b285760c81f28ac80dc15fa48b5fe7e58b52752e642553f1", + "sha256:9fda3e50abad8d0f48df621cf75adc73c63f7243cbe0e3b2171392b445401550", + "sha256:a4c5da39bca4f7979eefcbb36efea04471cd68db2d38fcbb4ee2c6d440699833", + "sha256:a56c18f21ac98209da9c54ae3ebb3b6f6e772038681d6cb43b8d53da3b09ee81", + "sha256:a623564d810e7a953ff1357f7799c14bc9beeab699aacc8b7ab7822da1e952b8", + "sha256:a8906669b03c63266b6a7693d1f487b02647beb12adea20f8840c1a087e2dfb5", + "sha256:a99757ad7fe5c8a2bb44829fc57ced11253e10f462233c1255fe03888e06bc19", + "sha256:aa7d032c1d84726aa9edeb6accf079b4caa87151ca9fabacef31fa028186c66d", + "sha256:aad5524c2aedaf9aa14ef1bc9327f8abd915699dea457d339bebbe2f0d218f86", + "sha256:afb1c70ec1e594a547f38ad6bf5e3d60304ce7539e677c1429eebab115bce56e", + "sha256:b6365703e8cf1644b82104cdd05270d1a9f043119a168d66c55684b1b557d008", + "sha256:b8b942d8b3ce765dbc3b1dad0a944712a89b5de290ce8f72681e22b3c55f3cc8", + "sha256:ba73a14e9c8f9ac409863543cde3290dba39098fc261f717dc337ea72d3ebad2", + "sha256:bd7b68fd2e79d59d86dcbc1ccd6e2ca09c505343445daaa4e07f43c8a9cc34da", + "sha256:bd966475e963122ee0a7118ec9024388c602d12ac72860f6eea119a3928be053", + "sha256:c2ce65bdeaf0a386bb3b533a28de3994e8e13b464ac15e1e67e4603dd88787fa", + "sha256:c64d5abe91a3dfe5ff250c6bb267ef00dbc01501518225b45a5f9def458f31fb", + "sha256:c8c143a65ce3ca42e54d8e6fcaf465b6b672ed1c6c90022794a802fb93105d22", + "sha256:cd46f30e758629c3ee91713529cfbe107ac50d27110fdcc326a42ce2acf4dafc", + "sha256:ced02e3bd55e16e89c08bbc8128cff0884d96e7f7a5633d3dc366b6d95fcd1d6", + "sha256:cf123225945aa58b3057d0fba67e8061c62d14cc8a4202630f8057df70189051", + "sha256:d19e57f888b00cd04fc38f5e18d0efbd91ccba2d45039453ab2236e6eec48d4d", + "sha256:d1cbe6b5be3b9b698d8cc4ee4dee7e017ad655e83361cd0ea8e653d65e469468", + "sha256:db09e6c18977a33fea26fe67b7a842f706c67cf8bda1450974d0ae0dd63570df", + "sha256:de2f780c3242ea114dd01f84848655356af4dd561501896c751d7b885ea6d3a1", + "sha256:e2205a81f815b5bb17e46e74cc946c575b484e5f0acfcb805fb252d67e22938d", + "sha256:e645c757183ee0e13f0bbe56508598e2d9cd42b8abc6c0599d53b0d0b8dd1479", + "sha256:f2910502f718828cecc8beff004917dcf577fc5f8f5dd40ffb1ea7612124547b", + "sha256:f764e4dfafa288e2eba21231f455d209f4709436baeebb05bdecfb5d8ddc3d35", + "sha256:f83fe9e10f9d0b6cf580564d4d23845b9d692e4c91bd8be57733958e4c602956", + "sha256:fb2b495dd94b02de8215625948132cc2ea360ae84fe6634cd19b6567709c8ae2", + "sha256:fee0016cc35a8a91e8cc9312ab26a6fe638d484131a7afa79e1ce6165328a135" ], "markers": "python_version >= '3.6'", - "version": "==2022.3.2" + "version": "==2023.5.5" }, "requests": { - "hashes": [ - "sha256:bc7861137fbce630f17b03d3ad02ad0bf978c844f3536d0edda6499dafce2b6f", - "sha256:d568723a7ebd25875d8d1eaf5dfa068cd2fc8194b2e483d7b1f7c81918dbec6b" + "extras": [ + "socks" ], - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==2.28.0" + "hashes": [ + "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294", + "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4" + ], + "index": "pypi", + "version": "==2.30.0" + }, + "requests-file": { + "hashes": [ + "sha256:07d74208d3389d01c38ab89ef403af0cfec63957d53a0081d8eca738d0247d8e", + "sha256:dfe5dae75c12481f68ba353183c53a65e6044c923e64c24b2209f6c7570ca953" + ], + "version": "==1.5.1" }, "requests-oauthlib": { "hashes": [ @@ -964,42 +1622,51 @@ }, "requests-toolbelt": { "hashes": [ - "sha256:64c6b8c51b515d123f9f708a29743f44eb70c4479440641ed2df8c4dea56d985", - "sha256:f695d6207931200b46c8ef6addbc8a921fb5d77cc4cd209c2e7d39293fcd2b30" + "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", + "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.10.0" + "version": "==1.0.0" }, "rich": { "hashes": [ - "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e", - "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0" + "sha256:2d11b9b8dd03868f09b4fffadc84a6a8cda574e40dc90821bd845720ebb8e89c", + "sha256:69cdf53799e63f38b95b9bf9c875f8c90e78dd62b2f00c13a911c7a3b9fa4704" ], - "markers": "python_version < '4' and python_full_version >= '3.6.3'", - "version": "==12.6.0" + "markers": "python_version >= '3.7'", + "version": "==13.3.5" }, "rsa": { "hashes": [ "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21" ], - "markers": "python_version >= '3.6' and python_version < '4'", + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==4.9" }, "s3transfer": { "hashes": [ - "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd", - "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947" + "sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346", + "sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9" ], "markers": "python_version >= '3.7'", - "version": "==0.6.0" + "version": "==0.6.1" }, "selenium": { "hashes": [ - "sha256:a733dd77d3171b846893f4d51b18967d809313f547a10974e26579f9ce797462" + "sha256:3444f4376321530c36ce8355b6b357d8cf4a7d588ce5cf772183465930bbed0e", + "sha256:82aedaa85d55bc861f4c89ff9609e82f6c958e2e1e3da3ffcc36703f21d3ee16" ], "index": "pypi", - "version": "==4.5.0" + "version": "==4.9.1" + }, + "setuptools": { + "hashes": [ + "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b", + "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990" + ], + "markers": "python_version >= '3.7'", + "version": "==67.7.2" }, "six": { "hashes": [ @@ -1019,11 +1686,11 @@ }, "snscrape": { "hashes": [ - "sha256:af30d12872da692ff9ccaf5651962edceb1fd4a28cf7cc92c8c898902f009ce3", - "sha256:fd176765196ca17979be7f54e041f430e4cb23a5e651fa29cf3dc382258019f2" + "sha256:03a80a82c0b516c2a788f9205492ed2569972054e36a33177b338b35116e489d", + "sha256:d4fcc2f69db69773021108d1c268252f807679d455676a8134e6f0719ced5f52" ], "index": "pypi", - "version": "==0.4.3.20220106" + "version": "==0.6.2.20230320" }, "sortedcontainers": { "hashes": [ @@ -1034,19 +1701,25 @@ }, "soupsieve": { "hashes": [ - "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759", - "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d" + "sha256:1c1bfee6819544a3447586c889157365a27e10d88cde3ad3da0cf0ddf646feb8", + "sha256:89d12b2d5dfcd2c9e8c22326da9d9aa9cb3dfab0a83a024f05704076ee8d35ea" ], - "markers": "python_version >= '3.6'", - "version": "==2.3.2.post1" + "markers": "python_version >= '3.7'", + "version": "==2.4.1" + }, + "surt": { + "hashes": [ + "sha256:24167eb6c01f24f757eef9bca6bf0ec089ec05ad5b6213c3b727a5e58c0c4720" + ], + "version": "==0.3.1" }, "telethon": { "hashes": [ - "sha256:8df802aad2d11f7198f1d5b1d84c7498ef19c28e160041dcb8aaf0814f91115b", - "sha256:a085348801bd62db79ad75c9a67c5c8312507b113f0228b92e2dd4397edc7c1d" + "sha256:b3990ec22351a3f3e1af376729c985025bbdd3bdabdde8c156112c3d3dfe1941", + "sha256:edc42fd58b8e1569830d3ead564cafa60fd51d684f03ee2a1fdd5f77a5a10438" ], "index": "pypi", - "version": "==1.25.4" + "version": "==1.28.5" }, "text-unidecode": { "hashes": [ @@ -1057,18 +1730,26 @@ }, "tiktok-downloader": { "hashes": [ - "sha256:8da29d0e0a97a3e7fc5378d4170d346339925afd9704cdde8dea664cd6bd51f0" + "sha256:f376ba0d2517fbab87b3185784d6e19481543326121427ae0986b9fdef6f4f75" ], "index": "pypi", - "version": "==0.3.4" + "version": "==0.3.5" + }, + "tldextract": { + "hashes": [ + "sha256:26f646987b01ae2946e7491cce4aaf54129f3489a196a274e6c843ec72968313", + "sha256:fa9e50c4a03bede2a1d95dca620d661678484626858ccf388cf9671a0dd497a4" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.1" }, "tqdm": { "hashes": [ - "sha256:5f4f682a004951c1b450bc753c710e9280c5746ce6ffedee253ddbcbf54cf1e4", - "sha256:6fee160d6ffcd1b1c68c65f14c829c22832bc401726335ce92c52d395944a6a1" + "sha256:1871fb68a86b8fb3b59ca4cdd3dcccbc7e6d613eeed31f4c332531977b89beb5", + "sha256:c4f53a17fe37e132815abceec022631be8ffe1b9381c2e6e30aa70edc99e9671" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==4.64.1" + "index": "pypi", + "version": "==4.65.0" }, "trio": { "hashes": [ @@ -1080,19 +1761,19 @@ }, "trio-websocket": { "hashes": [ - "sha256:5b558f6e83cc20a37c3b61202476c5295d1addf57bd65543364e0337e37ed2bc", - "sha256:a3d34de8fac26023eee701ed1e7bf4da9a8326b61a62934ec9e53b64970fd8fe" + "sha256:0908435e4eecc49d830ae1c4d6c47b978a75f00594a2be2104d58b61a04cdb53", + "sha256:af13e9393f9051111300287947ec595d601758ce3d165328e7d36325135a8d62" ], - "markers": "python_full_version >= '3.5.0'", - "version": "==0.9.2" + "markers": "python_version >= '3.7'", + "version": "==0.10.2" }, "typing-extensions": { "hashes": [ - "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa", - "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e" + "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb", + "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4" ], "markers": "python_version >= '3.7'", - "version": "==4.4.0" + "version": "==4.5.0" }, "typing-inspect": { "hashes": [ @@ -1103,19 +1784,26 @@ }, "tzdata": { "hashes": [ - "sha256:323161b22b7802fdc78f20ca5f6073639c64f1a7227c40cd3e19fd1d0ce6650a", - "sha256:e15b2b3005e2546108af42a0eb4ccab4d9e225e2dfbf4f77aad50c70a4b1f3ab" + "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a", + "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda" ], "markers": "python_version >= '3.6'", - "version": "==2022.5" + "version": "==2023.3" }, "tzlocal": { "hashes": [ - "sha256:89885494684c929d9191c57aa27502afc87a579be5cdd3225c77c463ea043745", - "sha256:ee5842fa3a795f023514ac2d801c4a81d1743bbe642e3940143326b3a00addd7" + "sha256:3f21d09e1b2aa9f2dacca12da240ca37de3ba5237a93addfd6d593afe9073355", + "sha256:b44c4388f3d34f25862cfbb387578a4d70fec417649da694a132f628a23367e2" ], - "markers": "python_version >= '3.6'", - "version": "==4.2" + "markers": "python_version >= '3.7'", + "version": "==4.3" + }, + "ua-parser": { + "hashes": [ + "sha256:ed3efc695f475ffe56248c9789b3016247e9c20e3556cfa4d5aadc78ab4b26c6", + "sha256:f97126300df8ac0f8f2c9d8559669532d626a1af529265fd253cba56e73ab36e" + ], + "version": "==0.16.1" }, "uritemplate": { "hashes": [ @@ -1126,90 +1814,146 @@ "version": "==4.1.1" }, "urllib3": { - "extras": [], "hashes": [ - "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14", - "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e" + "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305", + "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.9" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.26.15" + }, + "uwsgi": { + "hashes": [ + "sha256:35a30d83791329429bc04fe44183ce4ab512fcf6968070a7bfba42fc5a0552a9" + ], + "index": "pypi", + "version": "==2.0.21" }, "vk-api": { "hashes": [ - "sha256:11c731e214ebc7fa911db81efb021f97587493a5402b992f24748fe1cd9d7afc", - "sha256:d0ae766fa93a40d47c5da045d94201721bf766dbde122a1d2253516b35c5edf3" + "sha256:c71021506449afe5b9bbb1c4acb0d86b35a007ddc21678478e46fbbeabd1f3ef", + "sha256:c7741e40bc05980c91ed94c84542e1e7e7370e101b5eaa74222958d4130fe3c2" ], - "version": "==11.9.8" + "version": "==11.9.9" }, "vk-url-scraper": { "hashes": [ - "sha256:7caf8d788fc268d311b13c06ff0cbd9413dd8978f463af970459b9e7e2f42ba5", - "sha256:c4593d86b5096e75e2845e4838f46ce2cf0ac34b2fe1c4476d2eeb6744b18a11" + "sha256:dc1c5cebc2919620935be0b669bef899dca3b8a3ef6419a81c97d03903b012a5", + "sha256:edf155fde3e86950f715a4de09cbe975cf220cb1fcc2fa239dd27868d991ffb3" ], "index": "pypi", - "version": "==0.3.5" + "version": "==0.3.24" + }, + "warcio": { + "hashes": [ + "sha256:ced1a162d76434d56abd81b37ac152821d1a11e1db835ead5d649f58068c2203", + "sha256:e1889dad9ecac654de5b0973247f335a55827b1b14a8203772d18c749143ea51" + ], + "version": "==1.7.4" + }, + "webassets": { + "hashes": [ + "sha256:167132337677c8cedc9705090f6d48da3fb262c8e0b2773b29f3352f050181cd", + "sha256:a31a55147752ba1b3dc07dee0ad8c8efff274464e08bbdb88c1fd59ffd552724" + ], + "version": "==2.0" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" }, "websockets": { "hashes": [ - "sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af", - "sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c", - "sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76", - "sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47", - "sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69", - "sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079", - "sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c", - "sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55", - "sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02", - "sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559", - "sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3", - "sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e", - "sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978", - "sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98", - "sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae", - "sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755", - "sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d", - "sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991", - "sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1", - "sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680", - "sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247", - "sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f", - "sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2", - "sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7", - "sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4", - "sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667", - "sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb", - "sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094", - "sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36", - "sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79", - "sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500", - "sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e", - "sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582", - "sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442", - "sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd", - "sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6", - "sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731", - "sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4", - "sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d", - "sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8", - "sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f", - "sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677", - "sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8", - "sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9", - "sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e", - "sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b", - "sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916", - "sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4" + "sha256:01f5567d9cf6f502d655151645d4e8b72b453413d3819d2b6f1185abc23e82dd", + "sha256:03aae4edc0b1c68498f41a6772d80ac7c1e33c06c6ffa2ac1c27a07653e79d6f", + "sha256:0ac56b661e60edd453585f4bd68eb6a29ae25b5184fd5ba51e97652580458998", + "sha256:0ee68fe502f9031f19d495dae2c268830df2760c0524cbac5d759921ba8c8e82", + "sha256:1553cb82942b2a74dd9b15a018dce645d4e68674de2ca31ff13ebc2d9f283788", + "sha256:1a073fc9ab1c8aff37c99f11f1641e16da517770e31a37265d2755282a5d28aa", + "sha256:1d2256283fa4b7f4c7d7d3e84dc2ece74d341bce57d5b9bf385df109c2a1a82f", + "sha256:1d5023a4b6a5b183dc838808087033ec5df77580485fc533e7dab2567851b0a4", + "sha256:1fdf26fa8a6a592f8f9235285b8affa72748dc12e964a5518c6c5e8f916716f7", + "sha256:2529338a6ff0eb0b50c7be33dc3d0e456381157a31eefc561771ee431134a97f", + "sha256:279e5de4671e79a9ac877427f4ac4ce93751b8823f276b681d04b2156713b9dd", + "sha256:2d903ad4419f5b472de90cd2d40384573b25da71e33519a67797de17ef849b69", + "sha256:332d126167ddddec94597c2365537baf9ff62dfcc9db4266f263d455f2f031cb", + "sha256:34fd59a4ac42dff6d4681d8843217137f6bc85ed29722f2f7222bd619d15e95b", + "sha256:3580dd9c1ad0701169e4d6fc41e878ffe05e6bdcaf3c412f9d559389d0c9e016", + "sha256:3ccc8a0c387629aec40f2fc9fdcb4b9d5431954f934da3eaf16cdc94f67dbfac", + "sha256:41f696ba95cd92dc047e46b41b26dd24518384749ed0d99bea0a941ca87404c4", + "sha256:42cc5452a54a8e46a032521d7365da775823e21bfba2895fb7b77633cce031bb", + "sha256:4841ed00f1026dfbced6fca7d963c4e7043aa832648671b5138008dc5a8f6d99", + "sha256:4b253869ea05a5a073ebfdcb5cb3b0266a57c3764cf6fe114e4cd90f4bfa5f5e", + "sha256:54c6e5b3d3a8936a4ab6870d46bdd6ec500ad62bde9e44462c32d18f1e9a8e54", + "sha256:619d9f06372b3a42bc29d0cd0354c9bb9fb39c2cbc1a9c5025b4538738dbffaf", + "sha256:6505c1b31274723ccaf5f515c1824a4ad2f0d191cec942666b3d0f3aa4cb4007", + "sha256:660e2d9068d2bedc0912af508f30bbeb505bbbf9774d98def45f68278cea20d3", + "sha256:6681ba9e7f8f3b19440921e99efbb40fc89f26cd71bf539e45d8c8a25c976dc6", + "sha256:68b977f21ce443d6d378dbd5ca38621755f2063d6fdb3335bda981d552cfff86", + "sha256:69269f3a0b472e91125b503d3c0b3566bda26da0a3261c49f0027eb6075086d1", + "sha256:6f1a3f10f836fab6ca6efa97bb952300b20ae56b409414ca85bff2ad241d2a61", + "sha256:7622a89d696fc87af8e8d280d9b421db5133ef5b29d3f7a1ce9f1a7bf7fcfa11", + "sha256:777354ee16f02f643a4c7f2b3eff8027a33c9861edc691a2003531f5da4f6bc8", + "sha256:84d27a4832cc1a0ee07cdcf2b0629a8a72db73f4cf6de6f0904f6661227f256f", + "sha256:8531fdcad636d82c517b26a448dcfe62f720e1922b33c81ce695d0edb91eb931", + "sha256:86d2a77fd490ae3ff6fae1c6ceaecad063d3cc2320b44377efdde79880e11526", + "sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016", + "sha256:8a34e13a62a59c871064dfd8ffb150867e54291e46d4a7cf11d02c94a5275bae", + "sha256:8c82f11964f010053e13daafdc7154ce7385ecc538989a354ccc7067fd7028fd", + "sha256:92b2065d642bf8c0a82d59e59053dd2fdde64d4ed44efe4870fa816c1232647b", + "sha256:97b52894d948d2f6ea480171a27122d77af14ced35f62e5c892ca2fae9344311", + "sha256:9d9acd80072abcc98bd2c86c3c9cd4ac2347b5a5a0cae7ed5c0ee5675f86d9af", + "sha256:9f59a3c656fef341a99e3d63189852be7084c0e54b75734cde571182c087b152", + "sha256:aa5003845cdd21ac0dc6c9bf661c5beddd01116f6eb9eb3c8e272353d45b3288", + "sha256:b16fff62b45eccb9c7abb18e60e7e446998093cdcb50fed33134b9b6878836de", + "sha256:b30c6590146e53149f04e85a6e4fcae068df4289e31e4aee1fdf56a0dead8f97", + "sha256:b58cbf0697721120866820b89f93659abc31c1e876bf20d0b3d03cef14faf84d", + "sha256:b67c6f5e5a401fc56394f191f00f9b3811fe843ee93f4a70df3c389d1adf857d", + "sha256:bceab846bac555aff6427d060f2fcfff71042dba6f5fca7dc4f75cac815e57ca", + "sha256:bee9fcb41db2a23bed96c6b6ead6489702c12334ea20a297aa095ce6d31370d0", + "sha256:c114e8da9b475739dde229fd3bc6b05a6537a88a578358bc8eb29b4030fac9c9", + "sha256:c1f0524f203e3bd35149f12157438f406eff2e4fb30f71221c8a5eceb3617b6b", + "sha256:c792ea4eabc0159535608fc5658a74d1a81020eb35195dd63214dcf07556f67e", + "sha256:c7f3cb904cce8e1be667c7e6fef4516b98d1a6a0635a58a57528d577ac18a128", + "sha256:d67ac60a307f760c6e65dad586f556dde58e683fab03323221a4e530ead6f74d", + "sha256:dcacf2c7a6c3a84e720d1bb2b543c675bf6c40e460300b628bab1b1efc7c034c", + "sha256:de36fe9c02995c7e6ae6efe2e205816f5f00c22fd1fbf343d4d18c3d5ceac2f5", + "sha256:def07915168ac8f7853812cc593c71185a16216e9e4fa886358a17ed0fd9fcf6", + "sha256:df41b9bc27c2c25b486bae7cf42fccdc52ff181c8c387bfd026624a491c2671b", + "sha256:e052b8467dd07d4943936009f46ae5ce7b908ddcac3fda581656b1b19c083d9b", + "sha256:e063b1865974611313a3849d43f2c3f5368093691349cf3c7c8f8f75ad7cb280", + "sha256:e1459677e5d12be8bbc7584c35b992eea142911a6236a3278b9b5ce3326f282c", + "sha256:e1a99a7a71631f0efe727c10edfba09ea6bee4166a6f9c19aafb6c0b5917d09c", + "sha256:e590228200fcfc7e9109509e4d9125eace2042fd52b595dd22bbc34bb282307f", + "sha256:e6316827e3e79b7b8e7d8e3b08f4e331af91a48e794d5d8b099928b6f0b85f20", + "sha256:e7837cb169eca3b3ae94cc5787c4fed99eef74c0ab9506756eea335e0d6f3ed8", + "sha256:e848f46a58b9fcf3d06061d17be388caf70ea5b8cc3466251963c8345e13f7eb", + "sha256:ed058398f55163a79bb9f06a90ef9ccc063b204bb346c4de78efc5d15abfe602", + "sha256:f2e58f2c36cc52d41f2659e4c0cbf7353e28c8c9e63e30d8c6d3494dc9fdedcf", + "sha256:f467ba0050b7de85016b43f5a22b46383ef004c4f672148a8abf32bc999a87f0", + "sha256:f61bdb1df43dc9c131791fbc2355535f9024b9a04398d3bd0684fc16ab07df74", + "sha256:fb06eea71a00a7af0ae6aefbb932fb8a7df3cb390cc217d51a9ad7343de1b8d0", + "sha256:ffd7dcaf744f25f82190856bc26ed81721508fc5cbf2a330751e135ff1283564" ], "markers": "python_version >= '3.7'", - "version": "==10.3" + "version": "==11.0.3" }, "werkzeug": { "hashes": [ - "sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f", - "sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5" + "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", + "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" ], - "markers": "python_version >= '3.7'", - "version": "==2.2.2" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==1.0.1" + }, + "wsgiprox": { + "hashes": [ + "sha256:3a97f6f8d51122aa5e3a1c6c8c73a84a15a3f9b69f312a3aae8bef9b52a8da85", + "sha256:8dab64cef38ff39d525d246bc1b34b5a378c1476be7127ef79c85c29ab92765c" + ], + "version": "==1.5.2" }, "wsproto": { "hashes": [ @@ -1219,14 +1963,178 @@ "markers": "python_version >= '3.7'", "version": "==1.2.0" }, + "yarl": { + "hashes": [ + "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571", + "sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3", + "sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3", + "sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c", + "sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7", + "sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04", + "sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191", + "sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea", + "sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4", + "sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4", + "sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095", + "sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e", + "sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74", + "sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef", + "sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33", + "sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde", + "sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45", + "sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf", + "sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b", + "sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac", + "sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0", + "sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528", + "sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716", + "sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb", + "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18", + "sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72", + "sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6", + "sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582", + "sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5", + "sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368", + "sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc", + "sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9", + "sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be", + "sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a", + "sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80", + "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8", + "sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6", + "sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417", + "sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574", + "sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59", + "sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608", + "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82", + "sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1", + "sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3", + "sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d", + "sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8", + "sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc", + "sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac", + "sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8", + "sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955", + "sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0", + "sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367", + "sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb", + "sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a", + "sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623", + "sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2", + "sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6", + "sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7", + "sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4", + "sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051", + "sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938", + "sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8", + "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9", + "sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3", + "sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5", + "sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9", + "sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333", + "sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185", + "sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3", + "sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560", + "sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b", + "sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7", + "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78", + "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7" + ], + "markers": "python_version >= '3.7'", + "version": "==1.9.2" + }, "yt-dlp": { "hashes": [ - "sha256:3a7b59d2fb4b39ce8ba8e0b9c5a37fe20e5624f46a2346b4ae66ab1320e35134", - "sha256:deec1009442312c1e2ee5298966842194d0e950b433f0d4fc844ef464b9c32a7" + "sha256:265d5da97a76c15d7d9a4088a67b78acd5dcf6f8cfd8257c52f581ff996ff515", + "sha256:40ca421407ce07c8fd700854fd978d58526ec6fff3468caa34ff1c7333b8dc34" ], "index": "pypi", - "version": "==2022.5.18" + "version": "==2023.3.4" + }, + "zope.event": { + "hashes": [ + "sha256:73d9e3ef750cca14816a9c322c7250b0d7c9dbc337df5d1b807ff8d3d0b9e97c", + "sha256:81d98813046fc86cc4136e3698fee628a3282f9c320db18658c21749235fce80" + ], + "version": "==4.6" + }, + "zope.interface": { + "hashes": [ + "sha256:042f2381118b093714081fd82c98e3b189b68db38ee7d35b63c327c470ef8373", + "sha256:0ec9653825f837fbddc4e4b603d90269b501486c11800d7c761eee7ce46d1bbb", + "sha256:12175ca6b4db7621aedd7c30aa7cfa0a2d65ea3a0105393e05482d7a2d367446", + "sha256:1592f68ae11e557b9ff2bc96ac8fc30b187e77c45a3c9cd876e3368c53dc5ba8", + "sha256:23ac41d52fd15dd8be77e3257bc51bbb82469cf7f5e9a30b75e903e21439d16c", + "sha256:424d23b97fa1542d7be882eae0c0fc3d6827784105264a8169a26ce16db260d8", + "sha256:4407b1435572e3e1610797c9203ad2753666c62883b921318c5403fb7139dec2", + "sha256:48f4d38cf4b462e75fac78b6f11ad47b06b1c568eb59896db5b6ec1094eb467f", + "sha256:4c3d7dfd897a588ec27e391edbe3dd320a03684457470415870254e714126b1f", + "sha256:5171eb073474a5038321409a630904fd61f12dd1856dd7e9d19cd6fe092cbbc5", + "sha256:5a158846d0fca0a908c1afb281ddba88744d403f2550dc34405c3691769cdd85", + "sha256:6ee934f023f875ec2cfd2b05a937bd817efcc6c4c3f55c5778cbf78e58362ddc", + "sha256:790c1d9d8f9c92819c31ea660cd43c3d5451df1df61e2e814a6f99cebb292788", + "sha256:809fe3bf1a91393abc7e92d607976bbb8586512913a79f2bf7d7ec15bd8ea518", + "sha256:87b690bbee9876163210fd3f500ee59f5803e4a6607d1b1238833b8885ebd410", + "sha256:89086c9d3490a0f265a3c4b794037a84541ff5ffa28bb9c24cc9f66566968464", + "sha256:99856d6c98a326abbcc2363827e16bd6044f70f2ef42f453c0bd5440c4ce24e5", + "sha256:aab584725afd10c710b8f1e6e208dbee2d0ad009f57d674cb9d1b3964037275d", + "sha256:af169ba897692e9cd984a81cb0f02e46dacdc07d6cf9fd5c91e81f8efaf93d52", + "sha256:b39b8711578dcfd45fc0140993403b8a81e879ec25d53189f3faa1f006087dca", + "sha256:b3f543ae9d3408549a9900720f18c0194ac0fe810cecda2a584fd4dca2eb3bb8", + "sha256:d0583b75f2e70ec93f100931660328965bb9ff65ae54695fb3fa0a1255daa6f2", + "sha256:dfbbbf0809a3606046a41f8561c3eada9db811be94138f42d9135a5c47e75f6f", + "sha256:e538f2d4a6ffb6edfb303ce70ae7e88629ac6e5581870e66c306d9ad7b564a58", + "sha256:eba51599370c87088d8882ab74f637de0c4f04a6d08a312dce49368ba9ed5c2a", + "sha256:ee4b43f35f5dc15e1fec55ccb53c130adb1d11e8ad8263d68b1284b66a04190d", + "sha256:f2363e5fd81afb650085c6686f2ee3706975c54f331b426800b53531191fdf28", + "sha256:f299c020c6679cb389814a3b81200fe55d428012c5e76da7e722491f5d205990", + "sha256:f72f23bab1848edb7472309e9898603141644faec9fd57a823ea6b4d1c4c8995", + "sha256:fa90bac61c9dc3e1a563e5babb3fd2c0c1c80567e815442ddbe561eadc803b30" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0" } }, - "develop": {} + "develop": { + "autopep8": { + "hashes": [ + "sha256:86e9303b5e5c8160872b2f5ef611161b2893e9bfe8ccc7e2f76385947d57a2f1", + "sha256:f9849cdd62108cb739dbcdbfb7fdcc9a30d1b63c4cc3e1c1f893b5360941b61c" + ], + "index": "pypi", + "version": "==2.0.2" + }, + "pycodestyle": { + "hashes": [ + "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", + "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" + ], + "markers": "python_version >= '3.6'", + "version": "==2.10.0" + }, + "setuptools-pipfile": { + "hashes": [ + "sha256:54cb6bf6a662fe74951425d509772a5302d1cf723d9a3654d19c2468d3d80b6b", + "sha256:f6049892af8e8233a438cf00fb4477fe81de3ea0e8e90c1241d196cb40f703b5" + ], + "index": "pypi", + "version": "==0.7.0" + }, + "toml": { + "hashes": [ + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.2" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + } + } } diff --git a/README.md b/README.md index e420629..8789de3 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,243 @@ -# Auto Archiver +

Auto Archiver

+ +[![PyPI version](https://badge.fury.io/py/auto-archiver.svg)](https://badge.fury.io/py/auto-archiver) +[![Docker Image Version (latest by date)](https://img.shields.io/docker/v/bellingcat/auto-archiver?label=version&logo=docker)](https://hub.docker.com/r/bellingcat/auto-archiver) + + + + + Read the [article about Auto Archiver on bellingcat.com](https://www.bellingcat.com/resources/2022/09/22/preserve-vital-online-content-with-bellingcats-auto-archiver-tool/). -Python script to automatically archive social media posts, videos, and images from a Google Sheets document. Uses different archivers depending on the platform, and can save content to local storage, S3 bucket (Digital Ocean Spaces, AWS, ...), and Google Drive. The Google Sheets where the links come from is updated with information about the archived content. It can be run manually or on an automated basis. +Python tool to automatically archive social media posts, videos, and images from a Google Sheets, the console, and more. Uses different archivers depending on the platform, and can save content to local storage, S3 bucket (Digital Ocean Spaces, AWS, ...), and Google Drive. If using Google Sheets as the source for links, it will be updated with information about the archived content. It can be run manually or on an automated basis. -## Setup +There are 3 ways to use the auto-archiver: +1. (easiest installation) via docker +2. (local python install) `pip install auto-archiver` +3. (legacy/development) clone and manually install from repo (see legacy [tutorial video](https://youtu.be/VfAhcuV2tLQ)) -Check this [tutorial video](https://youtu.be/VfAhcuV2tLQ). +But **you always need a configuration/orchestration file**, which is where you'll configure where/what/how to archive. Make sure you read [orchestration](#orchestration). +## How to install and run the auto-archiver -If you are using `pipenv` (recommended), `pipenv install` is sufficient to install Python prerequisites. +### Option 1 - docker -You also need: -1. [A Google Service account is necessary for use with `gspread`.](https://gspread.readthedocs.io/en/latest/oauth2.html#for-bots-using-service-account) Credentials for this account should be stored in `service_account.json`, in the same directory as the script. -2. [ffmpeg](https://www.ffmpeg.org/) must also be installed locally for this tool to work. -3. [firefox](https://www.mozilla.org/en-US/firefox/new/) and [geckodriver](https://github.com/mozilla/geckodriver/releases) on a path folder like `/usr/local/bin`. -4. [fonts-noto](https://fonts.google.com/noto) to deal with multiple unicode characters during selenium/geckodriver's screenshots: `sudo apt install fonts-noto -y`. -5. Internet Archive credentials can be retrieved from https://archive.org/account/s3.php. -6. If you would like to take archival [WACZ](https://specs.webrecorder.net/wacz/1.1.1/) snapshots using [browsertrix-crawler](https://github.com/webrecorder/browsertrix-crawler) - in addition to screenshots you will need to install [Docker](https://www.docker.com/). +[![dockeri.co](https://dockerico.blankenship.io/image/bellingcat/auto-archiver)](https://hub.docker.com/r/bellingcat/auto-archiver) -### Configuration file -Configuration is done via a config.yaml file (see [example.config.yaml](example.config.yaml)) and some properties of that file can be overwritten via command line arguments. Here is the current result from running the `python auto_archive.py --help`: - -
python auto_archive.py --help +Docker works like a virtual machine running inside your computer, it isolates everything and makes installation simple. Since it is an isolated environment when you need to pass it your orchestration file or get downloaded media out of docker you will need to connect folders on your machine with folders inside docker with the `-v` volume flag. +1. install [docker](https://docs.docker.com/get-docker/) +2. pull the auto-archiver docker [image](https://hub.docker.com/r/bellingcat/auto-archiver) with `docker pull bellingcat/auto-archiver` +3. run the docker image locally in a container: `docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver --config secrets/orchestration.yaml` breaking this command down: + 1. `docker run` tells docker to start a new container (an instance of the image) + 2. `--rm` makes sure this container is removed after execution (less garbage locally) + 3. `-v $PWD/secrets:/app/secrets` - your secrets folder + 1. `-v` is a volume flag which means a folder that you have on your computer will be connected to a folder inside the docker container + 2. `$PWD/secrets` points to a `secrets/` folder in your current working directory (where your console points to), we use this folder as a best practice to hold all the secrets/tokens/passwords/... you use + 3. `/app/secrets` points to the path the docker container where this image can be found + 4. `-v $PWD/local_archive:/app/local_archive` - (optional) if you use local_storage + 1. `-v` same as above, this is a volume instruction + 2. `$PWD/local_archive` is a folder `local_archive/` in case you want to archive locally and have the files accessible outside docker + 3. `/app/local_archive` is a folder inside docker that you can reference in your orchestration.yml file -```js -usage: auto_archive.py [-h] [--config CONFIG] [--storage {s3,local,gd}] [--sheet SHEET] [--header HEADER] [--check-if-exists] [--save-logs] [--s3-private] [--col-url URL] [--col-status STATUS] [--col-folder FOLDER] - [--col-archive ARCHIVE] [--col-date DATE] [--col-thumbnail THUMBNAIL] [--col-thumbnail_index THUMBNAIL_INDEX] [--col-timestamp TIMESTAMP] [--col-title TITLE] [--col-duration DURATION] - [--col-screenshot SCREENSHOT] [--col-hash HASH] +### Option 2 - python package -Automatically archive social media posts, videos, and images from a Google Sheets document. -The command line arguments will always override the configurations in the provided YAML config file (--config), only some high-level options -are allowed via the command line and the YAML configuration file is the preferred method. The sheet must have the "url" and "status" for the archiver to work. +
Python package instructions + +1. make sure you have python 3.8 or higher installed +2. install the package `pip/pipenv/conda install auto-archiver` +3. test it's installed with `auto-archiver --help` +4. run it with your orchestration file and pass any flags you want in the command line `auto-archiver --config secrets/orchestration.yaml` if your orchestration file is inside a `secrets/`, which we advise + +You will also need [ffmpeg](https://www.ffmpeg.org/), [firefox](https://www.mozilla.org/en-US/firefox/new/) and [geckodriver](https://github.com/mozilla/geckodriver/releases), and optionally [fonts-noto](https://fonts.google.com/noto). Similar to the local installation. + +
+ + +### Option 3 - local installation +This can also be used for development. + +
Legacy instructions, only use if docker/package is not an option + + +Install the following locally: +1. [ffmpeg](https://www.ffmpeg.org/) must also be installed locally for this tool to work. +2. [firefox](https://www.mozilla.org/en-US/firefox/new/) and [geckodriver](https://github.com/mozilla/geckodriver/releases) on a path folder like `/usr/local/bin`. +3. (optional) [fonts-noto](https://fonts.google.com/noto) to deal with multiple unicode characters during selenium/geckodriver's screenshots: `sudo apt install fonts-noto -y`. + +Clone and run: +1. `git clone https://github.com/bellingcat/auto-archiver` +2. `pipenv install` +3. `pipenv run python -m src.auto_archiver --config secrets/orchestration.yaml` -optional arguments: - -h, --help show this help message and exit - --config CONFIG the filename of the YAML configuration file (defaults to 'config.yaml') - --storage {s3,local,gd} - which storage to use [execution.storage in config.yaml] - --sheet SHEET the name of the google sheets document [execution.sheet in config.yaml] - --header HEADER 1-based index for the header row [execution.header in config.yaml] - --check-if-exists when possible checks if the URL has been archived before and does not archive the same URL twice [exceution.check_if_exists] - --save-logs creates or appends execution logs to files logs/LEVEL.log [exceution.save_logs] - --s3-private Store content without public access permission (only for storage=s3) [secrets.s3.private in config.yaml] - --col-url URL the name of the column to READ url FROM (default='link') - --col-status STATUS the name of the column to FILL WITH status (default='archive status') - --col-folder FOLDER the name of the column to READ folder FROM (default='destination folder') - --col-archive ARCHIVE - the name of the column to FILL WITH archive (default='archive location') - --col-date DATE the name of the column to FILL WITH date (default='archive date') - --col-thumbnail THUMBNAIL - the name of the column to FILL WITH thumbnail (default='thumbnail') - --col-thumbnail_index THUMBNAIL_INDEX - the name of the column to FILL WITH thumbnail_index (default='thumbnail index') - --col-timestamp TIMESTAMP - the name of the column to FILL WITH timestamp (default='upload timestamp') - --col-title TITLE the name of the column to FILL WITH title (default='upload title') - --col-duration DURATION - the name of the column to FILL WITH duration (default='duration') - --col-screenshot SCREENSHOT - the name of the column to FILL WITH screenshot (default='screenshot') - --col-hash HASH the name of the column to FILL WITH hash (default='hash') -```

-#### Example invocations -All the configurations can be specified in the YAML config file, but sometimes it is useful to override only some of those like the sheet that we are running the archival on, here are some examples (possibly prepended by `pipenv run`): +# Orchestration +The archiver work is orchestrated by the following workflow (we call each a **step**): +1. **Feeder** gets the links (from a spreadsheet, from the console, ...) +2. **Archiver** tries to archive the link (twitter, youtube, ...) +3. **Enricher** adds more info to the content (hashes, thumbnails, ...) +4. **Formatter** creates a report from all the archived content (HTML, PDF, ...) +5. **Database** knows what's been archived and also stores the archive result (spreadsheet, CSV, or just the console) + +To setup an auto-archiver instance create an `orchestration.yaml` which contains the workflow you would like. We advise you put this file into a `secrets/` folder and do not share it with others because it will contain passwords and other secrets. + +The structure of orchestration file is split into 2 parts: `steps` (what **steps** to use) and `configurations` (how those steps should behave), here's a simplification: +```yaml +# orchestration.yaml content +steps: + feeder: gsheet_feeder + archivers: # order matters + - youtubedl_archiver + enrichers: + - thumbnail_enricher + formatter: html_formatter + storages: + - local_storage + databases: + - gsheet_db + +configurations: + gsheet_feeder: + sheet: "your google sheet name" + header: 2 # row with header for your sheet + # ... configurations for the other steps here ... +``` + +To see all available `steps` (which archivers, storages, databses, ...) exist check the [example.orchestration.yaml](example.orchestration.yaml). + +All the `configurations` in the `orchestration.yaml` file (you can name it differently but need to pass it in the `--config FILENAME` argument) can be seen in the console by using the `--help` flag. They can also be overwritten, for example if you are using the `cli_feeder` to archive from the command line and want to provide the URLs you should do: ```bash -# all the configurations come from config.yaml -python auto_archive.py +auto-archiver --config secrets/orchestration.yaml --cli_feeder.urls="url1,url2,url3" +``` -# all the configurations come from config.yaml, -# checks if URL is not archived twice and saves logs to logs/ folder -python auto_archive.py --check-if-exists --save_logs +Here's the complete workflow that the auto-archiver goes through: +```mermaid +graph TD + s((start)) --> F(fa:fa-table Feeder) + F -->|get and clean URL| D1{fa:fa-database Database} + D1 -->|is already archived| e((end)) + D1 -->|not yet archived| a(fa:fa-download Archivers) + a -->|got media| E(fa:fa-chart-line Enrichers) + E --> S[fa:fa-box-archive Storages] + E --> Fo(fa:fa-code Formatter) + Fo --> S + Fo -->|update database| D2(fa:fa-database Database) + D2 --> e +``` -# all the configurations come from my_config.yaml -python auto_archive.py --config my_config.yaml +## Orchestration checklist +Use this to make sure you help making sure you did all the required steps: +* [ ] you have a `/secrets` folder with all your configuration files including + * [ ] a orchestration file eg: `orchestration.yaml` pointing to the correct location of other files + * [ ] (optional if you use GoogleSheets) you have a `service_account.json` (see [how-to](https://gspread.readthedocs.io/en/latest/oauth2.html#for-bots-using-service-account)) + * [ ] (optional for telegram) a `anon.session` which appears after the 1st run where you login to telegram + * if you use private channels you need to add `channel_invites` and set `join_channels=true` at least once + * [ ] (optional for VK) a `vk_config.v2.json` + * [ ] (optional for using GoogleDrive storage) `gd-token.json` (see [help script](scripts/create_update_gdrive_oauth_token.py)) + * [ ] (optional for instagram) `instaloader.session` file which appears after the 1st run and login in instagram + * [ ] (optional for browsertrix) `profile.tar.gz` file -# reads the configurations but saves archived content to google drive instead -python auto_archive.py --config my_config.yaml --storage gd +#### Example invocations +The recommended way to run the auto-archiver is through Docker. The invocations below will run the auto-archiver Docker image using a configuration file that you have specified -# uses the configurations but for another google docs sheet +```bash +# all the configurations come from ./secrets/orchestration.yaml +docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver --config secrets/orchestration.yaml +# uses the same configurations but for another google docs sheet # with a header on row 2 and with some different column names -python auto_archive.py --config my_config.yaml --sheet="use it on another sheets doc" --header=2 --col-link="put urls here" +# notice that columns is a dictionary so you need to pass it as JSON and it will override only the values provided +docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver --config secrets/orchestration.yaml --gsheet_feeder.sheet="use it on another sheets doc" --gsheet_feeder.header=2 --gsheet_feeder.columns='{"url": "link"}' +# all the configurations come from orchestration.yaml and specifies that s3 files should be private +docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver --config secrets/orchestration.yaml --s3_storage.private=1 +``` -# all the configurations come from config.yaml and specifies that s3 files should be private -python auto_archive.py --s3-private +The auto-archiver can also be run locally, if pre-requisites are correctly configured. Equivalent invocations are below. + +```bash +# all the configurations come from ./secrets/orchestration.yaml +auto-archiver --config secrets/orchestration.yaml +# uses the same configurations but for another google docs sheet +# with a header on row 2 and with some different column names +# notice that columns is a dictionary so you need to pass it as JSON and it will override only the values provided +auto-archiver --config secrets/orchestration.yaml --gsheet_feeder.sheet="use it on another sheets doc" --gsheet_feeder.header=2 --gsheet_feeder.columns='{"url": "link"}' +# all the configurations come from orchestration.yaml and specifies that s3 files should be private +auto-archiver --config secrets/orchestration.yaml --s3_storage.private=1 ``` ### Extra notes on configuration #### Google Drive To use Google Drive storage you need the id of the shared folder in the `config.yaml` file which must be shared with the service account eg `autoarchiverservice@auto-archiver-111111.iam.gserviceaccount.com` and then you can use `--storage=gd` -#### Telethon (Telegrams API Library) +#### Telethon + Instagram with telegram bot The first time you run, you will be prompted to do a authentication with the phone number associated, alternatively you can put your `anon.session` in the root. -## Running -The `--sheet name` property (or `execution.sheet` in the YAML file) is the name of the Google Sheet to check for URLs. +## Running on Google Sheets Feeder (gsheet_feeder) +The `--gseets_feeder.sheet` property is the name of the Google Sheet to check for URLs. This sheet must have been shared with the Google Service account used by `gspread`. -This sheet must also have specific columns (case-insensitive) in the `header` row (see `COLUMN_NAMES` in [gworksheet.py](utils/gworksheet.py)), only the `link` and `status` columns are mandatory: -* `Link` (required): the location of the media to be archived. This is the only column that should be supplied with data initially -* `Archive status` (required): the status of the auto archiver script. Any row with text in this column will be skipped automatically. -* `Destination folder`: (optional) by default files are saved to a folder called `name-of-sheets-document/name-of-sheets-tab/` using this option you can organize documents into folder from the sheet. -* `Archive location`: the location of the archived version. For files that were not able to be auto archived, this can be manually updated. -* `Archive date`: the date that the auto archiver script ran for this file -* `Upload timestamp`: the timestamp extracted from the video. (For YouTube, this unfortunately does not currently include the time) -* `Upload title`: the "title" of the video from the original source -* `Hash`: a hash of the first video or image found -* `Screenshot`: a screenshot taken with from a browser view of opening the page -* in case of videos - * `Duration`: duration in seconds - * `Thumbnail`: an image thumbnail of the video (resize row height to make this more visible) - * `Thumbnail index`: a link to a page that shows many thumbnails for the video, useful for quickly seeing video content +This sheet must also have specific columns (case-insensitive) in the `header` as specified in [Gsheet.configs](src/auto_archiver/utils/gsheet.py). The default names of these columns and their purpose is: +Inputs: -For example, for use with this spreadsheet: +* **Link** *(required)*: the URL of the post to archive +* **Destination folder**: custom folder for archived file (regardless of storage) -![A screenshot of a Google Spreadsheet with column headers defined as above, and several Youtube and Twitter URLs in the "Media URL" column](docs/demo-before.png) +Outputs: +* **Archive status** *(required)*: Status of archive operation +* **Archive location**: URL of archived post +* **Archive date**: Date archived +* **Thumbnail**: Embeds a thumbnail for the post in the spreadsheet +* **Timestamp**: Timestamp of original post +* **Title**: Post title +* **Text**: Post text +* **Screenshot**: Link to screenshot of post +* **Hash**: Hash of archived HTML file (which contains hashes of post media) +* **WACZ**: Link to a WACZ web archive of post +* **ReplayWebpage**: Link to a ReplayWebpage viewer of the WACZ archive -```pipenv run python auto_archive.py --sheet archiver-test``` +For example, this is a spreadsheet configured with all of the columns for the auto archiver and a few URLs to archive. (Note that the column names are not case sensitive.) + +![A screenshot of a Google Spreadsheet with column headers defined as above, and several Youtube and Twitter URLs in the "Link" column](docs/demo-before.png) + +Now the auto archiver can be invoked, with this command in this example: `docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver:dockerize --config secrets/orchestration-global.yaml --gsheet_feeder.sheet "Auto archive test 2023-2"`. Note that the sheet name has been overridden/specified in the command line invocation. When the auto archiver starts running, it updates the "Archive status" column. -![A screenshot of a Google Spreadsheet with column headers defined as above, and several Youtube and Twitter URLs in the "Media URL" column. The auto archiver has added "archive in progress" to one of the status columns.](docs/demo-progress.png) +![A screenshot of a Google Spreadsheet with column headers defined as above, and several Youtube and Twitter URLs in the "Link" column. The auto archiver has added "archive in progress" to one of the status columns.](docs/demo-progress.png) The links are downloaded and archived, and the spreadsheet is updated to the following: ![A screenshot of a Google Spreadsheet with videos archived and metadata added per the description of the columns above.](docs/demo-after.png) -Note that the first row is skipped, as it is assumed to be a header row (`--header=1` and you can change it if you use more rows above). Rows with an empty URL column, or a non-empty archive column are also skipped. All sheets in the document will be checked. +Note that the first row is skipped, as it is assumed to be a header row (`--gsheet_feeder.header=1` and you can change it if you use more rows above). Rows with an empty URL column, or a non-empty archive column are also skipped. All sheets in the document will be checked. -## Automating +The "archive location" link contains the path of the archived file, in local storage, S3, or in Google Drive. -The auto-archiver can be run automatically via cron. An example crontab entry that runs the archiver every minute is as follows. +![The archive result for a link in the demo sheet.](docs/demo-archive.png) -```* * * * * python auto_archive.py --sheet archiver-test``` +--- +## Development +Use `python -m src.auto_archiver --config secrets/orchestration.yaml` to run from the local development environment. -With this configuration, the archiver should archive and store all media added to the Google Sheet every 60 seconds. Of course, additional logging information, etc. might be required. - -# auto_auto_archiver - -To make it easier to set up new auto-archiver sheets, the auto-auto-archiver will look at a particular sheet and run the auto-archiver on every sheet name in column A, starting from row 11. (It starts here to support instructional text in the first rows of the sheet, as shown below.) You can simply use your default config as for `auto_archiver.py` but use `--sheet` to specify the name of the sheet that lists the names of sheets to archive.It must be shared with the same service account. - -![A screenshot of a Google Spreadsheet configured to show instructional text and a list of sheet names to check with auto-archiver.](docs/auto-auto.png) - -# Code structure -Code is split into functional concepts: -1. [Archivers](archivers/) - receive a URL that they try to archive -2. [Storages](storages/) - they deal with where the archived files go -3. [Utilities](utils/) - 1. [GWorksheet](utils/gworksheet.py) - facilitates some of the reading/writing tasks for a Google Worksheet - -### Current Archivers -Archivers are tested in a meaningful order with Wayback Machine being the failsafe, that can easily be changed in the code. - -> Note: We have 2 Twitter Archivers (`TwitterArchiver`, `TwitterApiArchiver`) because one requires Twitter API V2 credentials and has better results and the other does not rely on official APIs and misses out on some content. - -```mermaid -graph TD - A(Archiver) -->|parent of| B(TelethonArchiver) - A -->|parent of| C(TiktokArchiver) - A -->|parent of| D(YoutubeDLArchiver) - A -->|parent of| E(TelegramArchiver) - A -->|parent of| F(TwitterArchiver) - A -->|parent of| G(VkArchiver) - A -->|parent of| H(WaybackArchiver) - F -->|parent of| I(TwitterApiArchiver) -``` -### Current Storages -```mermaid -graph TD - A(BaseStorage) -->|parent of| B(S3Storage) - A(BaseStorage) -->|parent of| C(LocalStorage) - A(BaseStorage) -->|parent of| D(GoogleDriveStorage) -``` +#### Docker development +working with docker locally: + * `docker build . -t auto-archiver` to build a local image + * `docker run --rm -v $PWD/secrets:/app/secrets auto-archiver pipenv run python3 -m auto_archiver --config secrets/orchestration.yaml` + * to use local archive, also create a volume `-v` for it by adding `-v $PWD/local_archive:/app/local_archive` +release to docker hub + * `docker image tag auto-archiver bellingcat/auto-archiver:latest` + * `docker push bellingcat/auto-archiver` +#### RELEASE +* update version in [version.py](src/auto_archiver/version.py) +* run `bash ./scripts/release.sh` and confirm +* package is automatically updated in pypi +* docker image is automatically pushed to dockerhup diff --git a/archivers/__init__.py b/archivers/__init__.py deleted file mode 100644 index 403ebea..0000000 --- a/archivers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# we need to explicitly expose the available imports here -from .base_archiver import Archiver, ArchiveResult -from .telegram_archiver import TelegramArchiver -from .telethon_archiver import TelethonArchiver -from .tiktok_archiver import TiktokArchiver -from .wayback_archiver import WaybackArchiver -from .youtubedl_archiver import YoutubeDLArchiver -from .twitter_archiver import TwitterArchiver -from .vk_archiver import VkArchiver -from .twitter_api_archiver import TwitterApiArchiver \ No newline at end of file diff --git a/archivers/base_archiver.py b/archivers/base_archiver.py deleted file mode 100644 index 96e0fbf..0000000 --- a/archivers/base_archiver.py +++ /dev/null @@ -1,348 +0,0 @@ -import os, datetime, shutil, hashlib, time, requests, re, mimetypes, subprocess -from dataclasses import dataclass -from abc import ABC, abstractmethod -from urllib.parse import urlparse -from random import randrange - -import ffmpeg -from loguru import logger -from selenium.common.exceptions import TimeoutException -from selenium.webdriver.common.by import By -from slugify import slugify - -from configs import Config -from storages import Storage -from utils import mkdir_if_not_exists - - -@dataclass -class ArchiveResult: - status: str - cdn_url: str = None - thumbnail: str = None - thumbnail_index: str = None - duration: float = None - title: str = None - timestamp: datetime.datetime = None - screenshot: str = None - wacz: str = None - hash: str = None - -class Archiver(ABC): - name = "default" - retry_regex = r"retrying at (\d+)$" - - def __init__(self, storage: Storage, config: Config): - self.storage = storage - self.driver = config.webdriver - self.hash_algorithm = config.hash_algorithm - self.browsertrix = config.browsertrix_config - - def __str__(self): - return self.__class__.__name__ - - def __repr__(self): - return self.__str__() - - @abstractmethod - def download(self, url, check_if_exists=False): pass - - def get_netloc(self, url): - return urlparse(url).netloc - - def generate_media_page_html(self, url, urls_info: dict, object, thumbnail=None): - """ - Generates an index.html page where each @urls_info is displayed - """ - page = f'''{url} - -

Archived media from {self.name}

-

{url}

    ''' - - for url_info in urls_info: - mime_global = self._guess_file_type(url_info["key"]) - preview = "" - if mime_global == "image": - preview = f'' - elif mime_global == "video": - preview = f'' - page += f'''
  • {preview}{url_info['key']}: {url_info['hash']}
  • ''' - - page += f"

{self.name} object data:

{object}" - page += f"" - - page_key = self.get_html_key(url) - page_filename = os.path.join(Storage.TMP_FOLDER, page_key) - - with open(page_filename, "w") as f: - f.write(page) - - page_hash = self.get_hash(page_filename) - - self.storage.upload(page_filename, page_key, extra_args={ - 'ACL': 'public-read', 'ContentType': 'text/html'}) - - page_cdn = self.storage.get_cdn_url(page_key) - return (page_cdn, page_hash, thumbnail) - - def _guess_file_type(self, path: str): - """ - Receives a URL or filename and returns global mimetype like 'image' or 'video' - see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types - """ - mime = mimetypes.guess_type(path)[0] - if mime is not None: - return mime.split("/")[0] - return "" - - def download_from_url(self, url, to_filename): - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36' - } - d = requests.get(url, headers=headers) - with open(to_filename, 'wb') as f: - f.write(d.content) - - def generate_media_page(self, urls, url, object): - """ - For a list of media urls, fetch them, upload them - and call self.generate_media_page_html with them - """ - - thumbnail = None - uploaded_media = [] - for media_url in urls: - key = self._get_key_from_url(media_url, ".jpg") - - filename = os.path.join(Storage.TMP_FOLDER, key) - self.download_from_url(media_url, filename) - self.storage.upload(filename, key) - hash = self.get_hash(filename) - cdn_url = self.storage.get_cdn_url(key) - - if thumbnail is None: - thumbnail = cdn_url - uploaded_media.append({'cdn_url': cdn_url, 'key': key, 'hash': hash}) - - return self.generate_media_page_html(url, uploaded_media, object, thumbnail=thumbnail) - - def get_key(self, filename): - """ - returns a key in the format "[archiverName]_[filename]" includes extension - """ - tail = os.path.split(filename)[1] # returns filename.ext from full path - _id, extension = os.path.splitext(tail) # returns [filename, .ext] - if 'unknown_video' in _id: - _id = _id.replace('unknown_video', 'jpg') - - # long filenames can cause problems, so trim them if necessary - if len(_id) > 128: - _id = _id[-128:] - - return f'{self.name}_{_id}{extension}' - - def get_html_key(self, url): - return self._get_key_from_url(url, ".html") - - def _get_key_from_url(self, url, with_extension: str = None, append_datetime: bool = False): - """ - Receives a URL and returns a slugified version of the URL path - if a string is passed in @with_extension the slug is appended with it if there is no "." in the slug - if @append_date is true, the key adds a timestamp after the URL slug and before the extension - """ - url_path = urlparse(url).path - path, ext = os.path.splitext(url_path) - slug = slugify(path) - if append_datetime: - slug += "-" + slugify(datetime.datetime.utcnow().isoformat()) - if len(ext): - slug += ext - if with_extension is not None: - if "." not in slug: - slug += with_extension - return self.get_key(slug) - - def get_hash(self, filename): - with open(filename, "rb") as f: - bytes = f.read() # read entire file as bytes - logger.debug(f'Hash algorithm is {self.hash_algorithm}') - - if self.hash_algorithm == "SHA-256": hash = hashlib.sha256(bytes) - elif self.hash_algorithm == "SHA3-512": hash = hashlib.sha3_512(bytes) - else: raise Exception(f"Unknown Hash Algorithm of {self.hash_algorithm}") - - return hash.hexdigest() - - def get_screenshot(self, url): - logger.debug(f"getting screenshot for {url=}") - key = self._get_key_from_url(url, ".png", append_datetime=True) - filename = os.path.join(Storage.TMP_FOLDER, key) - - # Accept cookies popup dismiss for ytdlp video - if 'facebook.com' in url: - try: - logger.debug(f'Trying fb click accept cookie popup for {url}') - self.driver.get("http://www.facebook.com") - foo = self.driver.find_element(By.XPATH, "//button[@data-cookiebanner='accept_only_essential_button']") - foo.click() - logger.debug(f'fb click worked') - # linux server needs a sleep otherwise facebook cookie won't have worked and we'll get a popup on next page - time.sleep(2) - except: - logger.warning(f'Failed on fb accept cookies for url {url}') - - try: - self.driver.get(url) - time.sleep(6) - except TimeoutException: - logger.info("TimeoutException loading page for screenshot") - - self.driver.save_screenshot(filename) - self.storage.upload(filename, key, extra_args={'ACL': 'public-read', 'ContentType': 'image/png'}) - - return self.storage.get_cdn_url(key) - - def get_wacz(self, url): - if not self.browsertrix.enabled: - logger.debug(f"Browsertrix WACZ generation is not enabled, skipping.") - return - - logger.debug(f"getting wacz for {url}") - key = self._get_key_from_url(url, ".wacz", append_datetime=True) - collection = re.sub('[^0-9a-zA-Z]+', '', key.replace(".wacz", "")) - - browsertrix_home = os.path.join(os.getcwd(), "browsertrix-tmp") - cmd = [ - "docker", "run", - "-v", f"{browsertrix_home}:/crawls/", - "-it", - "webrecorder/browsertrix-crawler", "crawl", - "--url", url, - "--scopeType", "page", - "--generateWACZ", - "--text", - "--collection", collection, - "--behaviors", "autoscroll,autoplay,autofetch,siteSpecific", - "--behaviorTimeout", str(self.browsertrix.timeout_seconds), - "--timeout", str(self.browsertrix.timeout_seconds) - ] - - if not os.path.isdir(browsertrix_home): - os.mkdir(browsertrix_home) - - if self.browsertrix.profile: - shutil.copyfile(self.browsertrix.profile, os.path.join(browsertrix_home, "profile.tar.gz")) - cmd.extend(["--profile", "/crawls/profile.tar.gz"]) - - try: - logger.info(f"Running browsertrix-crawler: {' '.join(cmd)}") - subprocess.run(cmd, check=True) - except Exception as e: - logger.error(f"WACZ generation failed: {e}") - return - - filename = os.path.join(browsertrix_home, "collections", collection, f"{collection}.wacz") - - self.storage.upload(filename, key, extra_args={ - 'ACL': 'public-read', 'ContentType': 'application/zip'}) - - # clean up the local browsertrix files - try: - shutil.rmtree(browsertrix_home) - except PermissionError: - logger.warn(f"Unable to clean up browsertrix-crawler files in {browsertrix_home}") - - return self.storage.get_cdn_url(key) - - def get_thumbnails(self, filename, key, duration=None): - thumbnails_folder = os.path.splitext(filename)[0] + os.path.sep - key_folder = key.split('.')[0] + os.path.sep - - mkdir_if_not_exists(thumbnails_folder) - - fps = 0.5 - if duration is not None: - duration = float(duration) - - if duration < 60: - fps = 10.0 / duration - elif duration < 120: - fps = 20.0 / duration - else: - fps = 40.0 / duration - - stream = ffmpeg.input(filename) - stream = ffmpeg.filter(stream, 'fps', fps=fps).filter('scale', 512, -1) - stream.output(thumbnails_folder + 'out%d.jpg').run() - - thumbnails = os.listdir(thumbnails_folder) - cdn_urls = [] - for fname in thumbnails: - if fname[-3:] == 'jpg': - thumbnail_filename = thumbnails_folder + fname - key = os.path.join(key_folder, fname) - - self.storage.upload(thumbnail_filename, key) - cdn_url = self.storage.get_cdn_url(key) - cdn_urls.append(cdn_url) - - if len(cdn_urls) == 0: - return ('', '') - - key_thumb = cdn_urls[int(len(cdn_urls) * 0.1)] - - index_page = f'''{filename} - ''' - - for t in cdn_urls: - index_page += f'' - - index_page += f"" - index_fname = thumbnails_folder + 'index.html' - - with open(index_fname, 'w') as f: - f.write(index_page) - - thumb_index = key_folder + 'index.html' - - self.storage.upload(index_fname, thumb_index, extra_args={ - 'ACL': 'public-read', 'ContentType': 'text/html'}) - shutil.rmtree(thumbnails_folder) - - thumb_index_cdn_url = self.storage.get_cdn_url(thumb_index) - - return (key_thumb, thumb_index_cdn_url) - - def signal_retry_in(self, min_seconds=1800, max_seconds=7200, **kwargs): - """ - sets state to retry in random between (min_seconds, max_seconds) - """ - now = datetime.datetime.now().timestamp() - retry_at = int(now + randrange(min_seconds, max_seconds)) - logger.debug(f"signaling {retry_at=}") - return ArchiveResult(status=f'retrying at {retry_at}', **kwargs) - - def is_retry(status): - return re.search(Archiver.retry_regex, status) is not None - - def should_retry_from_status(status): - """ - checks status against message in signal_retry_in - returns true if enough time has elapsed, false otherwise - """ - match = re.search(Archiver.retry_regex, status) - if match: - retry_at = int(match.group(1)) - now = datetime.datetime.now().timestamp() - should_retry = now >= retry_at - logger.debug(f"{should_retry=} since {now=} and {retry_at=}") - return should_retry - return False - - def remove_retry(status): - """ - transforms the status from retry into something else - """ - new_status = re.sub(Archiver.retry_regex, "failed: too many retries", status, 0) - logger.debug(f"removing retry message at {status=}, got {new_status=}") - return new_status diff --git a/archivers/telegram_archiver.py b/archivers/telegram_archiver.py deleted file mode 100644 index 026bdd0..0000000 --- a/archivers/telegram_archiver.py +++ /dev/null @@ -1,89 +0,0 @@ -import os, requests, re - -import html -from bs4 import BeautifulSoup -from loguru import logger - -from .base_archiver import Archiver, ArchiveResult -from storages import Storage - - -class TelegramArchiver(Archiver): - name = "telegram" - - def download(self, url, check_if_exists=False): - # detect URLs that we definitely cannot handle - if 't.me' != self.get_netloc(url): - return False - - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36' - } - status = "success" - - original_url = url - - # TODO: check if we can do this more resilient to variable URLs - if url[-8:] != "?embed=1": - url += "?embed=1" - - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - - t = requests.get(url, headers=headers) - s = BeautifulSoup(t.content, 'html.parser') - video = s.find("video") - - if video is None: - logger.warning("could not find video") - image_tags = s.find_all(class_="js-message_photo") - - images = [] - for im in image_tags: - urls = [u.replace("'", "") for u in re.findall(r'url\((.*?)\)', im['style'])] - images += urls - - page_cdn, page_hash, thumbnail = self.generate_media_page(images, url, html.escape(str(t.content))) - time_elements = s.find_all('time') - timestamp = time_elements[0].get('datetime') if len(time_elements) else None - - return ArchiveResult(status="success", cdn_url=page_cdn, screenshot=screenshot, hash=page_hash, thumbnail=thumbnail, timestamp=timestamp, wacz=wacz) - - video_url = video.get('src') - video_id = video_url.split('/')[-1].split('?')[0] - key = self.get_key(video_id) - - filename = os.path.join(Storage.TMP_FOLDER, key) - - if check_if_exists and self.storage.exists(key): - status = 'already archived' - - v = requests.get(video_url, headers=headers) - - with open(filename, 'wb') as f: - f.write(v.content) - - if status != 'already archived': - self.storage.upload(filename, key) - - hash = self.get_hash(filename) - - # extract duration from HTML - try: - duration = s.find_all('time')[0].contents[0] - if ':' in duration: - duration = float(duration.split( - ':')[0]) * 60 + float(duration.split(':')[1]) - else: - duration = float(duration) - except: - duration = "" - - # process thumbnails - key_thumb, thumb_index = self.get_thumbnails( - filename, key, duration=duration) - os.remove(filename) - - cdn_url = self.storage.get_cdn_url(key) - return ArchiveResult(status=status, cdn_url=cdn_url, thumbnail=key_thumb, thumbnail_index=thumb_index, - duration=duration, title=original_url, timestamp=s.find_all('time')[1].get('datetime'), hash=hash, screenshot=screenshot, wacz=wacz) diff --git a/archivers/telethon_archiver.py b/archivers/telethon_archiver.py deleted file mode 100644 index 9f9bbbf..0000000 --- a/archivers/telethon_archiver.py +++ /dev/null @@ -1,127 +0,0 @@ -import os, re - -import html -from loguru import logger -from telethon.sync import TelegramClient -from telethon.errors import ChannelInvalidError - -from storages import Storage -from .base_archiver import Archiver, ArchiveResult -from configs import Config -from utils import getattr_or - - -class TelethonArchiver(Archiver): - name = "telethon" - link_pattern = re.compile(r"https:\/\/t\.me(\/c){0,1}\/(.+)\/(\d+)") - - def __init__(self, storage: Storage, config: Config): - super().__init__(storage, config) - if config.telegram_config: - c = config.telegram_config - self.client = TelegramClient("./anon", c.api_id, c.api_hash) - self.bot_token = c.bot_token - - def _get_media_posts_in_group(self, chat, original_post, max_amp=10): - """ - Searches for Telegram posts that are part of the same group of uploads - The search is conducted around the id of the original post with an amplitude - of `max_amp` both ways - Returns a list of [post] where each post has media and is in the same grouped_id - """ - if getattr_or(original_post, "grouped_id") is None: - return [original_post] if getattr_or(original_post, "media") else [] - - search_ids = [i for i in range(original_post.id - max_amp, original_post.id + max_amp + 1)] - posts = self.client.get_messages(chat, ids=search_ids) - media = [] - for post in posts: - if post is not None and post.grouped_id == original_post.grouped_id and post.media is not None: - media.append(post) - return media - - def download(self, url, check_if_exists=False): - if not hasattr(self, "client"): - logger.warning('Missing Telethon config') - return False - - # detect URLs that we definitely cannot handle - matches = self.link_pattern.findall(url) - if not len(matches): - return False - - status = "success" - - # app will ask (stall for user input!) for phone number and auth code if anon.session not found - with self.client.start(bot_token=self.bot_token): - matches = list(matches[0]) - chat, post_id = matches[1], matches[2] - - post_id = int(post_id) - - try: - post = self.client.get_messages(chat, ids=post_id) - except ValueError as e: - logger.error(f"Could not fetch telegram {url} possibly it's private: {e}") - return False - except ChannelInvalidError as e: - logger.error(f"Could not fetch telegram {url}. This error can be fixed if you setup a bot_token in addition to api_id and api_hash: {e}") - return False - - if post is None: return False - - media_posts = self._get_media_posts_in_group(chat, post) - logger.debug(f'got {len(media_posts)=} for {url=}') - - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - - if len(media_posts) > 0: - key = self.get_html_key(url) - - if check_if_exists and self.storage.exists(key): - # only s3 storage supports storage.exists as not implemented on gd - cdn_url = self.storage.get_cdn_url(key) - return ArchiveResult(status='already archived', cdn_url=cdn_url, title=post.message, timestamp=post.date, screenshot=screenshot, wacz=wacz) - - key_thumb, thumb_index = None, None - group_id = post.grouped_id if post.grouped_id is not None else post.id - uploaded_media = [] - message = post.message - for mp in media_posts: - if len(mp.message) > len(message): message = mp.message - - # media can also be in entities - if mp.entities: - other_media_urls = [e.url for e in mp.entities if hasattr(e, "url") and e.url and self._guess_file_type(e.url) in ["video", "image"]] - logger.debug(f"Got {len(other_media_urls)} other medial urls from {mp.id=}: {other_media_urls}") - for om_url in other_media_urls: - filename = os.path.join(Storage.TMP_FOLDER, f'{chat}_{group_id}_{self._get_key_from_url(om_url)}') - self.download_from_url(om_url, filename) - key = filename.split(Storage.TMP_FOLDER)[1] - self.storage.upload(filename, key) - hash = self.get_hash(filename) - cdn_url = self.storage.get_cdn_url(key) - uploaded_media.append({'cdn_url': cdn_url, 'key': key, 'hash': hash}) - - filename_dest = os.path.join(Storage.TMP_FOLDER, f'{chat}_{group_id}', str(mp.id)) - filename = self.client.download_media(mp.media, filename_dest) - if not filename: - logger.debug(f"Empty media found, skipping {str(mp)=}") - continue - - key = filename.split(Storage.TMP_FOLDER)[1] - self.storage.upload(filename, key) - hash = self.get_hash(filename) - cdn_url = self.storage.get_cdn_url(key) - uploaded_media.append({'cdn_url': cdn_url, 'key': key, 'hash': hash}) - if key_thumb is None: - key_thumb, thumb_index = self.get_thumbnails(filename, key) - os.remove(filename) - - page_cdn, page_hash, _ = self.generate_media_page_html(url, uploaded_media, html.escape(str(post))) - - return ArchiveResult(status=status, cdn_url=page_cdn, title=message, timestamp=post.date, hash=page_hash, screenshot=screenshot, thumbnail=key_thumb, thumbnail_index=thumb_index, wacz=wacz) - - page_cdn, page_hash, _ = self.generate_media_page_html(url, [], html.escape(str(post))) - return ArchiveResult(status=status, cdn_url=page_cdn, title=post.message, timestamp=getattr_or(post, "date"), hash=page_hash, screenshot=screenshot, wacz=wacz) diff --git a/archivers/tiktok_archiver.py b/archivers/tiktok_archiver.py deleted file mode 100644 index bdaad52..0000000 --- a/archivers/tiktok_archiver.py +++ /dev/null @@ -1,72 +0,0 @@ -import os, traceback -import tiktok_downloader -from loguru import logger - -from .base_archiver import Archiver, ArchiveResult -from storages import Storage - - -class TiktokArchiver(Archiver): - name = "tiktok" - - def download(self, url, check_if_exists=False): - if 'tiktok.com' not in url: - return False - - status = 'success' - - try: - info = tiktok_downloader.info_post(url) - key = self.get_key(f'{info.id}.mp4') - filename = os.path.join(Storage.TMP_FOLDER, key) - logger.info(f'found video {key=}') - - if check_if_exists and self.storage.exists(key): - status = 'already archived' - - media = tiktok_downloader.snaptik(url).get_media() - - if len(media) <= 0: - if status == 'already archived': - return ArchiveResult(status='Could not download media, but already archived', cdn_url=self.storage.get_cdn_url(key)) - else: - return ArchiveResult(status='Could not download media') - - logger.info(f'downloading video {key=}') - media[0].download(filename) - - if status != 'already archived': - logger.info(f'uploading video {key=}') - self.storage.upload(filename, key) - - try: - key_thumb, thumb_index = self.get_thumbnails(filename, key, duration=info.duration) - except Exception as e: - logger.error(e) - key_thumb = '' - thumb_index = 'error creating thumbnails' - - hash = self.get_hash(filename) - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - - try: os.remove(filename) - except FileNotFoundError: - logger.info(f'tmp file not found thus not deleted {filename}') - cdn_url = self.storage.get_cdn_url(key) - timestamp = info.create.isoformat() if hasattr(info, "create") else None - - return ArchiveResult(status=status, cdn_url=cdn_url, thumbnail=key_thumb, - thumbnail_index=thumb_index, duration=getattr(info, "duration", 0), title=getattr(info, "caption", ""), - timestamp=timestamp, hash=hash, screenshot=screenshot, wacz=wacz) - - except tiktok_downloader.Except.InvalidUrl as e: - status = 'Invalid URL' - logger.warning(f'Invalid URL on {url} {e}\n{traceback.format_exc()}') - return ArchiveResult(status=status) - - except: - error = traceback.format_exc() - status = 'Other Tiktok error: ' + str(error) - logger.warning(f'Other Tiktok error' + str(error)) - return ArchiveResult(status=status) diff --git a/archivers/twitter_api_archiver.py b/archivers/twitter_api_archiver.py deleted file mode 100644 index 454cfe2..0000000 --- a/archivers/twitter_api_archiver.py +++ /dev/null @@ -1,75 +0,0 @@ - -import json -from datetime import datetime -from loguru import logger -from pytwitter import Api - -from storages.base_storage import Storage -from configs import Config -from .base_archiver import ArchiveResult -from .twitter_archiver import TwitterArchiver - - -class TwitterApiArchiver(TwitterArchiver): - name = "twitter_api" - - def __init__(self, storage: Storage, config: Config): - super().__init__(storage, config) - c = config.twitter_config - - if c.bearer_token: - self.api = Api(bearer_token=c.bearer_token) - elif c.consumer_key and c.consumer_secret and c.access_token and c.access_secret: - self.api = Api( - consumer_key=c.consumer_key, consumer_secret=c.consumer_secret, access_token=c.access_token, access_secret=c.access_secret) - - def download(self, url, check_if_exists=False): - if not hasattr(self, "api"): - logger.warning('Missing Twitter API config') - return False - - username, tweet_id = self.get_username_tweet_id(url) - if not username: return False - - tweet = self.api.get_tweet(tweet_id, expansions=["attachments.media_keys"], media_fields=["type", "duration_ms", "url", "variants"], tweet_fields=["attachments", "author_id", "created_at", "entities", "id", "text", "possibly_sensitive"]) - timestamp = datetime.strptime(tweet.data.created_at, "%Y-%m-%dT%H:%M:%S.%fZ") - - # check if exists - key = self.get_html_key(url) - if check_if_exists and self.storage.exists(key): - # only s3 storage supports storage.exists as not implemented on gd - cdn_url = self.storage.get_cdn_url(key) - screenshot = self.get_screenshot(url) - return ArchiveResult(status='already archived', cdn_url=cdn_url, title=tweet.data.text, timestamp=timestamp, screenshot=screenshot) - - urls = [] - if tweet.includes: - for m in tweet.includes.media: - if m.url: - urls.append(m.url) - elif hasattr(m, "variants"): - var_url = self.choose_variant(m.variants) - urls.append(var_url) - else: - urls.append(None) # will trigger error - - for u in urls: - if u is None: - logger.debug(f"Should not have gotten None url for {tweet.includes.media=} so going to download_alternative in twitter_archiver") - return self.download_alternative(url, tweet_id) - logger.debug(f"found {urls=}") - - output = json.dumps({ - "id": tweet.data.id, - "text": tweet.data.text, - "created_at": tweet.data.created_at, - "author_id": tweet.data.author_id, - "geo": tweet.data.geo, - "lang": tweet.data.lang, - "media": urls - }, ensure_ascii=False, indent=4) - - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - page_cdn, page_hash, thumbnail = self.generate_media_page(urls, url, output) - return ArchiveResult(status="success", cdn_url=page_cdn, screenshot=screenshot, hash=page_hash, thumbnail=thumbnail, timestamp=timestamp, title=tweet.data.text, wacz=wacz) diff --git a/archivers/twitter_archiver.py b/archivers/twitter_archiver.py deleted file mode 100644 index b868af5..0000000 --- a/archivers/twitter_archiver.py +++ /dev/null @@ -1,105 +0,0 @@ -import html, re, requests -from datetime import datetime -from loguru import logger -from snscrape.modules.twitter import TwitterTweetScraper, Video, Gif, Photo - -from .base_archiver import Archiver, ArchiveResult - -class TwitterArchiver(Archiver): - """ - This Twitter Archiver uses unofficial scraping methods, and it works as - an alternative to TwitterApiArchiver when no API credentials are provided. - """ - - name = "twitter" - link_pattern = re.compile(r"twitter.com\/(?:\#!\/)?(\w+)\/status(?:es)?\/(\d+)") - - def get_username_tweet_id(self, url): - # detect URLs that we definitely cannot handle - matches = self.link_pattern.findall(url) - if not len(matches): return False, False - - username, tweet_id = matches[0] # only one URL supported - logger.debug(f"Found {username=} and {tweet_id=} in {url=}") - - return username, tweet_id - - def download(self, url, check_if_exists=False): - username, tweet_id = self.get_username_tweet_id(url) - if not username: return False - - scr = TwitterTweetScraper(tweet_id) - - try: - tweet = next(scr.get_items()) - except Exception as ex: - logger.warning(f"can't get tweet: {type(ex).__name__} occurred. args: {ex.args}") - return self.download_alternative(url, tweet_id) - - if tweet.media is None: - logger.debug(f'No media found, archiving tweet text only') - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - page_cdn, page_hash, _ = self.generate_media_page_html(url, [], html.escape(tweet.json())) - return ArchiveResult(status="success", cdn_url=page_cdn, title=tweet.content, timestamp=tweet.date, hash=page_hash, screenshot=screenshot, wacz=wacz) - - urls = [] - - for media in tweet.media: - if type(media) == Video: - variant = max( - [v for v in media.variants if v.bitrate], key=lambda v: v.bitrate) - urls.append(variant.url) - elif type(media) == Gif: - urls.append(media.variants[0].url) - elif type(media) == Photo: - urls.append(media.fullUrl.replace('name=large', 'name=orig')) - else: - logger.warning(f"Could not get media URL of {media}") - - page_cdn, page_hash, thumbnail = self.generate_media_page(urls, url, tweet.json()) - - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - - return ArchiveResult(status="success", cdn_url=page_cdn, screenshot=screenshot, hash=page_hash, thumbnail=thumbnail, timestamp=tweet.date, title=tweet.content, wacz=wacz) - - def download_alternative(self, url, tweet_id): - # https://stackoverflow.com/a/71867055/6196010 - logger.debug(f"Trying twitter hack for {url=}") - hack_url = f"https://cdn.syndication.twimg.com/tweet?id={tweet_id}" - r = requests.get(hack_url) - if r.status_code != 200: return False - tweet = r.json() - - urls = [] - for p in tweet["photos"]: - urls.append(p["url"]) - - # 1 tweet has 1 video max - if "video" in tweet: - v = tweet["video"] - urls.append(self.choose_variant(v.get("variants", []))) - - logger.debug(f"Twitter hack got {urls=}") - - timestamp = datetime.strptime(tweet["created_at"], "%Y-%m-%dT%H:%M:%S.%fZ") - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - page_cdn, page_hash, thumbnail = self.generate_media_page(urls, url, r.text) - return ArchiveResult(status="success", cdn_url=page_cdn, screenshot=screenshot, hash=page_hash, thumbnail=thumbnail, timestamp=timestamp, title=tweet["text"], wacz=wacz) - - def choose_variant(self, variants): - # choosing the highest quality possible - variant, width, height = None, 0, 0 - for var in variants: - if var["type"] == "video/mp4": - width_height = re.search(r"\/(\d+)x(\d+)\/", var["src"]) - if width_height: - w, h = int(width_height[1]), int(width_height[2]) - if w > width or h > height: - width, height = w, h - variant = var.get("src", variant) - else: - variant = var.get("src") if not variant else variant - return variant diff --git a/archivers/vk_archiver.py b/archivers/vk_archiver.py deleted file mode 100644 index 91b8354..0000000 --- a/archivers/vk_archiver.py +++ /dev/null @@ -1,74 +0,0 @@ -import re, json, mimetypes, os - -from loguru import logger -from vk_url_scraper import VkScraper, DateTimeEncoder - -from storages import Storage -from .base_archiver import Archiver, ArchiveResult -from configs import Config - - -class VkArchiver(Archiver): - """" - VK videos are handled by YTDownloader, this archiver gets posts text and images. - Currently only works for /wall posts - """ - name = "vk" - wall_pattern = re.compile(r"(wall.{0,1}\d+_\d+)") - photo_pattern = re.compile(r"(photo.{0,1}\d+_\d+)") - - def __init__(self, storage: Storage, config: Config): - super().__init__(storage, config) - if config.vk_config != None: - self.vks = VkScraper(config.vk_config.username, config.vk_config.password) - - def download(self, url, check_if_exists=False): - if not hasattr(self, "vks") or self.vks is None: - logger.debug("VK archiver was not supplied with credentials.") - return False - - key = self.get_html_key(url) - # if check_if_exists and self.storage.exists(key): - # screenshot = self.get_screenshot(url) - # cdn_url = self.storage.get_cdn_url(key) - # return ArchiveResult(status="already archived", cdn_url=cdn_url, screenshot=screenshot) - - results = self.vks.scrape(url) # some urls can contain multiple wall/photo/... parts and all will be fetched - if len(results) == 0: - return False - - def dump_payload(p): return json.dumps(p, ensure_ascii=False, indent=4, cls=DateTimeEncoder) - textual_output = "" - title, datetime = results[0]["text"], results[0]["datetime"] - urls_found = [] - for res in results: - textual_output += f"id: {res['id']}
time utc: {res['datetime']}
text: {res['text']}
payload: {dump_payload(res['payload'])}


" - title = res["text"] if len(title) == 0 else title - datetime = res["datetime"] if not datetime else datetime - for attachments in res["attachments"].values(): - urls_found.extend(attachments) - - # we don't call generate_media_page which downloads urls because it cannot download vk video urls - thumbnail, thumbnail_index = None, None - uploaded_media = [] - filenames = self.vks.download_media(results, Storage.TMP_FOLDER) - for filename in filenames: - key = self.get_key(filename) - self.storage.upload(filename, key) - hash = self.get_hash(filename) - cdn_url = self.storage.get_cdn_url(key) - try: - _type = mimetypes.guess_type(filename)[0].split("/")[0] - if _type == "image" and thumbnail is None: - thumbnail = cdn_url - if _type == "video" and (thumbnail is None or thumbnail_index is None): - thumbnail, thumbnail_index = self.get_thumbnails(filename, key) - except Exception as e: - logger.warning(f"failed to get thumb for {filename=} with {e=}") - uploaded_media.append({'cdn_url': cdn_url, 'key': key, 'hash': hash}) - - page_cdn, page_hash, thumbnail = self.generate_media_page_html(url, uploaded_media, textual_output, thumbnail=thumbnail) - # # if multiple wall/photos/videos are present the screenshot will only grab the 1st - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - return ArchiveResult(status="success", cdn_url=page_cdn, screenshot=screenshot, hash=page_hash, thumbnail=thumbnail, thumbnail_index=thumbnail_index, timestamp=datetime, title=title, wacz=wacz) diff --git a/archivers/wayback_archiver.py b/archivers/wayback_archiver.py deleted file mode 100644 index e0ede90..0000000 --- a/archivers/wayback_archiver.py +++ /dev/null @@ -1,89 +0,0 @@ -import time, requests - -from loguru import logger -from bs4 import BeautifulSoup - -from storages import Storage -from .base_archiver import Archiver, ArchiveResult -from configs import Config - - -class WaybackArchiver(Archiver): - """ - This archiver could implement a check_if_exists by going to "https://web.archive.org/web/{url}" - but that might not be desirable since the webpage might have been archived a long time ago and thus have changed - """ - name = "wayback" - - def __init__(self, storage: Storage, config: Config): - super(WaybackArchiver, self).__init__(storage, config) - self.config = config.wayback_config - self.seen_urls = {} - - def download(self, url, check_if_exists=False): - if self.config is None: - logger.error('Missing Wayback config') - return False - if check_if_exists: - if url in self.seen_urls: return self.seen_urls[url] - - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - - logger.debug(f"POSTing {url=} to web.archive.org") - ia_headers = { - "Accept": "application/json", - "Authorization": f"LOW {self.config.key}:{self.config.secret}" - } - r = requests.post('https://web.archive.org/save/', headers=ia_headers, data={'url': url}) - - if r.status_code != 200: - logger.warning(f"Internet archive failed with status of {r.status_code}") - return ArchiveResult(status="Internet archive failed", screenshot=screenshot, wacz=wacz) - - if 'job_id' not in r.json() and 'message' in r.json(): - return self.custom_retry(r.json(), screenshot=screenshot, wacz=wacz) - - job_id = r.json()['job_id'] - logger.debug(f"GETting status for {job_id=} on {url=}") - status_r = requests.get(f'https://web.archive.org/save/status/{job_id}', headers=ia_headers) - retries = 0 - - # TODO: make the job queue parallel -> consider propagation of results back to sheet though - # wait 90-120 seconds for the archive job to finish - while (status_r.status_code != 200 or status_r.json()['status'] == 'pending') and retries < 30: - time.sleep(3) - try: - logger.debug(f"GETting status for {job_id=} on {url=} [{retries=}]") - status_r = requests.get(f'https://web.archive.org/save/status/{job_id}', headers=ia_headers) - except: - time.sleep(1) - retries += 1 - - if status_r.status_code != 200: - return ArchiveResult(status=f"Internet archive failed: check https://web.archive.org/save/status/{job_id}", screenshot=screenshot, wacz=wacz) - - status_json = status_r.json() - if status_json['status'] != 'success': - return self.custom_retry(status_json, screenshot=screenshot, wacz=wacz) - - archive_url = f"https://web.archive.org/web/{status_json['timestamp']}/{status_json['original_url']}" - - try: - req = requests.get(archive_url) - parsed = BeautifulSoup(req.content, 'html.parser') - title = parsed.find_all('title')[0].text - if title == 'Wayback Machine': - title = 'Could not get title' - except: - title = "Could not get title" - self.seen_urls[url] = ArchiveResult(status='success', cdn_url=archive_url, title=title, screenshot=screenshot, wacz=wacz) - return self.seen_urls[url] - - def custom_retry(self, json_data, **kwargs): - logger.warning(f"Internet archive failed json \n {json_data}") - if "please try again" in str(json_data).lower(): - return self.signal_retry_in(**kwargs) - if "this host has been already captured" in str(json_data).lower(): - return self.signal_retry_in(**kwargs, min_seconds=86400, max_seconds=129600) # 24h to 36h later - return ArchiveResult(status=f"Internet archive failed: {json_data}", **kwargs) diff --git a/archivers/youtubedl_archiver.py b/archivers/youtubedl_archiver.py deleted file mode 100644 index 5d09442..0000000 --- a/archivers/youtubedl_archiver.py +++ /dev/null @@ -1,118 +0,0 @@ - -import os, datetime - -import yt_dlp -from loguru import logger - -from .base_archiver import Archiver, ArchiveResult -from storages import Storage -from configs import Config - - -class YoutubeDLArchiver(Archiver): - name = "youtube_dl" - ydl_opts = {'outtmpl': f'{Storage.TMP_FOLDER}%(id)s.%(ext)s', 'quiet': False} - - def __init__(self, storage: Storage, config: Config): - super().__init__(storage, config) - self.fb_cookie = config.facebook_cookie - - def download(self, url, check_if_exists=False): - netloc = self.get_netloc(url) - if netloc in ['facebook.com', 'www.facebook.com'] and self.fb_cookie: - logger.debug('Using Facebook cookie') - yt_dlp.utils.std_headers['cookie'] = self.fb_cookie - - ydl = yt_dlp.YoutubeDL(YoutubeDLArchiver.ydl_opts) - cdn_url = None - status = 'success' - - try: - info = ydl.extract_info(url, download=False) - except yt_dlp.utils.DownloadError as e: - logger.debug(f'No video - Youtube normal control flow: {e}') - return False - except Exception as e: - logger.debug(f'ytdlp exception which is normal for example a facebook page with images only will cause a IndexError: list index out of range. Exception here is: \n {e}') - return False - - if info.get('is_live', False): - logger.warning("Live streaming media, not archiving now") - return ArchiveResult(status="Streaming media") - - if 'twitter.com' in netloc: - if 'https://twitter.com/' in info['webpage_url']: - logger.info('Found https://twitter.com/ in the download url from Twitter') - else: - logger.info('Found a linked video probably in a link in a tweet - not getting that video as there may be images in the tweet') - return False - - if check_if_exists: - if 'entries' in info: - if len(info['entries']) > 1: - logger.warning('YoutubeDLArchiver succeeded but cannot archive channels or pages with multiple videos') - return False - elif len(info['entries']) == 0: - logger.warning( - 'YoutubeDLArchiver succeeded but did not find video') - return False - - filename = ydl.prepare_filename(info['entries'][0]) - else: - filename = ydl.prepare_filename(info) - - key = self.get_key(filename) - - if self.storage.exists(key): - status = 'already archived' - cdn_url = self.storage.get_cdn_url(key) - - # sometimes this results in a different filename, so do this again - info = ydl.extract_info(url, download=True) - - # TODO: add support for multiple videos - if 'entries' in info: - if len(info['entries']) > 1: - logger.warning( - 'YoutubeDLArchiver cannot archive channels or pages with multiple videos') - return False - else: - info = info['entries'][0] - - filename = ydl.prepare_filename(info) - - if not os.path.exists(filename): - filename = filename.split('.')[0] + '.mkv' - - if status != 'already archived': - key = self.get_key(filename) - self.storage.upload(filename, key) - - # filename ='tmp/sDE-qZdi8p8.webm' - # key ='SM0022/youtube_dl_sDE-qZdi8p8.webm' - cdn_url = self.storage.get_cdn_url(key) - - hash = self.get_hash(filename) - screenshot = self.get_screenshot(url) - wacz = self.get_wacz(url) - - # get duration - duration = info.get('duration') - - # get thumbnails - try: - key_thumb, thumb_index = self.get_thumbnails(filename, key, duration=duration) - except: - key_thumb = '' - thumb_index = 'Could not generate thumbnails' - - os.remove(filename) - - timestamp = None - if 'timestamp' in info and info['timestamp'] is not None: - timestamp = datetime.datetime.utcfromtimestamp(info['timestamp']).replace(tzinfo=datetime.timezone.utc).isoformat() - elif 'upload_date' in info and info['upload_date'] is not None: - timestamp = datetime.datetime.strptime(info['upload_date'], '%Y%m%d').replace(tzinfo=datetime.timezone.utc) - - return ArchiveResult(status=status, cdn_url=cdn_url, thumbnail=key_thumb, thumbnail_index=thumb_index, duration=duration, - title=info['title'] if 'title' in info else None, timestamp=timestamp, hash=hash, screenshot=screenshot, wacz=wacz) diff --git a/auto_archive.py b/auto_archive.py deleted file mode 100644 index 50719a3..0000000 --- a/auto_archive.py +++ /dev/null @@ -1,170 +0,0 @@ -import os, datetime, traceback, random, tempfile - -from loguru import logger -from slugify import slugify -from urllib.parse import quote - -from archivers import TelethonArchiver, TelegramArchiver, TiktokArchiver, YoutubeDLArchiver, TwitterArchiver, TwitterApiArchiver, VkArchiver, WaybackArchiver, ArchiveResult, Archiver -from utils import GWorksheet, mkdir_if_not_exists, expand_url -from configs import Config -from storages import Storage - -random.seed() - - -def update_sheet(gw, row, url, result: ArchiveResult): - cell_updates = [] - row_values = gw.get_row(row) - - def batch_if_valid(col, val, final_value=None): - final_value = final_value or val - if val and gw.col_exists(col) and gw.get_cell(row_values, col) == '': - cell_updates.append((row, col, final_value)) - - cell_updates.append((row, 'status', result.status)) - - batch_if_valid('archive', result.cdn_url) - batch_if_valid('date', True, datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat()) - batch_if_valid('thumbnail', result.thumbnail, f'=IMAGE("{result.thumbnail}")') - batch_if_valid('thumbnail_index', result.thumbnail_index) - batch_if_valid('title', result.title) - batch_if_valid('duration', result.duration, str(result.duration)) - batch_if_valid('screenshot', result.screenshot) - batch_if_valid('hash', result.hash) - if result.wacz is not None: - batch_if_valid('wacz', result.wacz) - batch_if_valid('replaywebpage', f'https://replayweb.page/?source={quote(result.wacz)}#view=pages&url={quote(url)}') - - if result.timestamp is not None: - if type(result.timestamp) == int: - timestamp_string = datetime.datetime.fromtimestamp(result.timestamp).replace(tzinfo=datetime.timezone.utc).isoformat() - elif type(result.timestamp) == str: - timestamp_string = result.timestamp - else: - timestamp_string = result.timestamp.isoformat() - - batch_if_valid('timestamp', timestamp_string) - - gw.batch_set_cell(cell_updates) - - -def missing_required_columns(gw: GWorksheet): - missing = False - for required_col in ['url', 'status']: - if not gw.col_exists(required_col): - logger.warning(f'Required column for {required_col}: "{gw.columns[required_col]}" not found, skipping worksheet {gw.wks.title}') - missing = True - return missing - - -def should_process_sheet(c, sheet_name): - if len(c.worksheet_allow) and sheet_name not in c.worksheet_allow: - # ALLOW rules exist AND sheet name not explicitly allowed - return False - if len(c.worksheet_block) and sheet_name in c.worksheet_block: - # BLOCK rules exist AND sheet name is blocked - return False - return True - - -def process_sheet(c: Config): - sh = c.gsheets_client.open(c.sheet) - - # loop through worksheets to check - for ii, wks in enumerate(sh.worksheets()): - if not should_process_sheet(c, wks.title): - logger.info(f'Ignoring worksheet "{wks.title}" due to allow/block configurations') - continue - - logger.info(f'Opening worksheet {ii=}: {wks.title=} {c.header=}') - gw = GWorksheet(wks, header_row=c.header, columns=c.column_names) - - if missing_required_columns(gw): continue - - # archives will default to being in a folder 'doc_name/worksheet_name' - default_folder = os.path.join(slugify(c.sheet), slugify(wks.title)) - c.set_folder(default_folder) - storage = c.get_storage() - - # loop through rows in worksheet - for row in range(1 + c.header, gw.count_rows() + 1): - url = gw.get_cell(row, 'url') - original_status = gw.get_cell(row, 'status') - status = gw.get_cell(row, 'status', fresh=original_status in ['', None] and url != '') - - is_retry = False - if url == '' or status not in ['', None]: - is_retry = Archiver.should_retry_from_status(status) - if not is_retry: continue - - # All checks done - archival process starts here - try: - gw.set_cell(row, 'status', 'Archive in progress') - url = expand_url(url) - c.set_folder(gw.get_cell_or_default(row, 'folder', default_folder, when_empty_use_default=True)) - - # make a new driver so each spreadsheet row is idempotent - c.recreate_webdriver() - - # order matters, first to succeed excludes remaining - active_archivers = [ - TelethonArchiver(storage, c), - TiktokArchiver(storage, c), - TwitterApiArchiver(storage, c), - YoutubeDLArchiver(storage, c), - TelegramArchiver(storage, c), - TwitterArchiver(storage, c), - VkArchiver(storage, c), - WaybackArchiver(storage, c) - ] - - for archiver in active_archivers: - logger.debug(f'Trying {archiver} on {row=}') - - try: - result = archiver.download(url, check_if_exists=c.check_if_exists) - except KeyboardInterrupt as e: raise e # so the higher level catch can catch it - except Exception as e: - result = False - logger.error(f'Got unexpected error in row {row} with {archiver.name} for {url=}: {e}\n{traceback.format_exc()}') - - if result: - success = result.status in ['success', 'already archived'] - result.status = f"{archiver.name}: {result.status}" - if success: - logger.success(f'{archiver.name} succeeded on {row=}, {url=}') - break - # only 1 retry possible for now - if is_retry and Archiver.is_retry(result.status): - result.status = Archiver.remove_retry(result.status) - logger.warning(f'{archiver.name} did not succeed on {row=}, final status: {result.status}') - - if result: - update_sheet(gw, row, url, result) - else: - gw.set_cell(row, 'status', 'failed: no archiver') - except KeyboardInterrupt: - # catches keyboard interruptions to do a clean exit - logger.warning(f"caught interrupt on {row=}, {url=}") - gw.set_cell(row, 'status', '') - c.destroy_webdriver() - exit() - except Exception as e: - logger.error(f'Got unexpected error in row {row} for {url=}: {e}\n{traceback.format_exc()}') - gw.set_cell(row, 'status', 'failed: unexpected error (see logs)') - logger.success(f'Finished worksheet {wks.title}') - - -@logger.catch -def main(): - c = Config() - c.parse() - logger.info(f'Opening document {c.sheet} for header {c.header}') - with tempfile.TemporaryDirectory(dir="./") as tmpdir: - Storage.TMP_FOLDER = tmpdir - process_sheet(c) - c.destroy_webdriver() - - -if __name__ == '__main__': - main() diff --git a/auto_auto_archive.py b/auto_auto_archive.py deleted file mode 100644 index 8c794c5..0000000 --- a/auto_auto_archive.py +++ /dev/null @@ -1,29 +0,0 @@ -import tempfile -import auto_archive -from loguru import logger -from configs import Config -from storages import Storage - - -def main(): - c = Config() - c.parse() - logger.info(f'Opening document {c.sheet} to look for sheet names to archive') - - gc = c.gsheets_client - sh = gc.open(c.sheet) - - wks = sh.get_worksheet(0) - values = wks.get_all_values() - - with tempfile.TemporaryDirectory(dir="./") as tmpdir: - Storage.TMP_FOLDER = tmpdir - for i in range(11, len(values)): - c.sheet = values[i][0] - logger.info(f"Processing {c.sheet}") - auto_archive.process_sheet(c) - c.destroy_webdriver() - - -if __name__ == "__main__": - main() diff --git a/configs/__init__.py b/configs/__init__.py deleted file mode 100644 index 6940ed3..0000000 --- a/configs/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .config import Config -from .selenium_config import SeleniumConfig -from .telethon_config import TelethonConfig -from .wayback_config import WaybackConfig -from .twitter_api_config import TwitterApiConfig -from .vk_config import VkConfig \ No newline at end of file diff --git a/configs/browsertrix_config.py b/configs/browsertrix_config.py deleted file mode 100644 index bc7acd5..0000000 --- a/configs/browsertrix_config.py +++ /dev/null @@ -1,7 +0,0 @@ -from dataclasses import dataclass - -@dataclass -class BrowsertrixConfig: - enabled: bool - profile: str - timeout_seconds: str diff --git a/configs/config.py b/configs/config.py deleted file mode 100644 index 6e97dc4..0000000 --- a/configs/config.py +++ /dev/null @@ -1,291 +0,0 @@ -import argparse, yaml, json, os -import gspread -from loguru import logger -from selenium import webdriver -from dataclasses import asdict -from selenium.common.exceptions import TimeoutException - -from utils import GWorksheet, getattr_or -from .wayback_config import WaybackConfig -from .telethon_config import TelethonConfig -from .selenium_config import SeleniumConfig -from .vk_config import VkConfig -from .twitter_api_config import TwitterApiConfig -from .browsertrix_config import BrowsertrixConfig -from storages import S3Config, S3Storage, GDStorage, GDConfig, LocalStorage, LocalConfig - - -class Config: - """ - Controls the current execution parameters and manages API configurations - Usage: - c = Config() # initializes the argument parser - c.parse() # parses the values and initializes the Services and API clients - # you can then access the Services and APIs like 'c.s3_config' - All the configurations available as cmd line options, when included, will - override the configurations in the config.yaml file. - Configurations are split between: - 1. "secrets" containing API keys for generating services - not kept in memory - 2. "execution" containing specific execution configurations - """ - AVAILABLE_STORAGES = {"s3", "gd", "local"} - - def __init__(self): - self.parser = self.get_argument_parser() - self.folder = "" - - def parse(self): - self.args = self.parser.parse_args() - logger.success(f'Command line arguments parsed successfully') - self.config_file = self.args.config - self.read_config_yaml() - logger.info(f'APIs and Services initialized:\n{self}') - - def read_config_yaml(self): - with open(self.config_file, "r", encoding="utf-8") as inf: - self.config = yaml.safe_load(inf) - - # ----------------------EXECUTION - execution configurations - execution = self.config.get("execution", {}) - - self.sheet = getattr_or(self.args, "sheet", execution.get("sheet")) - assert self.sheet is not None, "'sheet' must be provided either through command line or configuration file" - - def ensure_set(l): - # always returns a set of strings, can receive a set or a string - l = l if isinstance(l, list) else [l] - return set([x for x in l if isinstance(x, str) and len(x) > 0]) - self.worksheet_allow = ensure_set(execution.get("worksheet_allow", [])) - self.worksheet_block = ensure_set(execution.get("worksheet_block", [])) - - self.header = int(getattr_or(self.args, "header", execution.get("header", 1))) - self.storage = getattr_or(self.args, "storage", execution.get("storage", "s3")) - self.save_logs = getattr(self.args, "save_logs") or execution.get("save_logs", False) - if self.save_logs: - self.set_log_files() - self.check_if_exists = getattr(self.args, "check_if_exists") or execution.get("check_if_exists", False) - - # Column names come from config and can be overwritten by CMD - # in the end all are considered as lower case - config_column_names = execution.get("column_names", {}) - self.column_names = {} - for k in GWorksheet.COLUMN_NAMES.keys(): - self.column_names[k] = getattr_or(self.args, k, config_column_names.get(k, GWorksheet.COLUMN_NAMES[k])).lower() - - # selenium driver - selenium_configs = execution.get("selenium", {}) - self.selenium_config = SeleniumConfig( - timeout_seconds=int(selenium_configs.get("timeout_seconds", SeleniumConfig.timeout_seconds)), - window_width=int(selenium_configs.get("window_width", SeleniumConfig.window_width)), - window_height=int(selenium_configs.get("window_height", SeleniumConfig.window_height)) - ) - self.webdriver = "not initialized" - - # browsertrix config - browsertrix_configs = execution.get("browsertrix", {}) - if len(browsertrix_profile := browsertrix_configs.get("profile", "")): - browsertrix_profile = os.path.abspath(browsertrix_profile) - self.browsertrix_config = BrowsertrixConfig( - enabled=bool(browsertrix_configs.get("enabled", False)), - profile=browsertrix_profile, - timeout_seconds=browsertrix_configs.get("timeout_seconds", "90") - ) - - self.hash_algorithm = execution.get("hash_algorithm", "SHA-256") - - # ---------------------- SECRETS - APIs and service configurations - secrets = self.config.get("secrets", {}) - - # assert selected storage credentials exist - for key, name in [("s3", "s3"), ("gd", "google_drive"), ("local", "local")]: - assert self.storage != key or name in secrets, f"selected storage '{key}' requires secrets.'{name}' in {self.config_file}" - - # google sheets config - self.gsheets_client = gspread.service_account( - filename=secrets.get("google_sheets", {}).get("service_account", 'service_account.json') - ) - - # facebook config - self.facebook_cookie = secrets.get("facebook", {}).get("cookie", None) - - # s3 config - if "s3" in secrets: - s3 = secrets["s3"] - self.s3_config = S3Config( - bucket=s3["bucket"], - region=s3["region"], - key=s3["key"], - secret=s3["secret"], - endpoint_url=s3.get("endpoint_url", S3Config.endpoint_url), - cdn_url=s3.get("cdn_url", S3Config.cdn_url), - key_path=s3.get("key_path", S3Config.key_path), - private=getattr_or(self.args, "s3-private", s3.get("private", S3Config.private)) - ) - - # GDrive config - if "google_drive" in secrets: - gd = secrets["google_drive"] - self.gd_config = GDConfig( - root_folder_id=gd.get("root_folder_id"), - oauth_token_filename=gd.get("oauth_token_filename"), - service_account=gd.get("service_account", GDConfig.service_account) - ) - - if "local" in secrets: - self.local_config = LocalConfig( - save_to=secrets["local"].get("save_to", LocalConfig.save_to), - ) - - # wayback machine config - if "wayback" in secrets: - self.wayback_config = WaybackConfig( - key=secrets["wayback"]["key"], - secret=secrets["wayback"]["secret"], - ) - else: - self.wayback_config = None - logger.debug(f"'wayback' key not present in the {self.config_file=}") - - # telethon config - if "telegram" in secrets: - self.telegram_config = TelethonConfig( - api_id=secrets["telegram"]["api_id"], - api_hash=secrets["telegram"]["api_hash"], - bot_token=secrets["telegram"].get("bot_token", None) - ) - else: - self.telegram_config = None - logger.debug(f"'telegram' key not present in the {self.config_file=}") - - # twitter config - if "twitter" in secrets: - self.twitter_config = TwitterApiConfig( - bearer_token=secrets["twitter"].get("bearer_token"), - consumer_key=secrets["twitter"].get("consumer_key"), - consumer_secret=secrets["twitter"].get("consumer_secret"), - access_token=secrets["twitter"].get("access_token"), - access_secret=secrets["twitter"].get("access_secret"), - ) - else: - self.twitter_config = None - logger.debug(f"'twitter' key not present in the {self.config_file=}") - - # vk config - if "vk" in secrets: - self.vk_config = VkConfig( - username=secrets["vk"]["username"], - password=secrets["vk"]["password"] - ) - else: - self.vk_config = None - logger.debug(f"'vk' key not present in the {self.config_file=}") - - del self.config["secrets"] # delete to prevent leaks - - def set_log_files(self): - # called only when config.execution.save_logs=true - logger.add("logs/1trace.log", level="TRACE") - logger.add("logs/2info.log", level="INFO") - logger.add("logs/3success.log", level="SUCCESS") - logger.add("logs/4warning.log", level="WARNING") - logger.add("logs/5error.log", level="ERROR") - - def get_argument_parser(self): - """ - Creates the CMD line arguments. 'python auto_archive.py --help' - """ - parser = argparse.ArgumentParser(description='Automatically archive social media posts, videos, and images from a Google Sheets document. The command line arguments will always override the configurations in the provided YAML config file (--config), only some high-level options are allowed via the command line and the YAML configuration file is the preferred method. The sheet must have the "url" and "status" for the archiver to work. ') - - parser.add_argument('--config', action='store', dest='config', help='the filename of the YAML configuration file (defaults to \'config.yaml\')', default='config.yaml') - parser.add_argument('--storage', action='store', dest='storage', help='which storage to use [execution.storage in config.yaml]', choices=Config.AVAILABLE_STORAGES) - parser.add_argument('--sheet', action='store', dest='sheet', help='the name of the google sheets document [execution.sheet in config.yaml]') - parser.add_argument('--header', action='store', dest='header', help='1-based index for the header row [execution.header in config.yaml]') - parser.add_argument('--check-if-exists', action='store_true', dest='check_if_exists', help='when possible checks if the URL has been archived before and does not archive the same URL twice [exceution.check_if_exists]') - parser.add_argument('--save-logs', action='store_true', dest='save_logs', help='creates or appends execution logs to files logs/LEVEL.log [exceution.save_logs]') - parser.add_argument('--s3-private', action='store_true', help='Store content without public access permission (only for storage=s3) [secrets.s3.private in config.yaml]') - - for k, v in GWorksheet.COLUMN_NAMES.items(): - help = f"the name of the column to FILL WITH {k} (default='{v}')" - if k in ["url", "folder"]: - help = f"the name of the column to READ {k} FROM (default='{v}')" - parser.add_argument(f'--col-{k}', action='store', dest=k, help=help) - - return parser - - def set_folder(self, folder): - """ - update the folder in each of the storages - """ - self.folder = folder - logger.info(f"setting folder to {folder}") - # s3 - if hasattr(self, "s3_config"): self.s3_config.folder = folder - if hasattr(self, "s3_storage"): self.s3_storage.folder = folder - # gdrive - if hasattr(self, "gd_config"): self.gd_config.folder = folder - if hasattr(self, "gd_storage"): self.gd_storage.folder = folder - # local - if hasattr(self, "local_config"): self.local_config.folder = folder - if hasattr(self, "local_storage"): self.local_storage.folder = folder - - def get_storage(self): - """ - returns the configured type of storage, creating if needed - """ - if self.storage == "s3": - self.s3_storage = getattr_or(self, "s3_storage", S3Storage(self.s3_config)) - return self.s3_storage - elif self.storage == "gd": - self.gd_storage = getattr_or(self, "gd_storage", GDStorage(self.gd_config)) - return self.gd_storage - elif self.storage == "local": - self.local_storage = getattr_or(self, "local_storage", LocalStorage(self.local_config)) - return self.local_storage - raise f"storage {self.storage} not implemented, available: {Config.AVAILABLE_STORAGES}" - - def destroy_webdriver(self): - if self.webdriver is not None and type(self.webdriver) != str: - self.webdriver.close() - self.webdriver.quit() - del self.webdriver - - def recreate_webdriver(self): - options = webdriver.FirefoxOptions() - options.headless = True - options.set_preference('network.protocol-handler.external.tg', False) - try: - new_webdriver = webdriver.Firefox(options=options) - # only destroy if creation is successful - self.destroy_webdriver() - self.webdriver = new_webdriver - self.webdriver.set_window_size(self.selenium_config.window_width, - self.selenium_config.window_height) - self.webdriver.set_page_load_timeout(self.selenium_config.timeout_seconds) - except TimeoutException as e: - logger.error(f"failed to get new webdriver, possibly due to insufficient system resources or timeout settings: {e}") - - def __str__(self) -> str: - return json.dumps({ - "config_file": self.config_file, - "sheet": self.sheet, - "worksheet_allow": list(self.worksheet_allow), - "worksheet_block": list(self.worksheet_block), - "storage": self.storage, - "header": self.header, - "check_if_exists": self.check_if_exists, - "hash_algorithm": self.hash_algorithm, - "browsertrix_config": asdict(self.browsertrix_config), - "save_logs": self.save_logs, - "selenium_config": asdict(self.selenium_config), - "selenium_webdriver": self.webdriver != None, - "s3_config": hasattr(self, "s3_config"), - "s3_private": getattr_or(getattr(self, "s3_config", {}), "private", None), - "gd_config": hasattr(self, "gd_config"), - "local_config": hasattr(self, "local_config"), - "wayback_config": self.wayback_config != None, - "telegram_config": self.telegram_config != None, - "twitter_config": self.twitter_config != None, - "vk_config": self.vk_config != None, - "gsheets_client": self.gsheets_client != None, - "column_names": self.column_names, - }, ensure_ascii=False, indent=4) diff --git a/configs/selenium_config.py b/configs/selenium_config.py deleted file mode 100644 index 8e060af..0000000 --- a/configs/selenium_config.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class SeleniumConfig: - timeout_seconds: int = 120 - window_width: int = 1400 - window_height: int = 2000 diff --git a/configs/telethon_config.py b/configs/telethon_config.py deleted file mode 100644 index 3099bb5..0000000 --- a/configs/telethon_config.py +++ /dev/null @@ -1,9 +0,0 @@ - -from dataclasses import dataclass - - -@dataclass -class TelethonConfig: - api_id: str - api_hash: str - bot_token: str diff --git a/configs/twitter_api_config.py b/configs/twitter_api_config.py deleted file mode 100644 index 4193111..0000000 --- a/configs/twitter_api_config.py +++ /dev/null @@ -1,11 +0,0 @@ - -from dataclasses import dataclass - - -@dataclass -class TwitterApiConfig: - bearer_token: str - consumer_key: str - consumer_secret: str - access_token: str - access_secret: str diff --git a/configs/vk_config.py b/configs/vk_config.py deleted file mode 100644 index db2e61c..0000000 --- a/configs/vk_config.py +++ /dev/null @@ -1,8 +0,0 @@ - -from dataclasses import dataclass - - -@dataclass -class VkConfig: - username: str - password: str diff --git a/configs/wayback_config.py b/configs/wayback_config.py deleted file mode 100644 index 7770f66..0000000 --- a/configs/wayback_config.py +++ /dev/null @@ -1,8 +0,0 @@ - -from dataclasses import dataclass - - -@dataclass -class WaybackConfig: - key: str - secret: str diff --git a/docs/auto-auto.png b/docs/auto-auto.png deleted file mode 100644 index 0a1662b..0000000 Binary files a/docs/auto-auto.png and /dev/null differ diff --git a/docs/demo-after.png b/docs/demo-after.png index 39bdfe4..d54a9ce 100644 Binary files a/docs/demo-after.png and b/docs/demo-after.png differ diff --git a/docs/demo-archive.png b/docs/demo-archive.png new file mode 100644 index 0000000..70199a3 Binary files /dev/null and b/docs/demo-archive.png differ diff --git a/docs/demo-before.png b/docs/demo-before.png index 1a29310..39e90bb 100644 Binary files a/docs/demo-before.png and b/docs/demo-before.png differ diff --git a/docs/demo-progress.png b/docs/demo-progress.png index 5ce0b27..dbcd376 100644 Binary files a/docs/demo-progress.png and b/docs/demo-progress.png differ diff --git a/example.config.yaml b/example.config.yaml deleted file mode 100644 index e42d10f..0000000 --- a/example.config.yaml +++ /dev/null @@ -1,133 +0,0 @@ ---- -secrets: - # needed if you use storage=s3 - s3: - # contains S3 info on region, bucket, key and secret - region: reg1 - bucket: my-bucket - key: "s3 API key" - secret: "s3 API secret" - # use region format like such - endpoint_url: "https://{region}.digitaloceanspaces.com" - # endpoint_url: "https://s3.{region}.amazonaws.com" - #use bucket, region, and key (key is the archived file path generated when executing) format like such as: - cdn_url: "https://{bucket}.{region}.cdn.digitaloceanspaces.com/{key}" - # if private:true S3 urls will not be readable online - private: false - # with 'random' you can generate a random UUID for the URL instead of a predictable path, useful to still have public but unlisted files, alternative is 'default' or not omitted from config - key_path: random - - # needed if you use storage=gd - google_drive: - # To authenticate with google you have two options (1. service account OR 2. OAuth token) - - # 1. service account - storage space will count towards the developer account - # filename can be the same or different file from google_sheets.service_account, defaults to "service_account.json" - # service_account: "service_account.json" - - # 2. OAuth token - storage space will count towards the owner of the GDrive folder - # (only 1. or 2. - if both specified then this 2. takes precedence) - # needs write access on the server so refresh flow works - # To get the token, run the file `create_update_test_oauth_token.py` - # you can edit that file if you want a different token filename, default is "gd-token.json" - oauth_token_filename: "gd-token.json" - - root_folder_id: copy XXXX from https://drive.google.com/drive/folders/XXXX - - # needed if you use storage=local - local: - # local path to save files in - save_to: "./local_archive" - - wayback: - # to get credentials visit https://archive.org/account/s3.php - key: your API key - secret: your API secret - - telegram: - # to get credentials see: https://telegra.ph/How-to-get-Telegram-APP-ID--API-HASH-05-27 - api_id: your API key, see - api_hash: your API hash - # optional, but allows access to more content such as large videos, talk to @botfather - bot_token: your bot-token - - # twitter configuration - API V2 only - # if you don't provide credentials the less-effective unofficial TwitterArchiver will be used instead - twitter: - # either bearer_token only - bearer_token: "" - # OR all of the below - consumer_key: "" - consumer_secret: "" - access_token: "" - access_secret: "" - - # vkontakte (vk.com) credentials - vk: - username: "phone number or email" - password: "password" - - google_sheets: - # local filename: defaults to service_account.json, see https://gspread.readthedocs.io/en/latest/oauth2.html#for-bots-using-service-account - service_account: "service_account.json" - - facebook: - # optional facebook cookie to have more access to content, from browser, looks like 'cookie: datr= xxxx' - cookie: "" -execution: - # can be overwritten with CMD --sheet= - sheet: your-sheet-name - - # block or allow worksheets by name, instead of defaulting to checking all worksheets in a Spreadsheet - # worksheet_allow and worksheet_block can be single values or lists - # if worksheet_allow is specified, worksheet_block is ignored - # worksheet_allow: - # - Sheet1 - # - "Sheet 2" - # worksheet_block: BlockedSheet - - # which row of your tabs contains the header, can be overwritten with CMD --header= - header: 1 - # which storage to use, can be overwritten with CMD --storage= - storage: s3 - # defaults to false, when true will try to avoid duplicate URL archives - check_if_exists: true - - # choose a hash algorithm (either SHA-256 or SHA3-512, defaults to SHA-256) - # hash_algorithm: SHA-256 - - # optional configurations for the selenium browser that takes screenshots, these are the defaults - selenium: - # values under 10s might mean screenshots fail to grab screenshot - timeout_seconds: 120 - window_width: 1400 - window_height: 2000 - - # optional browsertrix configuration (for profile generation see https://github.com/webrecorder/browsertrix-crawler#creating-and-using-browser-profiles) - # browsertrix will capture a WACZ archive of the page which can then be seen as the original on replaywebpage - browsertrix: - enabled: true # defaults to false - profile: "./browsertrix/crawls/profile.tar.gz" - timeout_seconds: 120 # defaults to 90s - # puts execution logs into /logs folder, defaults to false - save_logs: true - # custom column names, only needed if different from default, can be overwritten with CMD --col-NAME="VALUE" - # url and status are the only columns required to be present in the google sheet - column_names: - url: link - status: archive status - archive: archive location - # use this column to override default location data - folder: folder - date: archive date - thumbnail: thumbnail - thumbnail_index: thumbnail index - timestamp: upload timestamp - title: upload title - duration: duration - screenshot: screenshot - hash: hash - wacz: wacz - # if you want the replaypage to work, make sure to allow CORS on your bucket - replaywebpage: replaywebpage - diff --git a/example.orchestration.yaml b/example.orchestration.yaml new file mode 100644 index 0000000..19082d8 --- /dev/null +++ b/example.orchestration.yaml @@ -0,0 +1,121 @@ +steps: + # only 1 feeder allowed + feeder: gsheet_feeder # defaults to cli_feeder + archivers: # order matters, uncomment to activate + # - vk_archiver + # - telethon_archiver + # - telegram_archiver + # - twitter_archiver + # - twitter_api_archiver + # - instagram_tbot_archiver + # - instagram_archiver + # - tiktok_archiver + - youtubedl_archiver + - wayback_archiver_enricher + enrichers: + - hash_enricher + # - screenshot_enricher + # - thumbnail_enricher + # - wayback_archiver_enricher + # - wacz_enricher + + formatter: html_formatter # defaults to mute_formatter + storages: + - local_storage + # - s3_storage + # - gdrive_storage + databases: + - console_db + # - csv_db + # - gsheet_db + # - mongo_db + +configurations: + gsheet_feeder: + sheet: "your sheet name" + header: 1 + service_account: "secrets/service_account.json" + # allow_worksheets: "only parse this worksheet" + # block_worksheets: "blocked sheet 1,blocked sheet 2" + use_sheet_names_in_stored_paths: false + columns: + url: link + status: archive status + folder: destination folder + archive: archive location + date: archive date + thumbnail: thumbnail + timestamp: upload timestamp + title: upload title + text: textual content + screenshot: screenshot + hash: hash + wacz: wacz + replaywebpage: replaywebpage + instagram_tbot_archiver: + api_id: "TELEGRAM_BOT_API_ID" + api_hash: "TELEGRAM_BOT_API_HASH" + # session_file: "secrets/anon" + telethon_archiver: + api_id: "TELEGRAM_BOT_API_ID" + api_hash: "TELEGRAM_BOT_API_HASH" + # session_file: "secrets/anon" + join_channels: false + channel_invites: # if you want to archive from private channels + - invite: https://t.me/+123456789 + id: 0000000001 + - invite: https://t.me/+123456788 + id: 0000000002 + + twitter_api_archiver: + # either bearer_token only + bearer_token: "TWITTER_BEARER_TOKEN" + # OR all of the below + # consumer_key: "" + # consumer_secret: "" + # access_token: "" + # access_secret: "" + instagram_archiver: + username: "INSTAGRAM_USERNAME" + password: "INSTAGRAM_PASSWORD" + # session_file: "secrets/instaloader.session" + + vk_archiver: + username: "or phone number" + password: "vk pass" + session_file: "secrets/vk_config.v2.json" + + screenshot_enricher: + width: 1280 + height: 2300 + wayback_archiver_enricher: + timeout: 10 + key: "wayback key" + secret: "wayback secret" + hash_enricher: + algorithm: "SHA3-512" # can also be SHA-256 + wacz_enricher: + profile: secrets/profile.tar.gz + local_storage: + save_to: "./local_archive" + save_absolute: true + filename_generator: static + path_generator: flat + s3_storage: + bucket: your-bucket-name + region: reg1 + key: S3_KEY + secret: S3_SECRET + endpoint_url: "https://{region}.digitaloceanspaces.com" + cdn_url: "https://{bucket}.{region}.cdn.digitaloceanspaces.com/{key}" + # if private:true S3 urls will not be readable online + private: false + # with 'random' you can generate a random UUID for the URL instead of a predictable path, useful to still have public but unlisted files, alternative is 'default' or not omitted from config + key_path: random + + gdrive_storage: + path_generator: url + filename_generator: random + root_folder_id: folder_id_from_url + oauth_token: secrets/gd-token.json # needs to be generated with scripts/create_update_gdrive_oauth_token.py + service_account: "secrets/service_account.json" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b8719d0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires = ["setuptools", "wheel", "setuptools-pipfile"] +build-backend = "setuptools.build_meta" +[tool.setuptools-pipfile] diff --git a/create_update_test_oauth_token.py b/scripts/create_update_gdrive_oauth_token.py similarity index 57% rename from create_update_test_oauth_token.py rename to scripts/create_update_gdrive_oauth_token.py index 65b3086..ec8a120 100644 --- a/create_update_test_oauth_token.py +++ b/scripts/create_update_gdrive_oauth_token.py @@ -1,4 +1,5 @@ import os.path +import click, json from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials @@ -6,27 +7,42 @@ from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build from googleapiclient.errors import HttpError -# If creating for first time download the OAuth Client Ids json `credentials.json` from https://console.cloud.google.com/apis/credentials OAuth 2.0 Client IDs -# add "http://localhost:55192/" to the list of "Authorised redirect URIs" -# https://davemateer.com/2022/04/28/google-drive-with-python for more information - # You can run this code to get a new token and verify it belongs to the correct user # This token will be refresh automatically by the auto-archiver - # Code below from https://developers.google.com/drive/api/quickstart/python +# Example invocation: py scripts/create_update_gdrive_oauth_token.py -c secrets/credentials.json -t secrets/gd-token.json SCOPES = ['https://www.googleapis.com/auth/drive'] -def main(): - token_file = 'gd-token.json' - creds = None - +@click.command( + help="script to generate Google Drive OAuth token to use gdrive_storage, requires credentials.json and outputs gd-token.json, if you don't have credentials.json go to https://console.cloud.google.com/apis/credentials. Be sure to add 'http://localhost:55192/' to the Authorized redirect URIs in your OAuth App. More info: https://davemateer.com/2022/04/28/google-drive-with-python" +) +@click.option( + "--credentials", + "-c", + type=click.Path(exists=True), + help="path to the credentials.json file downloaded from https://console.cloud.google.com/apis/credentials", + required=True +) +@click.option( + "--token", + "-t", + type=click.Path(exists=False), + default="gd-token.json", + help="file where to place the OAuth token, defaults to gd-token.json which you must then move to where your orchestration file points to, defaults to gd-token.json", + required=True +) +def main(credentials, token): # The file token.json stores the user's access and refresh tokens, and is - # created automatically when the authorization flow completes for the first - # time. - if os.path.exists(token_file): - creds = Credentials.from_authorized_user_file(token_file, SCOPES) + # created automatically when the authorization flow completes for the first time. + creds = None + if os.path.exists(token): + with open(token, 'r') as stream: + creds_json = json.load(stream) + # creds = Credentials.from_authorized_user_file(creds_json, SCOPES) + creds_json['refresh_token'] = creds_json.get("refresh_token", "") + creds = Credentials.from_authorized_user_info(creds_json, SCOPES) # If there are no (valid) credentials available, let the user log in. if not creds or not creds.valid: @@ -36,10 +52,10 @@ def main(): else: print('First run through so putting up login dialog') # credentials.json downloaded from https://console.cloud.google.com/apis/credentials - flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES) + flow = InstalledAppFlow.from_client_secrets_file(credentials, SCOPES) creds = flow.run_local_server(port=55192) # Save the credentials for the next run - with open(token_file, 'w') as token: + with open(token, 'w') as token: print('Saving new token') token.write(creds.to_json()) else: diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..09e1fd8 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,19 @@ + +#!/bin/bash + +set -e + +TAG=$(python -c 'from src.auto_archiver.version import __version__; print("v" + __version__)') + +read -p "Creating new release for $TAG. Do you want to continue? [Y/n] " prompt + +if [[ $prompt == "y" || $prompt == "Y" || $prompt == "yes" || $prompt == "Yes" ]]; then + git add -A + git commit -m "Bump version to $TAG for release" || true && git push + echo "Creating new git tag $TAG" + git tag "$TAG" -m "$TAG" + git push --tags +else + echo "Cancelled" + exit 1 +fi \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2dc7f50 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,53 @@ +[metadata] +name = auto_archiver +version = attr: auto_archiver.version.__version__ +author = Bellingcat +author_email = tech@bellingcat.com +description = Easily archive online media content +long_description = file: README.md +long_description_content_type = text/markdown +keywords = archive, oosi, osint, scraping +license = MIT +classifiers = + Intended Audience :: Developers + Intended Audience :: Science/Research + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3 +project_urls = + Source Code = https://github.com/bellingcat/auto-archiver + Bug Tracker = https://github.com/bellingcat/auto-archiver/issues + Bellingcat = https://www.bellingcat.com +platforms = any + +[options] +setup_requires = + setuptools-pipfile +zip_safe = False +package_dir= + =src +packages=find: +find_packages=true +python_requires = >=3.8 + +[options.package_data] +* = *.html + +[options.entry_points] +console_scripts = + auto-archiver = auto_archiver.__main__:main + +# [options.extras_require] +# pdf = ReportLab>=1.2; RXP +# rest = docutils>=0.3; pack ==1.1, ==1.3 + +[options.packages.find] +where=src +# include=auto_archiver* +# exclude = +# examples* +# .eggs* +# build* +# secrets* +# tmp* +# docs* +# src.tests* \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..57c026b --- /dev/null +++ b/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + +if __name__ == "__main__": + setup() \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/auto_archiver/__init__.py b/src/auto_archiver/__init__.py new file mode 100644 index 0000000..e9fe79f --- /dev/null +++ b/src/auto_archiver/__init__.py @@ -0,0 +1,7 @@ +from . import archivers, databases, enrichers, feeders, formatters, storages, utils, core + +# need to manually specify due to cyclical deps +from .core.orchestrator import ArchivingOrchestrator +from .core.config import Config +# making accessible directly +from .core.metadata import Metadata diff --git a/src/auto_archiver/__main__.py b/src/auto_archiver/__main__.py new file mode 100644 index 0000000..e8a81c4 --- /dev/null +++ b/src/auto_archiver/__main__.py @@ -0,0 +1,12 @@ +from . import Config +from . import ArchivingOrchestrator + +def main(): + config = Config() + config.parse() + orchestrator = ArchivingOrchestrator(config) + orchestrator.feed() + + +if __name__ == "__main__": + main() diff --git a/src/auto_archiver/archivers/__init__.py b/src/auto_archiver/archivers/__init__.py new file mode 100644 index 0000000..f9cbb55 --- /dev/null +++ b/src/auto_archiver/archivers/__init__.py @@ -0,0 +1,10 @@ +from .archiver import Archiver +from .telethon_archiver import TelethonArchiver +from .twitter_archiver import TwitterArchiver +from .twitter_api_archiver import TwitterApiArchiver +from .instagram_archiver import InstagramArchiver +from .instagram_tbot_archiver import InstagramTbotArchiver +from .tiktok_archiver import TiktokArchiver +from .telegram_archiver import TelegramArchiver +from .vk_archiver import VkArchiver +from .youtubedl_archiver import YoutubeDLArchiver \ No newline at end of file diff --git a/src/auto_archiver/archivers/archiver.py b/src/auto_archiver/archivers/archiver.py new file mode 100644 index 0000000..419ee7a --- /dev/null +++ b/src/auto_archiver/archivers/archiver.py @@ -0,0 +1,64 @@ +from __future__ import annotations +from abc import abstractmethod +from dataclasses import dataclass +import os +import mimetypes, requests + +from ..core import Metadata, Step, ArchivingContext + + +@dataclass +class Archiver(Step): + name = "archiver" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + def init(name: str, config: dict) -> Archiver: + # only for typing... + return Step.init(name, config, Archiver) + + def setup(self) -> None: + # used when archivers need to login or do other one-time setup + pass + + def sanitize_url(self, url: str) -> str: + # used to clean unnecessary URL parameters OR unfurl redirect links + return url + + def is_rearchivable(self, url: str) -> bool: + # archivers can signal if it does not make sense to rearchive a piece of content + # default is rearchiving + return True + + def _guess_file_type(self, path: str) -> str: + """ + Receives a URL or filename and returns global mimetype like 'image' or 'video' + see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + """ + mime = mimetypes.guess_type(path)[0] + if mime is not None: + return mime.split("/")[0] + return "" + + def download_from_url(self, url: str, to_filename: str = None, item: Metadata = None) -> str: + """ + downloads a URL to provided filename, or inferred from URL, returns local filename, if item is present will use its tmp_dir + """ + if not to_filename: + to_filename = url.split('/')[-1].split('?')[0] + if len(to_filename) > 64: + to_filename = to_filename[-64:] + if item: + to_filename = os.path.join(ArchivingContext.get_tmp_dir(), to_filename) + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36' + } + d = requests.get(url, headers=headers) + with open(to_filename, 'wb') as f: + f.write(d.content) + return to_filename + + @abstractmethod + def download(self, item: Metadata) -> Metadata: pass diff --git a/src/auto_archiver/archivers/instagram_archiver.py b/src/auto_archiver/archivers/instagram_archiver.py new file mode 100644 index 0000000..97dd172 --- /dev/null +++ b/src/auto_archiver/archivers/instagram_archiver.py @@ -0,0 +1,143 @@ +import re, os, shutil, traceback +import instaloader # https://instaloader.github.io/as-module.html +from loguru import logger + +from . import Archiver +from ..core import Metadata +from ..core import Media + +class InstagramArchiver(Archiver): + """ + Uses Instaloader to download either a post (inc images, videos, text) or as much as possible from a profile (posts, stories, highlights, ...) + """ + name = "instagram_archiver" + + # NB: post regex should be tested before profile + # https://regex101.com/r/MGPquX/1 + post_pattern = re.compile(r"(?:(?:http|https):\/\/)?(?:www.)?(?:instagram.com|instagr.am|instagr.com)\/(?:p|reel)\/(\w+)") + # https://regex101.com/r/6Wbsxa/1 + profile_pattern = re.compile(r"(?:(?:http|https):\/\/)?(?:www.)?(?:instagram.com|instagr.am|instagr.com)\/(\w+)") + # TODO: links to stories + + def __init__(self, config: dict) -> None: + super().__init__(config) + # TODO: refactor how configuration validation is done + self.assert_valid_string("username") + self.assert_valid_string("password") + self.assert_valid_string("download_folder") + self.assert_valid_string("session_file") + self.insta = instaloader.Instaloader( + download_geotags=True, download_comments=True, compress_json=False, dirname_pattern=self.download_folder, filename_pattern="{date_utc}_UTC_{target}__{typename}" + ) + try: + self.insta.load_session_from_file(self.username, self.session_file) + except Exception as e: + logger.error(f"Unable to login from session file: {e}\n{traceback.format_exc()}") + try: + self.insta.login(self.username, config.instagram_self.password) + # TODO: wait for this issue to be fixed https://github.com/instaloader/instaloader/issues/1758 + self.insta.save_session_to_file(self.session_file) + except Exception as e2: + logger.error(f"Unable to finish login (retrying from file): {e2}\n{traceback.format_exc()}") + + @staticmethod + def configs() -> dict: + return { + "username": {"default": None, "help": "a valid Instagram username"}, + "password": {"default": None, "help": "the corresponding Instagram account password"}, + "download_folder": {"default": "instaloader", "help": "name of a folder to temporarily download content to"}, + "session_file": {"default": "secrets/instaloader.session", "help": "path to the instagram session which saves session credentials"}, + #TODO: fine-grain + # "download_stories": {"default": True, "help": "if the link is to a user profile: whether to get stories information"}, + } + + def download(self, item: Metadata) -> Metadata: + url = item.get_url() + + # detect URLs that we definitely cannot handle + post_matches = self.post_pattern.findall(url) + profile_matches = self.profile_pattern.findall(url) + + # return if not a valid instagram link + if not len(post_matches) and not len(profile_matches): return + + result = None + try: + os.makedirs(self.download_folder, exist_ok=True) + # process if post + if len(post_matches): + result = self.download_post(url, post_matches[0]) + # process if profile + elif len(profile_matches): + result = self.download_profile(url, profile_matches[0]) + except Exception as e: + logger.error(f"Failed to download with instagram archiver due to: {e}, make sure your account credentials are valid.") + finally: + shutil.rmtree(self.download_folder, ignore_errors=True) + return result + + def download_post(self, url: str, post_id: str) -> Metadata: + logger.debug(f"Instagram {post_id=} detected in {url=}") + + post = instaloader.Post.from_shortcode(self.insta.context, post_id) + if self.insta.download_post(post, target=post.owner_username): + return self.process_downloads(url, post.title, post._asdict(), post.date) + + def download_profile(self, url: str, username: str) -> Metadata: + # gets posts, posts where username is tagged, igtv postss, stories, and highlights + logger.debug(f"Instagram {username=} detected in {url=}") + + profile = instaloader.Profile.from_username(self.insta.context, username) + try: + for post in profile.get_posts(): + try: self.insta.download_post(post, target=f"profile_post_{post.owner_username}") + except Exception as e: logger.error(f"Failed to download post: {post.shortcode}: {e}") + except Exception as e: logger.error(f"Failed profile.get_posts: {e}") + + try: + for post in profile.get_tagged_posts(): + try: self.insta.download_post(post, target=f"tagged_post_{post.owner_username}") + except Exception as e: logger.error(f"Failed to download tagged post: {post.shortcode}: {e}") + except Exception as e: logger.error(f"Failed profile.get_tagged_posts: {e}") + + try: + for post in profile.get_igtv_posts(): + try: self.insta.download_post(post, target=f"igtv_post_{post.owner_username}") + except Exception as e: logger.error(f"Failed to download igtv post: {post.shortcode}: {e}") + except Exception as e: logger.error(f"Failed profile.get_igtv_posts: {e}") + + try: + for story in self.insta.get_stories([profile.userid]): + for item in story.get_items(): + try: self.insta.download_storyitem(item, target=f"story_item_{story.owner_username}") + except Exception as e: logger.error(f"Failed to download story item: {item}: {e}") + except Exception as e: logger.error(f"Failed get_stories: {e}") + + try: + for highlight in self.insta.get_highlights(profile.userid): + for item in highlight.get_items(): + try: self.insta.download_storyitem(item, target=f"highlight_item_{highlight.owner_username}") + except Exception as e: logger.error(f"Failed to download highlight item: {item}: {e}") + except Exception as e: logger.error(f"Failed get_highlights: {e}") + + return self.process_downloads(url, f"@{username}", profile._asdict(), None) + + def process_downloads(self, url, title, content, date): + result = Metadata() + result.set_title(title).set_content(str(content)).set_timestamp(date) + + try: + all_media = [] + for f in os.listdir(self.download_folder): + if os.path.isfile((filename := os.path.join(self.download_folder, f))): + if filename[-4:] == ".txt": continue + all_media.append(Media(filename)) + + assert len(all_media) > 1, "No uploaded media found" + all_media.sort(key=lambda m: m.filename, reverse=True) + for m in all_media: + result.add_media(m) + + return result.success("instagram") + except Exception as e: + logger.error(f"Could not fetch instagram post {url} due to: {e}") diff --git a/src/auto_archiver/archivers/instagram_tbot_archiver.py b/src/auto_archiver/archivers/instagram_tbot_archiver.py new file mode 100644 index 0000000..ee41247 --- /dev/null +++ b/src/auto_archiver/archivers/instagram_tbot_archiver.py @@ -0,0 +1,77 @@ + +from telethon.sync import TelegramClient +from loguru import logger +import time, os +from sqlite3 import OperationalError +from . import Archiver +from ..core import Metadata, Media, ArchivingContext + + +class InstagramTbotArchiver(Archiver): + """ + calls a telegram bot to fetch instagram posts/stories... and gets available media from it + https://github.com/adw0rd/instagrapi + https://t.me/instagram_load_bot + """ + name = "instagram_tbot_archiver" + + def __init__(self, config: dict) -> None: + super().__init__(config) + self.assert_valid_string("api_id") + self.assert_valid_string("api_hash") + self.timeout = int(self.timeout) + try: + self.client = TelegramClient(self.session_file, self.api_id, self.api_hash) + except OperationalError as e: + logger.error(f"Unable to access the {self.session_file} session, please make sure you don't use the same session file here and in telethon_archiver. if you do then disable at least one of the archivers for the 1st time you setup telethon session: {e}") + + @staticmethod + def configs() -> dict: + return { + "api_id": {"default": None, "help": "telegram API_ID value, go to https://my.telegram.org/apps"}, + "api_hash": {"default": None, "help": "telegram API_HASH value, go to https://my.telegram.org/apps"}, + "session_file": {"default": "secrets/anon-insta", "help": "optional, records the telegram login session for future usage, '.session' will be appended to the provided value."}, + "timeout": {"default": 45, "help": "timeout to fetch the instagram content in seconds."}, + } + + def setup(self) -> None: + logger.info(f"SETUP {self.name} checking login...") + with self.client.start(): + logger.success(f"SETUP {self.name} login works.") + + def download(self, item: Metadata) -> Metadata: + url = item.get_url() + if not "instagram.com" in url: return False + + result = Metadata() + tmp_dir = ArchivingContext.get_tmp_dir() + with self.client.start(): + chat = self.client.get_entity("instagram_load_bot") + since_id = self.client.send_message(entity=chat, message=url).id + + attempts = 0 + seen_media = [] + message = "" + time.sleep(3) + # media is added before text by the bot so it can be used as a stop-logic mechanism + while attempts < (self.timeout - 3) and (not message or not len(seen_media)): + attempts += 1 + time.sleep(1) + for post in self.client.iter_messages(chat, min_id=since_id): + since_id = max(since_id, post.id) + if post.media and post.id not in seen_media: + filename_dest = os.path.join(tmp_dir, f'{chat.id}_{post.id}') + media = self.client.download_media(post.media, filename_dest) + if media: + result.add_media(Media(media)) + seen_media.append(post.id) + if post.message: message += post.message + + if "You must enter a URL to a post" in message: + logger.debug(f"invalid link {url=} for {self.name}: {message}") + return False + + if message: + result.set_content(message).set_title(message[:128]) + + return result.success("insta-via-bot") diff --git a/src/auto_archiver/archivers/telegram_archiver.py b/src/auto_archiver/archivers/telegram_archiver.py new file mode 100644 index 0000000..6ddcf0e --- /dev/null +++ b/src/auto_archiver/archivers/telegram_archiver.py @@ -0,0 +1,76 @@ +import requests, re, html +from bs4 import BeautifulSoup +from loguru import logger + +from . import Archiver +from ..core import Metadata, Media + + +class TelegramArchiver(Archiver): + """ + Archiver for telegram that does not require login, but the telethon_archiver is much more advised, will only return if at least one image or one video is found + """ + name = "telegram_archiver" + + def __init__(self, config: dict) -> None: + super().__init__(config) + + @staticmethod + def configs() -> dict: + return {} + + def is_rearchivable(self, url: str) -> bool: + # telegram posts are static + return False + + def download(self, item: Metadata) -> Metadata: + url = item.get_url() + # detect URLs that we definitely cannot handle + if 't.me' != item.netloc: + return False + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36' + } + + # TODO: check if we can do this more resilient to variable URLs + if url[-8:] != "?embed=1": + url += "?embed=1" + + t = requests.get(url, headers=headers) + s = BeautifulSoup(t.content, 'html.parser') + + result = Metadata() + result.set_content(html.escape(str(t.content))) + if (timestamp := (s.find_all('time') or [{}])[0].get('datetime')): + result.set_timestamp(timestamp) + + video = s.find("video") + if video is None: + logger.warning("could not find video") + image_tags = s.find_all(class_="js-message_photo") + + image_urls = [] + for im in image_tags: + urls = [u.replace("'", "") for u in re.findall(r'url\((.*?)\)', im['style'])] + image_urls += urls + + if not len(image_urls): return False + for img_url in image_urls: + result.add_media(Media(self.download_from_url(img_url))) + else: + video_url = video.get('src') + m_video = Media(self.download_from_url(video_url)) + # extract duration from HTML + try: + duration = s.find_all('time')[0].contents[0] + if ':' in duration: + duration = float(duration.split( + ':')[0]) * 60 + float(duration.split(':')[1]) + else: + duration = float(duration) + m_video.set("duration", duration) + except: pass + result.add_media(m_video) + + return result.success("telegram") diff --git a/src/auto_archiver/archivers/telethon_archiver.py b/src/auto_archiver/archivers/telethon_archiver.py new file mode 100644 index 0000000..67b5b59 --- /dev/null +++ b/src/auto_archiver/archivers/telethon_archiver.py @@ -0,0 +1,173 @@ + +from telethon.sync import TelegramClient +from telethon.errors import ChannelInvalidError +from telethon.tl.functions.messages import ImportChatInviteRequest +from telethon.errors.rpcerrorlist import UserAlreadyParticipantError, FloodWaitError, InviteRequestSentError, InviteHashExpiredError +from loguru import logger +from tqdm import tqdm +import re, time, json, os + +from . import Archiver +from ..core import Metadata, Media, ArchivingContext + + +class TelethonArchiver(Archiver): + name = "telethon_archiver" + link_pattern = re.compile(r"https:\/\/t\.me(\/c){0,1}\/(.+)\/(\d+)") + invite_pattern = re.compile(r"t.me(\/joinchat){0,1}\/\+?(.+)") + + def __init__(self, config: dict) -> None: + super().__init__(config) + self.assert_valid_string("api_id") + self.assert_valid_string("api_hash") + + self.client = TelegramClient(self.session_file, self.api_id, self.api_hash) + + @staticmethod + def configs() -> dict: + return { + "api_id": {"default": None, "help": "telegram API_ID value, go to https://my.telegram.org/apps"}, + "api_hash": {"default": None, "help": "telegram API_HASH value, go to https://my.telegram.org/apps"}, + "bot_token": {"default": None, "help": "optional, but allows access to more content such as large videos, talk to @botfather"}, + "session_file": {"default": "secrets/anon", "help": "optional, records the telegram login session for future usage, '.session' will be appended to the provided value."}, + "join_channels": {"default": True, "help": "disables the initial setup with channel_invites config, useful if you have a lot and get stuck"}, + "channel_invites": { + "default": {}, + "help": "(JSON string) private channel invite links (format: t.me/joinchat/HASH OR t.me/+HASH) and (optional but important to avoid hanging for minutes on startup) channel id (format: CHANNEL_ID taken from a post url like https://t.me/c/CHANNEL_ID/1), the telegram account will join any new channels on setup", + "cli_set": lambda cli_val, cur_val: dict(cur_val, **json.loads(cli_val)) + } + } + + def is_rearchivable(self, url: str) -> bool: + # telegram posts are static + return False + + def setup(self) -> None: + """ + 1. trigger login process for telegram or proceed if already saved in a session file + 2. joins channel_invites where needed + """ + logger.info(f"SETUP {self.name} checking login...") + with self.client.start(): + logger.success(f"SETUP {self.name} login works.") + + if self.join_channels and len(self.channel_invites): + logger.info(f"SETUP {self.name} joining channels...") + with self.client.start(): + # get currently joined channels + # https://docs.telethon.dev/en/stable/modules/custom.html#module-telethon.tl.custom.dialog + joined_channel_ids = [c.id for c in self.client.get_dialogs() if c.is_channel] + logger.info(f"already part of {len(joined_channel_ids)} channels") + + i = 0 + pbar = tqdm(desc=f"joining {len(self.channel_invites)} invite links", total=len(self.channel_invites)) + while i < len(self.channel_invites): + channel_invite = self.channel_invites[i] + channel_id = channel_invite.get("id", False) + invite = channel_invite["invite"] + if (match := self.invite_pattern.search(invite)): + try: + if channel_id: + ent = self.client.get_entity(int(channel_id)) # fails if not a member + else: + ent = self.client.get_entity(invite) # fails if not a member + logger.warning(f"please add the property id='{ent.id}' to the 'channel_invites' configuration where {invite=}, not doing so can lead to a minutes-long setup time due to telegram's rate limiting.") + except ValueError as e: + logger.info(f"joining new channel {invite=}") + try: + self.client(ImportChatInviteRequest(match.group(2))) + except UserAlreadyParticipantError as e: + logger.info(f"already joined {invite=}") + except InviteRequestSentError: + logger.warning(f"already sent a join request with {invite} still no answer") + except InviteHashExpiredError: + logger.warning(f"{invite=} has expired please find a more recent one") + except Exception as e: + logger.error(f"could not join channel with {invite=} due to {e}") + except FloodWaitError as e: + logger.warning(f"got a flood error, need to wait {e.seconds} seconds") + time.sleep(e.seconds) + continue + else: + logger.warning(f"Invalid invite link {invite}") + i += 1 + pbar.update() + + def download(self, item: Metadata) -> Metadata: + """ + if this url is archivable will download post info and look for other posts from the same group with media. + can handle private/public channels + """ + url = item.get_url() + # detect URLs that we definitely cannot handle + match = self.link_pattern.search(url) + logger.debug(f"TELETHON: {match=}") + if not match: return False + + is_private = match.group(1) == "/c" + chat = int(match.group(2)) if is_private else match.group(2) + post_id = int(match.group(3)) + + result = Metadata() + + # NB: not using bot_token since then private channels cannot be archived: self.client.start(bot_token=self.bot_token) + with self.client.start(): + # with self.client.start(bot_token=self.bot_token): + try: + post = self.client.get_messages(chat, ids=post_id) + except ValueError as e: + logger.error(f"Could not fetch telegram {url} possibly it's private: {e}") + return False + except ChannelInvalidError as e: + logger.error(f"Could not fetch telegram {url}. This error may be fixed if you setup a bot_token in addition to api_id and api_hash (but then private channels will not be archived, we need to update this logic to handle both): {e}") + return False + + logger.debug(f"TELETHON GOT POST {post=}") + if post is None: return False + + media_posts = self._get_media_posts_in_group(chat, post) + logger.debug(f'got {len(media_posts)=} for {url=}') + + tmp_dir = ArchivingContext.get_tmp_dir() + + group_id = post.grouped_id if post.grouped_id is not None else post.id + title = post.message + for mp in media_posts: + if len(mp.message) > len(title): title = mp.message # save the longest text found (usually only 1) + + # media can also be in entities + if mp.entities: + other_media_urls = [e.url for e in mp.entities if hasattr(e, "url") and e.url and self._guess_file_type(e.url) in ["video", "image", "audio"]] + if len(other_media_urls): + logger.debug(f"Got {len(other_media_urls)} other media urls from {mp.id=}: {other_media_urls}") + for i, om_url in enumerate(other_media_urls): + filename = self.download_from_url(om_url, f'{chat}_{group_id}_{i}', item) + result.add_media(Media(filename=filename), id=f"{group_id}_{i}") + + filename_dest = os.path.join(tmp_dir, f'{chat}_{group_id}', str(mp.id)) + filename = self.client.download_media(mp.media, filename_dest) + if not filename: + logger.debug(f"Empty media found, skipping {str(mp)=}") + continue + result.add_media(Media(filename)) + + result.set_content(str(post)).set_title(title).set_timestamp(post.date) + return result.success("telethon") + + def _get_media_posts_in_group(self, chat, original_post, max_amp=10): + """ + Searches for Telegram posts that are part of the same group of uploads + The search is conducted around the id of the original post with an amplitude + of `max_amp` both ways + Returns a list of [post] where each post has media and is in the same grouped_id + """ + if getattr(original_post, "grouped_id", None) is None: + return [original_post] if getattr(original_post, "media", False) else [] + + search_ids = [i for i in range(original_post.id - max_amp, original_post.id + max_amp + 1)] + posts = self.client.get_messages(chat, ids=search_ids) + media = [] + for post in posts: + if post is not None and post.grouped_id == original_post.grouped_id and post.media is not None: + media.append(post) + return media diff --git a/src/auto_archiver/archivers/tiktok_archiver.py b/src/auto_archiver/archivers/tiktok_archiver.py new file mode 100644 index 0000000..e1fc88f --- /dev/null +++ b/src/auto_archiver/archivers/tiktok_archiver.py @@ -0,0 +1,58 @@ +import json, os, traceback, uuid +import tiktok_downloader +from loguru import logger + +from . import Archiver +from ..core import Metadata, Media, ArchivingContext + + +class TiktokArchiver(Archiver): + name = "tiktok_archiver" + + def __init__(self, config: dict) -> None: + super().__init__(config) + + @staticmethod + def configs() -> dict: + return {} + + def is_rearchivable(self, url: str) -> bool: + # TikTok posts are static + return False + + def download(self, item: Metadata) -> Metadata: + url = item.get_url() + if 'tiktok.com' not in url: + return False + + result = Metadata() + try: + info = tiktok_downloader.info_post(url) + result.set_title(info.desc) + result.set_timestamp(info.create_time) + result.set_content(json.dumps({ + "cover": info.cover, + "author": info.author, + "music_title": info.author, + "caption": getattr(info, "caption", info.desc), + }, ensure_ascii=False, indent=4)) + except: + error = traceback.format_exc() + logger.warning(f'Other Tiktok error {error}') + + try: + filename = os.path.join(ArchivingContext.get_tmp_dir(), f'{str(uuid.uuid4())[0:8]}.mp4') + tiktok_media = tiktok_downloader.snaptik(url).get_media() + + if len(tiktok_media) <= 0: + logger.debug(f"TikTok: could not get media from {url=}") + return False + + logger.info(f'downloading video {filename=}') + tiktok_media[0].download(filename) + + result.add_media(Media(filename)) + return result.success("tiktok") + except: + error = traceback.format_exc() + logger.warning(f'Other Tiktok error {error}') diff --git a/src/auto_archiver/archivers/twitter_api_archiver.py b/src/auto_archiver/archivers/twitter_api_archiver.py new file mode 100644 index 0000000..cd1eb1c --- /dev/null +++ b/src/auto_archiver/archivers/twitter_api_archiver.py @@ -0,0 +1,98 @@ + +import json, mimetypes +from datetime import datetime +from loguru import logger +from pytwitter import Api +from slugify import slugify + +from . import Archiver +from .twitter_archiver import TwitterArchiver +from ..core import Metadata,Media + + +class TwitterApiArchiver(TwitterArchiver, Archiver): + name = "twitter_api_archiver" + + def __init__(self, config: dict) -> None: + super().__init__(config) + + if self.bearer_token: + self.assert_valid_string("bearer_token") + self.api = Api(bearer_token=self.bearer_token) + elif self.consumer_key and self.consumer_secret and self.access_token and self.access_secret: + self.assert_valid_string("consumer_key") + self.assert_valid_string("consumer_secret") + self.assert_valid_string("access_token") + self.assert_valid_string("access_secret") + self.api = Api( + consumer_key=self.consumer_key, consumer_secret=self.consumer_secret, access_token=self.access_token, access_secret=self.access_secret) + assert hasattr(self, "api") and self.api is not None, "Missing Twitter API configurations, please provide either bearer_token OR (consumer_key, consumer_secret, access_token, access_secret) to use this archiver." + + @staticmethod + def configs() -> dict: + return { + "bearer_token": {"default": None, "help": "twitter API bearer_token which is enough for archiving, if not provided you will need consumer_key, consumer_secret, access_token, access_secret"}, + "consumer_key": {"default": None, "help": "twitter API consumer_key"}, + "consumer_secret": {"default": None, "help": "twitter API consumer_secret"}, + "access_token": {"default": None, "help": "twitter API access_token"}, + "access_secret": {"default": None, "help": "twitter API access_secret"}, + } + + def download(self, item: Metadata) -> Metadata: + url = item.get_url() + # detect URLs that we definitely cannot handle + username, tweet_id = self.get_username_tweet_id(url) + if not username: return False + + try: + tweet = self.api.get_tweet(tweet_id, expansions=["attachments.media_keys"], media_fields=["type", "duration_ms", "url", "variants"], tweet_fields=["attachments", "author_id", "created_at", "entities", "id", "text", "possibly_sensitive"]) + except Exception as e: + logger.error(f"Could not get tweet: {e}") + return False + + result = Metadata() + result.set_title(tweet.data.text) + result.set_timestamp(datetime.strptime(tweet.data.created_at, "%Y-%m-%dT%H:%M:%S.%fZ")) + + urls = [] + if tweet.includes: + for i, m in enumerate(tweet.includes.media): + media = Media(filename="") + if m.url and len(m.url): + media.set("src", m.url) + media.set("duration", (m.duration_ms or 1) // 1000) + mimetype = "image/jpeg" + elif hasattr(m, "variants"): + variant = self.choose_variant(m.variants) + if not variant: continue + media.set("src", variant.url) + mimetype = variant.content_type + else: + continue + logger.info(f"Found media {media}") + ext = mimetypes.guess_extension(mimetype) + media.filename = self.download_from_url(media.get("src"), f'{slugify(url)}_{i}{ext}', item) + result.add_media(media) + + result.set_content(json.dumps({ + "id": tweet.data.id, + "text": tweet.data.text, + "created_at": tweet.data.created_at, + "author_id": tweet.data.author_id, + "geo": tweet.data.geo, + "lang": tweet.data.lang, + "media": urls + }, ensure_ascii=False, indent=4)) + return result.success("twitter") + + def choose_variant(self, variants): + # choosing the highest quality possible + variant, bit_rate = None, -1 + for var in variants: + if var.content_type == "video/mp4": + if var.bit_rate > bit_rate: + bit_rate = var.bit_rate + variant = var + else: + variant = var if not variant else variant + return variant diff --git a/src/auto_archiver/archivers/twitter_archiver.py b/src/auto_archiver/archivers/twitter_archiver.py new file mode 100644 index 0000000..d15ebf0 --- /dev/null +++ b/src/auto_archiver/archivers/twitter_archiver.py @@ -0,0 +1,148 @@ +import re, requests, mimetypes, json +from datetime import datetime +from loguru import logger +from snscrape.modules.twitter import TwitterTweetScraper, Video, Gif, Photo +from slugify import slugify + +from . import Archiver +from ..core import Metadata, Media + + +class TwitterArchiver(Archiver): + """ + This Twitter Archiver uses unofficial scraping methods. + """ + + name = "twitter_archiver" + link_pattern = re.compile(r"twitter.com\/(?:\#!\/)?(\w+)\/status(?:es)?\/(\d+)") + link_clean_pattern = re.compile(r"(.+twitter\.com\/.+\/\d+)(\?)*.*") + + def __init__(self, config: dict) -> None: + super().__init__(config) + + @staticmethod + def configs() -> dict: + return {} + + def sanitize_url(self, url: str) -> str: + # expand URL if t.co and clean tracker GET params + if 'https://t.co/' in url: + try: + r = requests.get(url) + logger.debug(f'Expanded url {url} to {r.url}') + url = r.url + except: + logger.error(f'Failed to expand url {url}') + # https://twitter.com/MeCookieMonster/status/1617921633456640001?s=20&t=3d0g4ZQis7dCbSDg-mE7-w + return self.link_clean_pattern.sub("\\1", url) + + def is_rearchivable(self, url: str) -> bool: + # Twitter posts are static (for now) + return False + + def download(self, item: Metadata) -> Metadata: + """ + if this url is archivable will download post info and look for other posts from the same group with media. + can handle private/public channels + """ + url = item.get_url() + # detect URLs that we definitely cannot handle + username, tweet_id = self.get_username_tweet_id(url) + if not username: return False + + result = Metadata() + + scr = TwitterTweetScraper(tweet_id) + try: + tweet = next(scr.get_items()) + except Exception as ex: + logger.warning(f"can't get tweet: {type(ex).__name__} occurred. args: {ex.args}") + return self.download_alternative(item, url, tweet_id) + + result.set_title(tweet.content).set_content(tweet.json()).set_timestamp(tweet.date) + if tweet.media is None: + logger.debug(f'No media found, archiving tweet text only') + return result + + for i, tweet_media in enumerate(tweet.media): + media = Media(filename="") + mimetype = "" + if type(tweet_media) == Video: + variant = max( + [v for v in tweet_media.variants if v.bitrate], key=lambda v: v.bitrate) + media.set("src", variant.url).set("duration", tweet_media.duration) + mimetype = variant.contentType + elif type(tweet_media) == Gif: + variant = tweet_media.variants[0] + media.set("src", variant.url) + mimetype = variant.contentType + elif type(tweet_media) == Photo: + media.set("src", tweet_media.fullUrl.replace('name=large', 'name=orig')) + mimetype = "image/jpeg" + else: + logger.warning(f"Could not get media URL of {tweet_media}") + continue + ext = mimetypes.guess_extension(mimetype) + media.filename = self.download_from_url(media.get("src"), f'{slugify(url)}_{i}{ext}', item) + result.add_media(media) + + return result.success("twitter-snscrape") + + def download_alternative(self, item: Metadata, url: str, tweet_id: str) -> Metadata: + """ + CURRENTLY STOPPED WORKING + """ + return False + # https://stackoverflow.com/a/71867055/6196010 + logger.debug(f"Trying twitter hack for {url=}") + result = Metadata() + + hack_url = f"https://cdn.syndication.twimg.com/tweet?id={tweet_id}" + r = requests.get(hack_url) + if r.status_code != 200: return False + tweet = r.json() + + urls = [] + for p in tweet["photos"]: + urls.append(p["url"]) + + # 1 tweet has 1 video max + if "video" in tweet: + v = tweet["video"] + urls.append(self.choose_variant(v.get("variants", []))) + + logger.debug(f"Twitter hack got {urls=}") + + for u in urls: + media = Media() + media.set("src", u) + media.filename = self.download_from_url(u, f'{slugify(url)}_{i}', item) + result.add_media(media) + + result.set_content(json.dumps(tweet, ensure_ascii=False)).set_timestamp(datetime.strptime(tweet["created_at"], "%Y-%m-%dT%H:%M:%S.%fZ")) + return result + + def get_username_tweet_id(self, url): + # detect URLs that we definitely cannot handle + matches = self.link_pattern.findall(url) + if not len(matches): return False, False + + username, tweet_id = matches[0] # only one URL supported + logger.debug(f"Found {username=} and {tweet_id=} in {url=}") + + return username, tweet_id + + def choose_variant(self, variants): + # choosing the highest quality possible + variant, width, height = None, 0, 0 + for var in variants: + if var.get("type", "") == "video/mp4": + width_height = re.search(r"\/(\d+)x(\d+)\/", var["src"]) + if width_height: + w, h = int(width_height[1]), int(width_height[2]) + if w > width or h > height: + width, height = w, h + variant = var.get("src", variant) + else: + variant = var.get("src") if not variant else variant + return variant diff --git a/src/auto_archiver/archivers/vk_archiver.py b/src/auto_archiver/archivers/vk_archiver.py new file mode 100644 index 0000000..8defb96 --- /dev/null +++ b/src/auto_archiver/archivers/vk_archiver.py @@ -0,0 +1,57 @@ +from loguru import logger +from vk_url_scraper import VkScraper + +from ..utils.misc import dump_payload +from . import Archiver +from ..core import Metadata, Media, ArchivingContext + + +class VkArchiver(Archiver): + """" + VK videos are handled by YTDownloader, this archiver gets posts text and images. + Currently only works for /wall posts + """ + name = "vk_archiver" + + def __init__(self, config: dict) -> None: + super().__init__(config) + self.assert_valid_string("username") + self.assert_valid_string("password") + self.vks = VkScraper(self.username, self.password, session_file=self.session_file) + + @staticmethod + def configs() -> dict: + return { + "username": {"default": None, "help": "valid VKontakte username"}, + "password": {"default": None, "help": "valid VKontakte password"}, + "session_file": {"default": "secrets/vk_config.v2.json", "help": "valid VKontakte password"}, + } + + def is_rearchivable(self, url: str) -> bool: + # VK content is static + return False + + def download(self, item: Metadata) -> Metadata: + url = item.get_url() + + if "vk.com" not in item.netloc: return False + + # some urls can contain multiple wall/photo/... parts and all will be fetched + vk_scrapes = self.vks.scrape(url) + if not len(vk_scrapes): return False + logger.debug(f"VK: got {len(vk_scrapes)} scraped instances") + + result = Metadata() + for scrape in vk_scrapes: + if not result.get_title(): + result.set_title(scrape["text"]) + if not result.get_timestamp(): + result.set_timestamp(scrape["datetime"]) + + result.set_content(dump_payload(vk_scrapes)) + + filenames = self.vks.download_media(vk_scrapes, ArchivingContext.get_tmp_dir()) + for filename in filenames: + result.add_media(Media(filename)) + + return result.success("vk") diff --git a/src/auto_archiver/archivers/youtubedl_archiver.py b/src/auto_archiver/archivers/youtubedl_archiver.py new file mode 100644 index 0000000..92637c0 --- /dev/null +++ b/src/auto_archiver/archivers/youtubedl_archiver.py @@ -0,0 +1,67 @@ +import datetime, os, yt_dlp +from loguru import logger + +from . import Archiver +from ..core import Metadata, Media, ArchivingContext + + +class YoutubeDLArchiver(Archiver): + name = "youtubedl_archiver" + + def __init__(self, config: dict) -> None: + super().__init__(config) + + @staticmethod + def configs() -> dict: + return { + "facebook_cookie": {"default": None, "help": "optional facebook cookie to have more access to content, from browser, looks like 'cookie: datr= xxxx'"}, + } + + def download(self, item: Metadata) -> Metadata: + #TODO: yt-dlp for transcripts? + url = item.get_url() + + if item.netloc in ['facebook.com', 'www.facebook.com'] and self.facebook_cookie: + logger.debug('Using Facebook cookie') + yt_dlp.utils.std_headers['cookie'] = self.facebook_cookie + + ydl = yt_dlp.YoutubeDL({'outtmpl': os.path.join(ArchivingContext.get_tmp_dir(), f'%(id)s.%(ext)s'), 'quiet': False}) + + try: + # don'd download since it can be a live stream + info = ydl.extract_info(url, download=False) + if info.get('is_live', False): + logger.warning("Live streaming media, not archiving now") + return False + except yt_dlp.utils.DownloadError as e: + logger.debug(f'No video - Youtube normal control flow: {e}') + return False + except Exception as e: + logger.debug(f'ytdlp exception which is normal for example a facebook page with images only will cause a IndexError: list index out of range. Exception here is: \n {e}') + return False + + # this time download + info = ydl.extract_info(url, download=True) + if "entries" in info: + entries = info.get("entries", []) + if not len(entries): + logger.warning('YoutubeDLArchiver could not find any video') + return False + else: entries = [info] + + result = Metadata() + result.set_title(info.get("title")) + for entry in entries: + filename = ydl.prepare_filename(entry) + if not os.path.exists(filename): + filename = filename.split('.')[0] + '.mkv' + result.add_media(Media(filename).set("duration", info.get("duration"))) + + if (timestamp := info.get("timestamp")): + timestamp = datetime.datetime.utcfromtimestamp(timestamp).replace(tzinfo=datetime.timezone.utc).isoformat() + result.set_timestamp(timestamp) + if (upload_date := info.get("upload_date")): + upload_date = datetime.datetime.strptime(upload_date, '%Y%m%d').replace(tzinfo=datetime.timezone.utc) + result.set("upload_date", upload_date) + + return result.success("yt-dlp") diff --git a/src/auto_archiver/core/__init__.py b/src/auto_archiver/core/__init__.py new file mode 100644 index 0000000..99765c7 --- /dev/null +++ b/src/auto_archiver/core/__init__.py @@ -0,0 +1,8 @@ +from .metadata import Metadata +from .media import Media +from .step import Step +from .context import ArchivingContext + +# cannot import ArchivingOrchestrator/Config to avoid circular dep +# from .orchestrator import ArchivingOrchestrator +# from .config import Config \ No newline at end of file diff --git a/src/auto_archiver/core/config.py b/src/auto_archiver/core/config.py new file mode 100644 index 0000000..9739506 --- /dev/null +++ b/src/auto_archiver/core/config.py @@ -0,0 +1,117 @@ + + +import argparse, yaml +from dataclasses import dataclass, field +from typing import List +from collections import defaultdict +from loguru import logger + +from ..archivers import Archiver +from ..feeders import Feeder +from ..databases import Database +from ..formatters import Formatter +from ..storages import Storage +from ..enrichers import Enricher +from . import Step + + +@dataclass +class Config: + configurable_parents = [ + Feeder, + Enricher, + Archiver, + Database, + Storage, + Formatter + # Util + ] + feeder: Feeder + formatter: Formatter + archivers: List[Archiver] = field(default_factory=[]) + enrichers: List[Enricher] = field(default_factory=[]) + storages: List[Storage] = field(default_factory=[]) + databases: List[Database] = field(default_factory=[]) + + def __init__(self) -> None: + self.defaults = {} + self.cli_ops = {} + self.config = {} + + def parse(self, use_cli=True, yaml_config_filename: str = None): + """ + if yaml_config_filename is provided, the --config argument is ignored, + useful for library usage when the config values are preloaded + """ + # 1. parse CLI values + if use_cli: + parser = argparse.ArgumentParser( + # prog = "auto-archiver", + description="Auto Archiver is a CLI tool to archive media/metadata from online URLs; it can read URLs from many sources (Google Sheets, Command Line, ...); and write results to many destinations too (CSV, Google Sheets, MongoDB, ...)!", + epilog="Check the code at https://github.com/bellingcat/auto-archiver" + ) + + parser.add_argument('--config', action='store', dest='config', help='the filename of the YAML configuration file (defaults to \'config.yaml\')', default='orchestration.yaml') + + for configurable in self.configurable_parents: + child: Step + for child in configurable.__subclasses__(): + assert child.configs() is not None and type(child.configs()) == dict, f"class '{child.name}' should have a configs method returning a dict." + for config, details in child.configs().items(): + assert "." not in child.name, f"class prop name cannot contain dots('.'): {child.name}" + assert "." not in config, f"config property cannot contain dots('.'): {config}" + config_path = f"{child.name}.{config}" + + if use_cli: + try: + parser.add_argument(f'--{config_path}', action='store', dest=config_path, help=f"{details['help']} (defaults to {details['default']})", choices=details.get("choices", None)) + except argparse.ArgumentError: + # captures cases when a Step is used in 2 flows, eg: wayback enricher vs wayback archiver + pass + + self.defaults[config_path] = details["default"] + if "cli_set" in details: + self.cli_ops[config_path] = details["cli_set"] + + if use_cli: + args = parser.parse_args() + yaml_config_filename = yaml_config_filename or getattr(args, "config") + else: args = {} + + # 2. read YAML config file (or use provided value) + self.yaml_config = self.read_yaml(yaml_config_filename) + + # 3. CONFIGS: decide value with priority: CLI >> config.yaml >> default + self.config = defaultdict(dict) + for config_path, default in self.defaults.items(): + child, config = tuple(config_path.split(".")) + val = getattr(args, config_path, None) + if val is not None and config_path in self.cli_ops: + val = self.cli_ops[config_path](val, default) + if val is None: + val = self.yaml_config.get("configurations", {}).get(child, {}).get(config, default) + self.config[child][config] = val + self.config = dict(self.config) + + # 4. STEPS: read steps and validate they exist + steps = self.yaml_config.get("steps", {}) + assert "archivers" in steps, "your configuration steps are missing the archivers property" + assert "storages" in steps, "your configuration steps are missing the storages property" + + self.feeder = Feeder.init(steps.get("feeder", "cli_feeder"), self.config) + self.formatter = Formatter.init(steps.get("formatter", "mute_formatter"), self.config) + self.enrichers = [Enricher.init(e, self.config) for e in steps.get("enrichers", [])] + self.archivers = [Archiver.init(e, self.config) for e in (steps.get("archivers") or [])] + self.databases = [Database.init(e, self.config) for e in steps.get("databases", [])] + self.storages = [Storage.init(e, self.config) for e in steps.get("storages", [])] + + logger.info(f"FEEDER: {self.feeder.name}") + logger.info(f"ENRICHERS: {[x.name for x in self.enrichers]}") + logger.info(f"ARCHIVERS: {[x.name for x in self.archivers]}") + logger.info(f"DATABASES: {[x.name for x in self.databases]}") + logger.info(f"STORAGES: {[x.name for x in self.storages]}") + logger.info(f"FORMATTER: {self.formatter.name}") + + def read_yaml(self, yaml_filename: str) -> dict: + with open(yaml_filename, "r", encoding="utf-8") as inf: + return yaml.safe_load(inf) diff --git a/src/auto_archiver/core/context.py b/src/auto_archiver/core/context.py new file mode 100644 index 0000000..f836c67 --- /dev/null +++ b/src/auto_archiver/core/context.py @@ -0,0 +1,52 @@ +from loguru import logger + + +class ArchivingContext: + """ + Singleton context class. + ArchivingContext._get_instance() to retrieve it if needed + otherwise just + ArchivingContext.set(key, value) + and + ArchivingContext.get(key, default) + + When reset is called, all values are cleared EXCEPT if they were .set(keep_on_reset=True) + reset(full_reset=True) will recreate everything including the keep_on_reset status + """ + _instance = None + + def __init__(self): + self.configs = {} + self.keep_on_reset = set() + + @staticmethod + def get_instance(): + if ArchivingContext._instance is None: + ArchivingContext._instance = ArchivingContext() + return ArchivingContext._instance + + @staticmethod + def set(key, value, keep_on_reset: bool = False): + ac = ArchivingContext.get_instance() + ac.configs[key] = value + if keep_on_reset: ac.keep_on_reset.add(key) + + @staticmethod + def get(key: str, default=None): + return ArchivingContext.get_instance().configs.get(key, default) + + @staticmethod + def reset(full_reset: bool = False): + ac = ArchivingContext.get_instance() + if full_reset: ac.keep_on_reset = set() + ac.configs = {k: v for k, v in ac.configs.items() if k in ac.keep_on_reset} + + # ---- custom getters/setters for widely used context values + + @staticmethod + def set_tmp_dir(tmp_dir: str): + ArchivingContext.get_instance().configs["tmp_dir"] = tmp_dir + + @staticmethod + def get_tmp_dir() -> str: + return ArchivingContext.get_instance().configs.get("tmp_dir") diff --git a/src/auto_archiver/core/media.py b/src/auto_archiver/core/media.py new file mode 100644 index 0000000..4770301 --- /dev/null +++ b/src/auto_archiver/core/media.py @@ -0,0 +1,73 @@ + +from __future__ import annotations +from ast import List +from typing import Any +from dataclasses import dataclass, field +from dataclasses_json import dataclass_json, config +import mimetypes + +from .context import ArchivingContext + +from loguru import logger + + +@dataclass_json # annotation order matters +@dataclass +class Media: + filename: str + key: str = None + urls: List[str] = field(default_factory=list) + properties: dict = field(default_factory=dict) + _mimetype: str = None # eg: image/jpeg + _stored: bool = field(default=False, repr=False, metadata=config(exclude=lambda _: True)) # always exclude + + def store(self: Media, override_storages: List = None, url: str = "url-not-available"): + # stores the media into the provided/available storages [Storage] + # repeats the process for its properties, in case they have inner media themselves + # for now it only goes down 1 level but it's easy to make it recursive if needed + storages = override_storages or ArchivingContext.get("storages") + if not len(storages): + logger.warning(f"No storages found in local context or provided directly for {self.filename}.") + return + + for s in storages: + s.store(self, url) + # Media can be inside media properties, examples include transformations on original media + for prop in self.properties.values(): + if isinstance(prop, Media): + s.store(prop, url) + if isinstance(prop, list): + for prop_media in prop: + if isinstance(prop_media, Media): + s.store(prop_media, url) + + def is_stored(self) -> bool: + return len(self.urls) > 0 and len(self.urls) == len(ArchivingContext.get("storages")) + + def set(self, key: str, value: Any) -> Media: + self.properties[key] = value + return self + + def get(self, key: str, default: Any = None) -> Any: + return self.properties.get(key, default) + + def add_url(self, url: str) -> None: + # url can be remote, local, ... + self.urls.append(url) + + @property # getter .mimetype + def mimetype(self) -> str: + assert self.filename is not None and len(self.filename) > 0, "cannot get mimetype from media without filename" + if not self._mimetype: + self._mimetype = mimetypes.guess_type(self.filename)[0] + return self._mimetype or "" + + @mimetype.setter # setter .mimetype + def mimetype(self, v: str) -> None: + self._mimetype = v + + def is_video(self) -> bool: + return self.mimetype.startswith("video") + + def is_audio(self) -> bool: + return self.mimetype.startswith("audio") diff --git a/src/auto_archiver/core/metadata.py b/src/auto_archiver/core/metadata.py new file mode 100644 index 0000000..cefdab6 --- /dev/null +++ b/src/auto_archiver/core/metadata.py @@ -0,0 +1,142 @@ + +from __future__ import annotations +from ast import List, Set +from typing import Any, Union, Dict +from dataclasses import dataclass, field +from dataclasses_json import dataclass_json, config +import datetime +from urllib.parse import urlparse +from dateutil.parser import parse as parse_dt +from .media import Media +from .context import ArchivingContext + + +@dataclass_json # annotation order matters +@dataclass +class Metadata: + status: str = "no archiver" + metadata: Dict[str, Any] = field(default_factory=dict) + media: List[Media] = field(default_factory=list) + rearchivable: bool = True # defaults to true, archivers can overwrite + + def __post_init__(self): + self.set("_processed_at", datetime.datetime.utcnow()) + + def merge(self: Metadata, right: Metadata, overwrite_left=True) -> Metadata: + """ + merges two Metadata instances, will overwrite according to overwrite_left flag + """ + if not right: return self + if overwrite_left: + if right.status and len(right.status): + self.status = right.status + self.rearchivable |= right.rearchivable + for k, v in right.metadata.items(): + assert k not in self.metadata or type(v) == type(self.get(k)) + if type(v) not in [dict, list, set] or k not in self.metadata: + self.set(k, v) + else: # key conflict + if type(v) in [dict, set]: self.set(k, self.get(k) | v) + elif type(v) == list: self.set(k, self.get(k) + v) + self.media.extend(right.media) + else: # invert and do same logic + return right.merge(self) + return self + + def store(self: Metadata, override_storages: List = None): + # calls .store for all contained media. storages [Storage] + storages = override_storages or ArchivingContext.get("storages") + for media in self.media: + media.store(override_storages=storages, url=self.get_url()) + + def set(self, key: str, val: Any) -> Metadata: + self.metadata[key] = val + return self + + def get(self, key: str, default: Any = None, create_if_missing=False) -> Union[Metadata, str]: + # goes through metadata and returns the Metadata available + if create_if_missing and key not in self.metadata: + self.metadata[key] = default + return self.metadata.get(key, default) + + def success(self, context: str = None) -> Metadata: + if context: self.status = f"{context}: success" + else: self.status = "success" + return self + + def is_success(self) -> bool: + return "success" in self.status + + def is_empty(self) -> bool: + return not self.is_success() and len(self.media) == 0 and len(self.metadata) <= 2 # url, processed_at + + @property # getter .netloc + def netloc(self) -> str: + return urlparse(self.get_url()).netloc + + +# custom getter/setters + + + def set_url(self, url: str) -> Metadata: + assert type(url) is str and len(url) > 0, "invalid URL" + return self.set("url", url) + + def get_url(self) -> str: + url = self.get("url") + assert type(url) is str and len(url) > 0, "invalid URL" + return url + + def set_content(self, content: str) -> Metadata: + # a dump with all the relevant content + append_content = (self.get("content", "") + content + "\n").strip() + return self.set("content", append_content) + + def set_title(self, title: str) -> Metadata: + return self.set("title", title) + + def get_title(self) -> str: + return self.get("title") + + def set_timestamp(self, timestamp: datetime.datetime) -> Metadata: + if type(timestamp) == str: + timestamp = parse_dt(timestamp) + assert type(timestamp) == datetime.datetime, "set_timestamp expects a datetime instance" + return self.set("timestamp", timestamp) + + def get_timestamp(self, utc=True, iso=True) -> datetime.datetime: + ts = self.get("timestamp") + if not ts: return ts + if utc: ts = ts.replace(tzinfo=datetime.timezone.utc) + if iso: return ts.isoformat() + return ts + + def add_media(self, media: Media, id: str = None) -> Metadata: + # adds a new media, optionally including an id + if media is None: return + if id is not None: + assert not len([1 for m in self.media if m.get("id") == id]), f"cannot add 2 pieces of media with the same id {id}" + media.set("id", id) + self.media.append(media) + return media + + def get_media_by_id(self, id: str, default=None) -> Media: + for m in self.media: + if m.get("id") == id: return m + return default + + def get_first_image(self, default=None) -> Media: + for m in self.media: + if "image" in m.mimetype: return m + return default + + def set_final_media(self, final: Media) -> Metadata: + """final media is a special type of media: if you can show only 1 this is it, it's useful for some DBs like GsheetDb""" + self.add_media(final, "_final_media") + + def get_final_media(self) -> Media: + _default = self.media[0] if len(self.media) else None + return self.get_media_by_id("_final_media", _default) + + def __str__(self) -> str: + return self.__repr__() diff --git a/src/auto_archiver/core/orchestrator.py b/src/auto_archiver/core/orchestrator.py new file mode 100644 index 0000000..9e04206 --- /dev/null +++ b/src/auto_archiver/core/orchestrator.py @@ -0,0 +1,126 @@ +from __future__ import annotations +from ast import List +from typing import Union + +from .context import ArchivingContext + +from ..archivers import Archiver +from ..feeders import Feeder +from ..formatters import Formatter +from ..storages import Storage +from ..enrichers import Enricher +from ..databases import Database +from .media import Media +from .metadata import Metadata + +import tempfile, traceback +from loguru import logger + + +class ArchivingOrchestrator: + def __init__(self, config) -> None: + self.feeder: Feeder = config.feeder + self.formatter: Formatter = config.formatter + self.enrichers: List[Enricher] = config.enrichers + self.archivers: List[Archiver] = config.archivers + self.databases: List[Database] = config.databases + self.storages: List[Storage] = config.storages + ArchivingContext.set("storages", self.storages, keep_on_reset=True) + + for a in self.archivers: a.setup() + + def feed(self) -> None: + for item in self.feeder: + self.feed_item(item) + + def feed_item(self, item: Metadata) -> Metadata: + try: + ArchivingContext.reset() + with tempfile.TemporaryDirectory(dir="./") as tmp_dir: + ArchivingContext.set_tmp_dir(tmp_dir) + return self.archive(item) + except KeyboardInterrupt: + # catches keyboard interruptions to do a clean exit + logger.warning(f"caught interrupt on {item=}") + for d in self.databases: d.aborted(item) + exit() + except Exception as e: + logger.error(f'Got unexpected error on item {item}: {e}\n{traceback.format_exc()}') + for d in self.databases: d.failed(item) + + # how does this handle the parameters like folder which can be different for each archiver? + # the storage needs to know where to archive!! + # solution: feeders have context: extra metadata that they can read or ignore, + # all of it should have sensible defaults (eg: folder) + # default feeder is a list with 1 element + + def archive(self, result: Metadata) -> Union[Metadata, None]: + original_url = result.get_url() + + # 1 - cleanup + # each archiver is responsible for cleaning/expanding its own URLs + url = original_url + for a in self.archivers: url = a.sanitize_url(url) + result.set_url(url) + if original_url != url: result.set("original_url", original_url) + + # 2 - rearchiving logic + notify start to DB + # archivers can signal whether the content is rearchivable: eg: tweet vs webpage + for a in self.archivers: result.rearchivable |= a.is_rearchivable(url) + logger.debug(f"{result.rearchivable=} for {url=}") + + # signal to DB that archiving has started + # and propagate already archived if it exists + cached_result = None + for d in self.databases: + # are the databases to decide whether to archive? + # they can simply return True by default, otherwise they can avoid duplicates. should this logic be more granular, for example on the archiver level: a tweet will not need be scraped twice, whereas an instagram profile might. the archiver could not decide from the link which parts to archive, + # instagram profile example: it would always re-archive everything + # maybe the database/storage could use a hash/key to decide if there's a need to re-archive + d.started(result) + if (local_result := d.fetch(result)): + cached_result = (cached_result or Metadata()).merge(local_result) + if cached_result and not cached_result.rearchivable: + logger.debug("Found previously archived entry") + for d in self.databases: + d.done(cached_result) + return cached_result + + # 3 - call archivers until one succeeds + for a in self.archivers: + logger.info(f"Trying archiver {a.name} for {url}") + try: + # Q: should this be refactored so it's just a.download(result)? + result.merge(a.download(result)) + if result.is_success(): break + except Exception as e: logger.error(f"Unexpected error with archiver {a.name}: {e}: {traceback.format_exc()}") + + # what if an archiver returns multiple entries and one is to be part of HTMLgenerator? + # should it call the HTMLgenerator as if it's not an enrichment? + # eg: if it is enable: generates an HTML with all the returned media, should it include enrichers? yes + # then how to execute it last? should there also be post-processors? are there other examples? + # maybe as a PDF? or a Markdown file + + # 4 - call enrichers: have access to archived content, can generate metadata and Media + # eg: screenshot, wacz, webarchive, thumbnails + for e in self.enrichers: + try: e.enrich(result) + except Exception as exc: logger.error(f"Unexpected error with enricher {e.name}: {exc}: {traceback.format_exc()}") + + # 5 - store media + # looks for Media in result.media and also result.media[x].properties (as list or dict values) + result.store() + + # 6 - format and store formatted if needed + # enrichers typically need access to already stored URLs etc + if (final_media := self.formatter.format(result)): + final_media.store(url=url) + result.set_final_media(final_media) + + if result.is_empty(): + result.status = "nothing archived" + + # signal completion to databases (DBs, Google Sheets, CSV, ...) + for d in self.databases: d.done(result) + + return result diff --git a/src/auto_archiver/core/step.py b/src/auto_archiver/core/step.py new file mode 100644 index 0000000..6ac6648 --- /dev/null +++ b/src/auto_archiver/core/step.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from inspect import ClassFoundException +from typing import Type +from abc import ABC +# from collections.abc import Iterable + + +@dataclass +class Step(ABC): + name: str = None + + def __init__(self, config: dict) -> None: + # reads the configs into object properties + # self.config = config[self.name] + for k, v in config.get(self.name, {}).items(): + self.__setattr__(k, v) + + @staticmethod + def configs() -> dict: return {} + + def init(name: str, config: dict, child: Type[Step]) -> Step: + """ + looks into direct subclasses of child for name and returns such ab object + TODO: cannot find subclasses of child.subclasses + """ + for sub in child.__subclasses__(): + if sub.name == name: + return sub(config) + raise ClassFoundException(f"Unable to initialize STEP with {name=}, check your configuration file/step names, and make sure you made the step discoverable by putting it into __init__.py") + + def assert_valid_string(self, prop: str) -> None: + """ + receives a property name an ensures it exists and is a valid non-empty string, raises an exception if not + """ + assert hasattr(self, prop), f"property {prop} not found" + s = getattr(self, prop) + assert s is not None and type(s) == str and len(s) > 0, f"invalid property {prop} value '{s}', it should be a valid string" diff --git a/src/auto_archiver/databases/__init__.py b/src/auto_archiver/databases/__init__.py new file mode 100644 index 0000000..1e676ea --- /dev/null +++ b/src/auto_archiver/databases/__init__.py @@ -0,0 +1,4 @@ +from .database import Database +from .gsheet_db import GsheetsDb +from .console_db import ConsoleDb +from .csv_db import CSVDb \ No newline at end of file diff --git a/src/auto_archiver/databases/console_db.py b/src/auto_archiver/databases/console_db.py new file mode 100644 index 0000000..a22bc8e --- /dev/null +++ b/src/auto_archiver/databases/console_db.py @@ -0,0 +1,32 @@ +from loguru import logger + +from . import Database +from ..core import Metadata + + +class ConsoleDb(Database): + """ + Outputs results to the console + """ + name = "console_db" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + @staticmethod + def configs() -> dict: + return {} + + def started(self, item: Metadata) -> None: + logger.warning(f"STARTED {item}") + + def failed(self, item: Metadata) -> None: + logger.error(f"FAILED {item}") + + def aborted(self, item: Metadata) -> None: + logger.warning(f"ABORTED {item}") + + def done(self, item: Metadata) -> None: + """archival result ready - should be saved to DB""" + logger.success(f"DONE {item}") \ No newline at end of file diff --git a/src/auto_archiver/databases/csv_db.py b/src/auto_archiver/databases/csv_db.py new file mode 100644 index 0000000..0743047 --- /dev/null +++ b/src/auto_archiver/databases/csv_db.py @@ -0,0 +1,34 @@ +import os +from loguru import logger +from csv import DictWriter +from dataclasses import asdict + +from . import Database +from ..core import Metadata + + +class CSVDb(Database): + """ + Outputs results to a CSV file + """ + name = "csv_db" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + self.assert_valid_string("csv_file") + + @staticmethod + def configs() -> dict: + return { + "csv_file": {"default": "db.csv", "help": "CSV file name"} + } + + def done(self, item: Metadata) -> None: + """archival result ready - should be saved to DB""" + logger.success(f"DONE {item}") + is_empty = not os.path.isfile(self.csv_file) or os.path.getsize(self.csv_file) == 0 + with open(self.csv_file, "a", encoding="utf-8") as outf: + writer = DictWriter(outf, fieldnames=asdict(Metadata())) + if is_empty: writer.writeheader() + writer.writerow(asdict(item)) diff --git a/src/auto_archiver/databases/database.py b/src/auto_archiver/databases/database.py new file mode 100644 index 0000000..01b7869 --- /dev/null +++ b/src/auto_archiver/databases/database.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from dataclasses import dataclass +from abc import abstractmethod, ABC +from typing import Union + +from ..core import Metadata, Step + + +@dataclass +class Database(Step, ABC): + name = "database" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + def init(name: str, config: dict) -> Database: + # only for typing... + return Step.init(name, config, Database) + + def started(self, item: Metadata) -> None: + """signals the DB that the given item archival has started""" + pass + + def failed(self, item: Metadata) -> None: + """update DB accordingly for failure""" + pass + + def aborted(self, item: Metadata) -> None: + """abort notification if user cancelled after start""" + pass + + # @abstractmethod + def fetch(self, item: Metadata) -> Union[Metadata, bool]: + """check if the given item has been archived already""" + return False + + @abstractmethod + def done(self, item: Metadata) -> None: + """archival result ready - should be saved to DB""" + pass diff --git a/src/auto_archiver/databases/gsheet_db.py b/src/auto_archiver/databases/gsheet_db.py new file mode 100644 index 0000000..e76dc5a --- /dev/null +++ b/src/auto_archiver/databases/gsheet_db.py @@ -0,0 +1,92 @@ +from typing import Union, Tuple +import datetime +from urllib.parse import quote + +from loguru import logger + +from . import Database +from ..core import Metadata, Media, ArchivingContext +from ..utils import GWorksheet + + +class GsheetsDb(Database): + """ + NB: only works if GsheetFeeder is used. + could be updated in the future to support non-GsheetFeeder metadata + """ + name = "gsheet_db" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + @staticmethod + def configs() -> dict: + return {} + + def started(self, item: Metadata) -> None: + logger.warning(f"STARTED {item}") + gw, row = self._retrieve_gsheet(item) + gw.set_cell(row, 'status', 'Archive in progress') + + def failed(self, item: Metadata) -> None: + logger.error(f"FAILED {item}") + self._safe_status_update(item, 'Archive failed') + + def aborted(self, item: Metadata) -> None: + logger.warning(f"ABORTED {item}") + self._safe_status_update(item, '') + + def fetch(self, item: Metadata) -> Union[Metadata, bool]: + """check if the given item has been archived already""" + return False + + def done(self, item: Metadata) -> None: + """archival result ready - should be saved to DB""" + logger.success(f"DONE {item.get_url()}") + gw, row = self._retrieve_gsheet(item) + # self._safe_status_update(item, 'done') + + cell_updates = [] + row_values = gw.get_row(row) + + def batch_if_valid(col, val, final_value=None): + final_value = final_value or val + if val and gw.col_exists(col) and gw.get_cell(row_values, col) == '': + cell_updates.append((row, col, final_value)) + + cell_updates.append((row, 'status', item.status)) + + media: Media = item.get_final_media() + if hasattr(media, "urls"): + batch_if_valid('archive', "\n".join(media.urls)) + batch_if_valid('date', True, datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat()) + batch_if_valid('title', item.get_title()) + batch_if_valid('text', item.get("content", "")) + batch_if_valid('timestamp', item.get_timestamp()) + batch_if_valid('hash', media.get("hash", "not-calculated")) + if (screenshot := item.get_media_by_id("screenshot")) and hasattr(screenshot, "urls"): + batch_if_valid('screenshot', "\n".join(screenshot.urls)) + + if (thumbnail := item.get_first_image("thumbnail")): + if hasattr(thumbnail, "urls"): + batch_if_valid('thumbnail', f'=IMAGE("{thumbnail.urls[0]}")') + + if (browsertrix := item.get_media_by_id("browsertrix")): + batch_if_valid('wacz', "\n".join(browsertrix.urls)) + batch_if_valid('replaywebpage', "\n".join([f'https://replayweb.page/?source={quote(wacz)}#view=pages&url={quote(item.get_url())}' for wacz in browsertrix.urls])) + + gw.batch_set_cell(cell_updates) + + def _safe_status_update(self, item: Metadata, new_status: str) -> None: + try: + gw, row = self._retrieve_gsheet(item) + gw.set_cell(row, 'status', new_status) + except Exception as e: + logger.debug(f"Unable to update sheet: {e}") + + def _retrieve_gsheet(self, item: Metadata) -> Tuple[GWorksheet, int]: + # TODO: to make gsheet_db less coupled with gsheet_feeder's "gsheet" parameter, this method could 1st try to fetch "gsheet" from ArchivingContext and, if missing, manage its own singleton - not needed for now + gw: GWorksheet = ArchivingContext.get("gsheet").get("worksheet") + row: int = ArchivingContext.get("gsheet").get("row") + return gw, row diff --git a/src/auto_archiver/enrichers/__init__.py b/src/auto_archiver/enrichers/__init__.py new file mode 100644 index 0000000..d33b49d --- /dev/null +++ b/src/auto_archiver/enrichers/__init__.py @@ -0,0 +1,7 @@ +from .enricher import Enricher +from .screenshot_enricher import ScreenshotEnricher +from .wayback_enricher import WaybackArchiverEnricher +from .hash_enricher import HashEnricher +from .thumbnail_enricher import ThumbnailEnricher +from .wacz_enricher import WaczEnricher +from .whisper_enricher import WhisperEnricher \ No newline at end of file diff --git a/src/auto_archiver/enrichers/enricher.py b/src/auto_archiver/enrichers/enricher.py new file mode 100644 index 0000000..4948d57 --- /dev/null +++ b/src/auto_archiver/enrichers/enricher.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from dataclasses import dataclass +from abc import abstractmethod, ABC +from ..core import Metadata, Step + +@dataclass +class Enricher(Step, ABC): + name = "enricher" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + + # only for typing... + def init(name: str, config: dict) -> Enricher: + return Step.init(name, config, Enricher) + + @abstractmethod + def enrich(self, to_enrich: Metadata) -> None: pass diff --git a/src/auto_archiver/enrichers/hash_enricher.py b/src/auto_archiver/enrichers/hash_enricher.py new file mode 100644 index 0000000..7bf8f89 --- /dev/null +++ b/src/auto_archiver/enrichers/hash_enricher.py @@ -0,0 +1,49 @@ +import hashlib +from loguru import logger + +from . import Enricher +from ..core import Metadata, ArchivingContext + + +class HashEnricher(Enricher): + """ + Calculates hashes for Media instances + """ + name = "hash_enricher" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + algo_choices = self.configs()["algorithm"]["choices"] + assert self.algorithm in algo_choices, f"Invalid hash algorithm selected, must be one of {algo_choices} (you selected {self.algorithm})." + self.chunksize = int(self.chunksize) + ArchivingContext.set("hash_enricher.algorithm", self.algorithm, keep_on_reset=True) + + @staticmethod + def configs() -> dict: + return { + "algorithm": {"default": "SHA-256", "help": "hash algorithm to use", "choices": ["SHA-256", "SHA3-512"]}, + "chunksize": {"default": 1.6e7, "help": "number of bytes to use when reading files in chunks (if this value is too large you will run out of RAM), default is 16MB"}, + } + + def enrich(self, to_enrich: Metadata) -> None: + url = to_enrich.get_url() + logger.debug(f"calculating media hashes for {url=} (using {self.algorithm})") + + for i, m in enumerate(to_enrich.media): + if len(hd := self.calculate_hash(m.filename)): + to_enrich.media[i].set("hash", f"{self.algorithm}:{hd}") + + def calculate_hash(self, filename): + hash = None + if self.algorithm == "SHA-256": + hash = hashlib.sha256() + elif self.algorithm == "SHA3-512": + hash = hashlib.sha3_512() + else: return "" + with open(filename, "rb") as f: + while True: + buf = f.read(self.chunksize) + if not buf: break + hash.update(buf) + return hash.hexdigest() diff --git a/src/auto_archiver/enrichers/screenshot_enricher.py b/src/auto_archiver/enrichers/screenshot_enricher.py new file mode 100644 index 0000000..7be01f0 --- /dev/null +++ b/src/auto_archiver/enrichers/screenshot_enricher.py @@ -0,0 +1,38 @@ +from loguru import logger +import time, uuid, os +from selenium.common.exceptions import TimeoutException + +from . import Enricher +from ..utils import Webdriver, UrlUtil +from ..core import Media, Metadata, ArchivingContext + +class ScreenshotEnricher(Enricher): + name = "screenshot_enricher" + + @staticmethod + def configs() -> dict: + return { + "width": {"default": 1280, "help": "width of the screenshots"}, + "height": {"default": 720, "help": "height of the screenshots"}, + "timeout": {"default": 60, "help": "timeout for taking the screenshot"}, + "sleep_before_screenshot": {"default": 4, "help": "seconds to wait for the pages to load before taking screenshot"} + } + + def enrich(self, to_enrich: Metadata) -> None: + url = to_enrich.get_url() + if UrlUtil.is_auth_wall(url): + logger.debug(f"[SKIP] SCREENSHOT since url is behind AUTH WALL: {url=}") + return + + logger.debug(f"Enriching screenshot for {url=}") + with Webdriver(self.width, self.height, self.timeout, 'facebook.com' in url) as driver: + try: + driver.get(url) + time.sleep(int(self.sleep_before_screenshot)) + screenshot_file = os.path.join(ArchivingContext.get_tmp_dir(), f"screenshot_{str(uuid.uuid4())[0:8]}.png") + driver.save_screenshot(screenshot_file) + to_enrich.add_media(Media(filename=screenshot_file), id="screenshot") + except TimeoutException: + logger.info("TimeoutException loading page for screenshot") + except Exception as e: + logger.error(f"Got error while loading webdriver for screenshot enricher: {e}") diff --git a/src/auto_archiver/enrichers/thumbnail_enricher.py b/src/auto_archiver/enrichers/thumbnail_enricher.py new file mode 100644 index 0000000..480f186 --- /dev/null +++ b/src/auto_archiver/enrichers/thumbnail_enricher.py @@ -0,0 +1,45 @@ +import ffmpeg, os, uuid +from loguru import logger + +from . import Enricher +from ..core import Media, Metadata, ArchivingContext + + +class ThumbnailEnricher(Enricher): + """ + Generates thumbnails for all the media + """ + name = "thumbnail_enricher" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + @staticmethod + def configs() -> dict: + return {} + + def enrich(self, to_enrich: Metadata) -> None: + logger.debug(f"generating thumbnails") + for i, m in enumerate(to_enrich.media[::]): + if m.is_video(): + folder = os.path.join(ArchivingContext.get_tmp_dir(), str(uuid.uuid4())) + os.makedirs(folder, exist_ok=True) + logger.debug(f"generating thumbnails for {m.filename}") + fps, duration = 0.5, m.get("duration") + if duration is not None: + duration = float(duration) + if duration < 60: fps = 10.0 / duration + elif duration < 120: fps = 20.0 / duration + else: fps = 40.0 / duration + + stream = ffmpeg.input(m.filename) + stream = ffmpeg.filter(stream, 'fps', fps=fps).filter('scale', 512, -1) + stream.output(os.path.join(folder, 'out%d.jpg')).run() + + thumbnails = os.listdir(folder) + thumbnails_media = [] + for t, fname in enumerate(thumbnails): + if fname[-3:] == 'jpg': + thumbnails_media.append(Media(filename=os.path.join(folder, fname)).set("id", f"thumbnail_{t}")) + to_enrich.media[i].set("thumbnails", thumbnails_media) diff --git a/src/auto_archiver/enrichers/wacz_enricher.py b/src/auto_archiver/enrichers/wacz_enricher.py new file mode 100644 index 0000000..e682a08 --- /dev/null +++ b/src/auto_archiver/enrichers/wacz_enricher.py @@ -0,0 +1,100 @@ +import os, shutil, subprocess, uuid +from loguru import logger + +from ..core import Media, Metadata, ArchivingContext +from . import Enricher +from ..utils import UrlUtil + + +class WaczEnricher(Enricher): + """ + Submits the current URL to the webarchive and returns a job_id or completed archive + """ + name = "wacz_enricher" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + @staticmethod + def configs() -> dict: + return { + "profile": {"default": None, "help": "browsertrix-profile (for profile generation see https://github.com/webrecorder/browsertrix-crawler#creating-and-using-browser-profiles)."}, + "timeout": {"default": 90, "help": "timeout for WACZ generation in seconds"}, + "ignore_auth_wall": {"default": True, "help": "skip URL if it is behind authentication wall, set to False if you have browsertrix profile configured for private content."}, + } + + def enrich(self, to_enrich: Metadata) -> bool: + # TODO: figure out support for browsertrix in docker + + url = to_enrich.get_url() + + if UrlUtil.is_auth_wall(url): + logger.debug(f"[SKIP] SCREENSHOT since url is behind AUTH WALL: {url=}") + return + + collection = str(uuid.uuid4())[0:8] + browsertrix_home = os.path.abspath(ArchivingContext.get_tmp_dir()) + + if os.getenv('RUNNING_IN_DOCKER'): + logger.debug(f"generating WACZ without Docker for {url=}") + + cmd = [ + "crawl", + "--url", url, + "--scopeType", "page", + "--generateWACZ", + "--text", + "--collection", collection, + "--id", collection, + "--saveState", "never", + "--behaviors", "autoscroll,autoplay,autofetch,siteSpecific", + "--behaviorTimeout", str(self.timeout), + "--timeout", str(self.timeout), + "--profile", str(self.profile) + ] + else: + logger.debug(f"generating WACZ in Docker for {url=}") + + cmd = [ + "docker", "run", + "--rm", # delete container once it has completed running + "-v", f"{browsertrix_home}:/crawls/", + # "-it", # this leads to "the input device is not a TTY" + "webrecorder/browsertrix-crawler", "crawl", + "--url", url, + "--scopeType", "page", + "--generateWACZ", + "--text", + "--collection", collection, + "--behaviors", "autoscroll,autoplay,autofetch,siteSpecific", + "--behaviorTimeout", str(self.timeout), + "--timeout", str(self.timeout) + ] + + if self.profile: + profile_fn = os.path.join(browsertrix_home, "profile.tar.gz") + shutil.copyfile(self.profile, profile_fn) + # TODO: test which is right + cmd.extend(["--profile", profile_fn]) + # cmd.extend(["--profile", "/crawls/profile.tar.gz"]) + + try: + logger.info(f"Running browsertrix-crawler: {' '.join(cmd)}") + subprocess.run(cmd, check=True) + except Exception as e: + logger.error(f"WACZ generation failed: {e}") + return False + + + + if os.getenv('RUNNING_IN_DOCKER'): + filename = os.path.join("collections", collection, f"{collection}.wacz") + else: + filename = os.path.join(browsertrix_home, "collections", collection, f"{collection}.wacz") + + if not os.path.exists(filename): + logger.warning(f"Unable to locate and upload WACZ {filename=}") + return False + + to_enrich.add_media(Media(filename), "browsertrix") diff --git a/src/auto_archiver/enrichers/wayback_enricher.py b/src/auto_archiver/enrichers/wayback_enricher.py new file mode 100644 index 0000000..45ecb7d --- /dev/null +++ b/src/auto_archiver/enrichers/wayback_enricher.py @@ -0,0 +1,91 @@ +from loguru import logger +import time, requests + + +from . import Enricher +from ..archivers import Archiver +from ..utils import UrlUtil +from ..core import Metadata + +class WaybackArchiverEnricher(Enricher, Archiver): + """ + Submits the current URL to the webarchive and returns a job_id or completed archive + """ + name = "wayback_archiver_enricher" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + assert type(self.secret) == str and len(self.secret) > 0, "please provide a value for the wayback_enricher API key" + assert type(self.secret) == str and len(self.secret) > 0, "please provide a value for the wayback_enricher API secret" + + @staticmethod + def configs() -> dict: + return { + "timeout": {"default": 15, "help": "seconds to wait for successful archive confirmation from wayback, if more than this passes the result contains the job_id so the status can later be checked manually."}, + "key": {"default": None, "help": "wayback API key. to get credentials visit https://archive.org/account/s3.php"}, + "secret": {"default": None, "help": "wayback API secret. to get credentials visit https://archive.org/account/s3.php"} + } + + def download(self, item: Metadata) -> Metadata: + result = Metadata() + result.merge(item) + if self.enrich(result): + return result.success("wayback") + + def enrich(self, to_enrich: Metadata) -> bool: + url = to_enrich.get_url() + if UrlUtil.is_auth_wall(url): + logger.debug(f"[SKIP] WAYBACK since url is behind AUTH WALL: {url=}") + return + + logger.debug(f"calling wayback for {url=}") + + if to_enrich.get("wayback"): + logger.info(f"Wayback enricher had already been executed: {to_enrich.get('wayback')}") + return True + + ia_headers = { + "Accept": "application/json", + "Authorization": f"LOW {self.key}:{self.secret}" + } + r = requests.post('https://web.archive.org/save/', headers=ia_headers, data={'url': url}) + + if r.status_code != 200: + logger.error(em := f"Internet archive failed with status of {r.status_code}: {r.json()}") + to_enrich.set("wayback", em) + return False + + # check job status + job_id = r.json().get('job_id') + if not job_id: + logger.error(f"Wayback failed with {r.json()}") + return False + + # waits at most timeout seconds until job is completed, otherwise only enriches the job_id information + start_time = time.time() + wayback_url = False + attempt = 1 + while not wayback_url and time.time() - start_time <= self.timeout: + try: + logger.debug(f"GETting status for {job_id=} on {url=} ({attempt=})") + r_status = requests.get(f'https://web.archive.org/save/status/{job_id}', headers=ia_headers) + r_json = r_status.json() + if r_status.status_code == 200 and r_json['status'] == 'success': + wayback_url = f"https://web.archive.org/web/{r_json['timestamp']}/{r_json['original_url']}" + elif r_status.status_code != 200 or r_json['status'] != 'pending': + logger.error(f"Wayback failed with {r_json}") + return False + + except Exception as e: + logger.warning(f"error fetching status for {url=} due to: {e}") + if not wayback_url: + attempt += 1 + time.sleep(1) # TODO: can be improved with exponential backoff + + if wayback_url: + to_enrich.set("wayback", wayback_url) + else: + to_enrich.set("wayback", {"job_id": job_id, "check_status": f'https://web.archive.org/save/status/{job_id}'}) + to_enrich.set("check wayback", f"https://web.archive.org/web/*/{url}") + return True diff --git a/src/auto_archiver/enrichers/whisper_enricher.py b/src/auto_archiver/enrichers/whisper_enricher.py new file mode 100644 index 0000000..61a0870 --- /dev/null +++ b/src/auto_archiver/enrichers/whisper_enricher.py @@ -0,0 +1,130 @@ +import traceback +import requests, time +from loguru import logger + +from . import Enricher +from ..core import Metadata, Media, ArchivingContext +from ..storages import S3Storage + + +class WhisperEnricher(Enricher): + """ + Connects with a Whisper API service to get texts out of audio + whisper API repository: TODO + Only works if an S3 compatible storage is used + """ + name = "whisper_enricher" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + assert type(self.api_key) == str and len(self.api_key) > 0, "please provide a value for the whisper_enricher api_key" + self.timeout = int(self.timeout) + + @staticmethod + def configs() -> dict: + return { + "api_endpoint": {"default": "https://whisper.spoettel.dev/api/v1", "help": "WhisperApi api endpoint"}, + "api_key": {"default": None, "help": "WhisperApi api key for authentication"}, + "include_srt": {"default": False, "help": "Whether to include a subtitle SRT (SubRip Subtitle file) for the video (can be used in video players)."}, + "timeout": {"default": 90, "help": "How many seconds to wait at most for a successful job completion."}, + "action": {"default": "translation", "help": "which Whisper operation to execute", "choices": ["transcript", "translation", "language_detection"]}, + + } + + def enrich(self, to_enrich: Metadata) -> None: + if not self._get_s3_storage(): + logger.error("WhisperEnricher: To use the WhisperEnricher you need to use S3Storage so files are accessible publicly to the whisper service being called.") + return + + url = to_enrich.get_url() + logger.debug(f"WHISPER[{self.action}]: iterating media items for {url=}.") + + job_results = {} + for i, m in enumerate(to_enrich.media): + if m.is_video() or m.is_audio(): + m.store(url=url) + try: + job_id = self.submit_job(m) + job_results[job_id] = False + logger.debug(f"JOB SUBMITTED: {job_id=} for {m.key=}") + to_enrich.media[i].set("whisper_model", {"job_id": job_id}) + except Exception as e: + logger.error(f"Failed to submit whisper job for {m.filename=} with error {e}\n{traceback.format_exc()}") + + job_results = self.check_jobs(job_results) + + for i, m in enumerate(to_enrich.media): + if m.is_video() or m.is_audio(): + job_id = to_enrich.media[i].get("whisper_model")["job_id"] + to_enrich.media[i].set("whisper_model", { + "job_id": job_id, + **(job_results[job_id] if job_results[job_id] else {"result": "incomplete or failed job"}) + }) + # append the extracted text to the content of the post so it gets written to the DBs like gsheets text column + if job_results[job_id]: + for k,v in job_results[job_id].items(): + if "_text" in k and len(v): + to_enrich.set_content(f"\n[automatic video transcript]: {v}") + + def submit_job(self, media: Media): + s3 = self._get_s3_storage() + s3_url = s3.get_cdn_url(media) + assert s3_url in media.urls, f"Could not find S3 url ({s3_url}) in list of stored media urls " + payload = { + "url": s3_url, + "type": self.action, + # "language": "string" # may be a config + } + response = requests.post(f'{self.api_endpoint}/jobs', json=payload, headers={'Authorization': f'Bearer {self.api_key}'}) + assert response.status_code == 201, f"calling the whisper api {self.api_endpoint} returned a non-success code: {response.status_code}" + logger.debug(response.json()) + return response.json()['id'] + + def check_jobs(self, job_results: dict): + start_time = time.time() + all_completed = False + while not all_completed and (time.time() - start_time) <= self.timeout: + all_completed = True + for job_id in job_results: + if job_results[job_id] != False: continue + all_completed = False # at least one not ready + try: job_results[job_id] = self.check_job(job_id) + except Exception as e: + logger.error(f"Failed to check {job_id=} with error {e}\n{traceback.format_exc()}") + if not all_completed: time.sleep(3) + return job_results + + def check_job(self, job_id): + r = requests.get(f'{self.api_endpoint}/jobs/{job_id}', headers={'Authorization': f'Bearer {self.api_key}'}) + assert r.status_code == 200, f"Job status did not respond with 200, instead with: {r.status_code}" + j = r.json() + logger.debug(f"Checked job {job_id=} with status='{j['status']}'") + if j['status'] == "processing": return False + elif j['status'] == "error": return f"Error: {j['meta']['error']}" + elif j['status'] == "success": + r_res = requests.get(f'{self.api_endpoint}/jobs/{job_id}/artifacts', headers={'Authorization': f'Bearer {self.api_key}'}) + assert r_res.status_code == 200, f"Job artifacts did not respond with 200, instead with: {r_res.status_code}" + logger.success(r_res.json()) + result = {} + for art_id, artifact in enumerate(r_res.json()): + subtitle = [] + full_text = [] + for i, d in enumerate(artifact.get("data")): + subtitle.append(f"{i+1}\n{d.get('start')} --> {d.get('end')}\n{d.get('text').strip()}") + full_text.append(d.get('text').strip()) + if not len(subtitle): continue + if self.include_srt: result[f"artifact_{art_id}_subtitle"] = "\n".join(subtitle) + result[f"artifact_{art_id}_text"] = "\n".join(full_text) + # call /delete endpoint on timely success + r_del = requests.delete(f'{self.api_endpoint}/jobs/{job_id}', headers={'Authorization': f'Bearer {self.api_key}'}) + logger.debug(f"DELETE whisper {job_id=} result: {r_del.status_code}") + return result + return False + + def _get_s3_storage(self) -> S3Storage: + try: + return next(s for s in ArchivingContext.get("storages") if s.__class__ == S3Storage) + except: + logger.warning("No S3Storage instance found in storages") + return diff --git a/src/auto_archiver/feeders/__init__.py b/src/auto_archiver/feeders/__init__.py new file mode 100644 index 0000000..f42f98f --- /dev/null +++ b/src/auto_archiver/feeders/__init__.py @@ -0,0 +1,3 @@ +from.feeder import Feeder +from .gsheet_feeder import GsheetsFeeder +from .cli_feeder import CLIFeeder \ No newline at end of file diff --git a/src/auto_archiver/feeders/cli_feeder.py b/src/auto_archiver/feeders/cli_feeder.py new file mode 100644 index 0000000..b2f0add --- /dev/null +++ b/src/auto_archiver/feeders/cli_feeder.py @@ -0,0 +1,32 @@ +from loguru import logger + +from . import Feeder +from ..core import Metadata, ArchivingContext + + +class CLIFeeder(Feeder): + name = "cli_feeder" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + if type(self.urls) != list or len(self.urls) == 0: + raise Exception("CLI Feeder did not receive any URL to process") + + @staticmethod + def configs() -> dict: + return { + "urls": { + "default": None, + "help": "URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml", + "cli_set": lambda cli_val, cur_val: list(set(cli_val.split(","))) + }, + } + + def __iter__(self) -> Metadata: + for url in self.urls: + logger.debug(f"Processing {url}") + yield Metadata().set_url(url) + ArchivingContext.set("folder", "cli") + + logger.success(f"Processed {len(self.urls)} URL(s)") diff --git a/src/auto_archiver/feeders/feeder.py b/src/auto_archiver/feeders/feeder.py new file mode 100644 index 0000000..4aa263f --- /dev/null +++ b/src/auto_archiver/feeders/feeder.py @@ -0,0 +1,21 @@ +from __future__ import annotations +from dataclasses import dataclass +from abc import abstractmethod +from ..core import Metadata +from ..core import Step + + +@dataclass +class Feeder(Step): + name = "feeder" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + def init(name: str, config: dict) -> Feeder: + # only for code typing + return Step.init(name, config, Feeder) + + @abstractmethod + def __iter__(self) -> Metadata: return None \ No newline at end of file diff --git a/src/auto_archiver/feeders/gsheet_feeder.py b/src/auto_archiver/feeders/gsheet_feeder.py new file mode 100644 index 0000000..b3ca66c --- /dev/null +++ b/src/auto_archiver/feeders/gsheet_feeder.py @@ -0,0 +1,92 @@ +import gspread, os + +from loguru import logger +from slugify import slugify + +# from . import Enricher +from . import Feeder +from ..core import Metadata, ArchivingContext +from ..utils import Gsheets, GWorksheet + + +class GsheetsFeeder(Gsheets, Feeder): + name = "gsheet_feeder" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + self.gsheets_client = gspread.service_account(filename=self.service_account) + + @staticmethod + def configs() -> dict: + return dict( + Gsheets.configs(), + ** { + "allow_worksheets": { + "default": set(), + "help": "(CSV) only worksheets whose name is included in allow are included (overrides worksheet_block), leave empty so all are allowed", + "cli_set": lambda cli_val, cur_val: set(cli_val.split(",")) + }, + "block_worksheets": { + "default": set(), + "help": "(CSV) explicitly block some worksheets from being processed", + "cli_set": lambda cli_val, cur_val: set(cli_val.split(",")) + }, + "use_sheet_names_in_stored_paths": { + "default": True, + "help": "if True the stored files path will include 'workbook_name/worksheet_name/...'", + } + }) + + def __iter__(self) -> Metadata: + sh = self.gsheets_client.open(self.sheet) + for ii, wks in enumerate(sh.worksheets()): + if not self.should_process_sheet(wks.title): + logger.debug(f"SKIPPED worksheet '{wks.title}' due to allow/block rules") + continue + + logger.info(f'Opening worksheet {ii=}: {wks.title=} header={self.header}') + gw = GWorksheet(wks, header_row=self.header, columns=self.columns) + + if len(missing_cols := self.missing_required_columns(gw)): + logger.warning(f"SKIPPED worksheet '{wks.title}' due to missing required column(s) for {missing_cols}") + continue + + for row in range(1 + self.header, gw.count_rows() + 1): + url = gw.get_cell(row, 'url').strip() + if not len(url): continue + + original_status = gw.get_cell(row, 'status') + status = gw.get_cell(row, 'status', fresh=original_status in ['', None]) + # TODO: custom status parser(?) aka should_retry_from_status + if status not in ['', None]: continue + + # All checks done - archival process starts here + m = Metadata().set_url(url) + ArchivingContext.set("gsheet", {"row": row, "worksheet": gw}, keep_on_reset=True) + folder = slugify(gw.get_cell(row, 'folder').strip()) + if len(folder): + if self.use_sheet_names_in_stored_paths: + ArchivingContext.set("folder", os.path.join(folder, slugify(self.sheet), slugify(wks.title)), True) + else: + ArchivingContext.set("folder", folder, True) + + yield m + + logger.success(f'Finished worksheet {wks.title}') + + def should_process_sheet(self, sheet_name: str) -> bool: + if len(self.allow_worksheets) and sheet_name not in self.allow_worksheets: + # ALLOW rules exist AND sheet name not explicitly allowed + return False + if len(self.block_worksheets) and sheet_name in self.block_worksheets: + # BLOCK rules exist AND sheet name is blocked + return False + return True + + def missing_required_columns(self, gw: GWorksheet) -> list: + missing = [] + for required_col in ['url', 'status']: + if not gw.col_exists(required_col): + missing.append(required_col) + return missing diff --git a/src/auto_archiver/formatters/__init__.py b/src/auto_archiver/formatters/__init__.py new file mode 100644 index 0000000..ce8afac --- /dev/null +++ b/src/auto_archiver/formatters/__init__.py @@ -0,0 +1,3 @@ +from .formatter import Formatter +from .html_formatter import HtmlFormatter +from .mute_formatter import MuteFormatter \ No newline at end of file diff --git a/src/auto_archiver/formatters/formatter.py b/src/auto_archiver/formatters/formatter.py new file mode 100644 index 0000000..b10477e --- /dev/null +++ b/src/auto_archiver/formatters/formatter.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from dataclasses import dataclass +from abc import abstractmethod +from ..core import Metadata, Media, Step + + +@dataclass +class Formatter(Step): + name = "formatter" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + def init(name: str, config: dict) -> Formatter: + # only for code typing + return Step.init(name, config, Formatter) + + @abstractmethod + def format(self, item: Metadata) -> Media: return None \ No newline at end of file diff --git a/src/auto_archiver/formatters/html_formatter.py b/src/auto_archiver/formatters/html_formatter.py new file mode 100644 index 0000000..2d859f3 --- /dev/null +++ b/src/auto_archiver/formatters/html_formatter.py @@ -0,0 +1,90 @@ +from __future__ import annotations +from dataclasses import dataclass +import mimetypes, uuid, os, pathlib +from jinja2 import Environment, FileSystemLoader +from urllib.parse import quote +from loguru import logger + +from ..version import __version__ +from ..core import Metadata, Media, ArchivingContext +from . import Formatter +from ..enrichers import HashEnricher + + +@dataclass +class HtmlFormatter(Formatter): + name = "html_formatter" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + self.environment = Environment(loader=FileSystemLoader(os.path.join(pathlib.Path(__file__).parent.resolve(), "templates/"))) + # JinjaHelper class static methods are added as filters + self.environment.filters.update({ + k: v.__func__ for k, v in JinjaHelpers.__dict__.items() if isinstance(v, staticmethod) + }) + self.template = self.environment.get_template("html_template.html") + + @staticmethod + def configs() -> dict: + return { + "detect_thumbnails": {"default": True, "help": "if true will group by thumbnails generated by thumbnail enricher by id 'thumbnail_00'"} + } + + def format(self, item: Metadata) -> Media: + url = item.get_url() + if item.is_empty(): + logger.debug(f"[SKIP] FORMAT there is no media or metadata to format: {url=}") + return + + content = self.template.render( + url=url, + title=item.get_title(), + media=item.media, + metadata=item.metadata, + version=__version__ + ) + html_path = os.path.join(ArchivingContext.get_tmp_dir(), f"formatted{str(uuid.uuid4())}.html") + with open(html_path, mode="w", encoding="utf-8") as outf: + outf.write(content) + final_media = Media(filename=html_path) + + he = HashEnricher({"hash_enricher": {"algorithm": ArchivingContext.get("hash_enricher.algorithm"), "chunksize": 1.6e7}}) + if len(hd := he.calculate_hash(final_media.filename)): + final_media.set("hash", f"{he.algorithm}:{hd}") + + return final_media + + +# JINJA helper filters +class JinjaHelpers: + @staticmethod + def is_list(v) -> bool: + return isinstance(v, list) + + @staticmethod + def is_video(s: str) -> bool: + m = mimetypes.guess_type(s)[0] + return "video" in (m or "") + + @staticmethod + def is_image(s: str) -> bool: + m = mimetypes.guess_type(s)[0] + return "image" in (m or "") + + @staticmethod + def is_audio(s: str) -> bool: + m = mimetypes.guess_type(s)[0] + return "audio" in (m or "") + + @staticmethod + def is_media(v) -> bool: + return isinstance(v, Media) + + @staticmethod + def get_extension(filename: str) -> str: + return os.path.splitext(filename)[1] + + @staticmethod + def quote(s: str) -> str: + return quote(s) diff --git a/src/auto_archiver/formatters/mute_formatter.py b/src/auto_archiver/formatters/mute_formatter.py new file mode 100644 index 0000000..19830b1 --- /dev/null +++ b/src/auto_archiver/formatters/mute_formatter.py @@ -0,0 +1,16 @@ +from __future__ import annotations +from dataclasses import dataclass + +from ..core import Metadata, Media +from . import Formatter + + +@dataclass +class MuteFormatter(Formatter): + name = "mute_formatter" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + + def format(self, item: Metadata) -> Media: return None diff --git a/src/auto_archiver/formatters/templates/__init__.py b/src/auto_archiver/formatters/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/auto_archiver/formatters/templates/html_template.html b/src/auto_archiver/formatters/templates/html_template.html new file mode 100644 index 0000000..f9e53d9 --- /dev/null +++ b/src/auto_archiver/formatters/templates/html_template.html @@ -0,0 +1,217 @@ +{# templates/results.html #} +{% import 'macros.html' as macros %} + + + + + + + {{ url }} + + + + +
+

Archived media for {{ url }}

+

title: '{{ title }}'

+

content {{ media | length }} item(s)

+ + + + + + {% for m in media %} + + + + + {% endfor %} +
aboutpreview(s)
+
    +
  • key: {{ m.key }}
  • +
  • type: {{ m.mimetype }}
  • + + {% for prop in m.properties %} + + {% if m.properties[prop] | is_list %} +

    +
    + {{ prop }}: +

    +
    + {% for subprop in m.properties[prop] %} + {% if subprop | is_media %} + {{ macros.display_media(subprop, false, url) }} + {% else %} + {{ subprop }} + {% endif %} + {% endfor %} +
    +
    +

    + {% elif m.properties[prop] | string | length > 1 %} +
  • {{ prop }}: {{ macros.copy_urlize(m.properties[prop]) }}
  • + {% endif %} + + {% endfor %} +
+
+ {{ macros.display_media(m, true, url) }} +
+

metadata

+ + + + + + {% for key in metadata %} + + + + + {% endfor %} + + +

Made with bellingcat/auto-archiver v{{ version }}

+ + + + \ No newline at end of file diff --git a/src/auto_archiver/formatters/templates/macros.html b/src/auto_archiver/formatters/templates/macros.html new file mode 100644 index 0000000..e72f4f3 --- /dev/null +++ b/src/auto_archiver/formatters/templates/macros.html @@ -0,0 +1,77 @@ +{% macro display_media(m, links, main_url) -%} + +{% for url in m.urls %} +{% if url | length == 0 %} +No URL available for {{ m.key }}. +{% elif 'http' in url %} +{% if 'image' in m.mimetype %} +
+ + + + +
+ Reverse Image Search:  + Google,  + Google Lens,  + Yandex,  + Bing,  + Tineye,  + IQDB,  + SauceNAO,  + IMGOPS +
+

+
+{% elif 'video' in m.mimetype %} +
+ +
+{% elif 'audio' in m.mimetype %} +
+ +
+{% elif m.filename | get_extension == ".wacz" %} +replayweb +{% else %} +No preview available for {{ m.key }}. +{% endif %} +{% else %} +{{ m.url | urlize }} +{% endif %} +{% if links %} +open or +download or +{{ copy_urlize(url, "copy") }} + +
+{% endif %} +{% endfor %} + +{%- endmacro -%} + +{% macro copy_urlize(val, href_text) -%} + +{% if val is mapping %} +
    + {% for key in val %} +
  • + {{ key }}: {{ copy_urlize(val[key]) }} +
  • + {% endfor %} +
+ +{% else %} +{% if href_text | length == 0 %} +{{ val | string | urlize }} +{% else %} +{{ href_text | string | urlize }} +{% endif %} +{% endif %} + +{%- endmacro -%} \ No newline at end of file diff --git a/src/auto_archiver/storages/__init__.py b/src/auto_archiver/storages/__init__.py new file mode 100644 index 0000000..c375f8e --- /dev/null +++ b/src/auto_archiver/storages/__init__.py @@ -0,0 +1,4 @@ +from .storage import Storage +from .s3 import S3Storage +from .local import LocalStorage +from .gd import GDriveStorage \ No newline at end of file diff --git a/storages/gd_storage.py b/src/auto_archiver/storages/gd.py similarity index 67% rename from storages/gd_storage.py rename to src/auto_archiver/storages/gd.py index 5f3bbeb..7dbbb21 100644 --- a/storages/gd_storage.py +++ b/src/auto_archiver/storages/gd.py @@ -1,32 +1,27 @@ -import os, time +import shutil, os, time, json +from typing import IO from loguru import logger -from .base_storage import Storage -from dataclasses import dataclass + from googleapiclient.discovery import build from googleapiclient.http import MediaFileUpload from google.oauth2 import service_account - - from google.oauth2.credentials import Credentials from google.auth.transport.requests import Request -@dataclass -class GDConfig: - root_folder_id: str - oauth_token_filename: str - service_account: str = "service_account.json" - folder: str = "default" +from ..core import Media +from . import Storage -class GDStorage(Storage): - def __init__(self, config: GDConfig): - self.folder = config.folder - self.root_folder_id = config.root_folder_id - - SCOPES=['https://www.googleapis.com/auth/drive'] - - token_file = config.oauth_token_filename - if token_file is not None: + +class GDriveStorage(Storage): + name = "gdrive_storage" + + def __init__(self, config: dict) -> None: + super().__init__(config) + + SCOPES = ['https://www.googleapis.com/auth/drive'] + + if self.oauth_token is not None: """ Tokens are refreshed after 1 hour however keep working for 7 days (tbc) @@ -35,8 +30,13 @@ class GDStorage(Storage): see this link for details on the token https://davemateer.com/2022/04/28/google-drive-with-python#tokens """ - logger.debug(f'Using GD OAuth token {token_file}') - creds = Credentials.from_authorized_user_file(token_file, SCOPES) + logger.debug(f'Using GD OAuth token {self.oauth_token}') + # workaround for missing 'refresh_token' in from_authorized_user_file + with open(self.oauth_token, 'r') as stream: + creds_json = json.load(stream) + creds_json['refresh_token'] = creds_json.get("refresh_token", "") + creds = Credentials.from_authorized_user_info(creds_json, SCOPES) + # creds = Credentials.from_authorized_user_file(self.oauth_token, SCOPES) if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: @@ -46,7 +46,7 @@ class GDStorage(Storage): raise Exception("Problem with creds - create the token again") # Save the credentials for the next run - with open(token_file, 'w') as token: + with open(self.oauth_token, 'w') as token: logger.debug('Saving new GD OAuth token') token.write(creds.to_json()) else: @@ -58,18 +58,27 @@ class GDStorage(Storage): self.service = build('drive', 'v3', credentials=creds) - def get_cdn_url(self, key): + @staticmethod + def configs() -> dict: + return dict( + Storage.configs(), + ** { + "root_folder_id": {"default": None, "help": "root google drive folder ID to use as storage, found in URL: 'https://drive.google.com/drive/folders/FOLDER_ID'"}, + "oauth_token": {"default": None, "help": "JSON filename with Google Drive OAuth token: check auto-archiver repository scripts folder for create_update_gdrive_oauth_token.py. NOTE: storage used will count towards owner of GDrive folder, therefore it is best to use oauth_token_filename over service_account."}, + "service_account": {"default": "secrets/service_account.json", "help": "service account JSON file path, same as used for Google Sheets. NOTE: storage used will count towards the developer account."}, + }) + + def get_cdn_url(self, media: Media) -> str: """ only support files saved in a folder for GD S3 supports folder and all stored in the root """ - key = self.clean_key(key) - full_name = os.path.join(self.folder, key) + # full_name = os.path.join(self.folder, media.key) parent_id, folder_id = self.root_folder_id, None - path_parts = full_name.split(os.path.sep) + path_parts = media.key.split(os.path.sep) filename = path_parts[-1] - logger.info(f"looking for folders for {path_parts[0:-1]} before uploading {filename=}") + logger.info(f"looking for folders for {path_parts[0:-1]} before getting url for {filename=}") for folder in path_parts[0:-1]: folder_id = self._get_id_from_parent_and_name(parent_id, folder, use_mime_type=True, raise_on_missing=True) parent_id = folder_id @@ -78,22 +87,23 @@ class GDStorage(Storage): file_id = self._get_id_from_parent_and_name(folder_id, filename) return f"https://drive.google.com/file/d/{file_id}/view?usp=sharing" - def exists(self, key): - try: - self.get_cdn_url(key) - return True - except: return False + def upload(self, media: Media, **kwargs) -> bool: + # override parent so that we can use shutil.copy2 and keep metadata + dest = os.path.join(self.save_to, media.key) + os.makedirs(os.path.dirname(dest), exist_ok=True) + logger.debug(f'[{self.__class__.name}] storing file {media.filename} with key {media.key} to {dest}') + res = shutil.copy2(media.filename, dest) + logger.info(res) + return True - def uploadf(self, file: str, key: str, **_kwargs): + def upload(self, media: Media, **kwargs) -> bool: + logger.debug(f'[{self.__class__.name}] storing file {media.filename} with key {media.key}') """ 1. for each sub-folder in the path check if exists or create 2. upload file to root_id/other_paths.../filename """ - key = self.clean_key(key) - - full_name = os.path.join(self.folder, key) parent_id, upload_to = self.root_folder_id, None - path_parts = full_name.split(os.path.sep) + path_parts = media.key.split(os.path.sep) filename = path_parts[-1] logger.info(f"checking folders {path_parts[0:-1]} exist (or creating) before uploading {filename=}") for folder in path_parts[0:-1]: @@ -108,22 +118,13 @@ class GDStorage(Storage): 'name': [filename], 'parents': [upload_to] } - media = MediaFileUpload(file, resumable=True) + media = MediaFileUpload(media.filename, resumable=True) gd_file = self.service.files().create(body=file_metadata, media_body=media, fields='id').execute() - logger.debug(f'uploadf: uploaded file {gd_file["id"]} succesfully in folder={upload_to}') + logger.debug(f'uploadf: uploaded file {gd_file["id"]} successfully in folder={upload_to}') - def upload(self, filename: str, key: str, **kwargs): - # GD only requires the filename not a file reader - self.uploadf(filename, key, **kwargs) + # must be implemented even if unused + def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool: pass - def clean_key(self, key): - # GDrive does not work well with trailing forward slashes and some keys come with that - if key.startswith('/'): - logger.debug(f'Found and fixed a leading "/" for {key=}') - return key[1:] - return key - - # gets the Drive folderID if it is there def _get_id_from_parent_and_name(self, parent_id: str, name: str, retries: int = 1, sleep_seconds: int = 10, use_mime_type: bool = False, raise_on_missing: bool = True, use_cache=False): """ Retrieves the id of a folder or file from its @name and the @parent_id folder @@ -183,3 +184,9 @@ class GDStorage(Storage): } gd_folder = self.service.files().create(body=file_metadata, fields='id').execute() return gd_folder.get('id') + + # def exists(self, key): + # try: + # self.get_cdn_url(key) + # return True + # except: return False diff --git a/src/auto_archiver/storages/local.py b/src/auto_archiver/storages/local.py new file mode 100644 index 0000000..aa08e49 --- /dev/null +++ b/src/auto_archiver/storages/local.py @@ -0,0 +1,44 @@ + +import shutil +from typing import IO +import os +from loguru import logger + +from ..core import Media +from ..storages import Storage + + +class LocalStorage(Storage): + name = "local_storage" + + def __init__(self, config: dict) -> None: + super().__init__(config) + os.makedirs(self.save_to, exist_ok=True) + + @staticmethod + def configs() -> dict: + return dict( + Storage.configs(), + ** { + "save_to": {"default": "./archived", "help": "folder where to save archived content"}, + "save_absolute": {"default": False, "help": "whether the path to the stored file is absolute or relative in the output result inc. formatters (WARN: leaks the file structure)"}, + }) + + def get_cdn_url(self, media: Media) -> str: + # TODO: is this viable with Storage.configs on path/filename? + dest = os.path.join(self.save_to, media.key) + if self.save_absolute: + dest = os.path.abspath(dest) + return dest + + def upload(self, media: Media, **kwargs) -> bool: + # override parent so that we can use shutil.copy2 and keep metadata + dest = os.path.join(self.save_to, media.key) + os.makedirs(os.path.dirname(dest), exist_ok=True) + logger.debug(f'[{self.__class__.name}] storing file {media.filename} with key {media.key} to {dest}') + res = shutil.copy2(media.filename, dest) + logger.info(res) + return True + + # must be implemented even if unused + def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool: pass diff --git a/src/auto_archiver/storages/s3.py b/src/auto_archiver/storages/s3.py new file mode 100644 index 0000000..37370f4 --- /dev/null +++ b/src/auto_archiver/storages/s3.py @@ -0,0 +1,73 @@ + +from typing import IO, Any +import boto3, uuid, os, mimetypes +from botocore.errorfactory import ClientError +from ..core import Metadata +from ..core import Media +from ..storages import Storage +from loguru import logger +from slugify import slugify + + +class S3Storage(Storage): + name = "s3_storage" + + def __init__(self, config: dict) -> None: + super().__init__(config) + self.s3 = boto3.client( + 's3', + region_name=self.region, + endpoint_url=self.endpoint_url.format(region=self.region), + aws_access_key_id=self.key, + aws_secret_access_key=self.secret + ) + + @staticmethod + def configs() -> dict: + return dict( + Storage.configs(), + ** { + "bucket": {"default": None, "help": "S3 bucket name"}, + "region": {"default": None, "help": "S3 region name"}, + "key": {"default": None, "help": "S3 API key"}, + "secret": {"default": None, "help": "S3 API secret"}, + # TODO: how to have sth like a custom folder? has to come from the feeders + "endpoint_url": { + "default": 'https://{region}.digitaloceanspaces.com', + "help": "S3 bucket endpoint, {region} are inserted at runtime" + }, + "cdn_url": { + "default": 'https://{bucket}.{region}.cdn.digitaloceanspaces.com/{key}', + "help": "S3 CDN url, {bucket}, {region} and {key} are inserted at runtime" + }, + "private": {"default": False, "help": "if true S3 files will not be readable online"}, + }) + + def get_cdn_url(self, media: Media) -> str: + return self.cdn_url.format(bucket=self.bucket, region=self.region, key=media.key) + + def uploadf(self, file: IO[bytes], media: Media, **kwargs: dict) -> None: + extra_args = kwargs.get("extra_args", {}) + if not self.private and 'ACL' not in extra_args: + extra_args['ACL'] = 'public-read' + + if 'ContentType' not in extra_args: + try: + if media.mimetype: + extra_args['ContentType'] = media.mimetype + except Exception as e: + logger.warning(f"Unable to get mimetype for {media.key=}, error: {e}") + + self.s3.upload_fileobj(file, Bucket=self.bucket, Key=media.key, ExtraArgs=extra_args) + return True + + # def exists(self, key: str) -> bool: + # """ + # Tests if a given file with key=key exists in the bucket + # """ + # try: + # self.s3.head_object(Bucket=self.bucket, Key=key) + # return True + # except ClientError as e: + # logger.warning(f"got a ClientError when checking if {key=} exists in bucket={self.bucket}: {e}") + # return False diff --git a/src/auto_archiver/storages/storage.py b/src/auto_archiver/storages/storage.py new file mode 100644 index 0000000..b708a9b --- /dev/null +++ b/src/auto_archiver/storages/storage.py @@ -0,0 +1,84 @@ +from __future__ import annotations +from abc import abstractmethod +from dataclasses import dataclass +from typing import IO + +from ..core import Media, Step, ArchivingContext +from ..enrichers import HashEnricher +from loguru import logger +import os, uuid +from slugify import slugify + + +@dataclass +class Storage(Step): + name = "storage" + PATH_GENERATOR_OPTIONS = ["flat", "url", "random"] + FILENAME_GENERATOR_CHOICES = ["random", "static"] + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + assert self.path_generator in Storage.PATH_GENERATOR_OPTIONS, f"path_generator must be one of {Storage.PATH_GENERATOR_OPTIONS}" + assert self.filename_generator in Storage.FILENAME_GENERATOR_CHOICES, f"filename_generator must be one of {Storage.FILENAME_GENERATOR_CHOICES}" + + @staticmethod + def configs() -> dict: + return { + "path_generator": { + "default": "url", + "help": "how to store the file in terms of directory structure: 'flat' sets to root; 'url' creates a directory based on the provided URL; 'random' creates a random directory.", + "choices": Storage.PATH_GENERATOR_OPTIONS + }, + "filename_generator": { + "default": "random", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a replicable strategy such as a hash.", + "choices": Storage.FILENAME_GENERATOR_CHOICES + } + } + + def init(name: str, config: dict) -> Storage: + # only for typing... + return Step.init(name, config, Storage) + + def store(self, media: Media, url: str) -> None: + if media.is_stored(): + logger.debug(f"{media.key} already stored, skipping") + return + self.set_key(media, url) + self.upload(media) + media.add_url(self.get_cdn_url(media)) + + @abstractmethod + def get_cdn_url(self, media: Media) -> str: pass + + @abstractmethod + def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool: pass + + def upload(self, media: Media, **kwargs) -> bool: + logger.debug(f'[{self.__class__.name}] storing file {media.filename} with key {media.key}') + with open(media.filename, 'rb') as f: + return self.uploadf(f, media, **kwargs) + + def set_key(self, media: Media, url) -> None: + """takes the media and optionally item info and generates a key""" + if media.key is not None and len(media.key) > 0: return + folder = ArchivingContext.get("folder", "") + filename, ext = os.path.splitext(media.filename) + + # path_generator logic + if self.path_generator == "flat": + path = "" + filename = slugify(filename) # in case it comes with os.sep + elif self.path_generator == "url": path = slugify(url) + elif self.path_generator == "random": + path = ArchivingContext.get("random_path", str(uuid.uuid4())[:16], True) + + # filename_generator logic + if self.filename_generator == "random": filename = str(uuid.uuid4())[:16] + elif self.filename_generator == "static": + he = HashEnricher({"hash_enricher": {"algorithm": ArchivingContext.get("hash_enricher.algorithm"), "chunksize": 1.6e7}}) + hd = he.calculate_hash(media.filename) + filename = hd[:24] + + media.key = os.path.join(folder, path, f"{filename}{ext}") diff --git a/src/auto_archiver/utils/__init__.py b/src/auto_archiver/utils/__init__.py new file mode 100644 index 0000000..42ea0f5 --- /dev/null +++ b/src/auto_archiver/utils/__init__.py @@ -0,0 +1,6 @@ +# we need to explicitly expose the available imports here +from .gworksheet import GWorksheet +from .misc import * +from .webdriver import Webdriver +from .gsheet import Gsheets +from .url import UrlUtil \ No newline at end of file diff --git a/src/auto_archiver/utils/gsheet.py b/src/auto_archiver/utils/gsheet.py new file mode 100644 index 0000000..0980d8f --- /dev/null +++ b/src/auto_archiver/utils/gsheet.py @@ -0,0 +1,44 @@ +import json, gspread + +from ..core import Step + + +class Gsheets(Step): + name = "gsheets" + + def __init__(self, config: dict) -> None: + # without this STEP.__init__ is not called + super().__init__(config) + self.gsheets_client = gspread.service_account(filename=self.service_account) + #TODO: config should be responsible for conversions + try: self.header = int(self.header) + except: pass + assert type(self.header) == int, f"header ({self.header}) value must be an integer not {type(self.header)}" + assert self.sheet is not None, "You need to define a sheet name in your orchestration file when using gsheets." + + @staticmethod + def configs() -> dict: + return { + "sheet": {"default": None, "help": "name of the sheet to archive"}, + "header": {"default": 1, "help": "index of the header row (starts at 1)"}, + "service_account": {"default": "secrets/service_account.json", "help": "service account JSON file path"}, + "columns": { + "default": { + 'url': 'link', + 'status': 'archive status', + 'folder': 'destination folder', + 'archive': 'archive location', + 'date': 'archive date', + 'thumbnail': 'thumbnail', + 'timestamp': 'upload timestamp', + 'title': 'upload title', + 'text': 'text content', + 'screenshot': 'screenshot', + 'hash': 'hash', + 'wacz': 'wacz', + 'replaywebpage': 'replaywebpage', + }, + "help": "names of columns in the google sheet (stringified JSON object)", + "cli_set": lambda cli_val, cur_val: dict(cur_val, **json.loads(cli_val)) + }, + } \ No newline at end of file diff --git a/utils/gworksheet.py b/src/auto_archiver/utils/gworksheet.py similarity index 94% rename from utils/gworksheet.py rename to src/auto_archiver/utils/gworksheet.py index 8fe640e..1b85eab 100644 --- a/utils/gworksheet.py +++ b/src/auto_archiver/utils/gworksheet.py @@ -15,10 +15,8 @@ class GWorksheet: 'archive': 'archive location', 'date': 'archive date', 'thumbnail': 'thumbnail', - 'thumbnail_index': 'thumbnail index', 'timestamp': 'upload timestamp', 'title': 'upload title', - 'duration': 'duration', 'screenshot': 'screenshot', 'hash': 'hash', 'wacz': 'wacz', @@ -40,11 +38,11 @@ class GWorksheet: def _col_index(self, col: str): self._check_col_exists(col) - return self.headers.index(self.columns[col]) + return self.headers.index(self.columns[col].lower()) def col_exists(self, col: str): self._check_col_exists(col) - return self.columns[col] in self.headers + return self.columns[col].lower() in self.headers def count_rows(self): return len(self.values) @@ -98,7 +96,7 @@ class GWorksheet: cell_updates = [ { 'range': self.to_a1(row, col), - 'values': [[val]] + 'values': [[str(val)[0:49999]]] } for row, col, val in cell_updates ] diff --git a/utils/misc.py b/src/auto_archiver/utils/misc.py similarity index 63% rename from utils/misc.py rename to src/auto_archiver/utils/misc.py index 644c713..e7c5427 100644 --- a/utils/misc.py +++ b/src/auto_archiver/utils/misc.py @@ -29,3 +29,14 @@ def getattr_or(o: object, prop: str, default=None): except: return default + +class DateTimeEncoder(json.JSONEncoder): + # to allow json.dump with datetimes do json.dumps(obj, cls=DateTimeEncoder) + def default(self, o): + if isinstance(o, datetime): + return str(o) # with timezone + return json.JSONEncoder.default(self, o) + + +def dump_payload(p): + return json.dumps(p, ensure_ascii=False, indent=4, cls=DateTimeEncoder) diff --git a/src/auto_archiver/utils/url.py b/src/auto_archiver/utils/url.py new file mode 100644 index 0000000..c854405 --- /dev/null +++ b/src/auto_archiver/utils/url.py @@ -0,0 +1,19 @@ +import re + +class UrlUtil: + telegram_private = re.compile(r"https:\/\/t\.me(\/c)\/(.+)\/(\d+)") + is_istagram = re.compile(r"https:\/\/www\.instagram\.com") + + @staticmethod + def clean(url): return url + + @staticmethod + def is_auth_wall(url): + """ + checks if URL is behind an authentication wall meaning steps like wayback, wacz, ... may not work + """ + if UrlUtil.telegram_private.match(url): return True + if UrlUtil.is_istagram.match(url): return True + + return False + diff --git a/src/auto_archiver/utils/webdriver.py b/src/auto_archiver/utils/webdriver.py new file mode 100644 index 0000000..5ce0374 --- /dev/null +++ b/src/auto_archiver/utils/webdriver.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from selenium import webdriver +from selenium.common.exceptions import TimeoutException +from loguru import logger +from selenium.webdriver.common.by import By +import time + + +class Webdriver: + def __init__(self, width: int, height: int, timeout_seconds: int, facebook_accept_cookies: bool = False) -> webdriver: + self.width = width + self.height = height + self.timeout_seconds = timeout_seconds + self.facebook_accept_cookies = facebook_accept_cookies + + def __enter__(self) -> webdriver: + options = webdriver.FirefoxOptions() + options.headless = True + options.set_preference('network.protocol-handler.external.tg', False) + try: + self.driver = webdriver.Firefox(options=options) + self.driver.set_window_size(self.width, self.height) + self.driver.set_page_load_timeout(self.timeout_seconds) + except TimeoutException as e: + logger.error(f"failed to get new webdriver, possibly due to insufficient system resources or timeout settings: {e}") + + if self.facebook_accept_cookies: + try: + logger.debug(f'Trying fb click accept cookie popup.') + self.driver.get("http://www.facebook.com") + foo = self.driver.find_element(By.XPATH, "//button[@data-cookiebanner='accept_only_essential_button']") + foo.click() + logger.debug(f'fb click worked') + # linux server needs a sleep otherwise facebook cookie won't have worked and we'll get a popup on next page + time.sleep(2) + except: + logger.warning(f'Failed on fb accept cookies.') + + return self.driver + + def __exit__(self, exc_type, exc_val, exc_tb): + self.driver.close() + self.driver.quit() + del self.driver + return True diff --git a/src/auto_archiver/version.py b/src/auto_archiver/version.py new file mode 100644 index 0000000..0fa171c --- /dev/null +++ b/src/auto_archiver/version.py @@ -0,0 +1,12 @@ + +_MAJOR = "0" +_MINOR = "5" +# On main and in a nightly release the patch should be one ahead of the last +# released build. +_PATCH = "12" +# This is mainly for nightly builds which have the suffix ".dev$DATE". See +# https://semver.org/#is-v123-a-semantic-version for the semantics. +_SUFFIX = "" + +VERSION_SHORT = "{0}.{1}".format(_MAJOR, _MINOR) +__version__ = "{0}.{1}.{2}{3}".format(_MAJOR, _MINOR, _PATCH, _SUFFIX) \ No newline at end of file diff --git a/storages/__init__.py b/storages/__init__.py deleted file mode 100644 index 99f82b3..0000000 --- a/storages/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# we need to explicitly expose the available imports here -from .base_storage import Storage -from .local_storage import LocalStorage, LocalConfig -from .s3_storage import S3Config, S3Storage -from .gd_storage import GDConfig, GDStorage \ No newline at end of file diff --git a/storages/base_storage.py b/storages/base_storage.py deleted file mode 100644 index cde00fe..0000000 --- a/storages/base_storage.py +++ /dev/null @@ -1,24 +0,0 @@ -from loguru import logger -from abc import ABC, abstractmethod -from pathlib import Path - - -class Storage(ABC): - TMP_FOLDER = "tmp/" - - @abstractmethod - def __init__(self, config): pass - - @abstractmethod - def get_cdn_url(self, key): pass - - @abstractmethod - def exists(self, key): pass - - @abstractmethod - def uploadf(self, file, key, **kwargs): pass - - def upload(self, filename: str, key: str, **kwargs): - logger.debug(f'[{self.__class__.__name__}] uploading file {filename} with key {key}') - with open(filename, 'rb') as f: - self.uploadf(f, key, **kwargs) diff --git a/storages/local_storage.py b/storages/local_storage.py deleted file mode 100644 index ca328e0..0000000 --- a/storages/local_storage.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -from dataclasses import dataclass - -from .base_storage import Storage -from utils import mkdir_if_not_exists - - -@dataclass -class LocalConfig: - folder: str = "" - save_to: str = "./" - -class LocalStorage(Storage): - def __init__(self, config:LocalConfig): - self.folder = config.folder - self.save_to = config.save_to - mkdir_if_not_exists(self.save_to) - - def get_cdn_url(self, key): - full_path = os.path.join(self.save_to, self.folder, key) - mkdir_if_not_exists(os.path.join(*full_path.split(os.path.sep)[0:-1])) - return os.path.abspath(full_path) - - def exists(self, key): - return os.path.isfile(self.get_cdn_url(key)) - - def uploadf(self, file, key, **kwargs): - path = self.get_cdn_url(key) - with open(path, "wb") as outf: - outf.write(file.read()) diff --git a/storages/s3_storage.py b/storages/s3_storage.py deleted file mode 100644 index 563d2ea..0000000 --- a/storages/s3_storage.py +++ /dev/null @@ -1,80 +0,0 @@ -import uuid, os, mimetypes -from dataclasses import dataclass - -import boto3 -from botocore.errorfactory import ClientError - -from .base_storage import Storage -from dataclasses import dataclass -from loguru import logger - - -@dataclass -class S3Config: - bucket: str - region: str - key: str - secret: str - folder: str = "" - endpoint_url: str = "https://{region}.digitaloceanspaces.com" - cdn_url: str = "https://{bucket}.{region}.cdn.digitaloceanspaces.com/{key}" - private: bool = False - key_path: str = "default" # 'default' uses full naming, 'random' uses generated uuid - - -class S3Storage(Storage): - - def __init__(self, config: S3Config): - self.bucket = config.bucket - self.region = config.region - self.folder = config.folder - self.private = config.private - self.cdn_url = config.cdn_url - self.key_path = config.key_path - self.key_dict = {} - - self.s3 = boto3.client( - 's3', - region_name=config.region, - endpoint_url=config.endpoint_url.format(region=config.region), - aws_access_key_id=config.key, - aws_secret_access_key=config.secret - ) - - def _get_path(self, key): - """ - Depends on the self.key_path configuration: - * random - assigns a random UUID which can be used in conjunction with "private=false" to have unguessable documents publicly available -> self.folder/randomUUID - * default -> defaults to self.folder/key - """ - # defaults to /key - final_key = key - if self.key_path == "random": - if key not in self.key_dict: - ext = os.path.splitext(key)[1] - self.key_dict[key] = f"{str(uuid.uuid4())}{ext}" - final_key = self.key_dict[key] - return os.path.join(self.folder, final_key) - - def get_cdn_url(self, key): - return self.cdn_url.format(bucket=self.bucket, region=self.region, key=self._get_path(key)) - - def exists(self, key): - try: - self.s3.head_object(Bucket=self.bucket, Key=self._get_path(key)) - return True - except ClientError: - return False - - def uploadf(self, file, key, **kwargs): - extra_args = kwargs.get("extra_args", {}) - if not self.private and 'ACL' not in extra_args: - extra_args['ACL'] = 'public-read' - - if 'ContentType' not in extra_args: - try: - extra_args['ContentType'] = mimetypes.guess_type(key)[0] - except Exception as e: - logger.error(f"Unable to get mimetype for {key=}, error: {e}") - - self.s3.upload_fileobj(file, Bucket=self.bucket, Key=self._get_path(key), ExtraArgs=extra_args) diff --git a/utils/__init__.py b/utils/__init__.py deleted file mode 100644 index 68010ab..0000000 --- a/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# we need to explicitly expose the available imports here -from .gworksheet import * -from .misc import * \ No newline at end of file