diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml index 4d232e2..2031ad6 100644 --- a/.github/workflows/docker-publish.yaml +++ b/.github/workflows/docker-publish.yaml @@ -11,7 +11,7 @@ on: env: # Use docker.io for Docker Hub if empty - REGISTRY: ghcr.io + REGISTRY: docker.io # github.repository as / IMAGE_NAME: ${{ github.repository }} @@ -45,10 +45,12 @@ jobs: images: bellingcat/auto-archiver - name: Build and push Docker image - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache,mode=max diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml new file mode 100644 index 0000000..5ccbb1c --- /dev/null +++ b/.github/workflows/ruff.yaml @@ -0,0 +1,24 @@ +name: Ruff Formatting & Linting + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Run Ruff + run: ruff check --output-format=github . && ruff format --check \ No newline at end of file diff --git a/.github/workflows/tests-core.yaml b/.github/workflows/tests-core.yaml index 917cfbb..57028bd 100644 --- a/.github/workflows/tests-core.yaml +++ b/.github/workflows/tests-core.yaml @@ -5,9 +5,13 @@ on: branches: [ main ] paths: - src/** + - poetry.lock + - pyproject.toml pull_request: paths: - src/** + - poetry.lock + - pyproject.toml jobs: tests: diff --git a/.gitignore b/.gitignore index 701de43..f31bc6c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ dist* docs/_build/ docs/source/autoapi/ docs/source/modules/autogen/ +scripts/settings_page.html +.vite diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..aca3a0d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +# Run Ruff formatter on commits. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.10 + hooks: + - id: ruff + - id: ruff-format \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 434f805..6dc9fe5 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -9,6 +9,7 @@ build: os: ubuntu-22.04 tools: python: "3.10" + nodejs: "22" jobs: post_install: - pip install poetry @@ -17,6 +18,11 @@ build: # See https://github.com/readthedocs/readthedocs.org/pull/11152/ - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs + # generate the config editor page. Schema then HTML + - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry run python scripts/generate_settings_schema.py + # install node dependencies and build the settings + - cd scripts/settings && npm install && npm run build && yes | cp dist/index.html ../../docs/source/installation/settings_base.html && cd ../.. + sphinx: configuration: docs/source/conf.py diff --git a/Dockerfile b/Dockerfile index cbcfdd4..68aed42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,24 @@ ENV RUNNING_IN_DOCKER=1 \ PYTHONFAULTHANDLER=1 \ PATH="/root/.local/bin:$PATH" + +ARG TARGETARCH + # Installing system dependencies RUN add-apt-repository ppa:mozillateam/ppa && \ apt-get update && \ apt-get install -y --no-install-recommends gcc ffmpeg fonts-noto exiftool && \ 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.35.0/geckodriver-v0.35.0-linux64.tar.gz && \ + ln -s /usr/bin/firefox-esr /usr/bin/firefox + +ARG GECKODRIVER_VERSION=0.36.0 + +RUN if [ $(uname -m) = "aarch64" ]; then \ + GECKODRIVER_ARCH=linux-aarch64; \ + else \ + GECKODRIVER_ARCH=linux64; \ + fi && \ + wget https://github.com/mozilla/geckodriver/releases/download/v${GECKODRIVER_VERSION}/geckodriver-v${GECKODRIVER_VERSION}-${GECKODRIVER_ARCH}.tar.gz && \ tar -xvzf geckodriver* -C /usr/local/bin && \ chmod +x /usr/local/bin/geckodriver && \ rm geckodriver-v* && \ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7877543 --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +# Variables +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = docs/source +BUILDDIR = docs/_build + +.PHONY: help +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @echo "Additional Commands:" + @echo " make test - Run all tests in 'tests/' with pytest" + @echo " make ruff-check - Run Ruff linting and formatting checks (safe)" + @echo " make ruff-clean - Auto-fix Ruff linting and formatting issues" + @echo " make docs - Generate documentation (same as 'make html')" + @echo " make clean-docs - Remove generated docs" + @echo " make docker-build - Build the Auto Archiver Docker image" + @echo " make docker-compose - Run Auto Archiver with Docker Compose" + @echo " make docker-compose-rebuild - Rebuild and run Auto Archiver with Docker Compose" + @echo " make show-docs - Build and open the documentation in a browser" + + + +.PHONY: test +test: + @echo "Running tests..." + @pytest tests --disable-warnings + + +.PHONY: ruff-check +ruff-check: + @echo "Checking code style with Ruff (safe)..." + @ruff check . + + +.PHONY: ruff-clean +ruff-clean: + @echo "Fixing lint and formatting issues with Ruff..." + @ruff check . --fix + @ruff format . + + +.PHONY: docs +docs: + @echo "Building documentation..." + @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" + + +.PHONY: clean-docs +clean-docs: + @echo "Cleaning up generated documentation files..." + @$(SPHINXBUILD) -M clean "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @rm -rf "$(SOURCEDIR)/autoapi/" "$(SOURCEDIR)/modules/autogen/" + @echo "Cleanup complete." + + +.PHONY: show-docs +show-docs: + @echo "Opening documentation in browser..." + @open "$(BUILDDIR)/html/index.html" + +.PHONY: docker-build +docker-build: + @echo "Building local Auto Archiver Docker image..." + @docker compose build # Uses the same build context as docker-compose.yml + +.PHONY: docker-compose +docker-compose: + @echo "Running Auto Archiver with Docker Compose..." + @docker compose up + +.PHONY: docker-compose-rebuild +docker-compose-rebuild: + @echo "Rebuilding and running Auto Archiver with Docker Compose..." + @docker compose up --build + +# Catch-all for Sphinx commands +.PHONY: Makefile +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/README.md b/README.md index 368a904..8baa722 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ -Auto Archiver is a Python tool to automatically archive content on the web in a secure and verifiable way. It takes URLs from different sources (e.g. a CSV file, Google Sheets, command line etc.) and archives the content of each one. It can archive social media posts, videos, images and webpages. Content can enriched, then saved either locally or remotely (S3 bucket, Google Drive). The status of the archiving process can be appended to a CSV report, or if using Google Sheets – back to the original sheet. +Auto Archiver is a Python tool to automatically archive content on the web in a secure and verifiable way. It takes URLs from different sources (e.g. a CSV file, Google Sheets, command line etc.) and archives the content of each one. It can archive social media posts, videos, images and webpages. Content can be enriched, then saved either locally or remotely (S3 bucket, Google Drive). The status of the archiving process can be appended to a CSV report, or if using Google Sheets – back to the original sheet.
@@ -23,11 +23,13 @@ Read the [article about Auto Archiver on bellingcat.com](https://www.bellingcat. ## Installation -View the [Installation Guide](installation/installation.md) for full instructions +View the [Installation Guide](https://auto-archiver.readthedocs.io/en/latest/installation/installation.html) for full instructions + +**Advanced:** To get started quickly using Docker: -`docker pull bellingcat/auto-archiver && docker run` +`docker pull bellingcat/auto-archiver && docker run --rm -v secrets:/app/secrets bellingcat/auto-archiver --config secrets/orchestration.yaml` Or pip: diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 92dd33a..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/scripts/__init__.py b/docs/scripts/__init__.py index ba9737c..b76d0fe 100644 --- a/docs/scripts/__init__.py +++ b/docs/scripts/__init__.py @@ -1 +1 @@ -from scripts import generate_module_docs \ No newline at end of file +from scripts import generate_module_docs diff --git a/docs/scripts/scripts.py b/docs/scripts/scripts.py index a5f2998..bfddd29 100644 --- a/docs/scripts/scripts.py +++ b/docs/scripts/scripts.py @@ -3,18 +3,19 @@ from pathlib import Path from auto_archiver.core.module import ModuleFactory from auto_archiver.core.base_module import BaseModule from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap import io MODULES_FOLDER = Path(__file__).parent.parent.parent.parent / "src" / "auto_archiver" / "modules" SAVE_FOLDER = Path(__file__).parent.parent / "source" / "modules" / "autogen" type_color = { - 'feeder': "[feeder](/core_modules.md#feeder-modules)", - 'extractor': "[extractor](/core_modules.md#extractor-modules)", - 'enricher': "[enricher](/core_modules.md#enricher-modules)", - 'database': "[database](/core_modules.md#database-modules)", - 'storage': "[storage](/core_modules.md#storage-modules)", - 'formatter': "[formatter](/core_modules.md#formatter-modules)", + "feeder": "[feeder](/core_modules.md#feeder-modules)", + "extractor": "[extractor](/core_modules.md#extractor-modules)", + "enricher": "[enricher](/core_modules.md#enricher-modules)", + "database": "[database](/core_modules.md#database-modules)", + "storage": "[storage](/core_modules.md#storage-modules)", + "formatter": "[formatter](/core_modules.md#formatter-modules)", } TABLE_HEADER = ("Option", "Description", "Default", "Type") @@ -30,72 +31,86 @@ steps: ... {config_string} + """ + def generate_module_docs(): yaml = YAML() SAVE_FOLDER.mkdir(exist_ok=True) modules_by_type = {} header_row = "| " + " | ".join(TABLE_HEADER) + "|\n" + "| --- " * len(TABLE_HEADER) + "|\n" - configs_cheatsheet = "\n## Configuration Options\n" - configs_cheatsheet += header_row + global_table = "\n## Configuration Options\n" + header_row + + global_yaml = yaml.load("""\n# Module configuration\nplaceholder: {}""") for module in sorted(ModuleFactory().available_modules(), key=lambda x: (x.requires_setup, x.name)): # generate the markdown file from the __manifest__.py file. manifest = module.manifest - for type in manifest['type']: + for type in manifest["type"]: modules_by_type.setdefault(type, []).append(module) - description = "\n".join(l.lstrip() for l in manifest['description'].split("\n")) - types = ", ".join(type_color[t] for t in manifest['type']) + description = "\n".join(line.lstrip() for line in manifest["description"].split("\n")) + types = ", ".join(type_color[t] for t in manifest["type"]) readme_str = f""" -# {manifest['name']} +# {manifest["name"]} ```{{admonition}} Module type {types} ``` {description} -""" - steps_str = "\n".join(f" {t}s:\n - {module.name}" for t in manifest['type']) +""" + steps_str = "\n".join(f" {t}s:\n - {module.name}" for t in manifest["type"]) - if not manifest['configs']: + if not manifest["configs"]: config_string = f"# No configuration options for {module.name}.*\n" else: - config_table = header_row config_yaml = {} - for key, value in manifest['configs'].items(): - type = value.get('type', 'string') - if type == 'auto_archiver.utils.json_loader': - value['type'] = 'json' - elif type == 'str': + + global_yaml[module.name] = CommentedMap() + global_yaml.yaml_set_comment_before_after_key( + module.name, f"\n\n{module.display_name} configuration options" + ) + + for key, value in manifest["configs"].items(): + type = value.get("type", "string") + if type == "json_loader": + value["type"] = "json" + elif type == "str": type = "string" - - default = value.get('default', '') + + default = value.get("default", "") config_yaml[key] = default - help = "**Required**. " if value.get('required', False) else "Optional. " - help += value.get('help', '') + + global_yaml[module.name][key] = default + + if value.get("help", ""): + global_yaml[module.name].yaml_add_eol_comment(value.get("help", ""), key) + + help = "**Required**. " if value.get("required", False) else "Optional. " + help += value.get("help", "") config_table += f"| `{module.name}.{key}` | {help} | {value.get('default', '')} | {type} |\n" - configs_cheatsheet += f"| `{module.name}.{key}` | {help} | {default} | {type} |\n" + global_table += f"| `{module.name}.{key}` | {help} | {default} | {type} |\n" readme_str += "\n## Configuration Options\n" readme_str += "\n### YAML\n" config_string = io.BytesIO() yaml.dump({module.name: config_yaml}, config_string) - config_string = config_string.getvalue().decode('utf-8') + config_string = config_string.getvalue().decode("utf-8") yaml_string = EXAMPLE_YAML.format(steps_str=steps_str, config_string=config_string) readme_str += f"```{{code}} yaml\n{yaml_string}\n```\n" - if manifest['configs']: + if manifest["configs"]: readme_str += "\n### Command Line:\n" readme_str += config_table # add a link to the autodoc refs readme_str += f"\n[API Reference](../../../autoapi/{module.name}/index)\n" # create the module.type folder, use the first type just for where to store the file - for type in manifest['type']: + for type in manifest["type"]: type_folder = SAVE_FOLDER / type type_folder.mkdir(exist_ok=True) with open(type_folder / f"{module.name}.md", "w") as f: @@ -103,8 +118,13 @@ def generate_module_docs(): f.write(readme_str) generate_index(modules_by_type) + del global_yaml["placeholder"] + global_string = io.BytesIO() + global_yaml = yaml.dump(global_yaml, global_string) + global_string = global_string.getvalue().decode("utf-8") + global_yaml = f"```yaml\n{global_string}\n```" with open(SAVE_FOLDER / "configs_cheatsheet.md", "w") as f: - f.write(configs_cheatsheet) + f.write("### Configuration File\n" + global_yaml + "\n### Command Line\n" + global_table) def generate_index(modules_by_type): @@ -125,4 +145,4 @@ def generate_index(modules_by_type): if __name__ == "__main__": - generate_module_docs() \ No newline at end of file + generate_module_docs() diff --git a/docs/source/bc.png b/docs/source/bc.png new file mode 100644 index 0000000..766529b Binary files /dev/null and b/docs/source/bc.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index 5b1ad9b..8cfbd30 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,9 +3,11 @@ import sys import os from importlib.metadata import metadata +from datetime import datetime -sys.path.append(os.path.abspath('../scripts')) +sys.path.append(os.path.abspath("../scripts")) from scripts import generate_module_docs +from auto_archiver.version import __version__ # -- Project Hooks ----------------------------------------------------------- # convert the module __manifest__.py files into markdown files @@ -15,35 +17,38 @@ generate_module_docs() # -- Project information ----------------------------------------------------- package_metadata = metadata("auto-archiver") project = package_metadata["name"] -authors = "Bellingcat" +copyright = str(datetime.now().year) +author = "Bellingcat" release = package_metadata["version"] -language = 'en' +language = "en" # -- General configuration --------------------------------------------------- extensions = [ - "myst_parser", # Markdown support - "autoapi.extension", # Generate API documentation from docstrings - "sphinxcontrib.mermaid", # Mermaid diagrams - "sphinx.ext.viewcode", # Source code links + "myst_parser", # Markdown support + "autoapi.extension", # Generate API documentation from docstrings + "sphinxcontrib.mermaid", # Mermaid diagrams + "sphinx.ext.viewcode", # Source code links "sphinx_copybutton", - "sphinx.ext.napoleon", # Google-style and NumPy-style docstrings + "sphinx.ext.napoleon", # Google-style and NumPy-style docstrings "sphinx.ext.autosectionlabel", # 'sphinx.ext.autosummary', # Summarize module/class/function docs ] -templates_path = ['_templates'] -exclude_patterns = [] +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", ""] # -- AutoAPI Configuration --------------------------------------------------- -autoapi_type = 'python' +autoapi_type = "python" autoapi_dirs = ["../../src/auto_archiver/core/", "../../src/auto_archiver/utils/"] # get all the modules and add them to the autoapi_dirs autoapi_dirs.extend([f"../../src/auto_archiver/modules/{m}" for m in os.listdir("../../src/auto_archiver/modules")]) -autodoc_typehints = "signature" # Include type hints in the signature -autoapi_ignore = ["*/version.py", ] # Ignore specific modules -autoapi_keep_files = True # Option to retain intermediate JSON files for debugging -autoapi_add_toctree_entry = True # Include API docs in the TOC +autodoc_typehints = "signature" # Include type hints in the signature +autoapi_ignore = [ + "*/version.py", +] # Ignore specific modules +autoapi_keep_files = True # Option to retain intermediate JSON files for debugging +autoapi_add_toctree_entry = True # Include API docs in the TOC autoapi_python_use_implicit_namespaces = True autoapi_template_dir = "../_templates/autoapi" autoapi_options = [ @@ -56,13 +61,13 @@ autoapi_options = [ # -- Markdown Support -------------------------------------------------------- myst_enable_extensions = [ - "deflist", # Definition lists - "html_admonition", # HTML-style admonitions - "html_image", # Inline HTML images - "replacements", # Substitutions like (C) - "smartquotes", # Smart quotes - "linkify", # Auto-detect links - "substitution", # Text substitutions + "deflist", # Definition lists + "html_admonition", # HTML-style admonitions + "html_image", # Inline HTML images + "replacements", # Substitutions like (C) + "smartquotes", # Smart quotes + "linkify", # Auto-detect links + "substitution", # Text substitutions ] myst_heading_anchors = 2 myst_fence_as_directive = ["mermaid"] @@ -73,10 +78,17 @@ source_suffix = { } # -- Options for HTML output ------------------------------------------------- -html_theme = 'sphinx_book_theme' +html_theme = "sphinx_book_theme" html_static_path = ["../_static"] html_css_files = ["custom.css"] +html_title = f"Auto Archiver v{__version__}" +html_logo = "bc.png" +html_theme_options = { + "repository_url": "https://github.com/bellingcat/auto-archiver", + "use_repository_button": True, +} + copybutton_prompt_text = r">>> |\.\.\." copybutton_prompt_is_regexp = True -copybutton_only_copy_prompt_lines = False \ No newline at end of file +copybutton_only_copy_prompt_lines = False diff --git a/docs/source/core_modules.md b/docs/source/core_modules.md index 3a8e5ec..58eff08 100644 --- a/docs/source/core_modules.md +++ b/docs/source/core_modules.md @@ -1,8 +1,8 @@ # Module Documentation -These pages describe the core modules that come with `auto-archiver` and provide the main functionality for archiving websites on the internet. There are five core module types: +These pages describe the core modules that come with Auto Archiver and provide the main functionality for archiving websites on the internet. There are five core module types: -1. Feeders - these 'feed' information (the URLs) from various sources to the `auto-archiver` for processing +1. Feeders - these 'feed' information (the URLs) from various sources to the Auto Archiver for processing 2. Extractors - these 'extract' the page data for a given URL that is fed in by a feeder 3. Enrichers - these 'enrich' the data extracted in the previous step with additional information 4. Storage - these 'store' the data in a persistent location (on disk, Google Drive etc.) diff --git a/docs/source/development/creating_modules.md b/docs/source/development/creating_modules.md index 0950251..49468a4 100644 --- a/docs/source/development/creating_modules.md +++ b/docs/source/development/creating_modules.md @@ -1,6 +1,6 @@ # Creating Your Own Modules -Modules are what's used to extend `auto-archiver` to process different websites or media, and/or transform the data in a way that suits your needs. In most cases, the [Core Modules](../core_modules.md) should be sufficient for every day use, but the most common use-cases for making your own Modules include: +Modules are what's used to extend Auto Archiver to process different websites or media, and/or transform the data in a way that suits your needs. In most cases, the [Core Modules](../core_modules.md) should be sufficient for every day use, but the most common use-cases for making your own Modules include: 1. Extracting data from a website which doesn't work with the current core extractors. 2. Enriching or altering the data before saving with additional information that the core enrichers do not offer. @@ -21,7 +21,7 @@ When done, you should have a module structure as follows: │ └── awesome_extractor.py ``` -Check out the [core modules](https://github.com/bellingcat/auto-archiver/tree/main/src/auto_archiver/modules) in the `auto-archiver` repository for examples of the folder structure for real-world modules. +Check out the [core modules](https://github.com/bellingcat/auto-archiver/tree/main/src/auto_archiver/modules) in the Auto Archiver repository for examples of the folder structure for real-world modules. ## Populating the Manifest File diff --git a/docs/source/development/developer_guidelines.md b/docs/source/development/developer_guidelines.md index e72193a..dd94c57 100644 --- a/docs/source/development/developer_guidelines.md +++ b/docs/source/development/developer_guidelines.md @@ -31,4 +31,6 @@ docker_development testing docs release +settings_page +style_guide ``` \ No newline at end of file diff --git a/docs/source/development/release.md b/docs/source/development/release.md index 6939e97..a2ed4c6 100644 --- a/docs/source/development/release.md +++ b/docs/source/development/release.md @@ -2,14 +2,32 @@ ```{note} This is a work in progress. ``` +### Update the project version -1. Update the version number in [version.py](src/auto_archiver/version.py) -2. Go to github releases > new release > use `vx.y.z` for matching version notation - 1. package is automatically updated in pypi - 2. docker image is automatically pushed to dockerhup +Update the version number in the project file: [pyproject.toml](../../pyproject.toml) following SemVer: +```toml +[project] +name = "auto-archiver" +version = "0.1.1" +``` +Then commit and push the changes. +* The package version is automatically updated in PyPi using the workflow [python-publish.yml](../../.github/workflows/python-publish.yml) +* A Docker image is automatically pushed with the git tag to dockerhub using the workflow [docker-publish.yml](../../.github/workflows/docker-publish.yml) + +### Create the release on Git + +The release needs a git tag which should match the project version number, prefixed with a 'v'. For example, if the project version is `0.1.1`, the git tag should be `v0.1.1`. +This can be done the usual way, or created within the Github UI when you create the release. + +Go to GitHub releases > new release > create the release with the new tag and the release notes. manual release to docker hub * `docker image tag auto-archiver bellingcat/auto-archiver:latest` * `docker push bellingcat/auto-archiver` + + +### Building the Settings Page + +The Settings page is built as part of the python-publish workflow and packaged within the app. \ No newline at end of file diff --git a/docs/source/development/settings_page.md b/docs/source/development/settings_page.md new file mode 100644 index 0000000..41271b9 --- /dev/null +++ b/docs/source/development/settings_page.md @@ -0,0 +1,31 @@ +# Configuration Editor + +The [configuration editor](../installation/config_editor.md), is an easy-to-use UI for users to edit their auto-archiver settings. + +The single-file app is built using React and vite. To get started developing the package, follow these steps: + +1. Make sure you have Node v22 installed. + +```{note} Tip: if you don't have node installed: + +Use `nvm` to manage your node installations. Use: +`curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` to install `nvm` and then `nvm i 22` to install Node v22 +``` + +2. Generate the `schema.json` file for the currently installed modules using `python scripts/generate_settings_schema.py` +3. Go to the settings folder `cd scripts/settings/` and build your environment with `npm i` +4. Run a development version of the page with `npm run dev` and then open localhost:5173. +5. Build a release version of the page with `npm run build` + +A release version creates a single-file app called `dist/index.html`. This file should be copied to `docs/source/installation/settings_base.html` so that it can be integrated into the sphinx docs. + +```{note} + +The single-file app dist/index.html does not include any `` or `` tags as it is designed to be built into a RTD docs page. Edit `index.html` in the settings folder if you wish to modify the built page. +``` + +## Readthedocs Integration + +The configuration editor is built as part of the RTD deployment (see `.readthedocs.yaml` file). This command is run every time RTD is built: + +`cd scripts/settings && npm install && npm run build && yes | cp dist/index.html ../../docs/source/installation/settings_base.html && cd ../..` \ No newline at end of file diff --git a/docs/source/development/style_guide.md b/docs/source/development/style_guide.md new file mode 100644 index 0000000..390f11a --- /dev/null +++ b/docs/source/development/style_guide.md @@ -0,0 +1,70 @@ +# Style Guide + + +The project uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting. +Our style configurations are set in the `pyproject.toml` file. If needed, you can modify them there. + + +### **Formatting (Auto-Run Before Commit) 🛠️** + +We have a pre-commit hook to run the formatter before you commit. +This requires you to set it up once locally, then it will run automatically when you commit changes. + +```shell +poetry run pre-commit install +``` + +Ruff can also be to run automatically. +Alternative: Ruff can also be [integrated with most editors](https://docs.astral.sh/ruff/editors/setup/) for real-time formatting. + +If you wish to disable the pre-commit hook (for example, if you want to commit some WIP code) you can use the `--no-verify` flag when you commit. +For example: `git commit -m "WIP Code" --no-verify` + +### **Linting (Check Before Pushing) 🔍** + +We recommend you also run the linter before pushing code. + +We have [Makefile](../../../Makefile) commands to run common tasks. + +Tip: if you're on Windows you might need to install `make` first, or alternatively you can use ruff commands directly. + + +**Lint Check:** This outputs a report of any issues found, without attempting to fix them: +```shell +make ruff-check +``` + +Tip: To see a more detailed linting report, you can remove the following line from the `pyproject.toml` file: +```toml +[tool.ruff] + +# Remove this for a more detailed lint report +output-format = "concise" +``` + +**Lint Fix:** This command will attempt to fix some of the issues it picked up with the lint check. + +Note not all warnings can be fixed automatically. + +⚠️ Warning: This can cause breaking changes. ⚠️ + +Most fixes are safe, but some non-standard practices such as dynamic loading are not picked up by linters. Ensure you check any modifications by this before committing them. +```shell +make ruff-fix +``` + +**Changing Configurations ⚙️** + + +Our rules are quite lenient for general usage, but if you want to run more rigorous checks you can then run checks with additional rules to see more nuanced errors which you can review manually. +Check out the [ruff documentation](https://docs.astral.sh/ruff/configuration/) for the full list of rules. +One example is to extend the selected rules for linting the `pyproject.toml` file: + +```toml +[tool.ruff.lint] +# Extend the rules to check for by adding them to this option: +# See documentation for more details: https://docs.astral.sh/ruff/rules/ +extend-select = ["B"] +``` + +Then re-run the `make ruff-check` command to see the new rules in action. \ No newline at end of file diff --git a/docs/source/how_to.md b/docs/source/how_to.md index 25e1e1d..e2238dd 100644 --- a/docs/source/how_to.md +++ b/docs/source/how_to.md @@ -1,49 +1,6 @@ # How-To Guides -## How to use Google Sheets to load and store archive information -The `--gsheet_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` - see the [Gsheet Feeder Docs](modules/autogen/feeder/gsheet_feeder.md) for more info. The default names of these columns and their purpose is: - -Inputs: - -* **Link** *(required)*: the URL of the post to archive -* **Destination folder**: custom folder for archived file (regardless of storage) - -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) - for checksums/verification -* **Perceptual Hash**: Perceptual hashes of found images - these can be used for de-duplication of content -* **WACZ**: Link to a WACZ web archive of post -* **ReplayWebpage**: Link to a ReplayWebpage viewer of the WACZ archive - -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](../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 "Link" column. The auto archiver has added "archive in progress" to one of the status columns.](../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.](../demo-after.png) - -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. - -The "archive location" link contains the path of the archived file, in local storage, S3, or in Google Drive. - -![The archive result for a link in the demo sheet.](../demo-archive.png) - +The follow pages contain helpful how-to guides for common use cases of the Auto Archiver. --- ```{toctree} @@ -51,4 +8,5 @@ The "archive location" link contains the path of the archived file, in local sto :glob: how_to/* + ``` \ No newline at end of file diff --git a/docs/source/how_to/authentication_how_to.md b/docs/source/how_to/authentication_how_to.md new file mode 100644 index 0000000..0e842fb --- /dev/null +++ b/docs/source/how_to/authentication_how_to.md @@ -0,0 +1,110 @@ +# Logging in to sites + +This how-to guide shows you how you can use various authentication methods to allow you to login to a site you are trying to archive. This is useful for websites that require a user to be logged in to browse them, or for sites that restrict bots. + +In this How-To, we will authenticate on use Twitter/X.com using cookies, and on XXXX using username/password. + + + +## Using cookies to authenticate on Twitter/X + +It can be useful to archive tweets after logging in, since some tweets are only visible to authenticated users. One case is Tweets marked as 'Sensitive'. + +Take this tweet as an example: [https://x.com/SozinhoRamalho/status/1876710769913450647](https://x.com/SozinhoRamalho/status/1876710769913450647) + +This tweet has been marked as sensitive, so a normal run of Auto Archiver without a logged in session will fail to extract the tweet: + +```{code-block} console +:emphasize-lines: 3,4,5,6 + +>>> auto-archiver https://x.com/SozinhoRamalho/status/1876710769913450647 ✭ ✱ + ... +ERROR: [twitter] 1876710769913450647: NSFW tweet requires authentication. Use --cookies, +--cookies-from-browser, --username and --password, --netrc-cmd, or --netrc (twitter) to + provide account credentials. See https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp + for how to manually pass cookies +[twitter] 1876710769913450647: Downloading guest token +[twitter] 1876710769913450647: Downloading GraphQL JSON +2025-02-20 15:06:13.362 | ERROR | auto_archiver.modules.generic_extractor.generic_extractor:download_for_extractor:248 - Error downloading metadata for post: NSFW tweet requires authentication. Use --cookies, --cookies-from-browser, --username and --password, --netrc-cmd, or --netrc (twitter) to provide account credentials. See https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp for how to manually pass cookies +[generic] Extracting URL: https://x.com/SozinhoRamalho/status/1876710769913450647 +[generic] 1876710769913450647: Downloading webpage +WARNING: [generic] Falling back on generic information extractor +[generic] 1876710769913450647: Extracting information +ERROR: Unsupported URL: https://x.com/SozinhoRamalho/status/1876710769913450647 +2025-02-20 15:06:13.744 | INFO | auto_archiver.core.orchestrator:archive:483 - Trying extractor telegram_extractor for https://x.com/SozinhoRamalho/status/1876710769913450647 +2025-02-20 15:06:13.744 | SUCCESS | auto_archiver.modules.console_db.console_db:done:23 - DONE Metadata(status='nothing archived', metadata={'_processed_at': datetime.datetime(2025, 2, 20, 15, 6, 12, 473979, tzinfo=datetime.timezone.utc), 'url': 'https://x.com/SozinhoRamalho/status/1876710769913450647'}, media=[]) +... +``` + +To get round this limitation, we can use **cookies** (information about a logged in user) to mimic being logged in to Twitter. There are two ways to pass cookies to Auto Archiver. One is from a file, and the other is from a browser profile on your computer. + +In this tutorial, we will export the Twitter cookies from our browser and add them to Auto Archiver + +**1. Installing a cookie exporter extension** + +First, we need to install an extension in our browser to export the cookies for a certain site. The [FAQ on yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp) provides some suggestions: Get [cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) for Chrome or [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) for Firefox. + +**2. Export the cookies** + +```{note} See the note [here](../installation/authentication.md#recommendations-for-authentication) on why you shouldn't use your own personal account for archiving. +``` + +Once the extension is installed in your preferred browser, login to Twitter in this browser, and then activate the extension and export the cookies. You can choose to export all your cookies for your browser, or just cookies for this specific site. In the image below, we're only exporting cookies for Twitter/x.com: + +![extract cookies](extract_cookies.png) + + +**3. Adding the cookies file to Auto Archiver** + +You now will have a file called `cookies.txt` (tip: name it `twitter_cookies.txt` if you only exported cookies for Twitter), which needs to be added to Auto Archiver. + +Do this by going into your Auto Archiver configuration file, and editing the `authentication` section. We will add the `cookies_file` option for the site `x.com,twitter.com`. + +```{note} For websites that have multiple URLs (like x.com and twitter.com) you can 'reuse' the same login information without duplicating it using a comma separated list of domain names. +``` + +I've saved my `twitter_cookies.txt` file in a `secrets` folder, so here's how my authentication section looks now: + +```{code} yaml +:caption: orchestration.yaml + +... + +authentication: + x.com,twitter.com: + cookies_file: secrets/twitter_cookies.txt +... +``` + +**4. Re-run your archiving with the cookies enabled** + +Now, the next time we re-run Auto Archiver, the cookies from our logged-in session will be used by Auto Archiver, and restricted/sensitive tweets can be downloaded! + +```{code} console +>>> auto-archiver https://x.com/SozinhoRamalho/status/1876710769913450647 ✭ ✱ ◼ +... +2025-02-20 15:27:46.785 | WARNING | auto_archiver.modules.console_db.console_db:started:13 - STARTED Metadata(status='no archiver', metadata={'_processed_at': datetime.datetime(2025, 2, 20, 15, 27, 46, 785304, tzinfo=datetime.timezone.utc), 'url': 'https://x.com/SozinhoRamalho/status/1876710769913450647'}, media=[]) +2025-02-20 15:27:46.785 | INFO | auto_archiver.core.orchestrator:archive:483 - Trying extractor generic_extractor for https://x.com/SozinhoRamalho/status/1876710769913450647 +[twitter] Extracting URL: https://x.com/SozinhoRamalho/status/1876710769913450647 +... +2025-02-20 15:27:53.134 | INFO | auto_archiver.modules.local_storage.local_storage:upload:26 - ./local_archive/https-x-com-sozinhoramalho-status-1876710769913450647/06e8bacf27ac4bb983bf6280.html +2025-02-20 15:27:53.135 | SUCCESS | auto_archiver.modules.console_db.console_db:done:23 - DONE Metadata(status='yt-dlp_Twitter: success', +metadata={'_processed_at': datetime.datetime(2025, 2, 20, 15, 27, 48, 564738, tzinfo=datetime.timezone.utc), 'url': +'https://x.com/SozinhoRamalho/status/1876710769913450647', 'title': 'ignore tweet, testing sensitivity warning nudity https://t.co/t3u0hQsSB1', +... +``` + + +### Finishing Touches + +You've now successfully exported your cookies from a logged-in session in your browser, and used them to authenticate with Twitter and download a sensitive tweet. Congratulations! + +Finally,Some important things to remember: + +1. It's best not to use your own personal account for archiving. [Here's why](../installation/authentication.md#recommendations-for-authentication). +2. Cookies can be short-lived, so may need updating. Sometimes, a website session may 'expire' or a website may force you to login again. In these instances, you'll need to repeat the export step (step 2) after logging in again to update your cookies. + +## Authenticating on XXXX site with username/password + +```{note} This section is still under construction 🚧 +``` diff --git a/docs/source/how_to/extract_cookies.png b/docs/source/how_to/extract_cookies.png new file mode 100644 index 0000000..73b7917 Binary files /dev/null and b/docs/source/how_to/extract_cookies.png differ diff --git a/docs/source/how_to/gsheets_setup.md b/docs/source/how_to/gsheets_setup.md new file mode 100644 index 0000000..ade8024 --- /dev/null +++ b/docs/source/how_to/gsheets_setup.md @@ -0,0 +1,159 @@ +# Using Google Sheets + +This guide explains how to set up Google Sheets to process URLs automatically and then store the archiving status back into the Google sheet. It is broadly split into 3 steps: + +1. Setting up your Google Sheet +2. Setting up a service account so Auto Archiver can access the sheet +3. Setting the Auto Archiver settings + +### 1. Setting up your Google Sheet + +Any Google sheet must have at least *one* column, with the name 'link' (you can change this name afterwards). This is the column with the URLs that you want the Auto Archiver to archive. +Your sheet can have many other columns that the Auto Archiver can use, and you can also include any additional columns for your own personal use. The order of the columns does not matter, the naming just needs to be correctly assigned to its corresponding value in the configuration file. + +We recommend copying [this template Google Sheet](https://docs.google.com/spreadsheets/d/1NJZo_XZUBKTI1Ghlgi4nTPVvCfb0HXAs6j5tNGas72k/edit?usp=sharing) as a starting point for your project, as this matches the default column names. + +Here's an overview of all the columns, and what a complete sheet would look like. + +**Inputs:** + +These are processed by the Gsheet Feeder and passed to the Auto Archiver. + +* **Link** *(required)*: the URL of the post that is to be archived +* **Destination folder**: custom folder for archived file (regardless of storage) + +**Outputs:** + +These are updated by the Gsheet DB module during the archiving process. +Note the required columns are only required if you are using the Gsheet DB module as well as the feeder. + +* **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) - for checksums/verification +* **Perceptual Hash**: Perceptual hashes of found images - these can be used for de-duplication of content +* **WACZ**: Link to a WACZ web archive of post +* **ReplayWebpage**: Link to a ReplayWebpage viewer of the WACZ archive + +For example, this is a spreadsheet configured with all of the columns for the auto archiver and a few URLs to archive. +In this example the Ghseet Feeder and Gsheet DB are being used, and the archive is in progress. +(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](../../demo-before.png) + +We'll change the name of the 'Destination Folder' column in step 3. + +## 2. Setting up your Service Account + +Once your Google Sheet is set up, you need to create what's called a 'service account' that will allow the Auto Archiver to access it. + +To do this, follow the steps in [this guide](https://gspread.readthedocs.io/en/latest/oauth2.html) all the way up until step 8. You should have downloaded a file called `service_account.json` and shared the Google Sheet with the log 'client_email' email address in this file. + +Once you've downloaded the file, save it to `secrets/service_account.json` + +## 3. Setting up the configuration file + +Now that you've set up your Google sheet, and you've set up the service account so Auto Archiver can access the sheet, the final step is to set your configuration. + +First, make sure you have `gsheet_feeder_db` set in the `steps.feeders` section of your config. If you wish to store the results of the archiving process back in your Google sheet, make sure to also set the `ghseet_db` settig in the `steps.databases` section. Here's how this might look: + +```{code} yaml +steps: + feeders: + - gsheet_feeder_db + ... + databases: + - gsheet_feeder_db # optional, if you also want to store the results in the Google sheet and tract the status of active archivals. + ... +``` + +Next, set up the `gsheet_feeder_db` configuration settings in the 'Configurations' part of the config `orchestration.yaml` file. Open up the file, and set the `gsheet_feeder_db.sheet` setting or the `gsheet_feeder_db.sheet_id` setting. The `sheet` should be the name of your sheet, as it shows in the top left of the sheet. +For example, the sheet [here](https://docs.google.com/spreadsheets/d/1NJZo_XZUBKTI1Ghlgi4nTPVvCfb0HXAs6j5tNGas72k/edit?gid=0#gid=0) is called 'Public Auto Archiver template'. + +Here's how this might look: + +```{code} yaml +... +gsheet_feeder_db: + sheet: 'My Awesome Sheet' + ... +``` + +You can also pass these settings directly on the command line without having to edit the file, here'a an example of how to do that (using docker): + +`docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver:dockerize --gsheet_feeder_db.sheet "My Awesome Sheet 2"`. + +Here, the sheet name has been overridden/specified in the command line invocation. + +### 3a. (Optional) Changing the column names + +In step 1, we said we would change the name of the 'Destination Folder'. Perhaps you don't like this name, or already have a sheet with a different name. In our example here, we want to name this column 'Save Folder'. To do this, we need to edit the `ghseet_feeder_db.column` setting in the configuration file. +For more information on this setting, see the [Gsheet Feeder Database docs](../modules/autogen/feeder/gsheet_feeder_db.md#configuration-options). We will first copy the default settings from the Gsheet Feeder docs for the 'column' settings, and then edit the 'Destination Folder' section to rename it 'Save Folder'. Our final configuration section looks like: + +```{code} yaml +... +gsheet_feeder_db: + sheet: 'My Awesome Sheet' + header: 1 + service_account: secrets/service_account.json + columns: + url: link + status: archive status + folder: save folder # <-- note how this value has been changed + archive: archive location + date: archive date + thumbnail: thumbnail + timestamp: upload timestamp + title: upload title + text: text content + screenshot: screenshot + hash: hash + pdq_hash: perceptual hashes + wacz: wacz + replaywebpage: replaywebpage + +``` +## 4. Running the Auto Archiver +### Feeding the URLs to the Auto Archiver + +The URLs to be archived should be added to the Google Sheet, and optionally a folder value. Leave all the other configured columns empty (but you may add additional columns for your own use, as long as they don't conflict with the column names mapped in the configuration file). +The Auto Archiver will archive any URLs which have an empty 'status' column + +### Viewing the Results after archiving + +With the `ghseet_feeder_db` installed, once you start running the Auto Archiver, it will update the "Archive status" column. +The status will be set to "Archive in progress" once the archival starts. If the archival is stopped during a run, either manually or because an error is raised the status value should be cleared. + +![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.](../../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.](../../demo-after.png) + +Note that the first row is skipped, as it is assumed to be a header row (`--gsheet_feeder_db.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. + +The "archive location" link contains the path of the archived file, in local storage, S3, or in Google Drive. + +![The archive result for a link in the demo sheet.](../../demo-archive.png) + +### Troubleshooting + +**Hanging Archival in progress status** + +Occasionally system crashes or other unexpected events can cause the Auto Archiver to exit without cleaning up the status value. +If you are sure that all archival processes have stopped but you still see "Archive in progress" in the status column, you can manually clear the status column to allow the Auto Archiver to retry that archival on the next run. + +**Nothing archived status** + +Sometimes this means the tool is genuinely unable to extract the content at this point in time, but sometimes it can be resolved with different configurations. +Try: + - Turning on additional 'extractor' types in the configuration file (this can appear as 'no archiver' in the status column). + - Changing credentials or refreshing session files for extractors which require them + - Check if the extractors can accept any additional configurations such as adding a cookie file. + + diff --git a/docs/source/how_to/logging.md b/docs/source/how_to/logging.md new file mode 100644 index 0000000..d88882d --- /dev/null +++ b/docs/source/how_to/logging.md @@ -0,0 +1,71 @@ +# Keeping Logs + +Auto Archiver's logs can be helpful for debugging problematic archiving processes. This guide shows you how to use the logs to + +## Setting up logging + +Logging settings can be set on the command line or using the orchestration config file ([learn more](../installation/configuration)). A special `logging` section defines the logging options. + +#### Enabling or Disabling Logging + +Logging to the console is enabled by default. If you want to globally disable Auto Archiver's logging, then you can set `enabled: false` in your `logging` config: + +```{code} yaml + +... +logging: + enabled: false +... +``` + +```{note} +This will disable all logs from Auto Archiver, but it does not disable logs for other tools that the Auto Archiver uses (for example: yt-dlp, firefox or ffmpeg). These logs will still appear in your console. +``` + +#### Logging Level + +There are 7 logging levels in total, with 4 commonly used levels. They are: `DEBUG`, `INFO`, `WARNING` and `ERROR`. + +Change the warning level by setting the value in your orchestration config file: + +```{code} yaml +:caption: orchestration.yaml + +... +logging: + level: DEBUG # or INFO / WARNING / ERROR +... +``` + +For normal usage, it is recommended to use the `INFO` level, or if you prefer quieter logs with less information, you can use the `WARNING` level. If you encounter issues with the archiving, then it's recommended to enable the `DEBUG` level. + +```{note} To learn about all logging levels, see the [loguru documentation](https://loguru.readthedocs.io/en/stable/api/logger.html) +``` + +### Logging to a file + +As default, auto-archiver will log to the console. But if you wish to store your logs for future reference, or you are running the auto-archiver from within code a implementation, then you may with to enable file logging. This can be done by setting the `file:` config value in the logging settings. + +**Rotation:** For file logging, you can choose to 'rotate' your log files (creating new log files) so they do not get too large. Change this by setting the 'rotation' option in your logging settings. For a full list of rotation options, see the [loguru docs](https://loguru.readthedocs.io/en/stable/overview.html#easier-file-logging-with-rotation-retention-compression). + +```{code} yaml +:caption: orchestration.yaml + +logging: + ... + file: /my/log/file.log + rotation: 1 day +``` + +### Full logging example + +The below example logs only `WARNING` logs to the console and to the file `/my/file.log`, rotating that file once per week: + +```{code} yaml +:caption: orchestration.yaml + +logging: + level: WARNING + file: /my/file.log + rotation: 1 week +``` \ No newline at end of file diff --git a/docs/source/how_to/new_config_format.md b/docs/source/how_to/new_config_format.md new file mode 100644 index 0000000..5cef3c8 --- /dev/null +++ b/docs/source/how_to/new_config_format.md @@ -0,0 +1,146 @@ +# Upgrading from v0.12 + +```{note} This how-to is only relevant for people who used Auto Archiver before February 2025 (versions prior to 0.13). + +If you are new to Auto Archiver, then you are already using the latest configuration format and this how-to is not relevant for you. +``` + +Versions 0.13+ of Auto Archiver has breaking changes in the configuration format, which means earlier configuration formats will not work without slight modifications. + +## How do I know if I need to update my configuration format? + +There are two simple ways to check if you need to update your format: + +1. When you try and run auto-archiver using your existing configuration file, you get an error about no feeders or formatters being configured, like: + +```{code} console +AssertionError: No feeders were configured. Make sure to set at least one feeder in +your configuration file or on the command line (using --feeders) +``` + +2. Within your configuration file, you have a `feeder:` option. This is the old format. An example old format: +```{code} yaml + +steps: + feeder: cli_feeder +... +``` + +The next two sections outline the two methods you have for updating your file. + +## 1. Manually edit the configuration file and change the values. + +This is recommended if you want to keep all your old settings. Follow the steps below to change the relevant settings: + +#### a) Feeder & Formatter Steps Settings + +The feeder and formatter settings have been changed from a single string to a list. + +- `steps.feeder (string)` → `steps.feeders (list)` +- `steps.formatter (string)` → `steps.formatters (list)` + +Example: + +```{code} yaml + +steps: + feeder: cli_feeder + ... + formatter: html_formatter + +# the above should be changed to: +steps: + feeders: + - cli_feeder + ... + formatters: + - html_formatter +``` + +```{note} Auto Archiver still only supports one feeder and formatter, but from v0.13 onwards they must be added to the configuration file as a list. +``` + +#### b) Extractor (formerly Archiver) Steps Settings + +With v0.13 of Auto Archiver, `archivers` have been renamed to `extractors` to better reflect what they actually do - extract information from a URL. Change the configuration by renaming: + +- `steps.archivers` → `steps.extractors` + +The names of the actual modules have also changed, so for any extractor modules you have enabled, you will need to rename the `archiver` part to `extractor`. Some examples: + +- `telethon_archiver` → `telethon_extractor` +- `wacz_archiver_enricher` → `wacz_extractor_enricher` +- `wayback_archiver_enricher` → `wayback_extractor_enricher` +- `vk_archiver` → `vk_extractor` + + +#### c) Module Renaming + + +The `youtube_archiver` has been renamed to `generic_extractor` as it is considered the default/fallback extractor. Read more about the [generic extractor](../modules/autogen/extractor/generic_extractor.md). + +The `atlos` modules have been merged into one, as have the `gsheets` feeder and database. + +- `atlos_feeder` → `atlos_feeder_db_storage` +- `atlos_storage` → `atlos_feeder_db_storage` +- `atlos_db` → `atlos_feeder_db_storage` +- `gsheet_feeder` → `gsheet_feeder_db` +- `gsheet_db` → `gsheet_feeder_db` + + +Example: +```{code} yaml +steps: + feeders: + - gsheet_feeder_db # formerly gsheet_feeder + ... + extractors: # formerly 'archivers' + - telethon_extractor # formerly telethon_archiver + - generic_extractor # formerly youtube_archiver + - vk_extractor # formerly vk_archiver + databases: + - gsheet_feeder_db # formerly gsheet_db + ... + +``` + +```{note} + +Don't forget to also rename the configuration settings. For example: + +```{code} yaml +gsheet_feeder_db: # formerly gsheet_feeder + service_account: secrets/service_account.json + sheet: My Google Sheet +... +``` + +#### d) Redundant / Obsolete Modules + +With v0.13 of Auto Archiver, the following modules have been removed and their features have been built in to the generic_extractor. You should remove them from the 'steps' section of your configuration file: + +* `twitter_archiver` - use the `generic_extractor` for general extraction, or the `twitter_api_extractor` for API access. +* `tiktok_archiver` - use the `generic_extractor` to extract TikTok videos. + + +## 2. Auto-generate a new config, then copy over your settings. + +Using this method, you can have Auto Archiver auto-generate a configuration file for you, then you can copy over the desired settings from your old config file. This is probably the easiest method and quickest to setup, but it may require some trial and error as you copy over your settings. + +First, move your existing `orchestration.yaml` file to a different folder or rename it. + +Then, you can generate a `simple` or `full` config using: + +```{code} console +>>> # generate a simple config +>>> auto-archiver +>>> # config will be written to orchestration.yaml +>>> +>>> # generate a full config +>>> auto-archiver --mode=full +>>> +``` + +After this, copy over any settings from your old config to the new config. + + diff --git a/docs/source/index.md b/docs/source/index.md index 6a7f769..74b7969 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -8,10 +8,10 @@ :caption: Contents: Overview -contributing -installation/installation.rst +installation/setup core_modules.md how_to +contributing development/developer_guidelines autoapi/index.rst ``` \ No newline at end of file diff --git a/docs/source/how_to/authentication.md b/docs/source/installation/authentication.md similarity index 69% rename from docs/source/how_to/authentication.md rename to docs/source/installation/authentication.md index 5f3bc48..be30425 100644 --- a/docs/source/how_to/authentication.md +++ b/docs/source/installation/authentication.md @@ -4,22 +4,42 @@ The Authentication framework for auto-archiver allows you to add login details f There are two main use cases for authentication: * Some websites require some kind of authentication in order to view the content. Examples include Facebook, Telegram etc. -* Some websites use anti-bot systems to block bot-like tools from accessig the website. Adding real login information to auto-archiver can sometimes bypass this. +* Some websites use anti-bot systems to block bot-like tools from accessing the website. Adding real login information to auto-archiver can sometimes bypass this. ## The Authentication Config -You can save your authentication information directly inside your orchestration config file, or as a separate file (for security/multi-deploy purposes). Whether storing your settings inside the orchestration file, or as a separate file, the configuration format is the same. +You can save your authentication information directly inside your orchestration config file, or as a separate file (for security/multi-deploy purposes). Whether storing your settings inside the orchestration file, or as a separate file, the configuration format is the same. Currently, auto-archiver supports the following authentication types: + +**Username & Password:** +- `username`: str - the username to use for login +- `password`: str - the password to use for login + +**API** +- `api_key`: str - the API key to use for login +- `api_secret`: str - the API secret to use for login + +**Cookies** +- `cookie`: str - a cookie string to use for login (specific to this site) +- `cookies_from_browser`: str - load cookies from this browser, for this site only. +- `cookies_file`: str - load cookies from this file, for this site only. + +```{note} + +The Username & Password, and API settings only work with the Generic Extractor. Other modules (like the screenshot enricher) can only use the `cookies` options. Furthermore, many sites can still detect bots and block username/password logins. Twitter/X and YouTube are two prominent ones that block username/password logging. + +One of the 'Cookies' options is recommended for the most robust archiving. +``` ```{code} yaml authentication: # optional file to load authentication information from, for security or multi-system deploy purposes load_from_file: path/to/authentication/file.txt - # optional setting to load cookies from the named browser on the system. + # optional setting to load cookies from the named browser on the system, for **ALL** websites cookies_from_browser: firefox - # optional setting to load cookies from a cookies.txt/cookies.jar file. See note below on extracting these + # optional setting to load cookies from a cookies.txt/cookies.jar file, for **ALL** websites. See note below on extracting these cookies_file: path/to/cookies.jar - twitter.com,x.com: + mysite.com: username: myusername password: 123 @@ -29,15 +49,10 @@ authentication: othersite.com: api_key: 123 api_secret: 1234 - -# All available options: - # - username: str - the username to use for login - # - password: str - the password to use for login - # - api_key: str - the API key to use for login - # - api_secret: str - the API secret to use for login - # - cookie: str - a cookie string to use for login (specific to this site) + ``` + ### Recommendations for authentication 1. **Store authentication information separately:** diff --git a/docs/source/installation/config_editor.md b/docs/source/installation/config_editor.md new file mode 100644 index 0000000..a23ebce --- /dev/null +++ b/docs/source/installation/config_editor.md @@ -0,0 +1,5 @@ +# Configuration Editor + +```{raw} html +:file: settings.html +``` \ No newline at end of file diff --git a/docs/source/installation/configurations.md b/docs/source/installation/configurations.md index 705b6c5..e3aa76e 100644 --- a/docs/source/installation/configurations.md +++ b/docs/source/installation/configurations.md @@ -1,13 +1,18 @@ # Configuration -This section of the documentation provides guidelines for configuring the tool. +The recommended way to configure auto-archiver for first-time users is to [run the Auto Archiver](setup.md#running) and have it auto-generate a default configuration for you. Then, if needed, you can edit the configuration file using one of the following methods. -## Configuring using a file -The recommended way to configure auto-archiver for long-term and deployed projects is a configuration file, typically called `orchestration.yaml`. This is a YAML file containing all the settings for your entire workflow. +## 1. Configuration file -The structure of orchestration file is split into 2 parts: `steps` (what [steps](../flow_overview.md) to use) and `configurations` (settings for different modules), here's a simplification: +The configuration file is typically called `orchestration.yaml` and stored in the `secrets` folder on your desktop. The configuration file contains all the settings for your entire Auto Archiver workflow in one easy-to-find place. + +If you want to have Auto Archiver run with the recommended 'basic' setup, + +### Advanced Configuration + +The structure of orchestration file is split into 2 parts: `steps` (what [steps](../flow_overview.md) to use) and `configurations` (settings for individual modules). A default `orchestration.yaml` will be created for you the first time you run auto-archiver (without any arguments). Here's what it looks like: @@ -21,9 +26,9 @@ A default `orchestration.yaml` will be created for you the first time you run au -## Configuring from the Command Line +## 2. Command Line configuration -You can run auto-archiver directy from the command line, without the need for a configuration file, command line arguments are parsed using the format `module_name.config_value`. For example, a config value of `api_key` in the `instagram_extractor` module would be passed on the command line with the flag `--instagram_extractor.api_key=API_KEY`. +You can run auto-archiver directly from the command line, without the need for a configuration file, command line arguments are parsed using the format `module_name.config_value`. For example, a config value of `api_key` in the `instagram_extractor` module would be passed on the command line with the flag `--instagram_extractor.api_key=API_KEY`. The command line arguments are useful for testing or editing config values and enabling/disabling modules on the fly. When you are happy with your settings, you can store them back in your configuration file by passing the `-s/--store` flag on the command line. diff --git a/docs/source/installation/installation.md b/docs/source/installation/installation.md index fdd3184..eff0720 100644 --- a/docs/source/installation/installation.md +++ b/docs/source/installation/installation.md @@ -1,80 +1,44 @@ -# Installing Auto Archiver +# Installation -```{toctree} -:depth: 1 -:hidden: +There are 3 main ways to use the auto-archiver. We recommend the 'docker' method for most uses. This installs all the requirements in one command. -configurations.md -config_cheatsheet.md -``` - -There are 3 main ways to use the auto-archiver: -1. Easiest: [via docker](#installing-with-docker) +1. Easiest (recommended): [via docker](#installing-with-docker) 2. Local Install: [using pip](#installing-locally-with-pip) 3. Developer Install: [see the developer guidelines](../development/developer_guidelines) - -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). - - -## Installing with Docker +## 1. Installing with Docker [![dockeri.co](https://dockerico.blankenship.io/image/bellingcat/auto-archiver)](https://hub.docker.com/r/bellingcat/auto-archiver) -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. +Docker works like a virtual machine running inside your computer, making installation simple. You'll need to first set up Docker, and then download the Auto Archiver 'image': -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 +**a) Download and install docker** -### Example invocations +Go to the [Docker website](https://docs.docker.com/get-docker/) and download right version for your operating system. -The invocations below will run the auto-archiver Docker image using a configuration file that you have specified +**b) Pull the Auto Archiver docker image** + +Open your command line terminal, and copy-paste / type: ```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 -# 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 +docker pull bellingcat/auto-archiver ``` -## Installing Locally with Pip +This will download the docker image, which may take a while. + +That's it, all done! You're now ready to set up [your configuration file](configurations.md). Or, if you want to use the recommended defaults, then you can [run Auto Archiver immediately](setup.md#running-a-docker-install). + +------------ + +## 2. Installing Locally with Pip 1. Make sure you have python 3.10 or higher installed 2. Install the package with your preferred package manager: `pip/pipenv/conda install auto-archiver` or `poetry add auto-archiver` 3. Test it's installed with `auto-archiver --help` -4. Install other local dependency requirements (for ) -5. 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 +4. Install other local dependency requirements (for example `ffmpeg`, `firefox`) -### Example invocations - -Once all your [local requirements](#installing-local-requirements) are correctly installed, the - -```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 -``` +After this, you're ready to set up your [your configuration file](configurations.md), or if you want to use the recommended defaults, then you can [run Auto Archiver immediately](setup.md#running-a-local-install). ### Installing Local Requirements diff --git a/docs/source/installation/requirements.md b/docs/source/installation/requirements.md new file mode 100644 index 0000000..b820272 --- /dev/null +++ b/docs/source/installation/requirements.md @@ -0,0 +1,14 @@ +# Requirements + +Using the Auto Archiver is very simple, but ideally you have some familiarity with using the command line to run programs. ([Command line crash course](https://developer.mozilla.org/en-US/docs/Learn_web_development/Getting_started/Environment_setup/Command_line)). + +### System Requirements + +* Auto Archiver works on any Windows, macOS and Linux computer +* If you're using the **local install** method, then you should make sure to have python3.10+ installed + +### Storage Requirements + +By default, Auto Archiver uses your local computer storage for any downloaded media (videos, images etc.). If you're downloading large files, this may take up a lot of your local computer's space (more than 5GB of space). + +If your storage space is limited, then you may want to set up an [alternative storage method](../modules/storage.md) for your media. \ No newline at end of file diff --git a/docs/source/installation/settings.html b/docs/source/installation/settings.html new file mode 100644 index 0000000..915ee8d --- /dev/null +++ b/docs/source/installation/settings.html @@ -0,0 +1,48685 @@ + + +
diff --git a/docs/source/installation/setup.md b/docs/source/installation/setup.md new file mode 100644 index 0000000..f5b6e9d --- /dev/null +++ b/docs/source/installation/setup.md @@ -0,0 +1,78 @@ +# Getting Started + +```{toctree} +:maxdepth: 1 +:hidden: + +installation.md +configurations.md +config_editor.md +authentication.md +requirements.md +config_cheatsheet.md +``` + +## Getting Started + +To get started with Auto Archiver, there are 3 main steps you need to complete. + +1. [Install Auto Archiver](installation.md) +2. [Setup up your configuration](configurations.md) (if you are ok with the default settings, you can skip this step) +3. Run the archiving process + +The way you run the Auto Archiver depends on how you installed it (docker install or local install) + +### Running a Docker Install + +If you installed Auto Archiver using docker, open up your terminal, and copy-paste / type the following command: + +```bash +docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver + ``` + +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 with settings + 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 + +### Example invocations + +The invocations below will run the auto-archiver Docker image using a configuration file that you have specified + +```bash +# Have auto-archiver run with the default settings, generating a settings file in ./secrets/orchestration.yaml +docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver + +# uses the same configuration, but with the `gsheet_feeder`, a header on row 2 and with some different column names +# Note this expects you to have followed the [Google Sheets setup](how_to/google_sheets.md) and added your service_account.json to the `secrets/` folder +# 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 --feeders=gsheet_feeder --gsheet_feeder.sheet="use it on another sheets doc" --gsheet_feeder.header=2 --gsheet_feeder.columns='{"url": "link"}' +# Runs auto-archiver for the first time, but in 'full' mode, enabling all modules to get a full settings file +docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver --mode full +``` + +------------ + +### Running a Local Install + +### Example invocations + +Once all your [local requirements](#installing-local-requirements) are correctly installed, the + +```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 +``` diff --git a/docs/source/modules/database.md b/docs/source/modules/database.md index 9acecda..3ecd2e2 100644 --- a/docs/source/modules/database.md +++ b/docs/source/modules/database.md @@ -8,7 +8,7 @@ The default (enabled) databases are the CSV Database and the Console Database. ``` ```{toctree} -:depth: 1 +:maxdepth: 1 :hidden: :glob: autogen/database/* diff --git a/docs/source/modules/enricher.md b/docs/source/modules/enricher.md index 30568c3..a145a1d 100644 --- a/docs/source/modules/enricher.md +++ b/docs/source/modules/enricher.md @@ -7,7 +7,7 @@ Enricher modules are used to add additional information to the items that have ``` ```{toctree} -:depth: 1 +:maxdepth: 1 :hidden: :glob: autogen/enricher/* diff --git a/docs/source/modules/extractor.md b/docs/source/modules/extractor.md index 7f218fb..e6375db 100644 --- a/docs/source/modules/extractor.md +++ b/docs/source/modules/extractor.md @@ -4,14 +4,14 @@ Extractor modules are used to extract the content of a given URL. Typically, one Extractors that are able to extract content from a wide range of websites include: 1. Generic Extractor: parses videos and images on sites using the powerful yt-dlp library. -2. Wayback Machine Extractor: sends pages to the Waygback machine for archiving, and stores the link. +2. Wayback Machine Extractor: sends pages to the Wayback machine for archiving, and stores the link. 3. WACZ Extractor: runs a web browser to 'browse' the URL and save a copy of the page in WACZ format. ```{include} autogen/extractor.md ``` ```{toctree} -:depth: 1 +:maxdepth: 1 :hidden: :glob: autogen/extractor/* diff --git a/docs/source/modules/feeder.md b/docs/source/modules/feeder.md index ce5f7ca..dcac749 100644 --- a/docs/source/modules/feeder.md +++ b/docs/source/modules/feeder.md @@ -1,8 +1,8 @@ # Feeder Modules -Feeder modules are used to feed URLs into the `auto-archiver` for processing. Feeders can take these URLs from a variety of sources, such as a file, a database, or the command line. +Feeder modules are used to feed URLs into the Auto Archiver for processing. Feeders can take these URLs from a variety of sources, such as a file, a database, or the command line. -The default feeder is the command line feeder (`cli_feeder`), which allows you to input URLs directly into the `auto-archiver` from the command line. +The default feeder is the command line feeder (`cli_feeder`), which allows you to input URLs directly into `auto-archiver` from the command line. Command line feeder usage: ```{code} bash @@ -13,7 +13,7 @@ auto-archiver [options] -- URL1 URL2 ... ``` ```{toctree} -:depth: 1 +:maxdepth: 1 :glob: :hidden: autogen/feeder/* diff --git a/docs/source/modules/formatter.md b/docs/source/modules/formatter.md index b7ae77e..7d5713c 100644 --- a/docs/source/modules/formatter.md +++ b/docs/source/modules/formatter.md @@ -6,7 +6,7 @@ Formatter modules are used to format the data extracted from a URL into a specif ``` ```{toctree} -:depth: 1 +:maxdepth: 1 :hidden: :glob: autogen/formatter/* diff --git a/docs/source/modules/storage.md b/docs/source/modules/storage.md index 427213c..d4a2f99 100644 --- a/docs/source/modules/storage.md +++ b/docs/source/modules/storage.md @@ -8,7 +8,7 @@ The default is to store the files downloaded (e.g. images, videos) in a local di ``` ```{toctree} -:depth: 1 +:maxdepth: 1 :hidden: :glob: autogen/storage/* diff --git a/poetry.lock b/poetry.lock index 83b2860..1e5f105 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "accessible-pygments" @@ -51,7 +51,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -94,23 +94,23 @@ files = [ ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] [[package]] name = "authlib" -version = "1.4.1" +version = "1.5.1" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "Authlib-1.4.1-py2.py3-none-any.whl", hash = "sha256:edc29c3f6a3e72cd9e9f45fff67fc663a2c364022eb0371c003f22d5405915c1"}, - {file = "authlib-1.4.1.tar.gz", hash = "sha256:30ead9ea4993cdbab821dc6e01e818362f92da290c04c7f6a1940f86507a790d"}, + {file = "authlib-1.5.1-py2.py3-none-any.whl", hash = "sha256:8408861cbd9b4ea2ff759b00b6f02fd7d81ac5a56d0b2b22c08606c6049aae11"}, + {file = "authlib-1.5.1.tar.gz", hash = "sha256:5cbc85ecb0667312c1cdc2f9095680bb735883b123fb509fde1e65b1c5df972e"}, ] [package.dependencies] @@ -145,7 +145,7 @@ files = [ ] [package.extras] -dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] [[package]] name = "beautifulsoup4" @@ -172,18 +172,18 @@ lxml = ["lxml"] [[package]] name = "boto3" -version = "1.36.22" +version = "1.37.8" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "boto3-1.36.22-py3-none-any.whl", hash = "sha256:39957eabdce009353d72d131046489fbbfa15891865d5f069f1e8bfa414e6b81"}, - {file = "boto3-1.36.22.tar.gz", hash = "sha256:768c8a4d4a6227fe2258105efa086f1424cba5ca915a5eb2305b2cd979306ad1"}, + {file = "boto3-1.37.8-py3-none-any.whl", hash = "sha256:b9f506e08c9f54687d6c073ef1c550a24a62cc2d1e0bc7cda9f13112a38818bf"}, + {file = "boto3-1.37.8.tar.gz", hash = "sha256:9448f4a079189e19c3253cfdc5b8ef6dc51a3b82431e8347a51f4c1b2d9dab42"}, ] [package.dependencies] -botocore = ">=1.36.22,<1.37.0" +botocore = ">=1.37.8,<1.38.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.11.0,<0.12.0" @@ -192,14 +192,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.36.22" +version = "1.37.8" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "botocore-1.36.22-py3-none-any.whl", hash = "sha256:75d6b34acb0686ee4d54ff6eb285e78ccfe318407428769d1e3e13351714d890"}, - {file = "botocore-1.36.22.tar.gz", hash = "sha256:59520247d5a479731724f97c995d5a1c2aae3b303b324f39d99efcfad1d3019e"}, + {file = "botocore-1.37.8-py3-none-any.whl", hash = "sha256:a6c94f33de12f4b10b10684019e554c980469b8394c6d82448a738cbd8452cef"}, + {file = "botocore-1.37.8.tar.gz", hash = "sha256:b5825e08dd3e25642aa22a0d7d92bf81fef1ef857117e4155f923bbccf5aba63"}, ] [package.dependencies] @@ -363,14 +363,14 @@ beautifulsoup4 = "*" [[package]] name = "cachetools" -version = "5.5.1" +version = "5.5.2" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"}, - {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"}, + {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, + {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, ] [[package]] @@ -481,6 +481,18 @@ files = [ [package.dependencies] pycparser = "*" +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + [[package]] name = "charset-normalizer" version = "3.4.1" @@ -696,6 +708,18 @@ calendars = ["convertdate (>=2.2.1)", "hijridate"] fasttext = ["fasttext (>=0.9.1)", "numpy (>=1.19.3,<2)"] langdetect = ["langdetect (>=1.0.0)"] +[[package]] +name = "distlib" +version = "0.3.9" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, +] + [[package]] name = "docutils" version = "0.21.2" @@ -742,6 +766,23 @@ future = "*" [package.extras] dev = ["Sphinx (==2.1.0)", "future (==0.17.1)", "numpy (==1.16.4)", "pytest (==4.6.1)", "pytest-mock (==1.10.4)", "tox (==3.12.1)"] +[[package]] +name = "filelock" +version = "3.17.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338"}, + {file = "filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] + [[package]] name = "future" version = "1.0.0" @@ -775,20 +816,20 @@ requests = ">=2.18.0,<3.0.0.dev0" [package.extras] async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.dev0)"] -grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\""] grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] [[package]] name = "google-api-python-client" -version = "2.161.0" +version = "2.163.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "google_api_python_client-2.161.0-py2.py3-none-any.whl", hash = "sha256:9476a5a4f200bae368140453df40f9cda36be53fa7d0e9a9aac4cdb859a26448"}, - {file = "google_api_python_client-2.161.0.tar.gz", hash = "sha256:324c0cce73e9ea0a0d2afd5937e01b7c2d6a4d7e2579cdb6c384f9699d6c9f37"}, + {file = "google_api_python_client-2.163.0-py2.py3-none-any.whl", hash = "sha256:080e8bc0669cb4c1fb8efb8da2f5b91a2625d8f0e7796cfad978f33f7016c6c4"}, + {file = "google_api_python_client-2.163.0.tar.gz", hash = "sha256:88dee87553a2d82176e2224648bf89272d536c8f04dcdda37ef0a71473886dd7"}, ] [package.dependencies] @@ -860,14 +901,14 @@ tool = ["click (>=6.0.0)"] [[package]] name = "googleapis-common-protos" -version = "1.67.0" +version = "1.69.1" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "googleapis_common_protos-1.67.0-py2.py3-none-any.whl", hash = "sha256:579de760800d13616f51cf8be00c876f00a9f146d3e6510e19d1f4111758b741"}, - {file = "googleapis_common_protos-1.67.0.tar.gz", hash = "sha256:21398025365f138be356d5923e9168737d94d46a72aefee4a6110a1f23463c86"}, + {file = "googleapis_common_protos-1.69.1-py2.py3-none-any.whl", hash = "sha256:4077f27a6900d5946ee5a369fab9c8ded4c0ef1c6e880458ea2f70c14f7b70d5"}, + {file = "googleapis_common_protos-1.69.1.tar.gz", hash = "sha256:e20d2d8dda87da6fe7340afbbdf4f0bcb4c8fae7e6cadf55926c31f946b0b9b1"}, ] [package.dependencies] @@ -878,14 +919,14 @@ grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] [[package]] name = "gspread" -version = "6.1.4" +version = "6.2.0" description = "Google Spreadsheets Python API" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "gspread-6.1.4-py3-none-any.whl", hash = "sha256:c34781c426031a243ad154952b16f21ac56a5af90687885fbee3d1fba5280dcd"}, - {file = "gspread-6.1.4.tar.gz", hash = "sha256:b8eec27de7cadb338bb1b9f14a9be168372dee8965c0da32121816b5050ac1de"}, + {file = "gspread-6.2.0-py3-none-any.whl", hash = "sha256:7fa1a11e1ecacc6c5946fa016be05941baca8540404314f59aec963dd8ae5db3"}, + {file = "gspread-6.2.0.tar.gz", hash = "sha256:bc3d02d1c39e0b40bfc8035b4fec407aa71a17f343fc81cc7e3f75bfa6555de6"}, ] [package.dependencies] @@ -919,6 +960,21 @@ files = [ [package.dependencies] pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} +[[package]] +name = "identify" +version = "2.6.9" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "identify-2.6.9-py2.py3-none-any.whl", hash = "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150"}, + {file = "identify-2.6.9.tar.gz", hash = "sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.10" @@ -978,14 +1034,14 @@ browser-cookie3 = ["browser_cookie3 (>=0.19.1)"] [[package]] name = "jinja2" -version = "3.1.5" +version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" groups = ["main", "docs"] files = [ - {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, - {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [package.dependencies] @@ -1059,7 +1115,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] -dev = ["Sphinx (==8.1.3)", "build (==1.2.2)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.5.0)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.13.0)", "mypy (==v1.4.1)", "myst-parser (==4.0.0)", "pre-commit (==4.0.1)", "pytest (==6.1.2)", "pytest (==8.3.2)", "pytest-cov (==2.12.1)", "pytest-cov (==5.0.0)", "pytest-cov (==6.0.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.1.0)", "sphinx-rtd-theme (==3.0.2)", "tox (==3.27.1)", "tox (==4.23.2)", "twine (==6.0.1)"] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] [[package]] name = "markdown-it-py" @@ -1260,6 +1316,18 @@ rtd = ["ipython", "sphinx (>=7)", "sphinx-autodoc2 (>=0.5.0,<0.6.0)", "sphinx-bo testing = ["beautifulsoup4", "coverage[toml]", "defusedxml", "pygments (<2.19)", "pytest (>=8,<9)", "pytest-cov", "pytest-param-files (>=0.6.0,<0.7.0)", "pytest-regressions", "sphinx-pytest"] testing-docutils = ["pygments", "pytest (>=8,<9)", "pytest-param-files (>=0.6.0,<0.7.0)"] +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + [[package]] name = "numpy" version = "2.1.3" @@ -1361,6 +1429,22 @@ rsa = ["cryptography (>=3.0.0)"] signals = ["blinker (>=1.4.0)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] +[[package]] +name = "opentimestamps" +version = "0.4.5" +description = "Create and verify OpenTimestamps proofs" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "opentimestamps-0.4.5-py3-none-any.whl", hash = "sha256:a4912b3bd1b612a3ef5fac925b9137889e6c5cb91cc9e76c8202a2bf8abe26b5"}, + {file = "opentimestamps-0.4.5.tar.gz", hash = "sha256:56726ccde97fb67f336a7f237ce36808e5593c3089d68d900b1c83d0ebf9dcfa"}, +] + +[package.dependencies] +pycryptodomex = ">=3.3.1" +python-bitcoinlib = ">=0.9.0,<0.13.0" + [[package]] name = "oscrypto" version = "1.3.0" @@ -1510,9 +1594,26 @@ docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline fpx = ["olefile"] mic = ["olefile"] tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions"] +typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + [[package]] name = "pluggy" version = "1.5.0" @@ -1529,6 +1630,25 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pre-commit" +version = "4.1.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b"}, + {file = "pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "proto-plus" version = "1.26.0" @@ -1674,14 +1794,14 @@ files = [ [[package]] name = "pydata-sphinx-theme" -version = "0.16.1" +version = "0.15.4" description = "Bootstrap-based Sphinx theme from the PyData community" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ - {file = "pydata_sphinx_theme-0.16.1-py3-none-any.whl", hash = "sha256:225331e8ac4b32682c18fcac5a57a6f717c4e632cea5dd0e247b55155faeccde"}, - {file = "pydata_sphinx_theme-0.16.1.tar.gz", hash = "sha256:a08b7f0b7f70387219dc659bff0893a7554d5eb39b59d3b8ef37b8401b7642d7"}, + {file = "pydata_sphinx_theme-0.15.4-py3-none-any.whl", hash = "sha256:2136ad0e9500d0949f96167e63f3e298620040aea8f9c74621959eda5d4cf8e6"}, + {file = "pydata_sphinx_theme-0.15.4.tar.gz", hash = "sha256:7762ec0ac59df3acecf49fd2f889e1b4565dbce8b88b2e29ee06fdd90645a06d"}, ] [package.dependencies] @@ -1689,8 +1809,9 @@ accessible-pygments = "*" Babel = "*" beautifulsoup4 = "*" docutils = "!=0.17.0" +packaging = "*" pygments = ">=2.7" -sphinx = ">=6.1" +sphinx = ">=5" typing-extensions = "*" [package.extras] @@ -1776,14 +1897,14 @@ files = [ [[package]] name = "pytest" -version = "8.3.4" +version = "8.3.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, - {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, ] [package.dependencies] @@ -1833,6 +1954,18 @@ pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-bitcoinlib" +version = "0.12.2" +description = "The Swiss Army Knife of the Bitcoin protocol." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python-bitcoinlib-0.12.2.tar.gz", hash = "sha256:c65ab61427c77c38d397bfc431f71d86fd355b453a536496ec3fcb41bd10087d"}, + {file = "python_bitcoinlib-0.12.2-py3-none-any.whl", hash = "sha256:2f29a9f475f21c12169b3a6cc8820f34f11362d7ff1200a5703dce3e4e903a44"}, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1901,7 +2034,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -2245,34 +2378,62 @@ files = [ {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, ] +[[package]] +name = "ruff" +version = "0.9.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d"}, + {file = "ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d"}, + {file = "ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8"}, + {file = "ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029"}, + {file = "ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1"}, + {file = "ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69"}, + {file = "ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7"}, +] + [[package]] name = "s3transfer" -version = "0.11.2" +version = "0.11.4" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "s3transfer-0.11.2-py3-none-any.whl", hash = "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc"}, - {file = "s3transfer-0.11.2.tar.gz", hash = "sha256:3b39185cb72f5acc77db1a58b6e25b977f28d20496b6e58d6813d75f464d632f"}, + {file = "s3transfer-0.11.4-py3-none-any.whl", hash = "sha256:ac265fa68318763a03bf2dc4f39d5cbd6a9e178d81cc9483ad27da33637e320d"}, + {file = "s3transfer-0.11.4.tar.gz", hash = "sha256:559f161658e1cf0a911f45940552c696735f5c74e64362e515f333ebed87d679"}, ] [package.dependencies] -botocore = ">=1.36.0,<2.0a.0" +botocore = ">=1.37.4,<2.0a.0" [package.extras] -crt = ["botocore[crt] (>=1.36.0,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "selenium" -version = "4.28.1" +version = "4.29.0" description = "Official Python bindings for Selenium WebDriver" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "selenium-4.28.1-py3-none-any.whl", hash = "sha256:4238847e45e24e4472cfcf3554427512c7aab9443396435b1623ef406fff1cc1"}, - {file = "selenium-4.28.1.tar.gz", hash = "sha256:0072d08670d7ec32db901bd0107695a330cecac9f196e3afb3fa8163026e022a"}, + {file = "selenium-4.29.0-py3-none-any.whl", hash = "sha256:ce5d26f1ddc1111641113653af33694c13947dd36c2df09cdd33f554351d372e"}, + {file = "selenium-4.29.0.tar.gz", hash = "sha256:3a62f7ec33e669364a6c0562a701deb69745b569c50d55f1a912bf8eb33358ba"}, ] [package.dependencies] @@ -2425,19 +2586,19 @@ test = ["httpx", "pytest (>=6)"] [[package]] name = "sphinx-book-theme" -version = "1.1.3" +version = "1.1.4" description = "A clean book theme for scientific explanations and documentation with Sphinx" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ - {file = "sphinx_book_theme-1.1.3-py3-none-any.whl", hash = "sha256:a554a9a7ac3881979a87a2b10f633aa2a5706e72218a10f71be38b3c9e831ae9"}, - {file = "sphinx_book_theme-1.1.3.tar.gz", hash = "sha256:1f25483b1846cb3d353a6bc61b3b45b031f4acf845665d7da90e01ae0aef5b4d"}, + {file = "sphinx_book_theme-1.1.4-py3-none-any.whl", hash = "sha256:843b3f5c8684640f4a2d01abd298beb66452d1b2394cd9ef5be5ebd5640ea0e1"}, + {file = "sphinx_book_theme-1.1.4.tar.gz", hash = "sha256:73efe28af871d0a89bd05856d300e61edce0d5b2fbb7984e84454be0fedfe9ed"}, ] [package.dependencies] -pydata-sphinx-theme = ">=0.15.2" -sphinx = ">=5" +pydata-sphinx-theme = "0.15.4" +sphinx = ">=6.1" [package.extras] code-style = ["pre-commit"] @@ -2584,14 +2745,14 @@ test = ["pytest"] [[package]] name = "starlette" -version = "0.45.3" +version = "0.46.0" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ - {file = "starlette-0.45.3-py3-none-any.whl", hash = "sha256:dfb6d332576f136ec740296c7e8bb8c8a7125044e7c6da30744718880cdd059d"}, - {file = "starlette-0.45.3.tar.gz", hash = "sha256:2cbcba2a75806f8a41c722141486f37c28e30a0921c5f6fe4346cb0dcee1302f"}, + {file = "starlette-0.46.0-py3-none-any.whl", hash = "sha256:913f0798bd90ba90a9156383bcf1350a17d6259451d0d8ee27fc0cf2db609038"}, + {file = "starlette-0.46.0.tar.gz", hash = "sha256:b359e4567456b28d473d0193f34c0de0ed49710d75ef183a74a5ce0499324f50"}, ] [package.dependencies] @@ -2602,14 +2763,14 @@ full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart [[package]] name = "telethon" -version = "1.38.1" +version = "1.39.0" description = "Full-featured Telegram client library for Python 3" optional = false python-versions = ">=3.5" groups = ["main"] files = [ - {file = "Telethon-1.38.1-py3-none-any.whl", hash = "sha256:30c187017501bfb982b8af5659f864dda4108f77ea49cfce61e8f6fdb8a18d6e"}, - {file = "Telethon-1.38.1.tar.gz", hash = "sha256:f9866c1e37197a0894e0c02aa56a6359bffb14a585e88e18e3e819df4fda399a"}, + {file = "Telethon-1.39.0-py3-none-any.whl", hash = "sha256:aa9f394b94be144799a6f6a93ab463867bc7c63503ede9631751940a98f6c703"}, + {file = "telethon-1.39.0.tar.gz", hash = "sha256:35d4795d8c91deac515fb0bcb3723866b924de1c724e1d5c230460e96f284a63"}, ] [package.dependencies] @@ -2719,14 +2880,14 @@ sortedcontainers = "*" [[package]] name = "trio-websocket" -version = "0.12.1" +version = "0.12.2" description = "WebSocket library for Trio" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "trio_websocket-0.12.1-py3-none-any.whl", hash = "sha256:608ec746bb287e5d5a66baf483e41194193c5cf05ffaad6240e7d1fcd80d1e6f"}, - {file = "trio_websocket-0.12.1.tar.gz", hash = "sha256:d55ccd4d3eae27c494f3fdae14823317839bdcb8214d1173eacc4d42c69fc91b"}, + {file = "trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"}, + {file = "trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae"}, ] [package.dependencies] @@ -2798,14 +2959,14 @@ files = [ [[package]] name = "tzlocal" -version = "5.3" +version = "5.3.1" description = "tzinfo object for the local timezone" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "tzlocal-5.3-py3-none-any.whl", hash = "sha256:3814135a1bb29763c6e4f08fd6e41dbb435c7a60bfbb03270211bcc537187d8c"}, - {file = "tzlocal-5.3.tar.gz", hash = "sha256:2fafbfc07e9d8b49ade18f898d6bcd37ae88ce3ad6486842a2e4f03af68323d2"}, + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, ] [package.dependencies] @@ -2857,7 +3018,7 @@ files = [ pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2880,7 +3041,28 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "virtualenv" +version = "20.29.3" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-20.29.3-py3-none-any.whl", hash = "sha256:3e3d00f5807e83b234dfb6122bf37cfadf4be216c53a49ac059d02414f819170"}, + {file = "virtualenv-20.29.3.tar.gz", hash = "sha256:95e39403fcf3940ac45bc717597dba16110b74506131845d9b687d5e73d947ac"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "vk-api" @@ -3051,81 +3233,81 @@ test = ["websockets"] [[package]] name = "websockets" -version = "15.0" +version = "15.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.9" groups = ["main", "docs"] files = [ - {file = "websockets-15.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5e6ee18a53dd5743e6155b8ff7e8e477c25b29b440f87f65be8165275c87fef0"}, - {file = "websockets-15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ee06405ea2e67366a661ed313e14cf2a86e84142a3462852eb96348f7219cee3"}, - {file = "websockets-15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8711682a629bbcaf492f5e0af72d378e976ea1d127a2d47584fa1c2c080b436b"}, - {file = "websockets-15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94c4a9b01eede952442c088d415861b0cf2053cbd696b863f6d5022d4e4e2453"}, - {file = "websockets-15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:45535fead66e873f411c1d3cf0d3e175e66f4dd83c4f59d707d5b3e4c56541c4"}, - {file = "websockets-15.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e389efe46ccb25a1f93d08c7a74e8123a2517f7b7458f043bd7529d1a63ffeb"}, - {file = "websockets-15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:67a04754d121ea5ca39ddedc3f77071651fb5b0bc6b973c71c515415b44ed9c5"}, - {file = "websockets-15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:bd66b4865c8b853b8cca7379afb692fc7f52cf898786537dfb5e5e2d64f0a47f"}, - {file = "websockets-15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a4cc73a6ae0a6751b76e69cece9d0311f054da9b22df6a12f2c53111735657c8"}, - {file = "websockets-15.0-cp310-cp310-win32.whl", hash = "sha256:89da58e4005e153b03fe8b8794330e3f6a9774ee9e1c3bd5bc52eb098c3b0c4f"}, - {file = "websockets-15.0-cp310-cp310-win_amd64.whl", hash = "sha256:4ff380aabd7a74a42a760ee76c68826a8f417ceb6ea415bd574a035a111fd133"}, - {file = "websockets-15.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dd24c4d256558429aeeb8d6c24ebad4e982ac52c50bc3670ae8646c181263965"}, - {file = "websockets-15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f83eca8cbfd168e424dfa3b3b5c955d6c281e8fc09feb9d870886ff8d03683c7"}, - {file = "websockets-15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4095a1f2093002c2208becf6f9a178b336b7572512ee0a1179731acb7788e8ad"}, - {file = "websockets-15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb915101dfbf318486364ce85662bb7b020840f68138014972c08331458d41f3"}, - {file = "websockets-15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:45d464622314973d78f364689d5dbb9144e559f93dca11b11af3f2480b5034e1"}, - {file = "websockets-15.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace960769d60037ca9625b4c578a6f28a14301bd2a1ff13bb00e824ac9f73e55"}, - {file = "websockets-15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7cd4b1015d2f60dfe539ee6c95bc968d5d5fad92ab01bb5501a77393da4f596"}, - {file = "websockets-15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4f7290295794b5dec470867c7baa4a14182b9732603fd0caf2a5bf1dc3ccabf3"}, - {file = "websockets-15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3abd670ca7ce230d5a624fd3d55e055215d8d9b723adee0a348352f5d8d12ff4"}, - {file = "websockets-15.0-cp311-cp311-win32.whl", hash = "sha256:110a847085246ab8d4d119632145224d6b49e406c64f1bbeed45c6f05097b680"}, - {file = "websockets-15.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7bbbe2cd6ed80aceef2a14e9f1c1b61683194c216472ed5ff33b700e784e37"}, - {file = "websockets-15.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cccc18077acd34c8072578394ec79563664b1c205f7a86a62e94fafc7b59001f"}, - {file = "websockets-15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4c22992e24f12de340ca5f824121a5b3e1a37ad4360b4e1aaf15e9d1c42582d"}, - {file = "websockets-15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1206432cc6c644f6fc03374b264c5ff805d980311563202ed7fef91a38906276"}, - {file = "websockets-15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d3cc75ef3e17490042c47e0523aee1bcc4eacd2482796107fd59dd1100a44bc"}, - {file = "websockets-15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b89504227a5311610e4be16071465885a0a3d6b0e82e305ef46d9b064ce5fb72"}, - {file = "websockets-15.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56e3efe356416bc67a8e093607315951d76910f03d2b3ad49c4ade9207bf710d"}, - {file = "websockets-15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f2205cdb444a42a7919690238fb5979a05439b9dbb73dd47c863d39640d85ab"}, - {file = "websockets-15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aea01f40995fa0945c020228ab919b8dfc93fc8a9f2d3d705ab5b793f32d9e99"}, - {file = "websockets-15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9f8e33747b1332db11cf7fcf4a9512bef9748cb5eb4d3f7fbc8c30d75dc6ffc"}, - {file = "websockets-15.0-cp312-cp312-win32.whl", hash = "sha256:32e02a2d83f4954aa8c17e03fe8ec6962432c39aca4be7e8ee346b05a3476904"}, - {file = "websockets-15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc02b159b65c05f2ed9ec176b715b66918a674bd4daed48a9a7a590dd4be1aa"}, - {file = "websockets-15.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d2244d8ab24374bed366f9ff206e2619345f9cd7fe79aad5225f53faac28b6b1"}, - {file = "websockets-15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3a302241fbe825a3e4fe07666a2ab513edfdc6d43ce24b79691b45115273b5e7"}, - {file = "websockets-15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:10552fed076757a70ba2c18edcbc601c7637b30cdfe8c24b65171e824c7d6081"}, - {file = "websockets-15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c53f97032b87a406044a1c33d1e9290cc38b117a8062e8a8b285175d7e2f99c9"}, - {file = "websockets-15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1caf951110ca757b8ad9c4974f5cac7b8413004d2f29707e4d03a65d54cedf2b"}, - {file = "websockets-15.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf1ab71f9f23b0a1d52ec1682a3907e0c208c12fef9c3e99d2b80166b17905f"}, - {file = "websockets-15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bfcd3acc1a81f106abac6afd42327d2cf1e77ec905ae11dc1d9142a006a496b6"}, - {file = "websockets-15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c8c5c8e1bac05ef3c23722e591ef4f688f528235e2480f157a9cfe0a19081375"}, - {file = "websockets-15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:86bfb52a9cfbcc09aba2b71388b0a20ea5c52b6517c0b2e316222435a8cdab72"}, - {file = "websockets-15.0-cp313-cp313-win32.whl", hash = "sha256:26ba70fed190708551c19a360f9d7eca8e8c0f615d19a574292b7229e0ae324c"}, - {file = "websockets-15.0-cp313-cp313-win_amd64.whl", hash = "sha256:ae721bcc8e69846af00b7a77a220614d9b2ec57d25017a6bbde3a99473e41ce8"}, - {file = "websockets-15.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c348abc5924caa02a62896300e32ea80a81521f91d6db2e853e6b1994017c9f6"}, - {file = "websockets-15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5294fcb410ed0a45d5d1cdedc4e51a60aab5b2b3193999028ea94afc2f554b05"}, - {file = "websockets-15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c24ba103ecf45861e2e1f933d40b2d93f5d52d8228870c3e7bf1299cd1cb8ff1"}, - {file = "websockets-15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc8821a03bcfb36e4e4705316f6b66af28450357af8a575dc8f4b09bf02a3dee"}, - {file = "websockets-15.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc5ae23ada6515f31604f700009e2df90b091b67d463a8401c1d8a37f76c1d7"}, - {file = "websockets-15.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ac67b542505186b3bbdaffbc303292e1ee9c8729e5d5df243c1f20f4bb9057e"}, - {file = "websockets-15.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c86dc2068f1c5ca2065aca34f257bbf4f78caf566eb230f692ad347da191f0a1"}, - {file = "websockets-15.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:30cff3ef329682b6182c01c568f551481774c476722020b8f7d0daacbed07a17"}, - {file = "websockets-15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:98dcf978d4c6048965d1762abd534c9d53bae981a035bfe486690ba11f49bbbb"}, - {file = "websockets-15.0-cp39-cp39-win32.whl", hash = "sha256:37d66646f929ae7c22c79bc73ec4074d6db45e6384500ee3e0d476daf55482a9"}, - {file = "websockets-15.0-cp39-cp39-win_amd64.whl", hash = "sha256:24d5333a9b2343330f0f4eb88546e2c32a7f5c280f8dd7d3cc079beb0901781b"}, - {file = "websockets-15.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b499caef4bca9cbd0bd23cd3386f5113ee7378094a3cb613a2fa543260fe9506"}, - {file = "websockets-15.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:17f2854c6bd9ee008c4b270f7010fe2da6c16eac5724a175e75010aacd905b31"}, - {file = "websockets-15.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89f72524033abbfde880ad338fd3c2c16e31ae232323ebdfbc745cbb1b3dcc03"}, - {file = "websockets-15.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1657a9eecb29d7838e3b415458cc494e6d1b194f7ac73a34aa55c6fb6c72d1f3"}, - {file = "websockets-15.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e413352a921f5ad5d66f9e2869b977e88d5103fc528b6deb8423028a2befd842"}, - {file = "websockets-15.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8561c48b0090993e3b2a54db480cab1d23eb2c5735067213bb90f402806339f5"}, - {file = "websockets-15.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:190bc6ef8690cd88232a038d1b15714c258f79653abad62f7048249b09438af3"}, - {file = "websockets-15.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:327adab7671f3726b0ba69be9e865bba23b37a605b585e65895c428f6e47e766"}, - {file = "websockets-15.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd8ef197c87afe0a9009f7a28b5dc613bfc585d329f80b7af404e766aa9e8c7"}, - {file = "websockets-15.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:789c43bf4a10cd067c24c321238e800b8b2716c863ddb2294d2fed886fa5a689"}, - {file = "websockets-15.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7394c0b7d460569c9285fa089a429f58465db930012566c03046f9e3ab0ed181"}, - {file = "websockets-15.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ea4f210422b912ebe58ef0ad33088bc8e5c5ff9655a8822500690abc3b1232d"}, - {file = "websockets-15.0-py3-none-any.whl", hash = "sha256:51ffd53c53c4442415b613497a34ba0aa7b99ac07f1e4a62db5dcd640ae6c3c3"}, - {file = "websockets-15.0.tar.gz", hash = "sha256:ca36151289a15b39d8d683fd8b7abbe26fc50be311066c5f8dcf3cb8cee107ab"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, + {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, + {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, + {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, + {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, + {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, + {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, + {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, + {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, + {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, + {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, + {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, + {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] [[package]] @@ -3142,7 +3324,7 @@ files = [ ] [package.extras] -dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [[package]] name = "wsproto" @@ -3161,20 +3343,20 @@ h11 = ">=0.9.0,<1" [[package]] name = "yt-dlp" -version = "2025.1.26" +version = "2025.2.19" description = "A feature-rich command-line audio/video downloader" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "yt_dlp-2025.1.26-py3-none-any.whl", hash = "sha256:3e76bd896b9f96601021ca192ca0fbdd195e3c3dcc28302a3a34c9bc4979da7b"}, - {file = "yt_dlp-2025.1.26.tar.gz", hash = "sha256:1c9738266921ad43c568ad01ac3362fb7c7af549276fbec92bd72f140da16240"}, + {file = "yt_dlp-2025.2.19-py3-none-any.whl", hash = "sha256:3ed218eaeece55e9d715afd41abc450dc406ee63bf79355169dfde312d38fdb8"}, + {file = "yt_dlp-2025.2.19.tar.gz", hash = "sha256:f33ca76df2e4db31880f2fe408d44f5058d9f135015b13e50610dfbe78245bea"}, ] [package.extras] build = ["build", "hatchling", "pip", "setuptools (>=71.0.2)", "wheel"] -curl-cffi = ["curl-cffi (==0.5.10)", "curl-cffi (>=0.5.10,!=0.6.*,<0.7.2)"] -default = ["brotli", "brotlicffi", "certifi", "mutagen", "pycryptodomex", "requests (>=2.32.2,<3)", "urllib3 (>=1.26.17,<3)", "websockets (>=13.0)"] +curl-cffi = ["curl-cffi (==0.5.10) ; os_name == \"nt\" and implementation_name == \"cpython\"", "curl-cffi (>=0.5.10,!=0.6.*,<0.7.2) ; os_name != \"nt\" and implementation_name == \"cpython\""] +default = ["brotli ; implementation_name == \"cpython\"", "brotlicffi ; implementation_name != \"cpython\"", "certifi", "mutagen", "pycryptodomex", "requests (>=2.32.2,<3)", "urllib3 (>=1.26.17,<3)", "websockets (>=13.0)"] dev = ["autopep8 (>=2.0,<3.0)", "pre-commit", "pytest (>=8.1,<9.0)", "pytest-rerunfailures (>=14.0,<15.0)", "ruff (>=0.9.0,<0.10.0)"] pyinstaller = ["pyinstaller (>=6.11.1)"] secretstorage = ["cffi", "secretstorage"] @@ -3184,4 +3366,4 @@ test = ["pytest (>=8.1,<9.0)", "pytest-rerunfailures (>=14.0,<15.0)"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.13" -content-hash = "2d0a953383901fe12e97f6f56a76a9d8008788695425792eedbf739a18585188" +content-hash = "beb354960b8d8af491a13e09cb565c7e3099a2b150167c16147aa0438e970018" diff --git a/pyproject.toml b/pyproject.toml index 3c64eae..6896e6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "auto-archiver" -version = "0.13.4" +version = "0.13.6" description = "Automatically archive links to videos, images, and social media content from Google Sheets (and more)." requires-python = ">=3.10,<3.13" @@ -57,6 +57,7 @@ dependencies = [ "certvalidator (>=0.0.0)", "rich-argparse (>=1.6.0,<2.0.0)", "ruamel-yaml (>=0.18.10,<0.19.0)", + "opentimestamps (>=0.4.5,<0.5.0)", ] [tool.poetry.group.dev.dependencies] @@ -64,6 +65,8 @@ pytest = "^8.3.4" autopep8 = "^2.3.1" pytest-loguru = "^0.4.0" pytest-mock = "^3.14.0" +ruff = "^0.9.10" +pre-commit = "^4.1.0" [tool.poetry.group.docs.dependencies] sphinx = "^8.1.3" @@ -89,4 +92,29 @@ documentation = "https://github.com/bellingcat/auto-archiver" markers = [ "download: marks tests that download content from the network", "incremental: marks a class to run tests incrementally. If a test fails in the class, the remaining tests will be skipped", -] \ No newline at end of file +] + +[tool.ruff] +#exclude = ["docs"] +line-length = 120 +# Remove this for a more detailed lint report +output-format = "concise" +# TODO: temp ignore rule for timestamping_enricher to allow for open PR +exclude = ["src/auto_archiver/modules/timestamping_enricher/*"] + + +[tool.ruff.lint] +# Extend the rules to check for by adding them to this option: +# See documentation for more details: https://docs.astral.sh/ruff/rules/ +#extend-select = ["B"] + +[tool.ruff.lint.per-file-ignores] +# Ignore import violations in __init__.py files +"__init__.py" = ["F401", "F403"] +# Ignore 'useless expression' in manifest files. +"__manifest__.py" = ["B018"] + + +[tool.ruff.format] +docstring-code-format = false + diff --git a/scripts/create_update_gdrive_oauth_token.py b/scripts/create_update_gdrive_oauth_token.py index eb6fdbe..edd2565 100644 --- a/scripts/create_update_gdrive_oauth_token.py +++ b/scripts/create_update_gdrive_oauth_token.py @@ -1,5 +1,6 @@ import os.path -import click, json +import click +import json from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials @@ -70,11 +71,7 @@ def main(credentials, token): print(emailAddress) # Call the Drive v3 API and return some files - results = ( - service.files() - .list(pageSize=10, fields="nextPageToken, files(id, name)") - .execute() - ) + results = service.files().list(pageSize=10, fields="nextPageToken, files(id, name)").execute() items = results.get("files", []) if not items: diff --git a/scripts/generate_settings_schema.py b/scripts/generate_settings_schema.py new file mode 100644 index 0000000..fa7aaf6 --- /dev/null +++ b/scripts/generate_settings_schema.py @@ -0,0 +1,62 @@ +import json +import os +import io + +from ruamel.yaml import YAML + +from auto_archiver.core.module import ModuleFactory +from auto_archiver.core.consts import MODULE_TYPES +from auto_archiver.core.config import EMPTY_CONFIG + + +class SchemaEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, set): + return list(obj) + return json.JSONEncoder.default(self, obj) + + +# Get available modules +module_factory = ModuleFactory() +available_modules = module_factory.available_modules() + +modules_by_type = {} +# Categorize modules by type +for module in available_modules: + for type in module.manifest.get("type", []): + modules_by_type.setdefault(type, []).append(module) + +all_modules_ordered_by_type = sorted( + available_modules, key=lambda x: (MODULE_TYPES.index(x.type[0]), not x.requires_setup) +) + +yaml: YAML = YAML() + +config_string = io.BytesIO() +yaml.dump(EMPTY_CONFIG, config_string) +config_string = config_string.getvalue().decode("utf-8") +output_schema = { + "modules": dict( + ( + module.name, + { + "name": module.name, + "display_name": module.display_name, + "manifest": module.manifest, + "configs": module.configs or None, + }, + ) + for module in all_modules_ordered_by_type + ), + "steps": dict( + (f"{module_type}s", [module.name for module in modules_by_type[module_type]]) for module_type in MODULE_TYPES + ), + "configs": [m.name for m in all_modules_ordered_by_type if m.configs], + "module_types": MODULE_TYPES, + "empty_config": config_string, +} + +current_file_dir = os.path.dirname(os.path.abspath(__file__)) +output_file = os.path.join(current_file_dir, "settings/src/schema.json") +with open(output_file, "w") as file: + json.dump(output_schema, file, indent=4, cls=SchemaEncoder) diff --git a/scripts/settings/.gitignore b/scripts/settings/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/scripts/settings/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/scripts/settings/index.html b/scripts/settings/index.html new file mode 100644 index 0000000..22ff169 --- /dev/null +++ b/scripts/settings/index.html @@ -0,0 +1,3 @@ + +
+ diff --git a/scripts/settings/package-lock.json b/scripts/settings/package-lock.json new file mode 100644 index 0000000..cd40c14 --- /dev/null +++ b/scripts/settings/package-lock.json @@ -0,0 +1,3743 @@ +{ + "name": "material-ui-vite-ts", + "version": "5.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "material-ui-vite-ts", + "version": "5.0.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@emotion/react": "latest", + "@emotion/styled": "latest", + "@mui/icons-material": "latest", + "@mui/material": "latest", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-markdown": "^10.0.0", + "yaml": "^2.7.0" + }, + "devDependencies": { + "@types/react": "latest", + "@types/react-dom": "latest", + "@vitejs/plugin-react": "latest", + "typescript": "latest", + "vite": "latest", + "vite-plugin-singlefile": "^2.1.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", + "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", + "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", + "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.6.tgz", + "integrity": "sha512-rho5Q4IscbrVmK9rCrLTJmjLjfH6m/NcqKr/mchvck0EIXlyYUB9+Z0oVmkt/+Mben43LMRYBH8q/Uzxj/c4Vw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.6.tgz", + "integrity": "sha512-rGJBvIQQbQAlyKYljHQ8wAQS/K2/uYwvemcpygnAmCizmCI4zSF9HQPuiG8Ql4YLZ6V/uKjA3WHIYmF/8sV+pQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^6.4.6", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.6.tgz", + "integrity": "sha512-6UyAju+DBOdMogfYmLiT3Nu7RgliorimNBny1pN/acOjc+THNFVE7hlxLyn3RDONoZJNDi/8vO4AQQr6dLAXqA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.4.6", + "@mui/system": "^6.4.6", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.4.6", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.6.tgz", + "integrity": "sha512-T5FxdPzCELuOrhpA2g4Pi6241HAxRwZudzAuL9vBvniuB5YU82HCmrARw32AuCiyTfWzbrYGGpZ4zyeqqp9RvQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.6", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.6.tgz", + "integrity": "sha512-vSWYc9ZLX46be5gP+FCzWVn5rvDr4cXC5JBZwSIkYk9xbC7GeV+0kCvB8Q6XLFQJy+a62bbqtmdwS4Ghi9NBlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.6.tgz", + "integrity": "sha512-FQjWwPec7pMTtB/jw5f9eyLynKFZ6/Ej9vhm5kGdtmts1z5b7Vyn3Rz6kasfYm1j2TfrfGnSXRvvtwVWxjpz6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.6", + "@mui/styled-engine": "^6.4.6", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.21", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", + "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.6.tgz", + "integrity": "sha512-43nZeE1pJF2anGafNydUcYFPtHwAqiBiauRtaMvurdrZI3YrUjHkAu43RBsxef7OFtJMXGiHFvq43kb7lig0sA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.21", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", + "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", + "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", + "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", + "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", + "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", + "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", + "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", + "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", + "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", + "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", + "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", + "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", + "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", + "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", + "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", + "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", + "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", + "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", + "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", + "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001701", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001701.tgz", + "integrity": "sha512-faRs/AW3jA9nTwmJBSO1PQ6L/EOgsB5HMQQq4iCu5zhPgVVgO/pZRHlmatwijZKetFw8/Pr4q6dEN8sJuq8qTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.107", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.107.tgz", + "integrity": "sha512-dJr1o6yCntRkXElnhsHh1bAV19bo/hKyFf7tCcWgpXbuFIF0Lakjgqv5LRfSDaNzAII8Fnxg2tqgHkgCvxdbxw==", + "dev": true, + "license": "ISC" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.5.tgz", + "integrity": "sha512-gHD+HoFxOMmmXLuq9f2dZDMQHVcplCVpMfBNRpJsF03yyLZvJGzsFORe8orVuYDX9k2w0VH0uF8oryFd1whqKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.1.tgz", + "integrity": "sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz", + "integrity": "sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.4.tgz", + "integrity": "sha512-N6hXjrin2GTJDe3MVjf5FuXpm12PGm80BrUAeub9XFXca8JZbP+oIwY4LJSVwFUCL1IPm/WwSVUN7goFHmSGGQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", + "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz", + "integrity": "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-is": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", + "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.0.0.tgz", + "integrity": "sha512-4mTz7Sya/YQ1jYOrkwO73VcFdkFJ8L8I9ehCxdcV0XrClHyOJGKbBk5FR4OOOG+HnyKw5u+C/Aby9TwinCteYA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.1.tgz", + "integrity": "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", + "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.34.8", + "@rollup/rollup-android-arm64": "4.34.8", + "@rollup/rollup-darwin-arm64": "4.34.8", + "@rollup/rollup-darwin-x64": "4.34.8", + "@rollup/rollup-freebsd-arm64": "4.34.8", + "@rollup/rollup-freebsd-x64": "4.34.8", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", + "@rollup/rollup-linux-arm-musleabihf": "4.34.8", + "@rollup/rollup-linux-arm64-gnu": "4.34.8", + "@rollup/rollup-linux-arm64-musl": "4.34.8", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", + "@rollup/rollup-linux-riscv64-gnu": "4.34.8", + "@rollup/rollup-linux-s390x-gnu": "4.34.8", + "@rollup/rollup-linux-x64-gnu": "4.34.8", + "@rollup/rollup-linux-x64-musl": "4.34.8", + "@rollup/rollup-win32-arm64-msvc": "4.34.8", + "@rollup/rollup-win32-ia32-msvc": "4.34.8", + "@rollup/rollup-win32-x64-msvc": "4.34.8", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.1.0.tgz", + "integrity": "sha512-7tJo+UgZABlKpY/nubth/wxJ4+pUGREPnEwNOknxwl2MM0zTvF14KTU4Ln1lc140gjLLV5mjDrvuoquU7OZqCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.28.1", + "vite": "^5.4.11 || ^6.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/scripts/settings/package.json b/scripts/settings/package.json new file mode 100644 index 0000000..fc7bb7b --- /dev/null +++ b/scripts/settings/package.json @@ -0,0 +1,31 @@ +{ + "name": "material-ui-vite-ts", + "private": true, + "version": "5.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@emotion/react": "latest", + "@emotion/styled": "latest", + "@mui/icons-material": "latest", + "@mui/material": "latest", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-markdown": "^10.0.0", + "yaml": "^2.7.0" + }, + "devDependencies": { + "@types/react": "latest", + "@types/react-dom": "latest", + "@vitejs/plugin-react": "latest", + "typescript": "latest", + "vite": "latest", + "vite-plugin-singlefile": "^2.1.0" + } +} diff --git a/scripts/settings/src/App.tsx b/scripts/settings/src/App.tsx new file mode 100644 index 0000000..4d98528 --- /dev/null +++ b/scripts/settings/src/App.tsx @@ -0,0 +1,450 @@ +import * as React from 'react'; +import { useEffect, useState, useRef } from 'react'; +import Container from '@mui/material/Container'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +// +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragOverlay +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy +} from "@dnd-kit/sortable"; + +import type { DragStartEvent, DragEndEvent, UniqueIdentifier } from "@dnd-kit/core"; + + +import { Module } from './types'; + +import { modules, steps, module_types, empty_config } from './schema.json'; +import { + Stack, + Button, +} from '@mui/material'; +import Grid from '@mui/material/Grid2'; + +import { parseDocument, Document, YAMLSeq, YAMLMap, Scalar } from 'yaml' +import StepCard from './StepCard'; + + +function FileDrop({ setYamlFile }: { setYamlFile: React.Dispatch> }) { + + const [showError, setShowError] = useState(false); + const [label, setLabel] = useState(<>Drag and drop your orchestration.yaml file here, or click to select a file.); + const wrapperRef = useRef(null); + + function openYAMLFile(event: any) { + let file = event.target.files[0]; + if (file.type.indexOf('yaml') === -1) { + setShowError(true); + setLabel(<>Invalid type, only YAML files are accepted.) + return; + } + let reader = new FileReader(); + reader.onload = function (e) { + let contents = e.target ? e.target.result : ''; + try { + let document = parseDocument(contents as string); + if (document.errors.length > 0) { + // not a valid yaml file + setShowError(true); + setLabel(<>Invalid file. Make sure your Orchestration is a valid YAML file with a 'steps' section in it.) + return; + } else { + setShowError(false); + setLabel(<>File loaded successfully.) + } + // do some basic validation of 'steps' + let steps = document.get('steps'); + if (!steps) { + setShowError(true); + setLabel(<>Invalid file. Your orchestration file must have a 'steps' section in it.) + return; + } + const replacements = { + feeder: 'feeders', + formatter: 'formatters', + archivers: 'extractors', + }; + + let error = false; + for (let stepType of Object.keys(replacements)) { + if (steps.get(stepType) !== undefined) { + setShowError(true); + setLabel(<>Invalid file. Your orchestration file appears to be in the old (v0.12) format with a '{stepType}' section.
You should manually update your orchestration file first (hint: {stepType} → {replacements[stepType]})); + error = true; + return; + } + }; + setYamlFile(document); + } catch (e) { + console.error(e); + } + } + reader.readAsText(file); + } + return ( + <> +
{ + e.currentTarget.style.backgroundColor = 'var(--mui-palette-LinearProgress-infoBg)'; + }} + onDragLeave={(e) => { + e.currentTarget.style.backgroundColor = ''; + }} + onDrop={(e) => { + e.currentTarget.style.backgroundColor = ''; + }} + > + + + + {label} + +
+ + ); +} + +function ModuleTypes({ stepType, setEnabledModules, enabledModules, configValues }: { stepType: string, setEnabledModules: any, enabledModules: any, configValues: any }) { + const [showError, setShowError] = useState(false); + const [activeId, setActiveId] = useState(); + const [items, setItems] = useState([]); + + useEffect(() => { + setItems(enabledModules[stepType].map(([name, enabled]: [string, boolean]) => name)); + } + , [enabledModules]); + + const toggleModule = (event: any) => { + // make sure that 'feeder' and 'formatter' types only have one value + let name = event.target.id; + let checked = event.target.checked; + if (stepType === 'feeders' || stepType === 'formatters') { + // check how many modules of this type are enabled + const checkedModules = enabledModules[stepType].filter(([m, enabled]: [string, boolean]) => { + return (m !== name && enabled) || (checked && m === name) + }); + if (checkedModules.length > 1) { + setShowError(true); + } else { + setShowError(false); + } + } else { + setShowError(false); + } + let newEnabledModules = { ...enabledModules }; + newEnabledModules[stepType] = enabledModules[stepType].map(([m, enabled]: [string, boolean]) => { + return (m === name) ? [m, checked] : [m, enabled]; + }); + setEnabledModules(newEnabledModules); + } + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveId(undefined); + const { active, over } = event; + + if (active.id !== over?.id) { + const oldIndex = items.indexOf(active.id as string); + const newIndex = items.indexOf(over?.id as string); + + let newArray = arrayMove(items, oldIndex, newIndex); + // set it also on steps + let newEnabledModules = { ...enabledModules }; + newEnabledModules[stepType] = enabledModules[stepType].sort((a, b) => { + return newArray.indexOf(a[0]) - newArray.indexOf(b[0]); + }) + setEnabledModules(newEnabledModules); + } + }; + return ( + <> + + + {stepType} + + + Select the {stepType} you wish to enable. Drag to reorder. + + + {showError ? Only one {stepType.slice(0,-1)} can be enabled at a time. : null} + + + + + {items.map((name: string) => { + let m: Module = modules[name]; + return ( + + ); + })} + + {activeId ? ( +
+ + ) : null} +
+
+
+
+ + ); +} + + +export default function App() { + const [yamlFile, setYamlFile] = useState(new Document()); + const [enabledModules, setEnabledModules] = useState<{}>(Object.fromEntries(Object.keys(steps).map(type => [type, steps[type].map((name: string) => [name, false])]))); + const [configValues, setConfigValues] = useState<{ + [key: string]: { + [key: string + ]: any + } + }>( + Object.keys(modules).reduce((acc, module) => { + acc[module] = {}; + return acc; + }, {}) + ); + + const saveSettings = function (copy: boolean = false) { + // edit the yamlFile + + // generate the steps config + let stepsConfig = enabledModules; + + let finalYamlFile: Document = null; + if (!yamlFile || yamlFile.contents == null) { + // create the yaml file from + finalYamlFile = parseDocument(empty_config as string); + } else { + finalYamlFile = yamlFile; + } + + // set the steps + module_types.forEach((type: string) => { + let stepType = type + 's'; + let existingSteps = finalYamlFile.getIn(['steps', stepType]) as YAMLSeq; + stepsConfig[stepType].forEach(([name, enabled]: [string, boolean]) => { + let index = existingSteps.items.findIndex((item) => { + return (item.value || item) === name + }); + let stepItem = finalYamlFile.getIn(['steps', stepType], true) as YAMLSeq; + + if (enabled && index === -1) { + finalYamlFile.addIn(['steps', stepType], name); + stepItem.commentBefore = stepItem.commentBefore?.replace("\n - " + name, ''); + stepItem.comment = stepItem.comment?.replace("\n - " + name, ''); + } else if (!enabled && index !== -1) { + // set the value to empty and add a comment before with the commented value + finalYamlFile.deleteIn(['steps', stepType, index]); + stepItem.commentBefore += "\n - " + name; + finalYamlFile.setIn(['steps', stepType], stepItem); + } + }); + // sort the items + existingSteps.items.sort((a: Scalar | string, b: Scalar | string) => { + return (stepsConfig[stepType].findIndex((val: [string, boolean]) => {return val[0] === (a.value || a)}) - + stepsConfig[stepType].findIndex((val: [string, boolean]) => {return val[0] === (b.value || b)})) + }); + existingSteps.flow = existingSteps.items.length ? false : true; + }); + + // set all other settings + // loop through each item that isn't 'steps' in the finalYamlFile and check if it exists in configValues + + Object.keys(configValues).forEach((module_name: string) => { + // get an existing key + let existingConfig = finalYamlFile.get(module_name, true) as YAMLMap; + if (existingConfig) { + Object.keys(configValues[module_name]).forEach((config_name: string) => { + let existingConfigYAML = existingConfig.get(config_name, true) as Scalar; + if (existingConfigYAML) { + existingConfigYAML.value = configValues[module_name][config_name]; + existingConfig.set(config_name, existingConfigYAML); + } else { + existingConfig.set(config_name, configValues[module_name][config_name]); + } + }); + finalYamlFile.set(module_name, existingConfig); + } else { + if (configValues[module_name] && Object.keys(configValues[module_name]).length > 0) { + finalYamlFile.set(module_name, configValues[module_name]); + } + } + }); + + if (copy) { + navigator.clipboard.writeText(String(finalYamlFile)).then(() => { + alert("Settings copied to clipboard."); + }); + } else { + // offer the file for download + const blob = new Blob([String(finalYamlFile)], { type: 'application/x-yaml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'orchestration.yaml'; + a.click(); + } + } + + useEffect(() => { + // load the configs, and set the default values if they exist + let newConfigValues = {}; + Object.keys(modules).map((module: string) => { + let m = modules[module]; + let configs = m.configs; + if (!configs) { + return; + } + newConfigValues[module] = {}; + Object.keys(configs).map((config: string) => { + let config_args = configs[config]; + if (config_args.default !== undefined) { + newConfigValues[module][config] = config_args.default; + } + }); + }) + setConfigValues(newConfigValues); + }, []); + + useEffect(() => { + if (!yamlFile || yamlFile.contents == null) { + return; + } + + let settings = yamlFile.toJS(); + // make a deep copy of settings + let stepSettings = settings['steps']; + + let newEnabledModules = Object.fromEntries(Object.keys(steps).map((type: string) => { + return [type, steps[type].map((name: string) => { + return [name, stepSettings[type].indexOf(name) !== -1]; + }).sort((a, b) => { + let aIndex = stepSettings[type].indexOf(a[0]); + let bIndex = stepSettings[type].indexOf(b[0]); + if (aIndex === -1 && bIndex === -1) { + return a - b; + } + if (bIndex === -1) { + return -1; + } + if (aIndex === -1) { + return 1; + } + return aIndex - bIndex; + })]; + }).sort((a, b) => { + return module_types.indexOf(a[0]) - module_types.indexOf(b[0]); + })); + setEnabledModules(newEnabledModules); + + // set the config values + let newConfigValues = settings; + delete newConfigValues['steps']; + + + setConfigValues(Object.keys(modules).reduce((acc, module) => { + acc[module] = newConfigValues[module] || {}; + return acc; + }, {})); + }, [yamlFile]); + + + + return ( + + + + + 1. Select your orchestration.yaml settings file. + + Or skip this step to start from scratch + + + + + 2. Choose the Modules you wish to enable/disable + + {Object.keys(steps).map((stepType: string) => { + return ( + + + + ); + })} + + + + 3. Configure your Enabled Modules + + + Next to each module you've enabled, you can click 'Configure' to set the module's settings. + + + + + 4. Save your settings + + + + + + + + + ); +} diff --git a/scripts/settings/src/StepCard.tsx b/scripts/settings/src/StepCard.tsx new file mode 100644 index 0000000..52ee76b --- /dev/null +++ b/scripts/settings/src/StepCard.tsx @@ -0,0 +1,258 @@ +import { useState } from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import ReactMarkdown from 'react-markdown'; + +import { CSS } from "@dnd-kit/utilities"; + +import { + Card, + CardActions, + CardHeader, + Button, + Dialog, + DialogTitle, + DialogContent, + Box, + IconButton, + Checkbox, + Select, + MenuItem, + FormControl, + FormControlLabel, + FormHelperText, + TextField, + Stack, + Typography, + InputAdornment, +} from '@mui/material'; +import Grid from '@mui/material/Grid2'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; +import HelpIconOutlined from '@mui/icons-material/HelpOutline'; +import { Module, Config } from "./types"; + + +// adds 'capitalize' method to String prototype +declare global { + interface String { + capitalize(): string; + } +} +String.prototype.capitalize = function (this: string) { + return this.charAt(0).toUpperCase() + this.slice(1); +}; + +const StepCard = ({ + type, + module, + toggleModule, + enabledModules, + configValues +}: { + type: string, + module: Module, + toggleModule: any, + enabledModules: any, + configValues: any +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id: module.name }); + + + const style = { + ...Card.style, + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? "100" : "auto", + opacity: isDragging ? 0.3 : 1 + }; + + let name = module.name; + const [helpOpen, setHelpOpen] = useState(false); + const [configOpen, setConfigOpen] = useState(false); + const enabled = enabledModules[type].find((m: any) => m[0] === name)[1]; + + return ( + + + } + label={module.display_name} /> + } + /> + + + + setHelpOpen(true)}> + + + {enabled && module.configs && name != 'cli_feeder' ? ( + + ) : null} + + + + + + + + setHelpOpen(false)} + maxWidth="lg" + > + + {module.display_name} + + + + {module.manifest.description.split("\n").map((line: string) => line.trim()).join("\n")} + + + + {module.configs && name != 'cli_feeder' && } + + ) +} + +function ConfigField({ config_value, module, configValues }: { config_value: any, module: Module, configValues: any }) { + const [showPassword, setShowPassword] = useState(false); + const handleClickShowPassword = () => setShowPassword((show) => !show); + + const handleMouseDownPassword = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + const handleMouseUpPassword = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + function setConfigValue(config: any, value: any) { + configValues[module.name][config] = value; + } + const config_args: Config = module.configs[config_value]; + const config_name: string = config_value.replace(/_/g, " "); + const config_display_name = config_name.capitalize(); + const value = configValues[module.name][config_value] || config_args.default; + + + const config_value_lower = config_value.toLowerCase(); + const is_password = config_value_lower.includes('password') || + config_value_lower.includes('secret') || + config_value_lower.includes('token') || + config_value_lower.includes('key') || + config_value_lower.includes('api_hash') || + config_args.type === 'password'; + + const text_input_type = is_password ? 'password' : (config_args.type === 'int' ? 'number' : 'text'); + + return ( + + {config_display_name} {config_args.required && (`(required)`)} + + {config_args.type === 'bool' ? + { + setConfigValue(config_value, e.target.checked); + }} + />} label={config_args.help.capitalize()} + /> + : + ( + config_args.choices !== undefined ? + + : + (config_args.type === 'json_loader' ? + { + try { + let val = JSON.parse(e.target.value); + setConfigValue(config_value, val); + } catch (e) { + console.log(e); + } + } + } /> + : + { + setConfigValue(config_value, e.target.value); + }} + required={config_args.required} + slotProps={ is_password ? { + input: { endAdornment: ( + + + {showPassword ? : } + + + )} + } : {}} + /> + ) + ) + } + {config_args.type !== 'bool' && ( + {config_args.help.capitalize()} + )} + + + ) +} + +function ConfigPanel({ module, open, setOpen, configValues }: { module: Module, open: boolean, setOpen: any, configValues: any }) { + + return ( + <> + setOpen(false)} + maxWidth="lg" + > + + {module.display_name} + + + + {Object.keys(module.configs).map((config_value: any) => { + return ( + + ); + })} + + + + + ); +} + +export default StepCard; \ No newline at end of file diff --git a/scripts/settings/src/main.tsx b/scripts/settings/src/main.tsx new file mode 100644 index 0000000..44c7951 --- /dev/null +++ b/scripts/settings/src/main.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom/client'; +import { ThemeProvider } from '@mui/material/styles'; +import { CssBaseline } from '@mui/material'; +import App from './App'; +import { createTheme } from '@mui/material/styles'; +import { red } from '@mui/material/colors'; +import { useState, useEffect } from 'react'; + +function RootApp() { + const [mode, setMode] = useState('light'); + +useEffect(() => { + setMode(window.localStorage.getItem('theme') || 'light'); +}, []); + +var observer = new MutationObserver(function(mutations) { + setMode(window.localStorage.getItem('theme') || 'light'); + +}) +observer.observe(document.documentElement, {attributes: true, attributeFilter: ['data-theme']}); + +// A custom theme for this app +const theme = createTheme({ + palette: { + mode: mode == 'light' ? 'light' : 'dark', + }, + cssVariables: true +}); + + return ( + + + + + ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); + diff --git a/scripts/settings/src/schema.json b/scripts/settings/src/schema.json new file mode 100644 index 0000000..70eb71b --- /dev/null +++ b/scripts/settings/src/schema.json @@ -0,0 +1,2078 @@ +{ + "modules": { + "atlos_feeder_db_storage": { + "name": "atlos_feeder_db_storage", + "display_name": "Atlos Feeder Database Storage", + "manifest": { + "name": "Atlos Feeder Database Storage", + "author": "Bellingcat", + "type": [ + "feeder", + "database", + "storage" + ], + "requires_setup": true, + "description": "\n A module that integrates with the Atlos API to fetch source material URLs for archival, uplaod extracted media,\n \n [Atlos](https://www.atlos.org/) is a visual investigation and archiving platform designed for investigative research, journalism, and open-source intelligence (OSINT). \n It helps users organize, analyze, and store media from various sources, making it easier to track and investigate digital evidence.\n \n To get started create a new project and obtain an API token from the settings page. You can group event's into Atlos's 'incidents'.\n Here you can add 'source material' by URLn and the Atlos feeder will fetch these URLs for archival.\n \n You can use Atlos only as a 'feeder', however you can also implement the 'database' and 'storage' features to store the media files in Atlos which is recommended.\n The Auto Archiver will retain the Atlos ID for each item, ensuring that the media and database outputs are uplaoded back into the relevant media item.\n \n \n ### Features\n - Connects to the Atlos API to retrieve a list of source material URLs.\n - Iterates through the URLs from all source material items which are unprocessed, visible, and ready to archive.\n - If the storage option is selected, it will store the media files alongside the original source material item in Atlos.\n - Is the database option is selected it will output the results to the media item, as well as updating failure status with error details when archiving fails.\n - Skips Storege/ database upload for items without an Atlos ID - restricting that you must use the Atlos feeder so that it has the Atlos ID to store the results with.\n\n ### Notes\n - Requires an Atlos account with a project and a valid API token for authentication.\n - Ensures only unprocessed, visible, and ready-to-archive URLs are returned.\n - Feches any media items within an Atlos project, regardless of separation into incidents.\n ", + "dependencies": { + "python": [ + "loguru", + "requests" + ] + }, + "entry_point": "atlos_feeder_db_storage::AtlosFeederDbStorage", + "version": "1.0", + "configs": { + "api_token": { + "type": "str", + "required": true, + "help": "An Atlos API token. For more information, see https://docs.atlos.org/technical/api/" + }, + "atlos_url": { + "default": "https://platform.atlos.org", + "help": "The URL of your Atlos instance (e.g., https://platform.atlos.org), without a trailing slash.", + "type": "str" + } + } + }, + "configs": { + "api_token": { + "type": "str", + "required": true, + "help": "An Atlos API token. For more information, see https://docs.atlos.org/technical/api/" + }, + "atlos_url": { + "default": "https://platform.atlos.org", + "help": "The URL of your Atlos instance (e.g., https://platform.atlos.org), without a trailing slash.", + "type": "str" + } + } + }, + "csv_feeder": { + "name": "csv_feeder", + "display_name": "CSV Feeder", + "manifest": { + "name": "CSV Feeder", + "author": "Bellingcat", + "type": [ + "feeder" + ], + "requires_setup": true, + "description": "\n Reads URLs from CSV files and feeds them into the archiving process.\n\n ### Features\n - Supports reading URLs from multiple input files, specified as a comma-separated list.\n - Allows specifying the column number or name to extract URLs from.\n - Skips header rows if the first value is not a valid URL.\n\n ### Setup\n - Input files should be formatted with one URL per line, with or without a header row.\n - If you have a header row, you can specify the column number or name to read URLs from using the 'column' config option.\n ", + "dependencies": { + "python": [ + "loguru" + ], + "bin": [ + "" + ] + }, + "entry_point": "csv_feeder::CSVFeeder", + "version": "1.0", + "configs": { + "files": { + "default": null, + "help": "Path to the input file(s) to read the URLs from, comma separated. Input files should be formatted with one URL per line", + "required": true, + "type": "valid_file", + "nargs": "+" + }, + "column": { + "default": null, + "help": "Column number or name to read the URLs from, 0-indexed" + } + } + }, + "configs": { + "files": { + "default": null, + "help": "Path to the input file(s) to read the URLs from, comma separated. Input files should be formatted with one URL per line", + "required": true, + "type": "valid_file", + "nargs": "+" + }, + "column": { + "default": null, + "help": "Column number or name to read the URLs from, 0-indexed" + } + } + }, + "gsheet_feeder_db": { + "name": "gsheet_feeder_db", + "display_name": "Google Sheets Feeder Database", + "manifest": { + "name": "Google Sheets Feeder Database", + "author": "Bellingcat", + "type": [ + "feeder", + "database" + ], + "requires_setup": true, + "description": "\n GsheetsFeederDatabase\n A Google Sheets-based feeder and optional database for the Auto Archiver.\n\n This reads data from Google Sheets and filters rows based on user-defined rules.\n The filtered rows are processed into `Metadata` objects.\n\n ### Features\n - Validates the sheet structure and filters rows based on input configurations.\n - Processes only worksheets allowed by the `allow_worksheets` and `block_worksheets` configurations.\n - Ensures only rows with valid URLs and unprocessed statuses are included for archival.\n - Supports organizing stored files into folder paths based on sheet and worksheet names.\n - If the database is enabled, this updates the Google Sheet with the status of the archived URLs, including in progress, success or failure, and method used.\n - Saves metadata such as title, text, timestamp, hashes, screenshots, and media URLs to designated columns.\n - Formats media-specific metadata, such as thumbnails and PDQ hashes for the sheet.\n - Skips redundant updates for empty or invalid data fields.\n\n ### Setup\n - Requires a Google Service Account JSON file for authentication, which should be stored in `secrets/gsheets_service_account.json`.\n To set up a service account, follow the instructions [here](https://gspread.readthedocs.io/en/latest/oauth2.html).\n - Define the `sheet` or `sheet_id` configuration to specify the sheet to archive.\n - Customize the column names in your Google sheet using the `columns` configuration.\n - The Google Sheet can be used soley as a feeder or as a feeder and database, but note you can't currently feed into the database from an alternate feeder.\n ", + "dependencies": { + "python": [ + "loguru", + "gspread", + "slugify" + ] + }, + "entry_point": "gsheet_feeder_db::GsheetsFeederDB", + "version": "1.0", + "configs": { + "sheet": { + "default": null, + "help": "name of the sheet to archive" + }, + "sheet_id": { + "default": null, + "help": "the id of the sheet to archive (alternative to 'sheet' config)" + }, + "header": { + "default": 1, + "type": "int", + "help": "index of the header row (starts at 1)" + }, + "service_account": { + "default": "secrets/service_account.json", + "help": "service account JSON file path. Learn how to create one: https://gspread.readthedocs.io/en/latest/oauth2.html", + "required": true + }, + "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", + "pdq_hash": "perceptual hashes", + "wacz": "wacz", + "replaywebpage": "replaywebpage" + }, + "help": "Custom names for the columns in your Google sheet. If you don't want to use the default column names, change them with this setting", + "type": "json_loader" + }, + "allow_worksheets": { + "default": [], + "help": "(CSV) only worksheets whose name is included in allow are included (overrides worksheet_block), leave empty so all are allowed" + }, + "block_worksheets": { + "default": [], + "help": "(CSV) explicitly block some worksheets from being processed" + }, + "use_sheet_names_in_stored_paths": { + "default": true, + "type": "bool", + "help": "if True the stored files path will include 'workbook_name/worksheet_name/...'" + } + } + }, + "configs": { + "sheet": { + "default": null, + "help": "name of the sheet to archive" + }, + "sheet_id": { + "default": null, + "help": "the id of the sheet to archive (alternative to 'sheet' config)" + }, + "header": { + "default": 1, + "type": "int", + "help": "index of the header row (starts at 1)" + }, + "service_account": { + "default": "secrets/service_account.json", + "help": "service account JSON file path. Learn how to create one: https://gspread.readthedocs.io/en/latest/oauth2.html", + "required": true + }, + "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", + "pdq_hash": "perceptual hashes", + "wacz": "wacz", + "replaywebpage": "replaywebpage" + }, + "help": "Custom names for the columns in your Google sheet. If you don't want to use the default column names, change them with this setting", + "type": "json_loader" + }, + "allow_worksheets": { + "default": [], + "help": "(CSV) only worksheets whose name is included in allow are included (overrides worksheet_block), leave empty so all are allowed" + }, + "block_worksheets": { + "default": [], + "help": "(CSV) explicitly block some worksheets from being processed" + }, + "use_sheet_names_in_stored_paths": { + "default": true, + "type": "bool", + "help": "if True the stored files path will include 'workbook_name/worksheet_name/...'" + } + } + }, + "cli_feeder": { + "name": "cli_feeder", + "display_name": "Command Line Feeder", + "manifest": { + "name": "Command Line Feeder", + "author": "Bellingcat", + "type": [ + "feeder" + ], + "requires_setup": false, + "description": "\nThe Command Line Feeder is the default enabled feeder for the Auto Archiver. It allows you to pass URLs directly to the orchestrator from the command line \nwithout the need to specify any additional configuration or command line arguments:\n\n`auto-archiver --feeder cli_feeder -- \"https://example.com/1/,https://example.com/2/\"`\n\nYou can pass multiple URLs by separating them with a space. The URLs will be processed in the order they are provided.\n\n`auto-archiver --feeder cli_feeder -- https://example.com/1/ https://example.com/2/`\n", + "dependencies": {}, + "entry_point": "cli_feeder::CLIFeeder", + "version": "1.0", + "configs": { + "urls": { + "default": null, + "help": "URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml" + } + } + }, + "configs": { + "urls": { + "default": null, + "help": "URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml" + } + } + }, + "instagram_api_extractor": { + "name": "instagram_api_extractor", + "display_name": "Instagram API Extractor", + "manifest": { + "name": "Instagram API Extractor", + "author": "Bellingcat", + "type": [ + "extractor" + ], + "requires_setup": true, + "description": "\nArchives various types of Instagram content using the Instagrapi API.\n\nRequires setting up an Instagrapi API deployment and providing an access token and API endpoint.\n\n### Features\n- Connects to an Instagrapi API deployment to fetch Instagram profiles, posts, stories, highlights, reels, and tagged content.\n- Supports advanced configuration options, including:\n - Full profile download (all posts, stories, highlights, and tagged content).\n - Limiting the number of posts to fetch for large profiles.\n - Minimising JSON output to remove empty fields and redundant data.\n- Provides robust error handling and retries for API calls.\n- Ensures efficient media scraping, including handling nested or carousel media items.\n- Adds downloaded media and metadata to the result for further processing.\n\n### Notes\n- Requires a valid Instagrapi API token (`access_token`) and API endpoint (`api_endpoint`).\n- Full-profile downloads can be limited by setting `full_profile_max_posts`.\n- Designed to fetch content in batches for large profiles, minimising API load.\n", + "dependencies": { + "python": [ + "requests", + "loguru", + "retrying", + "tqdm" + ] + }, + "entry_point": "instagram_api_extractor::InstagramAPIExtractor", + "version": "1.0", + "configs": { + "access_token": { + "default": null, + "help": "a valid instagrapi-api token" + }, + "api_endpoint": { + "required": true, + "help": "API endpoint to use" + }, + "full_profile": { + "default": false, + "type": "bool", + "help": "if true, will download all posts, tagged posts, stories, and highlights for a profile, if false, will only download the profile pic and information." + }, + "full_profile_max_posts": { + "default": 0, + "type": "int", + "help": "Use to limit the number of posts to download when full_profile is true. 0 means no limit. limit is applied softly since posts are fetched in batch, once to: posts, tagged posts, and highlights" + }, + "minimize_json_output": { + "default": true, + "type": "bool", + "help": "if true, will remove empty values from the json output" + } + } + }, + "configs": { + "access_token": { + "default": null, + "help": "a valid instagrapi-api token" + }, + "api_endpoint": { + "required": true, + "help": "API endpoint to use" + }, + "full_profile": { + "default": false, + "type": "bool", + "help": "if true, will download all posts, tagged posts, stories, and highlights for a profile, if false, will only download the profile pic and information." + }, + "full_profile_max_posts": { + "default": 0, + "type": "int", + "help": "Use to limit the number of posts to download when full_profile is true. 0 means no limit. limit is applied softly since posts are fetched in batch, once to: posts, tagged posts, and highlights" + }, + "minimize_json_output": { + "default": true, + "type": "bool", + "help": "if true, will remove empty values from the json output" + } + } + }, + "instagram_tbot_extractor": { + "name": "instagram_tbot_extractor", + "display_name": "Instagram Telegram Bot Extractor", + "manifest": { + "name": "Instagram Telegram Bot Extractor", + "author": "Bellingcat", + "type": [ + "extractor" + ], + "requires_setup": true, + "description": "\nThe `InstagramTbotExtractor` module uses a Telegram bot (`instagram_load_bot`) to fetch and archive Instagram content,\nsuch as posts and stories. It leverages the Telethon library to interact with the Telegram API, sending Instagram URLs\nto the bot and downloading the resulting media and metadata. The downloaded content is stored as `Media` objects and\nreturned as part of a `Metadata` object.\n\n### Features\n- Supports archiving Instagram posts and stories through the Telegram bot.\n- Downloads and saves media files (e.g., images, videos) in a temporary directory.\n- Captures and returns metadata, including titles and descriptions, as a `Metadata` object.\n- Automatically manages Telegram session files for secure access.\n\n### Setup\n\nTo use the `InstagramTbotExtractor`, you need to provide the following configuration settings:\n- **API ID and Hash**: Telegram API credentials obtained from [my.telegram.org/apps](https://my.telegram.org/apps).\n- **Session File**: Optional path to store the Telegram session file for future use.\n- The session file is created automatically and should be unique for each instance.\n- You may need to enter your Telegram credentials (phone) and use the a 2FA code sent to you the first time you run the extractor.:\n```2025-01-30 00:43:49.348 | INFO | auto_archiver.modules.instagram_tbot_extractor.instagram_tbot_extractor:setup:36 - SETUP instagram_tbot_extractor checking login...\nPlease enter your phone (or bot token): +447123456789\nPlease enter the code you received: 00000\nSigned in successfully as E C; remember to not break the ToS or you will risk an account ban!\n```\n ", + "dependencies": { + "python": [ + "loguru", + "telethon" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "api_id": { + "default": null, + "help": "telegram API_ID value, go to https://my.telegram.org/apps" + }, + "api_hash": { + "default": null, + "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, + "type": "int", + "help": "timeout to fetch the instagram content in seconds." + } + } + }, + "configs": { + "api_id": { + "default": null, + "help": "telegram API_ID value, go to https://my.telegram.org/apps" + }, + "api_hash": { + "default": null, + "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, + "type": "int", + "help": "timeout to fetch the instagram content in seconds." + } + } + }, + "twitter_api_extractor": { + "name": "twitter_api_extractor", + "display_name": "Twitter API Extractor", + "manifest": { + "name": "Twitter API Extractor", + "author": "Bellingcat", + "type": [ + "extractor" + ], + "requires_setup": true, + "description": "\n The `TwitterApiExtractor` fetches tweets and associated media using the Twitter API. \n It supports multiple API configurations for extended rate limits and reliable access. \n Features include URL expansion, media downloads (e.g., images, videos), and structured output \n via `Metadata` and `Media` objects. Requires Twitter API credentials such as bearer tokens \n or consumer key/secret and access token/secret.\n \n ### Features\n - Fetches tweets and their metadata, including text, creation timestamp, and author information.\n - Downloads media attachments (e.g., images, videos) in high quality.\n - Supports multiple API configurations for improved rate limiting.\n - Expands shortened URLs (e.g., `t.co` links).\n - Outputs structured metadata and media using `Metadata` and `Media` objects.\n \n ### Setup\n To use the `TwitterApiExtractor`, you must provide valid Twitter API credentials via configuration:\n - **Bearer Token(s)**: A single token or a list for rate-limited API access.\n - **Consumer Key and Secret**: Required for user-authenticated API access.\n - **Access Token and Secret**: Complements the consumer key for enhanced API capabilities.\n \n Credentials can be obtained by creating a Twitter developer account at [Twitter Developer Platform](https://developer.twitter.com/en).\n ", + "dependencies": { + "python": [ + "requests", + "loguru", + "pytwitter", + "slugify" + ], + "bin": [ + "" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "bearer_token": { + "default": null, + "help": "[deprecated: see bearer_tokens] twitter API bearer_token which is enough for archiving, if not provided you will need consumer_key, consumer_secret, access_token, access_secret" + }, + "bearer_tokens": { + "default": [], + "help": " a list of twitter API bearer_token which is enough for archiving, if not provided you will need consumer_key, consumer_secret, access_token, access_secret, if provided you can still add those for better rate limits. CSV of bearer tokens if provided via the command line" + }, + "consumer_key": { + "default": null, + "help": "twitter API consumer_key" + }, + "consumer_secret": { + "default": null, + "help": "twitter API consumer_secret" + }, + "access_token": { + "default": null, + "help": "twitter API access_token" + }, + "access_secret": { + "default": null, + "help": "twitter API access_secret" + } + } + }, + "configs": { + "bearer_token": { + "default": null, + "help": "[deprecated: see bearer_tokens] twitter API bearer_token which is enough for archiving, if not provided you will need consumer_key, consumer_secret, access_token, access_secret" + }, + "bearer_tokens": { + "default": [], + "help": " a list of twitter API bearer_token which is enough for archiving, if not provided you will need consumer_key, consumer_secret, access_token, access_secret, if provided you can still add those for better rate limits. CSV of bearer tokens if provided via the command line" + }, + "consumer_key": { + "default": null, + "help": "twitter API consumer_key" + }, + "consumer_secret": { + "default": null, + "help": "twitter API consumer_secret" + }, + "access_token": { + "default": null, + "help": "twitter API access_token" + }, + "access_secret": { + "default": null, + "help": "twitter API access_secret" + } + } + }, + "instagram_extractor": { + "name": "instagram_extractor", + "display_name": "Instagram Extractor", + "manifest": { + "name": "Instagram Extractor", + "author": "Bellingcat", + "type": [ + "extractor" + ], + "requires_setup": true, + "description": "\n Uses the [Instaloader library](https://instaloader.github.io/as-module.html) to download content from Instagram. \n \n > \u26a0\ufe0f **Warning** \n > This module is not actively maintained due to known issues with blocking. \n > Prioritise usage of the [Instagram Tbot Extractor](./instagram_tbot_extractor.md) and [Instagram API Extractor](./instagram_api_extractor.md)\n \n This class handles both individual posts and user profiles, downloading as much information as possible, including images, videos, text, stories,\n highlights, and tagged posts. \n Authentication is required via username/password or a session file.\n \n ", + "dependencies": { + "python": [ + "instaloader", + "loguru" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "username": { + "required": true, + "help": "A valid Instagram username." + }, + "password": { + "required": true, + "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 file which saves session credentials. If one doesn't exist this gives the path to store a new one." + } + } + }, + "configs": { + "username": { + "required": true, + "help": "A valid Instagram username." + }, + "password": { + "required": true, + "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 file which saves session credentials. If one doesn't exist this gives the path to store a new one." + } + } + }, + "telethon_extractor": { + "name": "telethon_extractor", + "display_name": "Telethon Extractor", + "manifest": { + "name": "Telethon Extractor", + "author": "Bellingcat", + "type": [ + "extractor" + ], + "requires_setup": true, + "description": "\nThe `TelethonExtractor` uses the Telethon library to archive posts and media from Telegram channels and groups. \nIt supports private and public channels, downloading grouped posts with media, and can join channels using invite links \nif provided in the configuration. \n\n### Features\n- Fetches posts and metadata from Telegram channels and groups, including private channels.\n- Downloads media attachments (e.g., images, videos, audio) from individual posts or grouped posts.\n- Handles channel invites to join channels dynamically during setup.\n- Utilizes Telethon's capabilities for reliable Telegram interactions.\n- Outputs structured metadata and media using `Metadata` and `Media` objects.\n\n### Setup\nTo use the `TelethonExtractor`, you must configure the following:\n- **API ID and API Hash**: Obtain these from [my.telegram.org](https://my.telegram.org/apps).\n- **Session File**: Optional, but records login sessions for future use (default: `secrets/anon.session`).\n- **Bot Token**: Optional, allows access to additional content (e.g., large videos) but limits private channel archiving.\n- **Channel Invites**: Optional, specify a JSON string of invite links to join channels during setup.\n\n### First Time Login\nThe 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.\n\n\n", + "dependencies": { + "python": [ + "telethon", + "loguru", + "tqdm" + ], + "bin": [ + "" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "api_id": { + "default": null, + "help": "telegram API_ID value, go to https://my.telegram.org/apps" + }, + "api_hash": { + "default": null, + "help": "telegram API_HASH value, go to https://my.telegram.org/apps" + }, + "bot_token": { + "default": null, + "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, + "type": "bool", + "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", + "type": "json_loader" + } + } + }, + "configs": { + "api_id": { + "default": null, + "help": "telegram API_ID value, go to https://my.telegram.org/apps" + }, + "api_hash": { + "default": null, + "help": "telegram API_HASH value, go to https://my.telegram.org/apps" + }, + "bot_token": { + "default": null, + "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, + "type": "bool", + "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", + "type": "json_loader" + } + } + }, + "vk_extractor": { + "name": "vk_extractor", + "display_name": "VKontakte Extractor", + "manifest": { + "name": "VKontakte Extractor", + "author": "Bellingcat", + "type": [ + "extractor" + ], + "requires_setup": true, + "description": "\nThe `VkExtractor` fetches posts, text, and images from VK (VKontakte) social media pages. \nThis archiver is specialized for `/wall` posts and uses the `VkScraper` library to extract \nand download content. Note that VK videos are handled separately by the `YTDownloader`.\n\n### Features\n- Extracts text, timestamps, and metadata from VK `/wall` posts.\n- Downloads associated images and attaches them to the resulting `Metadata` object.\n- Processes multiple segments of VK URLs that contain mixed content (e.g., wall, photo).\n- Outputs structured metadata and media using `Metadata` and `Media` objects.\n\n### Setup\nTo use the `VkArchiver`, you must provide valid VKontakte login credentials and session information:\n- **Username**: A valid VKontakte account username.\n- **Password**: The corresponding password for the VKontakte account.\n- **Session File**: Optional. Path to a session configuration file (`.json`) for persistent VK login.\n\nCredentials can be set in the configuration file or directly via environment variables. Ensure you \nhave access to the VKontakte API by creating an account at [VKontakte](https://vk.com/).\n", + "dependencies": { + "python": [ + "loguru", + "vk_url_scraper" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "username": { + "required": true, + "help": "valid VKontakte username" + }, + "password": { + "required": true, + "help": "valid VKontakte password" + }, + "session_file": { + "default": "secrets/vk_config.v2.json", + "help": "valid VKontakte password" + } + }, + "depends": [ + "core", + "utils" + ] + }, + "configs": { + "username": { + "required": true, + "help": "valid VKontakte username" + }, + "password": { + "required": true, + "help": "valid VKontakte password" + }, + "session_file": { + "default": "secrets/vk_config.v2.json", + "help": "valid VKontakte password" + } + } + }, + "generic_extractor": { + "name": "generic_extractor", + "display_name": "Generic Extractor", + "manifest": { + "name": "Generic Extractor", + "author": "Bellingcat", + "type": [ + "extractor" + ], + "requires_setup": false, + "description": "\nThis is the generic extractor used by auto-archiver, which uses `yt-dlp` under the hood.\n\nThis module is responsible for downloading and processing media content from platforms\nsupported by `yt-dlp`, such as YouTube, Facebook, and others. It provides functionality\nfor retrieving videos, subtitles, comments, and other metadata, and it integrates with\nthe broader archiving framework.\n\n### Features\n- Supports downloading videos and playlists.\n- Retrieves metadata like titles, descriptions, upload dates, and durations.\n- Downloads subtitles and comments when enabled.\n- Configurable options for handling live streams, proxies, and more.\n- Supports authentication of websites using the 'authentication' settings from your orchestration.\n\n### Dropins\n- For websites supported by `yt-dlp` that also contain posts in addition to videos\n (e.g. Facebook, Twitter, Bluesky), dropins can be created to extract post data and create \n metadata objects. Some dropins are included in this generic_archiver by default, but\ncustom dropins can be created to handle additional websites and passed to the archiver\nvia the command line using the `--dropins` option (TODO!).\n\n### Auto-Updates\n\nThe Generic Extractor will also automatically check for updates to `yt-dlp` (every 5 days by default).\nThis can be configured using the `ytdlp_update_interval` setting (or disabled by setting it to -1).\nIf you are having issues with the extractor, you can review the version of `yt-dlp` being used with `yt-dlp --version`.\n\n", + "dependencies": { + "python": [ + "yt_dlp", + "requests", + "loguru", + "slugify" + ] + }, + "entry_point": "", + "version": "0.1.0", + "configs": { + "subtitles": { + "default": true, + "help": "download subtitles if available", + "type": "bool" + }, + "comments": { + "default": false, + "help": "download all comments if available, may lead to large metadata", + "type": "bool" + }, + "livestreams": { + "default": false, + "help": "if set, will download live streams, otherwise will skip them; see --max-filesize for more control", + "type": "bool" + }, + "live_from_start": { + "default": false, + "help": "if set, will download live streams from their earliest available moment, otherwise starts now.", + "type": "bool" + }, + "proxy": { + "default": "", + "help": "http/socks (https seems to not work atm) proxy to use for the webdriver, eg https://proxy-user:password@proxy-ip:port" + }, + "end_means_success": { + "default": true, + "help": "if True, any archived content will mean a 'success', if False this archiver will not return a 'success' stage; this is useful for cases when the yt-dlp will archive a video but ignore other types of content like images or text only pages that the subsequent archivers can retrieve.", + "type": "bool" + }, + "allow_playlist": { + "default": false, + "help": "If True will also download playlists, set to False if the expectation is to download a single video.", + "type": "bool" + }, + "max_downloads": { + "default": "inf", + "help": "Use to limit the number of videos to download when a channel or long page is being extracted. 'inf' means no limit." + }, + "ytdlp_update_interval": { + "default": 5, + "help": "How often to check for yt-dlp updates (days). If positive, will check and update yt-dlp every [num] days. Set it to -1 to disable, or 0 to always update on every run.", + "type": "int" + } + } + }, + "configs": { + "subtitles": { + "default": true, + "help": "download subtitles if available", + "type": "bool" + }, + "comments": { + "default": false, + "help": "download all comments if available, may lead to large metadata", + "type": "bool" + }, + "livestreams": { + "default": false, + "help": "if set, will download live streams, otherwise will skip them; see --max-filesize for more control", + "type": "bool" + }, + "live_from_start": { + "default": false, + "help": "if set, will download live streams from their earliest available moment, otherwise starts now.", + "type": "bool" + }, + "proxy": { + "default": "", + "help": "http/socks (https seems to not work atm) proxy to use for the webdriver, eg https://proxy-user:password@proxy-ip:port" + }, + "end_means_success": { + "default": true, + "help": "if True, any archived content will mean a 'success', if False this archiver will not return a 'success' stage; this is useful for cases when the yt-dlp will archive a video but ignore other types of content like images or text only pages that the subsequent archivers can retrieve.", + "type": "bool" + }, + "allow_playlist": { + "default": false, + "help": "If True will also download playlists, set to False if the expectation is to download a single video.", + "type": "bool" + }, + "max_downloads": { + "default": "inf", + "help": "Use to limit the number of videos to download when a channel or long page is being extracted. 'inf' means no limit." + }, + "ytdlp_update_interval": { + "default": 5, + "help": "How often to check for yt-dlp updates (days). If positive, will check and update yt-dlp every [num] days. Set it to -1 to disable, or 0 to always update on every run.", + "type": "int" + } + } + }, + "tiktok_tikwm_extractor": { + "name": "tiktok_tikwm_extractor", + "display_name": "Tiktok Tikwm Extractor", + "manifest": { + "name": "Tiktok Tikwm Extractor", + "author": "Bellingcat", + "type": [ + "extractor" + ], + "requires_setup": false, + "description": "\n Uses an unofficial TikTok video download platform's API to download videos: https://tikwm.com/\n\t\n\tThis extractor complements the generic_extractor which can already get TikTok videos, but this one can extract special videos like those marked as sensitive.\n\n ### Features\n - Downloads the video and, if possible, also the video cover.\n\t- Stores extra metadata about the post like author information, and more as returned by tikwm.com. \n\n ### Notes\n - If tikwm.com is down, this extractor will not work.\n\t- If tikwm.com changes their API, this extractor may break.\n\t- If no video is found, this extractor will consider the extraction failed.\n ", + "dependencies": { + "python": [ + "loguru", + "requests" + ], + "bin": [] + }, + "entry_point": "", + "version": "1.0", + "configs": {} + }, + "configs": null + }, + "telegram_extractor": { + "name": "telegram_extractor", + "display_name": "Telegram Extractor", + "manifest": { + "name": "Telegram Extractor", + "author": "Bellingcat", + "type": [ + "extractor" + ], + "requires_setup": false, + "description": " \n The `TelegramExtractor` retrieves publicly available media content from Telegram message links without requiring login credentials. \n It processes URLs to fetch images and videos embedded in Telegram messages, ensuring a structured output using `Metadata` \n and `Media` objects. Recommended for scenarios where login-based archiving is not viable, although `telethon_archiver` \n is advised for more comprehensive functionality, and higher quality media extraction.\n \n ### Features\n- Extracts images and videos from public Telegram message links (`t.me`).\n- Processes HTML content of messages to retrieve embedded media.\n- Sets structured metadata, including timestamps, content, and media details.\n- Does not require user authentication for Telegram.\n\n ", + "dependencies": { + "python": [ + "requests", + "bs4", + "loguru" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": {} + }, + "configs": null + }, + "wayback_extractor_enricher": { + "name": "wayback_extractor_enricher", + "display_name": "Wayback Machine Enricher (and Extractor)", + "manifest": { + "name": "Wayback Machine Enricher (and Extractor)", + "author": "Bellingcat", + "type": [ + "enricher", + "extractor" + ], + "requires_setup": true, + "description": "\n Submits the current URL to the Wayback Machine for archiving and returns either a job ID or the completed archive URL.\n\n ### Features\n - Archives URLs using the Internet Archive's Wayback Machine API.\n - Supports conditional archiving based on the existence of prior archives within a specified time range.\n - Provides proxies for HTTP and HTTPS requests.\n - Fetches and confirms the archive URL or provides a job ID for later status checks.\n\n ### Notes\n - Requires a valid Wayback Machine API key and secret.\n - Handles rate-limiting by Wayback Machine and retries status checks with exponential backoff.\n \n ### Steps to Get an Wayback API Key:\n - Sign up for an account at [Internet Archive](https://archive.org/account/signup).\n - Log in to your account.\n - Navigte to your [account settings](https://archive.org/account).\n - or: https://archive.org/developers/tutorial-get-ia-credentials.html\n - Under Wayback Machine API Keys, generate a new key.\n - Note down your API key and secret, as they will be required for authentication.\n ", + "dependencies": { + "python": [ + "loguru", + "requests" + ] + }, + "entry_point": "wayback_extractor_enricher::WaybackExtractorEnricher", + "version": "1.0", + "configs": { + "timeout": { + "default": 15, + "type": "int", + "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." + }, + "if_not_archived_within": { + "default": null, + "help": "only tell wayback to archive if no archive is available before the number of seconds specified, use None to ignore this option. For more information: https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA" + }, + "key": { + "required": true, + "help": "wayback API key. to get credentials visit https://archive.org/account/s3.php" + }, + "secret": { + "required": true, + "help": "wayback API secret. to get credentials visit https://archive.org/account/s3.php" + }, + "proxy_http": { + "default": null, + "help": "http proxy to use for wayback requests, eg http://proxy-user:password@proxy-ip:port" + }, + "proxy_https": { + "default": null, + "help": "https proxy to use for wayback requests, eg https://proxy-user:password@proxy-ip:port" + } + } + }, + "configs": { + "timeout": { + "default": 15, + "type": "int", + "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." + }, + "if_not_archived_within": { + "default": null, + "help": "only tell wayback to archive if no archive is available before the number of seconds specified, use None to ignore this option. For more information: https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA" + }, + "key": { + "required": true, + "help": "wayback API key. to get credentials visit https://archive.org/account/s3.php" + }, + "secret": { + "required": true, + "help": "wayback API secret. to get credentials visit https://archive.org/account/s3.php" + }, + "proxy_http": { + "default": null, + "help": "http proxy to use for wayback requests, eg http://proxy-user:password@proxy-ip:port" + }, + "proxy_https": { + "default": null, + "help": "https proxy to use for wayback requests, eg https://proxy-user:password@proxy-ip:port" + } + } + }, + "wacz_extractor_enricher": { + "name": "wacz_extractor_enricher", + "display_name": "WACZ Enricher (and Extractor)", + "manifest": { + "name": "WACZ Enricher (and Extractor)", + "author": "Bellingcat", + "type": [ + "enricher", + "extractor" + ], + "requires_setup": true, + "description": "\n Creates .WACZ archives of web pages using the `browsertrix-crawler` tool, with options for media extraction and screenshot saving.\n [Browsertrix-crawler](https://crawler.docs.browsertrix.com/user-guide/) is a headless browser-based crawler that archives web pages in WACZ format.\n\n ### Features\n - Archives web pages into .WACZ format using Docker or direct invocation of `browsertrix-crawler`.\n - Supports custom profiles for archiving private or dynamic content.\n - Extracts media (images, videos, audio) and screenshots from the archive, optionally adding them to the enrichment pipeline.\n - Generates metadata from the archived page's content and structure (e.g., titles, text).\n\n ### Notes\n - Requires Docker for running `browsertrix-crawler` .\n - Configurable via parameters for timeout, media extraction, screenshots, and proxy settings.\n ", + "dependencies": { + "python": [ + "loguru", + "jsonlines", + "warcio" + ], + "bin": [ + "docker" + ] + }, + "entry_point": "wacz_extractor_enricher::WaczExtractorEnricher", + "version": "1.0", + "configs": { + "profile": { + "default": null, + "help": "browsertrix-profile (for profile generation see https://github.com/webrecorder/browsertrix-crawler#creating-and-using-browser-profiles)." + }, + "docker_commands": { + "default": null, + "help": "if a custom docker invocation is needed" + }, + "timeout": { + "default": 120, + "type": "int", + "help": "timeout for WACZ generation in seconds" + }, + "extract_media": { + "default": false, + "type": "bool", + "help": "If enabled all the images/videos/audio present in the WACZ archive will be extracted into separate Media and appear in the html report. The .wacz file will be kept untouched." + }, + "extract_screenshot": { + "default": true, + "type": "bool", + "help": "If enabled the screenshot captured by browsertrix will be extracted into separate Media and appear in the html report. The .wacz file will be kept untouched." + }, + "socks_proxy_host": { + "default": null, + "help": "SOCKS proxy host for browsertrix-crawler, use in combination with socks_proxy_port. eg: user:password@host" + }, + "socks_proxy_port": { + "default": null, + "type": "int", + "help": "SOCKS proxy port for browsertrix-crawler, use in combination with socks_proxy_host. eg 1234" + }, + "proxy_server": { + "default": null, + "help": "SOCKS server proxy URL, in development" + } + } + }, + "configs": { + "profile": { + "default": null, + "help": "browsertrix-profile (for profile generation see https://github.com/webrecorder/browsertrix-crawler#creating-and-using-browser-profiles)." + }, + "docker_commands": { + "default": null, + "help": "if a custom docker invocation is needed" + }, + "timeout": { + "default": 120, + "type": "int", + "help": "timeout for WACZ generation in seconds" + }, + "extract_media": { + "default": false, + "type": "bool", + "help": "If enabled all the images/videos/audio present in the WACZ archive will be extracted into separate Media and appear in the html report. The .wacz file will be kept untouched." + }, + "extract_screenshot": { + "default": true, + "type": "bool", + "help": "If enabled the screenshot captured by browsertrix will be extracted into separate Media and appear in the html report. The .wacz file will be kept untouched." + }, + "socks_proxy_host": { + "default": null, + "help": "SOCKS proxy host for browsertrix-crawler, use in combination with socks_proxy_port. eg: user:password@host" + }, + "socks_proxy_port": { + "default": null, + "type": "int", + "help": "SOCKS proxy port for browsertrix-crawler, use in combination with socks_proxy_host. eg 1234" + }, + "proxy_server": { + "default": null, + "help": "SOCKS server proxy URL, in development" + } + } + }, + "metadata_enricher": { + "name": "metadata_enricher", + "display_name": "Media Metadata Enricher", + "manifest": { + "name": "Media Metadata Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": true, + "description": "\n Extracts metadata information from files using ExifTool.\n\n ### Features\n - Uses ExifTool to extract detailed metadata from media files.\n - Processes file-specific data like camera settings, geolocation, timestamps, and other embedded metadata.\n - Adds extracted metadata to the corresponding `Media` object within the `Metadata`.\n\n ### Notes\n - Requires ExifTool to be installed and accessible via the system's PATH.\n - Skips enrichment for files where metadata extraction fails.\n ", + "dependencies": { + "python": [ + "loguru" + ], + "bin": [ + "exiftool" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": {} + }, + "configs": null + }, + "timestamping_enricher": { + "name": "timestamping_enricher", + "display_name": "Timestamping Enricher", + "manifest": { + "name": "Timestamping Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": true, + "description": "\n Generates RFC3161-compliant timestamp tokens using Time Stamp Authorities (TSA) for archived files.\n\n ### Features\n - Creates timestamp tokens to prove the existence of files at a specific time, useful for legal and authenticity purposes.\n - Aggregates file hashes into a text file and timestamps the concatenated data.\n - Uses multiple Time Stamp Authorities (TSAs) to ensure reliability and redundancy.\n - Validates timestamping certificates against trusted Certificate Authorities (CAs) using the `certifi` trust store.\n\n ### Notes\n - Should be run after the `hash_enricher` to ensure file hashes are available.\n - Requires internet access to interact with the configured TSAs.\n ", + "dependencies": { + "python": [ + "loguru", + "slugify", + "tsp_client", + "asn1crypto", + "certvalidator", + "certifi" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "tsa_urls": { + "default": [ + "http://timestamp.digicert.com", + "http://timestamp.identrust.com", + "http://timestamp.globalsign.com/tsa/r6advanced1", + "http://tss.accv.es:8318/tsa" + ], + "help": "List of RFC3161 Time Stamp Authorities to use, separate with commas if passed via the command line." + } + } + }, + "configs": { + "tsa_urls": { + "default": [ + "http://timestamp.digicert.com", + "http://timestamp.identrust.com", + "http://timestamp.globalsign.com/tsa/r6advanced1", + "http://tss.accv.es:8318/tsa" + ], + "help": "List of RFC3161 Time Stamp Authorities to use, separate with commas if passed via the command line." + } + } + }, + "screenshot_enricher": { + "name": "screenshot_enricher", + "display_name": "Screenshot Enricher", + "manifest": { + "name": "Screenshot Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": true, + "description": "\n Captures screenshots and optionally saves web pages as PDFs using a WebDriver.\n\n ### Features\n - Takes screenshots of web pages, with configurable width, height, and timeout settings.\n - Optionally saves pages as PDFs, with additional configuration for PDF printing options.\n - Bypasses URLs detected as authentication walls.\n - Integrates seamlessly with the metadata enrichment pipeline, adding screenshots and PDFs as media.\n\n ### Notes\n - Requires a WebDriver (e.g., ChromeDriver) installed and accessible via the system's PATH.\n ", + "dependencies": { + "python": [ + "loguru", + "selenium" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "width": { + "default": 1280, + "type": "int", + "help": "width of the screenshots" + }, + "height": { + "default": 1024, + "type": "int", + "help": "height of the screenshots" + }, + "timeout": { + "default": 60, + "type": "int", + "help": "timeout for taking the screenshot" + }, + "sleep_before_screenshot": { + "default": 4, + "type": "int", + "help": "seconds to wait for the pages to load before taking screenshot" + }, + "http_proxy": { + "default": "", + "help": "http proxy to use for the webdriver, eg http://proxy-user:password@proxy-ip:port" + }, + "save_to_pdf": { + "default": false, + "type": "bool", + "help": "save the page as pdf along with the screenshot. PDF saving options can be adjusted with the 'print_options' parameter" + }, + "print_options": { + "default": {}, + "help": "options to pass to the pdf printer, in JSON format. See https://www.selenium.dev/documentation/webdriver/interactions/print_page/ for more information", + "type": "json_loader" + } + } + }, + "configs": { + "width": { + "default": 1280, + "type": "int", + "help": "width of the screenshots" + }, + "height": { + "default": 1024, + "type": "int", + "help": "height of the screenshots" + }, + "timeout": { + "default": 60, + "type": "int", + "help": "timeout for taking the screenshot" + }, + "sleep_before_screenshot": { + "default": 4, + "type": "int", + "help": "seconds to wait for the pages to load before taking screenshot" + }, + "http_proxy": { + "default": "", + "help": "http proxy to use for the webdriver, eg http://proxy-user:password@proxy-ip:port" + }, + "save_to_pdf": { + "default": false, + "type": "bool", + "help": "save the page as pdf along with the screenshot. PDF saving options can be adjusted with the 'print_options' parameter" + }, + "print_options": { + "default": {}, + "help": "options to pass to the pdf printer, in JSON format. See https://www.selenium.dev/documentation/webdriver/interactions/print_page/ for more information", + "type": "json_loader" + } + } + }, + "whisper_enricher": { + "name": "whisper_enricher", + "display_name": "Whisper Enricher", + "manifest": { + "name": "Whisper Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": true, + "description": "\n Integrates with a Whisper API service to transcribe, translate, or detect the language of audio and video files.\n\n ### Features\n - Submits audio or video files to a Whisper API deployment for processing.\n - Supports operations such as transcription, translation, and language detection.\n - Optionally generates SRT subtitle files for video content.\n - Integrates with S3-compatible storage systems to make files publicly accessible for processing.\n - Handles job submission, status checking, artifact retrieval, and cleanup.\n\n ### Notes\n - Requires a Whisper API endpoint and API key for authentication.\n - Only compatible with S3-compatible storage systems for media file accessibility.\n - ** This stores the media files in S3 prior to enriching them as Whisper requires public URLs to access the media files.\n - Handles multiple jobs and retries for failed or incomplete processing.\n ", + "dependencies": { + "python": [ + "s3_storage", + "loguru", + "requests" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "api_endpoint": { + "required": true, + "help": "WhisperApi api endpoint, eg: https://whisperbox-api.com/api/v1, a deployment of https://github.com/bellingcat/whisperbox-transcribe." + }, + "api_key": { + "required": true, + "help": "WhisperApi api key for authentication" + }, + "include_srt": { + "default": false, + "type": "bool", + "help": "Whether to include a subtitle SRT (SubRip Subtitle file) for the video (can be used in video players)." + }, + "timeout": { + "default": 90, + "type": "int", + "help": "How many seconds to wait at most for a successful job completion." + }, + "action": { + "default": "translate", + "help": "which Whisper operation to execute", + "choices": [ + "transcribe", + "translate", + "language_detection" + ] + } + } + }, + "configs": { + "api_endpoint": { + "required": true, + "help": "WhisperApi api endpoint, eg: https://whisperbox-api.com/api/v1, a deployment of https://github.com/bellingcat/whisperbox-transcribe." + }, + "api_key": { + "required": true, + "help": "WhisperApi api key for authentication" + }, + "include_srt": { + "default": false, + "type": "bool", + "help": "Whether to include a subtitle SRT (SubRip Subtitle file) for the video (can be used in video players)." + }, + "timeout": { + "default": 90, + "type": "int", + "help": "How many seconds to wait at most for a successful job completion." + }, + "action": { + "default": "translate", + "help": "which Whisper operation to execute", + "choices": [ + "transcribe", + "translate", + "language_detection" + ] + } + } + }, + "opentimestamps_enricher": { + "name": "opentimestamps_enricher", + "display_name": "OpenTimestamps Enricher", + "manifest": { + "name": "OpenTimestamps Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": true, + "description": "\n Creates OpenTimestamps proofs for archived files, providing blockchain-backed evidence of file existence at a specific time.\n\n Uses OpenTimestamps \u2013 a service that timestamps data using the Bitcoin blockchain, providing a decentralized \n and secure way to prove that data existed at a certain point in time.\n\n ### Features\n - Creates cryptographic timestamp proofs that link files to the Bitcoin blockchain\n - Verifies existing timestamp proofs to confirm the time a file existed\n - Uses multiple calendar servers to ensure reliability and redundancy\n - Stores timestamp proofs alongside original files for future verification\n\n ### Notes\n - Can work offline to create timestamp proofs that can be upgraded later\n - Verification checks if timestamps have been confirmed in the Bitcoin blockchain\n - Should run after files have been archived and hashed\n\n ### Verifying Timestamps Later\n If you wish to verify a timestamp (ots) file later, you can install the opentimestamps-client command line tool and use the `ots verify` command.\n Example: `ots verify my_file.ots`\n\n Note: if you're using local storage with a filename_generator set to 'static' (a hash) or random, the files will be renamed when they are saved to the\n final location meaning you will need to specify the original filename when verifying the timestamp with `ots verify -f original_filename my_file.ots`.\n ", + "dependencies": { + "python": [ + "loguru", + "opentimestamps" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "use_calendars": { + "default": true, + "help": "Whether to connect to OpenTimestamps calendar servers to create timestamps. If false, creates local timestamp proofs only.", + "type": "bool" + }, + "calendar_urls": { + "default": [ + "https://alice.btc.calendar.opentimestamps.org", + "https://bob.btc.calendar.opentimestamps.org", + "https://finney.calendar.eternitywall.com" + ], + "help": "List of OpenTimestamps calendar servers to use for timestamping. See here for a list of calendars maintained by opentimestamps:https://opentimestamps.org/#calendars", + "type": "list" + }, + "calendar_whitelist": { + "default": [], + "help": "Optional whitelist of calendar servers. Override this if you are using your own calendar servers. e.g. ['https://mycalendar.com']", + "type": "list" + }, + "verify_timestamps": { + "default": true, + "help": "Whether to verify timestamps after creating them.", + "type": "bool" + } + } + }, + "configs": { + "use_calendars": { + "default": true, + "help": "Whether to connect to OpenTimestamps calendar servers to create timestamps. If false, creates local timestamp proofs only.", + "type": "bool" + }, + "calendar_urls": { + "default": [ + "https://alice.btc.calendar.opentimestamps.org", + "https://bob.btc.calendar.opentimestamps.org", + "https://finney.calendar.eternitywall.com" + ], + "help": "List of OpenTimestamps calendar servers to use for timestamping. See here for a list of calendars maintained by opentimestamps:https://opentimestamps.org/#calendars", + "type": "list" + }, + "calendar_whitelist": { + "default": [], + "help": "Optional whitelist of calendar servers. Override this if you are using your own calendar servers. e.g. ['https://mycalendar.com']", + "type": "list" + }, + "verify_timestamps": { + "default": true, + "help": "Whether to verify timestamps after creating them.", + "type": "bool" + } + } + }, + "thumbnail_enricher": { + "name": "thumbnail_enricher", + "display_name": "Thumbnail Enricher", + "manifest": { + "name": "Thumbnail Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": false, + "description": "\n Generates thumbnails for video files to provide visual previews.\n\n ### Features\n - Processes video files and generates evenly distributed thumbnails.\n - Calculates the number of thumbnails based on video duration, `thumbnails_per_minute`, and `max_thumbnails`.\n - Distributes thumbnails equally across the video's duration and stores them as media objects.\n - Adds metadata for each thumbnail, including timestamps and IDs.\n\n ### Notes\n - Requires `ffmpeg` to be installed and accessible via the system's PATH.\n - Handles videos without pre-existing duration metadata by probing with `ffmpeg`.\n - Skips enrichment for non-video media files.\n ", + "dependencies": { + "python": [ + "loguru", + "ffmpeg" + ], + "bin": [ + "ffmpeg" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "thumbnails_per_minute": { + "default": 60, + "type": "int", + "help": "how many thumbnails to generate per minute of video, can be limited by max_thumbnails" + }, + "max_thumbnails": { + "default": 16, + "type": "int", + "help": "limit the number of thumbnails to generate per video, 0 means no limit" + } + } + }, + "configs": { + "thumbnails_per_minute": { + "default": 60, + "type": "int", + "help": "how many thumbnails to generate per minute of video, can be limited by max_thumbnails" + }, + "max_thumbnails": { + "default": 16, + "type": "int", + "help": "limit the number of thumbnails to generate per video, 0 means no limit" + } + } + }, + "meta_enricher": { + "name": "meta_enricher", + "display_name": "Archive Metadata Enricher", + "manifest": { + "name": "Archive Metadata Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": false, + "description": " \n Adds metadata information about the archive operations, Adds metadata about archive operations, including file sizes and archive duration./\n To be included at the end of all enrichments.\n \n ### Features\n- Calculates the total size of all archived media files, storing the result in human-readable and byte formats.\n- Computes the duration of the archival process, storing the elapsed time in seconds.\n- Ensures all enrichments are performed only if the `Metadata` object contains valid data.\n- Adds detailed metadata to provide insights into file sizes and archival performance.\n\n### Notes\n- Skips enrichment if no media or metadata is available in the `Metadata` object.\n- File sizes are calculated using the `os.stat` module, ensuring accurate byte-level reporting.\n", + "dependencies": { + "python": [ + "loguru" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": {} + }, + "configs": null + }, + "pdq_hash_enricher": { + "name": "pdq_hash_enricher", + "display_name": "PDQ Hash Enricher", + "manifest": { + "name": "PDQ Hash Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": false, + "description": "\n PDQ Hash Enricher for generating perceptual hashes of media files.\n\n ### Features\n - Calculates perceptual hashes for image files using the PDQ hashing algorithm.\n - Enables detection of duplicate or near-duplicate visual content.\n - Processes images stored in `Metadata` objects, adding computed hashes to the corresponding `Media` entries.\n - Skips non-image media or files unsuitable for hashing (e.g., corrupted or unsupported formats).\n\n ### Notes\n - Best used after enrichers like `thumbnail_enricher` or `screenshot_enricher` to ensure images are available.\n - Uses the `pdqhash` library to compute 256-bit perceptual hashes, which are stored as hexadecimal strings.\n ", + "dependencies": { + "python": [ + "loguru", + "pdqhash", + "numpy", + "PIL" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": {} + }, + "configs": null + }, + "ssl_enricher": { + "name": "ssl_enricher", + "display_name": "SSL Certificate Enricher", + "manifest": { + "name": "SSL Certificate Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": false, + "description": "\n Retrieves SSL certificate information for a domain and stores it as a file.\n\n ### Features\n - Fetches SSL certificates for domains using the HTTPS protocol.\n - Stores certificates in PEM format and adds them as media to the metadata.\n - Skips enrichment if no media has been archived, based on the `skip_when_nothing_archived` configuration.\n\n ### Notes\n - Requires the target URL to use the HTTPS scheme; other schemes are not supported.\n ", + "dependencies": { + "python": [ + "loguru", + "slugify" + ] + }, + "entry_point": "ssl_enricher::SSLEnricher", + "version": "1.0", + "configs": { + "skip_when_nothing_archived": { + "default": true, + "type": "bool", + "help": "if true, will skip enriching when no media is archived" + } + } + }, + "configs": { + "skip_when_nothing_archived": { + "default": true, + "type": "bool", + "help": "if true, will skip enriching when no media is archived" + } + } + }, + "hash_enricher": { + "name": "hash_enricher", + "display_name": "Hash Enricher", + "manifest": { + "name": "Hash Enricher", + "author": "Bellingcat", + "type": [ + "enricher" + ], + "requires_setup": false, + "description": "\nGenerates cryptographic hashes for media files to ensure data integrity and authenticity.\n\n### Features\n- Calculates cryptographic hashes (SHA-256 or SHA3-512) for media files stored in `Metadata` objects.\n- Ensures content authenticity, integrity validation, and duplicate identification.\n- Efficiently processes large files by reading file bytes in configurable chunk sizes.\n- Supports dynamic configuration of hash algorithms and chunk sizes.\n- Updates media metadata with the computed hash value in the format `:`.\n\n### Notes\n- Default hash algorithm is SHA-256, but SHA3-512 is also supported.\n- Chunk size defaults to 16 MB but can be adjusted based on memory requirements.\n- Useful for workflows requiring hash-based content validation or deduplication.\n", + "dependencies": { + "python": [ + "loguru" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "algorithm": { + "default": "SHA-256", + "help": "hash algorithm to use", + "choices": [ + "SHA-256", + "SHA3-512" + ] + }, + "chunksize": { + "default": 16000000, + "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", + "type": "int" + } + } + }, + "configs": { + "algorithm": { + "default": "SHA-256", + "help": "hash algorithm to use", + "choices": [ + "SHA-256", + "SHA3-512" + ] + }, + "chunksize": { + "default": 16000000, + "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", + "type": "int" + } + } + }, + "api_db": { + "name": "api_db", + "display_name": "Auto Archiver API Database", + "manifest": { + "name": "Auto Archiver API Database", + "author": "Bellingcat", + "type": [ + "database" + ], + "requires_setup": true, + "description": "\n Provides integration with the Auto Archiver API for querying and storing archival data.\n\n### Features\n- **API Integration**: Supports querying for existing archives and submitting results.\n- **Duplicate Prevention**: Avoids redundant archiving when `use_api_cache` is disabled.\n- **Configurable**: Supports settings like API endpoint, authentication token, tags, and permissions.\n- **Tagging and Metadata**: Adds tags and manages metadata for archives.\n- **Optional Storage**: Archives results conditionally based on configuration.\n\n### Setup\nRequires access to an Auto Archiver API instance and a valid API token.\n ", + "dependencies": { + "python": [ + "requests", + "loguru" + ] + }, + "entry_point": "api_db::AAApiDb", + "version": "1.0", + "configs": { + "api_endpoint": { + "required": true, + "help": "API endpoint where calls are made to" + }, + "api_token": { + "default": null, + "help": "API Bearer token." + }, + "public": { + "default": false, + "type": "bool", + "help": "whether the URL should be publicly available via the API" + }, + "author_id": { + "default": null, + "help": "which email to assign as author" + }, + "group_id": { + "default": null, + "help": "which group of users have access to the archive in case public=false as author" + }, + "use_api_cache": { + "default": false, + "type": "bool", + "help": "if True then the API database will be queried prior to any archiving operations and stop if the link has already been archived" + }, + "store_results": { + "default": true, + "type": "bool", + "help": "when set, will send the results to the API database." + }, + "tags": { + "default": [], + "help": "what tags to add to the archived URL" + } + } + }, + "configs": { + "api_endpoint": { + "required": true, + "help": "API endpoint where calls are made to" + }, + "api_token": { + "default": null, + "help": "API Bearer token." + }, + "public": { + "default": false, + "type": "bool", + "help": "whether the URL should be publicly available via the API" + }, + "author_id": { + "default": null, + "help": "which email to assign as author" + }, + "group_id": { + "default": null, + "help": "which group of users have access to the archive in case public=false as author" + }, + "use_api_cache": { + "default": false, + "type": "bool", + "help": "if True then the API database will be queried prior to any archiving operations and stop if the link has already been archived" + }, + "store_results": { + "default": true, + "type": "bool", + "help": "when set, will send the results to the API database." + }, + "tags": { + "default": [], + "help": "what tags to add to the archived URL" + } + } + }, + "console_db": { + "name": "console_db", + "display_name": "Console Database", + "manifest": { + "name": "Console Database", + "author": "Bellingcat", + "type": [ + "database" + ], + "requires_setup": false, + "description": "\nProvides a simple database implementation that outputs archival results and status updates to the console.\n\n### Features\n- Logs the status of archival tasks directly to the console, including:\n - started\n - failed (with error details)\n - aborted\n - done (with optional caching status)\n- Useful for debugging or lightweight setups where no external database is required.\n\n### Setup\nNo additional configuration is required.\n", + "dependencies": { + "python": [ + "loguru" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": {} + }, + "configs": null + }, + "csv_db": { + "name": "csv_db", + "display_name": "CSV Database", + "manifest": { + "name": "CSV Database", + "author": "Bellingcat", + "type": [ + "database" + ], + "requires_setup": false, + "description": "\nHandles exporting archival results to a CSV file.\n\n### Features\n- Saves archival metadata as rows in a CSV file.\n- Automatically creates the CSV file with a header if it does not exist.\n- Appends new metadata entries to the existing file.\n\n### Setup\nRequired config:\n- csv_file: Path to the CSV file where results will be stored (default: \"db.csv\").\n", + "dependencies": { + "python": [ + "loguru" + ] + }, + "entry_point": "csv_db::CSVDb", + "version": "1.0", + "configs": { + "csv_file": { + "default": "db.csv", + "help": "CSV file name to save metadata to" + } + } + }, + "configs": { + "csv_file": { + "default": "db.csv", + "help": "CSV file name to save metadata to" + } + } + }, + "gdrive_storage": { + "name": "gdrive_storage", + "display_name": "Google Drive Storage", + "manifest": { + "name": "Google Drive Storage", + "author": "Dave Mateer", + "type": [ + "storage" + ], + "requires_setup": true, + "description": "\n \n GDriveStorage: A storage module for saving archived content to Google Drive.\n\n Source Documentation: https://davemateer.com/2022/04/28/google-drive-with-python\n\n ### Features\n - Saves media files to Google Drive, organizing them into folders based on the provided path structure.\n - Supports OAuth token-based authentication or service account credentials for API access.\n - Automatically creates folders in Google Drive if they don't exist.\n - Retrieves CDN URLs for stored files, enabling easy sharing and access.\n\n ### Notes\n - Requires setup with either a Google OAuth token or a service account JSON file.\n - Files are uploaded to the specified `root_folder_id` and organized by the `media.key` structure.\n - Automatically handles Google Drive API token refreshes for long-running jobs.\n \n ## Overview\nThis module integrates Google Drive as a storage backend, enabling automatic folder creation and file uploads. It supports authentication via **service accounts** (recommended for automation) or **OAuth tokens** (for user-based authentication).\n\n## Features\n- Saves files to Google Drive, organizing them into structured folders.\n- Supports both **service account** and **OAuth token** authentication.\n- Automatically creates folders if they don't exist.\n- Generates public URLs for easy file sharing.\n\n## Setup Guide\n1. **Enable Google Drive API**\n - Create a Google Cloud project at [Google Cloud Console](https://console.cloud.google.com/)\n - Enable the **Google Drive API**.\n\n2. **Set Up a Google Drive Folder**\n - Create a folder in **Google Drive** and copy its **folder ID** from the URL.\n - Add the **folder ID** to your configuration (`orchestration.yaml`):\n ```yaml\n root_folder_id: \"FOLDER_ID\"\n ```\n\n3. **Authentication Options**\n - **Option 1: Service Account (Recommended)**\n - Create a **service account** in Google Cloud IAM.\n - Download the JSON key file and save it as:\n ```\n secrets/service_account.json\n ```\n - **Share your Drive folder** with the service account\u2019s `client_email` (found in the JSON file).\n \n - **Option 2: OAuth Token (User Authentication)**\n - Create OAuth **Desktop App credentials** in Google Cloud.\n - Save the credentials as:\n ```\n secrets/oauth_credentials.json\n ```\n - Generate an OAuth token by running:\n ```sh\n python scripts/create_update_gdrive_oauth_token.py -c secrets/oauth_credentials.json\n ```\n\n \n Notes on the OAuth token:\n Tokens are refreshed after 1 hour however keep working for 7 days (tbc)\n so as long as the job doesn't last for 7 days then this method of refreshing only once per run will work\n see this link for details on the token:\n https://davemateer.com/2022/04/28/google-drive-with-python#tokens\n \n \n", + "dependencies": { + "python": [ + "loguru", + "googleapiclient", + "google" + ] + }, + "entry_point": "gdrive_storage::GDriveStorage", + "version": "1.0", + "configs": { + "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": [ + "flat", + "url", + "random" + ] + }, + "filename_generator": { + "default": "static", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled).", + "choices": [ + "random", + "static" + ] + }, + "root_folder_id": { + "required": true, + "help": "root google drive folder ID to use as storage, found in URL: 'https://drive.google.com/drive/folders/FOLDER_ID'" + }, + "oauth_token": { + "default": null, + "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." + } + } + }, + "configs": { + "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": [ + "flat", + "url", + "random" + ] + }, + "filename_generator": { + "default": "static", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled).", + "choices": [ + "random", + "static" + ] + }, + "root_folder_id": { + "required": true, + "help": "root google drive folder ID to use as storage, found in URL: 'https://drive.google.com/drive/folders/FOLDER_ID'" + }, + "oauth_token": { + "default": null, + "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." + } + } + }, + "s3_storage": { + "name": "s3_storage", + "display_name": "S3 Storage", + "manifest": { + "name": "S3 Storage", + "author": "Bellingcat", + "type": [ + "storage" + ], + "requires_setup": true, + "description": "\n S3Storage: A storage module for saving media files to an S3-compatible object storage.\n\n ### Features\n - Uploads media files to an S3 bucket with customizable configurations.\n - Supports `random_no_duplicate` mode to avoid duplicate uploads by checking existing files based on SHA-256 hashes.\n - Automatically generates unique paths for files when duplicates are found.\n - Configurable endpoint and CDN URL for different S3-compatible providers.\n - Supports both private and public file storage, with public files being readable online.\n\n ### Notes\n - Requires S3 credentials (API key and secret) and a bucket name to function.\n - The `random_no_duplicate` option ensures no duplicate uploads by leveraging hash-based folder structures.\n - Uses `boto3` for interaction with the S3 API.\n - Depends on the `HashEnricher` module for hash calculation.\n ", + "dependencies": { + "python": [ + "hash_enricher", + "boto3", + "loguru" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "path_generator": { + "default": "flat", + "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": [ + "flat", + "url", + "random" + ] + }, + "filename_generator": { + "default": "static", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled).", + "choices": [ + "random", + "static" + ] + }, + "bucket": { + "default": null, + "help": "S3 bucket name" + }, + "region": { + "default": null, + "help": "S3 region name" + }, + "key": { + "default": null, + "help": "S3 API key" + }, + "secret": { + "default": null, + "help": "S3 API secret" + }, + "random_no_duplicate": { + "default": false, + "type": "bool", + "help": "if set, it will override `path_generator`, `filename_generator` and `folder`. It will check if the file already exists and if so it will not upload it again. Creates a new root folder path `no-dups/`" + }, + "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, + "type": "bool", + "help": "if true S3 files will not be readable online" + } + } + }, + "configs": { + "path_generator": { + "default": "flat", + "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": [ + "flat", + "url", + "random" + ] + }, + "filename_generator": { + "default": "static", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled).", + "choices": [ + "random", + "static" + ] + }, + "bucket": { + "default": null, + "help": "S3 bucket name" + }, + "region": { + "default": null, + "help": "S3 region name" + }, + "key": { + "default": null, + "help": "S3 API key" + }, + "secret": { + "default": null, + "help": "S3 API secret" + }, + "random_no_duplicate": { + "default": false, + "type": "bool", + "help": "if set, it will override `path_generator`, `filename_generator` and `folder`. It will check if the file already exists and if so it will not upload it again. Creates a new root folder path `no-dups/`" + }, + "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, + "type": "bool", + "help": "if true S3 files will not be readable online" + } + } + }, + "local_storage": { + "name": "local_storage", + "display_name": "Local Storage", + "manifest": { + "name": "Local Storage", + "author": "Bellingcat", + "type": [ + "storage" + ], + "requires_setup": false, + "description": "\n LocalStorage: A storage module for saving archived content locally on the filesystem.\n\n ### Features\n - Saves archived media files to a specified folder on the local filesystem.\n - Maintains file metadata during storage using `shutil.copy2`.\n - Supports both absolute and relative paths for stored files, configurable via `save_absolute`.\n - Automatically creates directories as needed for storing files.\n\n ### Notes\n - Default storage folder is `./archived`, but this can be changed via the `save_to` configuration.\n - The `save_absolute` option can reveal the file structure in output formats; use with caution.\n ", + "dependencies": { + "python": [ + "loguru" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "path_generator": { + "default": "flat", + "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": [ + "flat", + "url", + "random" + ] + }, + "filename_generator": { + "default": "static", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled)", + "choices": [ + "random", + "static" + ] + }, + "save_to": { + "default": "./local_archive", + "help": "folder where to save archived content" + }, + "save_absolute": { + "default": false, + "type": "bool", + "help": "whether the path to the stored file is absolute or relative in the output result inc. formatters (WARN: leaks the file structure)" + } + } + }, + "configs": { + "path_generator": { + "default": "flat", + "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": [ + "flat", + "url", + "random" + ] + }, + "filename_generator": { + "default": "static", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled)", + "choices": [ + "random", + "static" + ] + }, + "save_to": { + "default": "./local_archive", + "help": "folder where to save archived content" + }, + "save_absolute": { + "default": false, + "type": "bool", + "help": "whether the path to the stored file is absolute or relative in the output result inc. formatters (WARN: leaks the file structure)" + } + } + }, + "mute_formatter": { + "name": "mute_formatter", + "display_name": "Mute Formatter", + "manifest": { + "name": "Mute Formatter", + "author": "Bellingcat", + "type": [ + "formatter" + ], + "requires_setup": true, + "description": " Default formatter.\n ", + "dependencies": {}, + "entry_point": "", + "version": "1.0", + "configs": {} + }, + "configs": null + }, + "html_formatter": { + "name": "html_formatter", + "display_name": "HTML Formatter", + "manifest": { + "name": "HTML Formatter", + "author": "Bellingcat", + "type": [ + "formatter" + ], + "requires_setup": false, + "description": " ", + "dependencies": { + "python": [ + "hash_enricher", + "loguru", + "jinja2" + ], + "bin": [ + "" + ] + }, + "entry_point": "", + "version": "1.0", + "configs": { + "detect_thumbnails": { + "default": true, + "help": "if true will group by thumbnails generated by thumbnail enricher by id 'thumbnail_00'", + "type": "bool" + } + } + }, + "configs": { + "detect_thumbnails": { + "default": true, + "help": "if true will group by thumbnails generated by thumbnail enricher by id 'thumbnail_00'", + "type": "bool" + } + } + } + }, + "steps": { + "feeders": [ + "cli_feeder", + "atlos_feeder_db_storage", + "csv_feeder", + "gsheet_feeder_db" + ], + "extractors": [ + "wayback_extractor_enricher", + "wacz_extractor_enricher", + "instagram_api_extractor", + "instagram_tbot_extractor", + "generic_extractor", + "tiktok_tikwm_extractor", + "twitter_api_extractor", + "instagram_extractor", + "telethon_extractor", + "vk_extractor", + "telegram_extractor" + ], + "enrichers": [ + "wayback_extractor_enricher", + "wacz_extractor_enricher", + "metadata_enricher", + "timestamping_enricher", + "thumbnail_enricher", + "screenshot_enricher", + "meta_enricher", + "pdq_hash_enricher", + "whisper_enricher", + "opentimestamps_enricher", + "ssl_enricher", + "hash_enricher" + ], + "databases": [ + "console_db", + "api_db", + "csv_db", + "atlos_feeder_db_storage", + "gsheet_feeder_db" + ], + "storages": [ + "local_storage", + "gdrive_storage", + "atlos_feeder_db_storage", + "s3_storage" + ], + "formatters": [ + "html_formatter", + "mute_formatter" + ] + }, + "configs": [ + "atlos_feeder_db_storage", + "csv_feeder", + "gsheet_feeder_db", + "cli_feeder", + "instagram_api_extractor", + "instagram_tbot_extractor", + "twitter_api_extractor", + "instagram_extractor", + "telethon_extractor", + "vk_extractor", + "generic_extractor", + "wayback_extractor_enricher", + "wacz_extractor_enricher", + "timestamping_enricher", + "screenshot_enricher", + "whisper_enricher", + "opentimestamps_enricher", + "thumbnail_enricher", + "ssl_enricher", + "hash_enricher", + "api_db", + "csv_db", + "gdrive_storage", + "s3_storage", + "local_storage", + "html_formatter" + ], + "module_types": [ + "feeder", + "extractor", + "enricher", + "database", + "storage", + "formatter" + ], + "empty_config": "# Auto Archiver Configuration\n\n# Steps are the modules that will be run in the order they are defined\nsteps:\n feeders: []\n extractors: []\n enrichers: []\n databases: []\n storages: []\n formatters: []\n\n# Global configuration\n\n# Authentication\n# a dictionary of authentication information that can be used by extractors to login to website. \n# you can use a comma separated list for multiple domains on the same line (common usecase: x.com,twitter.com)\n# Common login 'types' are username/password, cookie, api key/token.\n# There are two special keys for using cookies, they are: cookies_file and cookies_from_browser. \n# Some Examples:\n# facebook.com:\n# username: \"my_username\"\n# password: \"my_password\"\n# or for a site that uses an API key:\n# twitter.com,x.com:\n# api_key\n# api_secret\n# youtube.com:\n# cookie: \"login_cookie=value ; other_cookie=123\" # multiple 'key=value' pairs should be separated by ;\n\nauthentication: {}\n\n# These are the global configurations that are used by the modules\n\nlogging:\n level: INFO\n\n" +} \ No newline at end of file diff --git a/scripts/settings/src/types.d.ts b/scripts/settings/src/types.d.ts new file mode 100644 index 0000000..fdf80fc --- /dev/null +++ b/scripts/settings/src/types.d.ts @@ -0,0 +1,21 @@ +export interface Config { + name: string; + description: string; + type: string?; + default: any; + help: string; + choices: string[]; + required: boolean; +} + +interface Manifest { + description: string; +} + +export interface Module { + name: string; + description: string; + configs: { [key: string]: Config }; + manifest: Manifest; + display_name: string; +} diff --git a/scripts/settings/tsconfig.json b/scripts/settings/tsconfig.json new file mode 100644 index 0000000..3d0a51a --- /dev/null +++ b/scripts/settings/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/scripts/settings/tsconfig.node.json b/scripts/settings/tsconfig.node.json new file mode 100644 index 0000000..9d31e2a --- /dev/null +++ b/scripts/settings/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/scripts/settings/vite.config.ts b/scripts/settings/vite.config.ts new file mode 100644 index 0000000..a04d8c7 --- /dev/null +++ b/scripts/settings/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { viteSingleFile } from "vite-plugin-singlefile" + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + minify: false, + sourcemap: true, + } +}); diff --git a/scripts/telegram_setup.py b/scripts/telegram_setup.py index e6fa43c..9480cd8 100644 --- a/scripts/telegram_setup.py +++ b/scripts/telegram_setup.py @@ -12,7 +12,6 @@ Then run this script to create a new session file. You will need to provide your phone number and a 2FA code the first time you run this script. """ - import os from telethon.sync import TelegramClient from loguru import logger @@ -26,4 +25,3 @@ SESSION_FILE = "secrets/anon-insta" os.makedirs("secrets", exist_ok=True) with TelegramClient(SESSION_FILE, API_ID, API_HASH) as client: logger.success(f"New session file created: {SESSION_FILE}.session") - diff --git a/src/auto_archiver/__main__.py b/src/auto_archiver/__main__.py index f901d21..615486e 100644 --- a/src/auto_archiver/__main__.py +++ b/src/auto_archiver/__main__.py @@ -1,9 +1,13 @@ -""" Entry point for the auto_archiver package. """ +"""Entry point for the auto_archiver package.""" + from auto_archiver.core.orchestrator import ArchivingOrchestrator import sys + def main(): - for _ in ArchivingOrchestrator()._command_line_run(sys.argv[1:]): pass + for _ in ArchivingOrchestrator()._command_line_run(sys.argv[1:]): + pass + if __name__ == "__main__": main() diff --git a/src/auto_archiver/core/__init__.py b/src/auto_archiver/core/__init__.py index 78d9a3d..ef1ec57 100644 --- a/src/auto_archiver/core/__init__.py +++ b/src/auto_archiver/core/__init__.py @@ -1,6 +1,5 @@ -""" Core modules to handle things such as orchestration, metadata and configs.. +"""Core modules to handle things such as orchestration, metadata and configs..""" -""" from .metadata import Metadata from .media import Media from .base_module import BaseModule @@ -14,4 +13,4 @@ from .enricher import Enricher from .feeder import Feeder from .storage import Storage from .extractor import Extractor -from .formatter import Formatter \ No newline at end of file +from .formatter import Formatter diff --git a/src/auto_archiver/core/base_module.py b/src/auto_archiver/core/base_module.py index 50ea3ff..642b8ee 100644 --- a/src/auto_archiver/core/base_module.py +++ b/src/auto_archiver/core/base_module.py @@ -1,9 +1,8 @@ - from __future__ import annotations -from typing import Mapping, Any, Type, TYPE_CHECKING +from typing import Mapping, Any, TYPE_CHECKING from abc import ABC -from copy import deepcopy, copy +from copy import deepcopy from tempfile import TemporaryDirectory from auto_archiver.utils import url as UrlUtil from auto_archiver.core.consts import MODULE_TYPES as CONF_MODULE_TYPES @@ -13,8 +12,8 @@ from loguru import logger if TYPE_CHECKING: from .module import ModuleFactory -class BaseModule(ABC): +class BaseModule(ABC): """ Base module class. All modules should inherit from this class. @@ -46,16 +45,13 @@ class BaseModule(ABC): @property def storages(self) -> list: - return self.config.get('storages', []) + return self.config.get("storages", []) def config_setup(self, config: dict): - - authentication = config.get('authentication', {}) - # this is important. Each instance is given its own deepcopied config, so modules cannot # change values to affect other modules config = deepcopy(config) - authentication = deepcopy(config.pop('authentication', {})) + authentication = deepcopy(config.pop("authentication", {})) self.authentication = authentication self.config = config @@ -63,14 +59,15 @@ class BaseModule(ABC): setattr(self, key, val) def setup(self): - # For any additional setup required by modules, e.g. autehntication + # For any additional setup required by modules outside of the configs in the manifesst, + # e.g. authentication pass def auth_for_site(self, site: str, extract_cookies=True) -> Mapping[str, Any]: """ Returns the authentication information for a given site. This is used to authenticate with a site before extracting data. The site should be the domain of the site, e.g. 'twitter.com' - + :param site: the domain of the site to get authentication information for :param extract_cookies: whether or not to extract cookies from the given browser/file and return the cookie jar (disabling can speed up processing if you don't actually need the cookies jar). @@ -86,15 +83,16 @@ class BaseModule(ABC): * api_key: str - the API key to use for login\n * api_secret: str - the API secret to use for login\n * cookie: str - a cookie string to use for login (specific to this site)\n + * cookies_file: str - the path to a cookies file to use for login (specific to this site)\n + * cookies_from_browser: str - the name of the browser to extract cookies from (specitic for this site)\n """ # TODO: think about if/how we can deal with sites that have multiple domains (main one is x.com/twitter.com) # for now the user must enter them both, like "x.com,twitter.com" in their config. Maybe we just hard-code? - site = UrlUtil.domain_for_url(site) + site = UrlUtil.domain_for_url(site).removeprefix("www.") # add the 'www' version of the site to the list of sites to check authdict = {} - for to_try in [site, f"www.{site}"]: if to_try in self.authentication: authdict.update(self.authentication[to_try]) @@ -104,30 +102,45 @@ class BaseModule(ABC): if not authdict: for key in self.authentication.keys(): if key in site or site in key: - logger.debug(f"Could not find exact authentication information for site '{site}'. \ - did find information for '{key}' which is close, is this what you meant? \ - If so, edit your authentication settings to make sure it exactly matches.") + logger.debug( + f"Could not find exact authentication information for site '{site}'. \ +did find information for '{key}' which is close, is this what you meant? \ +If so, edit your authentication settings to make sure it exactly matches." + ) def get_ytdlp_cookiejar(args): import yt_dlp from yt_dlp import parse_options + logger.debug(f"Extracting cookies from settings: {args[1]}") # parse_options returns a named tuple as follows, we only need the ydl_options part # collections.namedtuple('ParsedOptions', ('parser', 'options', 'urls', 'ydl_opts')) - ytdlp_opts = getattr(parse_options(args), 'ydl_opts') + ytdlp_opts = getattr(parse_options(args), "ydl_opts") return yt_dlp.YoutubeDL(ytdlp_opts).cookiejar - # get the cookies jar, prefer the browser cookies than the file - if 'cookies_from_browser' in self.authentication: - authdict['cookies_from_browser'] = self.authentication['cookies_from_browser'] - if extract_cookies: - authdict['cookies_jar'] = get_ytdlp_cookiejar(['--cookies-from-browser', self.authentication['cookies_from_browser']]) - elif 'cookies_file' in self.authentication: - authdict['cookies_file'] = self.authentication['cookies_file'] - if extract_cookies: - authdict['cookies_jar'] = get_ytdlp_cookiejar(['--cookies', self.authentication['cookies_file']]) - + get_cookiejar_options = None + + # order of priority: + # 1. cookies_from_browser setting in site config + # 2. cookies_file setting in site config + # 3. cookies_from_browser setting in global config + # 4. cookies_file setting in global config + + if "cookies_from_browser" in authdict: + get_cookiejar_options = ["--cookies-from-browser", authdict["cookies_from_browser"]] + elif "cookies_file" in authdict: + get_cookiejar_options = ["--cookies", authdict["cookies_file"]] + elif "cookies_from_browser" in self.authentication: + authdict["cookies_from_browser"] = self.authentication["cookies_from_browser"] + get_cookiejar_options = ["--cookies-from-browser", self.authentication["cookies_from_browser"]] + elif "cookies_file" in self.authentication: + authdict["cookies_file"] = self.authentication["cookies_file"] + get_cookiejar_options = ["--cookies", self.authentication["cookies_file"]] + + if get_cookiejar_options: + authdict["cookies_jar"] = get_ytdlp_cookiejar(get_cookiejar_options) + return authdict - + def repr(self): - return f"Module<'{self.display_name}' (config: {self.config[self.name]})>" \ No newline at end of file + return f"Module<'{self.display_name}' (config: {self.config[self.name]})>" diff --git a/src/auto_archiver/core/config.py b/src/auto_archiver/core/config.py index c3bc706..59c1eec 100644 --- a/src/auto_archiver/core/config.py +++ b/src/auto_archiver/core/config.py @@ -6,23 +6,27 @@ flexible setup in various environments. """ import argparse -from ruamel.yaml import YAML, CommentedMap, add_representer +from ruamel.yaml import YAML, CommentedMap +import json from loguru import logger from copy import deepcopy from auto_archiver.core.consts import MODULE_TYPES -from typing import Any, List, Type, Tuple _yaml: YAML = YAML() -EMPTY_CONFIG = _yaml.load(""" -# Auto Archiver Configuration -# Steps are the modules that will be run in the order they are defined +DEFAULT_CONFIG_FILE = "secrets/orchestration.yaml" -steps:""" + "".join([f"\n {module}s: []" for module in MODULE_TYPES]) + \ -""" +EMPTY_CONFIG = _yaml.load( + """ +# Auto Archiver Configuration + +# Steps are the modules that will be run in the order they are defined +steps:""" + + "".join([f"\n {module}s: []" for module in MODULE_TYPES]) + + """ # Global configuration @@ -49,11 +53,66 @@ authentication: {} logging: level: INFO -""") +""" +) # note: 'logging' is explicitly added above in order to better format the config file -class DefaultValidatingParser(argparse.ArgumentParser): +# Arg Parse Actions/Classes +class AuthenticationJsonParseAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + try: + auth_dict = json.loads(values) + setattr(namespace, self.dest, auth_dict) + except json.JSONDecodeError as e: + raise argparse.ArgumentTypeError(f"Invalid JSON input for argument '{self.dest}': {e}") from e + + def load_from_file(path): + try: + with open(path, "r") as f: + try: + auth_dict = json.load(f) + except json.JSONDecodeError: + f.seek(0) + # maybe it's yaml, try that + auth_dict = _yaml.load(f) + if auth_dict.get("authentication"): + auth_dict = auth_dict["authentication"] + auth_dict["load_from_file"] = path + return auth_dict + except Exception: + return None + + if isinstance(auth_dict, dict) and auth_dict.get("from_file"): + auth_dict = load_from_file(auth_dict["from_file"]) + elif isinstance(auth_dict, str): + # if it's a string + auth_dict = load_from_file(auth_dict) + + if not isinstance(auth_dict, dict): + raise argparse.ArgumentTypeError( + "Authentication must be a dictionary of site names and their authentication methods" + ) + global_options = ["cookies_from_browser", "cookies_file", "load_from_file"] + for key, auth in auth_dict.items(): + if key in global_options: + continue + if not isinstance(key, str) or not isinstance(auth, dict): + raise argparse.ArgumentTypeError( + f"Authentication must be a dictionary of site names and their authentication methods. Valid global configs are {global_options}" + ) + + setattr(namespace, self.dest, auth_dict) + + +class UniqueAppendAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + for value in values: + if value not in getattr(namespace, self.dest): + getattr(namespace, self.dest).append(value) + + +class DefaultValidatingParser(argparse.ArgumentParser): def error(self, message): """ Override of error to format a nicer looking error message using logger @@ -83,6 +142,9 @@ class DefaultValidatingParser(argparse.ArgumentParser): return super().parse_known_args(args, namespace) +# Config Utils + + def to_dot_notation(yaml_conf: CommentedMap | dict) -> dict: dotdict = {} @@ -96,6 +158,7 @@ def to_dot_notation(yaml_conf: CommentedMap | dict) -> dict: process_subdict(yaml_conf) return dotdict + def from_dot_notation(dotdict: dict) -> dict: normal_dict = {} @@ -116,9 +179,11 @@ def from_dot_notation(dotdict: dict) -> dict: def is_list_type(value): return isinstance(value, list) or isinstance(value, tuple) or isinstance(value, set) + def is_dict_type(value): return isinstance(value, dict) or isinstance(value, CommentedMap) + def merge_dicts(dotdict: dict, yaml_dict: CommentedMap) -> CommentedMap: yaml_dict: CommentedMap = deepcopy(yaml_dict) @@ -129,7 +194,7 @@ def merge_dicts(dotdict: dict, yaml_dict: CommentedMap) -> CommentedMap: yaml_subdict[key] = value continue - if key == 'steps': + if key == "steps": for module_type, modules in value.items(): # overwrite the 'steps' from the config file with the ones from the CLI yaml_subdict[key][module_type] = modules @@ -144,6 +209,7 @@ def merge_dicts(dotdict: dict, yaml_dict: CommentedMap) -> CommentedMap: update_dict(from_dot_notation(dotdict), yaml_dict) return yaml_dict + def read_yaml(yaml_filename: str) -> CommentedMap: config = None try: @@ -153,10 +219,11 @@ def read_yaml(yaml_filename: str) -> CommentedMap: pass if not config: - config = EMPTY_CONFIG - + config = deepcopy(EMPTY_CONFIG) + return config + # TODO: make this tidier/find a way to notify of which keys should not be stored @@ -164,10 +231,14 @@ def store_yaml(config: CommentedMap, yaml_filename: str) -> None: config_to_save = deepcopy(config) auth_dict = config_to_save.get("authentication", {}) - if auth_dict and auth_dict.get('load_from_file'): + if auth_dict and auth_dict.get("load_from_file"): # remove all other values from the config, don't want to store it in the config file auth_dict = {"load_from_file": auth_dict["load_from_file"]} - config_to_save.pop('urls', None) + config_to_save.pop("urls", None) with open(yaml_filename, "w", encoding="utf-8") as outf: - _yaml.dump(config_to_save, outf) \ No newline at end of file + _yaml.dump(config_to_save, outf) + + +def is_valid_config(config: CommentedMap) -> bool: + return config and config != EMPTY_CONFIG diff --git a/src/auto_archiver/core/consts.py b/src/auto_archiver/core/consts.py index 0fb81fb..3b99496 100644 --- a/src/auto_archiver/core/consts.py +++ b/src/auto_archiver/core/consts.py @@ -1,23 +1,19 @@ +class SetupError(ValueError): + pass -MODULE_TYPES = [ - 'feeder', - 'extractor', - 'enricher', - 'database', - 'storage', - 'formatter' -] + +MODULE_TYPES = ["feeder", "extractor", "enricher", "database", "storage", "formatter"] MANIFEST_FILE = "__manifest__.py" DEFAULT_MANIFEST = { - 'name': '', # the display name of the module - 'author': 'Bellingcat', # creator of the module, leave this as Bellingcat or set your own name! - 'type': [], # the type of the module, can be one or more of MODULE_TYPES - 'requires_setup': True, # whether or not this module requires additional setup such as setting API Keys or installing additional softare - 'description': '', # a description of the module - 'dependencies': {}, # external dependencies, e.g. python packages or binaries, in dictionary format - 'entry_point': '', # the entry point for the module, in the format 'module_name::ClassName'. This can be left blank to use the default entry point of module_name::ModuleName - 'version': '1.0', # the version of the module - 'configs': {} # any configuration options this module has, these will be exposed to the user in the config file or via the command line -} \ No newline at end of file + "name": "", # the display name of the module + "author": "Bellingcat", # creator of the module, leave this as Bellingcat or set your own name! + "type": [], # the type of the module, can be one or more of MODULE_TYPES + "requires_setup": True, # whether or not this module requires additional setup such as setting API Keys or installing additional software + "description": "", # a description of the module + "dependencies": {}, # external dependencies, e.g. python packages or binaries, in dictionary format + "entry_point": "", # the entry point for the module, in the format 'module_name::ClassName'. This can be left blank to use the default entry point of module_name::ModuleName + "version": "1.0", # the version of the module + "configs": {}, # any configuration options this module has, these will be exposed to the user in the config file or via the command line +} diff --git a/src/auto_archiver/core/database.py b/src/auto_archiver/core/database.py index a6e76e5..85575c1 100644 --- a/src/auto_archiver/core/database.py +++ b/src/auto_archiver/core/database.py @@ -1,6 +1,6 @@ """ Database module for the auto-archiver that defines the interface for implementing database modules -in the media archiving framework. +in the media archiving framework. """ from __future__ import annotations @@ -9,6 +9,7 @@ from typing import Union from auto_archiver.core import Metadata, BaseModule + class Database(BaseModule): """ Base class for implementing database modules in the media archiving framework. @@ -20,7 +21,7 @@ class Database(BaseModule): """signals the DB that the given item archival has started""" pass - def failed(self, item: Metadata, reason:str) -> None: + def failed(self, item: Metadata, reason: str) -> None: """update DB accordingly for failure""" pass @@ -34,6 +35,6 @@ class Database(BaseModule): return False @abstractmethod - def done(self, item: Metadata, cached: bool=False) -> None: + def done(self, item: Metadata, cached: bool = False) -> None: """archival result ready - should be saved to DB""" pass diff --git a/src/auto_archiver/core/enricher.py b/src/auto_archiver/core/enricher.py index 45e75d7..9b8e19a 100644 --- a/src/auto_archiver/core/enricher.py +++ b/src/auto_archiver/core/enricher.py @@ -8,13 +8,15 @@ the archiving step and before storage or formatting. Enrichers are optional but highly useful for making the archived data more powerful. """ + from __future__ import annotations from abc import abstractmethod from auto_archiver.core import Metadata, BaseModule + class Enricher(BaseModule): - """Base classes and utilities for enrichers in the Auto-Archiver system. - + """Base classes and utilities for enrichers in the Auto Archiver system. + Enricher modules must implement the `enrich` method to define their behavior. """ diff --git a/src/auto_archiver/core/extractor.py b/src/auto_archiver/core/extractor.py index 484a09d..cf42f1e 100644 --- a/src/auto_archiver/core/extractor.py +++ b/src/auto_archiver/core/extractor.py @@ -1,17 +1,15 @@ -""" The `extractor` module defines the base functionality for implementing extractors in the media archiving framework. - This class provides common utility methods and a standard interface for extractors. +"""The `extractor` module defines the base functionality for implementing extractors in the media archiving framework. +This class provides common utility methods and a standard interface for extractors. - Factory method to initialize an extractor instance based on its name. +Factory method to initialize an extractor instance based on its name. """ + from __future__ import annotations -from pathlib import Path from abc import abstractmethod -from dataclasses import dataclass import mimetypes import os -import mimetypes import requests from loguru import logger from retrying import retry @@ -39,7 +37,7 @@ class Extractor(BaseModule): Used to clean unnecessary URL parameters OR unfurl redirect links """ return url - + def match_link(self, url: str) -> re.Match: """ Returns a match object if the given URL matches the valid_url pattern or False/None if not. @@ -58,7 +56,7 @@ class Extractor(BaseModule): """ if self.valid_url: return self.match_link(url) is not None - + return True def _guess_file_type(self, path: str) -> str: @@ -74,16 +72,17 @@ class Extractor(BaseModule): @retry(wait_random_min=500, wait_random_max=3500, stop_max_attempt_number=5) def download_from_url(self, url: str, to_filename: str = None, verbose=True) -> str: """ - downloads a URL to provided filename, or inferred from URL, returns local filename + downloads a URL to provided filename, or inferred from URL, returns local filename """ if not to_filename: - to_filename = url.split('/')[-1].split('?')[0] + to_filename = url.split("/")[-1].split("?")[0] if len(to_filename) > 64: to_filename = to_filename[-64:] to_filename = os.path.join(self.tmp_dir, to_filename) - if verbose: logger.debug(f"downloading {url[0:50]=} {to_filename=}") + if verbose: + logger.debug(f"downloading {url[0:50]=} {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' + "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" } try: d = requests.get(url, stream=True, headers=headers, timeout=30) @@ -91,12 +90,12 @@ class Extractor(BaseModule): # get mimetype from the response headers if not mimetypes.guess_type(to_filename)[0]: - content_type = d.headers.get('Content-Type') or self._guess_file_type(url) + content_type = d.headers.get("Content-Type") or self._guess_file_type(url) extension = mimetypes.guess_extension(content_type) if extension: to_filename += extension - with open(to_filename, 'wb') as f: + with open(to_filename, "wb") as f: for chunk in d.iter_content(chunk_size=8192): f.write(chunk) return to_filename @@ -108,8 +107,8 @@ class Extractor(BaseModule): def download(self, item: Metadata) -> Metadata | False: """ Downloads the media from the given URL and returns a Metadata object with the downloaded media. - + If the URL is not supported or the download fails, this method should return False. """ - pass \ No newline at end of file + pass diff --git a/src/auto_archiver/core/feeder.py b/src/auto_archiver/core/feeder.py index e8302e6..dfcddb9 100644 --- a/src/auto_archiver/core/feeder.py +++ b/src/auto_archiver/core/feeder.py @@ -1,5 +1,5 @@ """ -The feeder base module defines the interface for implementing feeders in the media archiving framework. +The feeder base module defines the interface for implementing feeders in the media archiving framework. """ from __future__ import annotations @@ -7,8 +7,8 @@ from abc import abstractmethod from auto_archiver.core import Metadata from auto_archiver.core import BaseModule -class Feeder(BaseModule): +class Feeder(BaseModule): """ Base class for implementing feeders in the media archiving framework. @@ -19,7 +19,7 @@ class Feeder(BaseModule): def __iter__(self) -> Metadata: """ Returns an iterator (use `yield`) over the items to be archived. - + These should be instances of Metadata, typically created with Metadata().set_url(url). """ - return None \ No newline at end of file + return None diff --git a/src/auto_archiver/core/formatter.py b/src/auto_archiver/core/formatter.py index 3bfc250..0c63c7f 100644 --- a/src/auto_archiver/core/formatter.py +++ b/src/auto_archiver/core/formatter.py @@ -12,7 +12,7 @@ from auto_archiver.core import Metadata, Media, BaseModule class Formatter(BaseModule): """ Base class for implementing formatters in the media archiving framework. - + Subclasses must implement the `format` method to define their behavior. """ @@ -21,4 +21,4 @@ class Formatter(BaseModule): """ Formats a Metadata object into a user-viewable format (e.g. HTML) and stores it if needed. """ - return None \ No newline at end of file + return None diff --git a/src/auto_archiver/core/media.py b/src/auto_archiver/core/media.py index b6820ab..826b920 100644 --- a/src/auto_archiver/core/media.py +++ b/src/auto_archiver/core/media.py @@ -6,7 +6,7 @@ nested media retrieval, and type validation. from __future__ import annotations import os import traceback -from typing import Any, List +from typing import Any, List, Iterator from dataclasses import dataclass, field from dataclasses_json import dataclass_json, config import mimetypes @@ -21,14 +21,14 @@ class Media: Represents a media file with associated properties and storage details. Attributes: - - filename: The file path of the media. - - key: An optional identifier for the media. + - filename: The file path of the media as saved locally (temporarily, before uploading to the storage). - urls: A list of URLs where the media is stored or accessible. - properties: Additional metadata or transformations for the media. - _mimetype: The media's mimetype (e.g., image/jpeg, video/mp4). """ + filename: str - key: str = None + _key: str = None urls: List[str] = field(default_factory=list) properties: dict = field(default_factory=dict) _mimetype: str = None # eg: image/jpeg @@ -47,19 +47,20 @@ class Media: for any_media in self.all_inner_media(include_self=True): s.store(any_media, url, metadata=metadata) - def all_inner_media(self, include_self=False): + def all_inner_media(self, include_self=False) -> Iterator[Media]: """Retrieves all media, including nested media within properties or transformations on original media. This function returns a generator for all the inner media. """ - if include_self: yield self + if include_self: + yield self for prop in self.properties.values(): - if isinstance(prop, Media): + if isinstance(prop, Media): for inner_media in prop.all_inner_media(include_self=True): yield inner_media if isinstance(prop, list): for prop_media in prop: - if isinstance(prop_media, Media): + if isinstance(prop_media, Media): for inner_media in prop_media.all_inner_media(include_self=True): yield inner_media @@ -67,6 +68,10 @@ class Media: # checks if the media is already stored in the given storage return len(self.urls) > 0 and len(self.urls) == len(in_storage.config["steps"]["storages"]) + @property + def key(self) -> str: + return self._key + def set(self, key: str, value: Any) -> Media: self.properties[key] = value return self @@ -110,15 +115,17 @@ class Media: # checks for video streams with ffmpeg, or min file size for a video # self.is_video() should be used together with this method try: - streams = ffmpeg.probe(self.filename, select_streams='v')['streams'] + streams = ffmpeg.probe(self.filename, select_streams="v")["streams"] logger.warning(f"STREAMS FOR {self.filename} {streams}") return any(s.get("duration_ts", 0) > 0 for s in streams) - except Error: return False # ffmpeg errors when reading bad files + except Error: + return False # ffmpeg errors when reading bad files except Exception as e: logger.error(e) logger.error(traceback.format_exc()) try: fsize = os.path.getsize(self.filename) return fsize > 20_000 - except: pass + except Exception as e: + pass return True diff --git a/src/auto_archiver/core/metadata.py b/src/auto_archiver/core/metadata.py index a8d2ad4..bbb124d 100644 --- a/src/auto_archiver/core/metadata.py +++ b/src/auto_archiver/core/metadata.py @@ -13,7 +13,7 @@ from __future__ import annotations import hashlib from typing import Any, List, Union, Dict from dataclasses import dataclass, field -from dataclasses_json import dataclass_json, config +from dataclasses_json import dataclass_json import datetime from urllib.parse import urlparse from dateutil.parser import parse as parse_dt @@ -21,6 +21,7 @@ from loguru import logger from .media import Media + @dataclass_json # annotation order matters @dataclass class Metadata: @@ -40,19 +41,23 @@ class Metadata: - If `True`, this instance's values are overwritten by `right`. - If `False`, the inverse applies. """ - if not right: return self + if not right: + return self if overwrite_left: if right.status and len(right.status): self.status = right.status self._context.update(right._context) 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: + assert k not in self.metadata or type(v) is type(self.get(k)) + if not isinstance(v, (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) + if isinstance(v, (dict, set)): + self.set(k, self.get(k) | v) + elif type(v) is 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 @@ -69,7 +74,7 @@ class Metadata: def append(self, key: str, val: Any) -> Metadata: if key not in self.metadata: - self.metadata[key] = [] + self.metadata[key] = [] self.metadata[key] = val return self @@ -80,24 +85,26 @@ class Metadata: return self.metadata.get(key, default) def success(self, context: str = None) -> Metadata: - if context: self.status = f"{context}: success" - else: self.status = "success" + 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: - meaningfull_ids = set(self.metadata.keys()) - set(["_processed_at", "url", "total_bytes", "total_size", "archive_duration_seconds"]) + meaningfull_ids = set(self.metadata.keys()) - set( + ["_processed_at", "url", "total_bytes", "total_size", "archive_duration_seconds"] + ) return not self.is_success() and len(self.media) == 0 and len(meaningfull_ids) == 0 @property # getter .netloc def netloc(self) -> str: return urlparse(self.get_url()).netloc - -# custom getter/setters - + # custom getter/setters def set_url(self, url: str) -> Metadata: assert type(url) is str and len(url) > 0, "invalid URL" @@ -120,36 +127,43 @@ class Metadata: return self.get("title") def set_timestamp(self, timestamp: datetime.datetime) -> Metadata: - if type(timestamp) == str: + if isinstance(timestamp, str): timestamp = parse_dt(timestamp) - assert type(timestamp) == datetime.datetime, "set_timestamp expects a datetime instance" + assert isinstance(timestamp, datetime.datetime), "set_timestamp expects a datetime instance" return self.set("timestamp", timestamp) - def get_timestamp(self, utc=True, iso=True) -> datetime.datetime: + def get_timestamp(self, utc=True, iso=True) -> datetime.datetime | str | None: ts = self.get("timestamp") - if not ts: return + if not ts: + return None try: - if type(ts) == str: ts = datetime.datetime.fromisoformat(ts) - if type(ts) == float: ts = datetime.datetime.fromtimestamp(ts) - if utc: ts = ts.replace(tzinfo=datetime.timezone.utc) - if iso: return ts.isoformat() - return ts + if isinstance(ts, str): + ts = datetime.datetime.fromisoformat(ts) + elif isinstance(ts, float): + ts = datetime.datetime.fromtimestamp(ts) + if utc: + ts = ts.replace(tzinfo=datetime.timezone.utc) + return ts.isoformat() if iso else ts except Exception as e: logger.error(f"Unable to parse timestamp {ts}: {e}") - return + return None def add_media(self, media: Media, id: str = None) -> Metadata: # adds a new media, optionally including an id - if media is None: return + 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}" + 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 + if m.get("id") == id: + return m return default def remove_duplicate_media_by_hash(self) -> None: @@ -159,7 +173,8 @@ class Metadata: with open(filename, "rb") as f: while True: buf = f.read(chunksize) - if not buf: break + if not buf: + break hash_algo.update(buf) return hash_algo.hexdigest() @@ -167,15 +182,18 @@ class Metadata: new_media = [] for m in self.media: h = m.get("hash") - if not h: h = calculate_hash_in_chunks(hashlib.sha256(), int(1.6e7), m.filename) - if len(h) and h in media_hashes: continue + if not h: + h = calculate_hash_in_chunks(hashlib.sha256(), int(1.6e7), m.filename) + if len(h) and h in media_hashes: + continue media_hashes.add(h) new_media.append(m) self.media = new_media def get_first_image(self, default=None) -> Media: for m in self.media: - if "image" in m.mimetype: return m + if "image" in m.mimetype: + return m return default def set_final_media(self, final: Media) -> Metadata: @@ -193,22 +211,25 @@ class Metadata: def __str__(self) -> str: return self.__repr__() - @staticmethod def choose_most_complete(results: List[Metadata]) -> Metadata: # returns the most complete result from a list of results # prioritizes results with more media, then more metadata - if len(results) == 0: return None - if len(results) == 1: return results[0] + if len(results) == 0: + return None + if len(results) == 1: + return results[0] most_complete = results[0] for r in results[1:]: - if len(r.media) > len(most_complete.media): most_complete = r - elif len(r.media) == len(most_complete.media) and len(r.metadata) > len(most_complete.metadata): most_complete = r + if len(r.media) > len(most_complete.media): + most_complete = r + elif len(r.media) == len(most_complete.media) and len(r.metadata) > len(most_complete.metadata): + most_complete = r return most_complete def set_context(self, key: str, val: Any) -> Metadata: self._context[key] = val return self - + def get_context(self, key: str, default: Any = None) -> Any: - return self._context.get(key, default) \ No newline at end of file + return self._context.get(key, default) diff --git a/src/auto_archiver/core/module.py b/src/auto_archiver/core/module.py index 9556621..903a4ab 100644 --- a/src/auto_archiver/core/module.py +++ b/src/auto_archiver/core/module.py @@ -3,10 +3,11 @@ Defines the Step abstract base class, which acts as a blueprint for steps in the by handling user configuration, validating the steps properties, and implementing dynamic instantiation. """ + from __future__ import annotations from dataclasses import dataclass -from typing import List, TYPE_CHECKING +from typing import List, TYPE_CHECKING, Type import shutil import ast import copy @@ -24,17 +25,17 @@ if TYPE_CHECKING: HAS_SETUP_PATHS = False -class ModuleFactory: +class ModuleFactory: def __init__(self): self._lazy_modules = {} def setup_paths(self, paths: list[str]) -> None: """ Sets up the paths for the modules to be loaded from - + This is necessary for the modules to be imported correctly - + """ global HAS_SETUP_PATHS @@ -46,45 +47,51 @@ class ModuleFactory: # see odoo/module/module.py -> initialize_sys_path if path not in auto_archiver.modules.__path__: - if HAS_SETUP_PATHS == True: - logger.warning(f"You are attempting to re-initialise the module paths with: '{path}' for a 2nd time. \ + if HAS_SETUP_PATHS: + logger.warning( + f"You are attempting to re-initialise the module paths with: '{path}' for a 2nd time. \ This could lead to unexpected behaviour. It is recommended to only use a single modules path. \ - If you wish to load modules from different paths then load a 2nd python interpreter (e.g. using multiprocessing).") - auto_archiver.modules.__path__.append(path) + If you wish to load modules from different paths then load a 2nd python interpreter (e.g. using multiprocessing)." + ) + auto_archiver.modules.__path__.append(path) # sort based on the length of the path, so that the longest path is last in the list auto_archiver.modules.__path__ = sorted(auto_archiver.modules.__path__, key=len, reverse=True) HAS_SETUP_PATHS = True - def get_module(self, module_name: str, config: dict) -> BaseModule: + def get_module(self, module_name: str, config: dict) -> Type[BaseModule]: """ Gets and sets up a module using the provided config - + This will actually load and instantiate the module, and load all its dependencies (i.e. not lazy) - + """ return self.get_module_lazy(module_name).load(config) def get_module_lazy(self, module_name: str, suppress_warnings: bool = False) -> LazyBaseModule: """ Lazily loads a module, returning a LazyBaseModule - + This has all the information about the module, but does not load the module itself or its dependencies - + To load an actual module, call .setup() on a lazy module - + """ if module_name in self._lazy_modules: return self._lazy_modules[module_name] available = self.available_modules(limit_to_modules=[module_name], suppress_warnings=suppress_warnings) if not available: - raise IndexError(f"Module '{module_name}' not found. Are you sure it's installed/exists?") + message = f"Module '{module_name}' not found. Are you sure it's installed/exists?" + if "archiver" in module_name: + message += f" Did you mean {module_name.replace('archiver', 'extractor')}?" + raise IndexError(message) return available[0] - def available_modules(self, limit_to_modules: List[str]= [], suppress_warnings: bool = False) -> List[LazyBaseModule]: - + def available_modules( + self, limit_to_modules: List[str] = [], suppress_warnings: bool = False + ) -> List[LazyBaseModule]: # search through all valid 'modules' paths. Default is 'modules' in the current directory # see odoo/modules/module.py -> get_modules @@ -116,7 +123,7 @@ class ModuleFactory: self._lazy_modules[possible_module] = lazy_module all_modules.append(lazy_module) - + if not suppress_warnings: for module in limit_to_modules: if not any(module == m.name for m in all_modules): @@ -124,17 +131,17 @@ class ModuleFactory: return all_modules + @dataclass class LazyBaseModule: - """ A lazy module class, which only loads the manifest and does not load the module itself. This is useful for getting information about a module without actually loading it. """ + name: str - type: list description: str path: str module_factory: ModuleFactory @@ -148,28 +155,32 @@ class LazyBaseModule: self.path = path self.module_factory = factory + @property + def type(self): + return self.manifest["type"] + @property def entry_point(self): - if not self._entry_point and not self.manifest['entry_point']: + if not self._entry_point and not self.manifest["entry_point"]: # try to create the entry point from the module name self._entry_point = f"{self.name}::{self.name.replace('_', ' ').title().replace(' ', '')}" return self._entry_point @property def dependencies(self) -> dict: - return self.manifest['dependencies'] - + return self.manifest["dependencies"] + @property def configs(self) -> dict: - return self.manifest['configs'] - + return self.manifest["configs"] + @property def requires_setup(self) -> bool: - return self.manifest['requires_setup'] - + return self.manifest["requires_setup"] + @property def display_name(self) -> str: - return self.manifest['name'] + return self.manifest["name"] @property def manifest(self) -> dict: @@ -183,18 +194,16 @@ class LazyBaseModule: try: manifest.update(ast.literal_eval(f.read())) except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError) as e: - logger.error(f"Error loading manifest from file {self.path}/{MANIFEST_FILE}: {e}") - + raise ValueError(f"Error loading manifest from file {self.path}/{MANIFEST_FILE}: {e}") from e + self._manifest = manifest - self.type = manifest['type'] - self._entry_point = manifest['entry_point'] - self.description = manifest['description'] - self.version = manifest['version'] + self._entry_point = manifest["entry_point"] + self.description = manifest["description"] + self.version = manifest["version"] return manifest def load(self, config) -> BaseModule: - if self._instance: return self._instance @@ -205,8 +214,10 @@ class LazyBaseModule: # clear out any empty strings that a user may have erroneously added continue if not check(dep): - logger.error(f"Module '{self.name}' requires external dependency '{dep}' which is not available/setup. \ - Have you installed the required dependencies for the '{self.name}' module? See the README for more information.") + logger.error( + f"Module '{self.name}' requires external dependency '{dep}' which is not available/setup. \ + Have you installed the required dependencies for the '{self.name}' module? See the README for more information." + ) exit(1) def check_python_dep(dep): @@ -214,10 +225,10 @@ class LazyBaseModule: try: m = self.module_factory.get_module_lazy(dep, suppress_warnings=True) try: - # we must now load this module and set it up with the config + # we must now load this module and set it up with the config m.load(config) return True - except: + except Exception: logger.error(f"Unable to setup module '{dep}' for use in module '{self.name}'") return False except IndexError: @@ -226,13 +237,12 @@ class LazyBaseModule: return find_spec(dep) - check_deps(self.dependencies.get('python', []), check_python_dep) - check_deps(self.dependencies.get('bin', []), lambda dep: shutil.which(dep)) - + check_deps(self.dependencies.get("python", []), check_python_dep) + check_deps(self.dependencies.get("bin", []), lambda dep: shutil.which(dep)) logger.debug(f"Loading module '{self.display_name}'...") - for qualname in [self.name, f'auto_archiver.modules.{self.name}']: + for qualname in [self.name, f"auto_archiver.modules.{self.name}"]: try: # first import the whole module, to make sure it's working properly __import__(qualname) @@ -241,10 +251,10 @@ class LazyBaseModule: pass # then import the file for the entry point - file_name, class_name = self.entry_point.split('::') - sub_qualname = f'{qualname}.{file_name}' + file_name, class_name = self.entry_point.split("::") + sub_qualname = f"{qualname}.{file_name}" - __import__(f'{qualname}.{file_name}', fromlist=[self.entry_point]) + __import__(f"{qualname}.{file_name}", fromlist=[self.entry_point]) # finally, get the class instance instance: BaseModule = getattr(sys.modules[sub_qualname], class_name)() @@ -252,11 +262,11 @@ class LazyBaseModule: instance.name = self.name instance.display_name = self.display_name instance.module_factory = self.module_factory - - # merge the default config with the user config - default_config = dict((k, v['default']) for k, v in self.configs.items() if v.get('default')) - config[self.name] = default_config | config.get(self.name, {}) + # merge the default config with the user config + default_config = dict((k, v["default"]) for k, v in self.configs.items() if "default" in v) + + config[self.name] = default_config | config.get(self.name, {}) instance.config_setup(config) instance.setup() @@ -265,4 +275,4 @@ class LazyBaseModule: return instance def __repr__(self): - return f"Module<'{self.display_name}' ({self.name})>" \ No newline at end of file + return f"Module<'{self.display_name}' ({self.name})>" diff --git a/src/auto_archiver/core/orchestrator.py b/src/auto_archiver/core/orchestrator.py index 10d9215..672994a 100644 --- a/src/auto_archiver/core/orchestrator.py +++ b/src/auto_archiver/core/orchestrator.py @@ -1,102 +1,46 @@ -""" Orchestrates all archiving steps, including feeding items, - archiving them with specific archivers, enrichment, storage, - formatting, database operations and clean up. +"""Orchestrates all archiving steps, including feeding items, +archiving them with specific archivers, enrichment, storage, +formatting, database operations and clean up. """ from __future__ import annotations from typing import Generator, Union, List, Type, TYPE_CHECKING -from urllib.parse import urlparse -from ipaddress import ip_address -from copy import copy import argparse import os import sys -import json from tempfile import TemporaryDirectory import traceback +from copy import copy from rich_argparse import RichHelpFormatter - +from loguru import logger +import requests from .metadata import Metadata, Media from auto_archiver.version import __version__ -from .config import _yaml, read_yaml, store_yaml, to_dot_notation, merge_dicts, EMPTY_CONFIG, DefaultValidatingParser +from .config import ( + read_yaml, + store_yaml, + to_dot_notation, + merge_dicts, + is_valid_config, + DefaultValidatingParser, + UniqueAppendAction, + AuthenticationJsonParseAction, + DEFAULT_CONFIG_FILE, +) from .module import ModuleFactory, LazyBaseModule from . import validators, Feeder, Extractor, Database, Storage, Formatter, Enricher -from .consts import MODULE_TYPES -from loguru import logger +from .consts import MODULE_TYPES, SetupError +from auto_archiver.utils.url import check_url_or_raise if TYPE_CHECKING: from .base_module import BaseModule from .module import LazyBaseModule -DEFAULT_CONFIG_FILE = "orchestration.yaml" - - -class JsonParseAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - try: - setattr(namespace, self.dest, json.loads(values)) - except json.JSONDecodeError as e: - raise argparse.ArgumentTypeError(f"Invalid JSON input for argument '{self.dest}': {e}") - - -class AuthenticationJsonParseAction(JsonParseAction): - def __call__(self, parser, namespace, values, option_string=None): - super().__call__(parser, namespace, values, option_string) - auth_dict = getattr(namespace, self.dest) - - def load_from_file(path): - try: - with open(path, 'r') as f: - try: - auth_dict = json.load(f) - except json.JSONDecodeError: - f.seek(0) - # maybe it's yaml, try that - auth_dict = _yaml.load(f) - if auth_dict.get('authentication'): - auth_dict = auth_dict['authentication'] - auth_dict['load_from_file'] = path - return auth_dict - except: - return None - - if isinstance(auth_dict, dict) and auth_dict.get('from_file'): - auth_dict = load_from_file(auth_dict['from_file']) - elif isinstance(auth_dict, str): - # if it's a string - auth_dict = load_from_file(auth_dict) - - if not isinstance(auth_dict, dict): - raise argparse.ArgumentTypeError("Authentication must be a dictionary of site names and their authentication methods") - global_options = ['cookies_from_browser', 'cookies_file', 'load_from_file'] - for key, auth in auth_dict.items(): - if key in global_options: - continue - if not isinstance(key, str) or not isinstance(auth, dict): - raise argparse.ArgumentTypeError(f"Authentication must be a dictionary of site names and their authentication methods. Valid global configs are {global_options}") - - # extract out concatenated sites - for key, val in copy(auth_dict).items(): - if "," in key: - for site in key.split(","): - auth_dict[site] = val - del auth_dict[key] - - setattr(namespace, self.dest, auth_dict) - - -class UniqueAppendAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - for value in values: - if value not in getattr(namespace, self.dest): - getattr(namespace, self.dest).append(value) - class ArchivingOrchestrator: - # instance variables module_factory: ModuleFactory setup_finished: bool @@ -126,20 +70,63 @@ class ArchivingOrchestrator: epilog="Check the code at https://github.com/bellingcat/auto-archiver", formatter_class=RichHelpFormatter, ) - parser.add_argument('--help', '-h', action='store_true', dest='help', help='show a full help message and exit') - parser.add_argument('--version', action='version', version=__version__) - parser.add_argument('--config', action='store', dest="config_file", help='the filename of the YAML configuration file (defaults to \'config.yaml\')', default=DEFAULT_CONFIG_FILE) - parser.add_argument('--mode', action='store', dest='mode', type=str, choices=['simple', 'full'], help='the mode to run the archiver in', default='simple') + parser.add_argument("--help", "-h", action="store_true", dest="help", help="show a full help message and exit") + parser.add_argument("--version", action="version", version=__version__) + parser.add_argument( + "--config", + action="store", + dest="config_file", + help="the filename of the YAML configuration file (defaults to 'config.yaml')", + default=DEFAULT_CONFIG_FILE, + ) + parser.add_argument( + "--mode", + action="store", + dest="mode", + type=str, + choices=["simple", "full"], + help="the mode to run the archiver in", + default="simple", + ) # override the default 'help' so we can inject all the configs and show those - parser.add_argument('-s', '--store', dest='store', default=False, help='Store the created config in the config file', action=argparse.BooleanOptionalAction) - parser.add_argument('--module_paths', dest='module_paths', nargs='+', default=[], help='additional paths to search for modules', action=UniqueAppendAction) + parser.add_argument( + "-s", + "--store", + dest="store", + default=False, + help="Store the created config in the config file", + action=argparse.BooleanOptionalAction, + ) + parser.add_argument( + "--module_paths", + dest="module_paths", + nargs="+", + default=[], + help="additional paths to search for modules", + action=UniqueAppendAction, + ) self.basic_parser = parser return parser + def check_steps(self, config): + for module_type in MODULE_TYPES: + if not config["steps"].get(f"{module_type}s", []): + if module_type == "feeder" or module_type == "formatter" and config["steps"].get(f"{module_type}"): + raise SetupError( + f"It appears you have '{module_type}' set under 'steps' in your configuration file, but as of version 0.13.0 of Auto Archiver, you must use '{module_type}s'. Change this in your configuration file and try again. \ +Here's how that would look: \n\nsteps:\n {module_type}s:\n - [your_{module_type}_name_here]\n {'extractors:...' if module_type == 'feeder' else '...'}\n" + ) + if module_type == "extractor" and config["steps"].get("archivers"): + raise SetupError( + "As of version 0.13.0 of Auto Archiver, the 'archivers' step name has been changed to 'extractors'. Change this in your configuration file and try again. \ +Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_here]\n enrichers:...\n" + ) + raise SetupError( + f"No {module_type}s were configured. Make sure to set at least one {module_type} in your configuration file or on the command line (using --{module_type}s)" + ) + def setup_complete_parser(self, basic_config: dict, yaml_config: dict, unused_args: list[str]) -> None: - - # modules parser to get the overridden 'steps' values modules_parser = argparse.ArgumentParser( add_help=False, @@ -147,7 +134,9 @@ class ArchivingOrchestrator: self.add_modules_args(modules_parser) cli_modules, unused_args = modules_parser.parse_known_args(unused_args) for module_type in MODULE_TYPES: - yaml_config['steps'][f"{module_type}s"] = getattr(cli_modules, f"{module_type}s", []) or yaml_config['steps'].get(f"{module_type}s", []) + yaml_config["steps"][f"{module_type}s"] = getattr(cli_modules, f"{module_type}s", []) or yaml_config[ + "steps" + ].get(f"{module_type}s", []) parser = DefaultValidatingParser( add_help=False, @@ -163,34 +152,39 @@ class ArchivingOrchestrator: # TODO: BUG** - basic_config won't have steps in it, since these args aren't added to 'basic_parser' # but should we add them? Or should we just add them to the 'complete' parser? - if yaml_config != EMPTY_CONFIG: + if is_valid_config(yaml_config): + self.check_steps(yaml_config) # only load the modules enabled in config # TODO: if some steps are empty (e.g. 'feeders' is empty), should we default to the 'simple' ones? Or only if they are ALL empty? enabled_modules = [] # first loads the modules from the config file, then from the command line for module_type in MODULE_TYPES: - enabled_modules.extend(yaml_config['steps'].get(f"{module_type}s", [])) + enabled_modules.extend(yaml_config["steps"].get(f"{module_type}s", [])) # clear out duplicates, but keep the order enabled_modules = list(dict.fromkeys(enabled_modules)) - avail_modules = self.module_factory.available_modules(limit_to_modules=enabled_modules, suppress_warnings=True) + avail_modules = self.module_factory.available_modules( + limit_to_modules=enabled_modules, suppress_warnings=True + ) self.add_individual_module_args(avail_modules, parser) - elif basic_config.mode == 'simple': + elif basic_config.mode == "simple": simple_modules = [module for module in self.module_factory.available_modules() if not module.requires_setup] self.add_individual_module_args(simple_modules, parser) - # for simple mode, we use the cli_feeder and any modules that don't require setup - if not yaml_config['steps']['feeders']: - yaml_config['steps']['feeders'] = ['cli_feeder'] - # add them to the config for module in simple_modules: for module_type in module.type: - yaml_config['steps'].setdefault(f"{module_type}s", []).append(module.name) + yaml_config["steps"].setdefault(f"{module_type}s", []).append(module.name) else: # load all modules, they're not using the 'simple' mode - self.add_individual_module_args(self.module_factory.available_modules(), parser) - + all_modules = self.module_factory.available_modules() + # add all the modules to the steps + for module in all_modules: + for module_type in module.type: + yaml_config["steps"].setdefault(f"{module_type}s", []).append(module.name) + + self.add_individual_module_args(all_modules, parser) + parser.set_defaults(**to_dot_notation(yaml_config)) # reload the parser with the new arguments, now that we have them @@ -198,6 +192,9 @@ class ArchivingOrchestrator: # merge the new config with the old one config = merge_dicts(vars(parsed), yaml_config) + # set up the authentication dict as needed + config = self.setup_authentication(config) + # clean out args from the base_parser that we don't want in the config for key in vars(basic_config): config.pop(key, None) @@ -213,41 +210,75 @@ class ArchivingOrchestrator: store_yaml(config, basic_config.config_file) return config - + def add_modules_args(self, parser: argparse.ArgumentParser = None): if not parser: parser = self.parser # Module loading from the command line for module_type in MODULE_TYPES: - parser.add_argument(f'--{module_type}s', dest=f'{module_type}s', nargs='+', help=f'the {module_type}s to use', default=[], action=UniqueAppendAction) + parser.add_argument( + f"--{module_type}s", + dest=f"{module_type}s", + nargs="+", + help=f"the {module_type}s to use", + default=[], + action=UniqueAppendAction, + ) def add_additional_args(self, parser: argparse.ArgumentParser = None): if not parser: parser = self.parser - # allow passing URLs directly on the command line - parser.add_argument('urls', nargs='*', default=[], help='URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml') - - parser.add_argument('--authentication', dest='authentication', help='A dictionary of sites and their authentication methods \ + parser.add_argument( + "--authentication", + dest="authentication", + help="A dictionary of sites and their authentication methods \ (token, username etc.) that extractors can use to log into \ a website. If passing this on the command line, use a JSON string. \ - You may also pass a path to a valid JSON/YAML file which will be parsed.', - default={}, - nargs="?", - action=AuthenticationJsonParseAction) + You may also pass a path to a valid JSON/YAML file which will be parsed.", + default={}, + nargs="?", + action=AuthenticationJsonParseAction, + ) # logging arguments - parser.add_argument('--logging.level', action='store', dest='logging.level', choices=['INFO', 'DEBUG', 'ERROR', 'WARNING'], help='the logging level to use', default='INFO', type=str.upper) - parser.add_argument('--logging.file', action='store', dest='logging.file', help='the logging file to write to', default=None) - parser.add_argument('--logging.rotation', action='store', dest='logging.rotation', help='the logging rotation to use', default=None) - - def add_individual_module_args(self, modules: list[LazyBaseModule] = None, parser: argparse.ArgumentParser = None) -> None: + parser.add_argument( + "--logging.level", + action="store", + dest="logging.level", + choices=["INFO", "DEBUG", "ERROR", "WARNING"], + help="the logging level to use", + default="INFO", + type=str.upper, + ) + parser.add_argument( + "--logging.file", action="store", dest="logging.file", help="the logging file to write to", default=None + ) + parser.add_argument( + "--logging.rotation", + action="store", + dest="logging.rotation", + help="the logging rotation to use", + default=None, + ) + def add_individual_module_args( + self, modules: list[LazyBaseModule] = None, parser: argparse.ArgumentParser = None + ) -> None: if not modules: modules = self.module_factory.available_modules() - + for module in modules: + if module.name == "cli_feeder": + # special case. For the CLI feeder, allow passing URLs directly on the command line without setting --cli_feeder.urls= + parser.add_argument( + "urls", + nargs="*", + default=[], + help="URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml", + ) + continue if not module.configs: # this module has no configs, don't show anything in the help @@ -257,21 +288,21 @@ class ArchivingOrchestrator: group = parser.add_argument_group(module.display_name or module.name, f"{module.description[:100]}...") for name, kwargs in module.configs.items(): - if not kwargs.get('metavar', None): + if not kwargs.get("metavar", None): # make a nicer metavar, metavar is what's used in the help, e.g. --cli_feeder.urls [METAVAR] - kwargs['metavar'] = name.upper() + kwargs["metavar"] = name.upper() - if kwargs.get('required', False): + if kwargs.get("required", False): # required args shouldn't have a 'default' value, remove it - kwargs.pop('default', None) + kwargs.pop("default", None) - kwargs.pop('cli_set', None) - should_store = kwargs.pop('should_store', False) - kwargs['dest'] = f"{module.name}.{kwargs.pop('dest', name)}" + kwargs.pop("cli_set", None) + should_store = kwargs.pop("should_store", False) + kwargs["dest"] = f"{module.name}.{kwargs.pop('dest', name)}" try: - kwargs['type'] = getattr(validators, kwargs.get('type', '__invalid__')) + kwargs["type"] = getattr(validators, kwargs.get("type", "__invalid__")) except AttributeError: - kwargs['type'] = __builtins__.get(kwargs.get('type'), str) + kwargs["type"] = __builtins__.get(kwargs.get("type"), str) arg = group.add_argument(f"--{module.name}.{name}", **kwargs) arg.should_store = should_store @@ -286,79 +317,70 @@ class ArchivingOrchestrator: self.basic_parser.exit() def setup_logging(self, config): + logging_config = config["logging"] + + if logging_config.get("enabled", True) is False: + # disabled logging settings, they're set on a higher level + logger.disable("auto_archiver") + return + # setup loguru logging try: logger.remove(0) # remove the default logger except ValueError: pass - logging_config = config['logging'] - # add other logging info - if self.logger_id is None: # note - need direct comparison to None since need to consider falsy value 0 - self.logger_id = logger.add(sys.stderr, level=logging_config['level']) - if log_file := logging_config['file']: - logger.add(log_file) if not logging_config['rotation'] else logger.add(log_file, rotation=logging_config['rotation']) + if self.logger_id is None: # note - need direct comparison to None since need to consider falsy value 0 + self.logger_id = logger.add(sys.stderr, level=logging_config["level"]) + if log_file := logging_config["file"]: + logger.add(log_file) if not logging_config["rotation"] else logger.add( + log_file, rotation=logging_config["rotation"] + ) def install_modules(self, modules_by_type): """ - Traverses all modules in 'steps' and loads them into the orchestrator, storing them in the + Traverses all modules in 'steps' and loads them into the orchestrator, storing them in the orchestrator's attributes (self.feeders, self.extractors etc.). If no modules of a certain type are loaded, the program will exit with an error message. """ invalid_modules = [] for module_type in MODULE_TYPES: - step_items = [] modules_to_load = modules_by_type[f"{module_type}s"] - assert modules_to_load, f"No {module_type}s were configured. Make sure to set at least one {module_type} in your configuration file or on the command line (using --{module_type}s)" + if not modules_to_load: + raise SetupError( + f"No {module_type}s were configured. Make sure to set at least one {module_type} in your configuration file or on the command line (using --{module_type}s)" + ) def check_steps_ok(): if not len(step_items): - logger.error(f"NO {module_type.upper()}S LOADED. Please check your configuration and try again.") if len(modules_to_load): - logger.error(f"Tried to load the following modules, but none were available: {modules_to_load}") - exit() + logger.error( + f"Unable to load any {module_type}s. Tried the following, but none were available: {modules_to_load}" + ) + raise SetupError( + f"NO {module_type.upper()}S LOADED. Please check your configuration and try again." + ) - if (module_type == 'feeder' or module_type == 'formatter') and len(step_items) > 1: - logger.error(f"Only one {module_type} is allowed, found {len(step_items)} {module_type}s. Please remove one of the following from your configuration file: {modules_to_load}") - exit() + if (module_type == "feeder" or module_type == "formatter") and len(step_items) > 1: + raise SetupError( + f"Only one {module_type} is allowed, found {len(step_items)} {module_type}s. Please remove one of the following from your configuration file: {modules_to_load}" + ) for module in modules_to_load: - if module == 'cli_feeder': - # pseudo module, don't load it - urls = self.config['urls'] - if not urls: - logger.error("No URLs provided. Please provide at least one URL via the command line, or set up an alternative feeder. Use --help for more information.") - exit() - # cli_feeder is a pseudo module, it just takes the command line args - - def feed(self) -> Generator[Metadata]: - for url in urls: - logger.debug(f"Processing URL: '{url}'") - yield Metadata().set_url(url) - - pseudo_module = type('CLIFeeder', (Feeder,), { - 'name': 'cli_feeder', - 'display_name': 'CLI Feeder', - '__iter__': feed - - })() - - pseudo_module.__iter__ = feed - step_items.append(pseudo_module) - continue - if module in invalid_modules: continue + + loaded_module = None try: loaded_module: BaseModule = self.module_factory.get_module(module, self.config) except (KeyboardInterrupt, Exception) as e: logger.error(f"Error during setup of modules: {e}\n{traceback.format_exc()}") - if module_type == 'extractor' and loaded_module.name == module: + if loaded_module and module_type == "extractor": loaded_module.cleanup() - exit() + raise e if not loaded_module: invalid_modules.append(module) @@ -371,11 +393,13 @@ class ArchivingOrchestrator: def load_config(self, config_file: str) -> dict: if not os.path.exists(config_file) and config_file != DEFAULT_CONFIG_FILE: - logger.error(f"The configuration file {config_file} was not found. Make sure the file exists and try again, or run without the --config file to use the default settings.") - exit() + logger.error( + f"The configuration file {config_file} was not found. Make sure the file exists and try again, or run without the --config file to use the default settings." + ) + raise FileNotFoundError(f"Configuration file {config_file} not found") return read_yaml(config_file) - + def setup_config(self, args: list) -> dict: """ Sets up the configuration file, merging the default config with the user's config @@ -399,30 +423,51 @@ class ArchivingOrchestrator: return self.setup_complete_parser(basic_config, yaml_config, unused_args) + def check_for_updates(self): + response = requests.get("https://pypi.org/pypi/auto-archiver/json").json() + latest_version = response["info"]["version"] + # check version compared to current version + if latest_version != __version__: + if os.environ.get("RUNNING_IN_DOCKER"): + update_cmd = "`docker pull bellingcat/auto-archiver:latest`" + else: + update_cmd = "`pip install --upgrade auto-archiver`" + logger.warning("") + logger.warning("********* IMPORTANT: UPDATE AVAILABLE ********") + logger.warning(f"A new version of auto-archiver is available (v{latest_version}, you have {__version__})") + logger.warning(f"Make sure to update to the latest version using: {update_cmd}") + logger.warning("") + def setup(self, args: list): """ Function to configure all setup of the orchestrator: setup configs and load modules. - + This method should only ever be called once """ + self.check_for_updates() + if self.setup_finished: - logger.warning("The `setup_config()` function should only ever be run once. \ + logger.warning( + "The `setup_config()` function should only ever be run once. \ If you need to re-run the setup, please re-instantiate a new instance of the orchestrator. \ For code implementatations, you should call .setup_config() once then you may call .feed() \ - multiple times to archive multiple URLs.") + multiple times to archive multiple URLs." + ) return self.setup_basic_parser() self.config = self.setup_config(args) logger.info(f"======== Welcome to the AUTO ARCHIVER ({__version__}) ==========") - self.install_modules(self.config['steps']) + self.install_modules(self.config["steps"]) # log out the modules that were loaded for module_type in MODULE_TYPES: - logger.info(f"{module_type.upper()}S: " + ", ".join(m.display_name for m in getattr(self, f"{module_type}s"))) - + logger.info( + f"{module_type.upper()}S: " + ", ".join(m.display_name for m in getattr(self, f"{module_type}s")) + ) + self.setup_finished = True def _command_line_run(self, args: list) -> Generator[Metadata]: @@ -430,15 +475,19 @@ class ArchivingOrchestrator: This is the main entry point for the orchestrator, when run from the command line. :param args: list of arguments to pass to the orchestrator - these are the command line args - + You should not call this method from code implementations. - + This method sets up the configuration, loads the modules, and runs the feed. If you wish to make code invocations yourself, you should use the 'setup' and 'feed' methods separately. To test configurations, without loading any modules you can also first call 'setup_configs' """ - self.setup(args) - return self.feed() + try: + self.setup(args) + return self.feed() + except Exception as e: + logger.error(e) + exit(1) def cleanup(self) -> None: logger.info("Cleaning up") @@ -446,7 +495,6 @@ class ArchivingOrchestrator: e.cleanup() def feed(self) -> Generator[Metadata]: - url_count = 0 for feeder in self.feeders: for item in feeder: @@ -477,9 +525,9 @@ class ArchivingOrchestrator: self.cleanup() exit() except Exception as e: - logger.error(f'Got unexpected error on item {item}: {e}\n{traceback.format_exc()}') + logger.error(f"Got unexpected error on item {item}: {e}\n{traceback.format_exc()}") for d in self.databases: - if type(e) == AssertionError: + if isinstance(e, AssertionError): d.failed(item, str(e)) else: d.failed(item, reason="unexpected error") @@ -492,19 +540,19 @@ class ArchivingOrchestrator: def archive(self, result: Metadata) -> Union[Metadata, None]: """ - Runs the archiving process for a single URL - 1. Each archiver can sanitize its own URLs - 2. Check for cached results in Databases, and signal start to the databases - 3. Call Archivers until one succeeds - 4. Call Enrichers - 5. Store all downloaded/generated media - 6. Call selected Formatter and store formatted if needed + Runs the archiving process for a single URL + 1. Each archiver can sanitize its own URLs + 2. Check for cached results in Databases, and signal start to the databases + 3. Call Archivers until one succeeds + 4. Call Enrichers + 5. Store all downloaded/generated media + 6. Call selected Formatter and store formatted if needed """ original_url = result.get_url().strip() try: - self.assert_valid_url(original_url) - except AssertionError as e: + check_url_or_raise(original_url) + except ValueError as e: logger.error(f"Error archiving URL {original_url}: {e}") raise e @@ -514,7 +562,8 @@ class ArchivingOrchestrator: url = a.sanitize_url(url) result.set_url(url) - if original_url != url: result.set("original_url", original_url) + if original_url != url: + result.set("original_url", original_url) # 2 - notify start to DBs, propagate already archived if feature enabled in DBs cached_result = None @@ -525,7 +574,8 @@ class ArchivingOrchestrator: if cached_result: logger.debug("Found previously archived entry") for d in self.databases: - try: d.done(cached_result, cached=True) + try: + d.done(cached_result, cached=True) except Exception as e: logger.error(f"ERROR database {d.name}: {e}: {traceback.format_exc()}") return cached_result @@ -535,13 +585,15 @@ class ArchivingOrchestrator: logger.info(f"Trying extractor {a.name} for {url}") try: result.merge(a.download(result)) - if result.is_success(): break + if result.is_success(): + break except Exception as e: logger.error(f"ERROR archiver {a.name}: {e}: {traceback.format_exc()}") # 4 - call enrichers to work with archived content for e in self.enrichers: - try: e.enrich(result) + try: + e.enrich(result) except Exception as exc: logger.error(f"ERROR enricher {e.name}: {exc}: {traceback.format_exc()}") @@ -559,31 +611,32 @@ class ArchivingOrchestrator: # signal completion to databases and archivers for d in self.databases: - try: d.done(result) + try: + d.done(result) except Exception as e: logger.error(f"ERROR database {d.name}: {e}: {traceback.format_exc()}") return result - def assert_valid_url(self, url: str) -> bool: + def setup_authentication(self, config: dict) -> dict: """ - Blocks localhost, private, reserved, and link-local IPs and all non-http/https schemes. + Setup authentication for all modules that require it + + Split up strings into multiple sites if they are comma separated """ - assert url.startswith("http://") or url.startswith("https://"), f"Invalid URL scheme" - parsed = urlparse(url) - assert parsed.scheme in ["http", "https"], f"Invalid URL scheme" - assert parsed.hostname, f"Invalid URL hostname" - assert parsed.hostname != "localhost", f"Invalid URL" + authentication = config.get("authentication", {}) - try: # special rules for IP addresses - ip = ip_address(parsed.hostname) - except ValueError: pass - else: - assert ip.is_global, f"Invalid IP used" - assert not ip.is_reserved, f"Invalid IP used" - assert not ip.is_link_local, f"Invalid IP used" - assert not ip.is_private, f"Invalid IP used" + # extract out concatenated sites + for key, val in copy(authentication).items(): + if "," in key: + for site in key.split(","): + site = site.strip() + authentication[site] = val + del authentication[key] + + config["authentication"] = authentication + return config # Helper Properties diff --git a/src/auto_archiver/core/storage.py b/src/auto_archiver/core/storage.py index 1535eab..3205f5a 100644 --- a/src/auto_archiver/core/storage.py +++ b/src/auto_archiver/core/storage.py @@ -1,5 +1,22 @@ """ Base module for Storage modules – modular components that store media objects in various locations. + +If you are looking to implement a new storage module, you should subclass the `Storage` class and +implement the `get_cdn_url` and `uploadf` methods. + +Your module **must** also have two config variables 'path_generator' and 'filename_generator' which +determine how the key is generated for the media object. The 'path_generator' and 'filename_generator' +variables can be set to one of the following values: +- 'flat': A flat structure with no subfolders +- 'url': A structure based on the URL of the media object +- 'random': A random structure + +The 'filename_generator' variable can be set to one of the following values: +- 'random': A random string +- 'static': A replicable strategy such as a hash + +If you don't want to use this naming convention, you can override the `set_key` method in your subclass. + """ from __future__ import annotations @@ -15,18 +32,19 @@ from auto_archiver.utils.misc import random_str from auto_archiver.core import Media, BaseModule, Metadata from auto_archiver.modules.hash_enricher.hash_enricher import HashEnricher + class Storage(BaseModule): - """ Base class for implementing storage modules in the media archiving framework. Subclasses must implement the `get_cdn_url` and `uploadf` methods to define their behavior. """ - def store(self, media: Media, url: str, metadata: Metadata=None) -> None: - if media.is_stored(in_storage=self): + def store(self, media: Media, url: str, metadata: Metadata = None) -> None: + if media.is_stored(in_storage=self): logger.debug(f"{media.key} already stored, skipping") return + self.set_key(media, url, metadata) self.upload(media, metadata=metadata) media.add_url(self.get_cdn_url(media)) @@ -42,42 +60,55 @@ class Storage(BaseModule): def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool: """ Uploads (or saves) a file to the storage service/location. + + This method should not be called directly, but instead through the 'store' method, + which sets up the media for storage. """ 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: + """ + Uploads a media object to the storage service. + + This method should not be called directly, but instead be called through the 'store' method, + which sets up the media for storage. + """ + 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, metadata: Metadata) -> None: + def set_key(self, media: Media, url: str, metadata: Metadata) -> 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 = metadata.get_context('folder', '') + + if media.key is not None and len(media.key) > 0: + # media key is already set + return + + folder = metadata.get_context("folder", "") filename, ext = os.path.splitext(media.filename) # Handle path_generator logic - path_generator = self.config.get("path_generator", "url") + path_generator = self.path_generator if path_generator == "flat": path = "" - filename = slugify(filename) # Ensure filename is slugified elif path_generator == "url": - path = slugify(url) + path = slugify(url)[:70] elif path_generator == "random": - path = self.config.get("random_path", random_str(24), True) + path = random_str(24) else: raise ValueError(f"Invalid path_generator: {path_generator}") # Handle filename_generator logic - filename_generator = self.config.get("filename_generator", "random") + filename_generator = self.filename_generator if filename_generator == "random": filename = random_str(24) elif filename_generator == "static": # load the hash_enricher module - he = self.module_factory.get_module(HashEnricher, self.config) + he: HashEnricher = self.module_factory.get_module("hash_enricher", self.config) hd = he.calculate_hash(media.filename) filename = hd[:24] else: raise ValueError(f"Invalid filename_generator: {filename_generator}") - media.key = os.path.join(folder, path, f"{filename}{ext}") + key = os.path.join(folder, path, f"{filename}{ext}") + media._key = key diff --git a/src/auto_archiver/core/validators.py b/src/auto_archiver/core/validators.py index b868ddf..765a02b 100644 --- a/src/auto_archiver/core/validators.py +++ b/src/auto_archiver/core/validators.py @@ -1,12 +1,15 @@ # used as validators for config values. Should raise an exception if the value is invalid. from pathlib import Path import argparse +import json + def example_validator(value): if "example" not in value: raise argparse.ArgumentTypeError(f"{value} is not a valid value for this argument") return value + def positive_number(value): if value < 0: raise argparse.ArgumentTypeError(f"{value} is not a positive number") @@ -16,4 +19,8 @@ def positive_number(value): def valid_file(value): if not Path(value).is_file(): raise argparse.ArgumentTypeError(f"File '{value}' does not exist.") - return value \ No newline at end of file + return value + + +def json_loader(cli_val): + return json.loads(cli_val) diff --git a/src/auto_archiver/modules/api_db/__init__.py b/src/auto_archiver/modules/api_db/__init__.py index a4f39a1..e73511d 100644 --- a/src/auto_archiver/modules/api_db/__init__.py +++ b/src/auto_archiver/modules/api_db/__init__.py @@ -1 +1 @@ -from .api_db import AAApiDb \ No newline at end of file +from .api_db import AAApiDb diff --git a/src/auto_archiver/modules/api_db/__manifest__.py b/src/auto_archiver/modules/api_db/__manifest__.py index 8359174..66dfae9 100644 --- a/src/auto_archiver/modules/api_db/__manifest__.py +++ b/src/auto_archiver/modules/api_db/__manifest__.py @@ -1,5 +1,5 @@ { - "name": "Auto-Archiver API Database", + "name": "Auto Archiver API Database", "type": ["database"], "entry_point": "api_db::AAApiDb", "requires_setup": True, @@ -11,8 +11,7 @@ "required": True, "help": "API endpoint where calls are made to", }, - "api_token": {"default": None, - "help": "API Bearer token."}, + "api_token": {"default": None, "help": "API Bearer token."}, "public": { "default": False, "type": "bool", @@ -24,9 +23,9 @@ "help": "which group of users have access to the archive in case public=false as author", }, "use_api_cache": { - "default": True, + "default": False, "type": "bool", - "help": "if False then the API database will be queried prior to any archiving operations and stop if the link has already been archived", + "help": "if True then the API database will be queried prior to any archiving operations and stop if the link has already been archived", }, "store_results": { "default": True, @@ -39,7 +38,7 @@ }, }, "description": """ - Provides integration with the Auto-Archiver API for querying and storing archival data. + Provides integration with the Auto Archiver API for querying and storing archival data. ### Features - **API Integration**: Supports querying for existing archives and submitting results. @@ -49,6 +48,6 @@ - **Optional Storage**: Archives results conditionally based on configuration. ### Setup -Requires access to an Auto-Archiver API instance and a valid API token. +Requires access to an Auto Archiver API instance and a valid API token. """, } diff --git a/src/auto_archiver/modules/api_db/api_db.py b/src/auto_archiver/modules/api_db/api_db.py index 753ff3f..c422248 100644 --- a/src/auto_archiver/modules/api_db/api_db.py +++ b/src/auto_archiver/modules/api_db/api_db.py @@ -12,10 +12,11 @@ class AAApiDb(Database): """Connects to auto-archiver-api instance""" def fetch(self, item: Metadata) -> Union[Metadata, bool]: - """ query the database for the existence of this item. - Helps avoid re-archiving the same URL multiple times. + """query the database for the existence of this item. + Helps avoid re-archiving the same URL multiple times. """ - if not self.use_api_cache: return + if not self.use_api_cache: + return params = {"url": item.get_url(), "limit": 15} headers = {"Authorization": f"Bearer {self.api_token}", "accept": "application/json"} @@ -32,22 +33,25 @@ class AAApiDb(Database): def done(self, item: Metadata, cached: bool = False) -> None: """archival result ready - should be saved to DB""" - if not self.store_results: return + if not self.store_results: + return if cached: logger.debug(f"skipping saving archive of {item.get_url()} to the AA API because it was cached") return logger.debug(f"saving archive of {item.get_url()} to the AA API.") payload = { - 'author_id': self.author_id, - 'url': item.get_url(), - 'public': self.public, - 'group_id': self.group_id, - 'tags': list(self.tags), - 'result': item.to_json(), + "author_id": self.author_id, + "url": item.get_url(), + "public": self.public, + "group_id": self.group_id, + "tags": list(self.tags), + "result": item.to_json(), } headers = {"Authorization": f"Bearer {self.api_token}"} - response = requests.post(os.path.join(self.api_endpoint, "interop/submit-archive"), json=payload, headers=headers) + response = requests.post( + os.path.join(self.api_endpoint, "interop/submit-archive"), json=payload, headers=headers + ) if response.status_code == 201: logger.success(f"AA API: {response.json()}") diff --git a/src/auto_archiver/modules/atlos_db/__init__.py b/src/auto_archiver/modules/atlos_db/__init__.py deleted file mode 100644 index e14d202..0000000 --- a/src/auto_archiver/modules/atlos_db/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .atlos_db import AtlosDb \ No newline at end of file diff --git a/src/auto_archiver/modules/atlos_db/__manifest__.py b/src/auto_archiver/modules/atlos_db/__manifest__.py deleted file mode 100644 index d23ff23..0000000 --- a/src/auto_archiver/modules/atlos_db/__manifest__.py +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "Atlos Database", - "type": ["database"], - "entry_point": "atlos_db::AtlosDb", - "requires_setup": True, - "dependencies": - {"python": ["loguru", - ""], - "bin": [""]}, - "configs": { - "api_token": { - "default": None, - "help": "An Atlos API token. For more information, see https://docs.atlos.org/technical/api/", - "required": True, - "type": "str", - }, - "atlos_url": { - "default": "https://platform.atlos.org", - "help": "The URL of your Atlos instance (e.g., https://platform.atlos.org), without a trailing slash.", - "type": "str" - }, - }, - "description": """ -Handles integration with the Atlos platform for managing archival results. - -### Features -- Outputs archival results to the Atlos API for storage and tracking. -- Updates failure status with error details when archiving fails. -- Processes and formats metadata, including ISO formatting for datetime fields. -- Skips processing for items without an Atlos ID. - -### Setup -Required configs: -- atlos_url: Base URL for the Atlos API. -- api_token: Authentication token for API access. -""" -, -} diff --git a/src/auto_archiver/modules/atlos_db/atlos_db.py b/src/auto_archiver/modules/atlos_db/atlos_db.py deleted file mode 100644 index baa9fef..0000000 --- a/src/auto_archiver/modules/atlos_db/atlos_db.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import Union - -import requests -from loguru import logger - -from auto_archiver.core import Database -from auto_archiver.core import Metadata - - -class AtlosDb(Database): - """ - Outputs results to Atlos - """ - - def failed(self, item: Metadata, reason: str) -> None: - """Update DB accordingly for failure""" - # If the item has no Atlos ID, there's nothing for us to do - if not item.metadata.get("atlos_id"): - logger.info(f"Item {item.get_url()} has no Atlos ID, skipping") - return - - requests.post( - f"{self.atlos_url}/api/v2/source_material/metadata/{item.metadata['atlos_id']}/auto_archiver", - headers={"Authorization": f"Bearer {self.api_token}"}, - json={"metadata": {"processed": True, "status": "error", "error": reason}}, - ).raise_for_status() - logger.info( - f"Stored failure for {item.get_url()} (ID {item.metadata['atlos_id']}) on Atlos: {reason}" - ) - - def fetch(self, item: Metadata) -> Union[Metadata, bool]: - """check and fetch if the given item has been archived already, each - database should handle its own caching, and configuration mechanisms""" - return False - - def _process_metadata(self, item: Metadata) -> dict: - """Process metadata for storage on Atlos. Will convert any datetime - objects to ISO format.""" - - return { - k: v.isoformat() if hasattr(v, "isoformat") else v - for k, v in item.metadata.items() - } - - def done(self, item: Metadata, cached: bool = False) -> None: - """archival result ready - should be saved to DB""" - - if not item.metadata.get("atlos_id"): - logger.info(f"Item {item.get_url()} has no Atlos ID, skipping") - return - - requests.post( - f"{self.atlos_url}/api/v2/source_material/metadata/{item.metadata['atlos_id']}/auto_archiver", - headers={"Authorization": f"Bearer {self.api_token}"}, - json={ - "metadata": dict( - processed=True, - status="success", - results=self._process_metadata(item), - ) - }, - ).raise_for_status() - - logger.info( - f"Stored success for {item.get_url()} (ID {item.metadata['atlos_id']}) on Atlos" - ) diff --git a/src/auto_archiver/modules/atlos_feeder/__init__.py b/src/auto_archiver/modules/atlos_feeder/__init__.py deleted file mode 100644 index 67b243a..0000000 --- a/src/auto_archiver/modules/atlos_feeder/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .atlos_feeder import AtlosFeeder \ No newline at end of file diff --git a/src/auto_archiver/modules/atlos_feeder/__manifest__.py b/src/auto_archiver/modules/atlos_feeder/__manifest__.py deleted file mode 100644 index d59f420..0000000 --- a/src/auto_archiver/modules/atlos_feeder/__manifest__.py +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "Atlos Feeder", - "type": ["feeder"], - "requires_setup": True, - "dependencies": { - "python": ["loguru", "requests"], - }, - "configs": { - "api_token": { - "type": "str", - "required": True, - "help": "An Atlos API token. For more information, see https://docs.atlos.org/technical/api/", - }, - "atlos_url": { - "default": "https://platform.atlos.org", - "help": "The URL of your Atlos instance (e.g., https://platform.atlos.org), without a trailing slash.", - "type": "str" - }, - }, - "description": """ - AtlosFeeder: A feeder module that integrates with the Atlos API to fetch source material URLs for archival. - - ### Features - - Connects to the Atlos API to retrieve a list of source material URLs. - - Filters source materials based on visibility, processing status, and metadata. - - Converts filtered source materials into `Metadata` objects with the relevant `atlos_id` and URL. - - Iterates through paginated results using a cursor for efficient API interaction. - - ### Notes - - Requires an Atlos API endpoint and a valid API token for authentication. - - Ensures only unprocessed, visible, and ready-to-archive URLs are returned. - - Handles pagination transparently when retrieving data from the Atlos API. - """ -} diff --git a/src/auto_archiver/modules/atlos_feeder/atlos_feeder.py b/src/auto_archiver/modules/atlos_feeder/atlos_feeder.py deleted file mode 100644 index 8c8f9cb..0000000 --- a/src/auto_archiver/modules/atlos_feeder/atlos_feeder.py +++ /dev/null @@ -1,42 +0,0 @@ -import requests -from loguru import logger - -from auto_archiver.core import Feeder -from auto_archiver.core import Metadata - - -class AtlosFeeder(Feeder): - - def __iter__(self) -> Metadata: - # Get all the urls from the Atlos API - count = 0 - cursor = None - while True: - response = requests.get( - f"{self.atlos_url}/api/v2/source_material", - headers={"Authorization": f"Bearer {self.api_token}"}, - params={"cursor": cursor}, - ) - data = response.json() - response.raise_for_status() - cursor = data["next"] - - for item in data["results"]: - if ( - item["source_url"] not in [None, ""] - and ( - item["metadata"] - .get("auto_archiver", {}) - .get("processed", False) - != True - ) - and item["visibility"] == "visible" - and item["status"] not in ["processing", "pending"] - ): - yield Metadata().set_url(item["source_url"]).set( - "atlos_id", item["id"] - ) - count += 1 - - if len(data["results"]) == 0 or cursor is None: - break diff --git a/src/auto_archiver/modules/atlos_feeder_db_storage/__init__.py b/src/auto_archiver/modules/atlos_feeder_db_storage/__init__.py new file mode 100644 index 0000000..8ffadf6 --- /dev/null +++ b/src/auto_archiver/modules/atlos_feeder_db_storage/__init__.py @@ -0,0 +1 @@ +from .atlos_feeder_db_storage import AtlosFeederDbStorage diff --git a/src/auto_archiver/modules/atlos_feeder_db_storage/__manifest__.py b/src/auto_archiver/modules/atlos_feeder_db_storage/__manifest__.py new file mode 100644 index 0000000..eda3784 --- /dev/null +++ b/src/auto_archiver/modules/atlos_feeder_db_storage/__manifest__.py @@ -0,0 +1,46 @@ +{ + "name": "Atlos Feeder Database Storage", + "type": ["feeder", "database", "storage"], + "entry_point": "atlos_feeder_db_storage::AtlosFeederDbStorage", + "requires_setup": True, + "dependencies": { + "python": ["loguru", "requests"], + }, + "configs": { + "api_token": { + "type": "str", + "required": True, + "help": "An Atlos API token. For more information, see https://docs.atlos.org/technical/api/", + }, + "atlos_url": { + "default": "https://platform.atlos.org", + "help": "The URL of your Atlos instance (e.g., https://platform.atlos.org), without a trailing slash.", + "type": "str", + }, + }, + "description": """ + A module that integrates with the Atlos API to fetch source material URLs for archival, uplaod extracted media, + + [Atlos](https://www.atlos.org/) is a visual investigation and archiving platform designed for investigative research, journalism, and open-source intelligence (OSINT). + It helps users organize, analyze, and store media from various sources, making it easier to track and investigate digital evidence. + + To get started create a new project and obtain an API token from the settings page. You can group event's into Atlos's 'incidents'. + Here you can add 'source material' by URLn and the Atlos feeder will fetch these URLs for archival. + + You can use Atlos only as a 'feeder', however you can also implement the 'database' and 'storage' features to store the media files in Atlos which is recommended. + The Auto Archiver will retain the Atlos ID for each item, ensuring that the media and database outputs are uplaoded back into the relevant media item. + + + ### Features + - Connects to the Atlos API to retrieve a list of source material URLs. + - Iterates through the URLs from all source material items which are unprocessed, visible, and ready to archive. + - If the storage option is selected, it will store the media files alongside the original source material item in Atlos. + - Is the database option is selected it will output the results to the media item, as well as updating failure status with error details when archiving fails. + - Skips Storege/ database upload for items without an Atlos ID - restricting that you must use the Atlos feeder so that it has the Atlos ID to store the results with. + + ### Notes + - Requires an Atlos account with a project and a valid API token for authentication. + - Ensures only unprocessed, visible, and ready-to-archive URLs are returned. + - Feches any media items within an Atlos project, regardless of separation into incidents. + """, +} diff --git a/src/auto_archiver/modules/atlos_feeder_db_storage/atlos_feeder_db_storage.py b/src/auto_archiver/modules/atlos_feeder_db_storage/atlos_feeder_db_storage.py new file mode 100644 index 0000000..c84abd6 --- /dev/null +++ b/src/auto_archiver/modules/atlos_feeder_db_storage/atlos_feeder_db_storage.py @@ -0,0 +1,143 @@ +import hashlib +import os +from typing import IO, Iterator, Optional, Union + +import requests +from loguru import logger + +from auto_archiver.core import Database, Feeder, Media, Metadata, Storage +from auto_archiver.utils import calculate_file_hash + + +class AtlosFeederDbStorage(Feeder, Database, Storage): + def setup(self) -> requests.Session: + """create and return a persistent session.""" + self.session = requests.Session() + + def _get(self, endpoint: str, params: Optional[dict] = None) -> dict: + """Wrapper for GET requests to the Atlos API.""" + url = f"{self.atlos_url}{endpoint}" + response = self.session.get(url, headers={"Authorization": f"Bearer {self.api_token}"}, params=params) + response.raise_for_status() + return response.json() + + def _post( + self, + endpoint: str, + json: Optional[dict] = None, + params: Optional[dict] = None, + files: Optional[dict] = None, + ) -> dict: + """Wrapper for POST requests to the Atlos API.""" + url = f"{self.atlos_url}{endpoint}" + response = self.session.post( + url, + headers={"Authorization": f"Bearer {self.api_token}"}, + json=json, + params=params, + files=files, + ) + response.raise_for_status() + return response.json() + + # ! Atlos Module - Feeder Methods + + def __iter__(self) -> Iterator[Metadata]: + """Iterate over unprocessed, visible source materials from Atlos.""" + cursor = None + while True: + data = self._get("/api/v2/source_material", params={"cursor": cursor}) + cursor = data.get("next") + results = data.get("results", []) + for item in results: + if ( + item.get("source_url") not in [None, ""] + and not item.get("metadata", {}).get("auto_archiver", {}).get("processed", False) + and item.get("visibility") == "visible" + and item.get("status") not in ["processing", "pending"] + ): + yield Metadata().set_url(item["source_url"]).set("atlos_id", item["id"]) + if not results or cursor is None: + break + + # ! Atlos Module - Database Methods + + def failed(self, item: Metadata, reason: str) -> None: + """Mark an item as failed in Atlos, if the ID exists.""" + atlos_id = item.metadata.get("atlos_id") + if not atlos_id: + logger.info(f"Item {item.get_url()} has no Atlos ID, skipping") + return + self._post( + f"/api/v2/source_material/metadata/{atlos_id}/auto_archiver", + json={"metadata": {"processed": True, "status": "error", "error": reason}}, + ) + logger.info(f"Stored failure for {item.get_url()} (ID {atlos_id}) on Atlos: {reason}") + + def fetch(self, item: Metadata) -> Union[Metadata, bool]: + """check and fetch if the given item has been archived already, each + database should handle its own caching, and configuration mechanisms""" + return False + + def _process_metadata(self, item: Metadata) -> dict: + """Process metadata for storage on Atlos. Will convert any datetime + objects to ISO format.""" + return {k: v.isoformat() if hasattr(v, "isoformat") else v for k, v in item.metadata.items()} + + def done(self, item: Metadata, cached: bool = False) -> None: + """Mark an item as successfully archived in Atlos.""" + atlos_id = item.metadata.get("atlos_id") + if not atlos_id: + logger.info(f"Item {item.get_url()} has no Atlos ID, skipping") + return + self._post( + f"/api/v2/source_material/metadata/{atlos_id}/auto_archiver", + json={ + "metadata": { + "processed": True, + "status": "success", + "results": self._process_metadata(item), + } + }, + ) + logger.info(f"Stored success for {item.get_url()} (ID {atlos_id}) on Atlos") + + # ! Atlos Module - Storage Methods + + def get_cdn_url(self, _media: Media) -> str: + """Return the base Atlos URL as the CDN URL.""" + return self.atlos_url + + def upload(self, media: Media, metadata: Optional[Metadata] = None, **_kwargs) -> bool: + """Upload a media file to Atlos if it has not been uploaded already.""" + if metadata is None: + logger.error(f"No metadata provided for {media.filename}") + return False + + atlos_id = metadata.get("atlos_id") + if not atlos_id: + logger.error(f"No Atlos ID found in metadata; can't store {media.filename} in Atlos.") + return False + + media_hash = calculate_file_hash(media.filename, hash_algo=hashlib.sha256, chunksize=4096) + + # Check whether the media has already been uploaded + source_material = self._get(f"/api/v2/source_material/{atlos_id}")["result"] + existing_media = [artifact.get("file_hash_sha256") for artifact in source_material.get("artifacts", [])] + if media_hash in existing_media: + logger.info(f"{media.filename} with SHA256 {media_hash} already uploaded to Atlos") + return True + + # Upload the media to the Atlos API + with open(media.filename, "rb") as file_obj: + self._post( + f"/api/v2/source_material/upload/{atlos_id}", + params={"title": media.properties}, + files={"file": (os.path.basename(media.filename), file_obj)}, + ) + logger.info(f"Uploaded {media.filename} to Atlos with ID {atlos_id} and title {media.key}") + return True + + def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool: + """Upload a file-like object; not implemented.""" + pass diff --git a/src/auto_archiver/modules/atlos_storage/__init__.py b/src/auto_archiver/modules/atlos_storage/__init__.py deleted file mode 100644 index 9e815c7..0000000 --- a/src/auto_archiver/modules/atlos_storage/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .atlos_storage import AtlosStorage \ No newline at end of file diff --git a/src/auto_archiver/modules/atlos_storage/__manifest__.py b/src/auto_archiver/modules/atlos_storage/__manifest__.py deleted file mode 100644 index 55b5120..0000000 --- a/src/auto_archiver/modules/atlos_storage/__manifest__.py +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "Atlos Storage", - "type": ["storage"], - "requires_setup": True, - "dependencies": { - "python": ["loguru", "boto3"], - "bin": [] - }, - "description": """ - Stores media files in a [Atlos](https://www.atlos.org/). - - ### Features - - Saves media files to Atlos, organizing them into folders based on the provided path structure. - - ### Notes - - Requires setup with Atlos credentials. - - Files are uploaded to the specified `root_folder_id` and organized by the `media.key` structure. - """, - "configs": { - "api_token": { - "default": None, - "help": "An Atlos API token. For more information, see https://docs.atlos.org/technical/api/", - "required": True, - "type": "str" - }, - "atlos_url": { - "default": "https://platform.atlos.org", - "help": "The URL of your Atlos instance (e.g., https://platform.atlos.org), without a trailing slash.", - "type": "str" - }, - } -} \ No newline at end of file diff --git a/src/auto_archiver/modules/atlos_storage/atlos_storage.py b/src/auto_archiver/modules/atlos_storage/atlos_storage.py deleted file mode 100644 index f8eef68..0000000 --- a/src/auto_archiver/modules/atlos_storage/atlos_storage.py +++ /dev/null @@ -1,66 +0,0 @@ -import hashlib -import os -from typing import IO, Optional - -import requests -from loguru import logger - -from auto_archiver.core import Media, Metadata -from auto_archiver.core import Storage - - -class AtlosStorage(Storage): - - def get_cdn_url(self, _media: Media) -> str: - # It's not always possible to provide an exact URL, because it's - # possible that the media once uploaded could have been copied to - # another project. - return self.atlos_url - - def _hash(self, media: Media) -> str: - # Hash the media file using sha-256. We don't use the existing auto archiver - # hash because there's no guarantee that the configuerer is using sha-256, which - # is how Atlos hashes files. - - sha256 = hashlib.sha256() - with open(media.filename, "rb") as f: - while True: - buf = f.read(4096) - if not buf: break - sha256.update(buf) - return sha256.hexdigest() - - def upload(self, media: Media, metadata: Optional[Metadata]=None, **_kwargs) -> bool: - atlos_id = metadata.get("atlos_id") - if atlos_id is None: - logger.error(f"No Atlos ID found in metadata; can't store {media.filename} on Atlos") - return False - - media_hash = self._hash(media) - - # Check whether the media has already been uploaded - source_material = requests.get( - f"{self.atlos_url}/api/v2/source_material/{atlos_id}", - headers={"Authorization": f"Bearer {self.api_token}"}, - ).json()["result"] - existing_media = [x["file_hash_sha256"] for x in source_material.get("artifacts", [])] - if media_hash in existing_media: - logger.info(f"{media.filename} with SHA256 {media_hash} already uploaded to Atlos") - return True - - # Upload the media to the Atlos API - requests.post( - f"{self.atlos_url}/api/v2/source_material/upload/{atlos_id}", - headers={"Authorization": f"Bearer {self.api_token}"}, - params={ - "title": media.properties - }, - files={"file": (os.path.basename(media.filename), open(media.filename, "rb"))}, - ).raise_for_status() - - logger.info(f"Uploaded {media.filename} to Atlos with ID {atlos_id} and title {media.key}") - - 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/modules/cli_feeder/__manifest__.py b/src/auto_archiver/modules/cli_feeder/__manifest__.py new file mode 100644 index 0000000..f874405 --- /dev/null +++ b/src/auto_archiver/modules/cli_feeder/__manifest__.py @@ -0,0 +1,22 @@ +{ + "name": "Command Line Feeder", + "type": ["feeder"], + "entry_point": "cli_feeder::CLIFeeder", + "requires_setup": False, + "configs": { + "urls": { + "default": None, + "help": "URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml", + }, + }, + "description": """ +The Command Line Feeder is the default enabled feeder for the Auto Archiver. It allows you to pass URLs directly to the orchestrator from the command line +without the need to specify any additional configuration or command line arguments: + +`auto-archiver --feeder cli_feeder -- "https://example.com/1/,https://example.com/2/"` + +You can pass multiple URLs by separating them with a space. The URLs will be processed in the order they are provided. + +`auto-archiver --feeder cli_feeder -- https://example.com/1/ https://example.com/2/` +""", +} diff --git a/src/auto_archiver/modules/cli_feeder/cli_feeder.py b/src/auto_archiver/modules/cli_feeder/cli_feeder.py new file mode 100644 index 0000000..4367bbc --- /dev/null +++ b/src/auto_archiver/modules/cli_feeder/cli_feeder.py @@ -0,0 +1,22 @@ +from loguru import logger + +from auto_archiver.core.feeder import Feeder +from auto_archiver.core.metadata import Metadata + + +class CLIFeeder(Feeder): + def setup(self) -> None: + self.urls = self.config["urls"] + if not self.urls: + raise ValueError( + "No URLs provided. Please provide at least one URL via the command line, or set up an alternative feeder. Use --help for more information." + ) + + def __iter__(self) -> Metadata: + urls = self.config["urls"] + for url in urls: + logger.debug(f"Processing {url}") + m = Metadata().set_url(url) + yield m + + logger.success(f"Processed {len(urls)} URL(s)") diff --git a/src/auto_archiver/modules/console_db/__init__.py b/src/auto_archiver/modules/console_db/__init__.py index 343f09c..831beb1 100644 --- a/src/auto_archiver/modules/console_db/__init__.py +++ b/src/auto_archiver/modules/console_db/__init__.py @@ -1 +1 @@ -from .console_db import ConsoleDb \ No newline at end of file +from .console_db import ConsoleDb diff --git a/src/auto_archiver/modules/console_db/console_db.py b/src/auto_archiver/modules/console_db/console_db.py index 48609b0..c6711c5 100644 --- a/src/auto_archiver/modules/console_db/console_db.py +++ b/src/auto_archiver/modules/console_db/console_db.py @@ -6,18 +6,18 @@ from auto_archiver.core import Metadata class ConsoleDb(Database): """ - Outputs results to the console + Outputs results to the console """ def started(self, item: Metadata) -> None: - logger.warning(f"STARTED {item}") + logger.info(f"STARTED {item}") - def failed(self, item: Metadata, reason:str) -> None: + def failed(self, item: Metadata, reason: str) -> None: logger.error(f"FAILED {item}: {reason}") def aborted(self, item: Metadata) -> None: logger.warning(f"ABORTED {item}") - def done(self, item: Metadata, cached: bool=False) -> None: + def done(self, item: Metadata, cached: bool = False) -> None: """archival result ready - should be saved to DB""" - logger.success(f"DONE {item}") \ No newline at end of file + logger.success(f"DONE {item}") diff --git a/src/auto_archiver/modules/csv_db/__init__.py b/src/auto_archiver/modules/csv_db/__init__.py index 1092cb2..bc9eb85 100644 --- a/src/auto_archiver/modules/csv_db/__init__.py +++ b/src/auto_archiver/modules/csv_db/__init__.py @@ -1 +1 @@ -from .csv_db import CSVDb \ No newline at end of file +from .csv_db import CSVDb diff --git a/src/auto_archiver/modules/csv_db/__manifest__.py b/src/auto_archiver/modules/csv_db/__manifest__.py index 507ce14..0db9cc8 100644 --- a/src/auto_archiver/modules/csv_db/__manifest__.py +++ b/src/auto_archiver/modules/csv_db/__manifest__.py @@ -2,12 +2,11 @@ "name": "CSV Database", "type": ["database"], "requires_setup": False, - "dependencies": {"python": ["loguru"] - }, - 'entry_point': 'csv_db::CSVDb', + "dependencies": {"python": ["loguru"]}, + "entry_point": "csv_db::CSVDb", "configs": { - "csv_file": {"default": "db.csv", "help": "CSV file name"} - }, + "csv_file": {"default": "db.csv", "help": "CSV file name to save metadata to"}, + }, "description": """ Handles exporting archival results to a CSV file. diff --git a/src/auto_archiver/modules/csv_db/csv_db.py b/src/auto_archiver/modules/csv_db/csv_db.py index b5985e2..ac31027 100644 --- a/src/auto_archiver/modules/csv_db/csv_db.py +++ b/src/auto_archiver/modules/csv_db/csv_db.py @@ -9,14 +9,15 @@ from auto_archiver.core import Metadata class CSVDb(Database): """ - Outputs results to a CSV file + Outputs results to a CSV file """ - def done(self, item: Metadata, cached: bool=False) -> None: + def done(self, item: Metadata, cached: bool = False) -> 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() + if is_empty: + writer.writeheader() writer.writerow(asdict(item)) diff --git a/src/auto_archiver/modules/csv_feeder/__init__.py b/src/auto_archiver/modules/csv_feeder/__init__.py index 161b78d..14dbd75 100644 --- a/src/auto_archiver/modules/csv_feeder/__init__.py +++ b/src/auto_archiver/modules/csv_feeder/__init__.py @@ -1 +1 @@ -from .csv_feeder import CSVFeeder \ No newline at end of file +from .csv_feeder import CSVFeeder diff --git a/src/auto_archiver/modules/csv_feeder/__manifest__.py b/src/auto_archiver/modules/csv_feeder/__manifest__.py index 6d4c7bf..d6e8caa 100644 --- a/src/auto_archiver/modules/csv_feeder/__manifest__.py +++ b/src/auto_archiver/modules/csv_feeder/__manifest__.py @@ -1,27 +1,23 @@ { "name": "CSV Feeder", "type": ["feeder"], - "requires_setup": False, - "dependencies": { - "python": ["loguru"], - "bin": [""] - }, - 'requires_setup': True, - 'entry_point': "csv_feeder::CSVFeeder", + "dependencies": {"python": ["loguru"], "bin": [""]}, + "requires_setup": True, + "entry_point": "csv_feeder::CSVFeeder", "configs": { - "files": { - "default": None, - "help": "Path to the input file(s) to read the URLs from, comma separated. \ + "files": { + "default": None, + "help": "Path to the input file(s) to read the URLs from, comma separated. \ Input files should be formatted with one URL per line", - "required": True, - "type": "valid_file", - "nargs": "+", - }, - "column": { - "default": None, - "help": "Column number or name to read the URLs from, 0-indexed", - } + "required": True, + "type": "valid_file", + "nargs": "+", }, + "column": { + "default": None, + "help": "Column number or name to read the URLs from, 0-indexed", + }, + }, "description": """ Reads URLs from CSV files and feeds them into the archiving process. @@ -33,5 +29,5 @@ ### Setup - Input files should be formatted with one URL per line, with or without a header row. - If you have a header row, you can specify the column number or name to read URLs from using the 'column' config option. - """ + """, } diff --git a/src/auto_archiver/modules/csv_feeder/csv_feeder.py b/src/auto_archiver/modules/csv_feeder/csv_feeder.py index c3f6eea..9c72162 100644 --- a/src/auto_archiver/modules/csv_feeder/csv_feeder.py +++ b/src/auto_archiver/modules/csv_feeder/csv_feeder.py @@ -5,11 +5,10 @@ from auto_archiver.core import Feeder from auto_archiver.core import Metadata from auto_archiver.utils import url_or_none + class CSVFeeder(Feeder): - column = None - def __iter__(self) -> Metadata: for file in self.files: with open(file, "r") as f: @@ -20,9 +19,11 @@ class CSVFeeder(Feeder): try: url_column = first_row.index(url_column) except ValueError: - logger.error(f"Column {url_column} not found in header row: {first_row}. Did you set the 'column' config correctly?") + logger.error( + f"Column {url_column} not found in header row: {first_row}. Did you set the 'column' config correctly?" + ) return - elif not(url_or_none(first_row[url_column])): + elif not (url_or_none(first_row[url_column])): # it's a header row, but we've been given a column number already logger.debug(f"Skipping header row: {first_row}") else: @@ -35,4 +36,4 @@ class CSVFeeder(Feeder): continue url = row[url_column] logger.debug(f"Processing {url}") - yield Metadata().set_url(url) \ No newline at end of file + yield Metadata().set_url(url) diff --git a/src/auto_archiver/modules/gdrive_storage/__init__.py b/src/auto_archiver/modules/gdrive_storage/__init__.py index 2765e4b..bd326bb 100644 --- a/src/auto_archiver/modules/gdrive_storage/__init__.py +++ b/src/auto_archiver/modules/gdrive_storage/__init__.py @@ -1 +1 @@ -from .gdrive_storage import GDriveStorage \ No newline at end of file +from .gdrive_storage import GDriveStorage diff --git a/src/auto_archiver/modules/gdrive_storage/__manifest__.py b/src/auto_archiver/modules/gdrive_storage/__manifest__.py index 73784b8..f12380c 100644 --- a/src/auto_archiver/modules/gdrive_storage/__manifest__.py +++ b/src/auto_archiver/modules/gdrive_storage/__manifest__.py @@ -19,14 +19,21 @@ }, "filename_generator": { "default": "static", - "help": "how to name stored files: 'random' creates a random string; 'static' uses a replicable strategy such as a hash.", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled).", "choices": ["random", "static"], }, - "root_folder_id": {"required": True, - "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."}, + "root_folder_id": { + "required": True, + "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.", + }, }, "description": """ @@ -94,5 +101,5 @@ This module integrates Google Drive as a storage backend, enabling automatic fol https://davemateer.com/2022/04/28/google-drive-with-python#tokens -""" +""", } diff --git a/src/auto_archiver/modules/gdrive_storage/gdrive_storage.py b/src/auto_archiver/modules/gdrive_storage/gdrive_storage.py index 4971030..02ec427 100644 --- a/src/auto_archiver/modules/gdrive_storage/gdrive_storage.py +++ b/src/auto_archiver/modules/gdrive_storage/gdrive_storage.py @@ -1,4 +1,3 @@ - import json import os import time @@ -15,12 +14,9 @@ from auto_archiver.core import Media from auto_archiver.core import Storage - - class GDriveStorage(Storage): - def setup(self) -> None: - self.scopes = ['https://www.googleapis.com/auth/drive'] + self.scopes = ["https://www.googleapis.com/auth/drive"] # Initialize Google Drive service self._setup_google_drive_service() @@ -37,25 +33,25 @@ class GDriveStorage(Storage): def _initialize_with_oauth_token(self): """Initialize Google Drive service with OAuth token.""" - with open(self.oauth_token, 'r') as stream: + with open(self.oauth_token, "r") as stream: creds_json = json.load(stream) - creds_json['refresh_token'] = creds_json.get("refresh_token", "") + creds_json["refresh_token"] = creds_json.get("refresh_token", "") creds = Credentials.from_authorized_user_info(creds_json, self.scopes) if not creds.valid and creds.expired and creds.refresh_token: creds.refresh(Request()) - with open(self.oauth_token, 'w') as token_file: + with open(self.oauth_token, "w") as token_file: logger.debug("Saving refreshed OAuth token.") token_file.write(creds.to_json()) elif not creds.valid: raise ValueError("Invalid OAuth token. Please regenerate the token.") - return build('drive', 'v3', credentials=creds) + return build("drive", "v3", credentials=creds) def _initialize_with_service_account(self): """Initialize Google Drive service with service account.""" creds = service_account.Credentials.from_service_account_file(self.service_account, scopes=self.scopes) - return build('drive', 'v3', credentials=creds) + return build("drive", "v3", credentials=creds) def get_cdn_url(self, media: Media) -> str: """ @@ -79,7 +75,7 @@ class GDriveStorage(Storage): return f"https://drive.google.com/file/d/{file_id}/view?usp=sharing" def upload(self, media: Media, **kwargs) -> bool: - logger.debug(f'[{self.__class__.__name__}] storing file {media.filename} with key {media.key}') + 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 @@ -95,25 +91,30 @@ class GDriveStorage(Storage): parent_id = upload_to # upload file to gd - logger.debug(f'uploading {filename=} to folder id {upload_to}') - file_metadata = { - 'name': [filename], - 'parents': [upload_to] - } + logger.debug(f"uploading {filename=} to folder id {upload_to}") + file_metadata = {"name": [filename], "parents": [upload_to]} media = MediaFileUpload(media.filename, resumable=True) - gd_file = self.service.files().create(supportsAllDrives=True, body=file_metadata, media_body=media, fields='id').execute() - logger.debug(f'uploadf: uploaded file {gd_file["id"]} successfully in folder={upload_to}') + gd_file = ( + self.service.files() + .create(supportsAllDrives=True, body=file_metadata, media_body=media, fields="id") + .execute() + ) + logger.debug(f"uploadf: uploaded file {gd_file['id']} successfully in folder={upload_to}") # must be implemented even if unused - def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool: pass + def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool: + pass - 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): + 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 Optionally does multiple @retries and sleeps @sleep_seconds between them @@ -134,32 +135,39 @@ class GDriveStorage(Storage): debug_header: str = f"[searching {name=} in {parent_id=}]" query_string = f"'{parent_id}' in parents and name = '{name}' and trashed = false " if use_mime_type: - query_string += f" and mimeType='application/vnd.google-apps.folder' " + query_string += " and mimeType='application/vnd.google-apps.folder' " for attempt in range(retries): - results = self.service.files().list( - # both below for Google Shared Drives - supportsAllDrives=True, - includeItemsFromAllDrives=True, - q=query_string, - spaces='drive', # ie not appDataFolder or photos - fields='files(id, name)' - ).execute() - items = results.get('files', []) + results = ( + self.service.files() + .list( + # both below for Google Shared Drives + supportsAllDrives=True, + includeItemsFromAllDrives=True, + q=query_string, + spaces="drive", # ie not appDataFolder or photos + fields="files(id, name)", + ) + .execute() + ) + items = results.get("files", []) if len(items) > 0: - logger.debug(f"{debug_header} found {len(items)} matches, returning last of {','.join([i['id'] for i in items])}") - _id = items[-1]['id'] - if use_cache: self.api_cache[cache_key] = _id + logger.debug( + f"{debug_header} found {len(items)} matches, returning last of {','.join([i['id'] for i in items])}" + ) + _id = items[-1]["id"] + if use_cache: + self.api_cache[cache_key] = _id return _id else: - logger.debug(f'{debug_header} not found, attempt {attempt+1}/{retries}.') + logger.debug(f"{debug_header} not found, attempt {attempt + 1}/{retries}.") if attempt < retries - 1: - logger.debug(f'sleeping for {sleep_seconds} second(s)') + logger.debug(f"sleeping for {sleep_seconds} second(s)") time.sleep(sleep_seconds) if raise_on_missing: - raise ValueError(f'{debug_header} not found after {retries} attempt(s)') + raise ValueError(f"{debug_header} not found after {retries} attempt(s)") return None def _mkdir(self, name: str, parent_id: str): @@ -167,12 +175,7 @@ class GDriveStorage(Storage): Creates a new GDrive folder @name inside folder @parent_id Returns id of the created folder """ - logger.debug(f'Creating new folder with {name=} inside {parent_id=}') - file_metadata = { - 'name': [name], - 'mimeType': 'application/vnd.google-apps.folder', - 'parents': [parent_id] - } - gd_folder = self.service.files().create(supportsAllDrives=True, body=file_metadata, fields='id').execute() - return gd_folder.get('id') - + logger.debug(f"Creating new folder with {name=} inside {parent_id=}") + file_metadata = {"name": [name], "mimeType": "application/vnd.google-apps.folder", "parents": [parent_id]} + gd_folder = self.service.files().create(supportsAllDrives=True, body=file_metadata, fields="id").execute() + return gd_folder.get("id") diff --git a/src/auto_archiver/modules/generic_extractor/__init__.py b/src/auto_archiver/modules/generic_extractor/__init__.py index 5bfcd01..d573b3d 100644 --- a/src/auto_archiver/modules/generic_extractor/__init__.py +++ b/src/auto_archiver/modules/generic_extractor/__init__.py @@ -1 +1 @@ -from .generic_extractor import GenericExtractor \ No newline at end of file +from .generic_extractor import GenericExtractor diff --git a/src/auto_archiver/modules/generic_extractor/__manifest__.py b/src/auto_archiver/modules/generic_extractor/__manifest__.py index caa3ae1..274a4ba 100644 --- a/src/auto_archiver/modules/generic_extractor/__manifest__.py +++ b/src/auto_archiver/modules/generic_extractor/__manifest__.py @@ -28,6 +28,13 @@ the broader archiving framework. metadata objects. Some dropins are included in this generic_archiver by default, but custom dropins can be created to handle additional websites and passed to the archiver via the command line using the `--dropins` option (TODO!). + +### Auto-Updates + +The Generic Extractor will also automatically check for updates to `yt-dlp` (every 5 days by default). +This can be configured using the `ytdlp_update_interval` setting (or disabled by setting it to -1). +If you are having issues with the extractor, you can review the version of `yt-dlp` being used with `yt-dlp --version`. + """, "configs": { "subtitles": {"default": True, "help": "download subtitles if available", "type": "bool"}, @@ -64,5 +71,17 @@ via the command line using the `--dropins` option (TODO!). "default": "inf", "help": "Use to limit the number of videos to download when a channel or long page is being extracted. 'inf' means no limit.", }, + "ytdlp_update_interval": { + "default": 5, + "help": "How often to check for yt-dlp updates (days). If positive, will check and update yt-dlp every [num] days. Set it to -1 to disable, or 0 to always update on every run.", + "type": "int", + }, + "ytdlp_args": { + "default": "", + "help": "Additional arguments to pass to yt-dlp, e.g. --no-check-certificate or --plugin-dirs.\ +See yt-dlp documentation here for more information: https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options\ +Note: this is not to be confused with 'extractor_args' which are specific to the extractor itself.", + "type": "str", + }, }, } diff --git a/src/auto_archiver/modules/generic_extractor/bluesky.py b/src/auto_archiver/modules/generic_extractor/bluesky.py index 5eef520..5baad6c 100644 --- a/src/auto_archiver/modules/generic_extractor/bluesky.py +++ b/src/auto_archiver/modules/generic_extractor/bluesky.py @@ -4,15 +4,16 @@ from auto_archiver.core.extractor import Extractor from auto_archiver.core.metadata import Metadata, Media from .dropin import GenericDropin, InfoExtractor -class Bluesky(GenericDropin): +class Bluesky(GenericDropin): def create_metadata(self, post: dict, ie_instance: InfoExtractor, archiver: Extractor, url: str) -> Metadata: result = Metadata() result.set_url(url) result.set_title(post["record"]["text"]) result.set_timestamp(post["record"]["createdAt"]) for k, v in self._get_post_data(post).items(): - if v: result.set(k, v) + if v: + result.set(k, v) # download if embeds present (1 video XOR >=1 images) for media in self._download_bsky_embeds(post, archiver): @@ -23,12 +24,12 @@ class Bluesky(GenericDropin): def extract_post(self, url: str, ie_instance: InfoExtractor) -> dict: # TODO: If/when this PR (https://github.com/yt-dlp/yt-dlp/pull/12098) is merged on ytdlp, remove the comments and delete the code below - handle, video_id = ie_instance._match_valid_url(url).group('handle', 'id') + handle, video_id = ie_instance._match_valid_url(url).group("handle", "id") return ie_instance._extract_post(handle=handle, post_id=video_id) def _download_bsky_embeds(self, post: dict, archiver: Extractor) -> list[Media]: """ - Iterates over image(s) or video in a Bluesky post and downloads them + Iterates over image(s) or video in a Bluesky post and downloads them """ media = [] embed = post.get("record", {}).get("embed", {}) @@ -37,16 +38,15 @@ class Bluesky(GenericDropin): media_url = "https://bsky.social/xrpc/com.atproto.sync.getBlob?cid={}&did={}" for image_media in image_medias: - url = media_url.format(image_media['image']['ref']['$link'], post['author']['did']) + url = media_url.format(image_media["image"]["ref"]["$link"], post["author"]["did"]) image_media = archiver.download_from_url(url) media.append(Media(image_media)) for video_media in video_medias: - url = media_url.format(video_media['ref']['$link'], post['author']['did']) + url = media_url.format(video_media["ref"]["$link"], post["author"]["did"]) video_media = archiver.download_from_url(url) media.append(Media(video_media)) return media - def _get_post_data(self, post: dict) -> dict: """ Extracts relevant information returned by the .getPostThread api call (excluding text/created_at): author, mentions, tags, links. @@ -74,4 +74,4 @@ class Bluesky(GenericDropin): res["tags"] = tags if links: res["links"] = links - return res \ No newline at end of file + return res diff --git a/src/auto_archiver/modules/generic_extractor/dropin.py b/src/auto_archiver/modules/generic_extractor/dropin.py index 22f1792..8395f09 100644 --- a/src/auto_archiver/modules/generic_extractor/dropin.py +++ b/src/auto_archiver/modules/generic_extractor/dropin.py @@ -3,11 +3,12 @@ from yt_dlp.extractor.common import InfoExtractor from auto_archiver.core.metadata import Metadata from auto_archiver.core.extractor import Extractor + class GenericDropin: """Base class for dropins for the generic extractor. - + In many instances, an extractor will exist in ytdlp, but it will only process videos. - Dropins can be created and used to make use of the already-written private code of a + Dropins can be created and used to make use of the already-written private code of a specific extractor from ytdlp. The dropin should be able to handle the following methods: @@ -31,21 +32,19 @@ class GenericDropin: This method should return the post data from the url. """ raise NotImplementedError("This method should be implemented in the subclass") - def create_metadata(self, post: dict, ie_instance: InfoExtractor, archiver: Extractor, url: str) -> Metadata: """ This method should create a Metadata object from the post data. """ raise NotImplementedError("This method should be implemented in the subclass") - def skip_ytdlp_download(self, url: str, ie_instance: InfoExtractor): """ This method should return True if you want to skip the ytdlp download method. """ return False - + def keys_to_clean(self, video_data: dict, info_extractor: InfoExtractor): """ This method should return a list of strings (keys) to clean from the video_data dict. @@ -53,16 +52,16 @@ class GenericDropin: E.g. ["uploader", "uploader_id", "tiktok_specific_field"] """ return [] - + def download_additional_media(self, video_data: dict, info_extractor: InfoExtractor, metadata: Metadata): """ This method should download any additional media from the post. """ return metadata - + def is_suitable(self, url, info_extractor: InfoExtractor): """ Used to override the InfoExtractor's 'is_suitable' method. Dropins should override this method to return True if the url is suitable for the extractor (based on being able to parse other URLs) """ - return False \ No newline at end of file + return False diff --git a/src/auto_archiver/modules/generic_extractor/facebook.py b/src/auto_archiver/modules/generic_extractor/facebook.py index a487370..d778067 100644 --- a/src/auto_archiver/modules/generic_extractor/facebook.py +++ b/src/auto_archiver/modules/generic_extractor/facebook.py @@ -1,7 +1,6 @@ import re from .dropin import GenericDropin from auto_archiver.core.metadata import Metadata -from auto_archiver.core.media import Media # TODO: Remove if / when https://github.com/yt-dlp/yt-dlp/pull/12275 is merged from yt_dlp.utils import ( @@ -12,77 +11,124 @@ from yt_dlp.utils import ( merge_dicts, int_or_none, parse_count, - ) + def _extract_metadata(self, webpage, video_id): - post_data = [self._parse_json(j, video_id, fatal=False) for j in re.findall( - r'data-sjs>({.*?ScheduledServerJS.*?})', webpage)] - post = traverse_obj(post_data, ( - ..., 'require', ..., ..., ..., '__bbox', 'require', ..., ..., ..., '__bbox', 'result', 'data'), expected_type=dict) or [] - media = traverse_obj(post, (..., 'attachments', ..., lambda k, v: ( - k == 'media' and str(v['id']) == video_id and v['__typename'] == 'Video')), expected_type=dict) - title = get_first(media, ('title', 'text')) - description = get_first(media, ('creation_story', 'comet_sections', 'message', 'story', 'message', 'text')) - page_title = title or self._html_search_regex(( - r']*class="uiHeaderTitle"[^>]*>(?P[^<]*)', - r'(?s)(?P.*?)', - self._meta_regex('og:title'), self._meta_regex('twitter:title'), r'(?P<content>.+?)', - ), webpage, 'title', default=None, group='content') + post_data = [ + self._parse_json(j, video_id, fatal=False) + for j in re.findall(r"data-sjs>({.*?ScheduledServerJS.*?})", webpage) + ] + post = ( + traverse_obj( + post_data, + (..., "require", ..., ..., ..., "__bbox", "require", ..., ..., ..., "__bbox", "result", "data"), + expected_type=dict, + ) + or [] + ) + media = traverse_obj( + post, + ( + ..., + "attachments", + ..., + lambda k, v: (k == "media" and str(v["id"]) == video_id and v["__typename"] == "Video"), + ), + expected_type=dict, + ) + title = get_first(media, ("title", "text")) + description = get_first(media, ("creation_story", "comet_sections", "message", "story", "message", "text")) + page_title = title or self._html_search_regex( + ( + r']*class="uiHeaderTitle"[^>]*>(?P[^<]*)', + r'(?s)(?P.*?)', + self._meta_regex("og:title"), + self._meta_regex("twitter:title"), + r"(?P<content>.+?)", + ), + webpage, + "title", + default=None, + group="content", + ) description = description or self._html_search_meta( - ['description', 'og:description', 'twitter:description'], - webpage, 'description', default=None) + ["description", "og:description", "twitter:description"], webpage, "description", default=None + ) uploader_data = ( - get_first(media, ('owner', {dict})) - or get_first(post, ('video', 'creation_story', 'attachments', ..., 'media', lambda k, v: k == 'owner' and v['name'])) - or get_first(post, (..., 'video', lambda k, v: k == 'owner' and v['name'])) - or get_first(post, ('node', 'actors', ..., {dict})) - or get_first(post, ('event', 'event_creator', {dict})) - or get_first(post, ('video', 'creation_story', 'short_form_video_context', 'video_owner', {dict})) or {}) - uploader = uploader_data.get('name') or ( - clean_html(get_element_by_id('fbPhotoPageAuthorName', webpage)) + get_first(media, ("owner", {dict})) + or get_first( + post, ("video", "creation_story", "attachments", ..., "media", lambda k, v: k == "owner" and v["name"]) + ) + or get_first(post, (..., "video", lambda k, v: k == "owner" and v["name"])) + or get_first(post, ("node", "actors", ..., {dict})) + or get_first(post, ("event", "event_creator", {dict})) + or get_first(post, ("video", "creation_story", "short_form_video_context", "video_owner", {dict})) + or {} + ) + uploader = uploader_data.get("name") or ( + clean_html(get_element_by_id("fbPhotoPageAuthorName", webpage)) or self._search_regex( - (r'ownerName\s*:\s*"([^"]+)"', *self._og_regexes('title')), webpage, 'uploader', fatal=False)) - timestamp = int_or_none(self._search_regex( - r']+data-utime=["\'](\d+)', webpage, - 'timestamp', default=None)) - thumbnail = self._html_search_meta( - ['og:image', 'twitter:image'], webpage, 'thumbnail', default=None) + (r'ownerName\s*:\s*"([^"]+)"', *self._og_regexes("title")), webpage, "uploader", fatal=False + ) + ) + timestamp = int_or_none(self._search_regex(r']+data-utime=["\'](\d+)', webpage, "timestamp", default=None)) + thumbnail = self._html_search_meta(["og:image", "twitter:image"], webpage, "thumbnail", default=None) # some webpages contain unretrievable thumbnail urls # like https://lookaside.fbsbx.com/lookaside/crawler/media/?media_id=10155168902769113&get_thumbnail=1 # in https://www.facebook.com/yaroslav.korpan/videos/1417995061575415/ - if thumbnail and not re.search(r'\.(?:jpg|png)', thumbnail): + if thumbnail and not re.search(r"\.(?:jpg|png)", thumbnail): thumbnail = None info_dict = { - 'description': description, - 'uploader': uploader, - 'uploader_id': uploader_data.get('id'), - 'timestamp': timestamp, - 'thumbnail': thumbnail, - 'view_count': parse_count(self._search_regex( - (r'\bviewCount\s*:\s*["\']([\d,.]+)', r'video_view_count["\']\s*:\s*(\d+)'), - webpage, 'view count', default=None)), - 'concurrent_view_count': get_first(post, ( - ('video', (..., ..., 'attachments', ..., 'media')), 'liveViewerCount', {int_or_none})), - **traverse_obj(post, (lambda _, v: video_id in v['url'], 'feedback', { - 'like_count': ('likers', 'count', {int}), - 'comment_count': ('total_comment_count', {int}), - 'repost_count': ('share_count_reduced', {parse_count}), - }), get_all=False), + "description": description, + "uploader": uploader, + "uploader_id": uploader_data.get("id"), + "timestamp": timestamp, + "thumbnail": thumbnail, + "view_count": parse_count( + self._search_regex( + (r'\bviewCount\s*:\s*["\']([\d,.]+)', r'video_view_count["\']\s*:\s*(\d+)'), + webpage, + "view count", + default=None, + ) + ), + "concurrent_view_count": get_first( + post, (("video", (..., ..., "attachments", ..., "media")), "liveViewerCount", {int_or_none}) + ), + **traverse_obj( + post, + ( + lambda _, v: video_id in v["url"], + "feedback", + { + "like_count": ("likers", "count", {int}), + "comment_count": ("total_comment_count", {int}), + "repost_count": ("share_count_reduced", {parse_count}), + }, + ), + get_all=False, + ), } info_json_ld = self._search_json_ld(webpage, video_id, default={}) - info_json_ld['title'] = (re.sub(r'\s*\|\s*Facebook$', '', title or info_json_ld.get('title') or page_title or '') - or (description or '').replace('\n', ' ') or f'Facebook video #{video_id}') + info_json_ld["title"] = ( + re.sub(r"\s*\|\s*Facebook$", "", title or info_json_ld.get("title") or page_title or "") + or (description or "").replace("\n", " ") + or f"Facebook video #{video_id}" + ) return merge_dicts(info_json_ld, info_dict) -class Facebook(GenericDropin): - - def extract_post(self, url: str, ie_instance): - post_id_regex = r'(?Ppfbid[A-Za-z0-9]+|\d+|t\.(\d+\/\d+))' - post_id = re.search(post_id_regex, url).group('id') - webpage = ie_instance._download_webpage( - url.replace('://m.facebook.com/', '://www.facebook.com/'), post_id) + +class Facebook(GenericDropin): + def extract_post(self, url: str, ie_instance): + video_id = ie_instance._match_valid_url(url).group("id") + ie_instance._download_webpage(url.replace("://m.facebook.com/", "://www.facebook.com/"), video_id) + webpage = ie_instance._download_webpage(url, ie_instance._match_valid_url(url).group("id")) + + post_id_regex = r"(?Ppfbid[A-Za-z0-9]+|\d+|t\.(\d+\/\d+))" + post_id = re.search(post_id_regex, url).group("id") + webpage = ie_instance._download_webpage(url.replace("://m.facebook.com/", "://www.facebook.com/"), post_id) # TODO: For long posts, this _extract_metadata only seems to return the first 100 or so characters, followed by ... @@ -93,20 +139,19 @@ class Facebook(GenericDropin): def create_metadata(self, post: dict, ie_instance, archiver, url): result = Metadata() - result.set_content(post.get('description', '')) - result.set_title(post.get('title', '')) - result.set('author', post.get('uploader', '')) + result.set_content(post.get("description", "")) + result.set_title(post.get("title", "")) + result.set("author", post.get("uploader", "")) result.set_url(url) return result - + def is_suitable(self, url, info_extractor): - regex = r'(?:https?://(?:[\w-]+\.)?(?:facebook\.com||facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd\.onion)/)' + regex = r"(?:https?://(?:[\w-]+\.)?(?:facebook\.com||facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd\.onion)/)" return re.match(regex, url) - + def skip_ytdlp_download(self, url: str, ie_instance): """ Skip using the ytdlp download method for Facebook *photo* posts, they have a URL with an id of t.XXXXX/XXXXX """ - if re.search(r'/t.\d+/\d+', url): + if re.search(r"/t.\d+/\d+", url): return True - diff --git a/src/auto_archiver/modules/generic_extractor/generic_extractor.py b/src/auto_archiver/modules/generic_extractor/generic_extractor.py index cc0cbea..9036b0b 100644 --- a/src/auto_archiver/modules/generic_extractor/generic_extractor.py +++ b/src/auto_archiver/modules/generic_extractor/generic_extractor.py @@ -1,18 +1,68 @@ -import datetime, os, yt_dlp, pysubs2 +import datetime +import os import importlib +import subprocess + from typing import Generator, Type + +import yt_dlp from yt_dlp.extractor.common import InfoExtractor +import pysubs2 from loguru import logger from auto_archiver.core.extractor import Extractor from auto_archiver.core import Metadata, Media -class Skip(Exception): + +class SkipYtdlp(Exception): pass + + class GenericExtractor(Extractor): _dropins = {} + def setup(self): + # check for file .ytdlp-update in the secrets folder + if self.ytdlp_update_interval < 0: + return + + use_secrets = os.path.exists("secrets") + path = os.path.join("secrets" if use_secrets else "", ".ytdlp-update") + next_update_check = None + if os.path.exists(path): + with open(path, "r") as f: + next_update_check = datetime.datetime.fromisoformat(f.read()) + + if not next_update_check or next_update_check < datetime.datetime.now(): + self.update_ytdlp() + + next_update_check = datetime.datetime.now() + datetime.timedelta(days=self.ytdlp_update_interval) + with open(path, "w") as f: + f.write(next_update_check.isoformat()) + + def update_ytdlp(self): + logger.info("Checking and updating yt-dlp...") + logger.info( + f"Tip: change the 'ytdlp_update_interval' setting to control how often yt-dlp is updated. Set to -1 to disable or 0 to enable on every run. Current setting: {self.ytdlp_update_interval}" + ) + from importlib.metadata import version as get_version + + old_version = get_version("yt-dlp") + try: + # try and update with pip (this works inside poetry environment and in a normal virtualenv) + result = subprocess.run(["pip", "install", "--upgrade", "yt-dlp"], check=True, capture_output=True) + + if "Successfully installed yt-dlp" in result.stdout.decode(): + new_version = importlib.metadata.version("yt-dlp") + logger.info(f"yt-dlp successfully (from {old_version} to {new_version})") + importlib.reload(yt_dlp) + else: + logger.info("yt-dlp already up to date") + + except Exception as e: + logger.error(f"Error updating yt-dlp: {e}") + def suitable_extractors(self, url: str) -> Generator[str, None, None]: """ Returns a list of valid extractors for the given URL""" @@ -29,17 +79,17 @@ class GenericExtractor(Extractor): if info_extractor.suitable(url): yield info_extractor continue - - def suitable(self, url: str) -> bool: """ Checks for valid URLs out of all ytdlp extractors. Returns False for the GenericIE, which as labelled by yt-dlp: 'Generic downloader that works on some sites' """ return any(self.suitable_extractors(url)) - - def download_additional_media(self, video_data: dict, info_extractor: InfoExtractor, metadata: Metadata) -> Metadata: + + def download_additional_media( + self, video_data: dict, info_extractor: InfoExtractor, metadata: Metadata + ) -> Metadata: """ Downloads additional media like images, comments, subtitles, etc. @@ -48,7 +98,7 @@ class GenericExtractor(Extractor): # Just get the main thumbnail. More thumbnails are available in # video_data['thumbnails'] should they be required - thumbnail_url = video_data.get('thumbnail') + thumbnail_url = video_data.get("thumbnail") if thumbnail_url: try: cover_image_path = self.download_from_url(thumbnail_url) @@ -71,15 +121,65 @@ class GenericExtractor(Extractor): Clean up the ytdlp generic video data to make it more readable and remove unnecessary keys that ytdlp adds """ - base_keys = ['formats', 'thumbnail', 'display_id', 'epoch', 'requested_downloads', - 'duration_string', 'thumbnails', 'http_headers', 'webpage_url_basename', 'webpage_url_domain', - 'extractor', 'extractor_key', 'playlist', 'playlist_index', 'duration_string', 'protocol', 'requested_subtitles', - 'format_id', 'acodec', 'vcodec', 'ext', 'epoch', '_has_drm', 'filesize', 'audio_ext', 'video_ext', 'vbr', 'abr', - 'resolution', 'dynamic_range', 'aspect_ratio', 'cookies', 'format', 'quality', 'preference', 'artists', - 'channel_id', 'subtitles', 'tbr', 'url', 'original_url', 'automatic_captions', 'playable_in_embed', 'live_status', - '_format_sort_fields', 'chapters', 'requested_formats', 'format_note', - 'audio_channels', 'asr', 'fps', 'was_live', 'is_live', 'heatmap', 'age_limit', 'stretched_ratio'] - + base_keys = [ + "formats", + "thumbnail", + "display_id", + "epoch", + "requested_downloads", + "duration_string", + "thumbnails", + "http_headers", + "webpage_url_basename", + "webpage_url_domain", + "extractor", + "extractor_key", + "playlist", + "playlist_index", + "duration_string", + "protocol", + "requested_subtitles", + "format_id", + "acodec", + "vcodec", + "ext", + "epoch", + "_has_drm", + "filesize", + "audio_ext", + "video_ext", + "vbr", + "abr", + "resolution", + "dynamic_range", + "aspect_ratio", + "cookies", + "format", + "quality", + "preference", + "artists", + "channel_id", + "subtitles", + "tbr", + "url", + "original_url", + "automatic_captions", + "playable_in_embed", + "live_status", + "_format_sort_fields", + "chapters", + "requested_formats", + "format_note", + "audio_channels", + "asr", + "fps", + "was_live", + "is_live", + "heatmap", + "age_limit", + "stretched_ratio", + ] + dropin = self.dropin_for_name(info_extractor.ie_key()) if dropin: try: @@ -88,8 +188,8 @@ class GenericExtractor(Extractor): pass return base_keys - - def add_metadata(self, video_data: dict, info_extractor: InfoExtractor, url:str, result: Metadata) -> Metadata: + + def add_metadata(self, video_data: dict, info_extractor: InfoExtractor, url: str, result: Metadata) -> Metadata: """ Creates a Metadata object from the given video_data """ @@ -98,29 +198,36 @@ class GenericExtractor(Extractor): result = self.download_additional_media(video_data, info_extractor, result) # keep both 'title' and 'fulltitle', but prefer 'title', falling back to 'fulltitle' if it doesn't exist - result.set_title(video_data.pop('title', video_data.pop('fulltitle', ""))) + result.set_title(video_data.pop("title", video_data.pop("fulltitle", ""))) result.set_url(url) - + if "description" in video_data: + result.set_content(video_data["description"]) # extract comments if enabled if self.comments: - result.set("comments", [{ - "text": c["text"], - "author": c["author"], - "timestamp": datetime.datetime.fromtimestamp(c.get("timestamp"), tz = datetime.timezone.utc) - } for c in video_data.get("comments", [])]) + result.set( + "comments", + [ + { + "text": c["text"], + "author": c["author"], + "timestamp": datetime.datetime.fromtimestamp(c.get("timestamp"), tz=datetime.timezone.utc), + } + for c in video_data.get("comments", []) + ], + ) # then add the common metadata if timestamp := video_data.pop("timestamp", None): - timestamp = datetime.datetime.fromtimestamp(timestamp, tz = datetime.timezone.utc).isoformat() + timestamp = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc).isoformat() result.set_timestamp(timestamp) if upload_date := video_data.pop("upload_date", None): - upload_date = datetime.datetime.strptime(upload_date, '%Y%m%d').replace(tzinfo=datetime.timezone.utc) + upload_date = datetime.datetime.strptime(upload_date, "%Y%m%d").replace(tzinfo=datetime.timezone.utc) result.set("upload_date", upload_date) - + # then clean away any keys we don't want for clean_key in self.keys_to_clean(info_extractor, video_data): video_data.pop(clean_key, None) - + # then add the rest of the video data for k, v in video_data.items(): if v: @@ -138,26 +245,28 @@ class GenericExtractor(Extractor): if not dropin: # TODO: add a proper link to 'how to create your own dropin' - logger.debug(f"""Could not find valid dropin for {info_extractor.IE_NAME}. + logger.debug(f"""Could not find valid dropin for {info_extractor.ie_key()}. Why not try creating your own, and make sure it has a valid function called 'create_metadata'. Learn more: https://auto-archiver.readthedocs.io/en/latest/user_guidelines.html#""") return False - + post_data = dropin.extract_post(url, ie_instance) result = dropin.create_metadata(post_data, ie_instance, self, url) return self.add_metadata(post_data, info_extractor, url, result) - def get_metadata_for_video(self, data: dict, info_extractor: Type[InfoExtractor], url: str, ydl: yt_dlp.YoutubeDL) -> Metadata: - + def get_metadata_for_video( + self, data: dict, info_extractor: Type[InfoExtractor], url: str, ydl: yt_dlp.YoutubeDL + ) -> Metadata: # this time download - ydl.params['getcomments'] = self.comments - #TODO: for playlist or long lists of videos, how to download one at a time so they can be stored before the next one is downloaded? + ydl.params["getcomments"] = self.comments + # TODO: for playlist or long lists of videos, how to download one at a time so they can be stored before the next one is downloaded? data = ydl.extract_info(url, ie_key=info_extractor.ie_key(), download=True) if "entries" in data: entries = data.get("entries", []) if not len(entries): - logger.warning('YoutubeDLArchiver could not find any video') + logger.warning("YoutubeDLArchiver could not find any video") return False - else: entries = [data] + else: + entries = [data] result = Metadata() @@ -165,17 +274,18 @@ class GenericExtractor(Extractor): try: filename = ydl.prepare_filename(entry) if not os.path.exists(filename): - filename = filename.split('.')[0] + '.mkv' + filename = filename.split(".")[0] + ".mkv" new_media = Media(filename) for x in ["duration", "original_url", "fulltitle", "description", "upload_date"]: - if x in entry: new_media.set(x, entry[x]) + if x in entry: + new_media.set(x, entry[x]) # read text from subtitles if enabled if self.subtitles: - for lang, val in (data.get('requested_subtitles') or {}).items(): - try: - subs = pysubs2.load(val.get('filepath'), encoding="utf-8") + for lang, val in (data.get("requested_subtitles") or {}).items(): + try: + subs = pysubs2.load(val.get("filepath"), encoding="utf-8") text = " ".join([line.text for line in subs]) new_media.set(f"subtitles_{lang}", text) except Exception as e: @@ -185,8 +295,8 @@ class GenericExtractor(Extractor): logger.error(f"Error processing entry {entry}: {e}") return self.add_metadata(data, info_extractor, url, result) - - def dropin_for_name(self, dropin_name: str, additional_paths = [], package=__package__) -> Type[InfoExtractor]: + + def dropin_for_name(self, dropin_name: str, additional_paths=[], package=__package__) -> Type[InfoExtractor]: dropin_name = dropin_name.lower() if dropin_name == "generic": @@ -194,6 +304,7 @@ class GenericExtractor(Extractor): return None dropin_class_name = dropin_name.title() + def _load_dropin(dropin): dropin_class = getattr(dropin, dropin_class_name)() dropin.extractor = self @@ -218,7 +329,7 @@ class GenericExtractor(Extractor): return _load_dropin(dropin) except (FileNotFoundError, ModuleNotFoundError): pass - + # fallback to loading the dropins within auto-archiver try: return _load_dropin(importlib.import_module(f".{dropin_name}", package=package)) @@ -230,46 +341,53 @@ class GenericExtractor(Extractor): def download_for_extractor(self, info_extractor: InfoExtractor, url: str, ydl: yt_dlp.YoutubeDL) -> Metadata: """ Tries to download the given url using the specified extractor - + It first tries to use ytdlp directly to download the video. If the post is not a video, it will then try to use the extractor's _extract_post method to get the post metadata if possible. """ # when getting info without download, we also don't need the comments - ydl.params['getcomments'] = False + ydl.params["getcomments"] = False result = False dropin_submodule = self.dropin_for_name(info_extractor.ie_key()) try: - if dropin_submodule and dropin_submodule.skip_ytdlp_download(url, info_extractor): - logger.debug(f"Skipping using ytdlp to download files for {info_extractor.ie_key()} (dropin override)") - raise Skip() + if dropin_submodule and dropin_submodule.skip_ytdlp_download(info_extractor, url): + logger.debug(f"Skipping using ytdlp to download files for {info_extractor.ie_key()}") + raise SkipYtdlp() # don't download since it can be a live stream data = ydl.extract_info(url, ie_key=info_extractor.ie_key(), download=False) - if data.get('is_live', False) and not self.livestreams: + if data.get("is_live", False) and not self.livestreams: logger.warning("Livestream detected, skipping due to 'livestreams' configuration setting") return False # it's a valid video, that the youtubdedl can download out of the box result = self.get_metadata_for_video(data, info_extractor, url, ydl) except Exception as e: - if info_extractor.ie_key() == "generic": + if info_extractor.IE_NAME == "generic": # don't clutter the logs with issues about the 'generic' extractor not having a dropin return False - - if not isinstance(e, Skip): - logger.debug(f'Issue using "{info_extractor.IE_NAME}" extractor to download video (error: {repr(e)}), attempting to use dropin to get post data instead') + + if not isinstance(e, SkipYtdlp): + logger.debug( + f'Issue using "{info_extractor.IE_NAME}" extractor to download video (error: {repr(e)}), attempting to use dropin to get post data instead' + ) try: result = self.get_metadata_for_post(info_extractor, url, ydl) except (yt_dlp.utils.DownloadError, yt_dlp.utils.ExtractorError) as post_e: - logger.error(f'Error downloading metadata for post: {post_e}') + logger.error("Error downloading metadata for post: {error}", error=str(post_e)) return False except Exception as generic_e: - logger.debug(f'Attempt to extract using ytdlp dropin for "{info_extractor.IE_NAME}" failed: \n {repr(generic_e)}', exc_info=True) + logger.debug( + 'Attempt to extract using ytdlp extractor "{name}" failed: \n {error}', + name=info_extractor.IE_NAME, + error=str(generic_e), + exc_info=True, + ) return False - + if result: extractor_name = "yt-dlp" if info_extractor: @@ -285,42 +403,56 @@ class GenericExtractor(Extractor): def download(self, item: Metadata) -> Metadata: url = item.get_url() - #TODO: this is a temporary hack until this issue is closed: https://github.com/yt-dlp/yt-dlp/issues/11025 + # TODO: this is a temporary hack until this issue is closed: https://github.com/yt-dlp/yt-dlp/issues/11025 if url.startswith("https://ya.ru"): url = url.replace("https://ya.ru", "https://yandex.ru") item.set("replaced_url", url) + ydl_options = [ + "-o", + os.path.join(self.tmp_dir, "%(id)s.%(ext)s"), + "--quiet", + "--no-playlist" if not self.allow_playlist else "--yes-playlist", + "--write-subs" if self.subtitles else "--no-write-subs", + "--write-auto-subs" if self.subtitles else "--no-write-auto-subs", + "--live-from-start" if self.live_from_start else "--no-live-from-start", + "--proxy", + self.proxy if self.proxy else "", + f"--max-downloads {self.max_downloads}" if self.max_downloads != "inf" else "", + f"--playlist-end {self.max_downloads}" if self.max_downloads != "inf" else "", + ] - ydl_options = {'outtmpl': os.path.join(self.tmp_dir, f'%(id)s.%(ext)s'), - 'quiet': False, 'noplaylist': not self.allow_playlist , - 'writesubtitles': self.subtitles,'writeautomaticsub': self.subtitles, - "live_from_start": self.live_from_start, "proxy": self.proxy, - "max_downloads": self.max_downloads, "playlistend": self.max_downloads} - # set up auth auth = self.auth_for_site(url, extract_cookies=False) + # order of importance: username/pasword -> api_key -> cookie -> cookies_from_browser -> cookies_file if auth: - if 'username' in auth and 'password' in auth: - logger.debug(f'Using provided auth username and password for {url}') - ydl_options['username'] = auth['username'] - ydl_options['password'] = auth['password'] - elif 'cookie' in auth: - logger.debug(f'Using provided auth cookie for {url}') - yt_dlp.utils.std_headers['cookie'] = auth['cookie'] - elif 'cookies_from_browser' in auth: - logger.debug(f'Using extracted cookies from browser {self.cookies_from_browser} for {url}') - ydl_options['cookiesfrombrowser'] = auth['cookies_from_browser'] - elif 'cookies_file' in auth: - logger.debug(f'Using cookies from file {self.cookie_file} for {url}') - ydl_options['cookiesfile'] = auth['cookies_file'] + if "username" in auth and "password" in auth: + logger.debug(f"Using provided auth username and password for {url}") + ydl_options.extend(("--username", auth["username"])) + ydl_options.extend(("--password", auth["password"])) + elif "cookie" in auth: + logger.debug(f"Using provided auth cookie for {url}") + yt_dlp.utils.std_headers["cookie"] = auth["cookie"] + elif "cookies_from_browser" in auth: + logger.debug(f"Using extracted cookies from browser {auth['cookies_from_browser']} for {url}") + ydl_options.extend(("--cookies-from-browser", auth["cookies_from_browser"])) + elif "cookies_file" in auth: + logger.debug(f"Using cookies from file {auth['cookies_file']} for {url}") + ydl_options.extend(("--cookies", auth["cookies_file"])) - ydl = yt_dlp.YoutubeDL(ydl_options) # allsubtitles and subtitleslangs not working as expected, so default lang is always "en" + if self.ytdlp_args: + logger.debug("Adding additional ytdlp arguments: {self.ytdlp_args}") + ydl_options += self.ytdlp_args.split(" ") + + *_, validated_options = yt_dlp.parse_options(ydl_options) + ydl = yt_dlp.YoutubeDL( + validated_options + ) # allsubtitles and subtitleslangs not working as expected, so default lang is always "en" for info_extractor in self.suitable_extractors(url): result = self.download_for_extractor(info_extractor, url, ydl) if result: return result - return False diff --git a/src/auto_archiver/modules/generic_extractor/tiktok.py b/src/auto_archiver/modules/generic_extractor/tiktok.py new file mode 100644 index 0000000..e05d298 --- /dev/null +++ b/src/auto_archiver/modules/generic_extractor/tiktok.py @@ -0,0 +1,72 @@ +import requests +from loguru import logger +from auto_archiver.core import Metadata, Media +from datetime import datetime, timezone +from .dropin import GenericDropin + + +class Tiktok(GenericDropin): + """ + TikTok droping for the Generic Extractor that uses an unofficial API if/when ytdlp fails. + It's useful for capturing content that requires a login, like sensitive content. + """ + + TIKWM_ENDPOINT = "https://www.tikwm.com/api/?url={url}" + + def extract_post(self, url: str, ie_instance): + logger.debug(f"Using Tikwm API to attempt to download tiktok video from {url=}") + + endpoint = self.TIKWM_ENDPOINT.format(url=url) + + r = requests.get(endpoint) + if r.status_code != 200: + raise ValueError(f"unexpected status code '{r.status_code}' from tikwm.com for {url=}:") + + try: + json_response = r.json() + except ValueError: + raise ValueError(f"failed to parse JSON response from tikwm.com for {url=}") + + if not json_response.get("msg") == "success" or not (api_data := json_response.get("data", {})): + raise ValueError(f"failed to get a valid response from tikwm.com for {url=}: {repr(json_response)}") + + # tries to get the non-watermarked version first + video_url = api_data.pop("play", api_data.pop("wmplay", None)) + if not video_url: + raise ValueError(f"no valid video URL found in response from tikwm.com for {url=}") + + api_data["video_url"] = video_url + return api_data + + def create_metadata(self, post: dict, ie_instance, archiver, url): + # prepare result, start by downloading video + result = Metadata() + video_url = post.pop("video_url") + + # get the cover if possible + cover_url = post.pop("origin_cover", post.pop("cover", post.pop("ai_dynamic_cover", None))) + if cover_url and (cover_downloaded := archiver.download_from_url(cover_url)): + result.add_media(Media(cover_downloaded)) + + # get the video or fail + video_downloaded = archiver.download_from_url(video_url, f"vid_{post.get('id', '')}") + if not video_downloaded: + logger.error(f"failed to download video from {video_url}") + return False + video_media = Media(video_downloaded) + if duration := post.pop("duration", None): + video_media.set("duration", duration) + result.add_media(video_media) + + # add remaining metadata + result.set_title(post.pop("title", "")) + + if created_at := post.pop("create_time", None): + result.set_timestamp(datetime.fromtimestamp(created_at, tz=timezone.utc)) + + if author := post.pop("author", None): + result.set("author", author) + + result.set("api_data", post) + + return result diff --git a/src/auto_archiver/modules/generic_extractor/truth.py b/src/auto_archiver/modules/generic_extractor/truth.py index e65b4b1..345f1cd 100644 --- a/src/auto_archiver/modules/generic_extractor/truth.py +++ b/src/auto_archiver/modules/generic_extractor/truth.py @@ -9,11 +9,11 @@ from dateutil.parser import parse as parse_dt from .dropin import GenericDropin -class Truth(GenericDropin): +class Truth(GenericDropin): def extract_post(self, url, ie_instance: InfoExtractor) -> dict: video_id = ie_instance._match_id(url) - truthsocial_url = f'https://truthsocial.com/api/v1/statuses/{video_id}' + truthsocial_url = f"https://truthsocial.com/api/v1/statuses/{video_id}" return ie_instance._download_json(truthsocial_url, video_id) def skip_ytdlp_download(self, url, ie_instance: Type[InfoExtractor]) -> bool: @@ -22,31 +22,42 @@ class Truth(GenericDropin): def create_metadata(self, post: dict, ie_instance: InfoExtractor, archiver: Extractor, url: str) -> Metadata: """ Creates metadata from a truth social post - + Only used for posts that contain no media. ytdlp.TruthIE extractor can handle posts with media - + Format is: - + {'id': '109598702184774628', 'created_at': '2022-12-29T19:51:18.161Z', 'in_reply_to_id': None, 'quote_id': None, 'in_reply_to_account_id': None, 'sensitive': False, 'spoiler_text': '', 'visibility': 'public', 'language': 'en', 'uri': 'https://truthsocial.com/@bbcnewa/109598702184774628', 'url': 'https://truthsocial.com/@bbcnewa/109598702184774628', 'content': '

Pele, regarded by many as football\'s greatest ever player, has died in Brazil at the age of 82. bbc.com/sport/football/4275151

', 'account': {'id': '107905163010312793', 'username': 'bbcnewa', 'acct': 'bbcnewa', 'display_name': 'BBC News', 'locked': False, 'bot': False, 'discoverable': True, 'group': False, 'created_at': '2022-03-05T17:42:01.159Z', 'note': '

News, features and analysis by the BBC

', 'url': 'https://truthsocial.com/@bbcnewa', 'avatar': 'https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/accounts/avatars/107/905/163/010/312/793/original/e7c07550dc22c23a.jpeg', 'avatar_static': 'https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/accounts/avatars/107/905/163/010/312/793/original/e7c07550dc22c23a.jpeg', 'header': 'https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/accounts/headers/107/905/163/010/312/793/original/a00eeec2b57206c7.jpeg', 'header_static': 'https://static-assets-1.truthsocial.com/tmtg:prime-ts-assets/accounts/headers/107/905/163/010/312/793/original/a00eeec2b57206c7.jpeg', 'followers_count': 1131, 'following_count': 3, 'statuses_count': 9, 'last_status_at': '2024-11-12', 'verified': False, 'location': '', 'website': 'https://www.bbc.com/news', 'unauth_visibility': True, 'chats_onboarded': True, 'feeds_onboarded': True, 'accepting_messages': False, 'show_nonmember_group_statuses': None, 'emojis': [], 'fields': [], 'tv_onboarded': True, 'tv_account': False}, 'media_attachments': [], 'mentions': [], 'tags': [], 'card': None, 'group': None, 'quote': None, 'in_reply_to': None, 'reblog': None, 'sponsored': False, 'replies_count': 1, 'reblogs_count': 0, 'favourites_count': 2, 'favourited': False, 'reblogged': False, 'muted': False, 'pinned': False, 'bookmarked': False, 'poll': None, 'emojis': []} """ result = Metadata() result.set_url(url) - timestamp = post['created_at'] # format is 2022-12-29T19:51:18.161Z + timestamp = post["created_at"] # format is 2022-12-29T19:51:18.161Z result.set_timestamp(parse_dt(timestamp)) - result.set('description', post['content']) - result.set('author', post['account']['username']) + result.set("description", post["content"]) + result.set("author", post["account"]["username"]) - for key in ['replies_count', 'reblogs_count', 'favourites_count', ('account', 'followers_count'), ('account', 'following_count'), ('account', 'statuses_count'), ('account', 'display_name'), 'language', 'in_reply_to_account', 'replies_count']: + for key in [ + "replies_count", + "reblogs_count", + "favourites_count", + ("account", "followers_count"), + ("account", "following_count"), + ("account", "statuses_count"), + ("account", "display_name"), + "language", + "in_reply_to_account", + "replies_count", + ]: if isinstance(key, tuple): store_key = " ".join(key) else: store_key = key result.set(store_key, traverse_obj(post, key)) - - # add the media - for media in post.get('media_attachments', []): - filename = archiver.download_from_url(media['url']) - result.add_media(Media(filename), id=media.get('id')) - return result \ No newline at end of file + # add the media + for media in post.get("media_attachments", []): + filename = archiver.download_from_url(media["url"]) + result.add_media(Media(filename), id=media.get("id")) + + return result diff --git a/src/auto_archiver/modules/generic_extractor/twitter.py b/src/auto_archiver/modules/generic_extractor/twitter.py index 3faed6b..e4cbe74 100644 --- a/src/auto_archiver/modules/generic_extractor/twitter.py +++ b/src/auto_archiver/modules/generic_extractor/twitter.py @@ -1,4 +1,6 @@ -import re, mimetypes, json +import re +import mimetypes +import json from datetime import datetime from loguru import logger @@ -10,9 +12,8 @@ from auto_archiver.core.extractor import Extractor from .dropin import GenericDropin, InfoExtractor + class Twitter(GenericDropin): - - def choose_variant(self, variants): # choosing the highest quality possible variant, width, height = None, 0, 0 @@ -27,44 +28,43 @@ class Twitter(GenericDropin): else: variant = var if not variant else variant return variant - + def extract_post(self, url: str, ie_instance: InfoExtractor): - twid = ie_instance._match_valid_url(url).group('id') + twid = ie_instance._match_valid_url(url).group("id") return ie_instance._extract_status(twid=twid) def create_metadata(self, tweet: dict, ie_instance: InfoExtractor, archiver: Extractor, url: str) -> Metadata: result = Metadata() try: if not tweet.get("user") or not tweet.get("created_at"): - raise ValueError(f"Error retreiving post. Are you sure it exists?") + raise ValueError("Error retreiving post. Are you sure it exists?") timestamp = datetime.strptime(tweet["created_at"], "%a %b %d %H:%M:%S %z %Y") except (ValueError, KeyError) as ex: logger.warning(f"Unable to parse tweet: {str(ex)}\nRetreived tweet data: {tweet}") return False - - result\ - .set_title(tweet.get('full_text', ''))\ - .set_content(json.dumps(tweet, ensure_ascii=False))\ - .set_timestamp(timestamp) + + result.set_title(tweet.get("full_text", "")).set_content(json.dumps(tweet, ensure_ascii=False)).set_timestamp( + timestamp + ) if not tweet.get("entities", {}).get("media"): - logger.debug('No media found, archiving tweet text only') + logger.debug("No media found, archiving tweet text only") result.status = "twitter-ytdl" return result for i, tw_media in enumerate(tweet["entities"]["media"]): media = Media(filename="") mimetype = "" if tw_media["type"] == "photo": - media.set("src", UrlUtil.twitter_best_quality_url(tw_media['media_url_https'])) + media.set("src", UrlUtil.twitter_best_quality_url(tw_media["media_url_https"])) mimetype = "image/jpeg" elif tw_media["type"] == "video": - variant = self.choose_variant(tw_media['video_info']['variants']) - media.set("src", variant['url']) - mimetype = variant['content_type'] + variant = self.choose_variant(tw_media["video_info"]["variants"]) + media.set("src", variant["url"]) + mimetype = variant["content_type"] elif tw_media["type"] == "animated_gif": - variant = tw_media['video_info']['variants'][0] - media.set("src", variant['url']) - mimetype = variant['content_type'] + variant = tw_media["video_info"]["variants"][0] + media.set("src", variant["url"]) + mimetype = variant["content_type"] ext = mimetypes.guess_extension(mimetype) - media.filename = archiver.download_from_url(media.get("src"), f'{slugify(url)}_{i}{ext}') + media.filename = archiver.download_from_url(media.get("src"), f"{slugify(url)}_{i}{ext}") result.add_media(media) - return result \ No newline at end of file + return result diff --git a/src/auto_archiver/modules/gsheet_db/__init__.py b/src/auto_archiver/modules/gsheet_db/__init__.py deleted file mode 100644 index 01fdee6..0000000 --- a/src/auto_archiver/modules/gsheet_db/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .gsheet_db import GsheetsDb \ No newline at end of file diff --git a/src/auto_archiver/modules/gsheet_db/__manifest__.py b/src/auto_archiver/modules/gsheet_db/__manifest__.py deleted file mode 100644 index cf95245..0000000 --- a/src/auto_archiver/modules/gsheet_db/__manifest__.py +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "Google Sheets Database", - "type": ["database"], - "entry_point": "gsheet_db::GsheetsDb", - "requires_setup": True, - "dependencies": { - "python": ["loguru", "gspread", "slugify"], - }, - "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", - }, - "block_worksheets": { - "default": set(), - "help": "(CSV) explicitly block some worksheets from being processed", - }, - "use_sheet_names_in_stored_paths": { - "default": True, - "type": "bool", - "help": "if True the stored files path will include 'workbook_name/worksheet_name/...'", - } - }, - "description": """ - GsheetsDatabase: - Handles integration with Google Sheets for tracking archival tasks. - -### Features -- Updates a Google Sheet with the status of the archived URLs, including in progress, success or failure, and method used. -- Saves metadata such as title, text, timestamp, hashes, screenshots, and media URLs to designated columns. -- Formats media-specific metadata, such as thumbnails and PDQ hashes for the sheet. -- Skips redundant updates for empty or invalid data fields. - -### Notes -- Currently works only with metadata provided by GsheetFeeder. -- Requires configuration of a linked Google Sheet and appropriate API credentials. - """ -} diff --git a/src/auto_archiver/modules/gsheet_db/gsheet_db.py b/src/auto_archiver/modules/gsheet_db/gsheet_db.py deleted file mode 100644 index c19f2ae..0000000 --- a/src/auto_archiver/modules/gsheet_db/gsheet_db.py +++ /dev/null @@ -1,114 +0,0 @@ -from typing import Union, Tuple -from urllib.parse import quote - -from loguru import logger - -from auto_archiver.core import Database -from auto_archiver.core import Metadata, Media -from auto_archiver.modules.gsheet_feeder import GWorksheet -from auto_archiver.utils.misc import get_current_timestamp - - -class GsheetsDb(Database): - """ - NB: only works if GsheetFeeder is used. - could be updated in the future to support non-GsheetFeeder metadata - """ - - 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, reason: str) -> None: - logger.error(f"FAILED {item}") - self._safe_status_update(item, f"Archive failed {reason}") - - 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, cached: bool = False) -> 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 - try: - if val and gw.col_exists(col) and gw.get_cell(row_values, col) == "": - cell_updates.append((row, col, final_value)) - except Exception as e: - logger.error(f"Unable to batch {col}={final_value} due to {e}") - - status_message = item.status - if cached: - status_message = f"[cached] {status_message}" - cell_updates.append((row, "status", status_message)) - - media: Media = item.get_final_media() - if hasattr(media, "urls"): - batch_if_valid("archive", "\n".join(media.urls)) - batch_if_valid("date", True, get_current_timestamp()) - batch_if_valid("title", item.get_title()) - batch_if_valid("text", item.get("content", "")) - batch_if_valid("timestamp", item.get_timestamp()) - if media: - batch_if_valid("hash", media.get("hash", "not-calculated")) - - # merge all pdq hashes into a single string, if present - pdq_hashes = [] - all_media = item.get_all_media() - for m in all_media: - if pdq := m.get("pdq_hash"): - pdq_hashes.append(pdq) - if len(pdq_hashes): - batch_if_valid("pdq_hash", ",".join(pdq_hashes)) - - 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]: - - if gsheet := item.get_context("gsheet"): - gw: GWorksheet = gsheet.get("worksheet") - row: int = gsheet.get("row") - elif self.sheet_id: - logger.error(f"Unable to retrieve Gsheet for {item.get_url()}, GsheetDB must be used alongside GsheetFeeder.") - - return gw, row diff --git a/src/auto_archiver/modules/gsheet_feeder/__init__.py b/src/auto_archiver/modules/gsheet_feeder/__init__.py deleted file mode 100644 index bb4230a..0000000 --- a/src/auto_archiver/modules/gsheet_feeder/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .gworksheet import GWorksheet -from .gsheet_feeder import GsheetsFeeder \ No newline at end of file diff --git a/src/auto_archiver/modules/gsheet_feeder/gsheet_feeder.py b/src/auto_archiver/modules/gsheet_feeder/gsheet_feeder.py deleted file mode 100644 index 8612d02..0000000 --- a/src/auto_archiver/modules/gsheet_feeder/gsheet_feeder.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -GsheetsFeeder: A Google Sheets-based feeder for the Auto Archiver. - -This reads data from Google Sheets and filters rows based on user-defined rules. -The filtered rows are processed into `Metadata` objects. - -### Key properties -- validates the sheet's structure and filters rows based on input configurations. -- Ensures only rows with valid URLs and unprocessed statuses are included. -""" -import os -import gspread - -from loguru import logger -from slugify import slugify - -from auto_archiver.core import Feeder -from auto_archiver.core import Metadata -from . import GWorksheet - - -class GsheetsFeeder(Feeder): - - def setup(self) -> None: - self.gsheets_client = gspread.service_account(filename=self.service_account) - # TODO mv to validators - assert self.sheet or self.sheet_id, ( - "You need to define either a 'sheet' name or a 'sheet_id' in your manifest." - ) - - def open_sheet(self): - if self.sheet: - return self.gsheets_client.open(self.sheet) - else: # self.sheet_id - return self.gsheets_client.open_by_key(self.sheet_id) - - def __iter__(self) -> Metadata: - sh = self.open_sheet() - for ii, worksheet in enumerate(sh.worksheets()): - if not self.should_process_sheet(worksheet.title): - logger.debug(f"SKIPPED worksheet '{worksheet.title}' due to allow/block rules") - continue - logger.info(f'Opening worksheet {ii=}: {worksheet.title=} header={self.header}') - gw = GWorksheet(worksheet, header_row=self.header, columns=self.columns) - if len(missing_cols := self.missing_required_columns(gw)): - logger.warning(f"SKIPPED worksheet '{worksheet.title}' due to missing required column(s) for {missing_cols}") - continue - - # process and yield metadata here: - yield from self._process_rows(gw) - logger.success(f'Finished worksheet {worksheet.title}') - - def _process_rows(self, gw: GWorksheet): - 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) - self._set_context(m, gw, row) - yield m - - def _set_context(self, m: Metadata, gw: GWorksheet, row: int) -> Metadata: - # TODO: Check folder value not being recognised - m.set_context("gsheet", {"row": row, "worksheet": gw}) - - if gw.get_cell_or_default(row, 'folder', "") is None: - folder = '' - else: - folder = slugify(gw.get_cell_or_default(row, 'folder', "").strip()) - if len(folder): - if self.use_sheet_names_in_stored_paths: - m.set_context("folder", os.path.join(folder, slugify(self.sheet), slugify(gw.wks.title))) - else: - m.set_context("folder", folder) - - - 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/modules/gsheet_feeder_db/__init__.py b/src/auto_archiver/modules/gsheet_feeder_db/__init__.py new file mode 100644 index 0000000..fbd37b9 --- /dev/null +++ b/src/auto_archiver/modules/gsheet_feeder_db/__init__.py @@ -0,0 +1,2 @@ +from .gworksheet import GWorksheet +from .gsheet_feeder_db import GsheetsFeederDB diff --git a/src/auto_archiver/modules/gsheet_feeder/__manifest__.py b/src/auto_archiver/modules/gsheet_feeder_db/__manifest__.py similarity index 54% rename from src/auto_archiver/modules/gsheet_feeder/__manifest__.py rename to src/auto_archiver/modules/gsheet_feeder_db/__manifest__.py index 77026ea..5143218 100644 --- a/src/auto_archiver/modules/gsheet_feeder/__manifest__.py +++ b/src/auto_archiver/modules/gsheet_feeder_db/__manifest__.py @@ -1,7 +1,7 @@ { - "name": "Google Sheets Feeder", - "type": ["feeder"], - "entry_point": "gsheet_feeder::GsheetsFeeder", + "name": "Google Sheets Feeder Database", + "type": ["feeder", "database"], + "entry_point": "gsheet_feeder_db::GsheetsFeederDB", "requires_setup": True, "dependencies": { "python": ["loguru", "gspread", "slugify"], @@ -15,7 +15,8 @@ "header": {"default": 1, "help": "index of the header row (starts at 1)", "type": "int"}, "service_account": { "default": "secrets/service_account.json", - "help": "service account JSON file path", + "help": "service account JSON file path. Learn how to create one: https://gspread.readthedocs.io/en/latest/oauth2.html", + "required": True, }, "columns": { "default": { @@ -34,16 +35,16 @@ "wacz": "wacz", "replaywebpage": "replaywebpage", }, - "help": "names of columns in the google sheet (stringified JSON object)", - "type": "auto_archiver.utils.json_loader", + "help": "Custom names for the columns in your Google sheet. If you don't want to use the default column names, change them with this setting", + "type": "json_loader", }, "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", + "help": "A list of worksheet names that should be processed (overrides worksheet_block), leave empty so all are allowed", }, "block_worksheets": { "default": set(), - "help": "(CSV) explicitly block some worksheets from being processed", + "help": "A list of worksheet names for worksheets that should be explicitly blocked from being processed", }, "use_sheet_names_in_stored_paths": { "default": True, @@ -52,8 +53,8 @@ }, }, "description": """ - GsheetsFeeder - A Google Sheets-based feeder for the Auto Archiver. + GsheetsFeederDatabase + A Google Sheets-based feeder and optional database for the Auto Archiver. This reads data from Google Sheets and filters rows based on user-defined rules. The filtered rows are processed into `Metadata` objects. @@ -63,9 +64,16 @@ - Processes only worksheets allowed by the `allow_worksheets` and `block_worksheets` configurations. - Ensures only rows with valid URLs and unprocessed statuses are included for archival. - Supports organizing stored files into folder paths based on sheet and worksheet names. + - If the database is enabled, this updates the Google Sheet with the status of the archived URLs, including in progress, success or failure, and method used. + - Saves metadata such as title, text, timestamp, hashes, screenshots, and media URLs to designated columns. + - Formats media-specific metadata, such as thumbnails and PDQ hashes for the sheet. + - Skips redundant updates for empty or invalid data fields. - ### Notes - - Requires a Google Service Account JSON file for authentication. Suggested location is `secrets/gsheets_service_account.json`. - - Create the sheet using the template provided in the docs. + ### Setup + - Requires a Google Service Account JSON file for authentication, which should be stored in `secrets/gsheets_service_account.json`. + To set up a service account, follow the instructions [here](https://gspread.readthedocs.io/en/latest/oauth2.html). + - Define the `sheet` or `sheet_id` configuration to specify the sheet to archive. + - Customize the column names in your Google sheet using the `columns` configuration. + - The Google Sheet can be used soley as a feeder or as a feeder and database, but note you can't currently feed into the database from an alternate feeder. """, } diff --git a/src/auto_archiver/modules/gsheet_feeder_db/gsheet_feeder_db.py b/src/auto_archiver/modules/gsheet_feeder_db/gsheet_feeder_db.py new file mode 100644 index 0000000..109be3f --- /dev/null +++ b/src/auto_archiver/modules/gsheet_feeder_db/gsheet_feeder_db.py @@ -0,0 +1,198 @@ +""" +GsheetsFeeder: A Google Sheets-based feeder for the Auto Archiver. + +This reads data from Google Sheets and filters rows based on user-defined rules. +The filtered rows are processed into `Metadata` objects. + +### Key properties +- validates the sheet's structure and filters rows based on input configurations. +- Ensures only rows with valid URLs and unprocessed statuses are included. +""" + +import os +from typing import Tuple, Union +from urllib.parse import quote + +import gspread +from loguru import logger +from slugify import slugify + +from auto_archiver.core import Feeder, Database, Media +from auto_archiver.core import Metadata +from auto_archiver.modules.gsheet_feeder_db import GWorksheet +from auto_archiver.utils.misc import get_current_timestamp + + +class GsheetsFeederDB(Feeder, Database): + def setup(self) -> None: + self.gsheets_client = gspread.service_account(filename=self.service_account) + # TODO mv to validators + if not self.sheet and not self.sheet_id: + raise ValueError("You need to define either a 'sheet' name or a 'sheet_id' in your manifest.") + + def open_sheet(self): + if self.sheet: + return self.gsheets_client.open(self.sheet) + else: # self.sheet_id + return self.gsheets_client.open_by_key(self.sheet_id) + + def __iter__(self) -> Metadata: + sh = self.open_sheet() + for ii, worksheet in enumerate(sh.worksheets()): + if not self.should_process_sheet(worksheet.title): + logger.debug(f"SKIPPED worksheet '{worksheet.title}' due to allow/block rules") + continue + logger.info(f"Opening worksheet {ii=}: {worksheet.title=} header={self.header}") + gw = GWorksheet(worksheet, header_row=self.header, columns=self.columns) + if len(missing_cols := self.missing_required_columns(gw)): + logger.warning( + f"SKIPPED worksheet '{worksheet.title}' due to missing required column(s) for {missing_cols}" + ) + continue + + # process and yield metadata here: + yield from self._process_rows(gw) + logger.success(f"Finished worksheet {worksheet.title}") + + def _process_rows(self, gw: GWorksheet): + 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) + self._set_context(m, gw, row) + yield m + + def _set_context(self, m: Metadata, gw: GWorksheet, row: int) -> Metadata: + # TODO: Check folder value not being recognised + m.set_context("gsheet", {"row": row, "worksheet": gw}) + + if gw.get_cell_or_default(row, "folder", "") is None: + folder = "" + else: + folder = slugify(gw.get_cell_or_default(row, "folder", "").strip()) + if len(folder): + if self.use_sheet_names_in_stored_paths: + m.set_context("folder", os.path.join(folder, slugify(self.sheet), slugify(gw.wks.title))) + else: + m.set_context("folder", folder) + + 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 + + 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, reason: str) -> None: + logger.error(f"FAILED {item}") + self._safe_status_update(item, f"Archive failed {reason}") + + 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, cached: bool = False) -> 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 + try: + if val and gw.col_exists(col) and gw.get_cell(row_values, col) == "": + cell_updates.append((row, col, final_value)) + except Exception as e: + logger.error(f"Unable to batch {col}={final_value} due to {e}") + + status_message = item.status + if cached: + status_message = f"[cached] {status_message}" + cell_updates.append((row, "status", status_message)) + + media: Media = item.get_final_media() + if hasattr(media, "urls"): + batch_if_valid("archive", "\n".join(media.urls)) + batch_if_valid("date", True, get_current_timestamp()) + batch_if_valid("title", item.get_title()) + batch_if_valid("text", item.get("content", "")) + batch_if_valid("timestamp", item.get_timestamp()) + if media: + batch_if_valid("hash", media.get("hash", "not-calculated")) + + # merge all pdq hashes into a single string, if present + pdq_hashes = [] + all_media = item.get_all_media() + for m in all_media: + if pdq := m.get("pdq_hash"): + pdq_hashes.append(pdq) + if len(pdq_hashes): + batch_if_valid("pdq_hash", ",".join(pdq_hashes)) + + 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]: + if gsheet := item.get_context("gsheet"): + gw: GWorksheet = gsheet.get("worksheet") + row: int = gsheet.get("row") + elif self.sheet_id: + logger.error( + f"Unable to retrieve Gsheet for {item.get_url()}, GsheetDB must be used alongside GsheetFeeder." + ) + + return gw, row diff --git a/src/auto_archiver/modules/gsheet_feeder/gworksheet.py b/src/auto_archiver/modules/gsheet_feeder_db/gworksheet.py similarity index 71% rename from src/auto_archiver/modules/gsheet_feeder/gworksheet.py rename to src/auto_archiver/modules/gsheet_feeder_db/gworksheet.py index 3044780..6dac059 100644 --- a/src/auto_archiver/modules/gsheet_feeder/gworksheet.py +++ b/src/auto_archiver/modules/gsheet_feeder_db/gworksheet.py @@ -5,23 +5,25 @@ class GWorksheet: """ This class makes read/write operations to the a worksheet easier. It can read the headers from a custom row number, but the row references - should always include the offset of the header. - eg: if header=4, row 5 will be the first with data. + should always include the offset of the header. + eg: if header=4, row 5 will be the first with data. """ + COLUMN_NAMES = { - 'url': 'link', - 'status': 'archive status', - 'folder': 'destination folder', - 'archive': 'archive location', - 'date': 'archive date', - 'thumbnail': 'thumbnail', - 'timestamp': 'upload timestamp', - 'title': 'upload title', - 'screenshot': 'screenshot', - 'hash': 'hash', - 'pdq_hash': 'perceptual hashes', - 'wacz': 'wacz', - 'replaywebpage': 'replaywebpage', + "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", + "pdq_hash": "perceptual hashes", + "wacz": "wacz", + "replaywebpage": "replaywebpage", } def __init__(self, worksheet, columns=COLUMN_NAMES, header_row=1): @@ -35,7 +37,7 @@ class GWorksheet: def _check_col_exists(self, col: str): if col not in self.columns: - raise Exception(f'Column {col} is not in the configured column names: {self.columns.keys()}') + raise Exception(f"Column {col} is not in the configured column names: {self.columns.keys()}") def _col_index(self, col: str): self._check_col_exists(col) @@ -57,7 +59,7 @@ class GWorksheet: def get_cell(self, row, col: str, fresh=False): """ - returns the cell value from (row, col), + returns the cell value from (row, col), where row can be an index (1-based) OR list of values as received from self.get_row(row) if fresh=True, the sheet is queried again for this cell @@ -66,11 +68,11 @@ class GWorksheet: if fresh: return self.wks.cell(row, col_index + 1).value - if type(row) == int: + if isinstance(row, int): row = self.get_row(row) if col_index >= len(row): - return '' + return "" return row[col_index] def get_cell_or_default(self, row, col: str, default: str = None, fresh=False, when_empty_use_default=True): @@ -82,7 +84,7 @@ class GWorksheet: if when_empty_use_default and val.strip() == "": return default return val - except: + except Exception: return default def set_cell(self, row: int, col: str, val): @@ -95,13 +97,9 @@ class GWorksheet: receives a list of [(row:int, col:str, val)] and batch updates it, the parameters are the same as in the self.set_cell() method """ cell_updates = [ - { - 'range': self.to_a1(row, col), - 'values': [[str(val)[0:49999]]] - } - for row, col, val in cell_updates + {"range": self.to_a1(row, col), "values": [[str(val)[0:49999]]]} for row, col, val in cell_updates ] - self.wks.batch_update(cell_updates, value_input_option='USER_ENTERED') + self.wks.batch_update(cell_updates, value_input_option="USER_ENTERED") def to_a1(self, row: int, col: str): # row is 1-based diff --git a/src/auto_archiver/modules/hash_enricher/__init__.py b/src/auto_archiver/modules/hash_enricher/__init__.py index 18ec885..3532e93 100644 --- a/src/auto_archiver/modules/hash_enricher/__init__.py +++ b/src/auto_archiver/modules/hash_enricher/__init__.py @@ -1 +1 @@ -from .hash_enricher import HashEnricher \ No newline at end of file +from .hash_enricher import HashEnricher diff --git a/src/auto_archiver/modules/hash_enricher/__manifest__.py b/src/auto_archiver/modules/hash_enricher/__manifest__.py index c7a023e..4f638de 100644 --- a/src/auto_archiver/modules/hash_enricher/__manifest__.py +++ b/src/auto_archiver/modules/hash_enricher/__manifest__.py @@ -3,16 +3,17 @@ "type": ["enricher"], "requires_setup": False, "dependencies": { - "python": ["loguru"], + "python": ["loguru"], }, "configs": { - "algorithm": {"default": "SHA-256", "help": "hash algorithm to use", "choices": ["SHA-256", "SHA3-512"]}, - # TODO add non-negative requirement to match previous implementation? - "chunksize": {"default": 16000000, - "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", - 'type': 'int', - }, + "algorithm": {"default": "SHA-256", "help": "hash algorithm to use", "choices": ["SHA-256", "SHA3-512"]}, + # TODO add non-negative requirement to match previous implementation? + "chunksize": { + "default": 16000000, + "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", + "type": "int", }, + }, "description": """ Generates cryptographic hashes for media files to ensure data integrity and authenticity. diff --git a/src/auto_archiver/modules/hash_enricher/hash_enricher.py b/src/auto_archiver/modules/hash_enricher/hash_enricher.py index 7a0587c..71425f2 100644 --- a/src/auto_archiver/modules/hash_enricher/hash_enricher.py +++ b/src/auto_archiver/modules/hash_enricher/hash_enricher.py @@ -1,4 +1,4 @@ -""" Hash Enricher for generating cryptographic hashes of media files. +"""Hash Enricher for generating cryptographic hashes of media files. The `HashEnricher` calculates cryptographic hashes (e.g., SHA-256, SHA3-512) for media files stored in `Metadata` objects. These hashes are used for @@ -7,6 +7,7 @@ exact duplicates. The hash is computed by reading the file's bytes in chunks, making it suitable for handling large files efficiently. """ + import hashlib from loguru import logger @@ -20,7 +21,6 @@ class HashEnricher(Enricher): Calculates hashes for Media instances """ - def enrich(self, to_enrich: Metadata) -> None: url = to_enrich.get_url() logger.debug(f"calculating media hashes for {url=} (using {self.algorithm})") @@ -35,5 +35,6 @@ class HashEnricher(Enricher): hash_algo = hashlib.sha256 elif self.algorithm == "SHA3-512": hash_algo = hashlib.sha3_512 - else: return "" + else: + return "" return calculate_file_hash(filename, hash_algo, self.chunksize) diff --git a/src/auto_archiver/modules/html_formatter/__init__.py b/src/auto_archiver/modules/html_formatter/__init__.py index 432ef33..fd1bb70 100644 --- a/src/auto_archiver/modules/html_formatter/__init__.py +++ b/src/auto_archiver/modules/html_formatter/__init__.py @@ -1 +1 @@ -from .html_formatter import HtmlFormatter \ No newline at end of file +from .html_formatter import HtmlFormatter diff --git a/src/auto_archiver/modules/html_formatter/__manifest__.py b/src/auto_archiver/modules/html_formatter/__manifest__.py index ec19cf8..6501e4f 100644 --- a/src/auto_archiver/modules/html_formatter/__manifest__.py +++ b/src/auto_archiver/modules/html_formatter/__manifest__.py @@ -2,12 +2,13 @@ "name": "HTML Formatter", "type": ["formatter"], "requires_setup": False, - "dependencies": { - "python": ["hash_enricher", "loguru", "jinja2"], - "bin": [""] - }, + "dependencies": {"python": ["hash_enricher", "loguru", "jinja2"], "bin": [""]}, "configs": { - "detect_thumbnails": {"default": True, "help": "if true will group by thumbnails generated by thumbnail enricher by id 'thumbnail_00'"} + "detect_thumbnails": { + "default": True, + "help": "if true will group by thumbnails generated by thumbnail enricher by id 'thumbnail_00'", + "type": "bool", }, + }, "description": """ """, } diff --git a/src/auto_archiver/modules/html_formatter/html_formatter.py b/src/auto_archiver/modules/html_formatter/html_formatter.py index deb4b44..f5da1d8 100644 --- a/src/auto_archiver/modules/html_formatter/html_formatter.py +++ b/src/auto_archiver/modules/html_formatter/html_formatter.py @@ -1,5 +1,7 @@ from __future__ import annotations -import mimetypes, os, pathlib +import mimetypes +import os +import pathlib from jinja2 import Environment, FileSystemLoader from urllib.parse import quote from loguru import logger @@ -11,6 +13,7 @@ from auto_archiver.core import Metadata, Media from auto_archiver.core import Formatter from auto_archiver.utils.misc import random_str + class HtmlFormatter(Formatter): environment: Environment = None template: any = None @@ -21,9 +24,9 @@ class HtmlFormatter(Formatter): self.environment = Environment(loader=FileSystemLoader(template_dir), autoescape=True) # 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.environment.filters.update( + {k: v.__func__ for k, v in JinjaHelpers.__dict__.items() if isinstance(v, staticmethod)} + ) # Load a specific template or default to "html_template.html" template_name = self.config.get("template_name", "html_template.html") @@ -36,11 +39,7 @@ class HtmlFormatter(Formatter): return content = self.template.render( - url=url, - title=item.get_title(), - media=item.media, - metadata=item.metadata, - version=__version__ + url=url, title=item.get_title(), media=item.media, metadata=item.metadata, version=__version__ ) html_path = os.path.join(self.tmp_dir, f"formatted{random_str(24)}.html") @@ -49,7 +48,7 @@ class HtmlFormatter(Formatter): final_media = Media(filename=html_path, _mimetype="text/html") # get the already instantiated hash_enricher module - he = self.module_factory.get_module('hash_enricher', self.config) + he = self.module_factory.get_module("hash_enricher", self.config) if len(hd := he.calculate_hash(final_media.filename)): final_media.set("hash", f"{he.algorithm}:{hd}") diff --git a/src/auto_archiver/modules/instagram_api_extractor/__manifest__.py b/src/auto_archiver/modules/instagram_api_extractor/__manifest__.py index 2d8f1d9..e10bd1e 100644 --- a/src/auto_archiver/modules/instagram_api_extractor/__manifest__.py +++ b/src/auto_archiver/modules/instagram_api_extractor/__manifest__.py @@ -2,18 +2,18 @@ "name": "Instagram API Extractor", "type": ["extractor"], "entry_point": "instagram_api_extractor::InstagramAPIExtractor", - "dependencies": - {"python": ["requests", - "loguru", - "retrying", - "tqdm",], - }, + "dependencies": { + "python": [ + "requests", + "loguru", + "retrying", + "tqdm", + ], + }, "requires_setup": True, "configs": { - "access_token": {"default": None, - "help": "a valid instagrapi-api token"}, - "api_endpoint": {"required": True, - "help": "API endpoint to use"}, + "access_token": {"default": None, "help": "a valid instagrapi-api token"}, + "api_endpoint": {"required": True, "help": "API endpoint to use"}, "full_profile": { "default": False, "type": "bool", diff --git a/src/auto_archiver/modules/instagram_api_extractor/instagram_api_extractor.py b/src/auto_archiver/modules/instagram_api_extractor/instagram_api_extractor.py index a75e065..5f13ecf 100644 --- a/src/auto_archiver/modules/instagram_api_extractor/instagram_api_extractor.py +++ b/src/auto_archiver/modules/instagram_api_extractor/instagram_api_extractor.py @@ -36,21 +36,16 @@ class InstagramAPIExtractor(Extractor): if self.api_endpoint[-1] == "/": self.api_endpoint = self.api_endpoint[:-1] - def download(self, item: Metadata) -> Metadata: url = item.get_url() - url.replace("instagr.com", "instagram.com").replace( - "instagr.am", "instagram.com" - ) + url.replace("instagr.com", "instagram.com").replace("instagr.am", "instagram.com") insta_matches = self.valid_url.findall(url) logger.info(f"{insta_matches=}") if not len(insta_matches) or len(insta_matches[0]) != 3: return if len(insta_matches) > 1: - logger.warning( - f"Multiple instagram matches found in {url=}, using the first one" - ) + logger.warning(f"Multiple instagram matches found in {url=}, using the first one") return g1, g2, g3 = insta_matches[0][0], insta_matches[0][1], insta_matches[0][2] if g1 == "": @@ -73,23 +68,20 @@ class InstagramAPIExtractor(Extractor): def call_api(self, path: str, params: dict) -> dict: headers = {"accept": "application/json", "x-access-key": self.access_token} logger.debug(f"calling {self.api_endpoint}/{path} with {params=}") - return requests.get( - f"{self.api_endpoint}/{path}", headers=headers, params=params - ).json() + return requests.get(f"{self.api_endpoint}/{path}", headers=headers, params=params).json() def cleanup_dict(self, d: dict | list) -> dict: # repeats 3 times to remove nested empty values if not self.minimize_json_output: return d - if type(d) == list: + if isinstance(d, list): return [self.cleanup_dict(v) for v in d] - if type(d) != dict: + if not isinstance(d, dict): return d return { k: clean_v for k, v in d.items() - if (clean_v := self.cleanup_dict(v)) - not in [0.0, 0, [], {}, "", None, "null"] + if (clean_v := self.cleanup_dict(v)) not in [0.0, 0, [], {}, "", None, "null"] and k not in ["x", "y", "width", "height"] } @@ -103,7 +95,7 @@ class InstagramAPIExtractor(Extractor): result.set_title(user.get("full_name", username)).set("data", user) if pic_url := user.get("profile_pic_url_hd", user.get("profile_pic_url")): filename = self.download_from_url(pic_url) - result.add_media(Media(filename=filename), id=f"profile_picture") + result.add_media(Media(filename=filename), id="profile_picture") if self.full_profile: user_id = user.get("pk") @@ -126,9 +118,7 @@ class InstagramAPIExtractor(Extractor): try: self.download_all_tagged(result, user_id) except Exception as e: - result.append( - "errors", f"Error downloading tagged posts for {username}" - ) + result.append("errors", f"Error downloading tagged posts for {username}") logger.error(f"Error downloading tagged posts for {username}: {e}") # download all highlights @@ -143,7 +133,7 @@ class InstagramAPIExtractor(Extractor): def download_all_highlights(self, result, username, user_id): count_highlights = 0 - highlights = self.call_api(f"v1/user/highlights", {"user_id": user_id}) + highlights = self.call_api("v1/user/highlights", {"user_id": user_id}) for h in highlights: try: h_info = self._download_highlights_reusable(result, h.get("pk")) @@ -153,26 +143,17 @@ class InstagramAPIExtractor(Extractor): "errors", f"Error downloading highlight id{h.get('pk')} for {username}", ) - logger.error( - f"Error downloading highlight id{h.get('pk')} for {username}: {e}" - ) - if ( - self.full_profile_max_posts - and count_highlights >= self.full_profile_max_posts - ): - logger.info( - f"HIGHLIGHTS reached full_profile_max_posts={self.full_profile_max_posts}" - ) + logger.error(f"Error downloading highlight id{h.get('pk')} for {username}: {e}") + if self.full_profile_max_posts and count_highlights >= self.full_profile_max_posts: + logger.info(f"HIGHLIGHTS reached full_profile_max_posts={self.full_profile_max_posts}") break result.set("#highlights", count_highlights) - def download_post( - self, result: Metadata, code: str = None, id: str = None, context: str = None - ) -> Metadata: + def download_post(self, result: Metadata, code: str = None, id: str = None, context: str = None) -> Metadata: if id: - post = self.call_api(f"v1/media/by/id", {"id": id}) + post = self.call_api("v1/media/by/id", {"id": id}) else: - post = self.call_api(f"v1/media/by/code", {"code": code}) + post = self.call_api("v1/media/by/code", {"code": code}) assert post, f"Post {id or code} not found" if caption_text := post.get("caption_text"): @@ -192,15 +173,11 @@ class InstagramAPIExtractor(Extractor): return result.success("insta highlights") def _download_highlights_reusable(self, result: Metadata, id: str) -> dict: - full_h = self.call_api(f"v2/highlight/by/id", {"id": id}) + full_h = self.call_api("v2/highlight/by/id", {"id": id}) h_info = full_h.get("response", {}).get("reels", {}).get(f"highlight:{id}") assert h_info, f"Highlight {id} not found: {full_h=}" - if ( - cover_media := h_info.get("cover_media", {}) - .get("cropped_image_version", {}) - .get("url") - ): + if cover_media := h_info.get("cover_media", {}).get("cropped_image_version", {}).get("url"): filename = self.download_from_url(cover_media) result.add_media(Media(filename=filename), id=f"cover_media highlight {id}") @@ -210,9 +187,7 @@ class InstagramAPIExtractor(Extractor): self.scrape_item(result, h, "highlight") except Exception as e: result.append("errors", f"Error downloading highlight {h.get('id')}") - logger.error( - f"Error downloading highlight, skipping {h.get('id')}: {e}" - ) + logger.error(f"Error downloading highlight, skipping {h.get('id')}: {e}") return h_info @@ -225,7 +200,7 @@ class InstagramAPIExtractor(Extractor): return result.success(f"insta stories {now}") def _download_stories_reusable(self, result: Metadata, username: str) -> list[dict]: - stories = self.call_api(f"v1/user/stories/by/username", {"username": username}) + stories = self.call_api("v1/user/stories/by/username", {"username": username}) if not stories or not len(stories): return [] stories = stories[::-1] # newest to oldest @@ -244,10 +219,8 @@ class InstagramAPIExtractor(Extractor): post_count = 0 while end_cursor != "": - posts = self.call_api( - f"v1/user/medias/chunk", {"user_id": user_id, "end_cursor": end_cursor} - ) - if not len(posts) or not type(posts) == list or len(posts) != 2: + posts = self.call_api("v1/user/medias/chunk", {"user_id": user_id, "end_cursor": end_cursor}) + if not posts or not isinstance(posts, list) or len(posts) != 2: break posts, end_cursor = posts[0], posts[1] logger.info(f"parsing {len(posts)} posts, next {end_cursor=}") @@ -260,13 +233,8 @@ class InstagramAPIExtractor(Extractor): logger.error(f"Error downloading post, skipping {p.get('id')}: {e}") pbar.update(1) post_count += 1 - if ( - self.full_profile_max_posts - and post_count >= self.full_profile_max_posts - ): - logger.info( - f"POSTS reached full_profile_max_posts={self.full_profile_max_posts}" - ) + if self.full_profile_max_posts and post_count >= self.full_profile_max_posts: + logger.info(f"POSTS reached full_profile_max_posts={self.full_profile_max_posts}") break result.set("#posts", post_count) @@ -275,10 +243,8 @@ class InstagramAPIExtractor(Extractor): pbar = tqdm(desc="downloading tagged posts") tagged_count = 0 - while next_page_id != None: - resp = self.call_api( - f"v2/user/tag/medias", {"user_id": user_id, "page_id": next_page_id} - ) + while next_page_id is not None: + resp = self.call_api("v2/user/tag/medias", {"user_id": user_id, "page_id": next_page_id}) posts = resp.get("response", {}).get("items", []) if not len(posts): break @@ -290,21 +256,12 @@ class InstagramAPIExtractor(Extractor): try: self.scrape_item(result, p, "tagged") except Exception as e: - result.append( - "errors", f"Error downloading tagged post {p.get('id')}" - ) - logger.error( - f"Error downloading tagged post, skipping {p.get('id')}: {e}" - ) + result.append("errors", f"Error downloading tagged post {p.get('id')}") + logger.error(f"Error downloading tagged post, skipping {p.get('id')}: {e}") pbar.update(1) tagged_count += 1 - if ( - self.full_profile_max_posts - and tagged_count >= self.full_profile_max_posts - ): - logger.info( - f"TAGS reached full_profile_max_posts={self.full_profile_max_posts}" - ) + if self.full_profile_max_posts and tagged_count >= self.full_profile_max_posts: + logger.info(f"TAGS reached full_profile_max_posts={self.full_profile_max_posts}") break result.set("#tagged", tagged_count) @@ -318,9 +275,7 @@ class InstagramAPIExtractor(Extractor): context can be used to give specific id prefixes to media """ if "clips_metadata" in item: - if reusable_text := item.get("clips_metadata", {}).get( - "reusable_text_attribute_string" - ): + if reusable_text := item.get("clips_metadata", {}).get("reusable_text_attribute_string"): item["clips_metadata_text"] = reusable_text if self.minimize_json_output: del item["clips_metadata"] diff --git a/src/auto_archiver/modules/instagram_extractor/__init__.py b/src/auto_archiver/modules/instagram_extractor/__init__.py index 6f39171..cefbcc5 100644 --- a/src/auto_archiver/modules/instagram_extractor/__init__.py +++ b/src/auto_archiver/modules/instagram_extractor/__init__.py @@ -1 +1 @@ -from .instagram_extractor import InstagramExtractor \ No newline at end of file +from .instagram_extractor import InstagramExtractor diff --git a/src/auto_archiver/modules/instagram_extractor/__manifest__.py b/src/auto_archiver/modules/instagram_extractor/__manifest__.py index 05cae19..6067c92 100644 --- a/src/auto_archiver/modules/instagram_extractor/__manifest__.py +++ b/src/auto_archiver/modules/instagram_extractor/__manifest__.py @@ -9,26 +9,30 @@ }, "requires_setup": True, "configs": { - "username": {"required": True, - "help": "a valid Instagram username"}, + "username": {"required": True, "help": "A valid Instagram username."}, "password": { "required": True, - "help": "the corresponding Instagram account password", + "help": "The corresponding Instagram account password.", }, "download_folder": { "default": "instaloader", - "help": "name of a folder to temporarily download content to", + "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", + "help": "Path to the instagram session file which saves session credentials. If one doesn't exist this gives the path to store a new one.", }, # TODO: fine-grain # "download_stories": {"default": True, "help": "if the link is to a user profile: whether to get stories information"}, }, "description": """ - Uses the [Instaloader library](https://instaloader.github.io/as-module.html) to download content from Instagram. This class handles both individual posts - and user profiles, downloading as much information as possible, including images, videos, text, stories, + Uses the [Instaloader library](https://instaloader.github.io/as-module.html) to download content from Instagram. + + > ⚠️ **Warning** + > This module is not actively maintained due to known issues with blocking. + > Prioritise usage of the [Instagram Tbot Extractor](./instagram_tbot_extractor.md) and [Instagram API Extractor](./instagram_api_extractor.md) + + This class handles both individual posts and user profiles, downloading as much information as possible, including images, videos, text, stories, highlights, and tagged posts. Authentication is required via username/password or a session file. diff --git a/src/auto_archiver/modules/instagram_extractor/instagram_extractor.py b/src/auto_archiver/modules/instagram_extractor/instagram_extractor.py index 0af2c32..294b4e7 100644 --- a/src/auto_archiver/modules/instagram_extractor/instagram_extractor.py +++ b/src/auto_archiver/modules/instagram_extractor/instagram_extractor.py @@ -1,9 +1,12 @@ -""" Uses the Instaloader library to download content from Instagram. This class handles both individual posts - and user profiles, downloading as much information as possible, including images, videos, text, stories, - highlights, and tagged posts. Authentication is required via username/password or a session file. +"""Uses the Instaloader library to download content from Instagram. This class handles both individual posts +and user profiles, downloading as much information as possible, including images, videos, text, stories, +highlights, and tagged posts. Authentication is required via username/password or a session file. """ -import re, os, shutil, traceback + +import re +import os +import shutil import instaloader from loguru import logger @@ -11,14 +14,14 @@ from auto_archiver.core import Extractor from auto_archiver.core import Metadata from auto_archiver.core import Media + class InstagramExtractor(Extractor): """ Uses Instaloader to download either a post (inc images, videos, text) or as much as possible from a profile (posts, stories, highlights, ...) """ + # NB: post regex should be tested before profile - valid_url = re.compile(r"(?:(?:http|https):\/\/)?(?:www.)?(?:instagram.com|instagr.am|instagr.com)\/") - # https://regex101.com/r/MGPquX/1 post_pattern = re.compile(r"{valid_url}(?:p|reel)\/(\w+)".format(valid_url=valid_url)) # https://regex101.com/r/6Wbsxa/1 @@ -26,22 +29,23 @@ class InstagramExtractor(Extractor): # TODO: links to stories def setup(self) -> None: - 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}" + 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()}") + except Exception: 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 + logger.debug("Session file failed", exc_info=True) + logger.info("No valid session file found - Attempting login with use and password.") + self.insta.login(self.username, self.password) 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()}") - - + except Exception as e: + logger.error(f"Failed to setup Instagram Extractor with Instagrapi. {e}") def download(self, item: Metadata) -> Metadata: url = item.get_url() @@ -51,7 +55,8 @@ class InstagramExtractor(Extractor): 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 + if not len(post_matches) and not len(profile_matches): + return result = None try: @@ -63,7 +68,9 @@ class InstagramExtractor(Extractor): elif len(profile_matches): result = self.download_profile(url, profile_matches[0]) except Exception as e: - logger.error(f"Failed to download with instagram extractor due to: {e}, make sure your account credentials are valid.") + logger.error( + f"Failed to download with instagram extractor due to: {e}, make sure your account credentials are valid." + ) finally: shutil.rmtree(self.download_folder, ignore_errors=True) return result @@ -82,35 +89,50 @@ class InstagramExtractor(Extractor): 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: + 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: + 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: + 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: + 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}") + 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) @@ -122,7 +144,8 @@ class InstagramExtractor(Extractor): 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 + if filename[-4:] == ".txt": + continue all_media.append(Media(filename)) assert len(all_media) > 1, "No uploaded media found" diff --git a/src/auto_archiver/modules/instagram_tbot_extractor/__manifest__.py b/src/auto_archiver/modules/instagram_tbot_extractor/__manifest__.py index a24a864..e3f94d0 100644 --- a/src/auto_archiver/modules/instagram_tbot_extractor/__manifest__.py +++ b/src/auto_archiver/modules/instagram_tbot_extractor/__manifest__.py @@ -1,16 +1,21 @@ { "name": "Instagram Telegram Bot Extractor", "type": ["extractor"], - "dependencies": {"python": ["loguru", "telethon",], - }, + "dependencies": { + "python": [ + "loguru", + "telethon", + ], + }, "requires_setup": True, "configs": { - "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, - "type": "int", - "help": "timeout to fetch the instagram content in seconds."}, + "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, "type": "int", "help": "timeout to fetch the instagram content in seconds."}, }, "description": """ The `InstagramTbotExtractor` module uses a Telegram bot (`instagram_load_bot`) to fetch and archive Instagram content, diff --git a/src/auto_archiver/modules/instagram_tbot_extractor/instagram_tbot_extractor.py b/src/auto_archiver/modules/instagram_tbot_extractor/instagram_tbot_extractor.py index 4404d07..81d2bf6 100644 --- a/src/auto_archiver/modules/instagram_tbot_extractor/instagram_tbot_extractor.py +++ b/src/auto_archiver/modules/instagram_tbot_extractor/instagram_tbot_extractor.py @@ -51,7 +51,7 @@ class InstagramTbotExtractor(Extractor): """Initializes the Telegram client.""" try: self.client = TelegramClient(self.session_file, self.api_id, self.api_hash) - except OperationalError as e: + except OperationalError: logger.error( f"Unable to access the {self.session_file} session. " "Ensure that you don't use the same session file here and in telethon_extractor. " @@ -65,15 +65,15 @@ class InstagramTbotExtractor(Extractor): session_file_name = self.session_file + ".session" if os.path.exists(session_file_name): os.remove(session_file_name) - + def download(self, item: Metadata) -> Metadata: url = item.get_url() - if not "instagram.com" in url: return False + if "instagram.com" not in url: + return False result = Metadata() tmp_dir = self.tmp_dir with self.client.start(): - chat, since_id = self._send_url_to_bot(url) message = self._process_messages(chat, since_id, tmp_dir, result) @@ -104,19 +104,20 @@ class InstagramTbotExtractor(Extractor): 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)): + while attempts < max(self.timeout - 3, 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) # Skip known filler message: - if post.message == 'The bot receives information through https://hikerapi.com/p/hJqpppqi': + if post.message == "The bot receives information through https://hikerapi.com/p/hJqpppqi": continue if post.media and post.id not in seen_media: - filename_dest = os.path.join(tmp_dir, f'{chat.id}_{post.id}') + 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 - return message.strip() \ No newline at end of file + if post.message: + message += post.message + return message.strip() diff --git a/src/auto_archiver/modules/local_storage/__init__.py b/src/auto_archiver/modules/local_storage/__init__.py index d23147d..e9c81f8 100644 --- a/src/auto_archiver/modules/local_storage/__init__.py +++ b/src/auto_archiver/modules/local_storage/__init__.py @@ -1 +1 @@ -from .local_storage import LocalStorage \ No newline at end of file +from .local_storage import LocalStorage diff --git a/src/auto_archiver/modules/local_storage/__manifest__.py b/src/auto_archiver/modules/local_storage/__manifest__.py index ed978a7..3a7f481 100644 --- a/src/auto_archiver/modules/local_storage/__manifest__.py +++ b/src/auto_archiver/modules/local_storage/__manifest__.py @@ -13,11 +13,15 @@ }, "filename_generator": { "default": "static", - "help": "how to name stored files: 'random' creates a random string; 'static' uses a replicable strategy such as a hash.", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled)", "choices": ["random", "static"], }, "save_to": {"default": "./local_archive", "help": "folder where to save archived content"}, - "save_absolute": {"default": True, "help": "whether the path to the stored file is absolute or relative in the output result inc. formatters (Warning: saving an absolute path will show your computer's file structure)"}, + "save_absolute": { + "default": False, + "type": "bool", + "help": "whether the path to the stored file is absolute or relative in the output result inc. formatters (Warning: saving an absolute path will show your computer's file structure)", + }, }, "description": """ LocalStorage: A storage module for saving archived content locally on the filesystem. @@ -31,5 +35,5 @@ ### Notes - Default storage folder is `./archived`, but this can be changed via the `save_to` configuration. - The `save_absolute` option can reveal the file structure in output formats; use with caution. - """ + """, } diff --git a/src/auto_archiver/modules/local_storage/local_storage.py b/src/auto_archiver/modules/local_storage/local_storage.py index b995577..fdc6978 100644 --- a/src/auto_archiver/modules/local_storage/local_storage.py +++ b/src/auto_archiver/modules/local_storage/local_storage.py @@ -1,4 +1,3 @@ - import shutil from typing import IO import os @@ -6,25 +5,43 @@ from loguru import logger from auto_archiver.core import Media from auto_archiver.core import Storage +from auto_archiver.core.consts import SetupError class LocalStorage(Storage): + def setup(self) -> None: + if len(self.save_to) > 200: + raise SetupError( + "Your save_to path is too long, this will cause issues saving files on your computer. Please use a shorter path." + ) 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) + dest = media.key + if self.save_absolute: dest = os.path.abspath(dest) return dest + def set_key(self, media, url, metadata): + # clarify we want to save the file to the save_to folder + + old_folder = metadata.get("folder", "") + metadata.set_context("folder", os.path.join(self.save_to, metadata.get("folder", ""))) + super().set_key(media, url, metadata) + # don't impact other storages that might want a different 'folder' set + metadata.set_context("folder", old_folder) + 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) + dest = 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}') + 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 + def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool: + pass diff --git a/src/auto_archiver/modules/meta_enricher/__manifest__.py b/src/auto_archiver/modules/meta_enricher/__manifest__.py index 37c9201..d3732a3 100644 --- a/src/auto_archiver/modules/meta_enricher/__manifest__.py +++ b/src/auto_archiver/modules/meta_enricher/__manifest__.py @@ -3,7 +3,7 @@ "type": ["enricher"], "requires_setup": False, "dependencies": { - "python": ["loguru"], + "python": ["loguru"], }, "description": """ Adds metadata information about the archive operations, Adds metadata about archive operations, including file sizes and archive duration./ diff --git a/src/auto_archiver/modules/meta_enricher/meta_enricher.py b/src/auto_archiver/modules/meta_enricher/meta_enricher.py index 03fb01e..9356b16 100644 --- a/src/auto_archiver/modules/meta_enricher/meta_enricher.py +++ b/src/auto_archiver/modules/meta_enricher/meta_enricher.py @@ -23,7 +23,9 @@ class MetaEnricher(Enricher): self.enrich_archive_duration(to_enrich) def enrich_file_sizes(self, to_enrich: Metadata): - logger.debug(f"calculating archive file sizes for url={to_enrich.get_url()} ({len(to_enrich.media)} media files)") + logger.debug( + f"calculating archive file sizes for url={to_enrich.get_url()} ({len(to_enrich.media)} media files)" + ) total_size = 0 for media in to_enrich.get_all_media(): file_stats = os.stat(media.filename) @@ -34,7 +36,6 @@ class MetaEnricher(Enricher): to_enrich.set("total_bytes", total_size) to_enrich.set("total_size", self.human_readable_bytes(total_size)) - def human_readable_bytes(self, size: int) -> str: # receives number of bytes and returns human readble size for unit in ["bytes", "KB", "MB", "GB", "TB"]: @@ -46,4 +47,4 @@ class MetaEnricher(Enricher): logger.debug(f"calculating archive duration for url={to_enrich.get_url()} ") archive_duration = datetime.datetime.now(datetime.timezone.utc) - to_enrich.get("_processed_at") - to_enrich.set("archive_duration_seconds", archive_duration.seconds) \ No newline at end of file + to_enrich.set("archive_duration_seconds", archive_duration.seconds) diff --git a/src/auto_archiver/modules/metadata_enricher/__init__.py b/src/auto_archiver/modules/metadata_enricher/__init__.py index 020bd4a..2fe894a 100644 --- a/src/auto_archiver/modules/metadata_enricher/__init__.py +++ b/src/auto_archiver/modules/metadata_enricher/__init__.py @@ -1 +1 @@ -from .metadata_enricher import MetadataEnricher \ No newline at end of file +from .metadata_enricher import MetadataEnricher diff --git a/src/auto_archiver/modules/metadata_enricher/__manifest__.py b/src/auto_archiver/modules/metadata_enricher/__manifest__.py index f8ccdc6..3727551 100644 --- a/src/auto_archiver/modules/metadata_enricher/__manifest__.py +++ b/src/auto_archiver/modules/metadata_enricher/__manifest__.py @@ -2,10 +2,7 @@ "name": "Media Metadata Enricher", "type": ["enricher"], "requires_setup": True, - "dependencies": { - "python": ["loguru"], - "bin": ["exiftool"] - }, + "dependencies": {"python": ["loguru"], "bin": ["exiftool"]}, "description": """ Extracts metadata information from files using ExifTool. @@ -17,5 +14,5 @@ ### Notes - Requires ExifTool to be installed and accessible via the system's PATH. - Skips enrichment for files where metadata extraction fails. - """ + """, } diff --git a/src/auto_archiver/modules/metadata_enricher/metadata_enricher.py b/src/auto_archiver/modules/metadata_enricher/metadata_enricher.py index c052d0a..e4fac44 100644 --- a/src/auto_archiver/modules/metadata_enricher/metadata_enricher.py +++ b/src/auto_archiver/modules/metadata_enricher/metadata_enricher.py @@ -11,7 +11,6 @@ class MetadataEnricher(Enricher): Extracts metadata information from files using exiftool. """ - def enrich(self, to_enrich: Metadata) -> None: url = to_enrich.get_url() logger.debug(f"extracting EXIF metadata for {url=}") @@ -23,13 +22,13 @@ class MetadataEnricher(Enricher): def get_metadata(self, filename: str) -> dict: try: # Run ExifTool command to extract metadata from the file - cmd = ['exiftool', filename] + cmd = ["exiftool", filename] result = subprocess.run(cmd, capture_output=True, text=True) # Process the output to extract individual metadata fields metadata = {} for line in result.stdout.splitlines(): - field, value = line.strip().split(':', 1) + field, value = line.strip().split(":", 1) metadata[field.strip()] = value.strip() return metadata except FileNotFoundError: diff --git a/src/auto_archiver/modules/mute_formatter/__manifest__.py b/src/auto_archiver/modules/mute_formatter/__manifest__.py index e81dc4c..185645e 100644 --- a/src/auto_archiver/modules/mute_formatter/__manifest__.py +++ b/src/auto_archiver/modules/mute_formatter/__manifest__.py @@ -2,8 +2,7 @@ "name": "Mute Formatter", "type": ["formatter"], "requires_setup": True, - "dependencies": { - }, + "dependencies": {}, "description": """ Default formatter. """, } diff --git a/src/auto_archiver/modules/mute_formatter/mute_formatter.py b/src/auto_archiver/modules/mute_formatter/mute_formatter.py index 129ddcb..b7c0ba5 100644 --- a/src/auto_archiver/modules/mute_formatter/mute_formatter.py +++ b/src/auto_archiver/modules/mute_formatter/mute_formatter.py @@ -5,5 +5,5 @@ from auto_archiver.core import Formatter class MuteFormatter(Formatter): - - def format(self, item: Metadata) -> Media: return None + def format(self, item: Metadata) -> Media: + return None diff --git a/src/auto_archiver/modules/opentimestamps_enricher/__manifest__.py b/src/auto_archiver/modules/opentimestamps_enricher/__manifest__.py new file mode 100644 index 0000000..283d114 --- /dev/null +++ b/src/auto_archiver/modules/opentimestamps_enricher/__manifest__.py @@ -0,0 +1,100 @@ +{ + "name": "OpenTimestamps Enricher", + "type": ["enricher"], + "requires_setup": True, + "dependencies": { + "python": [ + "loguru", + "opentimestamps", + ], + }, + "configs": { + "calendar_urls": { + "default": [ + "https://alice.btc.calendar.opentimestamps.org", + "https://bob.btc.calendar.opentimestamps.org", + "https://finney.calendar.eternitywall.com", + # "https://ots.btc.catallaxy.com/", # ipv4 only + ], + "help": "List of OpenTimestamps calendar servers to use for timestamping. See here for a list of calendars maintained by opentimestamps:\ +https://opentimestamps.org/#calendars", + "type": "list", + }, + "calendar_whitelist": { + "default": [], + "help": "Optional whitelist of calendar servers. Override this if you are using your own calendar servers. e.g. ['https://mycalendar.com']", + "type": "list", + }, + }, + "description": """ + Creates OpenTimestamps proofs for archived files, providing blockchain-backed evidence of file existence at a specific time. + + Uses OpenTimestamps – a service that timestamps data using the Bitcoin blockchain, providing a decentralized + and secure way to prove that data existed at a certain point in time. A SHA256 hash of the file to be timestamped is used as the token + and sent to each of the 'timestamp calendars' for inclusion in the blockchain. The proof is then saved alongside the original file in a file with + the '.ots' extension. + + ### Features + - Creates cryptographic timestamp proofs that link files to the Bitcoin + - Verifies timestamp proofs have been submitted to the blockchain (note: does not confirm they have been *added*) + - Can use multiple calendar servers to ensure reliability and redundancy + - Stores timestamp proofs alongside original files for future verification + + ### Timestamp status + An opentimestamp, when submitted to a timestmap server will have a 'pending' status (Pending Attestation) as it waits to be added + to the blockchain. Once it has been added to the blockchain, it will have a 'confirmed' status (Bitcoin Block Timestamp). + This process typically takes several hours, depending on the calendar server and the current state of the Bitcoin network. As such, + the status of all timestamps added will be 'pending' until they are subsequently confirmed (see 'Upgrading Timestamps' below). + + There are two possible statuses for a timestamp: + - `Pending`: The timestamp has been submitted to the calendar server but has not yet been confirmed in the Bitcoin blockchain. + - `Confirmed`: The timestamp has been confirmed in the Bitcoin blockchain. + + ### Upgrading Timestamps + To upgrade a timestamp from 'pending' to 'confirmed', you can use the `ots upgrade` command from the opentimestamps-client package + (install it with `pip install opentimesptamps-client`). + Example: `ots upgrade my_file.ots` + + Here is a useful script that could be used to upgrade all timestamps in a directory, which could be run on a cron job: +```{code} bash +find . -name "*.ots" -type f | while read file; do + echo "Upgrading OTS $file" + ots upgrade $file +done +# The result might look like: +# Upgrading OTS ./my_file.ots +# Got 1 attestation(s) from https://alice.btc.calendar.opentimestamps.org +# Success! Timestamp complete +``` + +```{note} Note: this will only upgrade the .ots files, and will not change the status text in any output .html files or any databases where the +metadata is stored (e.g. Google Sheets, CSV database, API database etc.). +``` + + ### Verifying Timestamps + The easiest way to verify a timestamp (ots) file is to install the opentimestamps-client command line tool and use the `ots verify` command. + Example: `ots verify my_file.ots` + + ```{code} bash +$ ots verify my_file.ots +Calendar https://bob.btc.calendar.opentimestamps.org: Pending confirmation in Bitcoin blockchain +Calendar https://finney.calendar.eternitywall.com: Pending confirmation in Bitcoin blockchain +Calendar https://alice.btc.calendar.opentimestamps.org: Timestamped by transaction 12345; waiting for 6 confirmations +``` + + Note: if you're using a storage with `filename_generator` set to `static` or `random`, the files will be renamed when they are saved to the + final location meaning you will need to specify the original filename when verifying the timestamp with `ots verify -f original_filename my_file.ots`. + + ### Choosing Calendar Servers + + By default, the OpenTimestamps enricher uses a set of public calendar servers provided by the 'opentimestamps' project. + You can customize the list of calendar servers by providing URLs in the `calendar_urls` configuration option. + + ### Calendar WhiteList + + By default, the opentimestamps package only allows their own calendars to be used (see `DEFAULT_CALENDAR_WHITELIST` in `opentimestamps.calendar`), + if you want to use your own calendars, then you can override this setting in the `calendar_whitelist` configuration option. + + + """, +} diff --git a/src/auto_archiver/modules/opentimestamps_enricher/opentimestamps_enricher.py b/src/auto_archiver/modules/opentimestamps_enricher/opentimestamps_enricher.py new file mode 100644 index 0000000..d909d8e --- /dev/null +++ b/src/auto_archiver/modules/opentimestamps_enricher/opentimestamps_enricher.py @@ -0,0 +1,172 @@ +import os + +from loguru import logger +import opentimestamps +from opentimestamps.calendar import RemoteCalendar, DEFAULT_CALENDAR_WHITELIST +from opentimestamps.core.timestamp import Timestamp, DetachedTimestampFile +from opentimestamps.core.notary import PendingAttestation, BitcoinBlockHeaderAttestation +from opentimestamps.core.op import OpSHA256 +from opentimestamps.core import serialize +from auto_archiver.core import Enricher +from auto_archiver.core import Metadata, Media +from auto_archiver.utils.misc import get_current_timestamp + + +class OpentimestampsEnricher(Enricher): + def enrich(self, to_enrich: Metadata) -> None: + url = to_enrich.get_url() + logger.debug(f"OpenTimestamps timestamping files for {url=}") + + # Get the media files to timestamp + media_files = [m for m in to_enrich.media if m.filename and not m.get("opentimestamps")] + if not media_files: + logger.warning(f"No files found to timestamp in {url=}") + return + + timestamp_files = [] + for media in media_files: + try: + # Get the file path from the media + file_path = media.filename + if not os.path.exists(file_path): + logger.warning(f"File not found: {file_path}") + continue + + # Create timestamp for the file - hash is SHA256 + # Note: hash is hard-coded to SHA256 and does not use hash_enricher to set it. + # SHA256 is the recommended hash, ref: https://github.com/bellingcat/auto-archiver/pull/247#discussion_r1992433181 + logger.debug(f"Creating timestamp for {file_path}") + file_hash = None + with open(file_path, "rb") as f: + file_hash = OpSHA256().hash_fd(f) + + if not file_hash: + logger.warning(f"Failed to hash file for timestamping, skipping: {file_path}") + continue + + # Create a timestamp with the file hash + timestamp = Timestamp(file_hash) + + # Create a detached timestamp file with the hash operation and timestamp + detached_timestamp = DetachedTimestampFile(OpSHA256(), timestamp) + + # Submit to calendar servers + submitted_to_calendar = False + + logger.debug(f"Submitting timestamp to calendar servers for {file_path}") + calendars = [] + whitelist = DEFAULT_CALENDAR_WHITELIST + + if self.calendar_whitelist: + whitelist = set(self.calendar_whitelist) + + # Create calendar instances + calendar_urls = [] + for url in self.calendar_urls: + if url in whitelist: + calendars.append(RemoteCalendar(url)) + calendar_urls.append(url) + + # Submit the hash to each calendar + for calendar in calendars: + try: + calendar_timestamp = calendar.submit(file_hash) + timestamp.merge(calendar_timestamp) + logger.debug(f"Successfully submitted to calendar: {calendar.url}") + submitted_to_calendar = True + except Exception as e: + logger.warning(f"Failed to submit to calendar {calendar.url}: {e}") + + # If all calendar submissions failed, add pending attestations + if not submitted_to_calendar and not timestamp.attestations: + logger.error( + f"Failed to submit to any calendar for {file_path}. **This file will not be timestamped.**" + ) + media.set("opentimestamps", False) + continue + + # Save the timestamp proof to a file + timestamp_path = os.path.join(self.tmp_dir, f"{os.path.basename(file_path)}.ots") + try: + with open(timestamp_path, "wb") as f: + # Create a serialization context and write to the file + ctx = serialize.BytesSerializationContext() + detached_timestamp.serialize(ctx) + f.write(ctx.getbytes()) + except Exception as e: + logger.warning(f"Failed to serialize timestamp file: {e}") + continue + + # Create media for the timestamp file + timestamp_media = Media(filename=timestamp_path) + # explicitly set the mimetype, normally .ots files are 'application/vnd.oasis.opendocument.spreadsheet-template' + timestamp_media.mimetype = "application/vnd.opentimestamps" + timestamp_media.set("opentimestamps_version", opentimestamps.__version__) + + verification_info = self.verify_timestamp(detached_timestamp) + for key, value in verification_info.items(): + timestamp_media.set(key, value) + + media.set("opentimestamp_files", [timestamp_media]) + timestamp_files.append(timestamp_media.filename) + # Update the original media to indicate it's been timestamped + media.set("opentimestamps", True) + + except Exception as e: + logger.warning(f"Error while timestamping {media.filename}: {e}") + + # Add timestamp files to the metadata + if timestamp_files: + to_enrich.set("opentimestamped", True) + to_enrich.set("opentimestamps_count", len(timestamp_files)) + logger.success(f"{len(timestamp_files)} OpenTimestamps proofs created for {url=}") + else: + to_enrich.set("opentimestamped", False) + logger.warning(f"No successful timestamps created for {url=}") + + def verify_timestamp(self, detached_timestamp): + """ + Verify a timestamp and extract verification information. + + Args: + detached_timestamp: The detached timestamp to verify. + + Returns: + dict: Information about the verification result. + """ + result = {} + + # Check if we have attestations + attestations = list(detached_timestamp.timestamp.all_attestations()) + result["attestation_count"] = len(attestations) + + if attestations: + attestation_info = [] + for msg, attestation in attestations: + info = {} + + # Process different types of attestations + if isinstance(attestation, PendingAttestation): + info["status"] = "pending" + info["uri"] = attestation.uri + + elif isinstance(attestation, BitcoinBlockHeaderAttestation): + info["status"] = "confirmed" + info["block_height"] = attestation.height + + info["last_check"] = get_current_timestamp() + + attestation_info.append(info) + + result["attestations"] = attestation_info + + # For at least one confirmed attestation + if any("confirmed" in a.get("status") for a in attestation_info): + result["verified"] = True + else: + result["verified"] = False + else: + result["verified"] = False + result["last_updated"] = get_current_timestamp() + + return result diff --git a/src/auto_archiver/modules/pdq_hash_enricher/__init__.py b/src/auto_archiver/modules/pdq_hash_enricher/__init__.py index b444197..88a964b 100644 --- a/src/auto_archiver/modules/pdq_hash_enricher/__init__.py +++ b/src/auto_archiver/modules/pdq_hash_enricher/__init__.py @@ -1 +1 @@ -from .pdq_hash_enricher import PdqHashEnricher \ No newline at end of file +from .pdq_hash_enricher import PdqHashEnricher diff --git a/src/auto_archiver/modules/pdq_hash_enricher/__manifest__.py b/src/auto_archiver/modules/pdq_hash_enricher/__manifest__.py index 133fef7..9c7a5c8 100644 --- a/src/auto_archiver/modules/pdq_hash_enricher/__manifest__.py +++ b/src/auto_archiver/modules/pdq_hash_enricher/__manifest__.py @@ -17,5 +17,5 @@ ### Notes - Best used after enrichers like `thumbnail_enricher` or `screenshot_enricher` to ensure images are available. - Uses the `pdqhash` library to compute 256-bit perceptual hashes, which are stored as hexadecimal strings. - """ + """, } diff --git a/src/auto_archiver/modules/pdq_hash_enricher/pdq_hash_enricher.py b/src/auto_archiver/modules/pdq_hash_enricher/pdq_hash_enricher.py index e812e8b..c7d4a47 100644 --- a/src/auto_archiver/modules/pdq_hash_enricher/pdq_hash_enricher.py +++ b/src/auto_archiver/modules/pdq_hash_enricher/pdq_hash_enricher.py @@ -10,6 +10,7 @@ This enricher is typically used after thumbnail or screenshot enrichers to ensure images are available for hashing. """ + import traceback import pdqhash import numpy as np @@ -34,7 +35,12 @@ class PdqHashEnricher(Enricher): for m in to_enrich.media: for media in m.all_inner_media(True): media_id = media.get("id", "") - if media.is_image() and "screenshot" not in media_id and "warc-file-" not in media_id and len(hd := self.calculate_pdq_hash(media.filename)): + if ( + media.is_image() + and "screenshot" not in media_id + and "warc-file-" not in media_id + and len(hd := self.calculate_pdq_hash(media.filename)) + ): media.set("pdq_hash", hd) media_with_hashes.append(media.filename) @@ -51,5 +57,7 @@ class PdqHashEnricher(Enricher): hash = "".join(str(b) for b in hash_array) return hex(int(hash, 2))[2:] except UnidentifiedImageError as e: - logger.error(f"Image {filename=} is likely corrupted or in unsupported format {e}: {traceback.format_exc()}") + logger.error( + f"Image {filename=} is likely corrupted or in unsupported format {e}: {traceback.format_exc()}" + ) return "" diff --git a/src/auto_archiver/modules/s3_storage/__init__.py b/src/auto_archiver/modules/s3_storage/__init__.py index cbf3237..5e388d1 100644 --- a/src/auto_archiver/modules/s3_storage/__init__.py +++ b/src/auto_archiver/modules/s3_storage/__init__.py @@ -1 +1 @@ -from .s3_storage import S3Storage \ No newline at end of file +from .s3_storage import S3Storage diff --git a/src/auto_archiver/modules/s3_storage/__manifest__.py b/src/auto_archiver/modules/s3_storage/__manifest__.py index bf032e7..3118d0e 100644 --- a/src/auto_archiver/modules/s3_storage/__manifest__.py +++ b/src/auto_archiver/modules/s3_storage/__manifest__.py @@ -13,27 +13,27 @@ }, "filename_generator": { "default": "static", - "help": "how to name stored files: 'random' creates a random string; 'static' uses a replicable strategy such as a hash.", + "help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled).", "choices": ["random", "static"], }, "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"}, - "random_no_duplicate": {"default": False, - "type": "bool", - "help": "if set, it will override `path_generator`, `filename_generator` and `folder`. It will check if the file already exists and if so it will not upload it again. Creates a new root folder path `no-dups/`"}, + "random_no_duplicate": { + "default": False, + "type": "bool", + "help": "if set, it will override `path_generator`, `filename_generator` and `folder`. It will check if the file already exists and if so it will not upload it again. Creates a new root folder path `no-dups/`", + }, "endpoint_url": { - "default": 'https://{region}.digitaloceanspaces.com', - "help": "S3 bucket endpoint, {region} are inserted at runtime" + "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" + "default": "https://{bucket}.{region}.cdn.digitaloceanspaces.com/{key}", + "help": "S3 CDN url, {bucket}, {region} and {key} are inserted at runtime", }, - "private": {"default": False, - "type": "bool", - "help": "if true S3 files will not be readable online"}, + "private": {"default": False, "type": "bool", "help": "if true S3 files will not be readable online"}, }, "description": """ S3Storage: A storage module for saving media files to an S3-compatible object storage. @@ -50,5 +50,5 @@ - The `random_no_duplicate` option ensures no duplicate uploads by leveraging hash-based folder structures. - Uses `boto3` for interaction with the S3 API. - Depends on the `HashEnricher` module for hash calculation. - """ + """, } diff --git a/src/auto_archiver/modules/s3_storage/s3_storage.py b/src/auto_archiver/modules/s3_storage/s3_storage.py index 6590ac9..abac4f7 100644 --- a/src/auto_archiver/modules/s3_storage/s3_storage.py +++ b/src/auto_archiver/modules/s3_storage/s3_storage.py @@ -1,4 +1,3 @@ - from typing import IO import boto3 @@ -11,60 +10,62 @@ from auto_archiver.utils.misc import calculate_file_hash, random_str NO_DUPLICATES_FOLDER = "no-dups/" -class S3Storage(Storage): +class S3Storage(Storage): def setup(self) -> None: self.s3 = boto3.client( - 's3', + "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 + aws_secret_access_key=self.secret, ) if self.random_no_duplicate: - logger.warning("random_no_duplicate is set to True, this will override `path_generator`, `filename_generator` and `folder`.") + logger.warning( + "random_no_duplicate is set to True, this will override `path_generator`, `filename_generator` and `folder`." + ) 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: - if not self.is_upload_needed(media): return True + if not self.is_upload_needed(media): + return True extra_args = kwargs.get("extra_args", {}) - if not self.private and 'ACL' not in extra_args: - extra_args['ACL'] = 'public-read' + if not self.private and "ACL" not in extra_args: + extra_args["ACL"] = "public-read" - if 'ContentType' not in extra_args: + if "ContentType" not in extra_args: try: if media.mimetype: - extra_args['ContentType'] = 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 is_upload_needed(self, media: Media) -> bool: if self.random_no_duplicate: # checks if a folder with the hash already exists, if so it skips the upload hd = calculate_file_hash(media.filename) path = os.path.join(NO_DUPLICATES_FOLDER, hd[:24]) - if existing_key:=self.file_in_folder(path): - media.key = existing_key + if existing_key := self.file_in_folder(path): + media._key = existing_key media.set("previously archived", True) logger.debug(f"skipping upload of {media.filename} because it already exists in {media.key}") return False - + _, ext = os.path.splitext(media.key) - media.key = os.path.join(path, f"{random_str(24)}{ext}") + media._key = os.path.join(path, f"{random_str(24)}{ext}") return True - def file_in_folder(self, path:str) -> str: + def file_in_folder(self, path: str) -> str: # checks if path exists and is not an empty folder - if not path.endswith('/'): - path = path + '/' - resp = self.s3.list_objects(Bucket=self.bucket, Prefix=path, Delimiter='/', MaxKeys=1) - if 'Contents' in resp: - return resp['Contents'][0]['Key'] + if not path.endswith("/"): + path = path + "/" + resp = self.s3.list_objects(Bucket=self.bucket, Prefix=path, Delimiter="/", MaxKeys=1) + if "Contents" in resp: + return resp["Contents"][0]["Key"] return False - diff --git a/src/auto_archiver/modules/screenshot_enricher/__manifest__.py b/src/auto_archiver/modules/screenshot_enricher/__manifest__.py index 9829844..db04e6c 100644 --- a/src/auto_archiver/modules/screenshot_enricher/__manifest__.py +++ b/src/auto_archiver/modules/screenshot_enricher/__manifest__.py @@ -6,14 +6,29 @@ "python": ["loguru", "selenium"], }, "configs": { - "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"}, - "http_proxy": {"default": "", "help": "http proxy to use for the webdriver, eg http://proxy-user:password@proxy-ip:port"}, - "save_to_pdf": {"default": False, "help": "save the page as pdf along with the screenshot. PDF saving options can be adjusted with the 'print_options' parameter"}, - "print_options": {"default": {}, "help": "options to pass to the pdf printer"} + "width": {"default": 1280, "type": "int", "help": "width of the screenshots"}, + "height": {"default": 1024, "type": "int", "help": "height of the screenshots"}, + "timeout": {"default": 60, "type": "int", "help": "timeout for taking the screenshot"}, + "sleep_before_screenshot": { + "default": 4, + "type": "int", + "help": "seconds to wait for the pages to load before taking screenshot", }, + "http_proxy": { + "default": "", + "help": "http proxy to use for the webdriver, eg http://proxy-user:password@proxy-ip:port", + }, + "save_to_pdf": { + "default": False, + "type": "bool", + "help": "save the page as pdf along with the screenshot. PDF saving options can be adjusted with the 'print_options' parameter", + }, + "print_options": { + "default": {}, + "help": "options to pass to the pdf printer, in JSON format. See https://www.selenium.dev/documentation/webdriver/interactions/print_page/ for more information", + "type": "json_loader", + }, + }, "description": """ Captures screenshots and optionally saves web pages as PDFs using a WebDriver. @@ -25,5 +40,5 @@ ### Notes - Requires a WebDriver (e.g., ChromeDriver) installed and accessible via the system's PATH. - """ + """, } diff --git a/src/auto_archiver/modules/screenshot_enricher/screenshot_enricher.py b/src/auto_archiver/modules/screenshot_enricher/screenshot_enricher.py index 832d0f8..491bd51 100644 --- a/src/auto_archiver/modules/screenshot_enricher/screenshot_enricher.py +++ b/src/auto_archiver/modules/screenshot_enricher/screenshot_enricher.py @@ -1,5 +1,6 @@ from loguru import logger -import time, os +import time +import os import base64 from selenium.common.exceptions import TimeoutException @@ -9,8 +10,8 @@ from auto_archiver.core import Enricher from auto_archiver.utils import Webdriver, url as UrlUtil, random_str from auto_archiver.core import Media, Metadata -class ScreenshotEnricher(Enricher): +class ScreenshotEnricher(Enricher): def __init__(self, webdriver_factory=None): super().__init__() self.webdriver_factory = webdriver_factory or Webdriver @@ -25,8 +26,14 @@ class ScreenshotEnricher(Enricher): logger.debug(f"Enriching screenshot for {url=}") auth = self.auth_for_site(url) with self.webdriver_factory( - self.width, self.height, self.timeout, facebook_accept_cookies='facebook.com' in url, - http_proxy=self.http_proxy, print_options=self.print_options, auth=auth) as driver: + self.width, + self.height, + self.timeout, + facebook_accept_cookies="facebook.com" in url, + http_proxy=self.http_proxy, + print_options=self.print_options, + auth=auth, + ) as driver: try: driver.get(url) time.sleep(int(self.sleep_before_screenshot)) @@ -43,4 +50,3 @@ class ScreenshotEnricher(Enricher): 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/modules/ssl_enricher/__init__.py b/src/auto_archiver/modules/ssl_enricher/__init__.py index 23d2bee..86b9638 100644 --- a/src/auto_archiver/modules/ssl_enricher/__init__.py +++ b/src/auto_archiver/modules/ssl_enricher/__init__.py @@ -1 +1 @@ -from .ssl_enricher import SSLEnricher \ No newline at end of file +from .ssl_enricher import SSLEnricher diff --git a/src/auto_archiver/modules/ssl_enricher/__manifest__.py b/src/auto_archiver/modules/ssl_enricher/__manifest__.py index 9028f14..959fe2f 100644 --- a/src/auto_archiver/modules/ssl_enricher/__manifest__.py +++ b/src/auto_archiver/modules/ssl_enricher/__manifest__.py @@ -5,9 +5,13 @@ "dependencies": { "python": ["loguru", "slugify"], }, - 'entry_point': 'ssl_enricher::SSLEnricher', + "entry_point": "ssl_enricher::SSLEnricher", "configs": { - "skip_when_nothing_archived": {"default": True, "help": "if true, will skip enriching when no media is archived"}, + "skip_when_nothing_archived": { + "default": True, + "type": "bool", + "help": "if true, will skip enriching when no media is archived", + }, }, "description": """ Retrieves SSL certificate information for a domain and stores it as a file. @@ -19,5 +23,5 @@ ### Notes - Requires the target URL to use the HTTPS scheme; other schemes are not supported. - """ + """, } diff --git a/src/auto_archiver/modules/ssl_enricher/ssl_enricher.py b/src/auto_archiver/modules/ssl_enricher/ssl_enricher.py index b429163..3ab1389 100644 --- a/src/auto_archiver/modules/ssl_enricher/ssl_enricher.py +++ b/src/auto_archiver/modules/ssl_enricher/ssl_enricher.py @@ -1,4 +1,5 @@ -import ssl, os +import ssl +import os from slugify import slugify from urllib.parse import urlparse from loguru import logger @@ -13,16 +14,18 @@ class SSLEnricher(Enricher): """ def enrich(self, to_enrich: Metadata) -> None: - if not to_enrich.media and self.skip_when_nothing_archived: return - + if not to_enrich.media and self.skip_when_nothing_archived: + return + url = to_enrich.get_url() parsed = urlparse(url) assert parsed.scheme in ["https"], f"Invalid URL scheme {url=}" - + domain = parsed.netloc logger.debug(f"fetching SSL certificate for {domain=} in {url=}") cert = ssl.get_server_certificate((domain, 443)) cert_fn = os.path.join(self.tmp_dir, f"{slugify(domain)}.pem") - with open(cert_fn, "w") as f: f.write(cert) + with open(cert_fn, "w") as f: + f.write(cert) to_enrich.add_media(Media(filename=cert_fn), id="ssl_certificate") diff --git a/src/auto_archiver/modules/telegram_extractor/__init__.py b/src/auto_archiver/modules/telegram_extractor/__init__.py index 1fd80c2..18b73c2 100644 --- a/src/auto_archiver/modules/telegram_extractor/__init__.py +++ b/src/auto_archiver/modules/telegram_extractor/__init__.py @@ -1 +1 @@ -from .telegram_extractor import TelegramExtractor \ No newline at end of file +from .telegram_extractor import TelegramExtractor diff --git a/src/auto_archiver/modules/telegram_extractor/telegram_extractor.py b/src/auto_archiver/modules/telegram_extractor/telegram_extractor.py index d612e24..e70198d 100644 --- a/src/auto_archiver/modules/telegram_extractor/telegram_extractor.py +++ b/src/auto_archiver/modules/telegram_extractor/telegram_extractor.py @@ -1,4 +1,6 @@ -import requests, re, html +import requests +import re +import html from bs4 import BeautifulSoup from loguru import logger @@ -15,11 +17,11 @@ class TelegramExtractor(Extractor): def download(self, item: Metadata) -> Metadata: url = item.get_url() # detect URLs that we definitely cannot handle - if 't.me' != item.netloc: + 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' + "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 @@ -27,11 +29,11 @@ class TelegramExtractor(Extractor): url += "?embed=1" t = requests.get(url, headers=headers) - s = BeautifulSoup(t.content, 'html.parser') + 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')): + if timestamp := (s.find_all("time") or [{}])[0].get("datetime"): result.set_timestamp(timestamp) video = s.find("video") @@ -41,25 +43,26 @@ class TelegramExtractor(Extractor): image_urls = [] for im in image_tags: - urls = [u.replace("'", "") for u in re.findall(r'url\((.*?)\)', im['style'])] + urls = [u.replace("'", "") for u in re.findall(r"url\((.*?)\)", im["style"])] image_urls += urls - if not len(image_urls): return False + 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') + 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]) + 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 + except Exception: + pass result.add_media(m_video) return result.success("telegram") diff --git a/src/auto_archiver/modules/telethon_extractor/__init__.py b/src/auto_archiver/modules/telethon_extractor/__init__.py index 2eaa57c..9d5e963 100644 --- a/src/auto_archiver/modules/telethon_extractor/__init__.py +++ b/src/auto_archiver/modules/telethon_extractor/__init__.py @@ -1 +1 @@ -from .telethon_extractor import TelethonExtractor \ No newline at end of file +from .telethon_extractor import TelethonExtractor diff --git a/src/auto_archiver/modules/telethon_extractor/__manifest__.py b/src/auto_archiver/modules/telethon_extractor/__manifest__.py index e16d9db..150b62c 100644 --- a/src/auto_archiver/modules/telethon_extractor/__manifest__.py +++ b/src/auto_archiver/modules/telethon_extractor/__manifest__.py @@ -3,24 +3,35 @@ "type": ["extractor"], "requires_setup": True, "dependencies": { - "python": ["telethon", - "loguru", - "tqdm", - ], - "bin": [""] + "python": [ + "telethon", + "loguru", + "tqdm", + ], + "bin": [""], }, "configs": { - "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", - "type": "auto_archiver.utils.json_loader", - } + "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, + "type": "bool", + "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", + "type": "json_loader", + }, + }, "description": """ The `TelethonExtractor` uses the Telethon library to archive posts and media from Telegram channels and groups. It supports private and public channels, downloading grouped posts with media, and can join channels using invite links @@ -44,5 +55,5 @@ To use the `TelethonExtractor`, you must configure the following: 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. -""" -} \ No newline at end of file +""", +} diff --git a/src/auto_archiver/modules/telethon_extractor/telethon_extractor.py b/src/auto_archiver/modules/telethon_extractor/telethon_extractor.py index 65ea8cd..b06962e 100644 --- a/src/auto_archiver/modules/telethon_extractor/telethon_extractor.py +++ b/src/auto_archiver/modules/telethon_extractor/telethon_extractor.py @@ -1,12 +1,18 @@ - import shutil 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 telethon.errors.rpcerrorlist import ( + UserAlreadyParticipantError, + FloodWaitError, + InviteRequestSentError, + InviteHashExpiredError, +) from loguru import logger from tqdm import tqdm -import re, time, os +import re +import time +import os from auto_archiver.core import Extractor from auto_archiver.core import Metadata, Media @@ -17,9 +23,7 @@ class TelethonExtractor(Extractor): valid_url = re.compile(r"https:\/\/t\.me(\/c){0,1}\/(.+)\/(\d+)") invite_pattern = re.compile(r"t.me(\/joinchat){0,1}\/\+?(.+)") - def setup(self) -> None: - """ 1. makes a copy of session_file that is removed in cleanup 2. trigger login process for telegram or proceed if already saved in a session file @@ -34,7 +38,7 @@ class TelethonExtractor(Extractor): # initiate the client self.client = TelegramClient(self.session_file, self.api_id, self.api_hash) - + with self.client.start(): logger.success(f"SETUP {self.name} login works.") @@ -52,18 +56,20 @@ class TelethonExtractor(Extractor): channel_invite = self.channel_invites[i] channel_id = channel_invite.get("id", False) invite = channel_invite["invite"] - if (match := self.invite_pattern.search(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.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: logger.info(f"joining new channel {invite=}") try: self.client(ImportChatInviteRequest(match.group(2))) - except UserAlreadyParticipantError as e: + except UserAlreadyParticipantError: logger.info(f"already joined {invite=}") except InviteRequestSentError: logger.warning(f"already sent a join request with {invite} still no answer") @@ -95,7 +101,8 @@ class TelethonExtractor(Extractor): # detect URLs that we definitely cannot handle match = self.valid_url.search(url) logger.debug(f"TELETHON: {match=}") - if not match: return False + if not match: + return False is_private = match.group(1) == "/c" chat = int(match.group(2)) if is_private else match.group(2) @@ -105,45 +112,53 @@ class TelethonExtractor(Extractor): # 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): + # 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}") + 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 + 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=}') + logger.debug(f"got {len(media_posts)=} for {url=}") tmp_dir = self.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) + 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"]] + 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}') + filename = self.download_from_url(om_url, f"{chat}_{group_id}_{i}") 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_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_title(title).set_timestamp(post.date).set("api_data", post.to_dict()) if post.message != title: result.set_content(post.message) diff --git a/src/auto_archiver/modules/thumbnail_enricher/__manifest__.py b/src/auto_archiver/modules/thumbnail_enricher/__manifest__.py index 1bd23b5..b11d17b 100644 --- a/src/auto_archiver/modules/thumbnail_enricher/__manifest__.py +++ b/src/auto_archiver/modules/thumbnail_enricher/__manifest__.py @@ -2,18 +2,19 @@ "name": "Thumbnail Enricher", "type": ["enricher"], "requires_setup": False, - "dependencies": { - "python": ["loguru", "ffmpeg"], - "bin": ["ffmpeg"] - }, + "dependencies": {"python": ["loguru", "ffmpeg"], "bin": ["ffmpeg"]}, "configs": { - "thumbnails_per_minute": {"default": 60, - "type": "int", - "help": "how many thumbnails to generate per minute of video, can be limited by max_thumbnails"}, - "max_thumbnails": {"default": 16, - "type": "int", - "help": "limit the number of thumbnails to generate per video, 0 means no limit"}, + "thumbnails_per_minute": { + "default": 60, + "type": "int", + "help": "how many thumbnails to generate per minute of video, can be limited by max_thumbnails", }, + "max_thumbnails": { + "default": 16, + "type": "int", + "help": "limit the number of thumbnails to generate per video, 0 means no limit", + }, + }, "description": """ Generates thumbnails for video files to provide visual previews. @@ -27,5 +28,5 @@ - Requires `ffmpeg` to be installed and accessible via the system's PATH. - Handles videos without pre-existing duration metadata by probing with `ffmpeg`. - Skips enrichment for non-video media files. - """ + """, } diff --git a/src/auto_archiver/modules/thumbnail_enricher/thumbnail_enricher.py b/src/auto_archiver/modules/thumbnail_enricher/thumbnail_enricher.py index 8178cd8..1543cec 100644 --- a/src/auto_archiver/modules/thumbnail_enricher/thumbnail_enricher.py +++ b/src/auto_archiver/modules/thumbnail_enricher/thumbnail_enricher.py @@ -6,7 +6,9 @@ visual snapshots of the video's keyframes, helping users preview content and identify important moments without watching the entire video. """ -import ffmpeg, os + +import ffmpeg +import os from loguru import logger from auto_archiver.core import Enricher @@ -18,7 +20,7 @@ class ThumbnailEnricher(Enricher): """ Generates thumbnails for all the media """ - + def enrich(self, to_enrich: Metadata) -> None: """ Uses or reads the video duration to generate thumbnails @@ -36,7 +38,9 @@ class ThumbnailEnricher(Enricher): if duration is None: try: probe = ffmpeg.probe(m.filename) - duration = float(next(stream for stream in probe['streams'] if stream['codec_type'] == 'video')['duration']) + duration = float( + next(stream for stream in probe["streams"] if stream["codec_type"] == "video")["duration"] + ) to_enrich.media[m_id].set("duration", duration) except Exception as e: logger.error(f"error getting duration of video {m.filename}: {e}") @@ -48,11 +52,13 @@ class ThumbnailEnricher(Enricher): thumbnails_media = [] for index, timestamp in enumerate(timestamps): output_path = os.path.join(folder, f"out{index}.jpg") - ffmpeg.input(m.filename, ss=timestamp).filter('scale', 512, -1).output(output_path, vframes=1, loglevel="quiet").run() + ffmpeg.input(m.filename, ss=timestamp).filter("scale", 512, -1).output( + output_path, vframes=1, loglevel="quiet" + ).run() try: - thumbnails_media.append(Media( - filename=output_path) + thumbnails_media.append( + Media(filename=output_path) .set("id", f"thumbnail_{index}") .set("timestamp", "%.3fs" % timestamp) ) diff --git a/src/auto_archiver/modules/timestamping_enricher/__manifest__.py b/src/auto_archiver/modules/timestamping_enricher/__manifest__.py index 6ad9c57..c451437 100644 --- a/src/auto_archiver/modules/timestamping_enricher/__manifest__.py +++ b/src/auto_archiver/modules/timestamping_enricher/__manifest__.py @@ -3,38 +3,29 @@ "type": ["enricher"], "requires_setup": True, "dependencies": { - "python": [ - "loguru", - "slugify", - "tsp_client", - "asn1crypto", - "certvalidator", - "certifi" - ], + "python": ["loguru", "slugify", "tsp_client", "asn1crypto", "certvalidator", "certifi"], }, "configs": { "tsa_urls": { "default": [ - # [Adobe Approved Trust List] and [Windows Cert Store] - "http://timestamp.digicert.com", - "http://timestamp.identrust.com", - # "https://timestamp.entrust.net/TSS/RFC3161sha2TS", # not valid for timestamping - # "https://timestamp.sectigo.com", # wait 15 seconds between each request. - - # [Adobe: European Union Trusted Lists]. - # "https://timestamp.sectigo.com/qualified", # wait 15 seconds between each request. - - # [Windows Cert Store] - "http://timestamp.globalsign.com/tsa/r6advanced1", - # [Adobe: European Union Trusted Lists] and [Windows Cert Store] - # "http://ts.quovadisglobal.com/eu", # not valid for timestamping - # "http://tsa.belgium.be/connect", # self-signed certificate in certificate chain - # "https://timestamp.aped.gov.gr/qtss", # self-signed certificate in certificate chain - # "http://tsa.sep.bg", # self-signed certificate in certificate chain - # "http://tsa.izenpe.com", #unable to get local issuer certificate - # "http://kstamp.keynectis.com/KSign", # unable to get local issuer certificate - "http://tss.accv.es:8318/tsa", - ], + # [Adobe Approved Trust List] and [Windows Cert Store] + "http://timestamp.digicert.com", + "http://timestamp.identrust.com", + # "https://timestamp.entrust.net/TSS/RFC3161sha2TS", # not valid for timestamping + # "https://timestamp.sectigo.com", # wait 15 seconds between each request. + # [Adobe: European Union Trusted Lists]. + # "https://timestamp.sectigo.com/qualified", # wait 15 seconds between each request. + # [Windows Cert Store] + "http://timestamp.globalsign.com/tsa/r6advanced1", + # [Adobe: European Union Trusted Lists] and [Windows Cert Store] + # "http://ts.quovadisglobal.com/eu", # not valid for timestamping + # "http://tsa.belgium.be/connect", # self-signed certificate in certificate chain + # "https://timestamp.aped.gov.gr/qtss", # self-signed certificate in certificate chain + # "http://tsa.sep.bg", # self-signed certificate in certificate chain + # "http://tsa.izenpe.com", #unable to get local issuer certificate + # "http://kstamp.keynectis.com/KSign", # unable to get local issuer certificate + "http://tss.accv.es:8318/tsa", + ], "help": "List of RFC3161 Time Stamp Authorities to use, separate with commas if passed via the command line.", } }, @@ -50,5 +41,5 @@ ### Notes - Should be run after the `hash_enricher` to ensure file hashes are available. - Requires internet access to interact with the configured TSAs. - """ + """, } diff --git a/src/auto_archiver/modules/timestamping_enricher/timestamping_enricher.py b/src/auto_archiver/modules/timestamping_enricher/timestamping_enricher.py index 078c1ba..586b7f8 100644 --- a/src/auto_archiver/modules/timestamping_enricher/timestamping_enricher.py +++ b/src/auto_archiver/modules/timestamping_enricher/timestamping_enricher.py @@ -11,6 +11,7 @@ import certifi from auto_archiver.core import Enricher from auto_archiver.core import Metadata, Media + class TimestampingEnricher(Enricher): """ Uses several RFC3161 Time Stamp Authorities to generate a timestamp token that will be preserved. This can be used to prove that a certain file existed at a certain time, useful for legal purposes, for example, to prove that a certain file was not tampered with after a certain date. @@ -25,27 +26,30 @@ class TimestampingEnricher(Enricher): logger.debug(f"RFC3161 timestamping existing files for {url=}") # create a new text file with the existing media hashes - hashes = [m.get("hash").replace("SHA-256:", "").replace("SHA3-512:", "") for m in to_enrich.media if m.get("hash")] + hashes = [ + m.get("hash").replace("SHA-256:", "").replace("SHA3-512:", "") for m in to_enrich.media if m.get("hash") + ] if not len(hashes): logger.warning(f"No hashes found in {url=}") return - + tmp_dir = self.tmp_dir hashes_fn = os.path.join(tmp_dir, "hashes.txt") data_to_sign = "\n".join(hashes) - with open(hashes_fn, "w") as f: + with open(hashes_fn, "w") as f: f.write(data_to_sign) hashes_media = Media(filename=hashes_fn) timestamp_tokens = [] from slugify import slugify + for tsa_url in self.tsa_urls: try: signing_settings = SigningSettings(tsp_server=tsa_url, digest_algorithm=DigestAlgorithm.SHA256) signer = TSPSigner() - message = bytes(data_to_sign, encoding='utf8') + message = bytes(data_to_sign, encoding="utf8") # send TSQ and get TSR from the TSA server signed = signer.sign(message=message, signing_settings=signing_settings) # fail if there's any issue with the certificates, uses certifi list of trusted CAs @@ -54,7 +58,8 @@ class TimestampingEnricher(Enricher): cert_chain = self.download_and_verify_certificate(signed) # continue with saving the timestamp token tst_fn = os.path.join(tmp_dir, f"timestamp_token_{slugify(tsa_url)}") - with open(tst_fn, "wb") as f: f.write(signed) + with open(tst_fn, "wb") as f: + f.write(signed) timestamp_tokens.append(Media(filename=tst_fn).set("tsa", tsa_url).set("cert_chain", cert_chain)) except Exception as e: logger.warning(f"Error while timestamping {url=} with {tsa_url=}: {e}") @@ -75,7 +80,7 @@ class TimestampingEnricher(Enricher): tst = ContentInfo.load(signed) trust_roots = [] - with open(certifi.where(), 'rb') as f: + with open(certifi.where(), "rb") as f: for _, _, der_bytes in pem.unarmor(f.read(), multiple=True): trust_roots.append(der_bytes) context = ValidationContext(trust_roots=trust_roots) @@ -83,11 +88,11 @@ class TimestampingEnricher(Enricher): certificates = tst["content"]["certificates"] first_cert = certificates[0].dump() intermediate_certs = [] - for i in range(1, len(certificates)): # cannot use list comprehension [1:] + for i in range(1, len(certificates)): # cannot use list comprehension [1:] intermediate_certs.append(certificates[i].dump()) validator = CertificateValidator(first_cert, intermediate_certs=intermediate_certs, validation_context=context) - path = validator.validate_usage({'digital_signature'}, extended_key_usage={'time_stamping'}) + path = validator.validate_usage({"digital_signature"}, extended_key_usage={"time_stamping"}) cert_chain = [] for cert in path: @@ -96,4 +101,4 @@ class TimestampingEnricher(Enricher): f.write(cert.dump()) cert_chain.append(Media(filename=cert_fn).set("subject", cert.subject.native["common_name"])) - return cert_chain \ No newline at end of file + return cert_chain diff --git a/src/auto_archiver/modules/twitter_api_extractor/__init__.py b/src/auto_archiver/modules/twitter_api_extractor/__init__.py index 7005965..54e7b6c 100644 --- a/src/auto_archiver/modules/twitter_api_extractor/__init__.py +++ b/src/auto_archiver/modules/twitter_api_extractor/__init__.py @@ -1 +1 @@ -from .twitter_api_extractor import TwitterApiExtractor \ No newline at end of file +from .twitter_api_extractor import TwitterApiExtractor diff --git a/src/auto_archiver/modules/twitter_api_extractor/__manifest__.py b/src/auto_archiver/modules/twitter_api_extractor/__manifest__.py index 05d1ac0..203155f 100644 --- a/src/auto_archiver/modules/twitter_api_extractor/__manifest__.py +++ b/src/auto_archiver/modules/twitter_api_extractor/__manifest__.py @@ -3,21 +3,28 @@ "type": ["extractor"], "requires_setup": True, "dependencies": { - "python": ["requests", - "loguru", - "pytwitter", - "slugify",], - "bin": [""] + "python": [ + "requests", + "loguru", + "pytwitter", + "slugify", + ], + "bin": [""], }, "configs": { - "bearer_token": {"default": None, "help": "[deprecated: see bearer_tokens] twitter API bearer_token which is enough for archiving, if not provided you will need consumer_key, consumer_secret, access_token, access_secret"}, - "bearer_tokens": {"default": [], "help": " a list of twitter API bearer_token which is enough for archiving, if not provided you will need consumer_key, consumer_secret, access_token, access_secret, if provided you can still add those for better rate limits. CSV of bearer tokens if provided via the command line", - }, - "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"}, + "bearer_token": { + "default": None, + "help": "[deprecated: see bearer_tokens] twitter API bearer_token which is enough for archiving, if not provided you will need consumer_key, consumer_secret, access_token, access_secret", }, + "bearer_tokens": { + "default": [], + "help": " a list of twitter API bearer_token which is enough for archiving, if not provided you will need consumer_key, consumer_secret, access_token, access_secret, if provided you can still add those for better rate limits. CSV of bearer tokens if provided via the command line", + }, + "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"}, + }, "description": """ The `TwitterApiExtractor` fetches tweets and associated media using the Twitter API. It supports multiple API configurations for extended rate limits and reliable access. @@ -39,6 +46,5 @@ - **Access Token and Secret**: Complements the consumer key for enhanced API capabilities. Credentials can be obtained by creating a Twitter developer account at [Twitter Developer Platform](https://developer.twitter.com/en). - """ -, + """, } diff --git a/src/auto_archiver/modules/twitter_api_extractor/twitter_api_extractor.py b/src/auto_archiver/modules/twitter_api_extractor/twitter_api_extractor.py index 72fd2f2..1c08235 100644 --- a/src/auto_archiver/modules/twitter_api_extractor/twitter_api_extractor.py +++ b/src/auto_archiver/modules/twitter_api_extractor/twitter_api_extractor.py @@ -11,8 +11,8 @@ from slugify import slugify from auto_archiver.core import Extractor from auto_archiver.core import Metadata, Media -class TwitterApiExtractor(Extractor): +class TwitterApiExtractor(Extractor): valid_url: re.Pattern = re.compile(r"(?:twitter|x).com\/(?:\#!\/)?(\w+)\/status(?:es)?\/(\d+)") def setup(self) -> None: @@ -23,30 +23,38 @@ class TwitterApiExtractor(Extractor): if self.bearer_token: self.apis.append(Api(bearer_token=self.bearer_token)) if self.consumer_key and self.consumer_secret and self.access_token and self.access_secret: - self.apis.append(Api(consumer_key=self.consumer_key, consumer_secret=self.consumer_secret, - access_token=self.access_token, access_secret=self.access_secret)) - assert self.api_client is not None, "Missing Twitter API configurations, please provide either AND/OR (consumer_key, consumer_secret, access_token, access_secret) to use this archiver, you can provide both for better rate-limit results." + self.apis.append( + Api( + consumer_key=self.consumer_key, + consumer_secret=self.consumer_secret, + access_token=self.access_token, + access_secret=self.access_secret, + ) + ) + assert self.api_client is not None, ( + "Missing Twitter API configurations, please provide either AND/OR (consumer_key, consumer_secret, access_token, access_secret) to use this archiver, you can provide both for better rate-limit results." + ) @property # getter .mimetype def api_client(self) -> str: return self.apis[self.api_index] - + def sanitize_url(self, url: str) -> str: # expand URL if t.co and clean tracker GET params - if 'https://t.co/' in url: + if "https://t.co/" in url: try: r = requests.get(url, timeout=30) - logger.debug(f'Expanded url {url} to {r.url}') + logger.debug(f"Expanded url {url} to {r.url}") url = r.url - except: - logger.error(f'Failed to expand url {url}') + except Exception: + logger.error(f"Failed to expand url {url}") return url - def download(self, item: Metadata) -> Metadata: # call download retry until success or no more apis while self.api_index < len(self.apis): - if res := self.download_retry(item): return res + if res := self.download_retry(item): + return res self.api_index += 1 self.api_index = 0 return False @@ -54,7 +62,8 @@ class TwitterApiExtractor(Extractor): def get_username_tweet_id(self, url): # detect URLs that we definitely cannot handle matches = self.valid_url.findall(url) - if not len(matches): return False, False + 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=}") @@ -65,10 +74,16 @@ class TwitterApiExtractor(Extractor): 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 + if not username: + return False try: - tweet = self.api_client.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"]) + tweet = self.api_client.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"], + ) logger.debug(tweet) except Exception as e: logger.error(f"Could not get tweet: {e}") @@ -88,29 +103,35 @@ class TwitterApiExtractor(Extractor): mimetype = "image/jpeg" elif hasattr(m, "variants"): variant = self.choose_variant(m.variants) - if not variant: continue + 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}') + media.filename = self.download_from_url(media.get("src"), f"{slugify(url)}_{i}{ext}") 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)) + 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-api") def choose_variant(self, variants): - """ Chooses the highest quality variable possible out of a list of variants """ diff --git a/src/auto_archiver/modules/vk_extractor/__manifest__.py b/src/auto_archiver/modules/vk_extractor/__manifest__.py index 61e454e..ed16331 100644 --- a/src/auto_archiver/modules/vk_extractor/__manifest__.py +++ b/src/auto_archiver/modules/vk_extractor/__manifest__.py @@ -7,10 +7,8 @@ "python": ["loguru", "vk_url_scraper"], }, "configs": { - "username": {"required": True, - "help": "valid VKontakte username"}, - "password": {"required": True, - "help": "valid VKontakte password"}, + "username": {"required": True, "help": "valid VKontakte username"}, + "password": {"required": True, "help": "valid VKontakte password"}, "session_file": { "default": "secrets/vk_config.v2.json", "help": "valid VKontakte password", diff --git a/src/auto_archiver/modules/vk_extractor/vk_extractor.py b/src/auto_archiver/modules/vk_extractor/vk_extractor.py index 99527c4..997b0a8 100644 --- a/src/auto_archiver/modules/vk_extractor/vk_extractor.py +++ b/src/auto_archiver/modules/vk_extractor/vk_extractor.py @@ -7,7 +7,7 @@ from auto_archiver.core import Metadata, Media class VkExtractor(Extractor): - """" + """ " VK videos are handled by YTDownloader, this archiver gets posts text and images. Currently only works for /wall posts """ @@ -18,11 +18,13 @@ class VkExtractor(Extractor): def download(self, item: Metadata) -> Metadata: url = item.get_url() - if "vk.com" not in item.netloc: return False + 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 + if not len(vk_scrapes): + return False logger.debug(f"VK: got {len(vk_scrapes)} scraped instances") result = Metadata() diff --git a/src/auto_archiver/modules/wacz_enricher/__init__.py b/src/auto_archiver/modules/wacz_enricher/__init__.py deleted file mode 100644 index 686b8d8..0000000 --- a/src/auto_archiver/modules/wacz_enricher/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .wacz_enricher import WaczExtractorEnricher diff --git a/src/auto_archiver/modules/wacz_enricher/__manifest__.py b/src/auto_archiver/modules/wacz_enricher/__manifest__.py deleted file mode 100644 index bebfc9e..0000000 --- a/src/auto_archiver/modules/wacz_enricher/__manifest__.py +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "WACZ Enricher", - "type": ["enricher", "extractor"], - "entry_point": "wacz_enricher::WaczExtractorEnricher", - "requires_setup": True, - "dependencies": { - "python": [ - "loguru", - "jsonlines", - "warcio" - ], - # TODO? - "bin": [ - "docker" - ] - }, - "configs": { - "profile": {"default": None, "help": "browsertrix-profile (for profile generation see https://github.com/webrecorder/browsertrix-crawler#creating-and-using-browser-profiles)."}, - "docker_commands": {"default": None, "help":"if a custom docker invocation is needed"}, - "timeout": {"default": 120, "help": "timeout for WACZ generation in seconds"}, - "extract_media": {"default": False, "help": "If enabled all the images/videos/audio present in the WACZ archive will be extracted into separate Media and appear in the html report. The .wacz file will be kept untouched."}, - "extract_screenshot": {"default": True, "help": "If enabled the screenshot captured by browsertrix will be extracted into separate Media and appear in the html report. The .wacz file will be kept untouched."}, - "socks_proxy_host": {"default": None, "help": "SOCKS proxy host for browsertrix-crawler, use in combination with socks_proxy_port. eg: user:password@host"}, - "socks_proxy_port": {"default": None, "help": "SOCKS proxy port for browsertrix-crawler, use in combination with socks_proxy_host. eg 1234"}, - "proxy_server": {"default": None, "help": "SOCKS server proxy URL, in development"}, - }, - "description": """ - Creates .WACZ archives of web pages using the `browsertrix-crawler` tool, with options for media extraction and screenshot saving. - [Browsertrix-crawler](https://crawler.docs.browsertrix.com/user-guide/) is a headless browser-based crawler that archives web pages in WACZ format. - - ### Features - - Archives web pages into .WACZ format using Docker or direct invocation of `browsertrix-crawler`. - - Supports custom profiles for archiving private or dynamic content. - - Extracts media (images, videos, audio) and screenshots from the archive, optionally adding them to the enrichment pipeline. - - Generates metadata from the archived page's content and structure (e.g., titles, text). - - ### Notes - - Requires Docker for running `browsertrix-crawler` . - - Configurable via parameters for timeout, media extraction, screenshots, and proxy settings. - """ -} diff --git a/src/auto_archiver/modules/wacz_extractor_enricher/__init__.py b/src/auto_archiver/modules/wacz_extractor_enricher/__init__.py new file mode 100644 index 0000000..b9a53e3 --- /dev/null +++ b/src/auto_archiver/modules/wacz_extractor_enricher/__init__.py @@ -0,0 +1 @@ +from .wacz_extractor_enricher import WaczExtractorEnricher diff --git a/src/auto_archiver/modules/wacz_extractor_enricher/__manifest__.py b/src/auto_archiver/modules/wacz_extractor_enricher/__manifest__.py new file mode 100644 index 0000000..97e3bf6 --- /dev/null +++ b/src/auto_archiver/modules/wacz_extractor_enricher/__manifest__.py @@ -0,0 +1,53 @@ +{ + "name": "WACZ Enricher (and Extractor)", + "type": ["enricher", "extractor"], + "entry_point": "wacz_extractor_enricher::WaczExtractorEnricher", + "requires_setup": True, + "dependencies": { + "python": ["loguru", "jsonlines", "warcio"], + # TODO? + "bin": ["docker"], + }, + "configs": { + "profile": { + "default": None, + "help": "browsertrix-profile (for profile generation see https://github.com/webrecorder/browsertrix-crawler#creating-and-using-browser-profiles).", + }, + "docker_commands": {"default": None, "help": "if a custom docker invocation is needed"}, + "timeout": {"default": 120, "help": "timeout for WACZ generation in seconds", "type": "int"}, + "extract_media": { + "default": False, + "type": "bool", + "help": "If enabled all the images/videos/audio present in the WACZ archive will be extracted into separate Media and appear in the html report. The .wacz file will be kept untouched.", + }, + "extract_screenshot": { + "default": True, + "type": "bool", + "help": "If enabled the screenshot captured by browsertrix will be extracted into separate Media and appear in the html report. The .wacz file will be kept untouched.", + }, + "socks_proxy_host": { + "default": None, + "help": "SOCKS proxy host for browsertrix-crawler, use in combination with socks_proxy_port. eg: user:password@host", + }, + "socks_proxy_port": { + "default": None, + "type": "int", + "help": "SOCKS proxy port for browsertrix-crawler, use in combination with socks_proxy_host. eg 1234", + }, + "proxy_server": {"default": None, "help": "SOCKS server proxy URL, in development"}, + }, + "description": """ + Creates .WACZ archives of web pages using the `browsertrix-crawler` tool, with options for media extraction and screenshot saving. + [Browsertrix-crawler](https://crawler.docs.browsertrix.com/user-guide/) is a headless browser-based crawler that archives web pages in WACZ format. + + ### Features + - Archives web pages into .WACZ format using Docker or direct invocation of `browsertrix-crawler`. + - Supports custom profiles for archiving private or dynamic content. + - Extracts media (images, videos, audio) and screenshots from the archive, optionally adding them to the enrichment pipeline. + - Generates metadata from the archived page's content and structure (e.g., titles, text). + + ### Notes + - Requires Docker for running `browsertrix-crawler` . + - Configurable via parameters for timeout, media extraction, screenshots, and proxy settings. + """, +} diff --git a/src/auto_archiver/modules/wacz_enricher/wacz_enricher.py b/src/auto_archiver/modules/wacz_extractor_enricher/wacz_extractor_enricher.py similarity index 73% rename from src/auto_archiver/modules/wacz_enricher/wacz_enricher.py rename to src/auto_archiver/modules/wacz_extractor_enricher/wacz_extractor_enricher.py index ff7314a..975d49a 100644 --- a/src/auto_archiver/modules/wacz_enricher/wacz_enricher.py +++ b/src/auto_archiver/modules/wacz_extractor_enricher/wacz_extractor_enricher.py @@ -1,6 +1,8 @@ import jsonlines import mimetypes -import os, shutil, subprocess +import os +import shutil +import subprocess from zipfile import ZipFile from loguru import logger from warcio.archiveiterator import ArchiveIterator @@ -19,13 +21,12 @@ class WaczExtractorEnricher(Enricher, Extractor): """ def setup(self) -> None: - - self.use_docker = os.environ.get('WACZ_ENABLE_DOCKER') or not os.environ.get('RUNNING_IN_DOCKER') - self.docker_in_docker = os.environ.get('WACZ_ENABLE_DOCKER') and os.environ.get('RUNNING_IN_DOCKER') + self.use_docker = os.environ.get("WACZ_ENABLE_DOCKER") or not os.environ.get("RUNNING_IN_DOCKER") + self.docker_in_docker = os.environ.get("WACZ_ENABLE_DOCKER") and os.environ.get("RUNNING_IN_DOCKER") self.cwd_dind = f"/crawls/crawls{random_str(8)}" - self.browsertrix_home_host = os.environ.get('BROWSERTRIX_HOME_HOST') - self.browsertrix_home_container = os.environ.get('BROWSERTRIX_HOME_CONTAINER') or self.browsertrix_home_host + self.browsertrix_home_host = os.environ.get("BROWSERTRIX_HOME_HOST") + self.browsertrix_home_container = os.environ.get("BROWSERTRIX_HOME_CONTAINER") or self.browsertrix_home_host # create crawls folder if not exists, so it can be safely removed in cleanup if self.docker_in_docker: os.makedirs(self.cwd_dind, exist_ok=True) @@ -55,21 +56,32 @@ class WaczExtractorEnricher(Enricher, Extractor): cmd = [ "crawl", - "--url", url, - "--scopeType", "page", + "--url", + url, + "--scopeType", + "page", "--generateWACZ", - "--text", "to-pages", - "--screenshot", "fullPage", - "--collection", collection, - "--id", collection, - "--saveState", "never", - "--behaviors", "autoscroll,autoplay,autofetch,siteSpecific", - "--behaviorTimeout", str(self.timeout), - "--timeout", str(self.timeout), - "--diskUtilization", "99", + "--text", + "to-pages", + "--screenshot", + "fullPage", + "--collection", + collection, + "--id", + collection, + "--saveState", + "never", + "--behaviors", + "autoscroll,autoplay,autofetch,siteSpecific", + "--behaviorTimeout", + str(self.timeout), + "--timeout", + str(self.timeout), + "--diskUtilization", + "99", # "--blockAds" # note: this has been known to cause issues on cloudflare protected sites ] - + if self.docker_in_docker: cmd.extend(["--cwd", self.cwd_dind]) @@ -80,7 +92,14 @@ class WaczExtractorEnricher(Enricher, Extractor): if self.docker_commands: cmd = self.docker_commands + cmd else: - cmd = ["docker", "run", "--rm", "-v", f"{browsertrix_home_host}:/crawls/", "webrecorder/browsertrix-crawler"] + cmd + cmd = [ + "docker", + "run", + "--rm", + "-v", + f"{browsertrix_home_host}:/crawls/", + "webrecorder/browsertrix-crawler", + ] + cmd if self.profile: profile_fn = os.path.join(browsertrix_home_container, "profile.tar.gz") @@ -109,7 +128,6 @@ class WaczExtractorEnricher(Enricher, Extractor): logger.error(f"WACZ generation failed: {e}") return False - if self.docker_in_docker: wacz_fn = os.path.join(self.cwd_dind, "collections", collection, f"{collection}.wacz") elif self.use_docker: @@ -138,11 +156,10 @@ class WaczExtractorEnricher(Enricher, Extractor): logger.info(f"Parsing pages.jsonl {jsonl_fn=}") with jsonlines.open(jsonl_fn) as reader: for obj in reader: - if 'title' in obj: - to_enrich.set_title(obj['title']) - if 'text' in obj: - to_enrich.set_content(obj['text']) - + if "title" in obj: + to_enrich.set_title(obj["title"]) + if "text" in obj: + to_enrich.set_content(obj["text"]) return True @@ -155,36 +172,41 @@ class WaczExtractorEnricher(Enricher, Extractor): # unzipping the .wacz tmp_dir = self.tmp_dir unzipped_dir = os.path.join(tmp_dir, "unzipped") - with ZipFile(wacz_filename, 'r') as z_obj: + with ZipFile(wacz_filename, "r") as z_obj: z_obj.extractall(path=unzipped_dir) # if warc is split into multiple gzip chunks, merge those warc_dir = os.path.join(unzipped_dir, "archive") warc_filename = os.path.join(tmp_dir, "merged.warc") - with open(warc_filename, 'wb') as outfile: + with open(warc_filename, "wb") as outfile: for filename in sorted(os.listdir(warc_dir)): - if filename.endswith('.gz'): + if filename.endswith(".gz"): chunk_file = os.path.join(warc_dir, filename) - with open(chunk_file, 'rb') as infile: + with open(chunk_file, "rb") as infile: shutil.copyfileobj(infile, outfile) # get media out of .warc counter = 0 seen_urls = set() - import json - with open(warc_filename, 'rb') as warc_stream: + + with open(warc_filename, "rb") as warc_stream: for record in ArchiveIterator(warc_stream): # only include fetched resources - if record.rec_type == "resource" and record.content_type == "image/png" and self.extract_screenshot: # screenshots + if ( + record.rec_type == "resource" and record.content_type == "image/png" and self.extract_screenshot + ): # screenshots fn = os.path.join(tmp_dir, f"warc-file-{counter}.png") - with open(fn, "wb") as outf: outf.write(record.raw_stream.read()) + with open(fn, "wb") as outf: + outf.write(record.raw_stream.read()) m = Media(filename=fn) to_enrich.add_media(m, "browsertrix-screenshot") counter += 1 - if not self.extract_media: continue + if not self.extract_media: + continue - if record.rec_type != 'response': continue - record_url = record.rec_headers.get_header('WARC-Target-URI') + if record.rec_type != "response": + continue + record_url = record.rec_headers.get_header("WARC-Target-URI") if not UrlUtil.is_relevant_url(record_url): logger.debug(f"Skipping irrelevant URL {record_url} but it's still present in the WACZ.") continue @@ -194,8 +216,10 @@ class WaczExtractorEnricher(Enricher, Extractor): # filter by media mimetypes content_type = record.http_headers.get("Content-Type") - if not content_type: continue - if not any(x in content_type for x in ["video", "image", "audio"]): continue + if not content_type: + continue + if not any(x in content_type for x in ["video", "image", "audio"]): + continue # create local file and add media ext = mimetypes.guess_extension(content_type) @@ -203,7 +227,8 @@ class WaczExtractorEnricher(Enricher, Extractor): fn = os.path.join(tmp_dir, warc_fn) record_url_best_qual = UrlUtil.twitter_best_quality_url(record_url) - with open(fn, "wb") as outf: outf.write(record.raw_stream.read()) + with open(fn, "wb") as outf: + outf.write(record.raw_stream.read()) m = Media(filename=fn) m.set("src", record_url) @@ -213,12 +238,16 @@ class WaczExtractorEnricher(Enricher, Extractor): m.filename = self.download_from_url(record_url_best_qual, warc_fn) m.set("src", record_url_best_qual) m.set("src_alternative", record_url) - except Exception as e: logger.warning(f"Unable to download best quality URL for {record_url=} got error {e}, using original in WARC.") + except Exception as e: + logger.warning( + f"Unable to download best quality URL for {record_url=} got error {e}, using original in WARC." + ) # remove bad videos - if m.is_video() and not m.is_valid_video(): continue - + if m.is_video() and not m.is_valid_video(): + continue + to_enrich.add_media(m, warc_fn) counter += 1 seen_urls.add(record_url) - logger.info(f"WACZ extract_media/extract_screenshot finished, found {counter} relevant media file(s)") \ No newline at end of file + logger.info(f"WACZ extract_media/extract_screenshot finished, found {counter} relevant media file(s)") diff --git a/src/auto_archiver/modules/wayback_extractor_enricher/__init__.py b/src/auto_archiver/modules/wayback_extractor_enricher/__init__.py index b69332d..b6fb182 100644 --- a/src/auto_archiver/modules/wayback_extractor_enricher/__init__.py +++ b/src/auto_archiver/modules/wayback_extractor_enricher/__init__.py @@ -1 +1 @@ -from .wayback_extractor_enricher import WaybackExtractorEnricher \ No newline at end of file +from .wayback_extractor_enricher import WaybackExtractorEnricher diff --git a/src/auto_archiver/modules/wayback_extractor_enricher/__manifest__.py b/src/auto_archiver/modules/wayback_extractor_enricher/__manifest__.py index 4832265..62a7e8a 100644 --- a/src/auto_archiver/modules/wayback_extractor_enricher/__manifest__.py +++ b/src/auto_archiver/modules/wayback_extractor_enricher/__manifest__.py @@ -1,5 +1,5 @@ { - "name": "Wayback Machine Enricher", + "name": "Wayback Machine Enricher (and Extractor)", "type": ["enricher", "extractor"], "entry_point": "wayback_extractor_enricher::WaybackExtractorEnricher", "requires_setup": True, @@ -9,6 +9,7 @@ "configs": { "timeout": { "default": 15, + "type": "int", "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.", }, "if_not_archived_within": { diff --git a/src/auto_archiver/modules/wayback_extractor_enricher/wayback_extractor_enricher.py b/src/auto_archiver/modules/wayback_extractor_enricher/wayback_extractor_enricher.py index 1763b12..f06effd 100644 --- a/src/auto_archiver/modules/wayback_extractor_enricher/wayback_extractor_enricher.py +++ b/src/auto_archiver/modules/wayback_extractor_enricher/wayback_extractor_enricher.py @@ -1,16 +1,18 @@ import json from loguru import logger -import time, requests +import time +import requests from auto_archiver.core import Extractor, Enricher from auto_archiver.utils import url as UrlUtil from auto_archiver.core import Metadata + class WaybackExtractorEnricher(Enricher, Extractor): """ Submits the current URL to the webarchive and returns a job_id or completed archive. - The Wayback machine will rate-limit IP heavy usage. + The Wayback machine will rate-limit IP heavy usage. """ def download(self, item: Metadata) -> Metadata: @@ -22,8 +24,10 @@ class WaybackExtractorEnricher(Enricher, Extractor): def enrich(self, to_enrich: Metadata) -> bool: proxies = {} - if self.proxy_http: proxies["http"] = self.proxy_http - if self.proxy_https: proxies["https"] = self.proxy_https + if self.proxy_http: + proxies["http"] = self.proxy_http + if self.proxy_https: + proxies["https"] = self.proxy_https url = to_enrich.get_url() if UrlUtil.is_auth_wall(url): @@ -36,15 +40,12 @@ class WaybackExtractorEnricher(Enricher, Extractor): 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}" - } - post_data = {'url': url} + ia_headers = {"Accept": "application/json", "Authorization": f"LOW {self.key}:{self.secret}"} + post_data = {"url": url} if self.if_not_archived_within: post_data["if_not_archived_within"] = self.if_not_archived_within # see https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA for more options - r = requests.post('https://web.archive.org/save/', headers=ia_headers, data=post_data, proxies=proxies) + r = requests.post("https://web.archive.org/save/", headers=ia_headers, data=post_data, proxies=proxies) if r.status_code != 200: logger.error(em := f"Internet archive failed with status of {r.status_code}: {r.json()}") @@ -53,15 +54,14 @@ class WaybackExtractorEnricher(Enricher, Extractor): # check job status try: - job_id = r.json().get('job_id') + job_id = r.json().get("job_id") if not job_id: logger.error(f"Wayback failed with {r.json()}") return False - except json.decoder.JSONDecodeError as e: + except json.decoder.JSONDecodeError: logger.error(f"Expected a JSON with job_id from Wayback and got {r.text}") 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 @@ -69,17 +69,19 @@ class WaybackExtractorEnricher(Enricher, Extractor): 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, proxies=proxies) + r_status = requests.get( + f"https://web.archive.org/save/status/{job_id}", headers=ia_headers, proxies=proxies + ) r_json = r_status.json() - if r_status.status_code == 200 and r_json['status'] == 'success': + 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': + elif r_status.status_code != 200 or r_json["status"] != "pending": logger.error(f"Wayback failed with {r_json}") return False except requests.exceptions.RequestException as e: logger.warning(f"RequestException: fetching status for {url=} due to: {e}") break - except json.decoder.JSONDecodeError as e: + except json.decoder.JSONDecodeError: logger.error(f"Expected a JSON from Wayback and got {r.text} for {url=}") break except Exception as e: @@ -91,6 +93,8 @@ class WaybackExtractorEnricher(Enricher, Extractor): 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( + "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/modules/whisper_enricher/__init__.py b/src/auto_archiver/modules/whisper_enricher/__init__.py index d3d3526..d69bdd1 100644 --- a/src/auto_archiver/modules/whisper_enricher/__init__.py +++ b/src/auto_archiver/modules/whisper_enricher/__init__.py @@ -1 +1 @@ -from .whisper_enricher import WhisperEnricher \ No newline at end of file +from .whisper_enricher import WhisperEnricher diff --git a/src/auto_archiver/modules/whisper_enricher/__manifest__.py b/src/auto_archiver/modules/whisper_enricher/__manifest__.py index 98e743e..e7af7d1 100644 --- a/src/auto_archiver/modules/whisper_enricher/__manifest__.py +++ b/src/auto_archiver/modules/whisper_enricher/__manifest__.py @@ -6,15 +6,26 @@ "python": ["s3_storage", "loguru", "requests"], }, "configs": { - "api_endpoint": {"required": True, - "help": "WhisperApi api endpoint, eg: https://whisperbox-api.com/api/v1, a deployment of https://github.com/bellingcat/whisperbox-transcribe."}, - "api_key": {"required": True, - "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": "translate", - "help": "which Whisper operation to execute", - "choices": ["transcribe", "translate", "language_detection"]}, + "api_endpoint": { + "required": True, + "help": "WhisperApi api endpoint, eg: https://whisperbox-api.com/api/v1, a deployment of https://github.com/bellingcat/whisperbox-transcribe.", + }, + "api_key": {"required": True, "help": "WhisperApi api key for authentication"}, + "include_srt": { + "default": False, + "type": "bool", + "help": "Whether to include a subtitle SRT (SubRip Subtitle file) for the video (can be used in video players).", + }, + "timeout": { + "default": 90, + "type": "int", + "help": "How many seconds to wait at most for a successful job completion.", + }, + "action": { + "default": "translate", + "help": "which Whisper operation to execute", + "choices": ["transcribe", "translate", "language_detection"], + }, }, "description": """ Integrates with a Whisper API service to transcribe, translate, or detect the language of audio and video files. @@ -31,5 +42,5 @@ - Only compatible with S3-compatible storage systems for media file accessibility. - ** This stores the media files in S3 prior to enriching them as Whisper requires public URLs to access the media files. - Handles multiple jobs and retries for failed or incomplete processing. - """ + """, } diff --git a/src/auto_archiver/modules/whisper_enricher/whisper_enricher.py b/src/auto_archiver/modules/whisper_enricher/whisper_enricher.py index d63d2ed..063bd26 100644 --- a/src/auto_archiver/modules/whisper_enricher/whisper_enricher.py +++ b/src/auto_archiver/modules/whisper_enricher/whisper_enricher.py @@ -1,10 +1,12 @@ import traceback -import requests, time +import requests +import time from loguru import logger from auto_archiver.core import Enricher from auto_archiver.core import Metadata, Media + class WhisperEnricher(Enricher): """ Connects with a Whisper API service to get texts out of audio @@ -13,15 +15,15 @@ class WhisperEnricher(Enricher): """ def setup(self) -> None: - self.stores = self.config['steps']['storages'] + self.stores = self.config["steps"]["storages"] self.s3 = self.module_factory.get_module("s3_storage", self.config) - if not "s3_storage" in self.stores: - logger.error("WhisperEnricher: To use the WhisperEnricher you need to use S3Storage so files are accessible publicly to the whisper service being called.") + if "s3_storage" not in self.stores: + logger.error( + "WhisperEnricher: To use the WhisperEnricher you need to use S3Storage so files are accessible publicly to the whisper service being called." + ) return - def enrich(self, to_enrich: Metadata) -> None: - url = to_enrich.get_url() logger.debug(f"WHISPER[{self.action}]: iterating media items for {url=}.") @@ -36,28 +38,33 @@ class WhisperEnricher(Enricher): 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()}") + 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", {}).get("job_id") - if not job_id: continue - to_enrich.media[i].set("whisper_model", { - "job_id": job_id, - "job_status_check": f"{self.api_endpoint}/jobs/{job_id}", - "job_artifacts_check": f"{self.api_endpoint}/jobs/{job_id}/artifacts", - **(job_results[job_id] if job_results[job_id] else {"result": "incomplete or failed job"}) - }) + if not job_id: + continue + to_enrich.media[i].set( + "whisper_model", + { + "job_id": job_id, + "job_status_check": f"{self.api_endpoint}/jobs/{job_id}", + "job_artifacts_check": f"{self.api_endpoint}/jobs/{job_id}/artifacts", + **(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(): + 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_url = self.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 = { @@ -66,10 +73,14 @@ class WhisperEnricher(Enricher): # "language": "string" # may be a config } logger.debug(f"calling API with {payload=}") - 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}" + 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'] + return response.json()["id"] def check_jobs(self, job_results: dict): start_time = time.time() @@ -77,37 +88,50 @@ class WhisperEnricher(Enricher): 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 + if job_results[job_id] is not False: + continue all_completed = False # at least one not ready - try: job_results[job_id] = self.check_job(job_id) + 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) + 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}'}) + 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}" + 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) + 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}'}) + 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 diff --git a/src/auto_archiver/utils/__init__.py b/src/auto_archiver/utils/__init__.py index 46ca191..a8fa77f 100644 --- a/src/auto_archiver/utils/__init__.py +++ b/src/auto_archiver/utils/__init__.py @@ -1,7 +1,8 @@ -""" Auto Archiver Utilities. """ +"""Auto Archiver Utilities.""" + # we need to explicitly expose the available imports here from .misc import * from .webdriver import Webdriver # handy utils from ytdlp -from yt_dlp.utils import (clean_html, traverse_obj, strip_or_none, url_or_none) \ No newline at end of file +from yt_dlp.utils import clean_html, traverse_obj, strip_or_none, url_or_none diff --git a/src/auto_archiver/utils/misc.py b/src/auto_archiver/utils/misc.py index 108deae..fe1864b 100644 --- a/src/auto_archiver/utils/misc.py +++ b/src/auto_archiver/utils/misc.py @@ -1,9 +1,11 @@ -import os +import hashlib import json +import os import uuid from datetime import datetime, timezone +from dateutil.parser import parse as parse_dt + import requests -import hashlib from loguru import logger @@ -14,22 +16,23 @@ def mkdir_if_not_exists(folder): def expand_url(url): # expand short URL links - if 'https://t.co/' in url: + if "https://t.co/" in url: try: r = requests.get(url) - logger.debug(f'Expanded url {url} to {r.url}') + logger.debug(f"Expanded url {url} to {r.url}") return r.url - except: - logger.error(f'Failed to expand url {url}') + except Exception: + logger.error(f"Failed to expand url {url}") return url def getattr_or(o: object, prop: str, default=None): try: res = getattr(o, prop) - if res is None: raise + if res is None: + raise return res - except: + except Exception: return default @@ -59,46 +62,57 @@ def random_str(length: int = 32) -> str: return str(uuid.uuid4()).replace("-", "")[:length] -def json_loader(cli_val): - return json.loads(cli_val) - - -def calculate_file_hash(filename: str, hash_algo = hashlib.sha256, chunksize: int = 16000000) -> str: +def calculate_file_hash(filename: str, hash_algo=hashlib.sha256, chunksize: int = 16000000) -> str: hash = hash_algo() with open(filename, "rb") as f: while True: buf = f.read(chunksize) - if not buf: break + if not buf: + break hash.update(buf) return hash.hexdigest() -def get_current_datetime_iso() -> str: - return datetime.now(timezone.utc).replace(tzinfo=timezone.utc).isoformat() +def get_datetime_from_str(dt_str: str, fmt: str | None = None, dayfirst=True) -> datetime | None: + """parse a datetime string with option of passing a specific format -def get_datetime_from_str(dt_str: str, fmt: str | None = None) -> datetime | None: - # parse a datetime string with option of passing a specific format + Args: + dt_str: the datetime string to parse + fmt: the python date format of the datetime string, if None, dateutil.parser.parse is used + dayfirst: Use this to signify between date formats which put the day first, vs the month first: + e.g. DD/MM/YYYY vs MM/DD/YYYY + """ try: - return datetime.strptime(dt_str, fmt) if fmt else datetime.fromisoformat(dt_str) + return datetime.strptime(dt_str, fmt) if fmt else parse_dt(dt_str, dayfirst=dayfirst) except ValueError as e: logger.error(f"Unable to parse datestring {dt_str}: {e}") return None -def get_timestamp(ts, utc=True, iso=True) -> str | datetime | None: - # Consistent parsing of timestamps - # If utc=True, the timezone is set to UTC, - # if iso=True, the output is an iso string - if not ts: return +def get_timestamp(ts, utc=True, iso=True, dayfirst=True) -> str | datetime | None: + """Consistent parsing of timestamps. + Args: + If utc=True, the timezone is set to UTC, + if iso=True, the output is an iso string + Use dayfirst to signify between date formats which put the date vs month first: + e.g. DD/MM/YYYY vs MM/DD/YYYY + """ + if not ts: + return try: - if isinstance(ts, str): ts = datetime.fromisoformat(ts) - if isinstance(ts, (int, float)): ts = datetime.fromtimestamp(ts) - if utc: ts = ts.replace(tzinfo=timezone.utc) - if iso: return ts.isoformat() + if isinstance(ts, str): + ts = parse_dt(ts, dayfirst=dayfirst) + if isinstance(ts, (int, float)): + ts = datetime.fromtimestamp(ts) + if utc: + ts = ts.replace(tzinfo=timezone.utc) + if iso: + return ts.isoformat() return ts except Exception as e: logger.error(f"Unable to parse timestamp {ts}: {e}") return None + def get_current_timestamp() -> str: - return get_timestamp(datetime.now()) \ No newline at end of file + return get_timestamp(datetime.now()) diff --git a/src/auto_archiver/utils/url.py b/src/auto_archiver/utils/url.py index 40884da..169ed87 100644 --- a/src/auto_archiver/utils/url.py +++ b/src/auto_archiver/utils/url.py @@ -1,21 +1,61 @@ import re from urllib.parse import urlparse, urlunparse +from ipaddress import ip_address AUTHWALL_URLS = [ - re.compile(r"https:\/\/t\.me(\/c)\/(.+)\/(\d+)"), # telegram private channels - re.compile(r"https:\/\/www\.instagram\.com"), # instagram + re.compile(r"https:\/\/t\.me(\/c)\/(.+)\/(\d+)"), # telegram private channels + re.compile(r"https:\/\/www\.instagram\.com"), # instagram ] + +def check_url_or_raise(url: str) -> bool | ValueError: + """ + Blocks localhost, private, reserved, and link-local IPs and all non-http/https schemes. + """ + + if not (url.startswith("http://") or url.startswith("https://")): + raise ValueError(f"Invalid URL scheme for url {url}") + + parsed = urlparse(url) + if not parsed.hostname: + raise ValueError(f"Invalid URL hostname for url {url}") + + if parsed.hostname == "localhost": + raise ValueError(f"Localhost URLs cannot be parsed for security reasons (for url {url})") + + if parsed.scheme not in ["http", "https"]: + raise ValueError(f"Invalid URL scheme, only http and https supported (for url {url})") + + try: # special rules for IP addresses + ip = ip_address(parsed.hostname) + except ValueError: + pass + + else: + if not ip.is_global: + raise ValueError(f"IP address {ip} is not globally reachable") + if ip.is_reserved: + raise ValueError(f"Reserved IP address {ip} used") + if ip.is_link_local: + raise ValueError(f"Link-local IP address {ip} used") + if ip.is_private: + raise ValueError(f"Private IP address {ip} used") + + return True + + def domain_for_url(url: str) -> str: """ SECURITY: parse the domain using urllib to avoid any potential security issues """ return urlparse(url).netloc + def clean(url: str) -> str: return url + def is_auth_wall(url: str) -> bool: """ checks if URL is behind an authentication wall meaning steps like wayback, wacz, ... may not work @@ -26,13 +66,15 @@ def is_auth_wall(url: str) -> bool: return False + def remove_get_parameters(url: str) -> str: # http://example.com/file.mp4?t=1 -> http://example.com/file.mp4 # useful for mimetypes to work parsed_url = urlparse(url) - new_url = urlunparse(parsed_url._replace(query='')) + new_url = urlunparse(parsed_url._replace(query="")) return new_url + def is_relevant_url(url: str) -> bool: """ Detect if a detected media URL is recurring and therefore irrelevant to a specific archive. Useful, for example, for the enumeration of the media files in WARC files which include profile pictures, favicons, etc. @@ -40,42 +82,59 @@ def is_relevant_url(url: str) -> bool: clean_url = remove_get_parameters(url) # favicons - if "favicon" in url: return False + if "favicon" in url: + return False # ifnore icons - if clean_url.endswith(".ico"): return False + if clean_url.endswith(".ico"): + return False # ignore SVGs - if remove_get_parameters(url).endswith(".svg"): return False + if remove_get_parameters(url).endswith(".svg"): + return False # twitter profile pictures - if "twimg.com/profile_images" in url: return False - if "twimg.com" in url and "/default_profile_images" in url: return False + if "twimg.com/profile_images" in url: + return False + if "twimg.com" in url and "/default_profile_images" in url: + return False # instagram profile pictures - if "https://scontent.cdninstagram.com/" in url and "150x150" in url: return False + if "https://scontent.cdninstagram.com/" in url and "150x150" in url: + return False # instagram recurring images - if "https://static.cdninstagram.com/rsrc.php/" in url: return False + if "https://static.cdninstagram.com/rsrc.php/" in url: + return False # telegram - if "https://telegram.org/img/emoji/" in url: return False + if "https://telegram.org/img/emoji/" in url: + return False # youtube - if "https://www.youtube.com/s/gaming/emoji/" in url: return False - if "https://yt3.ggpht.com" in url and "default-user=" in url: return False - if "https://www.youtube.com/s/search/audio/" in url: return False + if "https://www.youtube.com/s/gaming/emoji/" in url: + return False + if "https://yt3.ggpht.com" in url and "default-user=" in url: + return False + if "https://www.youtube.com/s/search/audio/" in url: + return False # ok - if " https://ok.ru/res/i/" in url: return False + if " https://ok.ru/res/i/" in url: + return False # vk - if "https://vk.com/emoji/" in url: return False - if "vk.com/images/" in url: return False - if "vk.com/images/reaction/" in url: return False + if "https://vk.com/emoji/" in url: + return False + if "vk.com/images/" in url: + return False + if "vk.com/images/reaction/" in url: + return False # wikipedia - if "wikipedia.org/static" in url: return False + if "wikipedia.org/static" in url: + return False return True + def twitter_best_quality_url(url: str) -> str: """ some twitter image URLs point to a less-than best quality diff --git a/src/auto_archiver/utils/webdriver.py b/src/auto_archiver/utils/webdriver.py index db26d04..c866c25 100644 --- a/src/auto_archiver/utils/webdriver.py +++ b/src/auto_archiver/utils/webdriver.py @@ -1,25 +1,35 @@ -""" This Webdriver class acts as a context manager for the selenium webdriver. """ +"""This Webdriver class acts as a context manager for the selenium webdriver.""" + from __future__ import annotations -from selenium import webdriver -from selenium.common.exceptions import TimeoutException -from selenium.webdriver.common.proxy import Proxy, ProxyType -from selenium.webdriver.common.print_page_options import PrintOptions -from loguru import logger -from selenium.webdriver.common.by import By +import os import time +import re -#import domain_for_url +# import domain_for_url from urllib.parse import urlparse, urlunparse from http.cookiejar import MozillaCookieJar -class CookieSettingDriver(webdriver.Firefox): +from selenium import webdriver +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.common import exceptions as selenium_exceptions +from selenium.webdriver.common.print_page_options import PrintOptions +from selenium.webdriver.common.by import By +from loguru import logger + + +class CookieSettingDriver(webdriver.Firefox): facebook_accept_cookies: bool cookies: str cookiejar: MozillaCookieJar def __init__(self, cookies, cookiejar, facebook_accept_cookies, *args, **kwargs): + if os.environ.get("RUNNING_IN_DOCKER"): + # Selenium doesn't support linux-aarch64 driver, we need to set this manually + kwargs["service"] = webdriver.FirefoxService(executable_path="/usr/local/bin/geckodriver") + super(CookieSettingDriver, self).__init__(*args, **kwargs) self.cookies = cookies self.cookiejar = cookiejar @@ -29,55 +39,90 @@ class CookieSettingDriver(webdriver.Firefox): if self.cookies or self.cookiejar: # set up the driver to make it not 'cookie averse' (needs a context/URL) # get the 'robots.txt' file which should be quick and easy - robots_url = urlunparse(urlparse(url)._replace(path='/robots.txt', query='', fragment='')) + robots_url = urlunparse(urlparse(url)._replace(path="/robots.txt", query="", fragment="")) super(CookieSettingDriver, self).get(robots_url) if self.cookies: # an explicit cookie is set for this site, use that first for cookie in self.cookies.split(";"): for name, value in cookie.split("="): - self.driver.add_cookie({'name': name, 'value': value}) + self.driver.add_cookie({"name": name, "value": value}) elif self.cookiejar: - domain = urlparse(url).netloc.lstrip("www.") + domain = urlparse(url).netloc + regex = re.compile(f"(www)?\.?{domain}$") for cookie in self.cookiejar: - if domain in cookie.domain: + if regex.match(cookie.domain): try: - self.add_cookie({ - 'name': cookie.name, - 'value': cookie.value, - 'path': cookie.path, - 'domain': cookie.domain, - 'secure': bool(cookie.secure), - 'expiry': cookie.expires - }) + self.add_cookie( + { + "name": cookie.name, + "value": cookie.value, + "path": cookie.path, + "domain": cookie.domain, + "secure": bool(cookie.secure), + "expiry": cookie.expires, + } + ) except Exception as e: - logger.warning(f"Failed to add cookie to webdriver: {e}") - - if self.facebook_accept_cookies: - try: - logger.debug(f'Trying fb click accept cookie popup.') - super(CookieSettingDriver, self).get("http://www.facebook.com") - essential_only = self.find_element(By.XPATH, "//span[contains(text(), 'Decline optional cookies')]") - essential_only.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 Exception as e: - logger.warning(f'Failed on fb accept cookies.', e) - # now get the actual URL + logger.warning(f"Failed to add cookie ({cookie.domain}) to webdriver for url {domain}: {e}") + super(CookieSettingDriver, self).get(url) + time.sleep(2) + + # Try and use some common button text to reject/accept cookies + for text in [ + "Refuse non-essential cookies", + "Decline optional cookies", + "Reject additional cookies", + "Reject all", + "Accept all cookies", + ]: + try: + xpath = f"//*[contains(translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{text.lower()}')]" + self.find_element(By.XPATH, xpath).click() + time.sleep(2) + except selenium_exceptions.NoSuchElementException: + pass + + # now get the actual URL if self.facebook_accept_cookies: # try and click the 'close' button on the 'login' window to close it - close_button = self.find_element(By.XPATH, "//div[@role='dialog']//div[@aria-label='Close']") - if close_button: - close_button.click() + try: + xpath = "//div[@role='dialog']//div[@aria-label='Close']" + self.find_element(By.XPATH, xpath).click() + time.sleep(2) + except selenium_exceptions.NoSuchElementException: + logger.warning("Unable to find the 'close' button on the facebook login window") + pass + + else: + # for all other sites, try and use some common button text to reject/accept cookies + for text in [ + "Refuse non-essential cookies", + "Decline optional cookies", + "Reject additional cookies", + "Reject all", + "Accept all cookies", + ]: + try: + xpath = f"//*[contains(translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{text.lower()}')]" + WebDriverWait(self, 5).until(EC.element_to_be_clickable((By.XPATH, xpath))).click() + break + except selenium_exceptions.WebDriverException: + pass - class Webdriver: - def __init__(self, width: int, height: int, timeout_seconds: int, - facebook_accept_cookies: bool = False, http_proxy: str = "", - print_options: dict = {}, auth: dict = {}) -> webdriver: + def __init__( + self, + width: int, + height: int, + timeout_seconds: int, + facebook_accept_cookies: bool = False, + http_proxy: str = "", + print_options: dict = {}, + auth: dict = {}, + ) -> webdriver: self.width = width self.height = height self.timeout_seconds = timeout_seconds @@ -90,26 +135,31 @@ class Webdriver: setattr(self.print_options, k, v) def __enter__(self) -> webdriver: - options = webdriver.FirefoxOptions() options.add_argument("--headless") - options.add_argument(f'--proxy-server={self.http_proxy}') - options.set_preference('network.protocol-handler.external.tg', False) + options.add_argument(f"--proxy-server={self.http_proxy}") + options.set_preference("network.protocol-handler.external.tg", False) # if facebook cookie popup is present, force the browser to English since then it's easier to click the 'Decline optional cookies' option if self.facebook_accept_cookies: - options.add_argument('--lang=en') + options.add_argument("--lang=en") try: - self.driver = CookieSettingDriver(cookies=self.auth.get('cookies'), cookiejar=self.auth.get('cookies_jar'), - facebook_accept_cookies=self.facebook_accept_cookies, options=options) + self.driver = CookieSettingDriver( + cookies=self.auth.get("cookies"), + cookiejar=self.auth.get("cookies_jar"), + facebook_accept_cookies=self.facebook_accept_cookies, + options=options, + ) self.driver.set_window_size(self.width, self.height) self.driver.set_page_load_timeout(self.timeout_seconds) self.driver.print_options = self.print_options - except TimeoutException as e: - logger.error(f"failed to get new webdriver, possibly due to insufficient system resources or timeout settings: {e}") + except selenium_exceptions.TimeoutException as e: + logger.error( + f"failed to get new webdriver, possibly due to insufficient system resources or timeout settings: {e}" + ) return self.driver - + def __exit__(self, exc_type, exc_val, exc_tb): self.driver.close() self.driver.quit() diff --git a/src/auto_archiver/version.py b/src/auto_archiver/version.py index dd700b6..d0e6a5f 100644 --- a/src/auto_archiver/version.py +++ b/src/auto_archiver/version.py @@ -1,7 +1,8 @@ -""" Version information for the auto_archiver package. - TODO: This is a placeholder to replicate previous versioning. +"""Version information for the auto_archiver package. +TODO: This is a placeholder to replicate previous versioning. """ + from importlib.metadata import version as get_version VERSION_SHORT = get_version("auto_archiver") @@ -9,4 +10,4 @@ VERSION_SHORT = get_version("auto_archiver") # 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__ = f"{VERSION_SHORT}{_SUFFIX}" \ No newline at end of file +__version__ = f"{VERSION_SHORT}{_SUFFIX}" diff --git a/tests/conftest.py b/tests/conftest.py index a94abcd..379bfc2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ """ pytest conftest file, for shared fixtures and configuration """ + import os import pickle from datetime import datetime, timezone @@ -16,32 +17,36 @@ from auto_archiver.core.module import ModuleFactory # that you only want to run if everything else succeeds (e.g. API calls). The order here is important # what comes first will be run first (at the end of all other tests not mentioned) # format is the name of the module (python file) without the .py extension -TESTS_TO_RUN_LAST = ['test_twitter_api_archiver'] +TESTS_TO_RUN_LAST = ["test_twitter_api_archiver"] + @pytest.fixture def setup_module(request): - def _setup_module(module_name, config={}): - + def _setup_module(module_name, config=None): + if config is None: + config = {} module_factory = ModuleFactory() if isinstance(module_name, type): # get the module name: # if the class does not have a .name, use the name of the parent folder - module_name = module_name.__module__.rsplit(".",2)[-2] + module_name = module_name.__module__.rsplit(".", 2)[-2] m = module_factory.get_module(module_name, {module_name: config}) # add the tmp_dir to the module tmp_dir = TemporaryDirectory() m.tmp_dir = tmp_dir.name - + def cleanup(): tmp_dir.cleanup() + request.addfinalizer(cleanup) return m return _setup_module + @pytest.fixture def check_hash(): def _check_hash(filename: str, hash: str): @@ -51,6 +56,7 @@ def check_hash(): return _check_hash + @pytest.fixture def make_item(): def _make_item(url: str, **kwargs) -> Metadata: @@ -62,7 +68,6 @@ def make_item(): return _make_item - def pytest_collection_modifyitems(items): module_mapping = {item: item.module.__name__.split(".")[-1] for item in items} @@ -78,13 +83,13 @@ def pytest_collection_modifyitems(items): items[:] = sorted_items - # Incremental testing - fail tests in a class if any previous test fails # taken from https://docs.pytest.org/en/latest/example/simple.html#incremental-testing-test-steps # store history of failures per test class name and per index in parametrize (if parametrize used) _test_failed_incremental: Dict[str, Dict[Tuple[int, ...], str]] = {} + def pytest_runtest_makereport(item, call): if "incremental" in item.keywords: # incremental marker is used @@ -93,17 +98,11 @@ def pytest_runtest_makereport(item, call): # retrieve the class name of the test cls_name = str(item.cls) # retrieve the index of the test (if parametrize is used in combination with incremental) - parametrize_index = ( - tuple(item.callspec.indices.values()) - if hasattr(item, "callspec") - else () - ) + parametrize_index = tuple(item.callspec.indices.values()) if hasattr(item, "callspec") else () # retrieve the name of the test function test_name = item.originalname or item.name # store in _test_failed_incremental the original name of the failed test - _test_failed_incremental.setdefault(cls_name, {}).setdefault( - parametrize_index, test_name - ) + _test_failed_incremental.setdefault(cls_name, {}).setdefault(parametrize_index, test_name) def pytest_runtest_setup(item): @@ -119,16 +118,17 @@ def pytest_runtest_setup(item): pytest.xfail(f"previous test failed ({test_name})") - @pytest.fixture() def unpickle(): """ Returns a helper function that unpickles a file ** gets the file from the test_files directory: tests/data/ ** """ + def _unpickle(path): with open(os.path.join("tests/data", path), "rb") as f: return pickle.load(f) + return _unpickle @@ -145,9 +145,9 @@ def sample_datetime(): return datetime(2023, 1, 1, 12, 0, tzinfo=timezone.utc) -@pytest.fixture(autouse=True) +@pytest.fixture def mock_sleep(mocker): - """Globally mock time.sleep to avoid delays.""" + """Mock time.sleep to avoid delays.""" return mocker.patch("time.sleep") @@ -156,4 +156,4 @@ def metadata(): metadata = Metadata() metadata.set("_processed_at", "2021-01-01T00:00:00") metadata.set_url("https://example.com") - return metadata \ No newline at end of file + return metadata diff --git a/tests/data/dropin.py b/tests/data/dropin.py index 0049c48..93c7500 100644 --- a/tests/data/dropin.py +++ b/tests/data/dropin.py @@ -1,5 +1,6 @@ # this is a dummy class used to test importing a dropin in the # generic extractor by filename/path + class Dropin: - pass \ No newline at end of file + pass diff --git a/tests/data/test_modules/example_module/__init__.py b/tests/data/test_modules/example_module/__init__.py index 560a9b9..02986fc 100644 --- a/tests/data/test_modules/example_module/__init__.py +++ b/tests/data/test_modules/example_module/__init__.py @@ -1 +1 @@ -from .example_module import ExampleModule \ No newline at end of file +from .example_module import ExampleModule diff --git a/tests/data/test_modules/example_module/__manifest__.py b/tests/data/test_modules/example_module/__manifest__.py index e3a26bb..064b3c9 100644 --- a/tests/data/test_modules/example_module/__manifest__.py +++ b/tests/data/test_modules/example_module/__manifest__.py @@ -16,14 +16,14 @@ "dependencies": { "python": ["loguru"], "bin": ["bash"], - }, - # configurations that this module takes. These are argparse-compliant dicationaries, that are + }, + # configurations that this module takes. These are argparse-compliant dicationaries, that are # used to create command line arguments when the programme is run. # The full name of the config option will become: `module_name.config_name` "configs": { - "csv_file": {"default": "db.csv", "help": "CSV file name"}, - "required_field": {"required": True, "help": "required field in the CSV file"}, - }, + "csv_file": {"default": "db.csv", "help": "CSV file name"}, + "required_field": {"required": True, "help": "required field in the CSV file"}, + }, # A description of the module, used for documentation "description": "This is an example module", -} \ No newline at end of file +} diff --git a/tests/data/test_modules/example_module/example_module.py b/tests/data/test_modules/example_module/example_module.py index 7def054..392abe0 100644 --- a/tests/data/test_modules/example_module/example_module.py +++ b/tests/data/test_modules/example_module/example_module.py @@ -1,5 +1,6 @@ from auto_archiver.core import Extractor, Enricher, Feeder, Database, Storage, Formatter, Metadata + class ExampleModule(Extractor, Enricher, Feeder, Database, Storage, Formatter): def download(self, item): print("download") @@ -7,7 +8,6 @@ class ExampleModule(Extractor, Enricher, Feeder, Database, Storage, Formatter): def __iter__(self): yield Metadata().set_url("https://example.com") - def done(self, result): print("done") @@ -16,13 +16,12 @@ class ExampleModule(Extractor, Enricher, Feeder, Database, Storage, Formatter): def get_cdn_url(self, media): return "nice_url" - + def save(self, item): print("save") - + def uploadf(self, file, key, **kwargs): print("uploadf") - def format(self, item): print("format") diff --git a/tests/databases/test_api_db.py b/tests/databases/test_api_db.py index 5d1ea84..2e87a87 100644 --- a/tests/databases/test_api_db.py +++ b/tests/databases/test_api_db.py @@ -1,6 +1,5 @@ import pytest -from auto_archiver.core import Metadata from auto_archiver.modules.api_db import AAApiDb @@ -41,9 +40,16 @@ def test_fetch(api_db, metadata, mocker): mock_datetime = mocker.patch("auto_archiver.core.metadata.datetime.datetime") mock_datetime.now.return_value = "2021-01-01T00:00:00" mock_get.return_value.status_code = 200 - mock_get.return_value.json.return_value = [{"result": {}}, {"result": - {'media': [], 'metadata': {'_processed_at': '2021-01-01T00:00:00', 'url': 'https://example.com'}, - 'status': 'no archiver'}}] + mock_get.return_value.json.return_value = [ + {"result": {}}, + { + "result": { + "media": [], + "metadata": {"_processed_at": "2021-01-01T00:00:00", "url": "https://example.com"}, + "status": "no archiver", + } + }, + ] assert api_db.fetch(metadata) == metadata @@ -52,8 +58,15 @@ def test_done_success(api_db, metadata, mocker): mock_post.return_value.status_code = 201 api_db.done(metadata) mock_post.assert_called_once() - mock_post.assert_called_once_with("https://api.example.com/interop/submit-archive", - json={'author_id': 'Someone', 'url': 'https://example.com', - 'public': False, 'group_id': '123', 'tags': ['[', ']'], 'result': '{"status": "no archiver", "metadata": {"_processed_at": "2021-01-01T00:00:00", "url": "https://example.com"}, "media": []}'}, - headers={'Authorization': 'Bearer test-token'}) - + mock_post.assert_called_once_with( + "https://api.example.com/interop/submit-archive", + json={ + "author_id": "Someone", + "url": "https://example.com", + "public": False, + "group_id": "123", + "tags": ["[", "]"], + "result": '{"status": "no archiver", "metadata": {"_processed_at": "2021-01-01T00:00:00", "url": "https://example.com"}, "media": []}', + }, + headers={"Authorization": "Bearer test-token"}, + ) diff --git a/tests/databases/test_atlos_db.py b/tests/databases/test_atlos_db.py index 82c07ef..15f4b55 100644 --- a/tests/databases/test_atlos_db.py +++ b/tests/databases/test_atlos_db.py @@ -2,7 +2,7 @@ import pytest from datetime import datetime from auto_archiver.core import Metadata -from auto_archiver.modules.atlos_db import AtlosDb +from auto_archiver.modules.atlos_feeder_db_storage import AtlosFeederDbStorage as AtlosDb class FakeAPIResponse: @@ -12,19 +12,28 @@ class FakeAPIResponse: self._data = data self.raise_error = raise_error + def json(self) -> dict: + return self._data + def raise_for_status(self) -> None: if self.raise_error: raise Exception("HTTP error") @pytest.fixture -def atlos_db(setup_module) -> AtlosDb: +def atlos_db(setup_module, mocker) -> AtlosDb: """Fixture for AtlosDb.""" configs: dict = { "api_token": "abc123", "atlos_url": "https://platform.atlos.org", } - return setup_module("atlos_db", configs) + mocker.patch("requests.Session") + atlos_feeder = setup_module("atlos_feeder_db_storage", configs) + fake_session = mocker.MagicMock() + # Configure the default response to have no results so that __iter__ terminates + fake_session.get.return_value = FakeAPIResponse({"next": None, "results": []}) + atlos_feeder.session = fake_session + return atlos_feeder def test_failed_no_atlos_id(atlos_db, metadata, mocker): @@ -38,25 +47,18 @@ def test_failed_with_atlos_id(atlos_db, metadata, mocker): """Test failed() posts failure when atlos_id is present.""" metadata.set("atlos_id", 42) fake_resp = FakeAPIResponse({}, raise_error=False) - post_mock = mocker.patch("requests.post", return_value=fake_resp) + post_mock = mocker.patch.object(atlos_db, "_post", return_value=fake_resp) atlos_db.failed(metadata, "failure reason") - expected_url = ( - f"{atlos_db.atlos_url}/api/v2/source_material/metadata/42/auto_archiver" - ) - expected_headers = {"Authorization": f"Bearer {atlos_db.api_token}"} - expected_json = { - "metadata": {"processed": True, "status": "error", "error": "failure reason"} - } - post_mock.assert_called_once_with( - expected_url, headers=expected_headers, json=expected_json - ) + expected_endpoint = "/api/v2/source_material/metadata/42/auto_archiver" + expected_json = {"metadata": {"processed": True, "status": "error", "error": "failure reason"}} + post_mock.assert_called_once_with(expected_endpoint, json=expected_json) def test_failed_http_error(atlos_db, metadata, mocker): """Test failed() raises exception on HTTP error.""" metadata.set("atlos_id", 42) - fake_resp = FakeAPIResponse({}, raise_error=True) - mocker.patch("requests.post", return_value=fake_resp) + # Patch _post to raise an exception instead of returning a fake response. + mocker.patch.object(atlos_db, "_post", side_effect=Exception("HTTP error")) with pytest.raises(Exception, match="HTTP error"): atlos_db.failed(metadata, "failure reason") @@ -81,12 +83,9 @@ def test_done_with_atlos_id(atlos_db, metadata, mocker): now = datetime.now() metadata.set("timestamp", now) fake_resp = FakeAPIResponse({}, raise_error=False) - post_mock = mocker.patch("requests.post", return_value=fake_resp) + post_mock = mocker.patch.object(atlos_db, "_post", return_value=fake_resp) atlos_db.done(metadata) - expected_url = ( - f"{atlos_db.atlos_url}/api/v2/source_material/metadata/99/auto_archiver" - ) - expected_headers = {"Authorization": f"Bearer {atlos_db.api_token}"} + expected_endpoint = "/api/v2/source_material/metadata/99/auto_archiver" expected_results = metadata.metadata.copy() expected_results["timestamp"] = now.isoformat() expected_json = { @@ -96,15 +95,13 @@ def test_done_with_atlos_id(atlos_db, metadata, mocker): "results": expected_results, } } - post_mock.assert_called_once_with( - expected_url, headers=expected_headers, json=expected_json - ) + post_mock.assert_called_once_with(expected_endpoint, json=expected_json) def test_done_http_error(atlos_db, metadata, mocker): - """Test done() raises exception on HTTP error.""" + """Test done() raises an exception on HTTP error.""" metadata.set("atlos_id", 123) - fake_resp = FakeAPIResponse({}, raise_error=True) - mocker.patch("requests.post", return_value=fake_resp) + # Patch _post to raise an exception. + mocker.patch.object(atlos_db, "_post", side_effect=Exception("HTTP error")) with pytest.raises(Exception, match="HTTP error"): atlos_db.done(metadata) diff --git a/tests/databases/test_csv_db.py b/tests/databases/test_csv_db.py index afca0d8..bf5b7cb 100644 --- a/tests/databases/test_csv_db.py +++ b/tests/databases/test_csv_db.py @@ -1,4 +1,3 @@ - from auto_archiver.modules.csv_db import CSVDb from auto_archiver.core import Metadata @@ -9,12 +8,21 @@ def test_store_item(tmp_path, setup_module): temp_db = tmp_path / "temp_db.csv" db = setup_module(CSVDb, {"csv_file": temp_db.as_posix()}) - item = Metadata().set_url("http://example.com").set_title("Example").set_content("Example content").success("my-archiver") + item = ( + Metadata() + .set_url("http://example.com") + .set_title("Example") + .set_content("Example content") + .success("my-archiver") + ) db.done(item) with open(temp_db, "r", encoding="utf-8") as f: - assert f.read().strip() == f"status,metadata,media\nmy-archiver: success,\"{{'_processed_at': {repr(item.get('_processed_at'))}, 'url': 'http://example.com', 'title': 'Example', 'content': 'Example content'}}\",[]" + assert ( + f.read().strip() + == f"status,metadata,media\nmy-archiver: success,\"{{'_processed_at': {repr(item.get('_processed_at'))}, 'url': 'http://example.com', 'title': 'Example', 'content': 'Example content'}}\",[]" + ) # TODO: csv db doesn't have a fetch method - need to add it (?) - # assert db.fetch(item) == item \ No newline at end of file + # assert db.fetch(item) == item diff --git a/tests/databases/test_gsheet_db.py b/tests/databases/test_gsheet_db.py index 42a21b2..0760c79 100644 --- a/tests/databases/test_gsheet_db.py +++ b/tests/databases/test_gsheet_db.py @@ -2,8 +2,7 @@ from datetime import datetime, timezone import pytest from auto_archiver.core import Metadata, Media -from auto_archiver.modules.gsheet_db import GsheetsDb -from auto_archiver.modules.gsheet_feeder import GWorksheet +from auto_archiver.modules.gsheet_feeder_db import GsheetsFeederDB, GWorksheet @pytest.fixture @@ -29,6 +28,7 @@ def mock_metadata(mocker): metadata.get_first_image.return_value = None return metadata + @pytest.fixture def metadata(): metadata = Metadata() @@ -52,13 +52,36 @@ def mock_media(mocker): mock_media.get.return_value = "not-calculated" return mock_media + @pytest.fixture -def gsheets_db(mock_gworksheet, setup_module, mocker): - db = setup_module("gsheet_db", { - "allow_worksheets": "set()", - "block_worksheets": "set()", - "use_sheet_names_in_stored_paths": "True", - }) +def gsheets_db(mock_gworksheet, setup_module, mocker) -> GsheetsFeederDB: + mocker.patch("gspread.service_account") + config: dict = { + "sheet": "testsheet", + "sheet_id": None, + "header": 1, + "service_account": "test/service_account.json", + "columns": { + "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", + "pdq_hash": "perceptual hashes", + "wacz": "wacz", + "replaywebpage": "replaywebpage", + }, + "allow_worksheets": set(), + "block_worksheets": set(), + "use_sheet_names_in_stored_paths": True, + } + db = setup_module("gsheet_feeder_db", config) db._retrieve_gsheet = mocker.MagicMock(return_value=(mock_gworksheet, 1)) return db @@ -72,20 +95,21 @@ def fixed_timestamp(): @pytest.fixture def expected_calls(mock_media, fixed_timestamp): """Fixture for the expected cell updates.""" - return [ - (1, 'status', 'my-archiver: success'), - (1, 'archive', 'http://example.com/screenshot.png'), - (1, 'date', '2025-02-01T00:00:00+00:00'), - (1, 'title', 'Example Title'), - (1, 'text', 'Example Content'), - (1, 'timestamp', '2025-01-01T00:00:00+00:00'), - (1, 'hash', 'not-calculated'), + return [ + (1, "status", "my-archiver: success"), + (1, "archive", "http://example.com/screenshot.png"), + (1, "date", "2025-02-01T00:00:00+00:00"), + (1, "title", "Example Title"), + (1, "text", "Example Content"), + (1, "timestamp", "2025-01-01T00:00:00+00:00"), + (1, "hash", "not-calculated"), # (1, 'screenshot', 'http://example.com/screenshot.png'), # (1, 'thumbnail', '=IMAGE("http://example.com/thumbnail.png")'), # (1, 'wacz', 'http://example.com/browsertrix.wacz'), # (1, 'replaywebpage', 'https://replayweb.page/?source=http%3A%2F%2Fexample.com%2Fbrowsertrix.wacz#view=pages&url=') ] + def test_retrieve_gsheet(gsheets_db, metadata, mock_gworksheet): gw, row = gsheets_db._retrieve_gsheet(metadata) assert gw == mock_gworksheet @@ -94,27 +118,34 @@ def test_retrieve_gsheet(gsheets_db, metadata, mock_gworksheet): def test_started(gsheets_db, mock_metadata, mock_gworksheet): gsheets_db.started(mock_metadata) - mock_gworksheet.set_cell.assert_called_once_with(1, 'status', 'Archive in progress') + mock_gworksheet.set_cell.assert_called_once_with(1, "status", "Archive in progress") + def test_failed(gsheets_db, mock_metadata, mock_gworksheet): reason = "Test failure" gsheets_db.failed(mock_metadata, reason) - mock_gworksheet.set_cell.assert_called_once_with(1, 'status', f'Archive failed {reason}') + mock_gworksheet.set_cell.assert_called_once_with(1, "status", f"Archive failed {reason}") def test_aborted(gsheets_db, mock_metadata, mock_gworksheet): gsheets_db.aborted(mock_metadata) - mock_gworksheet.set_cell.assert_called_once_with(1, 'status', '') + mock_gworksheet.set_cell.assert_called_once_with(1, "status", "") def test_done(gsheets_db, metadata, mock_gworksheet, expected_calls, mocker): - mocker.patch("auto_archiver.modules.gsheet_db.gsheet_db.get_current_timestamp", return_value='2025-02-01T00:00:00+00:00') + mocker.patch( + "auto_archiver.modules.gsheet_feeder_db.gsheet_feeder_db.get_current_timestamp", + return_value="2025-02-01T00:00:00+00:00", + ) gsheets_db.done(metadata) mock_gworksheet.batch_set_cell.assert_called_once_with(expected_calls) def test_done_cached(gsheets_db, metadata, mock_gworksheet, mocker): - mocker.patch("auto_archiver.modules.gsheet_db.gsheet_db.get_current_timestamp", return_value='2025-02-01T00:00:00+00:00') + mocker.patch( + "auto_archiver.modules.gsheet_feeder_db.gsheet_feeder_db.get_current_timestamp", + return_value="2025-02-01T00:00:00+00:00", + ) gsheets_db.done(metadata, cached=True) # Verify the status message includes "[cached]" @@ -125,15 +156,17 @@ def test_done_cached(gsheets_db, metadata, mock_gworksheet, mocker): def test_done_missing_media(gsheets_db, metadata, mock_gworksheet, mocker): # clear media from metadata metadata.media = [] - mocker.patch("auto_archiver.modules.gsheet_db.gsheet_db.get_current_timestamp", return_value='2025-02-01T00:00:00+00:00') + mocker.patch( + "auto_archiver.modules.gsheet_feeder_db.gsheet_feeder_db.get_current_timestamp", + return_value="2025-02-01T00:00:00+00:00", + ) gsheets_db.done(metadata) # Verify nothing media-related gets updated call_args = mock_gworksheet.batch_set_cell.call_args[0][0] - media_fields = {'archive', 'screenshot', 'thumbnail', 'wacz', 'replaywebpage'} + media_fields = {"archive", "screenshot", "thumbnail", "wacz", "replaywebpage"} assert all(call[1] not in media_fields for call in call_args) + def test_safe_status_update(gsheets_db, metadata, mock_gworksheet): gsheets_db._safe_status_update(metadata, "Test status") - mock_gworksheet.set_cell.assert_called_once_with(1, 'status', 'Test status') - - + mock_gworksheet.set_cell.assert_called_once_with(1, "status", "Test status") diff --git a/tests/enrichers/test_hash_enricher.py b/tests/enrichers/test_hash_enricher.py index c2fe67a..05b3b3c 100644 --- a/tests/enrichers/test_hash_enricher.py +++ b/tests/enrichers/test_hash_enricher.py @@ -4,34 +4,50 @@ from auto_archiver.modules.hash_enricher import HashEnricher from auto_archiver.core import Metadata, Media from auto_archiver.core.module import ModuleFactory -@pytest.mark.parametrize("algorithm, filename, expected_hash", [ - ("SHA-256", "tests/data/testfile_1.txt", "1b4f0e9851971998e732078544c96b36c3d01cedf7caa332359d6f1d83567014"), - ("SHA-256", "tests/data/testfile_2.txt", "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752"), - ("SHA3-512", "tests/data/testfile_1.txt", "d2d8cc4f369b340130bd2b29b8b54e918b7c260c3279176da9ccaa37c96eb71735fc97568e892dc6220bf4ae0d748edb46bd75622751556393be3f482e6f794e"), - ("SHA3-512", "tests/data/testfile_2.txt", "e35970edaa1e0d8af7d948491b2da0450a49fd9cc1e83c5db4c6f175f9550cf341f642f6be8cfb0bfa476e4258e5088c5ad549087bf02811132ac2fa22b734c6") -]) + +@pytest.mark.parametrize( + "algorithm, filename, expected_hash", + [ + ("SHA-256", "tests/data/testfile_1.txt", "1b4f0e9851971998e732078544c96b36c3d01cedf7caa332359d6f1d83567014"), + ("SHA-256", "tests/data/testfile_2.txt", "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752"), + ( + "SHA3-512", + "tests/data/testfile_1.txt", + "d2d8cc4f369b340130bd2b29b8b54e918b7c260c3279176da9ccaa37c96eb71735fc97568e892dc6220bf4ae0d748edb46bd75622751556393be3f482e6f794e", + ), + ( + "SHA3-512", + "tests/data/testfile_2.txt", + "e35970edaa1e0d8af7d948491b2da0450a49fd9cc1e83c5db4c6f175f9550cf341f642f6be8cfb0bfa476e4258e5088c5ad549087bf02811132ac2fa22b734c6", + ), + ], +) def test_calculate_hash(algorithm, filename, expected_hash, setup_module): # test SHA-256 he = setup_module(HashEnricher, {"algorithm": algorithm, "chunksize": 100}) assert he.calculate_hash(filename) == expected_hash + def test_default_config_values(setup_module): he = setup_module(HashEnricher) assert he.algorithm == "SHA-256" assert he.chunksize == 16000000 + def test_config(): # test default config - c = ModuleFactory().get_module_lazy('hash_enricher').configs + c = ModuleFactory().get_module_lazy("hash_enricher").configs assert c["algorithm"]["default"] == "SHA-256" assert c["chunksize"]["default"] == 16000000 assert c["algorithm"]["choices"] == ["SHA-256", "SHA3-512"] assert c["algorithm"]["help"] == "hash algorithm to use" - assert c["chunksize"]["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" + assert ( + c["chunksize"]["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 test_hash_media(setup_module): - he = setup_module(HashEnricher, {"algorithm": "SHA-256", "chunksize": 1}) # generate metadata with two test files @@ -46,4 +62,4 @@ def test_hash_media(setup_module): he.enrich(m) assert m.media[0].get("hash") == "SHA-256:1b4f0e9851971998e732078544c96b36c3d01cedf7caa332359d6f1d83567014" - assert m.media[1].get("hash") == "SHA-256:60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752" \ No newline at end of file + assert m.media[1].get("hash") == "SHA-256:60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752" diff --git a/tests/enrichers/test_meta_enricher.py b/tests/enrichers/test_meta_enricher.py index 476e25b..fe0d737 100644 --- a/tests/enrichers/test_meta_enricher.py +++ b/tests/enrichers/test_meta_enricher.py @@ -1,4 +1,3 @@ -import datetime from datetime import datetime, timedelta, timezone import pytest @@ -16,6 +15,7 @@ def mock_metadata(mocker): mock.get_all_media.return_value = [] return mock + @pytest.fixture def mock_media(mocker): """Creates a mock Media object.""" @@ -59,6 +59,7 @@ def test_enrich_file_sizes(meta_enricher, metadata, tmp_path): assert metadata.get("total_bytes") == 3000 assert metadata.get("total_size") == "2.9 KB" + @pytest.mark.parametrize( "size, expected", [ @@ -74,6 +75,7 @@ def test_human_readable_bytes(size, expected): enricher = MetaEnricher() assert enricher.human_readable_bytes(size) == expected + def test_enrich_file_sizes_no_media(meta_enricher, metadata): """Test that enrich_file_sizes() handles empty media list gracefully.""" meta_enricher.enrich_file_sizes(metadata) @@ -91,4 +93,4 @@ def test_enrich_archive_duration(meta_enricher, metadata, mocker): mock_datetime.now.return_value = mock_now meta_enricher.enrich_archive_duration(metadata) - assert metadata.get("archive_duration_seconds") == 630 \ No newline at end of file + assert metadata.get("archive_duration_seconds") == 630 diff --git a/tests/enrichers/test_metadata_enricher.py b/tests/enrichers/test_metadata_enricher.py index 888837d..14cfc44 100644 --- a/tests/enrichers/test_metadata_enricher.py +++ b/tests/enrichers/test_metadata_enricher.py @@ -1,4 +1,3 @@ - import pytest from auto_archiver.core import Media @@ -33,9 +32,7 @@ def test_get_metadata(enricher, output, expected, mocker): result = enricher.get_metadata("test.jpg") assert result == expected - mock_run.assert_called_once_with( - ["exiftool", "test.jpg"], capture_output=True, text=True - ) + mock_run.assert_called_once_with(["exiftool", "test.jpg"], capture_output=True, text=True) def test_get_metadata_exiftool_not_found(enricher, mocker): @@ -85,4 +82,3 @@ def test_metadata_pickle(enricher, unpickle, mocker): actual_media = metadata.media assert len(expected_media) == len(actual_media) assert actual_media[0].properties.get("metadata") == expected_media[0].properties.get("metadata") - diff --git a/tests/enrichers/test_opentimestamps_enricher.py b/tests/enrichers/test_opentimestamps_enricher.py new file mode 100644 index 0000000..99ddd66 --- /dev/null +++ b/tests/enrichers/test_opentimestamps_enricher.py @@ -0,0 +1,276 @@ +import pytest +import hashlib + +from opentimestamps.core.timestamp import Timestamp, DetachedTimestampFile +from opentimestamps.calendar import RemoteCalendar +from opentimestamps.core.notary import PendingAttestation, BitcoinBlockHeaderAttestation + +from auto_archiver.core import Metadata, Media + + +# TODO: Remove once timestamping overhaul is merged +@pytest.fixture +def sample_media(tmp_path) -> Media: + """Fixture creating a Media object with temporary source file""" + src_file = tmp_path / "source.txt" + src_file.write_text("test content") + return Media(_key="subdir/test.txt", filename=str(src_file)) + + +@pytest.fixture +def sample_file_path(tmp_path): + tmp_file = tmp_path / "test.txt" + tmp_file.write_text("This is a test file content for OpenTimestamps") + return str(tmp_file) + + +@pytest.fixture +def detached_timestamp_file(): + """Create a simple detached timestamp file for testing""" + file_hash = hashlib.sha256(b"Test content").digest() + from opentimestamps.core.op import OpSHA256 + + file_hash_op = OpSHA256() + timestamp = Timestamp(file_hash) + + # Add a pending attestation + pending = PendingAttestation("https://example.calendar.com") + timestamp.attestations.add(pending) + + # Add a bitcoin attestation + bitcoin = BitcoinBlockHeaderAttestation(783000) # Some block height + timestamp.attestations.add(bitcoin) + + return DetachedTimestampFile(file_hash_op, timestamp) + + +@pytest.fixture +def verified_timestamp_file(): + """Create a timestamp file with a Bitcoin attestation""" + file_hash = hashlib.sha256(b"Verified content").digest() + from opentimestamps.core.op import OpSHA256 + + file_hash_op = OpSHA256() + timestamp = Timestamp(file_hash) + + # Add only a Bitcoin attestation + bitcoin = BitcoinBlockHeaderAttestation(783000) # Some block height + timestamp.attestations.add(bitcoin) + + return DetachedTimestampFile(file_hash_op, timestamp) + + +@pytest.fixture +def pending_timestamp_file(): + """Create a timestamp file with only pending attestations""" + file_hash = hashlib.sha256(b"Pending content").digest() + from opentimestamps.core.op import OpSHA256 + + file_hash_op = OpSHA256() + timestamp = Timestamp(file_hash) + + # Add only pending attestations + pending1 = PendingAttestation("https://example1.calendar.com") + pending2 = PendingAttestation("https://example2.calendar.com") + timestamp.attestations.add(pending1) + timestamp.attestations.add(pending2) + + return DetachedTimestampFile(file_hash_op, timestamp) + + +@pytest.mark.download +def test_download_tsr(setup_module, mocker): + """Test submitting a hash to calendar servers""" + # Mock the RemoteCalendar submit method + mock_submit = mocker.patch.object(RemoteCalendar, "submit") + test_timestamp = Timestamp(hashlib.sha256(b"test").digest()) + mock_submit.return_value = test_timestamp + + # Create a calendar + calendar = RemoteCalendar("https://alice.btc.calendar.opentimestamps.org") + + # Test submission + file_hash = hashlib.sha256(b"Test file content").digest() + result = calendar.submit(file_hash) + + assert mock_submit.called + assert isinstance(result, Timestamp) + assert result == test_timestamp + + +def test_verify_timestamp(setup_module, detached_timestamp_file): + """Test the verification of timestamp attestations""" + ots = setup_module("opentimestamps_enricher") + + # Test verification + verification_info = ots.verify_timestamp(detached_timestamp_file) + + # Check verification results + assert verification_info["attestation_count"] == 2 + assert verification_info["verified"] is True + assert len(verification_info["attestations"]) == 2 + + # Check attestation types + assertion_types = [a["status"] for a in verification_info["attestations"]] + assert "pending" in assertion_types + assert "confirmed" in assertion_types + + # Check Bitcoin attestation details + bitcoin_attestation = next(a for a in verification_info["attestations"] if a["status"] == "confirmed") + assert bitcoin_attestation["block_height"] == 783000 + + +def test_verify_pending_only(setup_module, pending_timestamp_file): + """Test verification of timestamps with only pending attestations""" + ots = setup_module("opentimestamps_enricher") + + verification_info = ots.verify_timestamp(pending_timestamp_file) + + assert verification_info["attestation_count"] == 2 + assert verification_info["verified"] is False + + # All attestations should be of type "pending" + assert all(a["status"] == "pending" for a in verification_info["attestations"]) + + # Check URIs of pending attestations + uris = [a["uri"] for a in verification_info["attestations"]] + assert "https://example1.calendar.com" in uris + assert "https://example2.calendar.com" in uris + + +def test_verify_bitcoin_completed(setup_module, verified_timestamp_file): + """Test verification of timestamps with completed Bitcoin attestations""" + + ots = setup_module("opentimestamps_enricher") + + verification_info = ots.verify_timestamp(verified_timestamp_file) + + assert verification_info["attestation_count"] == 1 + assert verification_info["verified"] is True + assert "pending" not in verification_info + + # Check that the attestation is a Bitcoin attestation + attestation = verification_info["attestations"][0] + assert attestation["status"] == "confirmed" + assert attestation["block_height"] == 783000 + + +def test_full_enriching(setup_module, sample_file_path, sample_media, mocker): + """Test the complete enrichment process""" + + # Mock the calendar submission to avoid network requests + mock_calendar = mocker.patch.object(RemoteCalendar, "submit") + + # Create a function that returns a new timestamp for each call + def side_effect(digest): + test_timestamp = Timestamp(digest) + # Add a bitcoin attestation to the test timestamp + bitcoin = BitcoinBlockHeaderAttestation(783000) + test_timestamp.attestations.add(bitcoin) + return test_timestamp + + mock_calendar.side_effect = side_effect + + ots = setup_module("opentimestamps_enricher") + + # Create test metadata with sample file + metadata = Metadata().set_url("https://example.com") + sample_media.filename = sample_file_path + metadata.add_media(sample_media) + + # Run enrichment + ots.enrich(metadata) + + # Verify results + assert metadata.get("opentimestamped") is True + assert metadata.get("opentimestamps_count") == 1 + + # Check that we have one parent media item: the original + assert len(metadata.media) == 1 + + # Check that the original media was updated + assert metadata.media[0].get("opentimestamps") is True + + # Check the timestamp file media is a child of the original + assert len(metadata.media[0].get("opentimestamp_files")) == 1 + + timestamp_media = metadata.media[0].get("opentimestamp_files")[0] + + assert timestamp_media.get("opentimestamps_version") is not None + + # Check verification results on the timestamp media + assert timestamp_media.get("verified") is True + assert timestamp_media.get("attestation_count") == 1 + + +def test_full_enriching_one_calendar_error( + setup_module, sample_file_path, sample_media, mocker, pending_timestamp_file +): + """Test enrichment when one calendar server returns an error""" + # Mock the calendar submission to raise an exception + mock_calendar = mocker.patch.object(RemoteCalendar, "submit") + + test_timestamp = Timestamp(bytes.fromhex("583988e03646c26fa290c5c2408540a2f4e2aa9be087aa4546aefb531385b935")) + # Add a bitcoin attestation to the test timestamp + bitcoin = BitcoinBlockHeaderAttestation(783000) + test_timestamp.attestations.add(bitcoin) + + mock_calendar.side_effect = [test_timestamp, Exception("Calendar server error")] + + ots = setup_module( + "opentimestamps_enricher", + { + "calendar_urls": [ + "https://alice.btc.calendar.opentimestamps.org", + "https://bob.btc.calendar.opentimestamps.org", + ] + }, + ) + + # Create test metadata with sample file + metadata = Metadata().set_url("https://example.com") + sample_media.filename = sample_file_path + metadata.add_media(sample_media) + + # Run enrichment (should complete despite calendar errors) + ots.enrich(metadata) + + # Verify results + assert metadata.get("opentimestamped") is True + assert metadata.get("opentimestamps_count") == 1 # only alice worked, not bob + + +def test_full_enriching_calendar_error(setup_module, sample_file_path, sample_media, mocker): + """Test enrichment when calendar servers return errors""" + # Mock the calendar submission to raise an exception + mock_calendar = mocker.patch.object(RemoteCalendar, "submit") + mock_calendar.side_effect = Exception("Calendar server error") + + ots = setup_module("opentimestamps_enricher") + + # Create test metadata with sample file + metadata = Metadata().set_url("https://example.com") + sample_media.filename = sample_file_path + metadata.add_media(sample_media) + + # Run enrichment (should complete despite calendar errors) + ots.enrich(metadata) + + # Verify results + assert metadata.get("opentimestamped") is False + assert metadata.get("opentimestamps_count") is None + + +def test_no_files_to_stamp(setup_module): + """Test enrichment with no files to timestamp""" + ots = setup_module("opentimestamps_enricher") + + # Create empty metadata + metadata = Metadata().set_url("https://example.com") + + # Run enrichment + ots.enrich(metadata) + + # Verify no timestamping occurred + assert metadata.get("opentimestamped") is None + assert len(metadata.media) == 0 diff --git a/tests/enrichers/test_pdq_hash_enricher.py b/tests/enrichers/test_pdq_hash_enricher.py index 9653734..483392d 100644 --- a/tests/enrichers/test_pdq_hash_enricher.py +++ b/tests/enrichers/test_pdq_hash_enricher.py @@ -14,23 +14,21 @@ def enricher(setup_module): def metadata_with_images(): m = Metadata() m.set_url("https://example.com") - m.add_media(Media(filename="image1.jpg", key="image1")) - m.add_media(Media(filename="image2.jpg", key="image2")) + m.add_media(Media(filename="image1.jpg", _key="image1")) + m.add_media(Media(filename="image2.jpg", _key="image2")) return m def test_successful_enrich(metadata_with_images, mocker): - with ( - mocker.patch("pdqhash.compute", return_value=([1, 0, 1, 0] * 64, 100)), - mocker.patch("PIL.Image.open"), - mocker.patch.object(Media, "is_image", return_value=True) as mock_is_image, - ): - enricher = PdqHashEnricher() - enricher.enrich(metadata_with_images) + mocker.patch("pdqhash.compute", return_value=([1, 0, 1, 0] * 64, 100)) + mocker.patch("PIL.Image.open") + mocker.patch.object(Media, "is_image", return_value=True) + enricher = PdqHashEnricher() + enricher.enrich(metadata_with_images) - # Ensure the hash is set for image media - for media in metadata_with_images.media: - assert media.get("pdq_hash") is not None + # Ensure the hash is set for image media + for media in metadata_with_images.media: + assert media.get("pdq_hash") is not None def test_enrich_skip_non_image(metadata_with_images, mocker): @@ -59,7 +57,7 @@ def test_enrich_handles_corrupted_image(metadata_with_images, mocker): ("screenshot", False), ("warc-file-123", False), ("regular-image", True), - ] + ], ) def test_enrich_excludes_by_filetype(media_id, should_have_hash, mocker): metadata = Metadata() @@ -75,4 +73,3 @@ def test_enrich_excludes_by_filetype(media_id, should_have_hash, mocker): media_item = metadata.media[0] assert (media_item.get("pdq_hash") is not None) == should_have_hash - diff --git a/tests/enrichers/test_screenshot_enricher.py b/tests/enrichers/test_screenshot_enricher.py index 25ca51d..b86bb17 100644 --- a/tests/enrichers/test_screenshot_enricher.py +++ b/tests/enrichers/test_screenshot_enricher.py @@ -15,13 +15,15 @@ def mock_selenium_env(mocker): mock_which = mocker.patch("shutil.which") mock_driver_class = mocker.patch("auto_archiver.utils.webdriver.CookieSettingDriver") mock_binary_paths = mocker.patch("selenium.webdriver.common.selenium_manager.SeleniumManager.binary_paths") - mock_is_file = mocker.patch("pathlib.Path.is_file", return_value=True) + mocker.patch("pathlib.Path.is_file", return_value=True) mock_popen = mocker.patch("subprocess.Popen") - mock_is_connectable = mocker.patch("selenium.webdriver.common.service.Service.is_connectable", return_value=True) + mocker.patch("selenium.webdriver.common.service.Service.is_connectable", return_value=True) mock_firefox_options = mocker.patch("selenium.webdriver.FirefoxOptions") + # Define side effect for `shutil.which` def mock_which_side_effect(dep): return "/mock/geckodriver" if dep == "geckodriver" else None + mock_which.side_effect = mock_which_side_effect # Mock binary paths @@ -104,13 +106,7 @@ def test_enrich_adds_screenshot( ], ) def test_enrich_auth_wall( - screenshot_enricher, - metadata_with_video, - mock_selenium_env, - common_patches, - url, - is_auth, - mocker + screenshot_enricher, metadata_with_video, mock_selenium_env, common_patches, url, is_auth, mocker ): # Testing with and without is_auth_wall mock_driver, mock_driver_class, _ = mock_selenium_env @@ -128,9 +124,7 @@ def test_enrich_auth_wall( assert metadata_with_video.media[1].properties.get("id") == "screenshot" -def test_handle_timeout_exception( - screenshot_enricher, metadata_with_video, mock_selenium_env, mocker -): +def test_handle_timeout_exception(screenshot_enricher, metadata_with_video, mock_selenium_env, mocker): mock_driver, mock_driver_class, mock_options_instance = mock_selenium_env mock_driver.get.side_effect = TimeoutException @@ -140,9 +134,7 @@ def test_handle_timeout_exception( assert len(metadata_with_video.media) == 1 -def test_handle_general_exception( - screenshot_enricher, metadata_with_video, mock_selenium_env, mocker -): +def test_handle_general_exception(screenshot_enricher, metadata_with_video, mock_selenium_env, mocker): """Test proper handling of unexpected general exceptions""" mock_driver, mock_driver_class, mock_options_instance = mock_selenium_env # Simulate a generic exception when save_screenshot is called @@ -152,9 +144,7 @@ def test_handle_general_exception( mock_log = mocker.patch("loguru.logger.error") screenshot_enricher.enrich(metadata_with_video) # Verify that the exception was logged with the log - mock_log.assert_called_once_with( - "Got error while loading webdriver for screenshot enricher: Unexpected Error" - ) + mock_log.assert_called_once_with("Got error while loading webdriver for screenshot enricher: Unexpected Error") # And no new media was added due to the error assert len(metadata_with_video.media) == 1 @@ -167,13 +157,12 @@ def test_pdf_creation(mocker, screenshot_enricher, metadata_with_video, mock_sel # Mock the print_page method to return base64-encoded content mock_driver.print_page.return_value = base64.b64encode(b"fake_pdf_content").decode("utf-8") # Patch functions with mocker - mock_os_path_join = mocker.patch("os.path.join", side_effect=lambda *args: f"{args[-1]}") - mock_random_str = mocker.patch( + mocker.patch("os.path.join", side_effect=lambda *args: f"{args[-1]}") + mocker.patch( "auto_archiver.modules.screenshot_enricher.screenshot_enricher.random_str", return_value="fixed123", ) mock_open = mocker.patch("builtins.open", new_callable=mocker.mock_open) - mock_log_error = mocker.patch("loguru.logger.error") screenshot_enricher.enrich(metadata_with_video) # Verify screenshot and PDF creation diff --git a/tests/enrichers/test_ssl_enricher.py b/tests/enrichers/test_ssl_enricher.py index eb7ba6b..cd118ad 100644 --- a/tests/enrichers/test_ssl_enricher.py +++ b/tests/enrichers/test_ssl_enricher.py @@ -51,4 +51,3 @@ def test_ssl_error_handling(enricher, metadata, mocker): mocker.patch("ssl.get_server_certificate", side_effect=ssl.SSLError("SSL error")) with pytest.raises(ssl.SSLError, match="SSL error"): enricher.enrich(metadata) - diff --git a/tests/enrichers/test_thumbnail_enricher.py b/tests/enrichers/test_thumbnail_enricher.py index effc25e..fdc28b7 100644 --- a/tests/enrichers/test_thumbnail_enricher.py +++ b/tests/enrichers/test_thumbnail_enricher.py @@ -25,7 +25,7 @@ def mock_ffmpeg_environment(mocker): # Mocking all the ffmpeg calls in one place mock_ffmpeg_input = mocker.patch("ffmpeg.input") mock_makedirs = mocker.patch("os.makedirs") - mocker.patch.object(Media, "is_video", return_value=True), + (mocker.patch.object(Media, "is_video", return_value=True),) mock_probe = mocker.patch( "ffmpeg.probe", return_value={ @@ -35,9 +35,7 @@ def mock_ffmpeg_environment(mocker): }, ) mock_output = mocker.MagicMock() - mock_ffmpeg_input.return_value.filter.return_value.output.return_value = ( - mock_output - ) + mock_ffmpeg_input.return_value.filter.return_value.output.return_value = mock_output return { "mock_ffmpeg_input": mock_ffmpeg_input, @@ -47,14 +45,21 @@ def mock_ffmpeg_environment(mocker): } -@pytest.mark.parametrize("thumbnails_per_minute, max_thumbnails, expected_count", [ - (10, 5, 5), # Capped at max_thumbnails - (1, 10, 2), # Less than max_thumbnails - (60, 7, 7), # Matches exactly -]) +@pytest.mark.parametrize( + "thumbnails_per_minute, max_thumbnails, expected_count", + [ + (10, 5, 5), # Capped at max_thumbnails + (1, 10, 2), # Less than max_thumbnails + (60, 7, 7), # Matches exactly + ], +) def test_enrich_thumbnail_limits( - thumbnail_enricher, metadata_with_video, mock_ffmpeg_environment, - thumbnails_per_minute, max_thumbnails, expected_count + thumbnail_enricher, + metadata_with_video, + mock_ffmpeg_environment, + thumbnails_per_minute, + max_thumbnails, + expected_count, ): thumbnail_enricher.thumbnails_per_minute = thumbnails_per_minute thumbnail_enricher.max_thumbnails = max_thumbnails @@ -65,8 +70,8 @@ def test_enrich_thumbnail_limits( thumbnails = metadata_with_video.media[0].get("thumbnails") assert len(thumbnails) == expected_count -def test_enrich_handles_probe_failure(thumbnail_enricher, metadata_with_video, mocker): +def test_enrich_handles_probe_failure(thumbnail_enricher, metadata_with_video, mocker): mocker.patch("ffmpeg.probe", side_effect=Exception("Probe error")) mocker.patch("os.makedirs") mock_logger = mocker.patch("loguru.logger.error") @@ -74,36 +79,43 @@ def test_enrich_handles_probe_failure(thumbnail_enricher, metadata_with_video, m thumbnail_enricher.enrich(metadata_with_video) # Ensure error was logged - mock_logger.assert_called_with( - f"error getting duration of video video.mp4: Probe error" - ) + mock_logger.assert_called_with("error getting duration of video video.mp4: Probe error") # Ensure no thumbnails were created thumbnails = metadata_with_video.media[0].get("thumbnails") assert thumbnails is None def test_enrich_skips_non_video_files(thumbnail_enricher, metadata_with_video, mocker): - mocker.patch.object(Media, "is_video", return_value=False) - mock_ffmpeg = mocker.patch("ffmpeg.input") - thumbnail_enricher.enrich(metadata_with_video) - mock_ffmpeg.assert_not_called() + mocker.patch.object(Media, "is_video", return_value=False) + mock_ffmpeg = mocker.patch("ffmpeg.input") + thumbnail_enricher.enrich(metadata_with_video) + mock_ffmpeg.assert_not_called() -@pytest.mark.parametrize("thumbnails_per_minute,max_thumbnails,expected_count", [ - (60, 5, 5), # caught by max - (60, 20, 10), # caught by t/min - (0, 20, 1), # test min caught (1) - (11, 20, 1), # test min caught (1) - (12, 20, 2), # test caught by t/min -]) +@pytest.mark.parametrize( + "thumbnails_per_minute,max_thumbnails,expected_count", + [ + (60, 5, 5), # caught by max + (60, 20, 10), # caught by t/min + (0, 20, 1), # test min caught (1) + (11, 20, 1), # test min caught (1) + (12, 20, 2), # test caught by t/min + ], +) def test_enrich_handles_short_video( - thumbnail_enricher, metadata_with_video, mock_ffmpeg_environment, thumbnails_per_minute, max_thumbnails, expected_count, mocker + thumbnail_enricher, + metadata_with_video, + mock_ffmpeg_environment, + thumbnails_per_minute, + max_thumbnails, + expected_count, + mocker, ): # override mock duration fake_duration = 10 mocker.patch( "ffmpeg.probe", - return_value={ "streams": [{"codec_type": "video", "duration": str(fake_duration)}]}, + return_value={"streams": [{"codec_type": "video", "duration": str(fake_duration)}]}, ) thumbnail_enricher.thumbnails_per_minute = thumbnails_per_minute thumbnail_enricher.max_thumbnails = max_thumbnails @@ -114,9 +126,7 @@ def test_enrich_handles_short_video( assert len(thumbnails) == expected_count -def test_uses_existing_duration( - thumbnail_enricher, metadata_with_video, mock_ffmpeg_environment -): +def test_uses_existing_duration(thumbnail_enricher, metadata_with_video, mock_ffmpeg_environment): metadata_with_video.media[0].set("duration", 60) thumbnail_enricher.enrich(metadata_with_video) mock_ffmpeg_environment["mock_probe"].assert_not_called() @@ -125,7 +135,7 @@ def test_uses_existing_duration( def test_enrich_metadata_structure(thumbnail_enricher, metadata_with_video, mock_ffmpeg_environment, mocker): fake_duration = 120 - mocker.patch("ffmpeg.probe", return_value={'streams': [{'codec_type': 'video', 'duration': str(fake_duration)}]}) + mocker.patch("ffmpeg.probe", return_value={"streams": [{"codec_type": "video", "duration": str(fake_duration)}]}) thumbnail_enricher.thumbnails_per_minute = 2 thumbnail_enricher.max_thumbnails = 4 diff --git a/tests/enrichers/test_wacz_enricher.py b/tests/enrichers/test_wacz_enricher.py index d55733d..ceab83b 100644 --- a/tests/enrichers/test_wacz_enricher.py +++ b/tests/enrichers/test_wacz_enricher.py @@ -18,7 +18,7 @@ def wacz_enricher(setup_module, mock_binary_dependencies): "socks_proxy_port": None, "proxy_server": None, } - wacz = setup_module("wacz_enricher", configs) + wacz = setup_module("wacz_extractor_enricher", configs) return wacz diff --git a/tests/enrichers/test_wayback_enricher.py b/tests/enrichers/test_wayback_enricher.py index 88f4662..113458b 100644 --- a/tests/enrichers/test_wayback_enricher.py +++ b/tests/enrichers/test_wayback_enricher.py @@ -5,37 +5,52 @@ from auto_archiver.modules.wayback_extractor_enricher import WaybackExtractorEnr from auto_archiver.core import Metadata +@pytest.fixture(autouse=True) +def mock_sleep(mocker): + """Mock time.sleep to avoid delays.""" + return mocker.patch("time.sleep") + + @pytest.fixture def mock_is_auth_wall(mocker): """Fixture to mock is_auth_wall behavior.""" + def _mock_is_auth_wall(return_value: bool): return mocker.patch("auto_archiver.utils.url.is_auth_wall", return_value=return_value) + return _mock_is_auth_wall + @pytest.fixture def mock_post_success(mocker): """Fixture to mock POST requests with a successful response.""" + def _mock_post(json_data: dict = None, status_code: int = 200): - json_data = json_data or {"job_id": "job123"} + json_data = {"job_id": "job123"} if json_data is None else json_data resp = mocker.Mock(status_code=status_code) resp.json.return_value = json_data return mocker.patch("requests.post", return_value=resp) + return _mock_post + @pytest.fixture def mock_get_success(mocker): """Fixture to mock GET requests returning a completed archive status.""" + def _mock_get(json_data: dict = None, status_code: int = 200): json_data = json_data or { "status": "success", "timestamp": "20250101010101", - "original_url": "https://example.com" + "original_url": "https://example.com", } resp = mocker.Mock(status_code=status_code) resp.json.return_value = json_data return mocker.patch("requests.get", return_value=resp) + return _mock_get + @pytest.fixture def wayback_extractor_enricher(setup_module) -> WaybackExtractorEnricher: configs: dict = { @@ -49,12 +64,7 @@ def wayback_extractor_enricher(setup_module) -> WaybackExtractorEnricher: return setup_module("wayback_extractor_enricher", configs) -def test_download_success( - wayback_extractor_enricher, - mock_is_auth_wall, - mock_post_success, - mock_get_success -): +def test_download_success(wayback_extractor_enricher, mock_is_auth_wall, mock_post_success, mock_get_success): mock_is_auth_wall(False) mock_post_success() mock_get_success() @@ -63,34 +73,28 @@ def test_download_success( result = wayback_extractor_enricher.download(metadata) assert result.get("wayback") == "https://web.archive.org/web/20250101010101/https://example.com" + def test_enrich_auth_wall(wayback_extractor_enricher, metadata, mock_is_auth_wall): mock_is_auth_wall(True) result = wayback_extractor_enricher.enrich(metadata) assert result is None + def test_enrich_already_enriched(wayback_extractor_enricher, metadata): metadata.set("wayback", "existing") result = wayback_extractor_enricher.enrich(metadata) assert result is True -def test_enrich_post_failure( - wayback_extractor_enricher, - metadata, - mock_is_auth_wall, - mock_post_success -): + +def test_enrich_post_failure(wayback_extractor_enricher, metadata, mock_is_auth_wall, mock_post_success): mock_is_auth_wall(False) mock_post_success(json_data={"error": "server error"}, status_code=500) result = wayback_extractor_enricher.enrich(metadata) assert result is False assert "Internet archive failed with status of 500" in metadata.get("wayback") -def test_enrich_post_json_decode_error( - wayback_extractor_enricher, - metadata, - mock_is_auth_wall, - mocker -): + +def test_enrich_post_json_decode_error(wayback_extractor_enricher, metadata, mock_is_auth_wall, mocker): mock_is_auth_wall(False) resp = mocker.Mock(status_code=200) resp.json.side_effect = json.decoder.JSONDecodeError("msg", "doc", 0) @@ -98,22 +102,15 @@ def test_enrich_post_json_decode_error( mocker.patch("requests.post", return_value=resp) assert wayback_extractor_enricher.enrich(metadata) is False -def test_enrich_no_job_id( - wayback_extractor_enricher, - metadata, - mock_is_auth_wall, - mock_post_success -): + +def test_enrich_no_job_id(wayback_extractor_enricher, metadata, mock_is_auth_wall, mock_post_success): mock_is_auth_wall(False) mock_post_success(json_data={}) assert wayback_extractor_enricher.enrich(metadata) is False + def test_enrich_get_success( - wayback_extractor_enricher, - metadata, - mock_is_auth_wall, - mock_post_success, - mock_get_success + wayback_extractor_enricher, metadata, mock_is_auth_wall, mock_post_success, mock_get_success ): mock_is_auth_wall(False) mock_post_success() @@ -122,24 +119,18 @@ def test_enrich_get_success( assert metadata.get("wayback") == "https://web.archive.org/web/20250101010101/https://example.com" assert metadata.get("check wayback") == "https://web.archive.org/web/*/https://example.com" + def test_enrich_get_failure( - wayback_extractor_enricher, - metadata, - mock_is_auth_wall, - mock_post_success, - mock_get_success + wayback_extractor_enricher, metadata, mock_is_auth_wall, mock_post_success, mock_get_success ): mock_is_auth_wall(False) mock_post_success() mock_get_success(json_data={"status": "failed"}, status_code=400) assert wayback_extractor_enricher.enrich(metadata) is False + def test_enrich_get_request_exception( - wayback_extractor_enricher, - metadata, - mock_is_auth_wall, - mock_post_success, - mocker + wayback_extractor_enricher, metadata, mock_is_auth_wall, mock_post_success, mocker ): mock_is_auth_wall(False) mock_post_success() @@ -149,12 +140,9 @@ def test_enrich_get_request_exception( assert wayback_extractor_enricher.enrich(metadata) is True assert metadata.get("wayback").get("job_id") == "job123" + def test_enrich_get_json_decode_error( - wayback_extractor_enricher, - metadata, - mock_is_auth_wall, - mock_post_success, - mocker + wayback_extractor_enricher, metadata, mock_is_auth_wall, mock_post_success, mocker ): mock_is_auth_wall(False) mock_post_success() diff --git a/tests/enrichers/test_whisper_enricher.py b/tests/enrichers/test_whisper_enricher.py index ee1844a..f669631 100644 --- a/tests/enrichers/test_whisper_enricher.py +++ b/tests/enrichers/test_whisper_enricher.py @@ -7,6 +7,12 @@ from auto_archiver.modules.whisper_enricher import WhisperEnricher TEST_S3_URL = "http://cdn.example.com/test.mp4" +@pytest.fixture(autouse=True) +def mock_sleep(mocker): + """Mock time.sleep to avoid delays.""" + return mocker.patch("time.sleep") + + @pytest.fixture def enricher(mocker): """Fixture with mocked S3 and API dependencies""" @@ -16,7 +22,7 @@ def enricher(mocker): "include_srt": False, "timeout": 5, "action": "translate", - "steps": {"storages": ["s3_storage"]} + "steps": {"storages": ["s3_storage"]}, } mock_s3 = mocker.MagicMock(spec=S3Storage) mock_s3.get_cdn_url.return_value = TEST_S3_URL @@ -25,7 +31,7 @@ def enricher(mocker): instance.display_name = "Whisper Enricher" instance.config_setup({instance.name: config}) # bypassing the setup method and mocking S3 setup - instance.stores = config['steps']['storages'] + instance.stores = config["steps"]["storages"] instance.s3 = mock_s3 yield instance, mock_s3 @@ -63,19 +69,14 @@ def test_successful_job_submission(enricher, metadata, mock_requests, mocker): # Mock the complete API interaction chain mock_status_response = mocker.MagicMock() mock_status_response.status_code = 200 - mock_status_response.json.return_value = { - "status": "success", - "meta": {} - } + mock_status_response.json.return_value = {"status": "success", "meta": {}} mock_artifacts_response = mocker.MagicMock() mock_artifacts_response.status_code = 200 - mock_artifacts_response.json.return_value = [{ - "data": [{"start": 0, "end": 5, "text": "test transcript"}] - }] + mock_artifacts_response.json.return_value = [{"data": [{"start": 0, "end": 5, "text": "test transcript"}]}] # Set up mock response sequence mock_requests.get.side_effect = [ mock_status_response, # First call: status check - mock_artifacts_response # Second call: artifacts check + mock_artifacts_response, # Second call: artifacts check ] # Run enrichment (without opening file) @@ -84,15 +85,17 @@ def test_successful_job_submission(enricher, metadata, mock_requests, mocker): mock_requests.post.assert_called_once_with( "http://testapi/jobs", json={"url": "http://cdn.example.com/test.mp4", "type": "translate"}, - headers={"Authorization": "Bearer whisper-key"} + headers={"Authorization": "Bearer whisper-key"}, ) # Verify job status checks assert mock_requests.get.call_count == 2 assert "artifact_0_text" in metadata.media[0].get("whisper_model") - assert metadata.media[0].get("whisper_model") == {'artifact_0_text': 'test transcript', - 'job_artifacts_check': 'http://testapi/jobs/job123/artifacts', - 'job_id': 'job123', - 'job_status_check': 'http://testapi/jobs/job123'} + assert metadata.media[0].get("whisper_model") == { + "artifact_0_text": "test transcript", + "job_artifacts_check": "http://testapi/jobs/job123/artifacts", + "job_id": "job123", + "job_status_check": "http://testapi/jobs/job123", + } def test_submit_job(enricher, mocker): diff --git a/tests/extractors/test_extractor_base.py b/tests/extractors/test_extractor_base.py index 6e77ec3..0240529 100644 --- a/tests/extractors/test_extractor_base.py +++ b/tests/extractors/test_extractor_base.py @@ -7,7 +7,6 @@ from auto_archiver.core.extractor import Extractor class TestExtractorBase(object): - extractor_module: str = None config: dict = None @@ -17,7 +16,7 @@ class TestExtractorBase(object): assert self.config is not None, "self.config must be a dict set on the subclass" self.extractor: Type[Extractor] = setup_module(self.extractor_module, self.config) - + def assertValidResponseMetadata(self, test_response: Metadata, title: str, timestamp: str, status: str = ""): assert test_response is not False diff --git a/tests/extractors/test_generic_extractor.py b/tests/extractors/test_generic_extractor.py index ac280f7..2089007 100644 --- a/tests/extractors/test_generic_extractor.py +++ b/tests/extractors/test_generic_extractor.py @@ -9,26 +9,28 @@ import pytest from auto_archiver.modules.generic_extractor.generic_extractor import GenericExtractor from .test_extractor_base import TestExtractorBase -CI=os.getenv("GITHUB_ACTIONS", '') == 'true' +CI = os.getenv("GITHUB_ACTIONS", "") == "true" + + class TestGenericExtractor(TestExtractorBase): - """Tests Generic Extractor - """ - extractor_module = 'generic_extractor' + """Tests Generic Extractor""" + + extractor_module = "generic_extractor" extractor: GenericExtractor config = { - 'subtitles': False, - 'comments': False, - 'livestreams': False, - 'live_from_start': False, - 'end_means_success': True, - 'allow_playlist': False, - 'max_downloads': "inf", - 'proxy': None, - 'cookies_from_browser': False, - 'cookie_file': None, - } - + "subtitles": False, + "comments": False, + "livestreams": False, + "live_from_start": False, + "end_means_success": True, + "allow_playlist": False, + "max_downloads": "inf", + "proxy": None, + "cookies_from_browser": False, + "cookie_file": None, + } + def test_load_dropin(self): # test loading dropins that are in the generic_archiver package package = "auto_archiver.modules.generic_extractor" @@ -38,32 +40,42 @@ class TestGenericExtractor(TestExtractorBase): path = os.path.join(dirname(dirname(__file__)), "data/") assert self.extractor.dropin_for_name("dropin", additional_paths=[path]) - - @pytest.mark.parametrize("url, suitable_extractors", [ - ("https://www.youtube.com/watch?v=5qap5aO4i9A", ["youtube"]), - ("https://www.tiktok.com/@funnycats0ftiktok/video/7345101300750748970?lang=en", ["tiktok"]), - ("https://www.instagram.com/p/CU1J9JYJ9Zz/", ["instagram"]), - ("https://www.facebook.com/nytimes/videos/10160796550110716", ["facebook"]), - ("https://www.facebook.com/BylineFest/photos/t.100057299682816/927879487315946/", ["facebook"]),]) + @pytest.mark.parametrize( + "url, suitable_extractors", + [ + ("https://www.youtube.com/watch?v=5qap5aO4i9A", ["youtube"]), + ("https://www.tiktok.com/@funnycats0ftiktok/video/7345101300750748970?lang=en", ["tiktok"]), + ("https://www.instagram.com/p/CU1J9JYJ9Zz/", ["instagram"]), + ("https://www.facebook.com/nytimes/videos/10160796550110716", ["facebook"]), + ("https://www.facebook.com/BylineFest/photos/t.100057299682816/927879487315946/", ["facebook"]), + ], + ) def test_suitable_extractors(self, url, suitable_extractors): - suitable_extractors = suitable_extractors + ['generic'] # the generic is valid for all + suitable_extractors = suitable_extractors + ["generic"] # the generic is valid for all extractors = list(self.extractor.suitable_extractors(url)) assert len(extractors) == len(suitable_extractors) assert [e.ie_key().lower() for e in extractors] == suitable_extractors - @pytest.mark.parametrize("url, is_suitable", [ - ("https://www.youtube.com/watch?v=5qap5aO4i9A", True), - ("https://www.tiktok.com/@funnycats0ftiktok/video/7345101300750748970?lang=en", True), - ("https://www.instagram.com/p/CU1J9JYJ9Zz/", True), - ("https://www.facebook.com/nytimes/videos/10160796550110716", True), - ("https://www.twitch.tv/videos/1167226570", True), - ("https://bellingcat.com/news/2021/10/08/ukrainian-soldiers-are-being-killed-by-landmines-in-the-donbas/", True), - ("https://google.com", True)]) + @pytest.mark.parametrize( + "url, is_suitable", + [ + ("https://www.youtube.com/watch?v=5qap5aO4i9A", True), + ("https://www.tiktok.com/@funnycats0ftiktok/video/7345101300750748970?lang=en", True), + ("https://www.instagram.com/p/CU1J9JYJ9Zz/", True), + ("https://www.facebook.com/nytimes/videos/10160796550110716", True), + ("https://www.twitch.tv/videos/1167226570", True), + ( + "https://bellingcat.com/news/2021/10/08/ukrainian-soldiers-are-being-killed-by-landmines-in-the-donbas/", + True, + ), + ("https://google.com", True), + ], + ) def test_suitable_urls(self, url, is_suitable): """ - Note: expected behaviour is to return True for all URLs, as YoutubeDLArchiver should be able to handle all URLs - This behaviour may be changed in the future (e.g. if we want the youtubedl archiver to just handle URLs it has extractors for, - and then if and only if all archivers fails, does it fall back to the generic archiver) + Note: expected behaviour is to return True for all URLs, as YoutubeDLArchiver should be able to handle all URLs + This behaviour may be changed in the future (e.g. if we want the youtubedl archiver to just handle URLs it has extractors for, + and then if and only if all archivers fails, does it fall back to the generic archiver) """ assert self.extractor.suitable(url) == is_suitable @@ -74,12 +86,15 @@ class TestGenericExtractor(TestExtractorBase): assert result.get_url() == "https://www.tiktok.com/@funnycats0ftiktok/video/7345101300750748970" @pytest.mark.download - @pytest.mark.parametrize("url", [ - "https://bsky.app/profile/colborne.bsky.social/post/3lcxcpgt6j42l", - "twitter.com/bellingcat/status/123", - "https://www.youtube.com/watch?v=1" - ]) - def test_download_nonexistend_media(self, make_item, url): + @pytest.mark.parametrize( + "url", + [ + "https://bsky.app/profile/colborne.bsky.social/post/3lcxcpgt6j42l", + "twitter.com/bellingcat/status/123", + "https://www.youtube.com/watch?v=1", + ], + ) + def test_download_nonexistent_media(self, make_item, url): """ Test to make sure that the extractor doesn't break on non-existend posts/media @@ -89,7 +104,10 @@ class TestGenericExtractor(TestExtractorBase): result = self.extractor.download(item) assert not result - @pytest.mark.skipif(CI, reason="Currently no way to authenticate when on CI. Youtube (yt-dlp) doesn't support logging in with username/password.") + @pytest.mark.skipif( + CI, + reason="Currently no way to authenticate when on CI. Youtube (yt-dlp) doesn't support logging in with username/password.", + ) @pytest.mark.download def test_youtube_download(self, make_item): # url https://www.youtube.com/watch?v=5qap5aO4i9A @@ -98,7 +116,10 @@ class TestGenericExtractor(TestExtractorBase): result = self.extractor.download(item) assert result.get_url() == "https://www.youtube.com/watch?v=J---aiyznGQ" assert result.get_title() == "Keyboard Cat! - THE ORIGINAL!" - assert result.get('description') == "Buy NEW Keyboard Cat Merch! https://keyboardcat.creator-spring.com\n\nxo Keyboard Cat memes make your day better!\nhttp://www.keyboardcatstore.com/\nhttps://www.facebook.com/thekeyboardcat\nhttp://www.charlieschmidt.com/" + assert ( + result.get("description") + == "Buy NEW Keyboard Cat Merch! https://keyboardcat.creator-spring.com\n\nxo Keyboard Cat memes make your day better!\nhttp://www.keyboardcatstore.com/\nhttps://www.facebook.com/thekeyboardcat\nhttp://www.charlieschmidt.com/" + ) assert len(result.media) == 2 assert Path(result.media[0].filename).name == "J---aiyznGQ.webm" assert Path(result.media[1].filename).name == "hqdefault.jpg" @@ -114,7 +135,7 @@ class TestGenericExtractor(TestExtractorBase): item = make_item("https://bsky.app/profile/bellingcat.com/post/3lfn3hbcxgc2q") result = self.extractor.download(item) assert result is not False - + @pytest.mark.download def test_bluesky_download_no_media(self, make_item): item = make_item("https://bsky.app/profile/bellingcat.com/post/3lfphwmcs4c2z") @@ -126,7 +147,7 @@ class TestGenericExtractor(TestExtractorBase): item = make_item("https://bsky.app/profile/bellingcat.com/post/3le2l4gsxlk2i") result = self.extractor.download(item) assert result is not False - + @pytest.mark.skipif(CI, reason="Truth social blocks GH actions.") @pytest.mark.download def test_truthsocial_download_video(self, make_item): @@ -141,14 +162,14 @@ class TestGenericExtractor(TestExtractorBase): item = make_item("https://truthsocial.com/@bbcnewa/posts/109598702184774628") result = self.extractor.download(item) assert result is not False - + @pytest.mark.skipif(CI, reason="Truth social blocks GH actions.") @pytest.mark.download def test_truthsocial_download_poll(self, make_item): item = make_item("https://truthsocial.com/@CNN_US/posts/113724326568555098") result = self.extractor.download(item) assert result is not False - + @pytest.mark.skipif(CI, reason="Truth social blocks GH actions.") @pytest.mark.download def test_truthsocial_download_single_image(self, make_item): @@ -170,7 +191,7 @@ class TestGenericExtractor(TestExtractorBase): url = "https://x.com/Bellingcat/status/17197025860711058" response = self.extractor.download(make_item(url)) assert not response - + @pytest.mark.download def test_twitter_download_malformed_tweetid(self, make_item): # this tweet does not exist @@ -180,7 +201,6 @@ class TestGenericExtractor(TestExtractorBase): @pytest.mark.download def test_twitter_download_tweet_no_media(self, make_item): - item = make_item("https://twitter.com/MeCookieMonster/status/1617921633456640001?s=20&t=3d0g4ZQis7dCbSDg-mE7-w") post = self.extractor.download(item) @@ -188,9 +208,9 @@ class TestGenericExtractor(TestExtractorBase): post, "Onion rings are just vegetable donuts.", datetime.datetime(2023, 1, 24, 16, 25, 51, tzinfo=datetime.timezone.utc), - "yt-dlp_Twitter: success" + "yt-dlp_Twitter: success", ) - + @pytest.mark.download def test_twitter_download_video(self, make_item): url = "https://x.com/bellingcat/status/1871552600346415571" @@ -198,33 +218,52 @@ class TestGenericExtractor(TestExtractorBase): self.assertValidResponseMetadata( post, "Bellingcat - This month's Bellingchat Premium is with @KolinaKoltai. She reveals how she investigated a platform allowing users to create AI-generated child sexual abuse material and explains why it's crucial to investigate the people behind these services", - datetime.datetime(2024, 12, 24, 13, 44, 46, tzinfo=datetime.timezone.utc) + datetime.datetime(2024, 12, 24, 13, 44, 46, tzinfo=datetime.timezone.utc), ) - @pytest.mark.xfail(reason="Currently failing, sensitive content requires logged in users/cookies - not yet implemented") + @pytest.mark.xfail( + reason="Currently failing, sensitive content requires logged in users/cookies - not yet implemented" + ) @pytest.mark.download - @pytest.mark.parametrize("url, title, timestamp, image_hash", [ - ("https://x.com/SozinhoRamalho/status/1876710769913450647", "ignore tweet, testing sensitivity warning nudity", datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), "image_hash"), - ("https://x.com/SozinhoRamalho/status/1876710875475681357", "ignore tweet, testing sensitivity warning violence", datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), "image_hash"), - ("https://x.com/SozinhoRamalho/status/1876711053813227618", "ignore tweet, testing sensitivity warning sensitive", datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), "image_hash"), - ("https://x.com/SozinhoRamalho/status/1876711141314801937", "ignore tweet, testing sensitivity warning nudity, violence, sensitivity", datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), "image_hash"), - ]) + @pytest.mark.parametrize( + "url, title, timestamp, image_hash", + [ + ( + "https://x.com/SozinhoRamalho/status/1876710769913450647", + "ignore tweet, testing sensitivity warning nudity", + datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), + "image_hash", + ), + ( + "https://x.com/SozinhoRamalho/status/1876710875475681357", + "ignore tweet, testing sensitivity warning violence", + datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), + "image_hash", + ), + ( + "https://x.com/SozinhoRamalho/status/1876711053813227618", + "ignore tweet, testing sensitivity warning sensitive", + datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), + "image_hash", + ), + ( + "https://x.com/SozinhoRamalho/status/1876711141314801937", + "ignore tweet, testing sensitivity warning nudity, violence, sensitivity", + datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), + "image_hash", + ), + ], + ) def test_twitter_download_sensitive_media(self, url, title, timestamp, image_hash, make_item): - """Download tweets with sensitive media""" post = self.extractor.download(make_item(url)) - self.assertValidResponseMetadata( - post, - title, - timestamp - ) + self.assertValidResponseMetadata(post, title, timestamp) assert len(post.media) == 1 assert post.media[0].hash == image_hash @pytest.mark.download def test_download_facebook_video(self, make_item): - post = self.extractor.download(make_item("https://www.facebook.com/bellingcat/videos/588371253839133")) assert len(post.media) == 2 assert post.media[0].filename.endswith("588371253839133.mp4") @@ -234,11 +273,12 @@ class TestGenericExtractor(TestExtractorBase): assert post.media[1].mimetype == "image/jpeg" assert "Bellingchat Premium is with Kolina Koltai" in post.get_title() - + @pytest.mark.download def test_download_facebook_image(self, make_item): - - post = self.extractor.download(make_item("https://www.facebook.com/BylineFest/photos/t.100057299682816/927879487315946/")) + post = self.extractor.download( + make_item("https://www.facebook.com/BylineFest/photos/t.100057299682816/927879487315946/") + ) assert len(post.media) == 1 assert post.media[0].filename.endswith(".png") @@ -248,5 +288,5 @@ class TestGenericExtractor(TestExtractorBase): def test_download_facebook_text_only(self, make_item): url = "https://www.facebook.com/bellingcat/posts/pfbid02rzpwZxAZ8bLkAX8NvHv4DWAidFaqAUfJMbo9vWkpwxL7uMUWzWMiizXLWRSjwihVl" post = self.extractor.download(make_item(url)) - assert "Bellingcat researcher Kolina Koltai delves deeper into Clothoff" in post.get('content') + assert "Bellingcat researcher Kolina Koltai delves deeper into Clothoff" in post.get("content") assert post.get_title() == "Bellingcat" diff --git a/tests/extractors/test_instagram_api_extractor.py b/tests/extractors/test_instagram_api_extractor.py index 7eba8e9..d8a1cc0 100644 --- a/tests/extractors/test_instagram_api_extractor.py +++ b/tests/extractors/test_instagram_api_extractor.py @@ -15,10 +15,11 @@ def mock_user_response(): "username": "test_user", "full_name": "Test User", "profile_pic_url_hd": "http://example.com/profile.jpg", - "profile_pic_url": "http://example.com/profile_lowres.jpg" + "profile_pic_url": "http://example.com/profile_lowres.jpg", } } + @pytest.fixture def mock_post_response(): return { @@ -27,16 +28,14 @@ def mock_post_response(): "caption_text": "Test Caption", "taken_at": datetime.now().timestamp(), "video_url": "http://example.com/video.mp4", - "thumbnail_url": "http://example.com/thumbnail.jpg" + "thumbnail_url": "http://example.com/thumbnail.jpg", } + @pytest.fixture def mock_story_response(): - return [{ - "id": "story_123", - "taken_at": datetime.now().timestamp(), - "video_url": "http://example.com/story.mp4" - }] + return [{"id": "story_123", "taken_at": datetime.now().timestamp(), "video_url": "http://example.com/story.mp4"}] + @pytest.fixture def mock_highlight_response(): @@ -46,11 +45,13 @@ def mock_highlight_response(): "highlight:123": { "id": "123", "title": "Test Highlight", - "items": [{ - "id": "item_123", - "taken_at": datetime.now().timestamp(), - "video_url": "http://example.com/highlight.mp4" - }] + "items": [ + { + "id": "item_123", + "taken_at": datetime.now().timestamp(), + "video_url": "http://example.com/highlight.mp4", + } + ], } } } @@ -81,24 +82,30 @@ class TestInstagramAPIExtractor(TestExtractorBase): m.set("netloc", "instagram.com") return m - @pytest.mark.parametrize("url,expected", [ - ("https://instagram.com/user", [("", "user", "")]), - ("https://instagr.am/p/post_id", []), - ("https://youtube.com", []), - ("https://www.instagram.com/reel/reel_id", [("reel", "reel_id", "")]), - ("https://instagram.com/stories/highlights/123", [("stories/highlights", "123", "")]), - ("https://instagram.com/stories/user/123", [("stories", "user", "123")]), - ]) + @pytest.mark.parametrize( + "url,expected", + [ + ("https://instagram.com/user", [("", "user", "")]), + ("https://instagr.am/p/post_id", []), + ("https://youtube.com", []), + ("https://www.instagram.com/reel/reel_id", [("reel", "reel_id", "")]), + ("https://instagram.com/stories/highlights/123", [("stories/highlights", "123", "")]), + ("https://instagram.com/stories/user/123", [("stories", "user", "123")]), + ], + ) def test_url_parsing(self, url, expected): assert self.extractor.valid_url.findall(url) == expected def test_initialize(self): assert self.extractor.api_endpoint[-1] != "/" - @pytest.mark.parametrize("input_dict,expected", [ - ({"x": 0, "valid": "data"}, {"valid": "data"}), - ({"nested": {"y": None, "valid": [{}]}}, {"nested": {"valid": [{}]}}), - ]) + @pytest.mark.parametrize( + "input_dict,expected", + [ + ({"x": 0, "valid": "data"}, {"valid": "data"}), + ({"nested": {"y": None, "valid": [{}]}}, {"nested": {"valid": [{}]}}), + ], + ) def test_cleanup_dict(self, input_dict, expected): assert self.extractor.cleanup_dict(input_dict) == expected @@ -114,8 +121,8 @@ class TestInstagramAPIExtractor(TestExtractorBase): def test_download_profile_basic(self, metadata, mock_user_response, mocker): """Test basic profile download without full_profile""" - mock_call = mocker.patch.object(self.extractor, 'call_api') - mock_download = mocker.patch.object(self.extractor, 'download_from_url') + mock_call = mocker.patch.object(self.extractor, "call_api") + mock_download = mocker.patch.object(self.extractor, "download_from_url") # Mock API responses mock_call.return_value = mock_user_response mock_download.return_value = "profile.jpg" @@ -132,17 +139,14 @@ class TestInstagramAPIExtractor(TestExtractorBase): def test_download_profile_full(self, metadata, mock_user_response, mock_story_response, mocker): """Test full profile download with stories/posts""" - mock_call = mocker.patch.object(self.extractor, 'call_api') - mock_posts = mocker.patch.object(self.extractor, 'download_all_posts') - mock_highlights = mocker.patch.object(self.extractor, 'download_all_highlights') - mock_tagged = mocker.patch.object(self.extractor, 'download_all_tagged') - mock_stories = mocker.patch.object(self.extractor, '_download_stories_reusable') + mock_call = mocker.patch.object(self.extractor, "call_api") + mock_posts = mocker.patch.object(self.extractor, "download_all_posts") + mock_highlights = mocker.patch.object(self.extractor, "download_all_highlights") + mock_tagged = mocker.patch.object(self.extractor, "download_all_tagged") + mock_stories = mocker.patch.object(self.extractor, "_download_stories_reusable") self.extractor.full_profile = True - mock_call.side_effect = [ - mock_user_response, - mock_story_response - ] + mock_call.side_effect = [mock_user_response, mock_story_response] mock_highlights.return_value = None mock_stories.return_value = mock_story_response mock_posts.return_value = None @@ -155,7 +159,7 @@ class TestInstagramAPIExtractor(TestExtractorBase): def test_download_profile_not_found(self, metadata, mocker): """Test profile not found error""" - mock_call = mocker.patch.object(self.extractor, 'call_api') + mock_call = mocker.patch.object(self.extractor, "call_api") mock_call.return_value = {"user": None} with pytest.raises(AssertionError) as exc_info: self.extractor.download_profile(metadata, "invalid_user") @@ -163,18 +167,14 @@ class TestInstagramAPIExtractor(TestExtractorBase): def test_download_profile_error_handling(self, metadata, mock_user_response, mocker): """Test error handling in full profile mode""" - mock_call = mocker.patch.object(self.extractor, 'call_api') - mock_highlights = mocker.patch.object(self.extractor, 'download_all_highlights') - mock_tagged = mocker.patch.object(self.extractor, 'download_all_tagged') - stories_tagged = mocker.patch.object(self.extractor, '_download_stories_reusable') - mock_posts = mocker.patch.object(self.extractor, 'download_all_posts') + mock_call = mocker.patch.object(self.extractor, "call_api") + mock_highlights = mocker.patch.object(self.extractor, "download_all_highlights") + mock_tagged = mocker.patch.object(self.extractor, "download_all_tagged") + stories_tagged = mocker.patch.object(self.extractor, "_download_stories_reusable") + mock_posts = mocker.patch.object(self.extractor, "download_all_posts") self.extractor.full_profile = True - mock_call.side_effect = [ - mock_user_response, - Exception("Stories API failed"), - Exception("Posts API failed") - ] + mock_call.side_effect = [mock_user_response, Exception("Stories API failed"), Exception("Posts API failed")] mock_highlights.return_value = None mock_tagged.return_value = None stories_tagged.return_value = None @@ -182,4 +182,4 @@ class TestInstagramAPIExtractor(TestExtractorBase): result = self.extractor.download_profile(metadata, "test_user") assert result.is_success() - assert "Error downloading stories for test_user" in result.metadata["errors"] \ No newline at end of file + assert "Error downloading stories for test_user" in result.metadata["errors"] diff --git a/tests/extractors/test_instagram_extractor.py b/tests/extractors/test_instagram_extractor.py index 7efe1b1..0cafa2b 100644 --- a/tests/extractors/test_instagram_extractor.py +++ b/tests/extractors/test_instagram_extractor.py @@ -1,21 +1,41 @@ import pytest from auto_archiver.modules.instagram_extractor import InstagramExtractor -from .test_extractor_base import TestExtractorBase -class TestInstagramExtractor(TestExtractorBase): - extractor_module: str = 'instagram_extractor' - config: dict = {} +@pytest.fixture +def instagram_extractor(setup_module, mocker): + extractor_module: str = "instagram_extractor" + config: dict = { + "username": "user_name", + "password": "password123", + "download_folder": "instaloader", + "session_file": "secrets/instaloader.session", + } + fake_loader = mocker.MagicMock() + fake_loader.load_session_from_file.return_value = None + fake_loader.login.return_value = None + fake_loader.save_session_to_file.return_value = None + mocker.patch( + "instaloader.Instaloader", + return_value=fake_loader, + ) + return setup_module(extractor_module, config) - @pytest.mark.parametrize("url", [ + +@pytest.mark.parametrize( + "url", + [ "https://www.instagram.com/p/", "https://www.instagram.com/p/1234567890/", "https://www.instagram.com/reel/1234567890/", "https://www.instagram.com/username/", "https://www.instagram.com/username/stories/", "https://www.instagram.com/username/highlights/", - ]) - def test_regex_matches(self, url): - # post - assert InstagramExtractor.valid_url.match(url) + ], +) +def test_regex_matches(url: str, instagram_extractor: InstagramExtractor) -> None: + """ + Ensure that the valid_url regex matches all provided Instagram URLs. + """ + assert instagram_extractor.valid_url.match(url) diff --git a/tests/extractors/test_instagram_tbot_extractor.py b/tests/extractors/test_instagram_tbot_extractor.py index f274728..47a4bec 100644 --- a/tests/extractors/test_instagram_tbot_extractor.py +++ b/tests/extractors/test_instagram_tbot_extractor.py @@ -7,10 +7,16 @@ from auto_archiver.modules.instagram_tbot_extractor import InstagramTbotExtracto from tests.extractors.test_extractor_base import TestExtractorBase +@pytest.fixture(autouse=True) +def mock_sleep(mocker): + """Mock time.sleep to avoid delays.""" + return mocker.patch("time.sleep") + + @pytest.fixture def patch_extractor_methods(request, setup_module, mocker): - mocker.patch.object(InstagramTbotExtractor, '_prepare_session_file', return_value=None) - mocker.patch.object(InstagramTbotExtractor, '_initialize_telegram_client', return_value=None) + mocker.patch.object(InstagramTbotExtractor, "_prepare_session_file", return_value=None) + mocker.patch.object(InstagramTbotExtractor, "_initialize_telegram_client", return_value=None) yield @@ -35,12 +41,7 @@ def mock_telegram_client(mocker): @pytest.fixture def extractor(setup_module, patch_extractor_methods, mocker): extractor_module = "instagram_tbot_extractor" - config = { - "api_id": 12345, - "api_hash": "test_api_hash", - "session_file": "test_session", - "timeout": 4 - } + config = {"api_id": 12345, "api_hash": "test_api_hash", "session_file": "test_session", "timeout": 4} extractor = setup_module(extractor_module, config) extractor.client = mocker.MagicMock() extractor.session_file = "test_session" @@ -79,21 +80,30 @@ class TestInstagramTbotExtractorReal(TestExtractorBase): "session_file": "secrets/anon-insta", } - @pytest.mark.parametrize("url, expected_status, message, len_media", [ - ("https://www.instagram.com/p/C4QgLbrIKXG", "insta-via-bot: success", - "Are you new to Bellingcat? - The way we share our investigations is different. 💭\nWe want you to read our story but also learn ou", - 6), - ("https://www.instagram.com/reel/DEVLK8qoIbg/", "insta-via-bot: success", - "Our volunteer community is at the centre of many incredible Bellingcat investigations and tools. Stephanie Ladel is one such vol", - 3), - # instagram tbot not working (potentially intermittently?) for stories - replace with a live story to retest - # ("https://www.instagram.com/stories/bellingcatofficial/3556336382743057476/", False, "Media not found or unavailable"), - # Seems to be working intermittently for highlights - # ("https://www.instagram.com/stories/highlights/17868810693068139/", "insta-via-bot: success", None, 50), - # Marking invalid url as success - ("https://www.instagram.com/p/INVALID", "insta-via-bot: success", "Media not found or unavailable", 0), - ("https://www.youtube.com/watch?v=ymCMy8OffHM", False, None, 0), - ]) + @pytest.mark.parametrize( + "url, expected_status, message, len_media", + [ + ( + "https://www.instagram.com/p/C4QgLbrIKXG", + "insta-via-bot: success", + "Are you new to Bellingcat? - The way we share our investigations is different. 💭\nWe want you to read our story but also learn ou", + 6, + ), + ( + "https://www.instagram.com/reel/DEVLK8qoIbg/", + "insta-via-bot: success", + "Our volunteer community is at the centre of many incredible Bellingcat investigations and tools. Stephanie Ladel is one such vol", + 3, + ), + # instagram tbot not working (potentially intermittently?) for stories - replace with a live story to retest + # ("https://www.instagram.com/stories/bellingcatofficial/3556336382743057476/", False, "Media not found or unavailable"), + # Seems to be working intermittently for highlights + # ("https://www.instagram.com/stories/highlights/17868810693068139/", "insta-via-bot: success", None, 50), + # Marking invalid url as success + ("https://www.instagram.com/p/INVALID", "insta-via-bot: success", "Media not found or unavailable", 0), + ("https://www.youtube.com/watch?v=ymCMy8OffHM", False, None, 0), + ], + ) def test_download(self, url, expected_status, message, len_media, metadata_sample): """Test the `download()` method with various Instagram URLs.""" metadata_sample.set_url(url) diff --git a/tests/extractors/test_tiktok_tikwm_extractor.py b/tests/extractors/test_tiktok_tikwm_extractor.py new file mode 100644 index 0000000..d04d7e4 --- /dev/null +++ b/tests/extractors/test_tiktok_tikwm_extractor.py @@ -0,0 +1,151 @@ +from datetime import datetime, timezone +import time +import pytest +import yt_dlp + +from auto_archiver.modules.generic_extractor.generic_extractor import GenericExtractor +from .test_extractor_base import TestExtractorBase + + +@pytest.fixture(autouse=True) +def skip_ytdlp_own_methods(mocker): + # mock this method, so that we skip the ytdlp download in these tests + mocker.patch("auto_archiver.modules.generic_extractor.tiktok.Tiktok.skip_ytdlp_download", return_value=True) + mocker.patch( + "auto_archiver.modules.generic_extractor.generic_extractor.GenericExtractor.suitable_extractors", + return_value=[e for e in yt_dlp.YoutubeDL()._ies.values() if e.IE_NAME == "TikTok"], + ) + + +@pytest.fixture() +def mock_get(mocker): + return mocker.patch("auto_archiver.modules.generic_extractor.tiktok.requests.get") + + +class TestTiktokTikwmExtractor(TestExtractorBase): + """ + Test suite for TestTiktokTikwmExtractor. + """ + + extractor_module = "generic_extractor" + extractor: GenericExtractor + + config = {} + + VALID_EXAMPLE_URL = "https://www.tiktok.com/@example/video/1234" + + def test_invalid_json_responses(self, mock_get, make_item, caplog): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.side_effect = ValueError + with caplog.at_level("DEBUG"): + assert self.extractor.download(make_item(self.VALID_EXAMPLE_URL)) is False + mock_get.assert_called_once() + mock_get.return_value.json.assert_called_once() + # first message is just the 'Skipping using ytdlp to download files for TikTok' message + assert ( + "failed to parse JSON response from tikwm.com for url='https://www.tiktok.com/@example/video/1234'" + in caplog.text + ) + + mock_get.return_value.json.side_effect = Exception + with caplog.at_level("ERROR"): + assert self.extractor.download(make_item(self.VALID_EXAMPLE_URL)) is False + mock_get.assert_called() + assert mock_get.call_count == 2 + assert mock_get.return_value.json.call_count == 2 + assert ( + "failed to parse JSON response from tikwm.com for url='https://www.tiktok.com/@example/video/1234'" + in caplog.text + ) + + @pytest.mark.parametrize( + "response", + [ + ({"msg": "failure"}), + ({"msg": "success"}), + ], + ) + def test_unsuccessful_responses(self, mock_get, make_item, response, caplog): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = response + with caplog.at_level("DEBUG"): + assert self.extractor.download(make_item(self.VALID_EXAMPLE_URL)) is False + mock_get.assert_called_once() + mock_get.return_value.json.assert_called_once() + assert "failed to get a valid response from tikwm.com" in caplog.text + + @pytest.mark.parametrize( + "response,has_vid", + [ + ({"data": {"id": 123}}, False), + ({"data": {"wmplay": "url"}}, True), + ({"data": {"play": "url"}}, True), + ], + ) + def test_correct_extraction(self, mock_get, make_item, response, has_vid, mocker): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"msg": "success", **response} + result = self.extractor.download(make_item(self.VALID_EXAMPLE_URL)) + if not has_vid: + assert result is False + else: + assert result.is_success() + assert len(result.media) == 1 + mock_get.assert_called() + assert mock_get.call_count == 1 + int(has_vid) + mock_get.return_value.json.assert_called_once() + + def test_correct_data_extracted(self, mock_get, make_item): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "msg": "success", + "data": { + "wmplay": "url", + "origin_cover": "cover.jpg", + "title": "Title", + "id": 123, + "duration": 60, + "create_time": 1736301699, + "author": "Author", + "other": "data", + }, + } + + result = self.extractor.download(make_item(self.VALID_EXAMPLE_URL)) + assert result.is_success() + assert len(result.media) == 2 + assert result.get_title() == "Title" + assert result.get("author") == "Author" + assert result.get("api_data") == {"other": "data", "id": 123} + assert result.media[1].get("duration") == 60 + assert result.get("timestamp") == datetime.fromtimestamp(1736301699, tz=timezone.utc) + + @pytest.mark.download + def test_download_video(self, make_item): + url = "https://www.tiktok.com/@bbcnews/video/7478038212070411542" + + result = self.extractor.download(make_item(url)) + assert result.is_success() + assert len(result.media) == 2 + assert ( + result.get_title() + == "The A23a iceberg is one of the world's oldest and it's so big you can see it from space. #Iceberg #A23a #Antarctica #Ice #ClimateChange #DavidAttenborough #Ocean #Sea #SouthGeorgia #BBCNews " + ) + assert result.get("author").get("unique_id") == "bbcnews" + assert result.get("api_data").get("id") == "7478038212070411542" + assert result.media[1].get("duration") == 59 + assert result.get("timestamp") == datetime.fromtimestamp(1741122000, tz=timezone.utc) + + @pytest.mark.download + def test_download_sensitive_video(self, make_item): + url = "https://www.tiktok.com/@ggs68taiwan.official/video/7441821351142362375" + # Required for rate limiting + time.sleep(1.1) + result = self.extractor.download(make_item(url)) + assert result.is_success() + assert len(result.media) == 2 + assert result.get_title() == "Căng nhất lúc này #ggs68 #ggs68taiwan #taiwan #dailoan #tiktoknews" + assert result.get("author").get("id") == "7197400619475649562" + assert result.get("api_data").get("id") == "7441821351142362375" + assert result.media[1].get("duration") == 34 + assert result.get("timestamp") == datetime.fromtimestamp(1732684060, tz=timezone.utc) diff --git a/tests/extractors/test_twitter_api_extractor.py b/tests/extractors/test_twitter_api_extractor.py index 26394ac..8b8e0d9 100644 --- a/tests/extractors/test_twitter_api_extractor.py +++ b/tests/extractors/test_twitter_api_extractor.py @@ -1,6 +1,5 @@ import os import datetime -import hashlib import pytest from pytwitter.models.media import MediaVariant @@ -10,8 +9,7 @@ from auto_archiver.modules.twitter_api_extractor import TwitterApiExtractor @pytest.mark.incremental class TestTwitterApiExtractor(TestExtractorBase): - - extractor_module = 'twitter_api_extractor' + extractor_module: TwitterApiExtractor = "twitter_api_extractor" config = { "bearer_tokens": [], @@ -22,41 +20,79 @@ class TestTwitterApiExtractor(TestExtractorBase): "access_secret": os.environ.get("TWITTER_ACCESS_SECRET"), } - @pytest.mark.parametrize("url, expected", [ - ("https://x.com/bellingcat/status/1874097816571961839", "https://x.com/bellingcat/status/1874097816571961839"), # x.com urls unchanged - ("https://twitter.com/bellingcat/status/1874097816571961839", "https://twitter.com/bellingcat/status/1874097816571961839"), # twitter urls unchanged - ("https://twitter.com/bellingcat/status/1874097816571961839?s=20&t=3d0g4ZQis7dCbSDg-mE7-w", "https://twitter.com/bellingcat/status/1874097816571961839?s=20&t=3d0g4ZQis7dCbSDg-mE7-w"), # don't strip params from twitter urls (changed Jan 2025) - ("https://www.bellingcat.com/category/resources/", "https://www.bellingcat.com/category/resources/"), # non-twitter/x urls unchanged - ("https://www.bellingcat.com/category/resources/?s=20&t=3d0g4ZQis7dCbSDg-mE7-w", "https://www.bellingcat.com/category/resources/?s=20&t=3d0g4ZQis7dCbSDg-mE7-w"), # shouldn't strip params from non-twitter/x URLs - ]) + @pytest.mark.parametrize( + "url, expected", + [ + ( + "https://x.com/bellingcat/status/1874097816571961839", + "https://x.com/bellingcat/status/1874097816571961839", + ), # x.com urls unchanged + ( + "https://twitter.com/bellingcat/status/1874097816571961839", + "https://twitter.com/bellingcat/status/1874097816571961839", + ), # twitter urls unchanged + ( + "https://twitter.com/bellingcat/status/1874097816571961839?s=20&t=3d0g4ZQis7dCbSDg-mE7-w", + "https://twitter.com/bellingcat/status/1874097816571961839?s=20&t=3d0g4ZQis7dCbSDg-mE7-w", + ), # don't strip params from twitter urls (changed Jan 2025) + ( + "https://www.bellingcat.com/category/resources/", + "https://www.bellingcat.com/category/resources/", + ), # non-twitter/x urls unchanged + ( + "https://www.bellingcat.com/category/resources/?s=20&t=3d0g4ZQis7dCbSDg-mE7-w", + "https://www.bellingcat.com/category/resources/?s=20&t=3d0g4ZQis7dCbSDg-mE7-w", + ), # shouldn't strip params from non-twitter/x URLs + ], + ) def test_sanitize_url(self, url, expected): assert expected == self.extractor.sanitize_url(url) @pytest.mark.download def test_sanitize_url_download(self): - assert "https://www.bellingcat.com/category/resources/" == self.extractor.sanitize_url("https://t.co/yl3oOJatFp") + assert "https://www.bellingcat.com/category/resources/" == self.extractor.sanitize_url( + "https://t.co/yl3oOJatFp" + ) - @pytest.mark.parametrize("url, exptected_username, exptected_tweetid", [ - ("https://twitter.com/bellingcat/status/1874097816571961839", "bellingcat", "1874097816571961839"), - ("https://x.com/bellingcat/status/1874097816571961839", "bellingcat", "1874097816571961839"), - ("https://www.bellingcat.com/category/resources/", False, False) - ]) + @pytest.mark.parametrize( + "url, exptected_username, exptected_tweetid", + [ + ("https://twitter.com/bellingcat/status/1874097816571961839", "bellingcat", "1874097816571961839"), + ("https://x.com/bellingcat/status/1874097816571961839", "bellingcat", "1874097816571961839"), + ("https://www.bellingcat.com/category/resources/", False, False), + ], + ) def test_get_username_tweet_id_from_url(self, url, exptected_username, exptected_tweetid): - username, tweet_id = self.extractor.get_username_tweet_id(url) assert exptected_username == username assert exptected_tweetid == tweet_id def test_choose_variants(self): # taken from the response for url https://x.com/bellingcat/status/1871552600346415571 - variant_list = [MediaVariant(content_type='application/x-mpegURL', url='https://video.twimg.com/ext_tw_video/1871551993677852672/pu/pl/ovWo7ux-bKROwYIC.m3u8?tag=12&v=e1b'), - MediaVariant(bit_rate=256000, content_type='video/mp4', url='https://video.twimg.com/ext_tw_video/1871551993677852672/pu/vid/avc1/480x270/OqZIrKV0LFswMvxS.mp4?tag=12'), - MediaVariant(bit_rate=832000, content_type='video/mp4', url='https://video.twimg.com/ext_tw_video/1871551993677852672/pu/vid/avc1/640x360/uiDZDSmZ8MZn9hsi.mp4?tag=12'), - MediaVariant(bit_rate=2176000, content_type='video/mp4', url='https://video.twimg.com/ext_tw_video/1871551993677852672/pu/vid/avc1/1280x720/6Y340Esh568WZnRZ.mp4?tag=12') - ] + variant_list = [ + MediaVariant( + content_type="application/x-mpegURL", + url="https://video.twimg.com/ext_tw_video/1871551993677852672/pu/pl/ovWo7ux-bKROwYIC.m3u8?tag=12&v=e1b", + ), + MediaVariant( + bit_rate=256000, + content_type="video/mp4", + url="https://video.twimg.com/ext_tw_video/1871551993677852672/pu/vid/avc1/480x270/OqZIrKV0LFswMvxS.mp4?tag=12", + ), + MediaVariant( + bit_rate=832000, + content_type="video/mp4", + url="https://video.twimg.com/ext_tw_video/1871551993677852672/pu/vid/avc1/640x360/uiDZDSmZ8MZn9hsi.mp4?tag=12", + ), + MediaVariant( + bit_rate=2176000, + content_type="video/mp4", + url="https://video.twimg.com/ext_tw_video/1871551993677852672/pu/vid/avc1/1280x720/6Y340Esh568WZnRZ.mp4?tag=12", + ), + ] chosen_variant = self.extractor.choose_variant(variant_list) assert chosen_variant == variant_list[3] - + @pytest.mark.skipif(not os.environ.get("TWITTER_BEARER_TOKEN"), reason="No Twitter bearer token provided") @pytest.mark.download def test_download_nonexistent_tweet(self, make_item): @@ -76,7 +112,6 @@ class TestTwitterApiExtractor(TestExtractorBase): @pytest.mark.skipif(not os.environ.get("TWITTER_BEARER_TOKEN"), reason="No Twitter bearer token provided") @pytest.mark.download def test_download_tweet_no_media(self, make_item): - item = make_item("https://twitter.com/MeCookieMonster/status/1617921633456640001?s=20&t=3d0g4ZQis7dCbSDg-mE7-w") post = self.extractor.download(item) @@ -84,7 +119,7 @@ class TestTwitterApiExtractor(TestExtractorBase): post, "Onion rings are just vegetable donuts.", datetime.datetime(2023, 1, 24, 16, 25, 51, tzinfo=datetime.timezone.utc), - "twitter-api: success" + "twitter-api: success", ) @pytest.mark.skipif(not os.environ.get("TWITTER_BEARER_TOKEN"), reason="No Twitter bearer token provided") @@ -95,27 +130,41 @@ class TestTwitterApiExtractor(TestExtractorBase): self.assertValidResponseMetadata( post, "This month's Bellingchat Premium is with @KolinaKoltai. She reveals how she investigated a platform allowing users to create AI-generated child sexual abuse material and explains why it's crucial to investigate the people behind these services https://t.co/SfBUq0hSD0 https://t.co/rIHx0WlKp8", - datetime.datetime(2024, 12, 24, 13, 44, 46, tzinfo=datetime.timezone.utc) + datetime.datetime(2024, 12, 24, 13, 44, 46, tzinfo=datetime.timezone.utc), ) @pytest.mark.skipif(not os.environ.get("TWITTER_BEARER_TOKEN"), reason="No Twitter bearer token provided") - @pytest.mark.parametrize("url, title, timestamp", [ - ("https://x.com/SozinhoRamalho/status/1876710769913450647", "ignore tweet, testing sensitivity warning nudity https://t.co/t3u0hQsSB1", datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc)), - ("https://x.com/SozinhoRamalho/status/1876710875475681357", "ignore tweet, testing sensitivity warning violence https://t.co/syYDSkpjZD", datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc)), - ("https://x.com/SozinhoRamalho/status/1876711053813227618", "ignore tweet, testing sensitivity warning sensitive https://t.co/XE7cRdjzYq", datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc)), - ("https://x.com/SozinhoRamalho/status/1876711141314801937", "ignore tweet, testing sensitivity warning nudity, violence, sensitivity https://t.co/YxCFbbhYE3", datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc)), - ]) + @pytest.mark.parametrize( + "url, title, timestamp", + [ + ( + "https://x.com/SozinhoRamalho/status/1876710769913450647", + "ignore tweet, testing sensitivity warning nudity https://t.co/t3u0hQsSB1", + datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), + ), + ( + "https://x.com/SozinhoRamalho/status/1876710875475681357", + "ignore tweet, testing sensitivity warning violence https://t.co/syYDSkpjZD", + datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), + ), + ( + "https://x.com/SozinhoRamalho/status/1876711053813227618", + "ignore tweet, testing sensitivity warning sensitive https://t.co/XE7cRdjzYq", + datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), + ), + ( + "https://x.com/SozinhoRamalho/status/1876711141314801937", + "ignore tweet, testing sensitivity warning nudity, violence, sensitivity https://t.co/YxCFbbhYE3", + datetime.datetime(2024, 12, 31, 14, 18, 33, tzinfo=datetime.timezone.utc), + ), + ], + ) @pytest.mark.download def test_download_sensitive_media(self, url, title, timestamp, check_hash, make_item): - """Download tweets with sensitive media""" post = self.extractor.download(make_item(url)) - self.assertValidResponseMetadata( - post, - title, - timestamp - ) + self.assertValidResponseMetadata(post, title, timestamp) assert len(post.media) == 1 # check the SHA1 hash (quick) of the media, to make sure it's valid - check_hash(post.media[0].filename, "3eea9c03b2dcedd1eb9a169d8bfd1cf877996fab4961de019a96eb9d32d2d733") \ No newline at end of file + check_hash(post.media[0].filename, "3eea9c03b2dcedd1eb9a169d8bfd1cf877996fab4961de019a96eb9d32d2d733") diff --git a/tests/extractors/test_vk_extractor.py b/tests/extractors/test_vk_extractor.py new file mode 100644 index 0000000..040e5f7 --- /dev/null +++ b/tests/extractors/test_vk_extractor.py @@ -0,0 +1,77 @@ +import pytest + +from auto_archiver.core import Metadata +from auto_archiver.modules.vk_extractor import VkExtractor + + +@pytest.fixture +def mock_vk_scraper(mocker): + """Fixture to mock VkScraper.""" + return mocker.patch("auto_archiver.modules.vk_extractor.vk_extractor.VkScraper") + + +@pytest.fixture +def vk_extractor(setup_module, mock_vk_scraper) -> VkExtractor: + """Fixture to initialize VkExtractor with mocked VkScraper.""" + extractor_module = "vk_extractor" + configs = { + "username": "name", + "password": "password123", + "session_file": "secrets/vk_config.v2.json", + } + vk = setup_module(extractor_module, configs) + vk.vks = mock_vk_scraper.return_value + return vk + + +def test_netloc(vk_extractor, metadata): + # metadata url set as: "https://example.com/" + assert vk_extractor.download(metadata) is False + + +def test_vk_url_but_scrape_returns_empty(vk_extractor, metadata): + metadata.set_url("https://vk.com/valid-wall") + vk_extractor.vks.scrape.return_value = [] + assert vk_extractor.download(metadata) is False + assert metadata.netloc == "vk.com" + vk_extractor.vks.scrape.assert_called_once_with(metadata.get_url()) + + +def test_successful_scrape_and_download(vk_extractor, metadata, mocker): + mock_scrapes = [ + {"text": "Post Title", "datetime": "2023-01-01T00:00:00", "id": 1}, + {"text": "Another Post", "datetime": "2023-01-02T00:00:00", "id": 2}, + ] + mock_filenames = ["image1.jpg", "image2.png"] + vk_extractor.vks.scrape.return_value = mock_scrapes + vk_extractor.vks.download_media.return_value = mock_filenames + metadata.set_url("https://vk.com/valid-wall") + result = vk_extractor.download(metadata) + # Test metadata + assert result.is_success() + assert result.status == "vk: success" + assert result.get_title() == "Post Title" + assert result.get_timestamp() == "2023-01-01T00:00:00+00:00" + assert "Another Post" in result.metadata["content"] + # Test Media objects + assert len(result.media) == 2 + assert result.media[0].filename == "image1.jpg" + assert result.media[1].filename == "image2.png" + vk_extractor.vks.download_media.assert_called_once_with(mock_scrapes, vk_extractor.tmp_dir) + + +def test_adds_first_title_and_timestamp(vk_extractor): + metadata = Metadata().set_url("https://vk.com/no-metadata") + metadata.set_url("https://vk.com/no-metadata") + mock_scrapes = [ + {"text": "value", "datetime": "2023-01-01T00:00:00"}, + {"text": "value2", "datetime": "2023-01-02T00:00:00"}, + ] + vk_extractor.vks.scrape.return_value = mock_scrapes + vk_extractor.vks.download_media.return_value = [] + result = vk_extractor.download(metadata) + + assert result.get_title() == "value" + # formatted timestamp + assert result.get_timestamp() == "2023-01-01T00:00:00+00:00" + assert result.is_success() diff --git a/tests/feeders/test_atlos_feeder.py b/tests/feeders/test_atlos_feeder.py index f26bdc9..f423136 100644 --- a/tests/feeders/test_atlos_feeder.py +++ b/tests/feeders/test_atlos_feeder.py @@ -1,5 +1,5 @@ import pytest -from auto_archiver.modules.atlos_feeder import AtlosFeeder +from auto_archiver.modules.atlos_feeder_db_storage import AtlosFeederDbStorage as AtlosFeeder class FakeAPIResponse: @@ -18,44 +18,63 @@ class FakeAPIResponse: @pytest.fixture -def atlos_feeder(setup_module) -> AtlosFeeder: +def atlos_feeder(setup_module, mocker) -> AtlosFeeder: """Fixture for AtlosFeeder.""" configs: dict = { "api_token": "abc123", "atlos_url": "https://platform.atlos.org", } - return setup_module("atlos_feeder", configs) + mocker.patch("requests.Session") + atlos_feeder = setup_module("atlos_feeder_db_storage", configs) + fake_session = mocker.MagicMock() + # Configure the default response to have no results so that __iter__ terminates + fake_session.get.return_value = FakeAPIResponse({"next": None, "results": []}) + atlos_feeder.session = fake_session + return atlos_feeder @pytest.fixture -def mock_atlos_api(mocker): - """Fixture to mock requests to Atlos API.""" +def mock_atlos_api(atlos_feeder): + """Fixture to update the atlos_feeder.session.get side_effect.""" + def _mock_responses(responses): - mocker.patch( - "requests.get", - side_effect=[FakeAPIResponse(data) for data in responses], - ) + atlos_feeder.session.get.side_effect = [FakeAPIResponse(data) for data in responses] + return _mock_responses def test_atlos_feeder_iter_yields_valid_metadata(atlos_feeder, mock_atlos_api): """Test valid items are yielded and invalid ones ignored.""" - mock_atlos_api([ - { - "next": None, - "results": [ - {"source_url": "http://example.com", "id": 1, - "metadata": {"auto_archiver": {"processed": False}}, - "visibility": "visible", "status": "complete"}, - {"source_url": "", "id": 2, - "metadata": {"auto_archiver": {"processed": False}}, - "visibility": "visible", "status": "complete"}, - {"source_url": "http://example.org", "id": 3, - "metadata": {"auto_archiver": {"processed": True}}, - "visibility": "visible", "status": "complete"}, - ], - } - ]) + mock_atlos_api( + [ + { + "next": None, + "results": [ + { + "source_url": "http://example.com", + "id": 1, + "metadata": {"auto_archiver": {"processed": False}}, + "visibility": "visible", + "status": "complete", + }, + { + "source_url": "", + "id": 2, + "metadata": {"auto_archiver": {"processed": False}}, + "visibility": "visible", + "status": "complete", + }, + { + "source_url": "http://example.org", + "id": 3, + "metadata": {"auto_archiver": {"processed": True}}, + "visibility": "visible", + "status": "complete", + }, + ], + } + ] + ) items = list(atlos_feeder) assert len(items) == 1 @@ -65,24 +84,34 @@ def test_atlos_feeder_iter_yields_valid_metadata(atlos_feeder, mock_atlos_api): def test_atlos_feeder_multiple_pages(atlos_feeder, mock_atlos_api): """Test iteration over multiple pages with valid items.""" - mock_atlos_api([ - { - "next": "cursor2", - "results": [ - {"source_url": "http://example1.com", "id": 10, - "metadata": {"auto_archiver": {"processed": False}}, - "visibility": "visible", "status": "complete"}, - ], - }, - { - "next": None, - "results": [ - {"source_url": "http://example2.com", "id": 20, - "metadata": {"auto_archiver": {"processed": False}}, - "visibility": "visible", "status": "complete"}, - ], - }, - ]) + mock_atlos_api( + [ + { + "next": "cursor2", + "results": [ + { + "source_url": "http://example1.com", + "id": 10, + "metadata": {"auto_archiver": {"processed": False}}, + "visibility": "visible", + "status": "complete", + }, + ], + }, + { + "next": None, + "results": [ + { + "source_url": "http://example2.com", + "id": 20, + "metadata": {"auto_archiver": {"processed": False}}, + "visibility": "visible", + "status": "complete", + }, + ], + }, + ] + ) items = list(atlos_feeder) assert len(items) == 2 @@ -100,9 +129,7 @@ def test_atlos_feeder_no_results(atlos_feeder, mock_atlos_api): def test_atlos_feeder_http_error(atlos_feeder, mocker): """Test raises an exception on HTTP error.""" - mocker.patch( - "requests.get", - return_value=FakeAPIResponse({"next": None, "results": []}, raise_error=True), - ) + fake_response = FakeAPIResponse({"next": None, "results": []}, raise_error=True) + atlos_feeder.session.get.side_effect = [fake_response] with pytest.raises(Exception, match="HTTP error"): list(atlos_feeder) diff --git a/tests/feeders/test_csv_feeder.py b/tests/feeders/test_csv_feeder.py index 546c3a7..965f8ad 100644 --- a/tests/feeders/test_csv_feeder.py +++ b/tests/feeders/test_csv_feeder.py @@ -1,13 +1,16 @@ import pytest + @pytest.fixture def headerless_csv_file(): return "tests/data/csv_no_headers.csv" + @pytest.fixture def header_csv_file(): return "tests/data/csv_with_headers.csv" + @pytest.fixture def header_csv_file_non_default_column(): return "tests/data/csv_with_headers_non_default_column.csv" @@ -23,6 +26,7 @@ def test_csv_feeder_no_headers(headerless_csv_file, setup_module): assert urls[0].get_url() == "https://example.com/1/" assert urls[1].get_url() == "https://example.com/2/" + def test_csv_feeder_with_headers(header_csv_file, setup_module): from auto_archiver.modules.csv_feeder.csv_feeder import CSVFeeder @@ -33,10 +37,10 @@ def test_csv_feeder_with_headers(header_csv_file, setup_module): assert urls[0].get_url() == "https://example.com/1/" assert urls[1].get_url() == "https://example.com/2/" + def test_csv_feeder_wrong_column(header_csv_file, setup_module, caplog): from auto_archiver.modules.csv_feeder.csv_feeder import CSVFeeder - with caplog.at_level("WARNING"): feeder = setup_module(CSVFeeder, {"files": [header_csv_file], "column": 1}) urls = list(feeder) @@ -54,4 +58,4 @@ def test_csv_feeder_column_by_name(header_csv_file, setup_module): urls = list(feeder) assert len(urls) == 2 assert urls[0].get_url() == "https://example.com/1/" - assert urls[1].get_url() == "https://example.com/2/" \ No newline at end of file + assert urls[1].get_url() == "https://example.com/2/" diff --git a/tests/feeders/test_gsheet_feeder.py b/tests/feeders/test_gsheet_feeder.py index 7c5f501..bf34757 100644 --- a/tests/feeders/test_gsheet_feeder.py +++ b/tests/feeders/test_gsheet_feeder.py @@ -2,52 +2,49 @@ from typing import Type import gspread import pytest -from auto_archiver.modules.gsheet_feeder import GsheetsFeeder +from auto_archiver.modules.gsheet_feeder_db import GsheetsFeederDB from auto_archiver.core import Metadata, Feeder def test_setup_without_sheet_and_sheet_id(setup_module, mocker): # Ensure setup() raises AssertionError if neither sheet nor sheet_id is set. mocker.patch("gspread.service_account") - with pytest.raises(AssertionError): + with pytest.raises(ValueError): setup_module( - "gsheet_feeder", + "gsheet_feeder_db", {"service_account": "dummy.json", "sheet": None, "sheet_id": None}, ) @pytest.fixture -def gsheet_feeder(setup_module, mocker) -> GsheetsFeeder: +def gsheet_feeder(setup_module, mocker) -> GsheetsFeederDB: config: dict = { - "service_account": "dummy.json", - "sheet": "test-auto-archiver", - "sheet_id": None, - "header": 1, - "columns": { - "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", - "pdq_hash": "perceptual hashes", - "wacz": "wacz", - "replaywebpage": "replaywebpage", - }, - "allow_worksheets": set(), - "block_worksheets": set(), - "use_sheet_names_in_stored_paths": True, - } + "service_account": "dummy.json", + "sheet": "test-auto-archiver", + "sheet_id": None, + "header": 1, + "columns": { + "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", + "pdq_hash": "perceptual hashes", + "wacz": "wacz", + "replaywebpage": "replaywebpage", + }, + "allow_worksheets": set(), + "block_worksheets": set(), + "use_sheet_names_in_stored_paths": True, + } mocker.patch("gspread.service_account") - feeder = setup_module( - "gsheet_feeder", - config - ) + feeder = setup_module("gsheet_feeder_db", config) feeder.gsheets_client = mocker.MagicMock() return feeder @@ -90,7 +87,7 @@ class MockWorksheet: return matching.get(col_name, default) -def test__process_rows(gsheet_feeder: GsheetsFeeder): +def test__process_rows(gsheet_feeder: GsheetsFeederDB): testworksheet = MockWorksheet() metadata_items = list(gsheet_feeder._process_rows(testworksheet)) assert len(metadata_items) == 3 @@ -98,7 +95,7 @@ def test__process_rows(gsheet_feeder: GsheetsFeeder): assert metadata_items[0].get("url") == "http://example.com" -def test__set_metadata(gsheet_feeder: GsheetsFeeder): +def test__set_metadata(gsheet_feeder: GsheetsFeederDB): worksheet = MockWorksheet() metadata = Metadata() gsheet_feeder._set_context(metadata, worksheet, 1) @@ -106,12 +103,12 @@ def test__set_metadata(gsheet_feeder: GsheetsFeeder): @pytest.mark.skip(reason="Not recognising folder column") -def test__set_metadata_with_folder_pickled(gsheet_feeder: GsheetsFeeder, worksheet): +def test__set_metadata_with_folder_pickled(gsheet_feeder: GsheetsFeederDB, worksheet): gsheet_feeder._set_context(worksheet, 7) assert Metadata.get_context("gsheet") == {"row": 1, "worksheet": worksheet} -def test__set_metadata_with_folder(gsheet_feeder: GsheetsFeeder): +def test__set_metadata_with_folder(gsheet_feeder: GsheetsFeederDB): testworksheet = MockWorksheet() metadata = Metadata() testworksheet.wks.title = "TestSheet" @@ -128,9 +125,7 @@ def test__set_metadata_with_folder(gsheet_feeder: GsheetsFeeder): (None, "ABC123", "open_by_key", "ABC123", "opening by sheet ID"), ], ) -def test_open_sheet_with_name_or_id( - setup_module, sheet, sheet_id, expected_method, expected_arg, description, mocker -): +def test_open_sheet_with_name_or_id(setup_module, sheet, sheet_id, expected_method, expected_arg, description, mocker): """Ensure open_sheet() correctly opens by name or ID based on configuration.""" mock_service_account = mocker.patch("gspread.service_account") mock_client = mocker.MagicMock() @@ -140,14 +135,12 @@ def test_open_sheet_with_name_or_id( # Setup module with parameterized values feeder = setup_module( - "gsheet_feeder", + "gsheet_feeder_db", {"service_account": "dummy.json", "sheet": sheet, "sheet_id": sheet_id}, ) sheet_result = feeder.open_sheet() # Validate the correct method was called - getattr(mock_client, expected_method).assert_called_once_with( - expected_arg - ), f"Failed: {description}" + getattr(mock_client, expected_method).assert_called_once_with(expected_arg), f"Failed: {description}" assert sheet_result == "MockSheet", f"Failed: {description}" @@ -159,7 +152,7 @@ def test_open_sheet_with_sheet_id(setup_module, mocker): mock_service_account.return_value = mock_client mock_client.open_by_key.return_value = "MockSheet" feeder = setup_module( - "gsheet_feeder", + "gsheet_feeder_db", {"service_account": "dummy.json", "sheet": None, "sheet_id": "ABC123"}, ) sheet = feeder.open_sheet() @@ -170,7 +163,7 @@ def test_open_sheet_with_sheet_id(setup_module, mocker): def test_should_process_sheet(setup_module, mocker): mocker.patch("gspread.service_account") gdb = setup_module( - "gsheet_feeder", + "gsheet_feeder_db", { "service_account": "dummy.json", "sheet": "TestSheet", @@ -179,18 +172,18 @@ def test_should_process_sheet(setup_module, mocker): "block_worksheets": {"Sheet3"}, }, ) - assert gdb.should_process_sheet("TestSheet") == True - assert gdb.should_process_sheet("Sheet3") == False + assert gdb.should_process_sheet("TestSheet") is True + assert gdb.should_process_sheet("Sheet3") is False # False if allow_worksheets is set - assert gdb.should_process_sheet("AnotherSheet") == False + assert gdb.should_process_sheet("AnotherSheet") is False @pytest.mark.skip(reason="Requires a real connection") class TestGSheetsFeederReal: - """Testing GSheetsFeeder class""" + """Testing GsheetsFeeder class""" - module_name: str = "gsheet_feeder" - feeder: GsheetsFeeder + module_name: str = "gsheet_feeder_db" + feeder: GsheetsFeederDB # You must follow the setup process explain in the docs for this to work config: dict = { "service_account": "secrets/service_account.json", @@ -220,9 +213,7 @@ class TestGSheetsFeederReal: @pytest.fixture(autouse=True) def setup_feeder(self, setup_module): - assert ( - self.module_name is not None - ), "self.module_name must be set on the subclass" + assert self.module_name is not None, "self.module_name must be set on the subclass" assert self.config is not None, "self.config must be a dict set on the subclass" self.feeder: Type[Feeder] = setup_module(self.module_name, self.config) @@ -241,9 +232,7 @@ class TestGSheetsFeederReal: """Ensure open_sheet() connects to a real Google Sheets instance.""" sheet = self.feeder.open_sheet() assert sheet is not None, "open_sheet() should return a valid sheet instance" - assert hasattr( - sheet, "worksheets" - ), "Returned object should have worksheets method" + assert hasattr(sheet, "worksheets"), "Returned object should have worksheets method" def test_iter_yields_metadata_real_data(self): """Ensure __iter__() yields Metadata objects for real test sheet data.""" diff --git a/tests/feeders/test_gworksheet.py b/tests/feeders/test_gworksheet.py index 2b05504..c817be3 100644 --- a/tests/feeders/test_gworksheet.py +++ b/tests/feeders/test_gworksheet.py @@ -1,7 +1,7 @@ # Note this isn't a feeder, but contained as utility of the gsheet feeder module import pytest -from auto_archiver.modules.gsheet_feeder import GWorksheet +from auto_archiver.modules.gsheet_feeder_db import GWorksheet class TestGWorksheet: @@ -81,40 +81,27 @@ class TestGWorksheet: (False, ""), ], ) - def test_get_cell_or_default_handles_empty_values( - self, mock_worksheet, when_empty, expected - ): + def test_get_cell_or_default_handles_empty_values(self, mock_worksheet, when_empty, expected): mock_worksheet.get_values.return_value[1][0] = "" # Empty URL cell g = GWorksheet(mock_worksheet) - assert ( - g.get_cell_or_default( - 2, "url", default="default", when_empty_use_default=when_empty - ) - == expected - ) + assert g.get_cell_or_default(2, "url", default="default", when_empty_use_default=when_empty) == expected def test_get_cell_or_default_handles_missing_columns(self, gworksheet): - assert ( - gworksheet.get_cell_or_default(1, "invalid_col", default="safe") == "safe" - ) + assert gworksheet.get_cell_or_default(1, "invalid_col", default="safe") == "safe" # Test write operations def test_set_cell_updates_correct_position(self, mock_worksheet, gworksheet): gworksheet.set_cell(2, "url", "new_url") mock_worksheet.update_cell.assert_called_once_with(2, 1, "new_url") - def test_batch_set_cell_formats_requests_correctly( - self, mock_worksheet, gworksheet - ): + def test_batch_set_cell_formats_requests_correctly(self, mock_worksheet, gworksheet): updates = [(2, "url", "new_url"), (3, "status", "processed")] gworksheet.batch_set_cell(updates) expected_batch = [ {"range": "A2", "values": [["new_url"]]}, {"range": "B3", "values": [["processed"]]}, ] - mock_worksheet.batch_update.assert_called_once_with( - expected_batch, value_input_option="USER_ENTERED" - ) + mock_worksheet.batch_update.assert_called_once_with(expected_batch, value_input_option="USER_ENTERED") def test_batch_set_cell_truncates_long_values(self, mock_worksheet, gworksheet): long_value = "x" * 50000 diff --git a/tests/formatters/test_html_formatter.py b/tests/formatters/test_html_formatter.py index 60abaa7..502e231 100644 --- a/tests/formatters/test_html_formatter.py +++ b/tests/formatters/test_html_formatter.py @@ -5,13 +5,13 @@ from auto_archiver.core import Metadata, Media def test_format(setup_module): formatter = setup_module(HtmlFormatter) - metadata = Metadata().set("content", "Hello, world!").set_url('https://example.com') + metadata = Metadata().set("content", "Hello, world!").set_url("https://example.com") final_media = formatter.format(metadata) assert isinstance(final_media, Media) assert ".html" in final_media.filename - with open (final_media.filename, "r", encoding="utf-8") as f: + with open(final_media.filename, "r", encoding="utf-8") as f: content = f.read() assert "Hello, world!" in content assert final_media.mimetype == "text/html" - assert "SHA-256:" in final_media.get('hash') \ No newline at end of file + assert "SHA-256:" in final_media.get("hash") diff --git a/tests/storages/test_S3_storage.py b/tests/storages/test_S3_storage.py index fe60329..87da776 100644 --- a/tests/storages/test_S3_storage.py +++ b/tests/storages/test_S3_storage.py @@ -8,6 +8,7 @@ class TestS3Storage: """ Test suite for S3Storage. """ + module_name: str = "s3_storage" storage: Type[S3Storage] config: dict = { @@ -32,28 +33,28 @@ class TestS3Storage: """Test that S3 client is initialized with correct parameters""" assert self.storage.s3 is not None - assert self.storage.s3.meta.region_name == 'test-region' + assert self.storage.s3.meta.region_name == "test-region" def test_get_cdn_url_generation(self): - """Test CDN URL formatting """ + """Test CDN URL formatting""" media = Media("test.txt") - media.key = "path/to/file.txt" + media._key = "path/to/file.txt" url = self.storage.get_cdn_url(media) assert url == "https://cdn.example.com/path/to/file.txt" - media.key = "another/path.jpg" + media._key = "another/path.jpg" assert self.storage.get_cdn_url(media) == "https://cdn.example.com/another/path.jpg" def test_uploadf_sets_acl_public(self, mocker): media = Media("test.txt") mock_file = mocker.MagicMock() - mock_s3_upload = mocker.patch.object(self.storage.s3, 'upload_fileobj') - mocker.patch.object(self.storage, 'is_upload_needed', return_value=True) + mock_s3_upload = mocker.patch.object(self.storage.s3, "upload_fileobj") + mocker.patch.object(self.storage, "is_upload_needed", return_value=True) self.storage.uploadf(mock_file, media) mock_s3_upload.assert_called_once_with( mock_file, - Bucket='test-bucket', + Bucket="test-bucket", Key=media.key, - ExtraArgs={'ACL': 'public-read', 'ContentType': 'text/plain'} + ExtraArgs={"ACL": "public-read", "ContentType": "text/plain"}, ) def test_upload_decision_logic(self, mocker): @@ -61,45 +62,48 @@ class TestS3Storage: media = Media("test.txt") assert self.storage.is_upload_needed(media) is True self.storage.random_no_duplicate = True - mock_calc_hash = mocker.patch('auto_archiver.modules.s3_storage.s3_storage.calculate_file_hash', return_value='beepboop123beepboop123beepboop123') - mock_file_in_folder = mocker.patch.object(self.storage, 'file_in_folder', return_value='existing_key.txt') + mocker.patch( + "auto_archiver.modules.s3_storage.s3_storage.calculate_file_hash", + return_value="beepboop123beepboop123beepboop123", + ) + mock_file_in_folder = mocker.patch.object(self.storage, "file_in_folder", return_value="existing_key.txt") assert self.storage.is_upload_needed(media) is False - assert media.key == 'existing_key.txt' - mock_file_in_folder.assert_called_with('no-dups/beepboop123beepboop123be') + assert media.key == "existing_key.txt" + mock_file_in_folder.assert_called_with("no-dups/beepboop123beepboop123be") def test_skips_upload_when_duplicate_exists(self, mocker): """Test that upload skips when file_in_folder finds existing object""" self.storage.random_no_duplicate = True - mock_file_in_folder = mocker.patch.object(S3Storage, 'file_in_folder', return_value="existing_folder/existing_file.txt") + mocker.patch.object(S3Storage, "file_in_folder", return_value="existing_folder/existing_file.txt") media = Media("test.txt") - media.key = "original_path.txt" - mock_calculate_hash = mocker.patch('auto_archiver.modules.s3_storage.s3_storage.calculate_file_hash', return_value="beepboop123beepboop123beepboop123") + media._key = "original_path.txt" + mocker.patch( + "auto_archiver.modules.s3_storage.s3_storage.calculate_file_hash", + return_value="beepboop123beepboop123beepboop123", + ) assert self.storage.is_upload_needed(media) is False assert media.key == "existing_folder/existing_file.txt" assert media.get("previously archived") is True - mock_upload = mocker.patch.object(self.storage.s3, 'upload_fileobj') + mock_upload = mocker.patch.object(self.storage.s3, "upload_fileobj") result = self.storage.uploadf(None, media) mock_upload.assert_not_called() assert result is True def test_uploads_with_correct_parameters(self, mocker): media = Media("test.txt") - media.key = "original_key.txt" - mocker.patch.object(S3Storage, 'is_upload_needed', return_value=True) - media.mimetype = 'image/png' + media._key = "original_key.txt" + mocker.patch.object(S3Storage, "is_upload_needed", return_value=True) + media.mimetype = "image/png" mock_file = mocker.MagicMock() - mock_upload = mocker.patch.object(self.storage.s3, 'upload_fileobj') + mock_upload = mocker.patch.object(self.storage.s3, "upload_fileobj") self.storage.uploadf(mock_file, media) mock_upload.assert_called_once_with( mock_file, - Bucket='test-bucket', - Key='original_key.txt', - ExtraArgs={ - 'ACL': 'public-read', - 'ContentType': 'image/png' - } + Bucket="test-bucket", + Key="original_key.txt", + ExtraArgs={"ACL": "public-read", "ContentType": "image/png"}, ) def test_file_in_folder_exists(self, mocker): - mock_list_objects = mocker.patch.object(self.storage.s3, 'list_objects', return_value={'Contents': [{'Key': 'path/to/file.txt'}]}) - assert self.storage.file_in_folder('path/to/') == 'path/to/file.txt' + mocker.patch.object(self.storage.s3, "list_objects", return_value={"Contents": [{"Key": "path/to/file.txt"}]}) + assert self.storage.file_in_folder("path/to/") == "path/to/file.txt" diff --git a/tests/storages/test_atlos_storage.py b/tests/storages/test_atlos_storage.py index 7528456..f273c7e 100644 --- a/tests/storages/test_atlos_storage.py +++ b/tests/storages/test_atlos_storage.py @@ -2,7 +2,7 @@ import os import hashlib import pytest from auto_archiver.core import Media, Metadata -from auto_archiver.modules.atlos_storage import AtlosStorage +from auto_archiver.modules.atlos_feeder_db_storage import AtlosFeederDbStorage as AtlosStorage class FakeAPIResponse: @@ -21,13 +21,19 @@ class FakeAPIResponse: @pytest.fixture -def atlos_storage(setup_module) -> AtlosStorage: +def atlos_storage(setup_module, mocker) -> AtlosStorage: """Fixture for AtlosStorage.""" configs: dict = { "api_token": "abc123", "atlos_url": "https://platform.atlos.org", } - return setup_module("atlos_storage", configs) + mocker.patch("requests.Session") + atlos_feeder = setup_module("atlos_feeder_db_storage", configs) + mock_session = mocker.MagicMock() + # Configure the default response to have no results so that __iter__ terminates + mock_session.get.return_value = FakeAPIResponse({"next": None, "results": []}) + atlos_feeder.session = mock_session + return atlos_feeder @pytest.fixture @@ -38,7 +44,7 @@ def media(tmp_path) -> Media: file_path.write_bytes(content) media = Media(filename=str(file_path)) media.properties = {"something": "Title"} - media.key = "key" + media._key = "key" return media @@ -49,17 +55,6 @@ def test_get_cdn_url(atlos_storage: AtlosStorage) -> None: assert url == atlos_storage.atlos_url -def test_hash(tmp_path, atlos_storage: AtlosStorage) -> None: - """Test _hash() computes the correct SHA-256 hash of a file.""" - content = b"hello world" - file_path = tmp_path / "test.txt" - file_path.write_bytes(content) - media = Media(filename="dummy.mp4") - media.filename = str(file_path) - expected_hash = hashlib.sha256(content).hexdigest() - assert atlos_storage._hash(media) == expected_hash - - def test_upload_no_atlos_id(tmp_path, atlos_storage: AtlosStorage, media: Media, mocker) -> None: """Test upload() returns False when metadata lacks atlos_id.""" metadata = Metadata() # atlos_id not set @@ -69,74 +64,49 @@ def test_upload_no_atlos_id(tmp_path, atlos_storage: AtlosStorage, media: Media, post_mock.assert_not_called() -def test_upload_already_uploaded(atlos_storage: AtlosStorage, - metadata: Metadata, - media: Media, - tmp_path, - mocker) -> None: +def test_upload_already_uploaded(atlos_storage: AtlosStorage, metadata: Metadata, media: Media, mocker) -> None: """Test upload() returns True if media hash already exists.""" content = b"media content" metadata.set("atlos_id", 101) media_hash = hashlib.sha256(content).hexdigest() - fake_get = FakeAPIResponse({ - "result": {"artifacts": [{"file_hash_sha256": media_hash}]} - }) - get_mock = mocker.patch("requests.get", return_value=fake_get) - post_mock = mocker.patch("requests.post") + fake_get_response = {"result": {"artifacts": [{"file_hash_sha256": media_hash}]}} + get_mock = mocker.patch.object(atlos_storage, "_get", return_value=fake_get_response) + post_mock = mocker.patch.object(atlos_storage, "_post") result = atlos_storage.upload(media, metadata) assert result is True get_mock.assert_called_once() post_mock.assert_not_called() -def test_upload_not_uploaded(tmp_path, atlos_storage: AtlosStorage, - metadata: Metadata, - media: Media, - mocker) -> None: +def test_upload_not_uploaded(tmp_path, atlos_storage: AtlosStorage, metadata: Metadata, media: Media, mocker) -> None: """Test upload() uploads media when not already present.""" metadata.set("atlos_id", 202) - fake_get = FakeAPIResponse({ - "result": {"artifacts": [{"file_hash_sha256": "different_hash"}]} - }) - get_mock = mocker.patch("requests.get", return_value=fake_get) - fake_post = FakeAPIResponse({}, raise_error=False) - post_mock = mocker.patch("requests.post", return_value=fake_post) + fake_get_response = {"result": {"artifacts": [{"file_hash_sha256": "different_hash"}]}} + get_mock = mocker.patch.object(atlos_storage, "_get", return_value=fake_get_response) + fake_post_response = {"result": "uploaded"} + post_mock = mocker.patch.object(atlos_storage, "_post", return_value=fake_post_response) result = atlos_storage.upload(media, metadata) assert result is True + get_mock.assert_called_once() post_mock.assert_called_once() - expected_url = f"{atlos_storage.atlos_url}/api/v2/source_material/upload/202" - expected_headers = {"Authorization": f"Bearer {atlos_storage.api_token}"} + expected_endpoint = "/api/v2/source_material/upload/202" + call_args = post_mock.call_args[0] + assert call_args[0] == expected_endpoint + call_kwargs = post_mock.call_args[1] expected_params = {"title": media.properties} - call_kwargs = post_mock.call_args.kwargs - assert call_kwargs["headers"] == expected_headers assert call_kwargs["params"] == expected_params - # Verify the URL passed to requests.post. - posted_url = call_kwargs.get("url") or post_mock.call_args.args[0] - assert posted_url == expected_url - # Verify files parameter contains the correct filename. file_tuple = call_kwargs["files"]["file"] assert file_tuple[0] == os.path.basename(media.filename) -def test_upload_post_http_error(tmp_path, - atlos_storage: AtlosStorage, - metadata: Metadata, - media: Media, - mocker) -> None: +def test_upload_post_http_error( + tmp_path, atlos_storage: AtlosStorage, metadata: Metadata, media: Media, mocker +) -> None: """Test upload() propagates HTTP error during POST.""" metadata.set("atlos_id", 303) - fake_get = FakeAPIResponse({ - "result": {"artifacts": []} - }) - mocker.patch("requests.get", return_value=fake_get) - fake_post = FakeAPIResponse({}, raise_error=True) - mocker.patch("requests.post", return_value=fake_post) + fake_get_response = {"result": {"artifacts": []}} + mocker.patch.object(atlos_storage, "_get", return_value=fake_get_response) + mocker.patch.object(atlos_storage, "_post", side_effect=Exception("HTTP error")) with pytest.raises(Exception, match="HTTP error"): atlos_storage.upload(media, metadata) - - -def test_uploadf_not_implemented(atlos_storage: AtlosStorage) -> None: - """Test uploadf() returns None (not implemented).""" - result = atlos_storage.uploadf(None, "dummy") - assert result is None diff --git a/tests/storages/test_gdrive_storage.py b/tests/storages/test_gdrive_storage.py index f5ff87c..99df536 100644 --- a/tests/storages/test_gdrive_storage.py +++ b/tests/storages/test_gdrive_storage.py @@ -1,37 +1,42 @@ from typing import Type import pytest -from oauth2client import service_account from auto_archiver.core import Media from auto_archiver.modules.gdrive_storage import GDriveStorage -from auto_archiver.core.metadata import Metadata from tests.storages.test_storage_base import TestStorageBase +@pytest.fixture(autouse=True) +def mock_sleep(mocker): + """Mock time.sleep to avoid delays.""" + return mocker.patch("time.sleep") + + @pytest.fixture -def gdrive_storage(setup_module, mocker): +def gdrive_storage(setup_module, mocker) -> GDriveStorage: module_name: str = "gdrive_storage" - storage: GDriveStorage - config: dict = {'path_generator': 'url', - 'filename_generator': 'static', - 'root_folder_id': "fake_root_folder_id", - 'oauth_token': None, - 'service_account': 'fake_service_account.json' - } - mocker.patch('google.oauth2.service_account.Credentials.from_service_account_file') + config: dict = { + "path_generator": "url", + "filename_generator": "static", + "root_folder_id": "fake_root_folder_id", + "oauth_token": None, + "service_account": "fake_service_account.json", + } + mocker.patch("google.oauth2.service_account.Credentials.from_service_account_file") return setup_module(module_name, config) def test_initialize_fails_with_non_existent_creds(setup_module): """Test that the Google Drive service raises a FileNotFoundError when the service account file does not exist. - (and isn't mocked) + (and isn't mocked) """ - config: dict = {'path_generator': 'url', - 'filename_generator': 'static', - 'root_folder_id': "fake_root_folder_id", - 'oauth_token': None, - 'service_account': 'fake_service_account.json' - } + config: dict = { + "path_generator": "url", + "filename_generator": "static", + "root_folder_id": "fake_root_folder_id", + "oauth_token": None, + "service_account": "fake_service_account.json", + } with pytest.raises(FileNotFoundError) as exc_info: setup_module("gdrive_storage", config) assert "No such file or directory" in str(exc_info.value) @@ -48,10 +53,10 @@ def test_get_id_from_parent_and_name(gdrive_storage, mocker): result = gdrive_storage._get_id_from_parent_and_name("parent", "mock", retries=1, use_mime_type=False) assert result == "123" + def test_path_parts(): media = Media(filename="test.jpg") - media.key = "folder1/folder2/test.jpg" - + media._key = "folder1/folder2/test.jpg" @pytest.mark.skip(reason="Requires real credentials") @@ -63,19 +68,17 @@ class TestGDriveStorageConnected(TestStorageBase): module_name: str = "gdrive_storage" storage: Type[GDriveStorage] - config: dict = {'path_generator': 'url', - 'filename_generator': 'static', - # TODO: replace with real root folder id - 'root_folder_id': "1TVY_oJt95_dmRSEdP9m5zFy7l50TeCSk", - 'oauth_token': None, - 'service_account': 'secrets/service_account.json' - } - + config: dict = { + "path_generator": "url", + "filename_generator": "static", + # TODO: replace with real root folder id + "root_folder_id": "1TVY_oJt95_dmRSEdP9m5zFy7l50TeCSk", + "oauth_token": None, + "service_account": "secrets/service_account.json", + } def test_initialize_with_real_credentials(self): """ Test that the Google Drive service can be initialized with real credentials. """ assert self.storage.service is not None - - diff --git a/tests/storages/test_local_storage.py b/tests/storages/test_local_storage.py index 85f97c6..1230e3d 100644 --- a/tests/storages/test_local_storage.py +++ b/tests/storages/test_local_storage.py @@ -1,19 +1,21 @@ - import os from pathlib import Path import pytest -from auto_archiver.core import Media +from auto_archiver.core import Media, Metadata from auto_archiver.modules.local_storage import LocalStorage +from auto_archiver.core.consts import SetupError @pytest.fixture -def local_storage(setup_module) -> LocalStorage: +def local_storage(setup_module, tmp_path) -> LocalStorage: + save_to = tmp_path / "local_archive" + save_to.mkdir() configs: dict = { "path_generator": "flat", "filename_generator": "static", - "save_to": "./local_archive", + "save_to": str(save_to), "save_absolute": False, } return setup_module("local_storage", configs) @@ -24,31 +26,39 @@ def sample_media(tmp_path) -> Media: """Fixture creating a Media object with temporary source file""" src_file = tmp_path / "source.txt" src_file.write_text("test content") - return Media(key="subdir/test.txt", filename=str(src_file)) + return Media(filename=str(src_file)) + + +def test_too_long_save_path(setup_module): + with pytest.raises(SetupError): + setup_module("local_storage", {"save_to": "long" * 100}) def test_get_cdn_url_relative(local_storage): - media = Media(key="test.txt", filename="dummy.txt") + local_storage.filename_generator = "random" + media = Media(filename="dummy.txt") + local_storage.set_key(media, "https://example.com", Metadata()) expected = os.path.join(local_storage.save_to, media.key) assert local_storage.get_cdn_url(media) == expected - def test_get_cdn_url_absolute(local_storage): - media = Media(key="test.txt", filename="dummy.txt") + local_storage.filename_generator = "random" + + media = Media(filename="dummy.txt") local_storage.save_absolute = True + local_storage.set_key(media, "https://example.com", Metadata()) expected = os.path.abspath(os.path.join(local_storage.save_to, media.key)) assert local_storage.get_cdn_url(media) == expected + def test_upload_file_contents_and_metadata(local_storage, sample_media): + local_storage.store(sample_media, "https://example.com", Metadata()) dest = os.path.join(local_storage.save_to, sample_media.key) - assert local_storage.upload(sample_media) is True assert Path(sample_media.filename).read_text() == Path(dest).read_text() def test_upload_nonexistent_source(local_storage): - media = Media(key="missing.txt", filename="nonexistent.txt") + media = Media(_key="missing.txt", filename="nonexistent.txt") with pytest.raises(FileNotFoundError): local_storage.upload(media) - - diff --git a/tests/storages/test_storage_base.py b/tests/storages/test_storage_base.py index 7578acd..730304e 100644 --- a/tests/storages/test_storage_base.py +++ b/tests/storages/test_storage_base.py @@ -2,21 +2,109 @@ from typing import Type import pytest -from auto_archiver.core.metadata import Metadata +from auto_archiver.core.metadata import Metadata, Media from auto_archiver.core.storage import Storage +from auto_archiver.core.module import ModuleFactory class TestStorageBase(object): - module_name: str = None config: dict = None @pytest.fixture(autouse=True) def setup_storage(self, setup_module): - assert ( - self.module_name is not None - ), "self.module_name must be set on the subclass" + assert self.module_name is not None, "self.module_name must be set on the subclass" assert self.config is not None, "self.config must be a dict set on the subclass" - self.storage: Type[Storage] = setup_module( - self.module_name, self.config - ) + self.storage: Type[Storage] = setup_module(self.module_name, self.config) + + +class TestBaseStorage(Storage): + name = "test_storage" + + def get_cdn_url(self, media): + return "cdn_url" + + def uploadf(self, file, key, **kwargs): + return True + + +@pytest.fixture +def dummy_file(tmp_path): + # create dummy.txt file + dummy_file = tmp_path / "dummy.txt" + dummy_file.write_text("test content") + return str(dummy_file) + + +@pytest.fixture +def storage_base(): + def _storage_base(config): + storage_base = TestBaseStorage() + storage_base.config_setup({TestBaseStorage.name: config}) + storage_base.module_factory = ModuleFactory() + return storage_base + + return _storage_base + + +@pytest.mark.parametrize( + "path_generator, filename_generator, url, expected_key", + [ + ("flat", "static", "https://example.com/file/", "folder/6ae8a75555209fd6c44157c0.txt"), + ("flat", "random", "https://example.com/file/", "folder/pretend-random.txt"), + ("url", "static", "https://example.com/file/", "folder/https-example-com-file/6ae8a75555209fd6c44157c0.txt"), + ("url", "random", "https://example.com/file/", "folder/https-example-com-file/pretend-random.txt"), + ("random", "static", "https://example.com/file/", "folder/pretend-random/6ae8a75555209fd6c44157c0.txt"), + ("random", "random", "https://example.com/file/", "folder/pretend-random/pretend-random.txt"), + ], +) +def test_storage_name_generation( + storage_base, path_generator, filename_generator, url, expected_key, mocker, tmp_path, dummy_file +): + mock_random = mocker.patch("auto_archiver.core.storage.random_str") + mock_random.return_value = "pretend-random" + + config: dict = { + "path_generator": path_generator, + "filename_generator": filename_generator, + } + storage: Storage = storage_base(config) + assert storage.path_generator == path_generator + assert storage.filename_generator == filename_generator + + metadata = Metadata() + metadata.set_context("folder", "folder") + media = Media(filename=dummy_file) + storage.set_key(media, url, metadata) + print(media.key) + assert media.key == expected_key + + +def test_really_long_name(storage_base, dummy_file): + config: dict = { + "path_generator": "url", + "filename_generator": "static", + } + storage: Storage = storage_base(config) + + url = f"https://example.com/{'file' * 100}" + media = Media(filename=dummy_file) + storage.set_key(media, url, Metadata()) + assert media.key == f"https-example-com-{'file' * 13}/6ae8a75555209fd6c44157c0.txt" + + +def test_storage_loads_hash_enricher(storage_base, dummy_file): + """Ensure 'hash_enricher' is properly loaded without an explicit import.""" + config = {"path_generator": "url", "filename_generator": "static"} + storage = storage_base(config) + + url = "https://example.com/file/" + media = Media(filename=dummy_file) + metadata = Metadata() + + try: + storage.set_key(media, url, metadata) + except Exception as e: + pytest.fail(f"Storage failed to dynamically load hash_enricher: {e}") + + assert media.key is not None, "Expected media.key to be set, but it was None" diff --git a/tests/test_config.py b/tests/test_config.py index 75fe515..03b06e7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,39 +3,46 @@ from auto_archiver.core import config from ruamel.yaml.scanner import ScannerError from ruamel.yaml.comments import CommentedMap + def test_return_default_config_for_nonexistent_file(): assert config.read_yaml("nonexistent_file.yaml") == config.EMPTY_CONFIG + def test_return_default_config_for_empty_file(tmp_path): empty_file = tmp_path / "empty_file.yaml" empty_file.write_text("") assert config.read_yaml(empty_file) == config.EMPTY_CONFIG + def test_raise_error_on_invalid_yaml(tmp_path): invalid_yaml = tmp_path / "invalid_yaml.yaml" - invalid_yaml.write_text("key: \"value_without_end_quote") + invalid_yaml.write_text('key: "value_without_end_quote') # make sure it raises ScannerError with pytest.raises(ScannerError): config.read_yaml(invalid_yaml) + def test_write_yaml(tmp_path): yaml_file = tmp_path / "write_yaml.yaml" config.store_yaml(config.EMPTY_CONFIG, yaml_file.as_posix()) assert "steps:\n" in yaml_file.read_text() + def test_round_trip_comments(tmp_path): yaml_file = tmp_path / "round_trip_comments.yaml" with open(yaml_file, "w") as f: - f.write("generic_extractor:\n facebook_cookie: abc # end of line comment\n subtitles: true\n # comments: false\n # livestreams: false\n list_type:\n - value1\n - value2") + f.write( + "generic_extractor:\n facebook_cookie: abc # end of line comment\n subtitles: true\n # comments: false\n # livestreams: false\n list_type:\n - value1\n - value2" + ) loaded = config.read_yaml(yaml_file) # check the comments are preserved - assert loaded['generic_extractor']['facebook_cookie'] == "abc" - assert loaded['generic_extractor'].ca.items['facebook_cookie'][2].value == "# end of line comment\n" + assert loaded["generic_extractor"]["facebook_cookie"] == "abc" + assert loaded["generic_extractor"].ca.items["facebook_cookie"][2].value == "# end of line comment\n" # add some more items to my_settings - loaded['generic_extractor']['list_type'].append("bellingcat") + loaded["generic_extractor"]["list_type"].append("bellingcat") config.store_yaml(loaded, yaml_file.as_posix()) assert "# comments: false" in yaml_file.read_text() @@ -43,14 +50,17 @@ def test_round_trip_comments(tmp_path): assert "abc # end of line comment" in yaml_file.read_text() assert "- value2\n - bellingcat" in yaml_file.read_text() + def test_merge_dicts(): yaml_dict = config.EMPTY_CONFIG - yaml_dict['settings'] = CommentedMap(**{ + yaml_dict["settings"] = CommentedMap( + **{ "key1": ["a"], "key2": "old_value", "key3": ["a", "b", "c"], "key5": "value5", - }) + } + ) dotdict = { "settings.key1": ["b", "c"], @@ -67,15 +77,16 @@ def test_merge_dicts(): def test_check_types(): - assert config.is_list_type([]) == True - assert config.is_list_type(()) == True - assert config.is_list_type(set()) == True - assert config.is_list_type({}) == False - assert config.is_list_type("") == False - assert config.is_dict_type({}) == True - assert config.is_dict_type(CommentedMap()) == True - assert config.is_dict_type([]) == False - assert config.is_dict_type("") == False + assert config.is_list_type([]) is True + assert config.is_list_type(()) is True + assert config.is_list_type(set()) is True + assert config.is_list_type({}) is False + assert config.is_list_type("") is False + assert config.is_dict_type({}) is True + assert config.is_dict_type(CommentedMap()) is True + assert config.is_dict_type([]) is False + assert config.is_dict_type("") is False + def test_from_dot_notation(): dotdict = { @@ -88,16 +99,17 @@ def test_from_dot_notation(): assert normal_dict["settings"]["key2"] == "new_value" assert normal_dict["settings"]["key3"]["key4"] == "value" + def test_to_dot_notation(): yaml_dict = config.EMPTY_CONFIG - yaml_dict['settings'] = { + yaml_dict["settings"] = { "key1": ["a", "b", "c"], "key2": "new_value", "key3": { "key4": "value", - } + }, } dotdict = config.to_dot_notation(yaml_dict) assert dotdict["settings.key1"] == ["a", "b", "c"] assert dotdict["settings.key2"] == "new_value" - assert dotdict["settings.key3.key4"] == "value" \ No newline at end of file + assert dotdict["settings.key3.key4"] == "value" diff --git a/tests/test_implementation.py b/tests/test_implementation.py index 85fc448..e52a8d8 100644 --- a/tests/test_implementation.py +++ b/tests/test_implementation.py @@ -6,28 +6,33 @@ from auto_archiver.__main__ import main @pytest.fixture def orchestration_file_path(tmp_path): - return (tmp_path / "example_orch.yaml").as_posix() + folder = tmp_path / "secrets" + folder.mkdir(exist_ok=True) + return (folder / "example_orch.yaml").as_posix() + @pytest.fixture def orchestration_file(orchestration_file_path): - def _orchestration_file(content=''): + def _orchestration_file(content=""): with open(orchestration_file_path, "w") as f: f.write(content) return orchestration_file_path - + return _orchestration_file + @pytest.fixture def autoarchiver(tmp_path, monkeypatch, request): def _autoarchiver(args=[]): - def cleanup(): from loguru import logger + if not logger._core.handlers.get(0): logger._core.handlers_count = 0 logger.add(sys.stderr) request.addfinalizer(cleanup) + (tmp_path / "secrets").mkdir(exist_ok=True) # change dir to tmp_path monkeypatch.chdir(tmp_path) @@ -41,9 +46,9 @@ def autoarchiver(tmp_path, monkeypatch, request): def test_run_auto_archiver_no_args(caplog, autoarchiver): with pytest.raises(SystemExit): autoarchiver() - assert "provide at least one URL via the command line, or set up an alternative feeder" in caplog.text + def test_run_auto_archiver_invalid_file(caplog, autoarchiver): # exec 'auto-archiver' on the command lin with pytest.raises(SystemExit): @@ -51,6 +56,7 @@ def test_run_auto_archiver_invalid_file(caplog, autoarchiver): assert "Make sure the file exists and try again, or run without th" in caplog.text + def test_run_auto_archiver_empty_file(caplog, autoarchiver, orchestration_file): # create a valid (empty) orchestration file path = orchestration_file(content="") @@ -61,14 +67,16 @@ def test_run_auto_archiver_empty_file(caplog, autoarchiver, orchestration_file): # should treat an empty file as if there is no file at all assert " No URLs provided. Please provide at least one URL via the com" in caplog.text + def test_call_autoarchiver_main(caplog, monkeypatch, tmp_path): from auto_archiver.__main__ import main # monkey patch to change the current working directory, so that we don't use the user's real config file monkeypatch.chdir(tmp_path) + (tmp_path / "secrets").mkdir(exist_ok=True) with monkeypatch.context() as m: m.setattr(sys, "argv", ["auto-archiver"]) with pytest.raises(SystemExit): main() - assert "No URLs provided. Please provide at least one" in caplog.text \ No newline at end of file + assert "No URLs provided. Please provide at least one" in caplog.text diff --git a/tests/test_metadata.py b/tests/test_metadata.py index e1f7797..e838979 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -62,18 +62,8 @@ def test_simple_merge(basic_metadata): def test_left_merge(): - left = ( - Metadata() - .set("tags", ["a"]) - .set("stats", {"views": 10}) - .set("status", "success") - ) - right = ( - Metadata() - .set("tags", ["b"]) - .set("stats", {"likes": 5}) - .set("status", "no archiver") - ) + left = Metadata().set("tags", ["a"]).set("stats", {"views": 10}).set("status", "success") + right = Metadata().set("tags", ["b"]).set("stats", {"likes": 5}).set("status", "no archiver") left.merge(right, overwrite_left=True) assert left.get("status") == "no archiver" @@ -120,6 +110,7 @@ def test_is_empty(): def test_store(): pass + # Test Media operations @@ -176,6 +167,7 @@ def test_choose_most_complete(): res = Metadata.choose_most_complete([m_more, m_less]) assert res.metadata.get("title") == "Title 1" + def test_choose_most_complete_from_pickles(unpickle): # test most complete from pickles before and after an enricher has run # Only compares length of media, not the actual media diff --git a/tests/test_modules.py b/tests/test_modules.py index 7a2b14d..f672ca6 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,25 +1,25 @@ -import sys import pytest from auto_archiver.core.module import ModuleFactory, LazyBaseModule from auto_archiver.core.base_module import BaseModule + @pytest.fixture def example_module(): import auto_archiver module_factory = ModuleFactory() - - previous_path = auto_archiver.modules.__path__ + # previous_path = auto_archiver.modules.__path__ auto_archiver.modules.__path__.append("tests/data/test_modules/") - return module_factory.get_module_lazy("example_module") + def test_get_module_lazy(example_module): assert example_module.name == "example_module" assert example_module.display_name == "Example Module" assert example_module.manifest is not None + def test_python_dependency_check(example_module): # example_module requires loguru, which is not installed # monkey patch the manifest to include a nonexistnet dependency @@ -30,11 +30,13 @@ def test_python_dependency_check(example_module): assert load_error.value.code == 1 + def test_binary_dependency_check(example_module): # example_module requires ffmpeg, which is not installed # monkey patch the manifest to include a nonexistnet dependency example_module.manifest["dependencies"]["binary"] = ["does_not_exist"] + def test_module_dependency_check_loads_module(example_module): # example_module requires cli_feeder, which is not installed # monkey patch the manifest to include a nonexistnet dependency @@ -49,19 +51,20 @@ def test_module_dependency_check_loads_module(example_module): assert module_factory._lazy_modules["hash_enricher"] is not None assert module_factory._lazy_modules["hash_enricher"]._instance is not None -def test_load_module(example_module): +def test_load_module(example_module): # setup the module, and check that config is set to the default values loaded_module = example_module.load({}) assert loaded_module is not None assert isinstance(loaded_module, BaseModule) assert loaded_module.name == "example_module" assert loaded_module.display_name == "Example Module" - assert loaded_module.config["example_module"] == {"csv_file" : "db.csv"} + assert loaded_module.config["example_module"] == {"csv_file": "db.csv"} # check that the vlaue is set on the module itself assert loaded_module.csv_file == "db.csv" + @pytest.mark.parametrize("module_name", ["local_storage", "generic_extractor", "html_formatter", "csv_db"]) def test_load_modules(module_name): # test that specific modules can be loaded @@ -78,6 +81,8 @@ def test_load_modules(module_name): # check that default settings are applied default_config = module.configs assert loaded_module.name in loaded_module.config.keys() + defaults = {k: v.get("default") for k, v in default_config.items()} + assert loaded_module.config[module_name] == defaults @pytest.mark.parametrize("module_name", ["local_storage", "generic_extractor", "html_formatter", "csv_db"]) @@ -96,5 +101,3 @@ def test_lazy_base_module(module_name): assert len(lazy_module.configs) > 0 assert len(lazy_module.description) > 0 assert len(lazy_module.version) > 0 - - diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 301e4d9..326b93d 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -1,59 +1,72 @@ import pytest -import sys from argparse import ArgumentParser, ArgumentTypeError from auto_archiver.core.orchestrator import ArchivingOrchestrator from auto_archiver.version import __version__ from auto_archiver.core.config import read_yaml, store_yaml - +from auto_archiver.core import Metadata TEST_ORCHESTRATION = "tests/data/test_orchestration.yaml" TEST_MODULES = "tests/data/test_modules/" + @pytest.fixture def test_args(): - return ["--config", TEST_ORCHESTRATION, - "--module_paths", TEST_MODULES, - "--example_module.required_field", "some_value"] # just set this for normal testing, we will remove it later + return [ + "--config", + TEST_ORCHESTRATION, + "--module_paths", + TEST_MODULES, + "--example_module.required_field", + "some_value", + ] # just set this for normal testing, we will remove it later + @pytest.fixture def orchestrator(): return ArchivingOrchestrator() + @pytest.fixture def basic_parser(orchestrator) -> ArgumentParser: return orchestrator.setup_basic_parser() + def test_setup_orchestrator(orchestrator): assert orchestrator is not None + def test_parse_config(): pass + def test_parse_basic(basic_parser): args = basic_parser.parse_args(["--config", TEST_ORCHESTRATION]) assert args.config_file == TEST_ORCHESTRATION + @pytest.mark.parametrize("mode", ["simple", "full"]) def test_mode(basic_parser, mode): args = basic_parser.parse_args(["--mode", mode]) assert args.mode == mode + def test_mode_invalid(basic_parser, capsys): with pytest.raises(SystemExit) as exit_error: basic_parser.parse_args(["--mode", "invalid"]) assert exit_error.value.code == 2 assert "invalid choice" in capsys.readouterr().err + def test_version(basic_parser, capsys): with pytest.raises(SystemExit) as exit_error: basic_parser.parse_args(["--version"]) assert exit_error.value.code == 0 assert capsys.readouterr().out == f"{__version__}\n" -def test_help(orchestrator, basic_parser, capsys): +def test_help(orchestrator, basic_parser, capsys): args = basic_parser.parse_args(["--help"]) - assert args.help == True + assert args.help is True # test the show_help() on orchestrator with pytest.raises(SystemExit) as exit_error: @@ -78,19 +91,22 @@ def test_help(orchestrator, basic_parser, capsys): assert "--logging.level" in logs # individual module configs - assert "--gsheet_feeder.sheet_id" in logs + assert "--gsheet_feeder_db.sheet_id" in logs def test_add_custom_modules_path(orchestrator, test_args): orchestrator.setup_config(test_args) - + import auto_archiver + assert "tests/data/test_modules/" in auto_archiver.modules.__path__ -def test_add_custom_modules_path_invalid(orchestrator, caplog, test_args): - orchestrator.setup_config(test_args + # we still need to load the real path to get the example_module - ["--module_paths", "tests/data/invalid_test_modules/"]) +def test_add_custom_modules_path_invalid(orchestrator, caplog, test_args): + orchestrator.setup_config( + test_args # we still need to load the real path to get the example_module + + ["--module_paths", "tests/data/invalid_test_modules/"] + ) assert caplog.records[0].message == "Path 'tests/data/invalid_test_modules/' does not exist. Skipping..." @@ -99,16 +115,16 @@ def test_check_required_values(orchestrator, caplog, test_args): # drop the example_module.required_field from the test_args test_args = test_args[:-2] - with pytest.raises(SystemExit) as exit_error: - config = orchestrator.setup_config(test_args) + with pytest.raises(SystemExit): + orchestrator.setup_config(test_args) assert caplog.records[1].message == "the following arguments are required: --example_module.required_field" -def test_get_required_values_from_config(orchestrator, test_args, tmp_path): +def test_get_required_values_from_config(orchestrator, test_args, tmp_path): # load the default example yaml, add a required field, then run the orchestrator test_yaml = read_yaml(TEST_ORCHESTRATION) - test_yaml['example_module'] = {'required_field': 'some_value'} + test_yaml["example_module"] = {"required_field": "some_value"} # write it to a temp file tmp_file = (tmp_path / "temp_config.yaml").as_posix() store_yaml(test_yaml, tmp_file) @@ -117,27 +133,42 @@ def test_get_required_values_from_config(orchestrator, test_args, tmp_path): config = orchestrator.setup_config(["--config", tmp_file, "--module_paths", TEST_MODULES]) assert config is not None -def test_load_authentication_string(orchestrator, test_args): - config = orchestrator.setup_config(test_args + ["--authentication", '{"facebook.com": {"username": "my_username", "password": "my_password"}}']) - assert config['authentication'] == {"facebook.com": {"username": "my_username", "password": "my_password"}} +def test_load_authentication_string(orchestrator, test_args): + config = orchestrator.setup_config( + test_args + ["--authentication", '{"facebook.com": {"username": "my_username", "password": "my_password"}}'] + ) + assert config["authentication"] == {"facebook.com": {"username": "my_username", "password": "my_password"}} + def test_load_authentication_string_concat_site(orchestrator, test_args): - config = orchestrator.setup_config(test_args + ["--authentication", '{"x.com,twitter.com": {"api_key": "my_key"}}']) - assert config['authentication'] == {"x.com": {"api_key": "my_key"}, - "twitter.com": {"api_key": "my_key"}} + assert config["authentication"] == {"x.com": {"api_key": "my_key"}, "twitter.com": {"api_key": "my_key"}} + def test_load_invalid_authentication_string(orchestrator, test_args): with pytest.raises(ArgumentTypeError): - orchestrator.setup_config(test_args + ["--authentication", "{\''invalid_json"]) + orchestrator.setup_config(test_args + ["--authentication", "{''invalid_json"]) + def test_load_authentication_invalid_dict(orchestrator, test_args): with pytest.raises(ArgumentTypeError): orchestrator.setup_config(test_args + ["--authentication", "[true, false]"]) + def test_load_modules_from_commandline(orchestrator, test_args): - args = test_args + ["--feeders", "example_module", "--extractors", "example_module", "--databases", "example_module", "--enrichers", "example_module", "--formatters", "example_module"] + args = test_args + [ + "--feeders", + "example_module", + "--extractors", + "example_module", + "--databases", + "example_module", + "--enrichers", + "example_module", + "--formatters", + "example_module", + ] orchestrator.setup(args) @@ -153,11 +184,43 @@ def test_load_modules_from_commandline(orchestrator, test_args): assert orchestrator.enrichers[0].name == "example_module" assert orchestrator.formatters[0].name == "example_module" + def test_load_settings_for_module_from_commandline(orchestrator, test_args): - args = test_args + ["--feeders", "gsheet_feeder", "--gsheet_feeder.sheet_id", "123", "--gsheet_feeder.service_account", "tests/data/test_service_account.json"] + args = test_args + [ + "--feeders", + "gsheet_feeder_db", + "--gsheet_feeder_db.sheet_id", + "123", + "--gsheet_feeder_db.service_account", + "tests/data/test_service_account.json", + ] orchestrator.setup(args) assert len(orchestrator.feeders) == 1 - assert orchestrator.feeders[0].name == "gsheet_feeder" - assert orchestrator.config['gsheet_feeder']['sheet_id'] == "123" \ No newline at end of file + assert orchestrator.feeders[0].name == "gsheet_feeder_db" + assert orchestrator.config["gsheet_feeder_db"]["sheet_id"] == "123" + + +def test_multiple_orchestrator(test_args): + o1_args = test_args + [ + "--feeders", + "gsheet_feeder_db", + "--gsheet_feeder_db.service_account", + "tests/data/test_service_account.json", + ] + o1 = ArchivingOrchestrator() + + with pytest.raises(ValueError): + # this should fail because the gsheet_feeder_db requires a sheet_id / sheet + o1.setup(o1_args) + + o2_args = test_args + ["--feeders", "example_module"] + o2 = ArchivingOrchestrator() + o2.setup(o2_args) + + assert o2.feeders[0].name == "example_module" + + output: Metadata = list(o2.feed()) + assert len(output) == 1 + assert output[0].get_url() == "https://example.com" diff --git a/tests/utils/test_misc.py b/tests/utils/test_misc.py index 0023077..844d3d2 100644 --- a/tests/utils/test_misc.py +++ b/tests/utils/test_misc.py @@ -14,7 +14,7 @@ from auto_archiver.utils.misc import ( update_nested_dict, calculate_file_hash, random_str, - get_timestamp + get_timestamp, ) @@ -38,40 +38,46 @@ class TestDirectoryUtils: mkdir_if_not_exists(existing_dir) assert existing_dir.exists() + class TestURLExpansion: - @pytest.mark.parametrize("input_url,expected", [ - ("https://example.com", "https://example.com"), - ("https://t.co/test", "https://expanded.url") - ]) + @pytest.mark.parametrize( + "input_url,expected", + [("https://example.com", "https://example.com"), ("https://t.co/test", "https://expanded.url")], + ) def test_expand_url(self, input_url, expected, mocker): mock_response = mocker.Mock() mock_response.url = "https://expanded.url" - mocker.patch('requests.get', return_value=mock_response) + mocker.patch("requests.get", return_value=mock_response) result = expand_url(input_url) assert result == expected def test_expand_url_handles_errors(self, caplog, mocker): - mocker.patch('requests.get', side_effect=Exception("Connection error")) + mocker.patch("requests.get", side_effect=Exception("Connection error")) url = "https://t.co/error" result = expand_url(url) assert result == url assert f"Failed to expand url {url}" in caplog.text + class TestAttributeHandling: class Sample: exists = "value" none = None - @pytest.mark.parametrize("obj,attr,default,expected", [ - (Sample(), "exists", "default", "value"), - (Sample(), "none", "default", "default"), - (Sample(), "missing", "default", "default"), - (None, "anything", "fallback", "fallback"), - ]) + @pytest.mark.parametrize( + "obj,attr,default,expected", + [ + (Sample(), "exists", "default", "value"), + (Sample(), "none", "default", "default"), + (Sample(), "missing", "default", "default"), + (None, "anything", "fallback", "fallback"), + ], + ) def test_getattr_or(self, obj, attr, default, expected): # Test gets attribute or returns a default value assert getattr_or(obj, attr, default) == expected + class TestDateTimeHandling: def test_datetime_encoder(self, sample_datetime): result = json.dumps({"dt": sample_datetime}, cls=DateTimeEncoder) @@ -83,11 +89,14 @@ class TestDateTimeHandling: result = dump_payload(payload) assert str(sample_datetime) in result - @pytest.mark.parametrize("dt_str,fmt,expected", [ - ("2023-01-01 12:00:00+00:00", None, datetime(2023, 1, 1, 12, 0, tzinfo=timezone.utc)), - ("20230101 120000", "%Y%m%d %H%M%S", datetime(2023, 1, 1, 12, 0)), - ("invalid", None, None), - ]) + @pytest.mark.parametrize( + "dt_str,fmt,expected", + [ + ("2023-01-01 12:00:00+00:00", None, datetime(2023, 1, 1, 12, 0, tzinfo=timezone.utc)), + ("20230101 120000", "%Y%m%d %H%M%S", datetime(2023, 1, 1, 12, 0)), + ("invalid", None, None), + ], + ) def test_datetime_from_string(self, dt_str, fmt, expected): result = get_datetime_from_str(dt_str, fmt) if expected is None: @@ -95,16 +104,21 @@ class TestDateTimeHandling: else: assert result == expected.replace(tzinfo=result.tzinfo) + class TestDictUtils: - @pytest.mark.parametrize("original,update,expected", [ - ({"a": 1}, {"b": 2}, {"a": 1, "b": 2}), - ({"nested": {"a": 1}}, {"nested": {"b": 2}}, {"nested": {"a": 1, "b": 2}}), - ({"a": {"b": {"c": 1}}}, {"a": {"b": {"c": 2}}}, {"a": {"b": {"c": 2}}}), - ]) + @pytest.mark.parametrize( + "original,update,expected", + [ + ({"a": 1}, {"b": 2}, {"a": 1, "b": 2}), + ({"nested": {"a": 1}}, {"nested": {"b": 2}}, {"nested": {"a": 1, "b": 2}}), + ({"a": {"b": {"c": 1}}}, {"a": {"b": {"c": 2}}}, {"a": {"b": {"c": 2}}}), + ], + ) def test_update_nested_dict(self, original, update, expected): update_nested_dict(original, update) assert original == expected + class TestHashingUtils: def test_file_hashing(self, sample_file): expected = hashlib.sha256(b"test content").hexdigest() @@ -118,6 +132,7 @@ class TestHashingUtils: expected = hashlib.sha256(content).hexdigest() assert calculate_file_hash(str(file_path)) == expected + class TestMiscUtils: def test_random_str_length(self): for length in [8, 16, 32]: @@ -131,14 +146,17 @@ class TestMiscUtils: def test_random_str_uniqueness(self): assert random_str() != random_str() - @pytest.mark.parametrize("ts_input,utc,iso,expected_type", [ - (datetime.now(), True, True, str), - ("2023-01-01T12:00:00+00:00", False, False, datetime), - (1672574400, True, True, str), - ]) + @pytest.mark.parametrize( + "ts_input,utc,iso,expected_type", + [ + (datetime.now(), True, True, str), + ("2023-01-01T12:00:00+00:00", False, False, datetime), + (1672574400, True, True, str), + ], + ) def test_timestamp_parsing(self, ts_input, utc, iso, expected_type): result = get_timestamp(ts_input, utc=utc, iso=iso) assert isinstance(result, expected_type) def test_invalid_timestamp_returns_none(self): - assert get_timestamp("invalid-date") is None \ No newline at end of file + assert get_timestamp("invalid-date") is None