From cab05eb1c642ed8ffef111ffe4ce9822b7256c92 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 1 Jun 2026 00:08:27 -0700 Subject: [PATCH] Refactor plugins search progress and config flows --- Dockerfile | 16 +- Dockerfile.multistage | 2 +- archivebox/api/models.py | 6 +- archivebox/api/urls.py | 2 +- archivebox/api/v1_cli.py | 8 + archivebox/api/v1_core.py | 43 +- archivebox/api/v1_crawls.py | 3 +- archivebox/base_models/admin.py | 2 +- archivebox/base_models/models.py | 4 +- archivebox/cli/archivebox_add.py | 30 +- archivebox/cli/archivebox_archiveresult.py | 2 +- archivebox/cli/archivebox_config.py | 2 +- archivebox/cli/archivebox_init.py | 2 + archivebox/cli/archivebox_install.py | 9 +- archivebox/cli/archivebox_list.py | 2 +- archivebox/cli/archivebox_pluginmap.py | 8 +- archivebox/cli/archivebox_remove.py | 2 +- archivebox/cli/archivebox_run.py | 2 +- archivebox/cli/archivebox_server.py | 8 +- archivebox/cli/archivebox_snapshot.py | 60 +- archivebox/cli/archivebox_status.py | 2 +- archivebox/cli/archivebox_update.py | 15 +- archivebox/config/collection.py | 4 +- archivebox/config/common.py | 201 ++- archivebox/config/ldap.py | 3 +- archivebox/config/views.py | 411 ------ archivebox/core/admin_archiveresults.py | 8 +- archivebox/core/admin_snapshots.py | 420 +----- archivebox/core/admin_tags.py | 2 +- archivebox/core/forms.py | 601 +------- archivebox/core/middleware.py | 2 +- archivebox/core/models.py | 222 ++- archivebox/core/recovery_util.py | 4 +- .../core/{host_util.py => routes_util.py} | 2 + archivebox/core/settings.py | 15 +- archivebox/core/settings_logging.py | 2 + archivebox/core/sqlite_backend/base.py | 16 +- archivebox/core/tag_util.py | 4 +- archivebox/core/templatetags/core_tags.py | 11 +- archivebox/core/urls.py | 2 +- archivebox/core/views.py | 1225 +---------------- archivebox/crawls/admin.py | 239 +++- .../0018_freeze_crawl_config_snapshots.py | 33 + .../migrations/0019_crawlschedule_config.py | 25 + archivebox/crawls/models.py | 161 ++- archivebox/machine/models.py | 115 +- archivebox/misc/checks.py | 54 +- archivebox/misc/db.py | 28 +- archivebox/misc/serve_static.py | 73 +- archivebox/personas/forms.py | 2 +- archivebox/personas/models.py | 4 +- archivebox/plugins/__init__.py | 1 + archivebox/plugins/apps.py | 9 + archivebox/plugins/discovery.py | 377 +++++ archivebox/plugins/forms.py | 599 ++++++++ archivebox/{ => plugins}/hooks.py | 523 +------ archivebox/plugins/views.py | 462 +++++++ archivebox/progressmonitor/__init__.py | 1 + archivebox/progressmonitor/apps.py | 8 + .../progressmonitor}/progress_monitor.html | 313 ++--- archivebox/progressmonitor/views.py | 953 +++++++++++++ archivebox/search/__init__.py | 352 ----- archivebox/search/admin.py | 46 +- archivebox/search/apps.py | 11 + archivebox/search/backends.py | 69 + archivebox/search/config.py | 77 ++ archivebox/search/query.py | 299 ++++ archivebox/search/views.py | 202 +++ archivebox/services/__init__.py | 4 +- archivebox/services/binary_service.py | 266 ++-- archivebox/services/crawl_service.py | 14 +- archivebox/services/process_service.py | 7 +- archivebox/services/runner.py | 122 +- archivebox/templates/admin/actions.html | 19 +- archivebox/templates/admin/base.html | 231 +++- .../templates/admin/change_list_panel.html | 2 +- .../templates/admin/change_list_results.html | 2 +- .../admin/personas/persona/change_form.html | 2 +- .../templates/admin/snapshots_grid.html | 2 +- archivebox/templates/admin/submit_line.html | 16 + archivebox/templates/core/add.html | 2 +- archivebox/templates/core/navigation.html | 2 +- archivebox/templates/core/public_index.html | 2 +- archivebox/templates/core/snapshot.html | 2 +- .../{core => plugins}/plugin_config_grid.html | 0 archivebox/templates/static/admin.css | 978 ++----------- archivebox/tests/conftest.py | 2 +- archivebox/tests/fixtures.py | 1 + .../tests/test_archive_result_service.py | 3 +- archivebox/tests/test_cli_add.py | 2 +- archivebox/tests/test_cli_list.py | 137 +- archivebox/tests/test_cli_piping.py | 6 +- archivebox/tests/test_cli_run.py | 61 + archivebox/tests/test_cli_search.py | 223 --- archivebox/tests/test_cli_server.py | 67 + archivebox/tests/test_cli_snapshot.py | 15 - archivebox/tests/test_crawl_admin.py | 131 ++ archivebox/tests/test_frozen_crawl_config.py | 203 +++ archivebox/tests/test_hooks.py | 48 +- archivebox/tests/test_misc_checks.py | 40 + archivebox/tests/test_search.py | 546 +++++++- archivebox/tests/test_search_backends_e2e.py | 136 -- .../tests/test_server_security_browser.py | 3 +- archivebox/tests/test_ui_add_view.py | 16 +- archivebox/tests/test_ui_admin_links.py | 2 +- archivebox/tests/test_ui_admin_views.py | 399 +----- archivebox/tests/test_ui_config_views.py | 7 +- archivebox/tests/test_urls.py | 4 +- archivebox/uuid_compat.py | 37 +- .../commands/supervisord_watchdog.py | 34 +- archivebox/workers/models.py | 11 + bin/release_docker.sh | 16 +- bin/test.sh | 3 +- etc/package.json | 2 +- pyproject.toml | 8 +- 115 files changed, 6162 insertions(+), 6095 deletions(-) rename archivebox/core/{host_util.py => routes_util.py} (99%) create mode 100644 archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py create mode 100644 archivebox/crawls/migrations/0019_crawlschedule_config.py create mode 100644 archivebox/plugins/__init__.py create mode 100644 archivebox/plugins/apps.py create mode 100644 archivebox/plugins/discovery.py create mode 100644 archivebox/plugins/forms.py rename archivebox/{ => plugins}/hooks.py (56%) create mode 100644 archivebox/plugins/views.py create mode 100644 archivebox/progressmonitor/__init__.py create mode 100644 archivebox/progressmonitor/apps.py rename archivebox/{templates/admin => progressmonitor/templates/progressmonitor}/progress_monitor.html (89%) create mode 100644 archivebox/progressmonitor/views.py create mode 100644 archivebox/search/apps.py create mode 100644 archivebox/search/backends.py create mode 100644 archivebox/search/config.py create mode 100644 archivebox/search/query.py create mode 100644 archivebox/search/views.py create mode 100644 archivebox/templates/admin/submit_line.html rename archivebox/templates/{core => plugins}/plugin_config_grid.html (100%) delete mode 100644 archivebox/tests/test_cli_search.py create mode 100644 archivebox/tests/test_frozen_crawl_config.py create mode 100644 archivebox/tests/test_misc_checks.py delete mode 100644 archivebox/tests/test_search_backends_e2e.py diff --git a/Dockerfile b/Dockerfile index 09c283a5..57a5704b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -313,11 +313,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$T && export CHROME_USER_DATA_DIR="$LIB_DIR/chrome_profile" \ && mkdir -p "$LIB_DIR" \ && apt-get update -qq \ - && abxpkg install --no-cache --install-timeout=900 --binproviders=playwright chrome \ - && CHROME_BINARY="$(abxpkg load --binproviders=playwright chromium | awk 'NF {print $2; exit}')" \ - && export CHROME_BINARY \ - && test -x "$CHROME_BINARY" \ - && "$CHROME_BINARY" --version | tee -a /VERSION.txt \ + && if [ "$TARGETARCH" = "arm64" ]; then \ + abxpkg install --binproviders=npm --overrides='{"npm":{"install_args":["playwright@next"]}}' playwright; \ + abxpkg install --no-cache --install-timeout=600 --binproviders=playwright --bin-dir="$LIB_DIR/env/bin" chromium; \ + fi \ && TIMEOUT=600 PUID=0 PGID=0 abx-dl plugins --install \ && find "$LIB_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \ && find "$LIB_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \ @@ -371,11 +370,8 @@ RUN (echo -e "\n\n[√] Finished Docker build successfully. Saving build summary # Verify ArchiveBox is installed and write full version/dependency info. RUN chmod +x "$CODE_DIR"/bin/*.sh \ && chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$LIB_DIR" \ - && chmod g+w "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \ - && CHROME_BINARY="$(abxpkg load --binproviders=playwright chromium | awk 'NF {print $2; exit}')" \ - && export CHROME_BINARY \ - && test -x "$CHROME_BINARY" \ - && "$CHROME_BINARY" --version | tee -a /VERSION.txt \ + && chmod g+w "$TMP_DIR" "$LIB_DIR" "$LIB_DIR"/bin "$PLAYWRIGHT_BROWSERS_PATH" \ + && TIMEOUT=600 gosu "$ARCHIVEBOX_USER" archivebox install 2>&1 | tee -a /VERSION.txt \ && gosu "$ARCHIVEBOX_USER" archivebox version 2>&1 | tee -a /VERSION.txt \ && find /venv "$CODE_DIR" "$LIB_DIR" "$DATA_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \ && find /venv "$CODE_DIR" "$LIB_DIR" "$DATA_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \ diff --git a/Dockerfile.multistage b/Dockerfile.multistage index edf56520..7b48effd 100644 --- a/Dockerfile.multistage +++ b/Dockerfile.multistage @@ -52,7 +52,7 @@ ENV CODE_DIR=/app \ DATA_DIR=/data \ LIB_DIR=/opt/archivebox/lib \ ABXPKG_LIB_DIR=/opt/archivebox/lib \ - PLAYWRIGHT_BROWSERS_PATH=/browsers \ + PLAYWRIGHT_BROWSERS_PATH=/opt/archivebox/lib/playwright/cache \ PERSONAS_DIR=/data/personas \ CHROME_USER_DATA_DIR=/data/personas/Default/chrome_profile \ CHROME_HEADLESS=true \ diff --git a/archivebox/api/models.py b/archivebox/api/models.py index 2fbfabe6..bb0e02ca 100755 --- a/archivebox/api/models.py +++ b/archivebox/api/models.py @@ -1,7 +1,7 @@ __package__ = "archivebox.api" import secrets -from archivebox.uuid_compat import uuid7 +from archivebox.uuid_compat import CompactUUIDField, uuid7 from django.conf import settings from django.db import models @@ -17,7 +17,7 @@ def generate_secret_token() -> str: class APIToken(models.Model): - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False) created_at = models.DateTimeField(default=timezone.now, db_index=True) modified_at = models.DateTimeField(auto_now=True) @@ -41,7 +41,7 @@ class APIToken(models.Model): class OutboundWebhook(WebhookBase): - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False) created_at = models.DateTimeField(default=timezone.now, db_index=True) modified_at = models.DateTimeField(auto_now=True) diff --git a/archivebox/api/urls.py b/archivebox/api/urls.py index b625d139..ced60588 100644 --- a/archivebox/api/urls.py +++ b/archivebox/api/urls.py @@ -7,7 +7,7 @@ from django.shortcuts import redirect from django.urls import path from django.views.generic.base import RedirectView -from archivebox.core.host_util import build_web_url +from archivebox.core.routes_util import build_web_url from .v1_api import urls as v1_api_urls diff --git a/archivebox/api/v1_cli.py b/archivebox/api/v1_cli.py index 2ad48699..b1630b74 100644 --- a/archivebox/api/v1_cli.py +++ b/archivebox/api/v1_cli.py @@ -66,6 +66,8 @@ class AddCommandSchema(Schema): parser: str = "auto" plugins: str = "" only_new: bool | None = None + update: bool = False + overwrite: bool = False index_only: bool = False @@ -90,6 +92,8 @@ class ScheduleCommandSchema(Schema): tag: str = "" depth: int = 0 only_new: bool | None = None + update: bool = False + overwrite: bool = False clear: bool = False @@ -120,6 +124,8 @@ def cli_add(request: HttpRequest, args: AddCommandSchema): config_overrides: dict[str, object] = {} if args.only_new is not None: config_overrides["ONLY_NEW"] = bool(args.only_new) + if args.update or args.overwrite: + config_overrides["ONLY_NEW"] = False crawl, snapshots = add( urls=args.urls, snapshot_ids=args.snapshot_ids, @@ -189,6 +195,8 @@ def cli_schedule(request: HttpRequest, args: ScheduleCommandSchema): config_overrides: dict[str, object] = {} if args.only_new is not None: config_overrides["ONLY_NEW"] = bool(args.only_new) + if args.update or args.overwrite: + config_overrides["ONLY_NEW"] = False result = schedule( import_path=args.import_path, add=args.add, diff --git a/archivebox/api/v1_core.py b/archivebox/api/v1_core.py index 4ca3cf7b..7de3750f 100644 --- a/archivebox/api/v1_core.py +++ b/archivebox/api/v1_core.py @@ -31,7 +31,7 @@ from archivebox.core.models import Snapshot, ArchiveResult, Tag from archivebox.core.permissions import public_snapshots_queryset from archivebox.api.auth import auth_using_token from archivebox.config.common import get_config -from archivebox.core.host_util import build_web_url +from archivebox.core.routes_util import build_web_url from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url_length from archivebox.core.tag_util import ( add_snapshot_counts, @@ -50,6 +50,8 @@ from archivebox.core.tag_util import ( ) from archivebox.crawls.models import Crawl from archivebox.api.v1_crawls import CrawlSchema +from archivebox.search.config import get_search_mode, get_search_mode_backend +from archivebox.search.query import apply_snapshot_search router = Router(tags=["Core Models"]) @@ -855,12 +857,8 @@ class SnapshotFilterSchema(FilterSchema): modified_at: Annotated[datetime | None, FilterLookup("modified_at")] = None modified_at__gte: Annotated[datetime | None, FilterLookup("modified_at__gte")] = None modified_at__lt: Annotated[datetime | None, FilterLookup("modified_at__lt")] = None - search: Annotated[ - str | None, - FilterLookup( - ["url__icontains", "title__icontains", "tags__name__icontains", "id__istartswith", "id__iendswith", "timestamp__startswith"], - ), - ] = None + search: str | None = None + search_mode: str | None = None url: Annotated[str | None, FilterLookup("url")] = None tag: Annotated[str | None, FilterLookup("tags__name")] = None title: Annotated[str | None, FilterLookup("title__icontains")] = None @@ -868,14 +866,37 @@ class SnapshotFilterSchema(FilterSchema): bookmarked_at__gte: Annotated[datetime | None, FilterLookup("bookmarked_at__gte")] = None bookmarked_at__lt: Annotated[datetime | None, FilterLookup("bookmarked_at__lt")] = None + def filter_search(self, value: str | None) -> Q: + return Q() + + def filter_search_mode(self, value: str | None) -> Q: + return Q() + @router.get("/snapshots", response=list[SnapshotSchema], url_name="get_snapshots") @paginate(CustomPagination) def get_snapshots(request: HttpRequest, filters: Query[SnapshotFilterSchema], with_archiveresults: bool = False): """List all Snapshot entries matching these filters.""" setattr(request, "with_archiveresults", with_archiveresults) - queryset = Snapshot.objects.all() - return filters.filter(queryset).distinct() + queryset = filters.filter(Snapshot.objects.all()).distinct() + query = (filters.search or "").strip() + if not query: + return queryset + + runtime_config = getattr(request, "archivebox_config", None) + search_mode = get_search_mode(filters.search_mode, config=runtime_config) + try: + return apply_snapshot_search( + queryset, + query, + search_mode=search_mode, + config=runtime_config, + include_id_matches=True, + ) + except Exception: + if get_search_mode_backend(search_mode, config=runtime_config): + return queryset.none() + return apply_snapshot_search(queryset, query, search_mode="meta", config=runtime_config, include_id_matches=True) @router.get("/snapshots.rss", url_name="get_snapshots_rss") @@ -1212,7 +1233,7 @@ def _get_snapshot_for_tag_edit(snapshot_ref: str) -> Snapshot: is_full_uuid = len(snapshot_ref.replace("-", "")) == 32 and all(char in "0123456789abcdef-" for char in snapshot_ref) if is_full_uuid: try: - return snapshot_qs.get(pk=snapshot_ref) + return snapshot_qs.get(pk=snapshot_ref.replace("-", "")) except (Snapshot.DoesNotExist, ValueError): pass @@ -1295,7 +1316,7 @@ def tags_autocomplete(request: HttpRequest, q: str = ""): raise HttpError(401, "Authentication required") public_only = not getattr(request.user, "is_authenticated", False) and not getattr(request, "_api_token", None) - queryset = get_matching_tags(q, with_snapshot_counts=False) + queryset = get_matching_tags(q) public_snapshots = public_snapshots_queryset(Snapshot.objects.all()) if public_only: queryset = queryset.filter(snapshot_set__id__in=public_snapshots.values("id")).distinct() diff --git a/archivebox/api/v1_crawls.py b/archivebox/api/v1_crawls.py index 32c1006b..e0f602b9 100644 --- a/archivebox/api/v1_crawls.py +++ b/archivebox/api/v1_crawls.py @@ -122,7 +122,8 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema): tags = normalize_tag_list(data.tags, data.tags_str) config = dict(data.config or {}) - config.setdefault("PERMISSIONS", str(get_config(user=request.user).PERMISSIONS)) + request_user = request.user if request.user.is_authenticated else None + config.setdefault("PERMISSIONS", str(get_config(user=request_user).PERMISSIONS)) crawl = Crawl.objects.create( urls="\n".join(urls), max_depth=data.max_depth, diff --git a/archivebox/base_models/admin.py b/archivebox/base_models/admin.py index c2941248..f882666b 100644 --- a/archivebox/base_models/admin.py +++ b/archivebox/base_models/admin.py @@ -77,7 +77,7 @@ class KeyValueWidget(forms.Widget): """Get available config options from plugins.""" try: from archivebox.config.common import ArchiveBoxConfig - from archivebox.hooks import discover_plugin_configs + from archivebox.plugins.discovery import discover_plugin_configs options: dict[str, ConfigOption] = {} skipped_core_keys = {"ABX_RUNTIME", "DATA_DIR", "CRAWL_DIR", "SNAP_DIR"} diff --git a/archivebox/base_models/models.py b/archivebox/base_models/models.py index 2fc94d02..f44bbbbf 100755 --- a/archivebox/base_models/models.py +++ b/archivebox/base_models/models.py @@ -6,7 +6,7 @@ import json import shutil from typing import Any -from archivebox.uuid_compat import uuid7 +from archivebox.uuid_compat import CompactUUIDField, uuid7 from pathlib import Path from django.db import models @@ -65,7 +65,7 @@ class AutoDateTimeField(models.DateTimeField): class ModelWithUUID(models.Model): - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) modified_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( diff --git a/archivebox/cli/archivebox_add.py b/archivebox/cli/archivebox_add.py index 1db8e93f..54d69299 100644 --- a/archivebox/cli/archivebox_add.py +++ b/archivebox/cli/archivebox_add.py @@ -185,7 +185,7 @@ def add( label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]", created_by_id=created_by_id, status=Crawl.StatusChoices.QUEUED, - retry_at=None if index_only else timezone.now(), + retry_at=None if (index_only or bg) else timezone.now(), config=crawl_config, ) @@ -198,15 +198,8 @@ def add( # Discovered URLs become child Snapshots (depth+1) if index_only: - # ``--index-only`` means "add the URLs to the index without archiving - # them now". That only holds if we actually materialize the Snapshot - # rows here — otherwise the CLI returns success with nothing in the - # index, which broke ``test_add_url_after_init`` & friends. Create - # the Snapshots synchronously (the same step the runner would do) - # but skip starting any worker so extractors don't run. - crawl.create_snapshots_from_urls() - print("[yellow]\\[*] Index-only mode - URLs indexed, runner not started[/yellow]") - return crawl, crawl.snapshot_set.all() + print("[yellow]\\[*] Index-only mode - URLs queued, runner not started[/yellow]") + return crawl, crawl.snapshot_set.none() # 5. Start the crawl runner to process the queue # The runner will: @@ -257,13 +250,14 @@ def add( # Print summary for foreground runs try: crawl.refresh_from_db() - snapshots_count = crawl.snapshot_set.count() try: from django.db.models import Count, Sum - totals = crawl.snapshot_set.aggregate(snapshot_count=Count("id"), total_bytes=Sum("archiveresult__output_size")) - total_bytes = int(totals["total_bytes"] or 0) if totals["snapshot_count"] else 0 + totals = crawl.snapshot_set.aggregate(snapshot_count=Count("id"), total_bytes=Sum("output_size")) + snapshots_count = int(totals["snapshot_count"] or 0) + total_bytes = int(totals["total_bytes"] or 0) except Exception: + snapshots_count = crawl.snapshot_set.count() total_bytes, _, _ = get_dir_size(crawl.output_dir) total_size = printable_filesize(total_bytes) total_time = timezone.now() - started_at @@ -284,9 +278,9 @@ def add( except Exception: rel_output_str = str(crawl.output_dir) - from archivebox.core.host_util import build_admin_url + from archivebox.core.routes_util import build_admin_url - admin_url = build_admin_url(f"/admin/crawls/crawl/{crawl.id.hex}/change/", config=config) + admin_url = build_admin_url(f"/admin/crawls/crawl/{crawl.id}/change/", config=config) print("\n[bold]crawl output saved to:[/bold]") print(f" {rel_output_str}") @@ -330,6 +324,8 @@ def add( "Pass --no-only-new to force re-archive of URLs that already exist.", ) @click.option("--index-only", is_flag=True, help="Just add the URLs to the index without archiving them now") +@click.option("--overwrite", is_flag=True, help="Re-archive URLs even if they already exist (alias for --no-only-new)") +@click.option("--update", is_flag=True, help="Re-archive URLs even if they already exist (alias for --no-only-new)") @click.option("--bg", is_flag=True, help="Run archiving in background (queue work and return immediately)") @click.argument("urls", nargs=-1, type=click.Path()) @docstring(add.__doc__) @@ -360,7 +356,11 @@ def main(**kwargs): # Translate --only-new/--no-only-new into a crawl config override. # add() takes config overrides as a dict; no per-flag kwargs. + overwrite = kwargs.pop("overwrite", False) + update = kwargs.pop("update", False) only_new = kwargs.pop("only_new", None) + if overwrite or update: + only_new = False if only_new is not None: kwargs["config"] = {"ONLY_NEW": bool(only_new)} diff --git a/archivebox/cli/archivebox_archiveresult.py b/archivebox/cli/archivebox_archiveresult.py index e34a413c..1b79f322 100644 --- a/archivebox/cli/archivebox_archiveresult.py +++ b/archivebox/cli/archivebox_archiveresult.py @@ -74,7 +74,7 @@ def create_archiveresults( 1: Failure """ from archivebox.config.common import get_config - from archivebox.hooks import discover_hooks + from archivebox.plugins.hooks import discover_hooks from archivebox.misc.jsonl import read_stdin, write_record, TYPE_SNAPSHOT, TYPE_ARCHIVERESULT from archivebox.core.models import Snapshot diff --git a/archivebox/cli/archivebox_config.py b/archivebox/cli/archivebox_config.py index 6f65d51a..f8e76ca0 100644 --- a/archivebox/cli/archivebox_config.py +++ b/archivebox/cli/archivebox_config.py @@ -31,7 +31,7 @@ def config( from abx_plugins.plugins.base.utils import resolve_alias from archivebox.config.collection import write_config_file from archivebox.config.common import ArchiveBoxConfig, get_config, get_all_configs - from archivebox.hooks import discover_plugin_configs + from archivebox.plugins.discovery import discover_plugin_configs check_data_folder() diff --git a/archivebox/cli/archivebox_init.py b/archivebox/cli/archivebox_init.py index 957b78ee..87bc05f2 100755 --- a/archivebox/cli/archivebox_init.py +++ b/archivebox/cli/archivebox_init.py @@ -73,6 +73,8 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non config.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True) config.USERS_DIR.mkdir(parents=True, exist_ok=True) Path(CONSTANTS.LOGS_DIR).mkdir(exist_ok=True) + for path in (Path(CONSTANTS.SOURCES_DIR), config.ARCHIVE_DIR, config.USERS_DIR, Path(CONSTANTS.LOGS_DIR)): + path.chmod(int(config.OUTPUT_PERMISSIONS, base=8) | 0o111) print(f" + {_display_data_path(CONSTANTS.CONFIG_FILE, DATA_DIR)}...") diff --git a/archivebox/cli/archivebox_install.py b/archivebox/cli/archivebox_install.py index 781c7024..6817e345 100755 --- a/archivebox/cli/archivebox_install.py +++ b/archivebox/cli/archivebox_install.py @@ -28,6 +28,11 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo config = get_config() archive_dir = config.ARCHIVE_DIR + + if dry_run: + print("[dim]Dry run - would detect ArchiveBox dependencies and run the abx-dl install flow[/dim]") + return + if not (os.access(archive_dir, os.R_OK) and archive_dir.is_dir()): init() # must init full index because we need a db to store Binary entries in @@ -47,10 +52,6 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo print(f" DATA_DIR will be owned by [blue]{ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}[/blue].") print() - if dry_run: - print("[dim]Dry run - would run the abx-dl install flow[/dim]") - return - # Set up Django from archivebox.config.django import setup_django diff --git a/archivebox/cli/archivebox_list.py b/archivebox/cli/archivebox_list.py index 14359453..8010bade 100644 --- a/archivebox/cli/archivebox_list.py +++ b/archivebox/cli/archivebox_list.py @@ -20,7 +20,7 @@ from archivebox.cli.archivebox_snapshot import list_snapshots @click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at") @click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: timestamp,url,title") @click.option("--with-headers", is_flag=True, help="Include column headers in structured output") -@click.option("--search", type=click.Choice(["meta", "content", "contents", "deep"]), help="Search mode to use for the query") +@click.option("--search", help="Search mode to use for the query") @click.argument("query", nargs=-1) def main( status: str | None, diff --git a/archivebox/cli/archivebox_pluginmap.py b/archivebox/cli/archivebox_pluginmap.py index 547d05e3..a0143b5a 100644 --- a/archivebox/cli/archivebox_pluginmap.py +++ b/archivebox/cli/archivebox_pluginmap.py @@ -56,13 +56,15 @@ def pluginmap( from rich.panel import Panel from rich import box - from archivebox.hooks import ( - BUILTIN_PLUGINS_DIR, - USER_PLUGINS_DIR, + from archivebox.plugins.hooks import ( discover_hooks, is_background_hook, normalize_hook_event_name, ) + from archivebox.plugins.discovery import ( + BUILTIN_PLUGINS_DIR, + USER_PLUGINS_DIR, + ) console = Console() prnt = console.print diff --git a/archivebox/cli/archivebox_remove.py b/archivebox/cli/archivebox_remove.py index 67b9d3d8..eb813325 100644 --- a/archivebox/cli/archivebox_remove.py +++ b/archivebox/cli/archivebox_remove.py @@ -65,7 +65,7 @@ def remove( log_removal_started(snapshots, yes=yes) from archivebox.core.models import Snapshot - from archivebox.search import flush_search_index + from archivebox.search.query import flush_search_index # Freeze the target set up-front so a concurrent daemon writing new # snapshots can't extend the deletion under us, and so the cursor isn't diff --git a/archivebox/cli/archivebox_run.py b/archivebox/cli/archivebox_run.py index c5318163..b8894e30 100644 --- a/archivebox/cli/archivebox_run.py +++ b/archivebox/cli/archivebox_run.py @@ -264,7 +264,7 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on from django.utils import timezone from archivebox.crawls.models import Crawl - crawl = Crawl.objects.filter(id=crawl_id, status__in=[Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED]).first() + crawl = Crawl.objects.filter(id=crawl_id, status__in=Crawl.RUNNABLE_STATES).first() now = timezone.now() # Only re-lease when the row is unscheduled (retry_at IS NULL) or its # existing lease has already expired. A future retry_at means another diff --git a/archivebox/cli/archivebox_server.py b/archivebox/cli/archivebox_server.py index e389cc8b..09f2d88f 100644 --- a/archivebox/cli/archivebox_server.py +++ b/archivebox/cli/archivebox_server.py @@ -162,7 +162,7 @@ def _print_server_startup_warnings(config, host: str, port: str) -> None: # CSRF_TRUSTED_ORIGINS set, get_base_url() will silently use that as the # implicit BASE_URL. Surface what we picked so the user knows where their # links / redirects are going — and tell them how to make it explicit. - from archivebox.core.host_util import derive_base_url_from_csrf + from archivebox.core.routes_util import derive_base_url_from_csrf csrf_derived = derive_base_url_from_csrf(config) if csrf_derived: @@ -178,7 +178,7 @@ def _print_server_startup_warnings(config, host: str, port: str) -> None: print() return - # BASE_URL was not set explicitly. The host_util derivation gives one of + # BASE_URL was not set explicitly. The routes_util derivation gives one of # three results, with very different risk profiles — show a tailored hint # so new users coming from the 0.7.x single-domain world know whether the # default is fine for them or needs attention. @@ -203,7 +203,7 @@ def _print_server_startup_warnings(config, host: str, port: str) -> None: ) print() else: - # Loopback / wildcard bind. The host_util default of + # Loopback / wildcard bind. The routes_util default of # http://archivebox.localhost:PORT works in a browser on the same # machine, but anything else (reverse proxy, k8s ingress, LAN client) # needs BASE_URL set. (Real hostnames can't reach this branch — the @@ -298,7 +298,7 @@ def server( return os.environ["BIND_ADDR"] = f"{host}:{port}" - from archivebox.core.host_util import get_base_url + from archivebox.core.routes_util import get_base_url base_url = get_base_url().rstrip("/") admin_url = f"{base_url}/admin/" diff --git a/archivebox/cli/archivebox_snapshot.py b/archivebox/cli/archivebox_snapshot.py index 9f934f5f..5ec3acd2 100644 --- a/archivebox/cli/archivebox_snapshot.py +++ b/archivebox/cli/archivebox_snapshot.py @@ -35,7 +35,7 @@ from collections.abc import Iterable import rich_click as click from rich import print as rprint -from django.db.models import Case, IntegerField, Q, QuerySet, When +from django.db.models import Case, IntegerField, QuerySet, When from archivebox.cli.cli_util import apply_filters @@ -191,12 +191,7 @@ def build_snapshot_queryset( limit: int | None = None, ) -> QuerySet: from archivebox.core.models import Snapshot - from archivebox.search import ( - get_default_search_mode, - get_search_mode, - prioritize_metadata_matches, - query_search_index, - ) + from archivebox.search.query import apply_snapshot_search queryset = Snapshot.objects.order_by("-created_at") queryset = apply_filters( @@ -214,39 +209,22 @@ def build_snapshot_queryset( query = (query or "").strip() if query: - metadata_qs = queryset.filter( - Q(title__icontains=query) | Q(url__icontains=query) | Q(timestamp__icontains=query) | Q(tags__name__icontains=query), - ) - requested_search_mode = (search or "").strip().lower() - if requested_search_mode == "content": - requested_search_mode = "contents" - search_mode = get_default_search_mode() if not requested_search_mode else get_search_mode(requested_search_mode) - - if search_mode == "meta": - queryset = metadata_qs - elif limit and len(list(metadata_qs.values_list("pk", flat=True).distinct()[:limit])) >= limit: - queryset = metadata_qs - else: - try: - deep_qsearch = None - if search_mode == "deep": - qsearch = query_search_index(query, search_mode="contents", max_results=limit) - deep_qsearch = query_search_index(query, search_mode="deep", max_results=limit) - else: - qsearch = query_search_index(query, search_mode=search_mode, max_results=limit) - queryset = prioritize_metadata_matches( - queryset, - metadata_qs, - qsearch, - deep_queryset=deep_qsearch, - ordering=("-created_at",) if not sort else None, - ) - except Exception as err: - rprint( - f"[yellow]Search backend error, falling back to metadata search: {err}[/yellow]", - file=sys.stderr, - ) - queryset = metadata_qs + try: + queryset = apply_snapshot_search( + queryset, + query, + search_mode=search, + ordering=("-created_at",) if not sort else None, + max_results=limit, + skip_backend_when_metadata_satisfies_limit=True, + include_metadata_for_forced_backend=True, + ) + except Exception as err: + rprint( + f"[yellow]Search backend error, falling back to metadata search: {err}[/yellow]", + file=sys.stderr, + ) + queryset = apply_snapshot_search(queryset, query, search_mode="meta") if sort: queryset = queryset.order_by(sort) @@ -531,7 +509,7 @@ def create_cmd(urls: tuple, tag: str, status: str, depth: int): @click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at") @click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: timestamp,url,title") @click.option("--with-headers", is_flag=True, help="Include column headers in structured output") -@click.option("--search", type=click.Choice(["meta", "content", "contents", "deep"]), help="Search mode to use for the query") +@click.option("--search", help="Search mode to use for the query") @click.argument("query", nargs=-1) def list_cmd( status: str | None, diff --git a/archivebox/cli/archivebox_status.py b/archivebox/cli/archivebox_status.py index 781df941..2aad7b1e 100644 --- a/archivebox/cli/archivebox_status.py +++ b/archivebox/cli/archivebox_status.py @@ -61,7 +61,7 @@ def status(out_dir: Path = DATA_DIR) -> None: num_dirs += root_dirs num_files += root_files else: - num_bytes = ArchiveResult.objects.aggregate(total=Coalesce(Sum("output_size"), 0))["total"] or 0 + num_bytes = snapshots_qs.aggregate(total=Coalesce(Sum("output_size"), 0))["total"] or 0 num_dirs = 0 num_files = ArchiveResult.objects.exclude(output_files__in=["", "{}"]).count() size = printable_filesize(num_bytes) diff --git a/archivebox/cli/archivebox_update.py b/archivebox/cli/archivebox_update.py index af0af7b2..83a151d2 100644 --- a/archivebox/cli/archivebox_update.py +++ b/archivebox/cli/archivebox_update.py @@ -33,7 +33,7 @@ def _get_snapshot_crawl(snapshot: Snapshot) -> Crawl | None: def _get_search_indexing_plugins() -> list[str]: from abx_dl.models import discover_plugins - from archivebox.hooks import get_search_backends + from archivebox.plugins.discovery import get_search_backends available_backends = set(get_search_backends()) plugins = discover_plugins() @@ -629,6 +629,7 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w """ from archivebox.core.models import Snapshot from archivebox.crawls.models import Crawl + from django.db.models import Q from django.utils import timezone stats = { @@ -694,7 +695,7 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w stats["sealed"] += updated_rows stats["updated_db"] += updated_rows - fs_version_rows = queryset.exclude(fs_version=current_fs_version) + fs_version_rows = queryset.exclude(fs_version=current_fs_version).filter(Q(retry_at__isnull=True) | Q(retry_at__gt=now)) stale_batch = [] def queue_stale_fs_batch() -> None: @@ -746,14 +747,10 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w now = timezone.now() stats["crawls_queued"] = ( Crawl.objects.filter( - status__in=[Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED], + status__in=Crawl.RUNNABLE_STATES, ) .exclude( - snapshot_set__status__in=[ - Snapshot.StatusChoices.QUEUED, - Snapshot.StatusChoices.STARTED, - Snapshot.StatusChoices.PAUSED, - ], + snapshot_set__status__in=Snapshot.OPEN_STATES, ) .update( retry_at=now, @@ -921,7 +918,7 @@ def print_index_stats(stats: dict[str, Any]) -> None: @click.option("--crawl-id", help="Filter by crawl ID") @click.option("--limit", "-n", type=int, help="Limit number of snapshots to update") @click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at") -@click.option("--search", type=click.Choice(["meta", "content", "contents", "deep"]), help="Search mode to use for positional query") +@click.option("--search", help="Search mode to use for positional query") @click.option("--before", type=float, help="Only snapshots before timestamp") @click.option("--after", type=float, help="Only snapshots after timestamp") @click.option("--filter-type", type=click.Choice(["exact", "substring", "regex", "domain", "tag", "timestamp"]), default="exact") diff --git a/archivebox/config/collection.py b/archivebox/config/collection.py index 69ed276b..1a83777c 100644 --- a/archivebox/config/collection.py +++ b/archivebox/config/collection.py @@ -98,7 +98,7 @@ def _resolve_section_for_key(key: str, config_sections, plugin_configs) -> str: def _render_config_file_content(config: dict[str, str]) -> str: """Render a flat config dict to INI text, grouped by inferred section.""" from archivebox.config.common import get_all_configs - from archivebox.hooks import discover_plugin_configs + from archivebox.plugins.discovery import discover_plugin_configs config_sections = get_all_configs() plugin_configs = discover_plugin_configs() @@ -284,7 +284,7 @@ def write_config_file(config: dict[str, str]) -> AttrDict: """ from archivebox.config.common import get_all_configs - from archivebox.hooks import discover_plugin_configs + from archivebox.plugins.discovery import discover_plugin_configs from archivebox.misc.system import atomic_write config_path = CONSTANTS.CONFIG_FILE diff --git a/archivebox/config/common.py b/archivebox/config/common.py index e5bbc79f..88d30207 100644 --- a/archivebox/config/common.py +++ b/archivebox/config/common.py @@ -1,3 +1,5 @@ +from __future__ import annotations + __package__ = "archivebox.config" import json @@ -13,7 +15,7 @@ from typing import Any, ClassVar, cast from pathlib import Path from rich.console import Console -from pydantic import BaseModel, Field, create_model, field_validator, model_validator +from pydantic import BaseModel, Field, PrivateAttr, create_model, field_validator, model_validator from pydantic_settings import SettingsConfigDict from abx_plugins.plugins.base.utils import BASE_CONFIG_PATH, build_config_model, resolve_plugin_configs @@ -64,6 +66,18 @@ def permissions_from_legacy_public_flags(raw_config: Mapping[str, object]) -> st _SENSITIVE_CONFIG_KEY_NEEDLES = ("TOKEN", "SECRET", "API_KEY", "APIKEY", "PASSWORD") SENSITIVE_CONFIG_VALUE_REDACTED = "********" +_SCOPE_CRAWL_FROZEN = "crawl_frozen" +_SCOPE_CRAWL_EXECUTION = "crawl_execution" +_SCOPE_SERVER = "server" + + +@lru_cache(maxsize=1) +def _plugin_sensitive_config_keys() -> frozenset[str]: + sensitive_keys: set[str] = set() + for prop_key, prop_schema in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS).items(): + if isinstance(prop_schema, Mapping) and prop_schema.get("x-sensitive"): + sensitive_keys.add(str(prop_key)) + return frozenset(sensitive_keys) def is_sensitive_config_key(key: str) -> bool: @@ -77,8 +91,9 @@ def is_sensitive_config_key(key: str) -> bool: REST API responses, and any future surface that round-trips raw config values all agree on which keys to redact. """ - upper = (key or "").upper() - return any(needle in upper for needle in _SENSITIVE_CONFIG_KEY_NEEDLES) + key = str(key or "") + upper = key.upper() + return key in _plugin_sensitive_config_keys() or any(needle in upper for needle in _SENSITIVE_CONFIG_KEY_NEEDLES) def redact_sensitive_config(config: Mapping[str, Any] | None) -> dict[str, Any]: @@ -103,6 +118,34 @@ def redact_sensitive_config(config: Mapping[str, Any] | None) -> dict[str, Any]: return redacted +def normalize_runtime_config(config: BaseConfigSet | Mapping[str, Any] | str | None) -> dict[str, Any]: + """Return a JSON-safe config dict suitable for storage or event payloads.""" + if config is None: + return {} + if isinstance(config, BaseConfigSet): + config = config.model_dump(mode="json") + elif isinstance(config, str): + config = json.loads(config) + else: + config = dict(config) + return {key: value for key, value in json.loads(json.dumps(config, default=str)).items() if value is not None} + + +def build_crawl_config_snapshot( + *, + user: Any = None, + persona: Any = None, + overrides: Mapping[str, Any] | None = None, + base_config: ArchiveBoxBaseConfig | Mapping[str, object] | None = None, +) -> dict[str, Any]: + """Build the frozen runtime config stored on Crawl.config at creation time.""" + effective = get_config(user=user, persona=persona, base_config=base_config) + frozen = effective.for_crawl_frozen() + if overrides: + frozen = get_config(base_config=frozen, overrides=overrides, include_machine=False).for_crawl_frozen() + return frozen + + def rprint(*args, file=None, **kwargs): console = _STDERR_CONSOLE if file is sys.stderr else _STDOUT_CONSOLE console.print(*args, **kwargs) @@ -110,6 +153,7 @@ def rprint(*args, file=None, **kwargs): class ShellConfig(BaseConfigSet): toml_section_header: str = "SHELL_CONFIG" + _scope: str = PrivateAttr(default=_SCOPE_CRAWL_EXECUTION) DEBUG: bool = Field(default="--debug" in sys.argv) @@ -141,6 +185,7 @@ class ShellConfig(BaseConfigSet): class StorageConfig(BaseConfigSet): toml_section_header: str = "STORAGE_CONFIG" + _scope: str = PrivateAttr(default=_SCOPE_SERVER) # ARCHIVE_DIR / USERS_DIR are resolved dynamically via get_config(). ARCHIVE_DIR: Path = Field(default=CONSTANTS.ARCHIVE_DIR) @@ -150,16 +195,16 @@ class StorageConfig(BaseConfigSet): # TMP_DIR must be a local, fast, readable/writable dir by archivebox user, # must be a short path due to unix path length restrictions for socket files (<100 chars) # must be a local SSD/tmpfs for speed and because bind mounts/network mounts/FUSE dont support unix sockets - TMP_DIR: Path = Field(default=CONSTANTS.DEFAULT_TMP_DIR) + TMP_DIR: Path = Field(default=CONSTANTS.DEFAULT_TMP_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION}) # LIB_DIR must be a local, fast, readable/writable dir by archivebox user, # must be able to contain executable binaries (up to 5GB size) # should not be a remote/network/FUSE mount for speed reasons, otherwise extractors will be slow - LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_DIR) + LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION}) # LIB_BIN_DIR is an optional human-facing symlink convenience directory. # Runtime lookup must use provider-specific paths under LIB_DIR instead. - LIB_BIN_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_BIN_DIR) + LIB_BIN_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_BIN_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION}) # CUSTOM_TEMPLATES_DIR allows users to override default templates # defaults to DATA_DIR / 'user_templates' but can be configured @@ -172,12 +217,14 @@ class StorageConfig(BaseConfigSet): class GeneralConfig(BaseConfigSet): toml_section_header: str = "GENERAL_CONFIG" + _scope: str = PrivateAttr(default=_SCOPE_SERVER) TAG_SEPARATOR_PATTERN: str = Field(default=r"[,]") class ServerConfig(BaseConfigSet): toml_section_header: str = "SERVER_CONFIG" + _scope: str = PrivateAttr(default=_SCOPE_SERVER) SERVER_SECURITY_MODES: ClassVar[tuple[str, ...]] = ( "safe-subdomains-fullreplay", @@ -258,6 +305,7 @@ class ServerConfig(BaseConfigSet): class DatabaseConfig(BaseConfigSet): toml_section_header: str = "DATABASE_CONFIG" + _scope: str = PrivateAttr(default=_SCOPE_SERVER) DATABASE_NAME: str = Field(default=str(CONSTANTS.DATABASE_FILE), alias="ARCHIVEBOX_DATABASE_NAME") SQLITE_JOURNAL_MODE: str = Field( @@ -277,6 +325,7 @@ class DatabaseConfig(BaseConfigSet): class ArchivingConfig(BaseConfigSet): toml_section_header: str = "ARCHIVING_CONFIG" + _scope: str = PrivateAttr(default=_SCOPE_CRAWL_FROZEN) PLUGINS: str = Field( default="", @@ -284,6 +333,7 @@ class ArchivingConfig(BaseConfigSet): ) ONLY_NEW: bool = Field(default=True) + INDEX_ONLY: bool = Field(default=False) TIMEOUT: int = Field(default=60) CRAWL_MAX_URLS: int = Field(default=0) @@ -397,6 +447,7 @@ def parse_delete_after(value) -> timedelta | None: class SearchBackendConfig(BaseConfigSet): toml_section_header: str = "SEARCH_BACKEND_CONFIG" + _scope: str = PrivateAttr(default=_SCOPE_SERVER) SEARCH_BACKEND_ENGINE: str = Field(default="ripgrep") @@ -414,7 +465,7 @@ def _plugin_user_config(config: Mapping[str, object]) -> dict[str, str]: def _discover_plugin_config_schemas() -> PluginSchemaDocuments: - from archivebox.hooks import discover_plugin_configs + from archivebox.plugins.discovery import discover_plugin_configs schemas: PluginSchemaDocuments = {} if BASE_CONFIG_PATH.exists(): @@ -473,12 +524,78 @@ class ArchiveBoxBaseConfig( populate_by_name=True, ) - DATA_DIR: Path = Field(default=CONSTANTS.DATA_DIR) - ABX_RUNTIME: str = Field(default="archivebox") - CRAWL_DIR: Path | None = Field(default=None) - SNAP_DIR: Path | None = Field(default=None) + DATA_DIR: Path = Field(default=CONSTANTS.DATA_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION}) + ABX_RUNTIME: str = Field(default="archivebox", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION}) + CRAWL_DIR: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION}) + SNAP_DIR: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION}) computed_config_keys: ClassVar[tuple[str, ...]] = COMPUTED_CONFIG_KEYS + @classmethod + def _core_config_classes(cls) -> tuple[type[BaseConfigSet], ...]: + return ( + ShellConfig, + StorageConfig, + GeneralConfig, + ServerConfig, + DatabaseConfig, + ArchivingConfig, + SearchBackendConfig, + LDAPConfig, + ) + + @classmethod + def _core_field_scope(cls, key: str) -> str | None: + if key == "toml_section_header": + return _SCOPE_SERVER + for config_cls in cls._core_config_classes(): + field = config_cls.model_fields.get(key) + if field is None: + continue + default_scope = str(config_cls.__private_attributes__["_scope"].default) + extra = field.json_schema_extra + if isinstance(extra, dict) and "scope" in extra: + return str(extra["scope"]) + return default_scope + if key in ArchiveBoxBaseConfig.model_fields: + field = ArchiveBoxBaseConfig.model_fields[key] + extra = field.json_schema_extra + if isinstance(extra, dict) and "scope" in extra: + return str(extra["scope"]) + return _SCOPE_SERVER + return None + + @classmethod + def _plugin_field_scope(cls, key: str) -> str | None: + for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items(): + properties = schema.get("properties") if isinstance(schema, dict) else None + if not isinstance(properties, dict) or key not in properties: + continue + prop_schema = properties.get(key) or {} + if isinstance(prop_schema, Mapping) and prop_schema.get("x-scope"): + return str(prop_schema["x-scope"]) + if str(plugin_name).startswith("search_backend_"): + return _SCOPE_SERVER + return _SCOPE_CRAWL_FROZEN + return None + + @classmethod + def scope_for_key(cls, key: str) -> str: + return cls._core_field_scope(key) or cls._plugin_field_scope(key) or _SCOPE_SERVER + + def _scoped_config(self, *, include_execution: bool) -> dict[str, Any]: + allowed_scopes = {_SCOPE_CRAWL_FROZEN} + if include_execution: + allowed_scopes.add(_SCOPE_CRAWL_EXECUTION) + return {key: value for key, value in normalize_runtime_config(self).items() if type(self).scope_for_key(key) in allowed_scopes} + + def for_crawl_execution(self) -> dict[str, Any]: + """Config safe to pass to crawl/snapshot hook execution.""" + return self._scoped_config(include_execution=True) + + def for_crawl_frozen(self) -> dict[str, Any]: + """Config safe to persist permanently on Crawl.config.""" + return self._scoped_config(include_execution=False) + @model_validator(mode="after") def resolve_runtime_paths(self): self.DATA_DIR = self.DATA_DIR.expanduser().resolve() @@ -544,6 +661,7 @@ def get_config( machine: Any = None, include_machine: bool = True, resolve_plugins: bool = True, + redact_sensitive: bool = False, ) -> ArchiveBoxBaseConfig: """ Get merged config from all sources. @@ -552,12 +670,12 @@ def get_config( 1. Explicit overrides 2. Per-ArchiveResult config 3. Per-snapshot config and output path - 4. Per-crawl config and output path - 5. Per-user config - 6. Per-persona derived config - 7. Current machine derived config - 8. Environment variables - 9. Config file (ArchiveBox.conf) + 4. Frozen per-crawl config and output path + 5. Per-user config (only when resolving outside a crawl) + 6. Per-persona derived config (only when resolving outside a crawl) + 7. Current machine derived config (only when resolving outside a crawl) + 8. Environment variables (only when resolving outside a crawl) + 9. Config file (ArchiveBox.conf, only when resolving outside a crawl) 10. Plugin schema defaults 11. Core config defaults """ @@ -567,7 +685,9 @@ def get_config( if crawl is None and snapshot is not None: crawl = snapshot.crawl - if include_machine and machine is None: + crawl_config_base = crawl is not None and base_config is None + + if include_machine and machine is None and not crawl_config_base: try: from django.apps import apps @@ -578,15 +698,19 @@ def get_config( except Exception: machine = None - if persona is None and crawl is not None: + if persona is None and crawl is not None and not crawl_config_base: persona = crawl.resolve_persona() config_data: ConfigPayload = dict(defaults or {}) - if base_config is not None: + base_config_payload: ConfigPayload = {} + if crawl_config_base: + config_data.update(dict(crawl.config or {})) + elif base_config is not None: if isinstance(base_config, ArchiveBoxBaseConfig): - config_data.update(base_config.model_dump(mode="json")) + base_config_payload.update(base_config.model_dump(mode="json")) else: - config_data.update(dict(base_config)) + base_config_payload.update(dict(base_config)) + config_data.update(base_config_payload) else: config_data.update(ArchiveBoxConfig().model_dump(mode="json")) legacy_permissions = permissions_from_legacy_public_flags({**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **os.environ}) @@ -595,19 +719,20 @@ def get_config( scope_overrides: ConfigPayload = {} - if include_machine and machine is not None and machine.config: - from archivebox.machine.models import _sanitize_machine_config + if not crawl_config_base: + if include_machine and machine is not None and machine.config: + from archivebox.machine.models import _sanitize_machine_config - scope_overrides.update(_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR"))) + scope_overrides.update(_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR"))) - if persona is not None: - scope_overrides.update(persona.get_derived_config()) + if persona is not None: + scope_overrides.update(persona.get_derived_config()) - user_config = getattr(user, "config", None) - if user_config: - scope_overrides.update(user_config) + user_config = getattr(user, "config", None) + if user_config: + scope_overrides.update(user_config) - if crawl is not None and crawl.config: + if crawl is not None and crawl.config and not crawl_config_base: scope_overrides.update(crawl.config) if crawl is not None: @@ -638,13 +763,20 @@ def get_config( plugin_name: schema.get("properties", {}) for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items() if isinstance(schema, dict) } plugin_global_config = {key: str(value) if isinstance(value, Path) else value for key, value in config_data.items()} + plugin_user_config = _plugin_user_config(scope_overrides) + if not crawl_config_base: + plugin_user_config = {**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **plugin_user_config} plugin_sections = resolve_plugin_configs( plugin_schemas, global_config=plugin_global_config, - user_config={**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **_plugin_user_config(scope_overrides)}, + user_config=plugin_user_config, ) for plugin_config in plugin_sections.values(): config_data.update(plugin_config) + if base_config_payload: + config_data.update({key: value for key, value in base_config_payload.items() if key in _archivebox_config_input_names()}) + if crawl_config_base: + config_data.update(dict(crawl.config or {})) config_data.update(archivebox_scope_overrides) config_data["ABX_RUNTIME"] = "archivebox" @@ -673,6 +805,11 @@ def get_config( ) config = ArchiveBoxConfig.model_validate(config_data) + if redact_sensitive: + for key in type(config).model_fields: + value = getattr(config, key, None) + if is_sensitive_config_key(key) and value not in (None, ""): + setattr(config, key, SENSITIVE_CONFIG_VALUE_REDACTED) os.environ["LIB_DIR"] = str(config.LIB_DIR) os.environ["LIB_BIN_DIR"] = str(config.LIB_BIN_DIR) os.environ["ABXPKG_LIB_DIR"] = str(config.LIB_DIR) diff --git a/archivebox/config/ldap.py b/archivebox/config/ldap.py index 40218c5e..16d84ad2 100644 --- a/archivebox/config/ldap.py +++ b/archivebox/config/ldap.py @@ -1,6 +1,6 @@ __package__ = "archivebox.config" -from pydantic import Field +from pydantic import Field, PrivateAttr from archivebox.config.configset import BaseConfigSet @@ -14,6 +14,7 @@ class LDAPConfig(BaseConfigSet): """ toml_section_header: str = "LDAP_CONFIG" + _scope: str = PrivateAttr(default="server") LDAP_ENABLED: bool = Field(default=False) LDAP_SERVER_URI: str | None = Field(default=None) diff --git a/archivebox/config/views.py b/archivebox/config/views.py index 9ef6ea50..f95a0ae3 100644 --- a/archivebox/config/views.py +++ b/archivebox/config/views.py @@ -1,13 +1,9 @@ __package__ = "archivebox.config" -import html -import json import os import inspect -import re from pathlib import Path from typing import Any -from collections.abc import Callable from urllib.parse import quote, urlencode from django.http import HttpRequest from django.utils import timezone @@ -22,8 +18,6 @@ from archivebox.misc.util import parse_date from archivebox.machine.models import Binary -ABX_PLUGINS_DOCS_BASE_URL = "https://archivebox.github.io/abx-plugins/" -ABX_PLUGINS_GITHUB_BASE_URL = "https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/" LIVE_CONFIG_BASE_URL = "/admin/environment/config/" ENVIRONMENT_BINARIES_BASE_URL = "/admin/environment/binaries/" INSTALLED_BINARIES_BASE_URL = "/admin/machine/binary/" @@ -38,55 +32,6 @@ def format_parsed_datetime(value: object) -> str: return parsed.strftime("%Y-%m-%d %H:%M:%S") if parsed else "" -JSON_TOKEN_RE = re.compile( - r'(?P"(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*")(?=\s*:)' - r'|(?P"(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*")' - r"|(?P\btrue\b|\bfalse\b)" - r"|(?P\bnull\b)" - r"|(?P-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)", -) - - -def render_code_block(text: str, *, highlighted: bool = False) -> str: - code = html.escape(text, quote=False) - - if highlighted: - - def _wrap_token(match: re.Match[str]) -> str: - styles = { - "key": "color: #0550ae;", - "string": "color: #0a7f45;", - "boolean": "color: #8250df; font-weight: 600;", - "null": "color: #6e7781; font-style: italic;", - "number": "color: #b35900;", - } - token_type = next(name for name, value in match.groupdict().items() if value is not None) - return f'{match.group(0)}' - - code = JSON_TOKEN_RE.sub(_wrap_token, code) - - return ( - '
'
-        '"
-        f"{code}"
-        "
" - ) - - -def render_highlighted_json_block(value: Any) -> str: - return render_code_block(json.dumps(value, indent=2, ensure_ascii=False), highlighted=True) - - -def get_plugin_docs_url(plugin_name: str) -> str: - return f"{ABX_PLUGINS_DOCS_BASE_URL}#{plugin_name}" - - -def get_plugin_hook_source_url(plugin_name: str, hook_name: str) -> str: - return f"{ABX_PLUGINS_GITHUB_BASE_URL}{quote(plugin_name)}/{quote(hook_name)}" - - def get_live_config_url(key: str) -> str: return f"{LIVE_CONFIG_BASE_URL}{quote(key)}/" @@ -104,195 +49,6 @@ def get_installed_binary_change_url(name: str, binary: Binary | None) -> str | N return f"{base_url}?{urlencode({'_changelist_filters': changelist_filters})}" -def get_machine_admin_url() -> str | None: - try: - from archivebox.machine.models import Machine - - machine = Machine.current() - return getattr(machine, "admin_change_url", None) or f"/admin/machine/machine/{machine.id.hex}/change/" - except Exception: - return None - - -def render_code_tag_list(values: list[str]) -> str: - if not values: - return '(none)' - - tags = "".join( - str( - format_html( - '{}', - value, - ), - ) - for value in values - ) - return f'
{tags}
' - - -def render_plugin_metadata_html(config: dict[str, Any]) -> str: - required_binaries = [ - str(item.get("name")) for item in (config.get("required_binaries") or []) if isinstance(item, dict) and item.get("name") - ] - rows = ( - ("Title", config.get("title") or "(none)"), - ("Description", config.get("description") or "(none)"), - ("Required Plugins", mark_safe(render_link_tag_list(config.get("required_plugins") or [], get_plugin_docs_url))), - ("Required Binaries", mark_safe(render_link_tag_list(required_binaries, get_environment_binary_url))), - ("Output MIME Types", mark_safe(render_code_tag_list(config.get("output_mimetypes") or []))), - ) - - rendered_rows = "".join( - str( - format_html( - '
{}
{}
', - label, - value, - ), - ) - for label, value in rows - ) - return f'
{rendered_rows}
' - - -def render_link_tag_list(values: list[str], url_resolver: Callable[[str], str] | None = None) -> str: - if not values: - return '(none)' - - tags = [] - for value in values: - if url_resolver is None: - tags.append( - str( - format_html( - '{}', - value, - ), - ), - ) - else: - tags.append( - str( - format_html( - '' - '{}' - "", - url_resolver(value), - value, - ), - ), - ) - return f'
{"".join(tags)}
' - - -def render_property_links(prop_name: str, prop_info: dict[str, Any], machine_admin_url: str | None) -> str: - links = [ - str(format_html('Computed value', get_live_config_url(prop_name))), - ] - if machine_admin_url: - links.append(str(format_html('Edit override', machine_admin_url))) - - fallback = prop_info.get("x-fallback") - if isinstance(fallback, str) and fallback: - links.append(str(format_html('Fallback: {}', get_live_config_url(fallback), fallback))) - - aliases = prop_info.get("x-aliases") or [] - if isinstance(aliases, list): - for alias in aliases: - if isinstance(alias, str) and alias: - links.append(str(format_html('Alias: {}', get_live_config_url(alias), alias))) - - default = prop_info.get("default") - if prop_name.endswith("_BINARY") and isinstance(default, str) and default: - links.append(str(format_html('Binary: {}', get_environment_binary_url(default), default))) - - return "   ".join(links) - - -def render_config_properties_html(properties: dict[str, Any], machine_admin_url: str | None) -> str: - header_links = [ - str(format_html('Dependencies', ENVIRONMENT_BINARIES_BASE_URL)), - str(format_html('Installed Binaries', INSTALLED_BINARIES_BASE_URL)), - ] - if machine_admin_url: - header_links.insert(0, str(format_html('Machine Config Editor', machine_admin_url))) - - cards = [ - f'
{"   |   ".join(header_links)}
', - ] - - for prop_name, prop_info in properties.items(): - prop_type = prop_info.get("type", "unknown") - if isinstance(prop_type, list): - prop_type = " | ".join(str(type_name) for type_name in prop_type) - prop_desc = prop_info.get("description", "") - - default_html = "" - if "default" in prop_info: - default_html = str( - format_html( - '
Default: {}
', - prop_info["default"], - ), - ) - - description_html = prop_desc or mark_safe('(no description)') - cards.append( - str( - format_html( - '
' - '
' - '{}' - ' ({})' - "
" - '
{}
' - '
{}
' - "{}" - "
", - get_live_config_url(prop_name), - prop_name, - prop_type, - description_html, - mark_safe(render_property_links(prop_name, prop_info, machine_admin_url)), - mark_safe(default_html), - ), - ), - ) - - return "".join(cards) - - -def render_hook_links_html(plugin_name: str, hooks: list[str], source: str) -> str: - if not hooks: - return '(none)' - - items = [] - for hook_name in hooks: - if source == "builtin": - items.append( - str( - format_html( - '', - get_plugin_hook_source_url(plugin_name, hook_name), - hook_name, - ), - ), - ) - else: - items.append( - str( - format_html( - '
{}
', - hook_name, - ), - ), - ) - return "".join(items) - - def render_binary_detail_description(name: str, merged: dict[str, Any], db_binary: Any) -> str: installed_binary_url = get_installed_binary_change_url(name, db_binary) @@ -386,48 +142,6 @@ def get_db_binaries_by_name() -> dict[str, Binary]: return {name: max(records, key=_binary_sort_key) for name, records in grouped.items()} -def get_filesystem_plugins() -> dict[str, dict[str, Any]]: - """Discover plugins from filesystem directories.""" - import json - from archivebox.hooks import BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR - - plugins = {} - - for base_dir, source in [(BUILTIN_PLUGINS_DIR, "builtin"), (USER_PLUGINS_DIR, "user")]: - if not base_dir.exists(): - continue - - for plugin_dir in base_dir.iterdir(): - if plugin_dir.is_dir() and not plugin_dir.name.startswith("_"): - plugin_id = f"{source}.{plugin_dir.name}" - - # Find hook scripts - hooks = [] - for ext in ("sh", "py", "js"): - hooks.extend(plugin_dir.glob(f"on_*__*.{ext}")) - - # Load config.json if it exists - config_file = plugin_dir / "config.json" - config_data = None - if config_file.exists(): - try: - with open(config_file) as f: - config_data = json.load(f) - except (json.JSONDecodeError, OSError): - config_data = None - - plugins[plugin_id] = { - "id": plugin_id, - "name": plugin_dir.name, - "path": str(plugin_dir), - "source": source, - "hooks": [str(h.name) for h in hooks], - "config": config_data, - } - - return plugins - - @render_with_table_view def binaries_list_view(request: HttpRequest, **kwargs) -> TableContext: assert is_superuser(request), "Must be a superuser to view configuration settings." @@ -512,131 +226,6 @@ def binary_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: ) -@render_with_table_view -def plugins_list_view(request: HttpRequest, **kwargs) -> TableContext: - assert is_superuser(request), "Must be a superuser to view configuration settings." - - rows = { - "Name": [], - "Source": [], - "Path": [], - "Hooks": [], - "Config": [], - } - - plugins = get_filesystem_plugins() - - for plugin_id, plugin in plugins.items(): - rows["Name"].append(ItemLink(plugin["name"], key=plugin_id)) - rows["Source"].append(plugin["source"]) - rows["Path"].append(format_html("{}", plugin["path"])) - rows["Hooks"].append(", ".join(plugin["hooks"]) or "(none)") - - # Show config status - if plugin.get("config"): - config_properties = plugin["config"].get("properties", {}) - config_count = len(config_properties) - rows["Config"].append(f"✅ {config_count} properties" if config_count > 0 else "✅ present") - else: - rows["Config"].append("❌ none") - - if not plugins: - # Show a helpful message when no plugins found - rows["Name"].append("(no plugins found)") - rows["Source"].append("-") - rows["Path"].append(mark_safe("abx_plugins/plugins/ or data/custom_plugins/")) - rows["Hooks"].append("-") - rows["Config"].append("-") - - return TableContext( - title="Installed plugins", - table=rows, - ) - - -@render_with_item_view -def plugin_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: - assert is_superuser(request), "Must be a superuser to view configuration settings." - - plugins = get_filesystem_plugins() - - plugin = plugins.get(key) - if not plugin: - return ItemContext( - slug=key, - title=f"Plugin not found: {key}", - data=[], - ) - - # Base fields that all plugins have - docs_url = get_plugin_docs_url(plugin["name"]) - machine_admin_url = get_machine_admin_url() - fields = { - "id": plugin["id"], - "name": plugin["name"], - "source": plugin["source"], - } - - sections: list[SectionData] = [ - { - "name": plugin["name"], - "description": format_html( - '{}
ABX Plugin Docs', - plugin["path"], - docs_url, - ), - "fields": fields, - "help_texts": {}, - }, - ] - - if plugin["hooks"]: - sections.append( - { - "name": "Hooks", - "description": mark_safe(render_hook_links_html(plugin["name"], plugin["hooks"], plugin["source"])), - "fields": {}, - "help_texts": {}, - }, - ) - - if plugin.get("config"): - sections.append( - { - "name": "Plugin Metadata", - "description": mark_safe(render_plugin_metadata_html(plugin["config"])), - "fields": {}, - "help_texts": {}, - }, - ) - - sections.append( - { - "name": "config.json", - "description": mark_safe(render_highlighted_json_block(plugin["config"])), - "fields": {}, - "help_texts": {}, - }, - ) - - config_properties = plugin["config"].get("properties", {}) - if config_properties: - sections.append( - { - "name": "Config Properties", - "description": mark_safe(render_config_properties_html(config_properties, machine_admin_url)), - "fields": {}, - "help_texts": {}, - }, - ) - - return ItemContext( - slug=key, - title=plugin["name"], - data=sections, - ) - - @render_with_table_view def worker_list_view(request: HttpRequest, **kwargs) -> TableContext: assert is_superuser(request), "Must be a superuser to view configuration settings." diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py index 7de662bc..bf803abc 100644 --- a/archivebox/core/admin_archiveresults.py +++ b/archivebox/core/admin_archiveresults.py @@ -23,10 +23,10 @@ from archivebox.config import DATA_DIR from archivebox.config.common import get_config from archivebox.misc.paginators import AcceleratedPaginator from archivebox.base_models.admin import BaseModelAdmin -from archivebox.hooks import get_plugin_icon -from archivebox.core.host_util import build_snapshot_url +from archivebox.plugins.discovery import get_plugin_icon +from archivebox.plugins.views import LIVE_PLUGIN_BASE_URL +from archivebox.core.routes_util import build_snapshot_url from archivebox.core.widgets import InlineTagEditorWidget -from archivebox.core.views import LIVE_PLUGIN_BASE_URL from archivebox.machine.env_util import env_to_shell_exports @@ -62,7 +62,7 @@ def build_abx_dl_replay_command(result: ArchiveResult, config=None) -> str: def get_plugin_admin_url(plugin_name: str) -> str: - from archivebox.hooks import BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR, iter_plugin_dirs + from archivebox.plugins.discovery import BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR, iter_plugin_dirs plugin_dir = next((path.resolve() for path in iter_plugin_dirs() if path.name == plugin_name), None) if plugin_dir: diff --git a/archivebox/core/admin_snapshots.py b/archivebox/core/admin_snapshots.py index 985e0b04..c1a61b8a 100644 --- a/archivebox/core/admin_snapshots.py +++ b/archivebox/core/admin_snapshots.py @@ -1,43 +1,37 @@ __package__ = "archivebox.core" -import asyncio import json -import threading -from copy import copy from functools import lru_cache -from queue import Full, Queue from types import SimpleNamespace -from urllib.parse import urlsplit -from uuid import UUID from django.contrib import admin, messages -from django.contrib.admin.views.main import IncorrectLookupParameters from django.urls import path, reverse from django.shortcuts import get_object_or_404, redirect -from django.core.cache import cache -from django.core.paginator import InvalidPage -from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotAllowed, QueryDict, StreamingHttpResponse +from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotAllowed from django.utils import timezone from django.utils.html import format_html, format_html_join from django.utils.safestring import mark_safe -from django.db.models import Q, Count, Exists, F, IntegerField, OuterRef, Prefetch, Subquery +from django.db.models import Q, Count, Exists, OuterRef, Prefetch from django import forms from django.template import Template, RequestContext from django.contrib.admin.helpers import ActionForm from archivebox.config.common import get_config from archivebox.misc.util import htmldecode, urldecode -from archivebox.misc.paginators import AcceleratedPaginator, CountlessPaginator +from archivebox.misc.paginators import AcceleratedPaginator from archivebox.misc.logging_util import printable_filesize -from archivebox.search.admin import SEARCH_RESULT_CACHE_TTL, SearchResultsAdminMixin, SearchResultsChangeList, get_admin_search_cache_key -from archivebox.core.host_util import build_snapshot_url, build_web_url +from archivebox.search.admin import SearchResultsAdminMixin, SearchResultsChangeList +from archivebox.search.views import admin_snapshot_search_stream_view +from archivebox.core.routes_util import build_snapshot_url, build_web_url from archivebox.core.tag_util import get_or_create_tag -from archivebox.hooks import discover_hooks, get_plugin_icon, get_plugin_name, get_plugins +from archivebox.plugins.hooks import discover_hooks +from archivebox.plugins.discovery import get_plugin_icon, get_plugin_name, get_plugins from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin from archivebox.core.models import Tag, Snapshot, ArchiveResult from archivebox.core.admin_archiveresults import render_archiveresults_list +from archivebox.progressmonitor.views import progress_endpoint from archivebox.core.permissions import ( PERMISSIONS_CHOICES, PERMISSIONS_META, @@ -133,56 +127,6 @@ class SnapshotStatusListFilter(admin.SimpleListFilter): return queryset -class SnapshotDepthListFilter(admin.SimpleListFilter): - title = "depth" - parameter_name = "depth_bucket" - - def lookups(self, request, model_admin): - return ( - ("0", "0 root"), - ("1", "1"), - ("2", "2"), - ("3plus", "3+"), - ) - - def queryset(self, request, queryset): - value = self.value() - if value == "0": - return queryset.filter(depth=0) - if value == "1": - return queryset.filter(depth=1) - if value == "2": - return queryset.filter(depth=2) - if value == "3plus": - return queryset.filter(depth__gte=3) - return queryset - - -class SnapshotRelationListFilter(admin.SimpleListFilter): - title = "crawl position" - parameter_name = "position" - - def lookups(self, request, model_admin): - return ( - ("root", "Root URL"), - ("discovered", "Discovered URL"), - ("has_children", "Has discovered URLs"), - ("no_children", "No discovered URLs"), - ) - - def queryset(self, request, queryset): - value = self.value() - if value == "root": - return queryset.filter(parent_snapshot__isnull=True) - if value == "discovered": - return queryset.filter(parent_snapshot__isnull=False) - if value in {"has_children", "no_children"}: - child_snapshots = Snapshot.objects.filter(parent_snapshot_id=OuterRef("pk")) - queryset = queryset.annotate(has_child_snapshots=Exists(child_snapshots)) - return queryset.filter(has_child_snapshots=value == "has_children") - return queryset - - class SnapshotArchiveStateListFilter(admin.SimpleListFilter): title = "archive state" parameter_name = "archive_state" @@ -243,32 +187,9 @@ class SnapshotSizeListFilter(admin.SimpleListFilter): return queryset -class SnapshotRetryListFilter(admin.SimpleListFilter): - title = "retry" - parameter_name = "retry" - - def lookups(self, request, model_admin): - return ( - ("due", "Due now"), - ("future", "Scheduled later"), - ("none", "No retry time"), - ) - - def queryset(self, request, queryset): - value = self.value() - if value == "due": - return queryset.filter(retry_at__isnull=False, retry_at__lte=timezone.now()) - if value == "future": - return queryset.filter(retry_at__gt=timezone.now()) - if value == "none": - return queryset.filter(retry_at__isnull=True) - return queryset - - class SnapshotResultHealthListFilter(admin.SimpleListFilter): title = "ArchiveResult status" parameter_name = "archiveresult_status" - SNAPSHOT_FIRST_VALUES = {"succeeded"} def lookups(self, request, model_admin): return ( @@ -282,47 +203,6 @@ class SnapshotResultHealthListFilter(admin.SimpleListFilter): ("noresults", ">50% noresults"), ) - @staticmethod - def _snapshot_total_count_subquery(outer_ref: str = "pk"): - return ( - ArchiveResult.objects.filter(snapshot_id=OuterRef(outer_ref)) - .order_by() - .values("snapshot_id") - .annotate(count=Count("pk")) - .values("count") - ) - - @staticmethod - def _snapshot_status_count_subquery(status: str, outer_ref: str = "pk"): - return ( - ArchiveResult.objects.filter(snapshot_id=OuterRef(outer_ref), status=status) - .order_by() - .values("snapshot_id") - .annotate(count=Count("pk")) - .values("count") - ) - - def _filter_snapshot_first(self, queryset, status: str): - return queryset.annotate( - total_results=Subquery(self._snapshot_total_count_subquery(), output_field=IntegerField()), - matching_results=Subquery(self._snapshot_status_count_subquery(status), output_field=IntegerField()), - ).filter(matching_results__gt=F("total_results") / 2) - - def _filter_status_first(self, queryset, status: str): - total_results = self._snapshot_total_count_subquery("snapshot_id") - matching_snapshot_ids = ( - ArchiveResult.objects.filter(status=status) - .order_by() - .values("snapshot_id") - .annotate( - matching_results=Count("pk"), - total_results=Subquery(total_results, output_field=IntegerField()), - ) - .filter(matching_results__gt=F("total_results") / 2) - .values("snapshot_id") - ) - return queryset.filter(pk__in=matching_snapshot_ids) - def queryset(self, request, queryset): value = self.value() if value: @@ -340,10 +220,10 @@ class SnapshotResultHealthListFilter(admin.SimpleListFilter): "noresults": ArchiveResult.StatusChoices.NORESULTS, } if value in status_by_value: - status = status_by_value[value] - if value in self.SNAPSHOT_FIRST_VALUES: - return self._filter_snapshot_first(queryset, status) - return self._filter_status_first(queryset, status) + # Start from ArchiveResult.status for every majority-status filter. + # The (status, snapshot_id) index keeps this plan stable regardless + # of which status is most common in a user's collection. + return queryset.filter(pk__in=ArchiveResult.snapshot_ids_with_majority_status(status_by_value[value])) return queryset @@ -356,28 +236,7 @@ class SnapshotChangeList(SearchResultsChangeList): resolver_name == "grid" or request.path.rstrip("/").endswith("/grid") ) - def _uses_expensive_archiveresult_filter(self, request) -> bool: - return bool(request.GET.get(SnapshotResultHealthListFilter.parameter_name)) - def get_results(self, request): - if self._uses_expensive_archiveresult_filter(request): - paginator = CountlessPaginator(self.queryset, self.list_per_page) - try: - page = paginator.page(self.page_num) - except InvalidPage: - raise IncorrectLookupParameters - - self.result_count = paginator.count - self.show_full_result_count = False - self.show_admin_actions = True - self.full_result_count = None - self.result_list = page.object_list - self.can_show_all = False - self.multi_page = page.has_next() or self.page_num > 1 - self.paginator = paginator - self.show_search_index_hint = False - return - super().get_results(request) if request.GET.get("_embedded") == "crawl": self.full_result_count = self.result_count @@ -506,11 +365,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): SnapshotPermissionsListFilter, SnapshotStatusListFilter, SnapshotResultHealthListFilter, - SnapshotDepthListFilter, - SnapshotRelationListFilter, SnapshotArchiveStateListFilter, SnapshotSizeListFilter, - SnapshotRetryListFilter, "created_at", "downloaded_at", "crawl__created_by", @@ -592,7 +448,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): ) ordering = ["-timestamp"] - actions = ["add_tags", "remove_tags", "resnapshot_snapshot", "update_snapshots", "overwrite_snapshots", "delete_snapshots"] + actions = [ + "add_tags", + "remove_tags", + "resnapshot_snapshot", + "update_snapshots", + "overwrite_snapshots", + "set_snapshot_permissions", + "delete_snapshots", + ] inlines = [] # Removed TagInline, using TagEditorWidget instead list_per_page = 50 @@ -614,6 +478,14 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): request.archivebox_config = getattr(request, "archivebox_config", None) or get_config() extra_context = extra_context or {} extra_context["CONFIG"] = request.archivebox_config + snapshot = self.get_object(request, object_id) + if snapshot and snapshot.status in { + Snapshot.StatusChoices.QUEUED, + Snapshot.StatusChoices.STARTED, + Snapshot.StatusChoices.PAUSED, + }: + extra_context["progress_auto_expand"] = True + extra_context["progress_endpoint"] = progress_endpoint("snapshot", snapshot.id) return super().change_view(request, object_id, form_url, extra_context | GLOBAL_CONTEXT) def changelist_view(self, request, extra_context=None): @@ -679,155 +551,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): return custom_urls + urls def search_stream_view(self, request): - from archivebox.search import iter_query_search_ids - - query = (request.GET.get("q") or "").strip() - from archivebox.search import get_search_mode, get_search_mode_base - - search_mode = get_search_mode(request.GET.get("search_mode"), config=getattr(request, "archivebox_config", None)) - if not query: - return StreamingHttpResponse((), content_type="text/plain") - - search_url = request.GET.get("search_url") or request.get_full_path() - target_url = urlsplit(search_url) - target_get = QueryDict(target_url.query, mutable=True) - for key in ("q", "search_mode", "p", "search_url"): - target_get.pop(key, None) - - filter_request = copy(request) - filter_request.path = target_url.path or request.path - filter_request.path_info = target_url.path or request.path_info - filter_request.GET = target_get - filter_request.archivebox_config = getattr(request, "archivebox_config", None) - - # Build the same filtered base queryset the changelist uses, but with - # the search params stripped. The stream then intersects each wave with - # this queryset before writing IDs into the short-lived cache. - current_request = getattr(self, "request", None) - try: - base_queryset = self.get_changelist_instance(filter_request).queryset - finally: - self.request = current_request - - async def snapshot_ids(): - seen = set() - ids = [] - last_sent = 0 - stream_batch_size = 100 - stream_padding = " " * 4096 - cache_key = get_admin_search_cache_key(request, search_url) - cache.set(cache_key, {"ids": ids, "done": False}, SEARCH_RESULT_CACHE_TTL) - yield f"0{stream_padding}\n" - queue = Queue(maxsize=8) - stop_event = threading.Event() - - def emit(item): - while not stop_event.is_set(): - try: - queue.put(item, timeout=0.1) - return - except Full: - continue - - def run_search(): - nonlocal last_sent - iterator = None - try: - search_mode_base = get_search_mode_base(search_mode, config=getattr(request, "archivebox_config", None)) - iterator = ( - self.iter_meta_search_ids(query, base_queryset) - if search_mode_base == "meta" - else self.iter_backend_search_ids( - iter_query_search_ids(query, search_mode=search_mode, config=getattr(request, "archivebox_config", None)), - base_queryset, - ) - ) - for snapshot_id in iterator: - if stop_event.is_set(): - break - snapshot_id = str(snapshot_id).strip().lower() - if len(snapshot_id.replace("-", "")) != 32 or snapshot_id in seen: - continue - seen.add(snapshot_id) - ids.append(snapshot_id) - if len(ids) - last_sent >= stream_batch_size: - cache.set(cache_key, {"ids": ids, "done": False}, SEARCH_RESULT_CACHE_TTL) - last_sent = len(ids) - emit(f"{last_sent}{stream_padding}\n") - if not stop_event.is_set() and len(ids) != last_sent: - cache.set(cache_key, {"ids": ids, "done": False}, SEARCH_RESULT_CACHE_TTL) - emit(f"{len(ids)}{stream_padding}\n") - except BaseException as err: - emit(err) - finally: - if iterator is not None: - try: - iterator.close() - except AttributeError: - pass - cache.set(cache_key, {"ids": ids, "done": True}, SEARCH_RESULT_CACHE_TTL) - emit(None) - - threading.Thread(target=run_search, name="admin-snapshot-search-stream", daemon=True).start() - try: - while True: - item = await asyncio.to_thread(queue.get) - if item is None: - break - if isinstance(item, BaseException): - raise item - yield item - finally: - stop_event.set() - - response = StreamingHttpResponse(snapshot_ids(), content_type="text/plain") - response["X-Accel-Buffering"] = "no" - return response - - def iter_meta_search_ids(self, query, queryset): - seen = set() - try: - snapshot_id = UUID(query) - except ValueError: - snapshot_id = None - if snapshot_id: - for pk in queryset.filter(pk=snapshot_id).values_list("pk", flat=True): - seen.add(pk) - yield pk - - for wave in ( - Q(timestamp__startswith=query) | Q(url__istartswith=query) | Q(title__istartswith=query), - Q(url__icontains=query), - Q(title__icontains=query), - Q(tags__name__icontains=query), - ): - for pk in queryset.filter(wave).values_list("pk", flat=True).distinct().iterator(chunk_size=500): - if pk in seen: - continue - seen.add(pk) - yield pk - - def iter_backend_search_ids(self, iterator, queryset): - batch = [] - seen = set() - - def flush_batch(): - valid = {str(pk) for pk in queryset.filter(pk__in=batch).values_list("pk", flat=True)} - for snapshot_id in batch: - if snapshot_id in valid and snapshot_id not in seen: - seen.add(snapshot_id) - yield snapshot_id - - for snapshot_id in iterator: - snapshot_id = str(snapshot_id).strip().lower() - if len(snapshot_id.replace("-", "")) != 32: - continue - batch.append(snapshot_id) - if len(batch) >= 200: - yield from flush_batch() - batch = [] - if batch: - yield from flush_batch() + return admin_snapshot_search_stream_view(self, request) def set_permissions_view(self, request, object_id): if request.method != "POST": @@ -852,6 +576,43 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): icon, label, fg, bg = SNAPSHOT_PERMISSION_META[permissions] return JsonResponse({"permissions": permissions, "icon": icon, "label": label, "fg": fg, "bg": bg}) + @admin.action(description="Permissions ▾") + def set_snapshot_permissions(self, request, queryset): + permissions = (request.POST.get("permissions") or "").strip().lower() + if permissions not in dict(PERMISSIONS_CHOICES): + messages.error(request, "Choose a valid permissions value.") + return + updated = self.update_snapshot_permissions(queryset, permissions) + messages.success(request, f"Set permissions to {permissions} on {updated} snapshot(s).") + + def update_snapshot_permissions(self, queryset, permissions): + now = timezone.now() + updated = 0 + batch = [] + snapshots = ( + queryset.select_related(None) + .select_related("crawl") + .only("id", "config", "crawl__id", "crawl__permissions") + .prefetch_related(None) + ) + for snapshot in snapshots.iterator(chunk_size=500): + config = dict(snapshot.config or {}) + if permissions == snapshot.crawl.permissions: + config.pop("PERMISSIONS", None) + else: + config["PERMISSIONS"] = permissions + snapshot.config = config + snapshot.modified_at = now + batch.append(snapshot) + if len(batch) >= 500: + Snapshot.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500) + updated += len(batch) + batch.clear() + if batch: + Snapshot.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500) + updated += len(batch) + return updated + def redo_failed_view(self, request, object_id): snapshot = get_object_or_404(Snapshot, pk=object_id) @@ -870,12 +631,6 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): return redirect(snapshot.admin_change_url) - # def get_queryset(self, request): - # # tags_qs = SnapshotTag.objects.all().select_related('tag') - # # prefetch = Prefetch('snapshottag_set', queryset=tags_qs) - - # self.request = request - # return super().get_queryset(request).prefetch_related('archiveresult_set').distinct() # .annotate(archiveresult_count=Count('archiveresult')) def get_queryset(self, request): self.request = request ordering_fields = self._get_ordering_fields(request) @@ -924,10 +679,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): qs = qs.prefetch_related(*prefetches) if needs_files_sort: qs = qs.annotate( - ar_succeeded_count=Count( - "archiveresult", - filter=Q(archiveresult__status="succeeded"), - ), + ar_succeeded_count=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED), ) if needs_tags_sort: qs = qs.annotate(tag_count=Count("tags", distinct=True)) @@ -1322,33 +1074,6 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): ordering="ar_succeeded_count", ) def files(self, obj): - stats = self._get_progress_stats(obj) - if obj.status == Snapshot.StatusChoices.STARTED and stats["total"] > 0: - succeeded = stats["succeeded"] - failed = stats["failed"] - skipped = stats["skipped"] - completed = succeeded + failed + skipped + stats["noresults"] - return format_html( - """
-
- - {}/{} hooks -
-
-
-
-
- ✓{} ✗{} ⏳{} -
-
""", - completed, - stats["total"], - stats["percent"], - succeeded, - failed, - stats["running"], - ) - results = self._get_prefetched_results(obj) if results is None: results = obj.archiveresult_set.only("plugin", "status", "output_size") @@ -1576,8 +1301,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): crawl = getattr(obj, "crawl", None) snapshot_config = getattr(obj, "config", None) or {} crawl_config = getattr(crawl, "config", None) or {} - crawl_persona_id = getattr(crawl, "persona_id", None) - has_scoped_config = bool(snapshot_config or crawl_config or crawl_persona_id) + has_scoped_config = bool(snapshot_config or crawl_config) if request is not None and not has_scoped_config: cached_total = getattr(request, "archivebox_expected_snapshot_hook_total", None) @@ -1595,7 +1319,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): if snapshot_config: cache_key = ("snapshot", json.dumps(snapshot_config, sort_keys=True, default=str)) else: - cache_key = ("crawl", json.dumps(crawl_config, sort_keys=True, default=str), crawl_persona_id) + cache_key = ("crawl", json.dumps(crawl_config, sort_keys=True, default=str)) cached_total = scoped_cache.get(cache_key) if cached_total is None: config = get_config(crawl=crawl, snapshot=obj if snapshot_config else None) @@ -1702,7 +1426,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): # work-in-progress crawl, not the old snapshot they re-archived from. # A snapshot-view redirect would race the runner — the new snapshot # may sit queued for a while before the runner creates the DB row. - return redirect(f"/admin/crawls/crawl/{crawl.id.hex}/change/#snapshots") + return redirect(f"/admin/crawls/crawl/{crawl.id}/change/#snapshots") @admin.action( description="🔄 Redo", diff --git a/archivebox/core/admin_tags.py b/archivebox/core/admin_tags.py index 7c053b23..07eb7209 100644 --- a/archivebox/core/admin_tags.py +++ b/archivebox/core/admin_tags.py @@ -24,7 +24,7 @@ from archivebox.core.tag_util import ( normalize_has_snapshots_filter, normalize_tag_sort, ) -from archivebox.core.host_util import build_snapshot_url +from archivebox.core.routes_util import build_snapshot_url class TagInline(admin.TabularInline): diff --git a/archivebox/core/forms.py b/archivebox/core/forms.py index c452b3e4..57fc280d 100644 --- a/archivebox/core/forms.py +++ b/archivebox/core/forms.py @@ -1,14 +1,9 @@ __package__ = "archivebox.core" -import json import re -from collections.abc import Iterable, Mapping from decimal import Decimal, InvalidOperation, ROUND_CEILING -from pathlib import Path -from typing import Any from django import forms -from django.utils.html import format_html from archivebox.misc.util import URL_REGEX, find_all_urls, parse_filesize_to_bytes from taggit.utils import edit_string_for_tags, parse_tags @@ -17,7 +12,13 @@ from archivebox.crawls.schedule_util import validate_schedule from archivebox.config.common import get_config, parse_delete_after from archivebox.core.permissions import PERMISSIONS_CHOICES, PERMISSIONS_PUBLIC, filter_personas_by_permissions, is_admin_user from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget -from archivebox.hooks import get_plugins, discover_plugin_configs, get_plugin_icon +from archivebox.plugins.discovery import get_plugins +from archivebox.plugins.forms import ( + PLUGIN_GROUP_DEFINITIONS, + TIMEOUT_INPUT_PATTERN, + PluginConfigFormMixin, + get_choice_field, +) from archivebox.personas.models import Persona DEPTH_CHOICES = ( @@ -28,594 +29,6 @@ DEPTH_CHOICES = ( ("4", "depth = 4 (+ URLs four hops away)"), ) -PLUGIN_CONFIG_FIELD_PREFIX = "plugin_config__" -PLUGIN_GROUP_DEFINITIONS = ( - ( - "main_plugins", - "Main", - "", - "", - "", - ( - "dom", - "screenshot", - "pdf", - "singlefile", - "wget", - "archivedotorg", - "chrome_mhtml", - "archivewebpage", - ), - ), - ( - "page_setup_plugins", - "Page Setup", - "", - "", - "", - ( - "chrome", - "infiniscroll", - "modalcloser", - "ublock", - "istilldontcareaboutcookies", - "twocaptcha", - "claudechrome", - ), - ), - ( - "media_plugins", - "Media", - "", - "", - "", - ( - "staticfile", - "responses", - "chrome_screencast", - "ytdlp", - "gallerydl", - "git", - ), - ), - ( - "text_plugins", - "Text", - "", - "", - "", - ( - "readability", - "htmltotext", - "defuddle", - "forumdl", - "mercury", - "trafilatura", - "liteparse", - "opendataloader", - "papersdl", - ), - ), - ( - "metadata_plugins", - "Metadata", - "", - "", - "", - ( - "title", - "favicon", - "headers", - "redirects", - "accessibility", - "consolelog", - "sslcerts", - "dns", - "seo", - "hashes", - ), - ), - ( - "postprocessing_plugins", - "Postprocessing", - "", - "", - "", - ( - "parse_dom_outlinks", - "parse_html_urls", - "parse_jsonl_urls", - "parse_netscape_urls", - "parse_rss_urls", - "parse_txt_urls", - "claudecode", - "claudecodecleanup", - "claudecodeextract", - ), - ), -) -HIDDEN_PLUGIN_CONFIG_UI_PLUGINS = { - "apt", - "base", - "bash", - "brew", - "cargo", - "chromewebstore", - "env", - "media", - "npm", - "pip", - "puppeteer", - "search_backend_ripgrep", - "search_backend_sonic", - "search_backend_sqlite", - "ssl", -} -TIMEOUT_INPUT_PATTERN = r"(0|[1-9][0-9]*|[0-9]+(?:\.[0-9]+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours))" - - -def get_plugin_choices(): - """Get available extractor plugins from discovered hooks.""" - return [(name, name) for name in get_plugins()] - - -def get_plugin_choice_label(plugin_name: str, plugin_configs: dict[str, dict]) -> str: - schema = plugin_configs.get(plugin_name, {}) - description = str(schema.get("description") or "").strip() - if not description: - return plugin_name - icon_html = get_plugin_icon(plugin_name) - - return format_html( - '{}{}', - icon_html, - plugin_name, - ) - - -def get_choice_field(form: forms.Form, name: str) -> forms.ChoiceField: - field = form.fields[name] - if not isinstance(field, forms.ChoiceField): - raise TypeError(f"{name} must be a ChoiceField") - return field - - -def _plugin_config_input_name(plugin_name: str, config_key: str) -> str: - return f"{PLUGIN_CONFIG_FIELD_PREFIX}{plugin_name}__{config_key}" - - -def _schema_types(schema: Mapping[str, Any]) -> list[str]: - raw_type = schema.get("type") or "string" - if isinstance(raw_type, list): - return [str(item) for item in raw_type] - return [str(raw_type)] - - -def _jsonish(value: Any) -> str: - if isinstance(value, str): - return value - return json.dumps(value, sort_keys=True, default=str) - - -def _same_config_value(left: Any, right: Any) -> bool: - return json.dumps(left, sort_keys=True, default=str) == json.dumps(right, sort_keys=True, default=str) - - -def _coerce_plugin_config_value(raw_value: Any, schema: Mapping[str, Any]) -> Any: - schema_types = _schema_types(schema) - - if "boolean" in schema_types: - if isinstance(raw_value, bool): - return raw_value - value = str(raw_value).strip().lower() - if value in {"true", "1", "yes", "on"}: - return True - if value in {"false", "0", "no", "off", ""}: - return False - raise forms.ValidationError("Must be true or false.") - - if "integer" in schema_types: - value = int(str(raw_value).strip()) - minimum = schema.get("minimum") - maximum = schema.get("maximum") - if minimum is not None and value < int(minimum): - raise forms.ValidationError(f"Must be at least {minimum}.") - if maximum is not None and value > int(maximum): - raise forms.ValidationError(f"Must be at most {maximum}.") - return value - - if "number" in schema_types: - value = float(str(raw_value).strip()) - minimum = schema.get("minimum") - maximum = schema.get("maximum") - if minimum is not None and value < float(minimum): - raise forms.ValidationError(f"Must be at least {minimum}.") - if maximum is not None and value > float(maximum): - raise forms.ValidationError(f"Must be at most {maximum}.") - return value - - if "array" in schema_types: - if isinstance(raw_value, list): - return raw_value - value = str(raw_value).strip() - if not value: - return [] - if value.startswith("["): - parsed = json.loads(value) - if not isinstance(parsed, list): - raise forms.ValidationError("Must be a JSON array.") - return parsed - return [item.strip() for item in value.replace(",", "\n").splitlines() if item.strip()] - - if "object" in schema_types: - value = str(raw_value).strip() - if not value: - return {} - parsed = json.loads(value) - if not isinstance(parsed, dict): - raise forms.ValidationError("Must be a JSON object.") - return parsed - - value = str(raw_value) - enum = schema.get("enum") - if isinstance(enum, list) and enum and value not in {str(item) for item in enum}: - raise forms.ValidationError(f"Must be one of: {', '.join(str(item) for item in enum)}.") - return value - - -class PluginConfigFormMixin: - plugin_groups: list[dict[str, Any]] - - def build_plugin_groups(self, runtime_config: Mapping[str, Any] | None = None) -> None: - all_plugins = get_plugins() - plugin_configs = discover_plugin_configs() - runtime_config = runtime_config or get_config() - self.plugin_config_binary_urls = get_plugin_config_binary_urls(runtime_config) - grouped_plugins = set().union(*(group[-1] for group in PLUGIN_GROUP_DEFINITIONS)) - other_plugins = tuple(sorted(set(all_plugins) - grouped_plugins - HIDDEN_PLUGIN_CONFIG_UI_PLUGINS)) - - for field_name, *_rest, plugin_names in PLUGIN_GROUP_DEFINITIONS: - if field_name in self.fields: - get_choice_field(self, field_name).choices = [ - (p, get_plugin_choice_label(p, plugin_configs)) for p in plugin_names if p in all_plugins - ] - - if "other_plugins" in self.fields: - get_choice_field(self, "other_plugins").choices = [(p, get_plugin_choice_label(p, plugin_configs)) for p in other_plugins] - - group_specs = ( - *PLUGIN_GROUP_DEFINITIONS, - ("other_plugins", "Other", "", "", "", other_plugins), - ) - binary_url_lookup = _build_required_binary_url_lookup(plugin_configs, runtime_config) - self.plugin_groups = [ - { - "field_name": field_name, - "title": title, - "note": note, - "dom_id": dom_id, - "select_all_group": select_all_group, - "show_selectors": field_name in self.fields, - "plugins": self._build_plugin_cards(field_name, plugin_names, plugin_configs, runtime_config, binary_url_lookup), - } - for field_name, title, note, dom_id, select_all_group, plugin_names in group_specs - if any(plugin in all_plugins for plugin in plugin_names) - ] - - def _build_plugin_cards( - self, - field_name: str, - plugin_names: Iterable[str], - plugin_configs: dict[str, dict[str, Any]], - runtime_config: Mapping[str, Any], - binary_url_lookup: Mapping[str, str] | None = None, - ) -> list[dict[str, Any]]: - if field_name in self.fields: - choices = list(get_choice_field(self, field_name).choices) - selected_values = set(self.data.getlist(field_name)) if self.is_bound else set(get_choice_field(self, field_name).initial or []) - else: - all_plugins = get_plugins() - choices = [(p, get_plugin_choice_label(p, plugin_configs)) for p in plugin_names if p in all_plugins] - selected_values = set() - - cards = [] - for index, (plugin_name, label) in enumerate(choices): - schema = plugin_configs.get(str(plugin_name), {}) - properties = schema.get("properties") or {} - enabled_config_key = f"{str(plugin_name).upper()}_ENABLED" - enabled_prop_schema = properties.get(enabled_config_key) - if not isinstance(enabled_prop_schema, dict) or "boolean" not in _schema_types(enabled_prop_schema): - enabled_config_key = "" - config_fields = [ - self._build_plugin_config_field(str(plugin_name), str(config_key), prop_schema, runtime_config) - for config_key, prop_schema in properties.items() - if isinstance(prop_schema, dict) - ] - cards.append( - { - "name": str(plugin_name), - "label": label, - "checked": str(plugin_name) in selected_values, - "checkbox_id": f"id_{field_name}_{index}", - "enabled_config_key": enabled_config_key, - "description": str(schema.get("description") or "").strip(), - "source_url": f"https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/{plugin_name}", - "docs_url": f"https://archivebox.github.io/abx-plugins/#{plugin_name}", - "required_plugins": [str(item) for item in schema.get("required_plugins") or []], - "required_binary_links": _build_required_binary_links( - schema.get("required_binaries") or [], - runtime_config, - binary_url_lookup, - ), - "config_fields": config_fields, - "config_count": len(config_fields), - }, - ) - return cards - - def _build_plugin_config_field( - self, - plugin_name: str, - config_key: str, - prop_schema: Mapping[str, Any], - runtime_config: Mapping[str, Any], - ) -> dict[str, Any]: - schema_types = _schema_types(prop_schema) - enum = prop_schema.get("enum") - input_name = _plugin_config_input_name(plugin_name, config_key) - current_value = runtime_config.get(config_key, prop_schema.get("default", "")) - if self.is_bound and input_name in self.data: - try: - current_value = _coerce_plugin_config_value(self.data.get(input_name), prop_schema) - except (TypeError, ValueError, json.JSONDecodeError, forms.ValidationError): - current_value = self.data.get(input_name) - - default_value = prop_schema.get("default", "") - fallback_key = prop_schema.get("x-fallback") - default_display = f"{{{fallback_key}}}" if fallback_key else default_value - # A field is sensitive if either the schema explicitly marks it - # (``x-sensitive``) or the key name matches our credential heuristic - # (``*TOKEN*`` / ``*SECRET*`` / ``*API_KEY*`` / ``*APIKEY*``). The - # plugin grid lives on user-facing pages, so we redact the value, - # render a password input, and on empty submit we preserve the - # previously-saved value in ``clean_plugin_config_overrides`` below. - from archivebox.config.common import is_sensitive_config_key - - is_sensitive = bool(prop_schema.get("x-sensitive")) or is_sensitive_config_key(config_key) - input_value = "" if is_sensitive else _jsonish(current_value) - field_kind = "text" - input_type = "text" - options = [] - - if "boolean" in schema_types: - field_kind = "boolean" - input_value = "true" if bool(current_value) else "false" - elif isinstance(enum, list) and enum: - field_kind = "select" - options = [ - { - "value": str(option), - "label": str(option), - "selected": str(option) == str(current_value), - } - for option in enum - ] - elif "integer" in schema_types or "number" in schema_types: - field_kind = "number" - input_type = "number" - elif "array" in schema_types or "object" in schema_types: - field_kind = "json" - input_value = "" if is_sensitive else json.dumps(current_value, indent=2, sort_keys=True, default=str) - elif is_sensitive: - input_type = "password" - else: - input_value = "" if is_sensitive else str(current_value) - - return { - "key": config_key, - "input_name": input_name, - "kind": field_kind, - "input_type": input_type, - "value": input_value, - "checked": bool(current_value), - "options": options, - "description": str(prop_schema.get("description") or "").strip(), - "default": _jsonish(default_display), - "current": "configured" - if is_sensitive and current_value - else (str(current_value) if "string" in schema_types else _jsonish(current_value)), - "current_url": self.plugin_config_binary_urls.get(config_key, "") if str(config_key).endswith("_BINARY") else "", - "is_sensitive": is_sensitive, - "minimum": prop_schema.get("minimum"), - "maximum": prop_schema.get("maximum"), - "pattern": prop_schema.get("pattern"), - "type_label": " / ".join(schema_types), - } - - def clean_plugin_config_overrides(self, effective_config: Mapping[str, Any] | None = None) -> dict[str, Any]: - if not self.is_bound: - return {} - - effective_config = effective_config or get_config() - overrides: dict[str, Any] = {} - sources: dict[str, str] = {} - - for plugin_name, schema in discover_plugin_configs().items(): - for config_key, prop_schema in (schema.get("properties") or {}).items(): - if not isinstance(prop_schema, dict): - continue - - input_name = _plugin_config_input_name(plugin_name, config_key) - if input_name not in self.data: - continue - - raw_value: Any = self.data.get(input_name) - if "array" in _schema_types(prop_schema) and isinstance(prop_schema.get("enum"), list): - raw_value = self.data.getlist(input_name) - - from archivebox.config.common import is_sensitive_config_key - - if (prop_schema.get("x-sensitive") or is_sensitive_config_key(config_key)) and raw_value == "": - continue - - try: - coerced_value = _coerce_plugin_config_value(raw_value, prop_schema) - except (TypeError, ValueError, json.JSONDecodeError) as err: - self.add_error("config", forms.ValidationError(f"{config_key}: {err}")) - continue - except forms.ValidationError as err: - self.add_error("config", forms.ValidationError(f"{config_key}: {err.messages[0]}")) - continue - - base_value = effective_config.get(config_key, prop_schema.get("default", "")) - if _same_config_value(coerced_value, base_value): - continue - - existing_value = overrides.get(config_key) - if config_key in overrides and not _same_config_value(existing_value, coerced_value): - self.add_error( - "config", - forms.ValidationError( - f"{config_key} was set differently under {sources[config_key]} and {plugin_name}. Set it once in Custom config overrides.", - ), - ) - continue - - overrides[config_key] = coerced_value - sources[config_key] = plugin_name - - return overrides - - def plugin_config_keys(self) -> set[str]: - return { - str(config_key) - for schema in discover_plugin_configs().values() - for config_key, prop_schema in (schema.get("properties") or {}).items() - if isinstance(prop_schema, dict) - } - - -_BINARY_TEMPLATE_PATTERN = re.compile(r"\{([A-Z_][A-Z0-9_]*)\}") - - -def _resolve_required_binary_name(template_name: str, runtime_config: Mapping[str, Any]) -> str: - if "{" not in template_name: - return template_name - - def _replace(match: re.Match[str]) -> str: - key = match.group(1) - try: - value = runtime_config.get(key) - except Exception: - value = None - if value is None or value == "": - return match.group(0) - return str(value) - - resolved = _BINARY_TEMPLATE_PATTERN.sub(_replace, template_name).strip() - if not resolved: - return template_name - return Path(resolved).name if "/" in resolved else resolved - - -def _iter_required_binary_names( - required_binaries: Iterable[Any], - runtime_config: Mapping[str, Any], -) -> Iterable[str]: - for item in required_binaries or []: - if not isinstance(item, dict): - continue - raw_name = str(item.get("name") or "").strip() - if not raw_name: - continue - resolved = _resolve_required_binary_name(raw_name, runtime_config) - if resolved: - yield resolved - - -def _build_required_binary_url_lookup( - plugin_configs: Mapping[str, dict[str, Any]], - runtime_config: Mapping[str, Any], -) -> dict[str, str]: - """Resolve admin URLs for every required binary across all plugin schemas in a single DB query.""" - from archivebox.config.views import get_environment_binary_url, get_installed_binary_change_url - from archivebox.machine.models import Binary, Machine - - resolved_names: set[str] = set() - for schema in plugin_configs.values(): - for name in _iter_required_binary_names(schema.get("required_binaries") or [], runtime_config): - resolved_names.add(name) - - if not resolved_names: - return {} - - machine = Machine.current() - name_to_binary: dict[str, Binary] = {} - for binary in ( - Binary.objects.filter(machine=machine, name__in=resolved_names) - .exclude(abspath="") - .exclude(abspath__isnull=True) - .order_by("-modified_at") - ): - key = binary.name.lower() - if key not in name_to_binary: - name_to_binary[key] = binary - - return { - name: (get_installed_binary_change_url(name, name_to_binary.get(name.lower())) or get_environment_binary_url(name)) - for name in resolved_names - } - - -def _build_required_binary_links( - required_binaries: list[dict[str, Any]], - runtime_config: Mapping[str, Any], - binary_url_lookup: Mapping[str, str] | None = None, -) -> list[dict[str, str]]: - from archivebox.config.views import get_environment_binary_url - - links: list[dict[str, str]] = [] - seen: set[str] = set() - for resolved in _iter_required_binary_names(required_binaries, runtime_config): - if resolved in seen: - continue - seen.add(resolved) - url = (binary_url_lookup or {}).get(resolved) or get_environment_binary_url(resolved) - links.append({"name": resolved, "url": url}) - return links - - -def get_plugin_config_binary_urls(runtime_config: Mapping[str, Any]) -> dict[str, str]: - from archivebox.config.views import get_environment_binary_url, get_installed_binary_change_url - from archivebox.machine.models import Binary, Machine - - binary_keys = { - str(config_key) - for schema in discover_plugin_configs().values() - for config_key, prop_schema in (schema.get("properties") or {}).items() - if isinstance(prop_schema, dict) and str(config_key).endswith("_BINARY") - } - urls: dict[str, str] = {} - machine = Machine.current() - for key in binary_keys: - value = str(runtime_config.get(key) or "").strip() - if not value: - continue - name = Path(value).name if "/" in value else value - binary = Binary.objects.get_valid_binary(value, machine=machine) - if binary is None and "/" in value: - binary = ( - Binary.objects.exclude(abspath="") - .exclude(abspath__isnull=True) - .filter(machine=machine, abspath=value) - .order_by("-modified_at") - .first() - ) - if binary is None and name != value: - binary = Binary.objects.get_valid_binary(name, machine=machine) - urls[key] = get_installed_binary_change_url(getattr(binary, "name", name), binary) or get_environment_binary_url(name) - return urls - class AddLinkForm(PluginConfigFormMixin, forms.Form): # Basic fields diff --git a/archivebox/core/middleware.py b/archivebox/core/middleware.py index 85e6e47d..cfe7b770 100644 --- a/archivebox/core/middleware.py +++ b/archivebox/core/middleware.py @@ -15,7 +15,7 @@ from django.http import HttpResponseForbidden, HttpResponseNotModified from archivebox.config.common import get_config from archivebox.config import VERSION from archivebox.config.version import get_COMMIT_HASH -from archivebox.core.host_util import ( +from archivebox.core.routes_util import ( build_snapshot_url, build_admin_url, build_web_url, diff --git a/archivebox/core/models.py b/archivebox/core/models.py index f1250b26..33ee9e46 100755 --- a/archivebox/core/models.py +++ b/archivebox/core/models.py @@ -3,7 +3,7 @@ __package__ = "archivebox.core" from typing import TYPE_CHECKING, Optional, Any from collections.abc import Iterable, Sequence import uuid -from archivebox.uuid_compat import uuid7 +from archivebox.uuid_compat import CompactUUIDField, uuid7 from datetime import datetime, timedelta import os @@ -15,7 +15,7 @@ from statemachine import State, registry from django.db import models, transaction from django.db.models import Case, Q, QuerySet, Sum, Value, When -from django.db.models.functions import Concat +from django.db.models.functions import Coalesce, Concat from django.db.models.fields.json import KT from django.utils.functional import cached_property from django.utils.text import slugify @@ -41,7 +41,7 @@ from archivebox.misc.util import ( urldecode, validate_url_length, ) -from archivebox.hooks import ( +from archivebox.plugins.discovery import ( get_plugins, get_plugin_name, get_plugin_icon, @@ -276,18 +276,6 @@ class SnapshotQuerySet(models.QuerySet): raise SystemExit(2) return self.filter(q_filter) - def search(self, patterns: list[str]) -> "SnapshotQuerySet": - """Search snapshots using the configured search backend""" - from archivebox.search import query_search_index - - qsearch = self.none() - for pattern in patterns: - try: - qsearch |= query_search_index(pattern) - except BaseException: - raise SystemExit(2) - return self.all() & qsearch - # ========================================================================= # Export Methods # ========================================================================= @@ -397,7 +385,7 @@ class SnapshotManager(models.Manager.from_queryset(SnapshotQuerySet)): # ty: ig class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithNotes, ModelWithHealthStats, ModelWithStateMachine): - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) modified_at = models.DateTimeField(auto_now=True) @@ -459,6 +447,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW StatusChoices = ModelWithStateMachine.StatusChoices active_state = StatusChoices.STARTED delete_after_final_statuses = (StatusChoices.SEALED,) + RUNNABLE_STATES = (StatusChoices.QUEUED, StatusChoices.STARTED) + OPEN_STATES = (*RUNNABLE_STATES, StatusChoices.PAUSED) crawl_id: uuid.UUID parent_snapshot_id: uuid.UUID | None @@ -492,6 +482,43 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW def __str__(self): return f"[{self.id}] {self.url[:64]}" + @classmethod + def crawl_count_subquery(cls, *, status: str | None = None, outer_ref: str = "pk") -> QuerySet: + """Return a scalar subquery counting Snapshots for one outer Crawl.""" + qs = cls.objects.filter(crawl_id=models.OuterRef(outer_ref)) + if status is not None: + qs = qs.filter(status=status) + return qs.order_by().values("crawl_id").annotate(count=models.Count("pk")).values("count") + + @classmethod + def crawl_count_expr(cls, *, status: str | None = None, outer_ref: str = "pk"): + # Use scalar subqueries for sortable Crawl admin counters: SQLite can + # probe the (crawl_id, status, modified_at) index per Crawl row instead + # of joining/grouping all visible Snapshot rows. + return Coalesce( + models.Subquery(cls.crawl_count_subquery(status=status, outer_ref=outer_ref), output_field=models.IntegerField()), + models.Value(0), + ) + + @classmethod + def crawl_total_and_status_counts(cls, crawl_ids: Iterable[Any], *, status: str) -> dict[str, dict[str, int]]: + """Return total and status-filtered Snapshot counts keyed by Crawl ID.""" + crawl_ids = list(crawl_ids) + if not crawl_ids: + return {} + return { + str(row["crawl_id"]): { + "total": row["total"], + "status": row["status_count"], + } + for row in cls.objects.filter(crawl_id__in=crawl_ids) + .values("crawl_id") + .annotate( + total=models.Count("pk"), + status_count=models.Count("pk", filter=Q(status=status)), + ) + } + def update_and_requeue(self, **kwargs) -> bool: """ Update this Snapshot through the shared retry_at ownership path. @@ -636,11 +663,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW @classmethod def missing_delete_at_candidates(cls): - from archivebox.personas.models import Persona - - persona_ids = Persona.objects.filter(config__has_key="DELETE_AFTER").values_list("id", flat=True) return cls.objects.filter(delete_at__isnull=True).filter( - Q(config__has_key="DELETE_AFTER") | Q(crawl__config__has_key="DELETE_AFTER") | Q(crawl__persona_id__in=persona_ids), + Q(config__has_key="DELETE_AFTER") | Q(crawl__config__has_key="DELETE_AFTER"), ) @classmethod @@ -649,7 +673,14 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW if parsed.scheme not in ("http", "https") or not parsed.hostname: return False - from archivebox.core.host_util import get_admin_host, get_api_host, get_listen_host, get_public_host, get_web_host, split_host_port + from archivebox.core.routes_util import ( + get_admin_host, + get_api_host, + get_listen_host, + get_public_host, + get_web_host, + split_host_port, + ) config = get_config() host = parsed.hostname.lower().strip(".") @@ -742,10 +773,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW if update_fields is not None: kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "fs_version", "modified_at"])) elif self.pk: - legacy_dir = get_config().ARCHIVE_DIR / self.timestamp current_dir = self.get_storage_path_for_version(self._fs_current_version()) - if legacy_dir.exists() and not legacy_dir.is_symlink() and current_dir.exists() and legacy_dir != current_dir: - self.migrate_filesystem_to_current_version(source_dir=legacy_dir) + source_dir = Path(self.output_dir) + if source_dir.exists() and source_dir != current_dir and not source_dir.is_symlink(): + self.migrate_filesystem_to_current_version(source_dir=source_dir) super().save(*args, **kwargs) @@ -814,17 +845,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW @staticmethod def _fs_current_version() -> str: - """Get current ArchiveBox filesystem version (normalized to x.x.0 format)""" - from archivebox.config import VERSION - - # Normalize version to x.x.0 format (e.g., "0.9.0rc1" -> "0.9.0") - parts = VERSION.split(".") - if len(parts) >= 2: - major, minor = parts[0], parts[1] - # Strip any non-numeric suffix from minor version - minor = "".join(c for c in minor if c.isdigit()) - return f"{major}.{minor}.0" - return "0.9.0" # Fallback if version parsing fails + """Get current ArchiveBox filesystem layout version.""" + return "0.9.4" @property def fs_migration_needed(self) -> bool: @@ -836,6 +858,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW # Treat 0.7.0 and 0.8.0 as equivalent (both used archive/{timestamp}) if version in ("0.7.0", "0.8.0"): return "0.9.0" + if version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3"): + return "0.9.4" return self._fs_current_version() @staticmethod @@ -868,6 +892,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW if source_dir and current == target: current_dir = self.get_storage_path_for_version(target, config=runtime_config) cleanup = self._fs_migrate_legacy_to_0_9_0(source_dir=source_dir, target_dir=current_dir) + crawl_dir = self.crawl.output_dir_for_config(runtime_config) + old_crawl_dir = crawl_dir.with_name(str(uuid.UUID(hex=self.crawl.id.hex))) + if old_crawl_dir.exists() and not crawl_dir.exists() and not old_crawl_dir.is_symlink(): + crawl_dir.parent.mkdir(parents=True, exist_ok=True) + old_crawl_dir.rename(crawl_dir) if cleanup: self._pending_fs_migration_cleanup = cleanup return @@ -877,6 +906,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW migrations = { ("0.7.0", "0.9.0"): self._fs_migrate_from_0_7_0_to_0_9_0, ("0.8.0", "0.9.0"): self._fs_migrate_from_0_8_0_to_0_9_0, + ("0.9.0", "0.9.4"): self._fs_migrate_from_0_9_0_to_0_9_4, + ("0.9.1", "0.9.4"): self._fs_migrate_from_0_9_0_to_0_9_4, + ("0.9.2", "0.9.4"): self._fs_migrate_from_0_9_0_to_0_9_4, + ("0.9.3", "0.9.4"): self._fs_migrate_from_0_9_0_to_0_9_4, } migration = migrations.get((current, next_ver)) @@ -897,6 +930,17 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW def _fs_migrate_from_0_8_0_to_0_9_0(self, source_dir: Path | None = None, config: "ArchiveBoxBaseConfig | None" = None): return self._fs_migrate_legacy_to_0_9_0(source_dir=source_dir, config=config) + def _fs_migrate_from_0_9_0_to_0_9_4(self, source_dir: Path | None = None, config: "ArchiveBoxBaseConfig | None" = None): + runtime_config = config or get_config() + target_dir = self.get_storage_path_for_version("0.9.4", config=runtime_config) + cleanup = self._fs_migrate_legacy_to_0_9_0(source_dir=source_dir or self.output_dir, target_dir=target_dir, config=runtime_config) + crawl_dir = self.crawl.output_dir_for_config(runtime_config) + old_crawl_dir = crawl_dir.with_name(str(uuid.UUID(hex=self.crawl.id.hex))) + if old_crawl_dir.exists() and not crawl_dir.exists() and not old_crawl_dir.is_symlink(): + crawl_dir.parent.mkdir(parents=True, exist_ok=True) + old_crawl_dir.rename(crawl_dir) + return cleanup + def _fs_migrate_legacy_to_0_9_0( self, source_dir: Path | None = None, @@ -1056,7 +1100,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW if version in ("0.7.0", "0.8.0"): return runtime_config.ARCHIVE_DIR / self.timestamp - elif version in ("0.9.0", "1.0.0"): + elif version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"): username = self.created_by.username date_base = self.bookmarked_at or self.created_at @@ -2055,7 +2099,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW @property def api_url(self) -> str: - return str(reverse_lazy("api-1:get_snapshot", args=[self.id.hex])) + return str(reverse_lazy("api-1:get_snapshot", args=[self.id])) def get_absolute_url(self): return f"/{self.archive_path}" @@ -2162,6 +2206,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW if current_path.exists(): return current_path + if self.fs_version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"): + hyphen_path = current_path.with_name(str(uuid.UUID(hex=self.id.hex))) + if hyphen_path.exists(): + return hyphen_path + # Check for backwards-compat symlink old_path = runtime_config.ARCHIVE_DIR / self.timestamp if old_path.is_symlink(): @@ -2250,7 +2299,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW if self.fs_version in ("0.7.0", "0.8.0"): return self.legacy_archive_path - if self.fs_version in ("0.9.0", "1.0.0"): + if self.fs_version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"): username = "web" crawl = getattr(self, "crawl", None) if crawl and getattr(crawl, "created_by_id", None): @@ -2272,7 +2321,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW @cached_property def url_path(self) -> str: """URL path matching the current snapshot output_dir layout.""" - if self.fs_version in ("0.9.0", "1.0.0"): + if self.fs_version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"): return self.archive_path_from_db output_dir = Path(self.output_dir).resolve() @@ -2290,7 +2339,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW username = "web" date_str = parts[2] domain = parts[3] - snapshot_id = parts[4] + snapshot_id = parts[4].replace("-", "") return f"{username}/{date_str}/{domain}/{snapshot_id}" try: @@ -2311,7 +2360,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW username = "web" date_str = parts[4] domain = parts[5] - snapshot_id = parts[6] + snapshot_id = parts[6].replace("-", "") return f"{username}/{date_str}/{domain}/{snapshot_id}" # Previous dev layout: users//snapshots//// @@ -2321,7 +2370,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW username = "web" date_str = parts[3] domain = parts[4] - snapshot_id = parts[5] + snapshot_id = parts[5].replace("-", "") return f"{username}/{date_str}/{domain}/{snapshot_id}" # Legacy layout: archive// @@ -2614,7 +2663,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW Creates one ArchiveResult per hook (not per plugin), with hook_name set. This enables step-based execution where all hooks in a step can run in parallel. """ - from archivebox.hooks import discover_hooks + from archivebox.plugins.hooks import discover_hooks from archivebox.config.common import get_config # Get merged config with crawl-specific PLUGINS filter @@ -2673,12 +2722,21 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW results = self.archiveresult_set.all() - # Count by status - succeeded = results.filter(status="succeeded").count() - failed = results.filter(status="failed").count() - running = results.filter(status="started").count() - skipped = results.filter(status="skipped").count() - noresults = results.filter(status="noresults").count() + counts = ArchiveResult.status_counts( + results, + ( + ArchiveResult.StatusChoices.SUCCEEDED, + ArchiveResult.StatusChoices.FAILED, + ArchiveResult.StatusChoices.STARTED, + ArchiveResult.StatusChoices.SKIPPED, + ArchiveResult.StatusChoices.NORESULTS, + ), + ) + succeeded = counts.get(ArchiveResult.StatusChoices.SUCCEEDED, 0) + failed = counts.get(ArchiveResult.StatusChoices.FAILED, 0) + running = counts.get(ArchiveResult.StatusChoices.STARTED, 0) + skipped = counts.get(ArchiveResult.StatusChoices.SKIPPED, 0) + noresults = counts.get(ArchiveResult.StatusChoices.NORESULTS, 0) total = results.count() pending = total - succeeded - failed - running - skipped - noresults @@ -2858,7 +2916,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW def latest_outputs(self, status: str | None = None) -> dict[str, Any]: """Get the latest output that each plugin produced""" - from archivebox.hooks import get_plugins + from archivebox.plugins.discovery import get_plugins from django.db.models import Q latest: dict[str, Any] = {} @@ -3054,7 +3112,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW def to_dict(self, extended: bool = False) -> dict[str, Any]: """Convert Snapshot to a dictionary (replacement for Link._asdict())""" - from archivebox.core.host_util import build_snapshot_url + from archivebox.core.routes_util import build_snapshot_url archive_size = self.archive_size @@ -3453,8 +3511,58 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M plugins = [get_plugin_name(e) for e in get_plugins()] return tuple((e, e) for e in plugins) + @classmethod + def snapshot_count_subquery(cls, *, status: str | None = None, outer_ref: str = "pk") -> QuerySet: + """Return a scalar subquery counting ArchiveResults for one outer Snapshot. + + Use this instead of filtered join aggregates for per-row Snapshot counts: + the scalar form lets SQLite probe the covering ``(snapshot_id, status)`` + or ``(status, snapshot_id)`` indexes once per visible Snapshot row, + instead of joining and grouping the whole candidate Snapshot queryset. + """ + qs = cls.objects.filter(snapshot_id=models.OuterRef(outer_ref)) + if status is not None: + qs = qs.filter(status=status) + return qs.order_by().values("snapshot_id").annotate(count=models.Count("pk")).values("count") + + @classmethod + def snapshot_count_expr(cls, *, status: str | None = None, outer_ref: str = "pk"): + return Coalesce( + models.Subquery(cls.snapshot_count_subquery(status=status, outer_ref=outer_ref), output_field=models.IntegerField()), + models.Value(0), + ) + + @classmethod + def status_counts(cls, queryset: QuerySet | None = None, statuses: Iterable[str] | None = None) -> dict[str, int]: + """Count requested statuses with separate indexed COUNT probes.""" + qs = queryset if queryset is not None else cls.objects.all() + return {status: qs.filter(status=status).count() for status in (statuses or cls.StatusChoices.values)} + + @classmethod + def snapshot_ids_with_majority_status(cls, status: str) -> QuerySet: + """Return Snapshot IDs where more than half of ArchiveResults have ``status``. + + Start from ArchiveResult.status for every majority-status filter. The + ``(status, snapshot_id)`` index keeps the plan predictable even when a + user's collection has an unusual status distribution. + """ + return ( + cls.objects.filter(status=status) + .order_by() + .values("snapshot_id") + .annotate( + matching_results=models.Count("pk"), + total_results=models.Subquery( + cls.snapshot_count_subquery(outer_ref="snapshot_id"), + output_field=models.IntegerField(), + ), + ) + .filter(matching_results__gt=models.F("total_results") / 2) + .values("snapshot_id") + ) + # UUID primary key (migrated from integer in 0029) - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) modified_at = models.DateTimeField(auto_now=True) @@ -3540,14 +3648,10 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M @classmethod def missing_delete_at_candidates(cls): - from archivebox.personas.models import Persona - - persona_ids = Persona.objects.filter(config__has_key="DELETE_AFTER").values_list("id", flat=True) return cls.objects.filter(delete_at__isnull=True).filter( Q(config__has_key="DELETE_AFTER") | Q(snapshot__config__has_key="DELETE_AFTER") - | Q(snapshot__crawl__config__has_key="DELETE_AFTER") - | Q(snapshot__crawl__persona_id__in=persona_ids), + | Q(snapshot__crawl__config__has_key="DELETE_AFTER"), ) @property @@ -4209,7 +4313,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M from pathlib import Path from django.utils import timezone from abx_dl.output_files import guess_mimetype - from archivebox.hooks import process_hook_records, extract_records_from_process + from archivebox.plugins.hooks import process_hook_records, extract_records_from_process from archivebox.machine.models import Process plugin_dir = Path(self.pwd) if self.pwd else None @@ -4259,7 +4363,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M # No ArchiveResult record: treat background hooks or clean exits as skipped is_background = False try: - from archivebox.hooks import is_background_hook + from archivebox.plugins.hooks import is_background_hook is_background = bool(self.hook_name and is_background_hook(self.hook_name)) except Exception: diff --git a/archivebox/core/recovery_util.py b/archivebox/core/recovery_util.py index 183ec84b..725f5d7f 100644 --- a/archivebox/core/recovery_util.py +++ b/archivebox/core/recovery_util.py @@ -34,7 +34,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int ) active_child_snapshots = Snapshot.objects.filter( crawl_id=OuterRef("pk"), - status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.PAUSED], + status__in=Snapshot.OPEN_STATES, ) due_child_snapshots = active_child_snapshots.exclude(status=Snapshot.StatusChoices.PAUSED).filter( Q(retry_at__isnull=True) | Q(retry_at__lte=now), @@ -52,7 +52,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int cleaned["snapshots_queued_without_retry_at"] = Snapshot.objects.filter( status=Snapshot.StatusChoices.QUEUED, retry_at__isnull=True, - crawl__status__in=[Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED], + crawl__status__in=Crawl.RUNNABLE_STATES, ).update(retry_at=now, modified_at=now) backoff_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.BACKOFF) orphaned_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.STARTED).exclude( diff --git a/archivebox/core/host_util.py b/archivebox/core/routes_util.py similarity index 99% rename from archivebox/core/host_util.py rename to archivebox/core/routes_util.py index 82496405..7576d733 100644 --- a/archivebox/core/host_util.py +++ b/archivebox/core/routes_util.py @@ -257,6 +257,8 @@ def get_snapshot_lookup_key(snapshot_ref: str) -> str: match = _SNAPSHOT_SUBDOMAIN_RE.match(value) if match: return match.group("suffix") + if _SNAPSHOT_ID_RE.match(value): + return re.sub(r"[^0-9a-fA-F]", "", value).lower() return value diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py index dd74fbf1..0e97b412 100644 --- a/archivebox/core/settings.py +++ b/archivebox/core/settings.py @@ -13,7 +13,7 @@ import archivebox from archivebox.config.constants import CONSTANTS from archivebox.config.common import get_config -from archivebox.core.host_util import normalize_base_url, get_admin_base_url, get_api_base_url +from archivebox.core.routes_util import normalize_base_url, get_admin_base_url, get_api_base_url from .settings_logging import SETTINGS_LOGGING @@ -63,14 +63,15 @@ INSTALLED_APPS = [ # Our ArchiveBox-provided apps (use fully qualified names) # NOTE: Order matters! Apps with migrations that depend on other apps must come AFTER their dependencies # "archivebox.config", # ArchiveBox config settings (no models, not a real Django app) + "archivebox.plugins", # plugin discovery, hook helpers, config UI, and plugin metadata views + "archivebox.search", # search backend query helpers, admin search UI, and daemon integrations "archivebox.machine", # handles collecting and storing information about the host machine, network interfaces, binaries, etc. "archivebox.workers", # handles starting and managing background workers and processes (orchestrators and actors) "archivebox.personas", # handles Persona and session management "archivebox.core", # core django model with Snapshot, ArchiveResult, etc. (crawls depends on this) "archivebox.crawls", # handles Crawl and CrawlSchedule models and management (depends on core) + "archivebox.progressmonitor", # live progress endpoint and admin monitor template "archivebox.api", # Django-Ninja-based Rest API interfaces, config, APIToken model, etc. - # ArchiveBox plugins (hook-based plugins no longer add Django apps) - # Use hooks.py discover_hooks() for plugin functionality # 3rd-party apps from PyPI that need to be loaded last "admin_data_views", # handles rendering some convenient automatic read-only views of data in Django admin "django_extensions", # provides Django Debug Toolbar (and other non-debug helpers) @@ -275,7 +276,7 @@ MIGRATION_MODULES = {"signal_webhooks": None} # Django requires DEFAULT_AUTO_FIELD to subclass AutoField (BigAutoField, SmallAutoField, etc.) # Cannot use UUIDField here until Django 6.0 introduces DEFAULT_PK_FIELD setting -# For now: manually add `id = models.UUIDField(primary_key=True, default=uuid7, ...)` to all models +# For now: manually add `id = CompactUUIDField(primary_key=True, default=uuid7, ...)` to all models # OR inherit from ModelWithUUID base class which provides UUID primary key DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" @@ -418,6 +419,8 @@ SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" CSRF_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_NAME = f"archivebox_sessionid_{CONSTANTS.COLLECTION_ID}" +CSRF_COOKIE_NAME = f"archivebox_csrftoken_{CONSTANTS.COLLECTION_ID}" # Auth cookies are intentionally scoped to the exact host that set them so # the admin session is NOT readable from public.* / web.* / api.* — that # split is a security boundary, not a UX choice. Subdomains that need to @@ -530,11 +533,11 @@ ADMIN_DATA_VIEWS = { }, { "route": "plugins/", - "view": "archivebox.config.views.plugins_list_view", + "view": "archivebox.plugins.views.plugins_list_view", "name": "Plugins", "items": { "route": "/", - "view": "archivebox.config.views.plugin_detail_view", + "view": "archivebox.plugins.views.plugin_detail_view", "name": "plugin", }, }, diff --git a/archivebox/core/settings_logging.py b/archivebox/core/settings_logging.py index 957d2f2a..5613451f 100644 --- a/archivebox/core/settings_logging.py +++ b/archivebox/core/settings_logging.py @@ -7,6 +7,7 @@ import logging from archivebox.config import CONSTANTS +from archivebox.misc.logging import STDERR IGNORABLE_URL_PATTERNS = [ @@ -182,6 +183,7 @@ SETTINGS_LOGGING = { "level": "DEBUG", "markup": False, "rich_tracebacks": False, # Use standard Python tracebacks (no frame/box) + "console": STDERR, "filters": ["noisyrequestsfilter", "daphneclosetimeout", "asynciocancelledshield", "stripansi"], }, "logfile": { diff --git a/archivebox/core/sqlite_backend/base.py b/archivebox/core/sqlite_backend/base.py index 8659bb4c..731ef077 100644 --- a/archivebox/core/sqlite_backend/base.py +++ b/archivebox/core/sqlite_backend/base.py @@ -45,26 +45,14 @@ def _format_sql(query: str, params=None) -> str: def _log_locked_database(query: str, params=None, *, attempt: int, elapsed: float, retry_interval: float) -> None: from rich.console import Console - from archivebox.misc.db import sqlite_lock_holders + from archivebox.misc.db import log_sqlite_lock_holders console = Console(stderr=True) console.print( f"[yellow][*] SQLite database is locked for {elapsed:.0f}s; retrying in {retry_interval:g}s... attempt={attempt}[/yellow]", ) console.print(f"[yellow] Query: {_format_sql(query, params)}[/yellow]") - holders = sqlite_lock_holders() - if holders: - console.print("[yellow] DB holders:[/yellow]") - for holder in holders[:8]: - console.print(f"[yellow] - {holder}[/yellow]") - if len(holders) > 8: - console.print(f"[yellow] ... {len(holders) - 8} more[/yellow]") - else: - console.print("[yellow] No local process with index.sqlite3 open was visible to this user.[/yellow]") - if attempt == 1: - console.print( - "[dim] SQLite does not expose the active SQL statement from another process; only local PIDs with the DB open can be shown.[/dim]", - ) + log_sqlite_lock_holders(console) def _connection_in_transaction(connection) -> bool: diff --git a/archivebox/core/tag_util.py b/archivebox/core/tag_util.py index da9da86a..d970cb09 100644 --- a/archivebox/core/tag_util.py +++ b/archivebox/core/tag_util.py @@ -12,7 +12,7 @@ from django.http import HttpRequest from django.urls import reverse from archivebox.config.common import get_config -from archivebox.core.host_util import build_snapshot_url, build_web_url +from archivebox.core.routes_util import build_snapshot_url, build_web_url from archivebox.core.models import Snapshot, SnapshotTag, Tag @@ -61,7 +61,6 @@ def get_matching_tags( created_by: str = "", year: str = "", has_snapshots: str = "all", - with_snapshot_counts: bool = True, ) -> QuerySet[Tag]: sort = normalize_tag_sort(sort) has_snapshots = normalize_has_snapshots_filter(has_snapshots) @@ -284,7 +283,6 @@ def build_tag_cards( created_by=created_by, year=year, has_snapshots=has_snapshots, - with_snapshot_counts=needs_snapshot_count_annotation, ) if limit is not None: queryset = queryset[:limit] diff --git a/archivebox/core/templatetags/core_tags.py b/archivebox/core/templatetags/core_tags.py index 1ca580ee..1ff044ee 100644 --- a/archivebox/core/templatetags/core_tags.py +++ b/archivebox/core/templatetags/core_tags.py @@ -7,12 +7,14 @@ from django.utils.html import escape from pathlib import Path -from archivebox.hooks import ( +from abx_plugins.plugins.archivewebpage.replay_preview import is_replay_target as is_archivewebpage_replay_target + +from archivebox.plugins.discovery import ( get_plugin_icon, get_plugin_template, get_plugin_name, ) -from archivebox.core.host_util import ( +from archivebox.core.routes_util import ( canonical_base_host_for_request, get_admin_base_url, get_public_base_url, @@ -27,7 +29,6 @@ register = template.Library() _TEXT_PREVIEW_EXTS = (".json", ".jsonl", ".txt", ".csv", ".tsv", ".xml", ".yml", ".yaml", ".md", ".log") _IMAGE_PREVIEW_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif") _MHTML_PREVIEW_EXTS = (".mhtml", ".mht") -_WACZ_PREVIEW_EXTS = (".wacz", ".warc", ".warc.gz") _MEDIA_FILE_EXTS = { ".mp4", @@ -210,7 +211,7 @@ def _build_snapshot_preview_url(snapshot_id: str, path: str = "", request=None, _is_text_preview_path(path) or _is_image_preview_path(path) or (path or "").lower().endswith(_MHTML_PREVIEW_EXTS) - or (path or "").lower().endswith(_WACZ_PREVIEW_EXTS) + or is_archivewebpage_replay_target(path or "") ): return url separator = "&" if "?" in url else "?" @@ -450,7 +451,7 @@ def _unconfigured_banner_context(request) -> dict: from archivebox.machine.models import Machine machine = Machine.current() - machine_admin_url = f"/admin/machine/machine/{machine.id.hex}/change/" + machine_admin_url = f"/admin/machine/machine/{machine.id}/change/" except Exception: machine_admin_url = "" return { diff --git a/archivebox/core/urls.py b/archivebox/core/urls.py index eca2f2e2..afc053ae 100644 --- a/archivebox/core/urls.py +++ b/archivebox/core/urls.py @@ -24,8 +24,8 @@ from archivebox.core.views import ( AddView, WebAddView, HealthCheckView, - live_progress_view, ) +from archivebox.progressmonitor.views import live_progress_view # GLOBAL_CONTEXT doesn't work as-is, disabled for now: https://github.com/ArchiveBox/ArchiveBox/discussions/1306 diff --git a/archivebox/core/views.py b/archivebox/core/views.py index 2811d303..94a5b29e 100644 --- a/archivebox/core/views.py +++ b/archivebox/core/views.py @@ -8,32 +8,29 @@ from django.utils import timezone import inspect from typing import cast from collections.abc import Callable -from functools import lru_cache from pathlib import Path from urllib.parse import quote, urlparse from django.shortcuts import render, redirect -from django.http import JsonResponse, HttpRequest, HttpResponse, Http404, HttpResponseForbidden, QueryDict +from django.http import HttpRequest, HttpResponse, Http404, HttpResponseForbidden, QueryDict from django.utils.html import format_html from django.utils.safestring import mark_safe from django.views import View from django.views.generic.list import ListView from django.views.generic import FormView -from django.db.models import CharField, Count, Q, Sum -from django.db.models.functions import Cast +from django.db.models import Q from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.gzip import gzip_page from django.utils.decorators import method_decorator from admin_data_views.typing import TableContext, ItemContext, SectionData from admin_data_views.utils import render_with_table_view, render_with_item_view, ItemLink -from abx_dl.events import PROCESS_EXIT_SKIPPED +from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay from archivebox.config import CONSTANTS, CONSTANTS_CONFIG, VERSION -from archivebox.config.common import get_config, get_all_configs +from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED, get_config, get_all_configs, redact_sensitive_config from archivebox.config.configset import BaseConfigSet from archivebox.misc.paginators import CountlessPaginator from archivebox.misc.util import ( @@ -46,15 +43,13 @@ from archivebox.misc.util import ( ) from archivebox.misc.serve_static import serve_static_with_byterange_support from archivebox.misc.logging_util import printable_filesize -from archivebox.search import ( - get_search_backend_display_name, +from archivebox.search.config import ( get_search_mode, get_search_mode_backend, get_search_mode_base, get_search_mode_options, - prioritize_metadata_matches, - query_search_index, ) +from archivebox.search.query import apply_snapshot_search from archivebox.core.models import ArchiveResult, Snapshot from archivebox.core.permissions import ( @@ -66,42 +61,23 @@ from archivebox.core.permissions import ( is_admin_user, public_snapshots_queryset, ) -from archivebox.core.host_util import ( +from archivebox.core.routes_util import ( build_admin_url, build_snapshot_url, build_web_url, get_admin_host, - get_api_base_url, get_snapshot_host, get_snapshot_lookup_key, get_web_host, host_matches, ) -from archivebox.core.forms import AddLinkForm, get_plugin_config_binary_urls +from archivebox.core.forms import AddLinkForm +from archivebox.plugins.forms import get_plugin_config_binary_urls from archivebox.crawls.models import Crawl from archivebox.workers.models import RETRY_AT_MAX -from archivebox.hooks import ( - BUILTIN_PLUGINS_DIR, - USER_PLUGINS_DIR, - discover_plugin_configs, - iter_plugin_dirs, -) - - -ABX_PLUGINS_GITHUB_BASE_URL = "https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/" -LIVE_PLUGIN_BASE_URL = "/admin/environment/plugins/" - - -@lru_cache(maxsize=1) -def _live_progress_plugin_names() -> tuple[frozenset[str], frozenset[str]]: - plugin_configs = discover_plugin_configs() - download_plugin_names = frozenset( - plugin_name - for plugin_name, plugin_config in plugin_configs.items() - if plugin_config.get("output_mimetypes") and not plugin_name.startswith("search_backend_") - ) - indexing_plugin_names = frozenset(plugin_name for plugin_name in plugin_configs if plugin_name.startswith("search_backend_")) - return download_plugin_names, indexing_plugin_names +from archivebox.plugins.discovery import discover_plugin_configs +from archivebox.plugins.views import get_config_definition_link +from archivebox.progressmonitor.views import live_progress_view, progress_endpoint def _get_request_config(request: HttpRequest, *, resolve_plugins: bool = False): @@ -269,8 +245,8 @@ class SnapshotView(View): SnapshotView.find_snapshots_for_url(snapshot.url) .select_related("crawl", "crawl__created_by") .annotate( - num_outputs_cached=Count("archiveresult", filter=Q(archiveresult__status="succeeded")), - num_failures_cached=Count("archiveresult", filter=Q(archiveresult__status="failed")), + num_outputs_cached=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED), + num_failures_cached=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED), ) ) related_snapshots = list( @@ -332,15 +308,10 @@ class SnapshotView(View): else: status_label, status_color = status_label_by_state.get(snapshot_status, ("not yet archived", "danger")) - # One canonical progress endpoint, same-origin to whichever host the page is on. - # The id is always carried explicitly in the query string (derived from the page - # context, never the host) so this works in every routing/security mode. - progress_endpoint = f"/progress.json?snapshot_id={snapshot.id.hex}" - context = { "id": str(snapshot.id), "snapshot_id": str(snapshot.id), - "progress_endpoint": progress_endpoint, + "progress_endpoint": progress_endpoint("snapshot", snapshot.id), "url": snapshot.url, "archive_path": snapshot.archive_path_from_db, "title": htmlencode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)), @@ -495,7 +466,7 @@ class SnapshotView(View): f'- list all the Snapshot files .*
' f'- view the Snapshot ./index.html
' f'- go to the Snapshot admin to edit
' - f'- go to the Snapshot actions to re-archive
' + f'- go to the Snapshot actions to re-archive
' '- or return to the main index...' "" "" @@ -600,8 +571,9 @@ class SnapshotPathView(View): snapshot = None snapshots_qs = direct_snapshots_queryset(request, Snapshot.objects.select_related("crawl", "crawl__created_by")) if snapshot_id: - matches = list(filter_queryset_by_uuid_substring(snapshots_qs, snapshot_id)[:2]) - snapshot = matches[0] if matches else None + snapshot = _find_snapshot_by_ref(snapshot_id) + if snapshot and not can_view_snapshot(request, snapshot): + return _admin_login_redirect_or_forbidden(request) else: # fuzzy lookup by date + domain/url (most recent) username_lookup = "system" if username == "web" else username @@ -762,7 +734,7 @@ def _replay_path_visible(request: HttpRequest, path: Path) -> bool: snapshot = Snapshot.objects.filter(id=snapshot_id).select_related("crawl", "crawl__created_by").first() if not snapshot or not can_view_snapshot(request, snapshot): return False - request.archivebox_config = get_config(snapshot=snapshot, resolve_plugins=False) + request.archivebox_config = _get_request_config(request, resolve_plugins=False) return True @@ -873,33 +845,16 @@ def _serve_responses_path(request, responses_root: Path, rel_path: str, show_ind def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = ""): - request_config = get_config(snapshot=snapshot, resolve_plugins=False) + request_config = _get_request_config(request, resolve_plugins=False) request.archivebox_config = request_config request.archivebox_snapshot_url = snapshot.url snapshot._runtime_config = request_config rel_path = path or "" - # POLICY EXCEPTION: archivebox normally does not depend on any specific - # plugin. The WACZ/WARC embedded-replay viewer needs ``/replay/sw.js`` and - # ``/replay/ui.js`` served same-origin on each snapshot host so its - # service worker can register. Until plugins can register their own URL - # routes generically, we conditionally import the archivewebpage plugin's - # ``replay_preview`` module here and let it handle these paths. If the - # plugin is not installed the import fails and the path falls through to - # the regular snapshot file lookup (which 404s, the expected behavior). if rel_path.startswith("replay/") or rel_path == "replay": - try: - from abx_plugins.plugins.archivewebpage import replay_preview as _awp_preview - except ImportError: - _awp_preview = None - if _awp_preview is not None: - replay_asset = _awp_preview.serve_replay_asset(rel_path, request_config) - if replay_asset is not None: - body, content_type, headers = replay_asset - response = HttpResponse(body, content_type=content_type) - for key, value in headers.items(): - response.headers[key] = value - return response + response = archivewebpage_replay.serve_replay_asset_response(rel_path, request_config, HttpResponse) + if response is not None: + return response if rel_path == "progress.json": # Host routing forwards every snap-* path to SnapshotHostView, so we forward @@ -1063,7 +1018,6 @@ class PublicIndexView(ListView): "WEB_BASE_URL": build_web_url(request=self.request, config=runtime_config), "search_mode": search_mode, "search_mode_options": get_search_mode_options(config=runtime_config), - "search_backend_label": get_search_backend_display_name(search_mode_backend) if search_mode_backend else "", } context["show_search_index_hint"] = bool( self.request.GET.get("q") @@ -1121,11 +1075,7 @@ class PublicIndexView(ListView): public_snapshots_queryset(super().get_queryset(**kwargs)) .select_related("crawl__created_by") .annotate( - num_outputs_cached=Count( - "archiveresult", - filter=Q(archiveresult__status=ArchiveResult.StatusChoices.SUCCEEDED), - distinct=True, - ), + num_outputs_cached=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED), ) .prefetch_related( "tags", @@ -1136,32 +1086,21 @@ class PublicIndexView(ListView): if not query: return qs - search_mode = get_search_mode(self.request.GET.get("search_mode"), config=getattr(self, "runtime_config", None)) - - metadata_qs = qs.filter( - Q(title__icontains=query) | Q(url__icontains=query) | Q(timestamp__icontains=query) | Q(tags__name__icontains=query), - ) - search_mode_base = get_search_mode_base(search_mode, config=getattr(self, "runtime_config", None)) - search_mode_backend = get_search_mode_backend(search_mode, config=getattr(self, "runtime_config", None)) - if search_mode_base == "meta": - qs = metadata_qs - else: - try: - backend_qs = query_search_index(query, search_mode=search_mode) - if search_mode_backend: - qs = qs.filter(pk__in=backend_qs.values("pk")) - else: - qs = prioritize_metadata_matches( - qs, - metadata_qs, - backend_qs, - ordering=self.ordering, - ) - except Exception as err: - print(f"[!] Error while using search backend: {err.__class__.__name__} {err}") - qs = qs.none() if search_mode_backend else metadata_qs - - return qs.distinct() + runtime_config = getattr(self, "runtime_config", None) + search_mode = get_search_mode(self.request.GET.get("search_mode"), config=runtime_config) + try: + return apply_snapshot_search( + qs, + query, + search_mode=search_mode, + config=runtime_config, + ordering=self.ordering, + ) + except Exception as err: + print(f"[!] Error while using search backend: {err.__class__.__name__} {err}") + if get_search_mode_backend(search_mode, config=runtime_config): + return qs.none() + return apply_snapshot_search(qs, query, search_mode="meta", config=runtime_config) def get(self, *args, **kwargs): if self.request.user.is_authenticated: @@ -1215,21 +1154,6 @@ class AddView(UserPassesTestMixin, FormView): required_search_plugin = f"search_backend_{request_config.SEARCH_BACKEND_ENGINE}".strip() can_override_crawl_config = self._can_override_crawl_config() plugin_configs = discover_plugin_configs() if can_override_crawl_config else {} - from archivebox.config.common import is_sensitive_config_key - - sensitive_keys = { - str(config_key) - for schema in plugin_configs.values() - for config_key, prop_schema in (schema.get("properties") or {}).items() - if isinstance(prop_schema, dict) and prop_schema.get("x-sensitive") - } - - def _drop_sensitive(items): - # Filter by both the schema-level ``x-sensitive`` marker (above) and - # the key-name heuristic so a persona/effective config can never - # ship credential values to the public ``/add`` UI. - return {str(key): value for key, value in items if str(key) not in sensitive_keys and not is_sensitive_config_key(str(key))} - public_persona_config_keys = { "CRAWL_MAX_CONCURRENT_SNAPSHOTS", "DELETE_AFTER", @@ -1243,13 +1167,14 @@ class AddView(UserPassesTestMixin, FormView): persona_config_map = {} for persona in persona_queryset.order_by("name"): effective_config = get_config(persona=persona) + effective_config_redacted = get_config(persona=persona, redact_sensitive=True).model_dump(mode="json") if can_override_crawl_config: - raw_config = _drop_sensitive((persona.config or {}).items()) - effective_config_json = _drop_sensitive(effective_config.items()) + raw_config = redact_sensitive_config(persona.config or {}) + effective_config_json = effective_config_redacted binary_urls = get_plugin_config_binary_urls(effective_config) else: raw_config = {} - effective_config_json = {key: effective_config.get(key) for key in public_persona_config_keys} + effective_config_json = {key: effective_config_redacted.get(key) for key in public_persona_config_keys} binary_urls = {} persona_config_map[persona.name] = { "config": raw_config, @@ -1337,12 +1262,13 @@ class AddView(UserPassesTestMixin, FormView): # 2. create a new Crawl with the URLs from the file timestamp = timezone.now().strftime("%Y-%m-%d__%H-%M-%S") urls_content = sources_file.read_text() - # Store only explicit crawl-scoped overrides. Persona/machine/plugin - # defaults are resolved at hook runtime via get_config(...). + # Store explicit crawl-scoped overrides; Crawl.save() freezes them + # over the resolved persona/user/machine defaults at creation time. config = {} if plugins: config["PLUGINS"] = plugins - effective_config = get_config(persona=persona, user=self.request.user) if persona else get_config(user=self.request.user) + request_user = self.request.user if self.request.user.is_authenticated else None + effective_config = get_config(persona=persona, user=request_user) if persona else get_config(user=request_user) if crawl_max_concurrent_snapshots != int(effective_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS): config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] = crawl_max_concurrent_snapshots if delete_after != str(effective_config.DELETE_AFTER): @@ -1369,7 +1295,7 @@ class AddView(UserPassesTestMixin, FormView): if url_filters.get("denylist"): config["URL_DENYLIST"] = url_filters["denylist"] - crawl = Crawl.objects.create( + crawl = Crawl.create_scheduler_row( urls=urls_content, max_depth=depth, tags_str=tag, @@ -1390,6 +1316,7 @@ class AddView(UserPassesTestMixin, FormView): template=crawl, schedule=schedule, is_enabled=True, + config=config, label=crawl.label, notes=f"Auto-created from add page. {notes}".strip(), created_by_id=created_by_id, @@ -1534,982 +1461,6 @@ class HealthCheckView(View): return HttpResponse("OK", content_type="text/plain", status=200) -@gzip_page -def live_progress_view(request): - """Simple JSON endpoint for live progress status - used by admin progress monitor.""" - try: - from archivebox.crawls.models import Crawl - from archivebox.core.models import Snapshot, ArchiveResult - from archivebox.machine.models import Process, Machine - - snapshot_id_filter = (request.GET.get("snapshot_id") or "").strip() - crawl_id_filter = (request.GET.get("crawl_id") or "").strip() - is_admin = is_admin_user(request) - - scoped_snapshot = None - if snapshot_id_filter: - import uuid as _uuid - - try: - _uuid.UUID(snapshot_id_filter) - except (TypeError, ValueError): - return JsonResponse({"error": "Invalid snapshot_id"}, status=400) - scoped_snapshot = Snapshot.objects.filter(id=snapshot_id_filter).select_related("crawl").first() - if scoped_snapshot is None or not can_view_snapshot(request, scoped_snapshot): - return JsonResponse({"error": "Permission denied"}, status=403) - elif crawl_id_filter: - # Crawl-only scope still requires staff: there's no per-crawl ACL helper, - # and a crawl can mix snapshot permissions levels. - if not is_admin: - return JsonResponse({"error": "Permission denied"}, status=403) - else: - if not is_admin: - return JsonResponse({"error": "Permission denied"}, status=403) - - request_config = request.archivebox_config - now = timezone.now() - crawl_scope = Crawl.objects.all() - snapshot_scope = Snapshot.objects.all() - archiveresult_scope = ArchiveResult.objects.all() - if is_admin and not request.user.is_superuser: - crawl_scope = crawl_scope.filter(created_by=request.user) - snapshot_scope = snapshot_scope.filter(crawl__created_by=request.user) - archiveresult_scope = archiveresult_scope.filter(snapshot__crawl__created_by=request.user) - if scoped_snapshot is not None: - snapshot_scope = Snapshot.objects.filter(id=scoped_snapshot.id) - crawl_scope = Crawl.objects.filter(id=scoped_snapshot.crawl_id) - archiveresult_scope = ArchiveResult.objects.filter(snapshot_id=scoped_snapshot.id) - elif crawl_id_filter: - snapshot_scope = snapshot_scope.filter(crawl_id=crawl_id_filter) - crawl_scope = crawl_scope.filter(id=crawl_id_filter) - archiveresult_scope = archiveresult_scope.filter(snapshot__crawl_id=crawl_id_filter) - - def is_current_run_timestamp(event_ts, run_started_at) -> bool: - if run_started_at is None: - return True - if event_ts is None: - return False - return event_ts >= run_started_at - - def archiveresult_matches_current_run(ar, run_started_at) -> bool: - if run_started_at is None: - return True - if ar.status in ( - ArchiveResult.StatusChoices.QUEUED, - ArchiveResult.StatusChoices.STARTED, - ArchiveResult.StatusChoices.BACKOFF, - ): - return True - event_ts = ar.end_ts or ar.start_ts or ar.modified_at or ar.created_at - return is_current_run_timestamp(event_ts, run_started_at) - - def hook_details(hook_name: str, plugin: str = "setup") -> tuple[str, str, str, str]: - normalized_hook_name = Path(hook_name).name if hook_name else "" - if not normalized_hook_name: - return (plugin, plugin, "unknown", "") - - phase = "unknown" - if normalized_hook_name == "InstallEvent": - phase = "install" - elif normalized_hook_name.startswith("on_CrawlSetup__"): - phase = "crawl" - elif normalized_hook_name.startswith("on_Snapshot__"): - phase = "snapshot" - elif normalized_hook_name.startswith("on_BinaryRequest__"): - phase = "binary" - - label = normalized_hook_name - if "__" in normalized_hook_name: - label = normalized_hook_name.split("__", 1)[1] - label = label.rsplit(".", 1)[0] - if len(label) > 3 and label[:2].isdigit() and label[2] == "_": - label = label[3:] - label = label.replace("_", " ").strip() or plugin - - return (plugin, label, phase, normalized_hook_name) - - def process_label(cmd: list[str] | None) -> tuple[str, str, str, str]: - hook_path = "" - if isinstance(cmd, list) and cmd: - first = cmd[0] - if isinstance(first, str): - hook_path = first - - if not hook_path: - return ("", "setup", "unknown", "") - - return hook_details(Path(hook_path).name, plugin=Path(hook_path).parent.name or "setup") - - def archiveresult_output_path(ar) -> str | None: - output_file_map = ar.output_files if isinstance(ar.output_files, dict) else {} - - def is_root_relative(path: str) -> bool: - metadata = output_file_map.get(path) or {} - return bool(isinstance(metadata, dict) and metadata.get("root_relative")) - - if ar.output_str: - raw_output = str(ar.output_str).strip() - if ar._looks_like_output_path(raw_output, ar.plugin): - output_path = Path(raw_output) - if output_path.is_absolute(): - return None - - if raw_output.startswith(f"{ar.plugin}/"): - candidates = [raw_output] - elif len(output_path.parts) == 1: - candidates = [f"{ar.plugin}/{raw_output}", raw_output] - else: - candidates = [raw_output] - - if raw_output in output_file_map and is_root_relative(raw_output): - return raw_output - - for relative_path in candidates: - plugin_relative = relative_path.removeprefix(f"{ar.plugin}/") - if relative_path in output_file_map: - return f"{ar.plugin}/{relative_path}" if not relative_path.startswith(f"{ar.plugin}/") else relative_path - if plugin_relative in output_file_map: - return f"{ar.plugin}/{plugin_relative}" - - output_file_paths = list(output_file_map.keys()) - if output_file_paths: - fallback_path = ArchiveResult._fallback_output_file_path(output_file_paths, ar.plugin, output_file_map) - if fallback_path: - if is_root_relative(fallback_path): - return fallback_path - return f"{ar.plugin}/{fallback_path}" - - return None - - def snapshot_output_url(snapshot, output_path: str) -> str: - return build_snapshot_url(str(snapshot["id"]), output_path, request=request, config=request_config) - - def snapshot_archive_path(snapshot) -> str: - if snapshot["fs_version"] in ("0.7.0", "0.8.0"): - return f"{CONSTANTS.ARCHIVE_DIR_NAME}/{snapshot['timestamp']}" - crawl = crawls_by_id.get(str(snapshot["crawl_id"])) - username = "web" - if crawl is not None and crawl["created_by_id"]: - username = crawl["created_by__username"] - if username == "system": - username = "web" - date_base = snapshot["bookmarked_at"] or snapshot["created_at"] - date_str = date_base.strftime("%Y%m%d") if date_base else "unknown" - domain = Snapshot.extract_domain_from_url(snapshot["url"]) - return f"{username}/{date_str}/{domain}/{snapshot['id']}" - - def snapshot_view_url(snapshot, output_path: str = "") -> str: - anchor = f"#{output_path}" if output_path else "" - return build_web_url( - f"/{snapshot_archive_path(snapshot)}/index.html{anchor}", - request=request, - config=request_config, - ) - - def snapshot_display_url(url: str) -> str: - url = str(url or "") - return url if len(url) <= 96 else f"{url[:93]}..." - - api_base = get_api_base_url(request=request, config=request_config) if scoped_snapshot is not None else "" - - def screencast_frame_url(crawl_id: str, crawl_dir: Path) -> str: - frame_path = crawl_dir / "chrome_screencast" / "latest.jpg" - try: - frame_stat = frame_path.stat() - except OSError: - return "" - if frame_stat.st_size <= 0: - return "" - if now.timestamp() - frame_stat.st_mtime > 15: - return "" - rel = f"/api/v1/crawls/crawl/{crawl_id}/files/chrome_screencast/latest.jpg?v={frame_stat.st_mtime_ns}" - return f"{api_base}{rel}" if api_base else rel - - machine_id = Machine.current().id - orchestrator_proc = ( - Process.objects.filter( - machine_id=machine_id, - process_type=Process.TypeChoices.ORCHESTRATOR, - status=Process.StatusChoices.RUNNING, - ) - .only("id", "pid", "started_at", "machine_id", "process_type", "status") - .order_by("-started_at") - .first() - if machine_id is not None - else None - ) - runner_worker = None - orchestrator_proc_running = bool(orchestrator_proc and orchestrator_proc.is_running) - if not orchestrator_proc_running: - try: - from archivebox.workers.supervisord_util import get_existing_supervisord_process, get_worker - - supervisor = get_existing_supervisord_process(quiet=True) - runner_worker = get_worker(supervisor, "worker_runner") if supervisor else None - except Exception: - runner_worker = None - - runner_worker_running = bool(runner_worker and runner_worker.get("statename") in ("STARTING", "RUNNING")) - runner_worker_pid = runner_worker.get("pid") if runner_worker else None - orchestrator_running = orchestrator_proc_running or runner_worker_running - orchestrator_pid = orchestrator_proc.pid if orchestrator_proc_running and orchestrator_proc else runner_worker_pid - - def count_statuses(queryset, statuses) -> dict[str, int]: - # Keep these as individual indexed COUNTs instead of GROUP BY over - # every matching row. On large SQLite data dirs, GROUP BY can scan - # and sort far more of the status index than the live-progress - # header needs before the runner gets CPU again. - return {status: queryset.filter(status=status).count() for status in statuses} - - # Get model counts by status - crawl_status_counts = count_statuses( - crawl_scope, - (Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED, Crawl.StatusChoices.PAUSED), - ) - crawls_pending = crawl_status_counts.get(Crawl.StatusChoices.QUEUED, 0) - crawls_started = crawl_status_counts.get(Crawl.StatusChoices.STARTED, 0) - crawls_paused = crawl_status_counts.get(Crawl.StatusChoices.PAUSED, 0) - - # Get recent crawls (last 24 hours) - from datetime import timedelta - - one_day_ago = now - timedelta(days=1) - paused_crawl_cutoff = now - timedelta(hours=12) - crawls_recent = crawl_scope.filter(created_at__gte=one_day_ago).count() - - snapshot_status_counts = count_statuses( - snapshot_scope, - (Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.PAUSED), - ) - snapshots_pending = snapshot_status_counts.get(Snapshot.StatusChoices.QUEUED, 0) - snapshots_started = snapshot_status_counts.get(Snapshot.StatusChoices.STARTED, 0) - snapshots_paused = snapshot_status_counts.get(Snapshot.StatusChoices.PAUSED, 0) - - download_plugin_names, indexing_plugin_names = _live_progress_plugin_names() - result_statuses = ( - ArchiveResult.StatusChoices.QUEUED, - ArchiveResult.StatusChoices.STARTED, - ArchiveResult.StatusChoices.PAUSED, - ) - archiveresult_status_counts = count_statuses(archiveresult_scope, result_statuses) - download_scope = archiveresult_scope.filter( - plugin__in=download_plugin_names, - snapshot__status__in=(Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED), - snapshot__crawl__status__in=(Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED), - ) - indexing_scope = archiveresult_scope.filter(plugin__in=indexing_plugin_names) - download_status_counts = count_statuses(download_scope, result_statuses) - indexing_status_counts = count_statuses(indexing_scope, result_statuses) - archiveresults_pending = archiveresult_status_counts.get(ArchiveResult.StatusChoices.QUEUED, 0) - archiveresults_started = archiveresult_status_counts.get(ArchiveResult.StatusChoices.STARTED, 0) - archiveresults_paused = archiveresult_status_counts.get(ArchiveResult.StatusChoices.PAUSED, 0) - archiveresults_succeeded = 0 - archiveresults_failed = 0 - - downloads_pending = download_status_counts.get(ArchiveResult.StatusChoices.QUEUED, 0) - downloads_started = download_status_counts.get(ArchiveResult.StatusChoices.STARTED, 0) - indexing_pending = indexing_status_counts.get(ArchiveResult.StatusChoices.QUEUED, 0) - indexing_started = indexing_status_counts.get(ArchiveResult.StatusChoices.STARTED, 0) - - # Build hierarchical active crawls with nested snapshots and archive results - max_active_crawls = 10 - max_queued_crawls = 10 - max_started_snapshots_per_crawl = 50 - max_queued_snapshots_per_crawl = 50 - - active_crawl_fields = ( - "id", - "created_at", - "created_by_id", - "modified_at", - "urls", - "config", - "max_depth", - "tags_str", - "persona_id", - "status", - "retry_at", - "label", - "created_by__id", - "created_by__username", - ) - started_crawls = list( - crawl_scope.filter(status=Crawl.StatusChoices.STARTED) - .values(*active_crawl_fields) - .order_by("-modified_at")[:max_active_crawls], - ) - paused_crawls = list( - crawl_scope.filter(status=Crawl.StatusChoices.PAUSED, created_at__gte=paused_crawl_cutoff) - .values(*active_crawl_fields) - .order_by("-modified_at")[:max_active_crawls], - ) - queued_crawls = list( - crawl_scope.filter(status=Crawl.StatusChoices.QUEUED).values(*active_crawl_fields).order_by("-modified_at")[:max_queued_crawls], - ) - queued_crawls_hidden = max(crawls_pending - len(queued_crawls), 0) - active_crawls_list = started_crawls + paused_crawls + queued_crawls - for crawl in active_crawls_list: - crawl["id"] = str(crawl["id"]) - if crawl["persona_id"]: - crawl["persona_id"] = str(crawl["persona_id"]) - persona_details_by_id: dict[str, dict[str, str]] = {} - persona_details_by_name: dict[str, dict[str, str]] = {} - persona_objects_by_id = {} - persona_objects_by_name = {} - persona_ids = {crawl["persona_id"] for crawl in active_crawls_list if crawl["persona_id"]} - persona_names = { - str((crawl["config"] or {}).get("DEFAULT_PERSONA") or "Default") for crawl in active_crawls_list if not crawl["persona_id"] - } - if persona_ids or persona_names: - from archivebox.personas.models import Persona - - for persona in Persona.objects.filter(Q(id__in=persona_ids) | Q(name__in=persona_names)).only("id", "name", "config"): - persona_details = { - "name": persona.name, - "admin_url": f"/admin/personas/persona/{persona.pk}/change/", - } - persona_details_by_id[str(persona.id)] = persona_details - persona_details_by_name[persona.name] = persona_details - persona_objects_by_id[str(persona.id)] = persona - persona_objects_by_name[persona.name] = persona - active_crawl_ids = [crawl["id"] for crawl in active_crawls_list] - active_crawl_objects = {} - if active_crawl_ids: - for crawl_obj in Crawl.objects.filter(id__in=active_crawl_ids).select_related("created_by", "persona"): - crawl_obj._runtime_config = request_config - active_crawl_objects[str(crawl_obj.id)] = crawl_obj - snapshot_counts_by_crawl: dict[str, dict[str, int]] = {str(crawl_id): {} for crawl_id in active_crawl_ids} - cancelled_snapshot_counts_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids} - crawl_output_sizes_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids} - queued_snapshot_overflow_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids} - active_snapshot_scope = snapshot_scope.filter(crawl_id__in=active_crawl_ids) - if active_crawl_ids: - for row in active_snapshot_scope.values("crawl_id", "status").annotate(count=Count("id")): - snapshot_counts_by_crawl.setdefault(str(row["crawl_id"]), {})[row["status"]] = row["count"] - - for row in ( - active_snapshot_scope.filter(status=Snapshot.StatusChoices.SEALED, downloaded_at__isnull=True) - .values("crawl_id") - .annotate(count=Count("id")) - ): - cancelled_snapshot_counts_by_crawl[str(row["crawl_id"])] = row["count"] - - for row in ( - archiveresult_scope.filter( - snapshot__crawl_id__in=active_crawl_ids, - snapshot__status=Snapshot.StatusChoices.SEALED, - ) - .values("snapshot__crawl_id") - .annotate(size=Sum("output_size")) - ): - crawl_output_sizes_by_crawl[str(row["snapshot__crawl_id"])] = int(row["size"] or 0) - - crawl_process_pids: dict[str, int] = {} - snapshot_process_pids: dict[str, int] = {} - process_records_by_crawl: dict[str, list[tuple[dict[str, object], object | None]]] = {} - process_records_by_snapshot: dict[str, list[tuple[dict[str, object], object | None]]] = {} - seen_process_records: set[str] = set() - crawls_by_id = {str(crawl["id"]): crawl for crawl in active_crawls_list} - started_snapshot_fields = ( - "id_str", - "created_at", - "modified_at", - "url", - "timestamp", - "bookmarked_at", - "crawl_id_str", - "title", - "downloaded_at", - "fs_version", - "status", - ) - queued_snapshot_fields = ( - "id_str", - "url", - "crawl_id_str", - "title", - "status", - ) - snapshots = [] - for crawl_id in active_crawl_ids: - crawl_snapshot_scope = active_snapshot_scope.filter(crawl_id=crawl_id) - snapshots.extend( - crawl_snapshot_scope.filter(status=Snapshot.StatusChoices.STARTED) - .annotate(id_str=Cast("id", CharField()), crawl_id_str=Cast("crawl_id", CharField())) - .values(*started_snapshot_fields) - .order_by("-modified_at")[:max_started_snapshots_per_crawl], - ) - queued_snapshots = list( - crawl_snapshot_scope.filter(status=Snapshot.StatusChoices.QUEUED) - .annotate(id_str=Cast("id", CharField()), crawl_id_str=Cast("crawl_id", CharField())) - .values( - *queued_snapshot_fields, - ) - .order_by("modified_at")[:max_queued_snapshots_per_crawl], - ) - queued_snapshot_overflow_by_crawl[str(crawl_id)] = max( - snapshot_counts_by_crawl.get(str(crawl_id), {}).get(Snapshot.StatusChoices.QUEUED, 0) - len(queued_snapshots), - 0, - ) - snapshots.extend(queued_snapshots) - - def dashed_uuid(value: str) -> str: - value = str(value) - if len(value) == 32: - return f"{value[:8]}-{value[8:12]}-{value[12:16]}-{value[16:20]}-{value[20:]}" - return value - - for snapshot in snapshots: - snapshot["id"] = ( - snapshot.pop("id_str") if snapshot["status"] == Snapshot.StatusChoices.QUEUED else dashed_uuid(snapshot.pop("id_str")) - ) - snapshot["crawl_id"] = dashed_uuid(snapshot.pop("crawl_id_str")) - snapshots_by_id = {str(snapshot["id"]): snapshot for snapshot in snapshots} - displayed_snapshots_by_crawl: dict[str, list[Snapshot]] = {str(crawl_id): [] for crawl_id in active_crawl_ids} - for snapshot in snapshots: - crawl_snapshots = displayed_snapshots_by_crawl.setdefault(str(snapshot["crawl_id"]), []) - crawl_snapshots.append(snapshot) - displayed_snapshot_ids = [ - snapshot["id"] for crawl_snapshots in displayed_snapshots_by_crawl.values() for snapshot in crawl_snapshots - ] - detailed_snapshot_ids = [snapshot["id"] for snapshot in snapshots if snapshot["status"] != Snapshot.StatusChoices.QUEUED] - process_value_fields = ("id", "process_type", "status", "pwd", "cmd", "pid", "exit_code", "started_at", "modified_at") - if active_crawl_ids or displayed_snapshot_ids: - process_scope = Process.objects.filter( - machine_id=machine_id, - process_type__in=[ - Process.TypeChoices.HOOK, - Process.TypeChoices.BINARY, - ], - ) - running_processes = process_scope.filter(status=Process.StatusChoices.RUNNING).values(*process_value_fields) - recent_processes = ( - process_scope.filter(modified_at__gte=now - timedelta(minutes=10)).values(*process_value_fields).order_by("-modified_at") - ) - else: - running_processes = Process.objects.none() - recent_processes = Process.objects.none() - - archiveresults_by_snapshot: dict[str, list[ArchiveResult]] = {str(snapshot_id): [] for snapshot_id in detailed_snapshot_ids} - if detailed_snapshot_ids: - displayed_archiveresults = ( - archiveresult_scope.filter(snapshot_id__in=detailed_snapshot_ids) - .select_related("process") - .only( - "id", - "snapshot_id", - "plugin", - "hook_name", - "status", - "output_str", - "output_files", - "output_size", - "start_ts", - "end_ts", - "created_at", - "modified_at", - "process_id", - "process__id", - "process__pid", - "process__started_at", - "process__timeout", - ) - .order_by("snapshot_id", "start_ts", "created_at") - ) - for archiveresult in displayed_archiveresults: - archiveresults_by_snapshot.setdefault(str(archiveresult.snapshot_id), []).append(archiveresult) - if archiveresult.status == ArchiveResult.StatusChoices.SUCCEEDED: - archiveresults_succeeded += 1 - elif archiveresult.status == ArchiveResult.StatusChoices.FAILED: - archiveresults_failed += 1 - - def find_snapshot_for_process(proc_pwd: Path) -> Snapshot | None: - for path_part in reversed(proc_pwd.parts): - snapshot = snapshots_by_id.get(path_part) - if snapshot: - return snapshot - return None - - def find_crawl_for_process(proc_pwd: Path) -> Crawl | None: - for path_part in reversed(proc_pwd.parts): - crawl = crawls_by_id.get(path_part) - if crawl: - return crawl - return None - - running_worker_ids: set[str] = set() - for proc in running_processes: - if not proc["pwd"]: - continue - proc_pwd = Path(proc["pwd"]) - matched_snapshot = find_snapshot_for_process(proc_pwd) - matched_crawl = ( - crawls_by_id.get(str(matched_snapshot["crawl_id"])) if matched_snapshot is not None else find_crawl_for_process(proc_pwd) - ) - if matched_snapshot is None: - if matched_crawl is None: - continue - crawl_id = str(matched_crawl["id"]) - snapshot_id = "" - else: - crawl_id = str(matched_snapshot["crawl_id"]) - snapshot_id = str(matched_snapshot["id"]) - running_worker_ids.add(str(proc["id"])) - _plugin, _label, phase, _hook_name = process_label(proc["cmd"]) - if crawl_id and proc["pid"]: - crawl_process_pids.setdefault(crawl_id, proc["pid"]) - if phase == "snapshot" and snapshot_id and proc["pid"]: - snapshot_process_pids.setdefault(snapshot_id, proc["pid"]) - - for proc in recent_processes: - if not proc["pwd"]: - continue - proc_pwd = Path(proc["pwd"]) - matched_snapshot = find_snapshot_for_process(proc_pwd) - matched_crawl = ( - crawls_by_id.get(str(matched_snapshot["crawl_id"])) if matched_snapshot is not None else find_crawl_for_process(proc_pwd) - ) - if matched_snapshot is None and matched_crawl is None: - continue - crawl_id = str(matched_snapshot["crawl_id"] if matched_snapshot is not None else matched_crawl["id"]) - snapshot_id = str(matched_snapshot["id"]) if matched_snapshot is not None else "" - - plugin, label, phase, hook_name = process_label(proc["cmd"]) - - record_scope = str(snapshot_id) if phase == "snapshot" and snapshot_id else str(crawl_id) - proc_key = f"{record_scope}:{plugin}:{label}:{proc['status']}:{proc['exit_code']}" - if proc_key in seen_process_records: - continue - seen_process_records.add(proc_key) - - status = ( - "started" - if proc["status"] == Process.StatusChoices.RUNNING - else ( - "skipped" - if proc["exit_code"] == PROCESS_EXIT_SKIPPED or (phase == "binary" and proc["exit_code"] not in (None, 0)) - else ("failed" if proc["exit_code"] not in (None, 0) else "succeeded") - ) - ) - payload: dict[str, object] = { - "id": str(proc["id"]), - "plugin": plugin, - "label": label, - "hook_name": hook_name, - "status": status, - "phase": phase, - "source": "process", - "process_id": str(proc["id"]), - } - if status == "started" and proc["pid"]: - payload["pid"] = proc["pid"] - proc_started_at = proc["started_at"] or proc["modified_at"] - if phase == "snapshot" and snapshot_id: - process_records_by_snapshot.setdefault(snapshot_id, []).append((payload, proc_started_at)) - elif crawl_id: - process_records_by_crawl.setdefault(crawl_id, []).append((payload, proc_started_at)) - - active_crawls = [] - total_workers = len(running_worker_ids) - for crawl in active_crawls_list: - crawl_id = str(crawl["id"]) - crawl_snapshot_counts = snapshot_counts_by_crawl.get(crawl_id, {}) - total_snapshots = sum(crawl_snapshot_counts.values()) - completed_snapshots = crawl_snapshot_counts.get(Snapshot.StatusChoices.SEALED, 0) - started_snapshots = crawl_snapshot_counts.get(Snapshot.StatusChoices.STARTED, 0) - pending_snapshots = crawl_snapshot_counts.get(Snapshot.StatusChoices.QUEUED, 0) - cancelled_snapshots = cancelled_snapshot_counts_by_crawl.get(crawl_id, 0) - - # Count URLs in the crawl (for when snapshots haven't been created yet) - urls_count = 0 - if crawl["urls"]: - urls_count = len([u for u in crawl["urls"].split("\n") if u.strip() and not u.startswith("#")]) - - # Calculate crawl progress - crawl_progress = int((completed_snapshots / total_snapshots) * 100) if total_snapshots > 0 else 0 - crawl_run_started_at = crawl["created_at"] - crawl_setup_plugins = [ - payload - for payload, proc_started_at in process_records_by_crawl.get(crawl_id, []) - if is_current_run_timestamp(proc_started_at, crawl_run_started_at) - ] - crawl_setup_total = len(crawl_setup_plugins) - crawl_setup_completed = sum(1 for item in crawl_setup_plugins if item.get("status") == "succeeded") - crawl_setup_failed = sum(1 for item in crawl_setup_plugins if item.get("status") == "failed") - crawl_setup_pending = sum(1 for item in crawl_setup_plugins if item.get("status") == "queued") - crawl_screencast_url = screencast_frame_url(crawl_id, active_crawl_objects[crawl_id].output_dir) - crawl_screencast_link = f"/admin/crawls/crawl/{crawl_id.replace('-', '')}/change/" if crawl_screencast_url else "" - - # Get active snapshots for this crawl (already prefetched) - active_snapshots_for_crawl = [] - for snapshot in displayed_snapshots_by_crawl.get(crawl_id, []): - snapshot_run_started_at = snapshot.get("downloaded_at") or snapshot.get("created_at") - # Get archive results only for displayed active snapshots. Large crawls can - # contain thousands of sealed snapshots, and prefetching all their results - # makes the progress endpoint compete with the runner. - snapshot_results = [ - ar - for ar in archiveresults_by_snapshot.get(str(snapshot["id"]), []) - if archiveresult_matches_current_run(ar, snapshot_run_started_at) - ] - if snapshot["status"] == Snapshot.StatusChoices.QUEUED: - snapshot_results = [] - - plugin_progress_values: list[int] = [] - all_plugins: list[dict[str, object]] = [] - seen_plugin_keys: set[str] = set() - snapshot_title = ( - str(snapshot["title"] or "") - if snapshot["status"] == Snapshot.StatusChoices.QUEUED - else Snapshot._normalize_title_candidate(snapshot["title"], snapshot_url=snapshot["url"]) - ) - snapshot_favicon_url = "" - snapshot_preview_url = "" - snapshot_preview_link = "" - snapshot_screencast_url = "" - snapshot_screencast_link = "" - snapshot_fallback_urls: list[str] = [] - result_by_plugin = {result.plugin: result for result in snapshot_results} - title_result = result_by_plugin.get("title") - if not snapshot_title and title_result is not None and title_result.status == ArchiveResult.StatusChoices.SUCCEEDED: - snapshot_title = Snapshot._normalize_title_candidate(title_result.output_str, snapshot_url=snapshot["url"]) - favicon_result = result_by_plugin.get("favicon") - if favicon_result is not None and favicon_result.status == ArchiveResult.StatusChoices.SUCCEEDED: - favicon_path = archiveresult_output_path(favicon_result) or "favicon/favicon.ico" - snapshot_favicon_url = snapshot_output_url(snapshot, favicon_path) - screenshot_result = result_by_plugin.get("screenshot") - if screenshot_result is not None and screenshot_result.status == ArchiveResult.StatusChoices.SUCCEEDED: - snapshot_preview_link = snapshot_view_url(snapshot) - screenshot_path = archiveresult_output_path(screenshot_result) or "screenshot/screenshot.png" - snapshot_preview_url = snapshot_output_url(snapshot, screenshot_path) - snapshot_preview_link = snapshot_view_url(snapshot, screenshot_path) - if snapshot_favicon_url: - snapshot_fallback_urls.append(snapshot_favicon_url) - elif snapshot_favicon_url: - snapshot_preview_url = snapshot_favicon_url - - if snapshot["status"] == Snapshot.StatusChoices.STARTED: - snapshot_screencast_url = screencast_frame_url(crawl_id, active_crawl_objects[crawl_id].output_dir) - snapshot_screencast_link = snapshot_view_url(snapshot) if snapshot_screencast_url else "" - - def plugin_sort_key(ar): - status_order = { - ArchiveResult.StatusChoices.STARTED: 0, - ArchiveResult.StatusChoices.QUEUED: 1, - ArchiveResult.StatusChoices.SUCCEEDED: 2, - ArchiveResult.StatusChoices.NORESULTS: 3, - ArchiveResult.StatusChoices.FAILED: 4, - } - return (status_order.get(ar.status, 5), ar.plugin, ar.hook_name or "") - - for ar in sorted(snapshot_results, key=plugin_sort_key): - status = ar.status - process = ar.process_record - progress_value = 0 - if status in ( - ArchiveResult.StatusChoices.SUCCEEDED, - ArchiveResult.StatusChoices.FAILED, - ArchiveResult.StatusChoices.SKIPPED, - ArchiveResult.StatusChoices.NORESULTS, - ): - progress_value = 100 - elif status == ArchiveResult.StatusChoices.STARTED: - started_at = ar.start_ts or (process.started_at if process else None) - timeout = process.timeout if process else 120 - if started_at and timeout: - elapsed = max(0.0, (now - started_at).total_seconds()) - progress_value = int(min(99, max(1, (elapsed / float(timeout)) * 100))) - else: - progress_value = 1 - else: - progress_value = 0 - - plugin_progress_values.append(progress_value) - plugin, label, phase, hook_name = hook_details(ar.hook_name or ar.plugin, plugin=ar.plugin) - - plugin_payload = { - "id": str(ar.id), - "plugin": ar.plugin, - "label": label, - "hook_name": hook_name, - "phase": phase, - "status": status, - "process_id": str(process.id) if process else None, - "admin_url": f"/admin/core/archiveresult/{ar.id.hex}/change/", - } - output_path = archiveresult_output_path(ar) - if output_path: - plugin_payload["output_path"] = output_path - plugin_payload["output_url"] = snapshot_view_url(snapshot, output_path) - if status == ArchiveResult.StatusChoices.STARTED and process: - plugin_payload["pid"] = process.pid - if status == ArchiveResult.StatusChoices.STARTED: - plugin_payload["progress"] = progress_value - plugin_payload["timeout"] = process.timeout if process else 120 - plugin_payload["source"] = "archiveresult" - all_plugins.append(plugin_payload) - seen_plugin_keys.add(str(process.id) if process else f"{ar.plugin}:{hook_name}") - - for proc_payload, proc_started_at in process_records_by_snapshot.get(str(snapshot["id"]), []): - if not is_current_run_timestamp(proc_started_at, snapshot_run_started_at): - continue - proc_key = str(proc_payload.get("process_id") or f"{proc_payload.get('plugin')}:{proc_payload.get('hook_name')}") - if proc_key in seen_plugin_keys: - continue - seen_plugin_keys.add(proc_key) - all_plugins.append(proc_payload) - - proc_status = proc_payload.get("status") - if proc_status in ("succeeded", "failed", "skipped"): - plugin_progress_values.append(100) - elif proc_status == "started": - plugin_progress_values.append(1) - else: - plugin_progress_values.append(0) - - total_plugins = len(all_plugins) - completed_plugins = sum(1 for item in all_plugins if item.get("status") == "succeeded") - failed_plugins = sum(1 for item in all_plugins if item.get("status") == "failed") - pending_plugins = sum(1 for item in all_plugins if item.get("status") == "queued") - - snapshot_progress = int(sum(plugin_progress_values) / len(plugin_progress_values)) if plugin_progress_values else 0 - worker_state = "running" if snapshot_process_pids.get(str(snapshot["id"])) else "waiting" - if ( - snapshot["status"] == Snapshot.StatusChoices.STARTED - and worker_state == "waiting" - and not all_plugins - and snapshot["modified_at"] - and (now - snapshot["modified_at"]).total_seconds() > 30 - ): - worker_state = "waiting" if orchestrator_running else "crashed" - - if snapshot["status"] == Snapshot.StatusChoices.QUEUED and not snapshot_process_pids.get(str(snapshot["id"])): - compact_snapshot = [ - str(snapshot["id"]), - snapshot_display_url(snapshot["url"]), - ] - if snapshot_title: - compact_snapshot.append(snapshot_title) - active_snapshots_for_crawl.append(compact_snapshot) - continue - - snapshot_payload = { - "id": str(snapshot["id"]), - "url": snapshot_display_url(snapshot["url"]), - "title": snapshot_title, - "status": snapshot["status"], - "worker_state": worker_state, - } - if snapshot["status"] != Snapshot.StatusChoices.QUEUED or all_plugins or snapshot_process_pids.get(str(snapshot["id"])): - snapshot_payload.update( - { - "view_url": snapshot_view_url(snapshot), - "started": (snapshot["downloaded_at"] or snapshot["created_at"]).isoformat() - if (snapshot["downloaded_at"] or snapshot["created_at"]) - else None, - "progress": snapshot_progress, - "total_plugins": total_plugins, - "completed_plugins": completed_plugins, - "failed_plugins": failed_plugins, - "pending_plugins": pending_plugins, - "all_plugins": all_plugins, - }, - ) - if snapshot_favicon_url: - snapshot_payload["favicon_url"] = snapshot_favicon_url - if snapshot_preview_url: - snapshot_payload["preview_url"] = snapshot_preview_url - snapshot_payload["preview_link"] = snapshot_preview_link - if snapshot_screencast_url: - snapshot_payload["screencast_url"] = snapshot_screencast_url - snapshot_payload["screencast_link"] = snapshot_screencast_link - if snapshot_fallback_urls: - snapshot_payload["preview_fallbacks"] = snapshot_fallback_urls - if snapshot_process_pids.get(str(snapshot["id"])): - snapshot_payload["worker_pid"] = snapshot_process_pids[str(snapshot["id"])] - - active_snapshots_for_crawl.append(snapshot_payload) - - # Check if crawl can start (for debugging stuck crawls) - can_start = bool(crawl["urls"]) - urls_preview = crawl["urls"][:60] if crawl["urls"] else None - crawl_tags = [tag.strip() for tag in (crawl["tags_str"] or "").replace("\n", ",").split(",") if tag.strip()] - persona_details = persona_details_by_id.get(str(crawl["persona_id"])) if crawl["persona_id"] else None - persona_name = persona_details["name"] if persona_details else str((crawl["config"] or {}).get("DEFAULT_PERSONA") or "Default") - persona_details = persona_details or persona_details_by_name.get(persona_name) - crawl_output_size = crawl_output_sizes_by_crawl.get(crawl_id, 0) - avg_snapshot_size = int(crawl_output_size / completed_snapshots) if completed_snapshots else 0 - crawl_obj = active_crawl_objects[crawl_id] - persona_obj = ( - persona_objects_by_id.get(str(crawl["persona_id"])) if crawl["persona_id"] else persona_objects_by_name.get(persona_name) - ) - effective_crawl_config = get_config( - base_config=request_config, - user=crawl_obj.created_by, - persona=persona_obj, - crawl=crawl_obj, - resolve_plugins=False, - ) - max_urls = int(effective_crawl_config.CRAWL_MAX_URLS or 0) - crawl_max_size = int(effective_crawl_config.CRAWL_MAX_SIZE or 0) - crawl_timeout = int(effective_crawl_config.CRAWL_TIMEOUT or 0) - snapshot_max_size = int(effective_crawl_config.SNAPSHOT_MAX_SIZE or 0) - - # Check if retry_at is in the future (would prevent worker from claiming) - retry_at_future = crawl["retry_at"] > now if crawl["retry_at"] else False - is_paused = crawl_obj.is_paused - seconds_until_retry = ( - 0 if is_paused else int((crawl["retry_at"] - now).total_seconds()) if crawl["retry_at"] and retry_at_future else 0 - ) - crawl_worker_state = ( - "running" - if crawl_process_pids.get(crawl_id) - or any(isinstance(snapshot, dict) and snapshot.get("worker_pid") for snapshot in active_snapshots_for_crawl) - else "waiting" - ) - if is_paused: - crawl_worker_state = "paused" - elif ( - crawl["status"] == Crawl.StatusChoices.STARTED - and crawl_worker_state == "waiting" - and (started_snapshots or pending_snapshots) - ): - crawl_worker_state = "waiting" if orchestrator_running else "crashed" - - active_crawls.append( - { - "id": crawl_id, - "label": (next((line.strip() for line in (crawl["urls"] or "").splitlines() if line.strip()), "") or crawl_id)[:60], - "status": crawl["status"], - "is_paused": is_paused, - "started": crawl["created_at"].isoformat() if crawl["created_at"] else None, - "progress": crawl_progress, - "created_by": crawl["created_by__username"], - "persona": persona_name, - "persona_admin_url": persona_details["admin_url"] if persona_details else None, - "max_depth": crawl["max_depth"], - "max_urls": max_urls, - "max_crawl_size": crawl_max_size, - "crawl_timeout": crawl_timeout, - "max_snapshot_size": snapshot_max_size, - "max_crawl_size_display": printable_filesize(crawl_max_size) if crawl_max_size else "unlimited", - "crawl_timeout_display": f"{crawl_timeout}s" if crawl_timeout else "unlimited", - "max_snapshot_size_display": printable_filesize(snapshot_max_size) if snapshot_max_size else "unlimited", - "crawl_output_size": crawl_output_size, - "avg_snapshot_size": avg_snapshot_size, - "crawl_output_size_display": printable_filesize(crawl_output_size) if crawl_output_size else "0 B", - "avg_snapshot_size_display": printable_filesize(avg_snapshot_size) if avg_snapshot_size else "0 B", - "tags": crawl_tags, - "urls_count": urls_count, - "total_snapshots": total_snapshots, - "completed_snapshots": completed_snapshots, - "started_snapshots": started_snapshots, - "failed_snapshots": 0, - "pending_snapshots": pending_snapshots, - "cancelled_snapshots": cancelled_snapshots, - "setup_plugins": crawl_setup_plugins, - "setup_total_plugins": crawl_setup_total, - "setup_completed_plugins": crawl_setup_completed, - "setup_failed_plugins": crawl_setup_failed, - "setup_pending_plugins": crawl_setup_pending, - "screencast_url": crawl_screencast_url, - "screencast_link": crawl_screencast_link, - "active_snapshots": active_snapshots_for_crawl, - "queued_snapshots_hidden": queued_snapshot_overflow_by_crawl.get(crawl_id, 0), - "can_start": can_start, - "urls_preview": urls_preview, - "retry_at_future": retry_at_future, - "seconds_until_retry": seconds_until_retry, - "worker_pid": crawl_process_pids.get(crawl_id), - "worker_state": crawl_worker_state, - }, - ) - - payload = { - "is_admin": is_admin, - "scope": { - "snapshot_id": str(scoped_snapshot.id) if scoped_snapshot is not None else "", - "crawl_id": crawl_id_filter, - }, - "orchestrator_running": orchestrator_running, - "orchestrator_pid": orchestrator_pid, - "total_workers": total_workers, - "crawls_pending": crawls_pending, - "crawls_started": crawls_started, - "crawls_active": crawls_started, - "crawls_queued": crawls_pending, - "crawls_paused": crawls_paused, - "crawls_recent": crawls_recent, - "snapshots_pending": snapshots_pending, - "snapshots_started": snapshots_started, - "snapshots_active": snapshots_started, - "snapshots_queued": snapshots_pending, - "snapshots_paused": snapshots_paused, - "archiveresults_pending": archiveresults_pending, - "archiveresults_started": archiveresults_started, - "archiveresults_paused": archiveresults_paused, - "archiveresults_succeeded": archiveresults_succeeded, - "archiveresults_failed": archiveresults_failed, - "downloads_pending": downloads_pending, - "downloads_started": downloads_started, - "downloads_active": downloads_started, - "downloads_queued": downloads_pending, - "indexing_pending": indexing_pending, - "indexing_started": indexing_started, - "indexing_active": indexing_started, - "indexing_queued": indexing_pending, - "active_crawls": active_crawls, - "queued_crawls_hidden": queued_crawls_hidden, - "recent_thumbnails": [], - "server_time": timezone.now().isoformat(), - } - try: - import ujson - - return HttpResponse(ujson.dumps(payload), content_type="application/json") - except ImportError: - return JsonResponse(payload) - except Exception as e: - import traceback - - return JsonResponse( - { - "error": str(e), - "traceback": traceback.format_exc(), - "orchestrator_running": False, - "total_workers": 0, - "crawls_pending": 0, - "crawls_started": 0, - "crawls_active": 0, - "crawls_queued": 0, - "crawls_paused": 0, - "crawls_recent": 0, - "snapshots_pending": 0, - "snapshots_started": 0, - "snapshots_active": 0, - "snapshots_queued": 0, - "snapshots_paused": 0, - "archiveresults_pending": 0, - "archiveresults_started": 0, - "archiveresults_paused": 0, - "archiveresults_succeeded": 0, - "archiveresults_failed": 0, - "downloads_pending": 0, - "downloads_started": 0, - "downloads_active": 0, - "downloads_queued": 0, - "indexing_pending": 0, - "indexing_started": 0, - "indexing_active": 0, - "indexing_queued": 0, - "active_crawls": [], - "recent_thumbnails": [], - "server_time": timezone.now().isoformat(), - }, - status=500, - ) - - def find_config_section(key: str) -> str: CONFIGS = get_all_configs() @@ -2553,13 +1504,6 @@ def find_config_type(key: str) -> str: return "str" -def key_is_safe(key: str) -> bool: - for term in ("key", "password", "secret", "token"): - if term in key.lower(): - return False - return True - - def find_config_source(key: str, merged_config: dict) -> str: """Determine where a config value comes from.""" from archivebox.machine.models import Machine @@ -2585,50 +1529,13 @@ def find_config_source(key: str, merged_config: dict) -> str: return "Default" -def find_plugin_for_config_key(key: str) -> str | None: - for plugin_name, schema in discover_plugin_configs().items(): - if key in (schema.get("properties") or {}): - return plugin_name - return None - - -def get_config_definition_link(key: str) -> tuple[str, str]: - plugin_name = find_plugin_for_config_key(key) - if not plugin_name: - return ( - f"https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{quote(key)}&type=code", - "archivebox/config", - ) - - plugin_dir = next((path.resolve() for path in iter_plugin_dirs() if path.name == plugin_name), None) - if plugin_dir: - builtin_root = BUILTIN_PLUGINS_DIR.resolve() - if plugin_dir.is_relative_to(builtin_root): - return ( - f"{ABX_PLUGINS_GITHUB_BASE_URL}{quote(plugin_name)}/config.json", - f"abx_plugins/plugins/{plugin_name}/config.json", - ) - - user_root = USER_PLUGINS_DIR.resolve() - if plugin_dir.is_relative_to(user_root): - return ( - f"{LIVE_PLUGIN_BASE_URL}user.{quote(plugin_name)}/", - f"data/custom_plugins/{plugin_name}/config.json", - ) - - return ( - f"{LIVE_PLUGIN_BASE_URL}builtin.{quote(plugin_name)}/", - f"abx_plugins/plugins/{plugin_name}/config.json", - ) - - @render_with_table_view def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext: CONFIGS = get_all_configs() assert getattr(request.user, "is_superuser", False), "Must be a superuser to view configuration settings." - merged_config = get_config() + merged_config = get_config(redact_sensitive=True) rows = { "Section": [], @@ -2649,7 +1556,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext: # Use merged config value (includes machine overrides) actual_value = merged_config.get(key, getattr(section, key, None)) - rows["Value"].append(mark_safe(f"{actual_value}") if key_is_safe(key) else "******** (redacted)") + rows["Value"].append(mark_safe(f"{actual_value}")) # Show where the value comes from source = find_config_source(key, merged_config) @@ -2669,7 +1576,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext: rows["Section"].append(section) # section.replace('_', ' ').title().replace(' Config', '') rows["Key"].append(ItemLink(key, key=key)) rows["Type"].append(format_html("{}", getattr(type(CONSTANTS_CONFIG[key]), "__name__", str(CONSTANTS_CONFIG[key])))) - rows["Value"].append(format_html("{}", CONSTANTS_CONFIG[key]) if key_is_safe(key) else "******** (redacted)") + rows["Value"].append(format_html("{}", redact_sensitive_config(CONSTANTS_CONFIG).get(key))) rows["Source"].append(mark_safe('Constant')) rows["Default"].append( mark_safe( @@ -2693,23 +1600,23 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont assert getattr(request.user, "is_superuser", False), "Must be a superuser to view configuration settings." - merged_config = get_config() + merged_config = get_config(redact_sensitive=True) # Determine all sources for this config value sources_info = [] # Environment variable if key in os.environ: - sources_info.append(("Environment", os.environ[key] if key_is_safe(key) else "********", "blue")) + sources_info.append(("Environment", redact_sensitive_config(os.environ).get(key), "blue")) # Machine config machine = None machine_admin_url = None try: machine = Machine.current() - machine_admin_url = f"/admin/machine/machine/{machine.id.hex}/change/" + machine_admin_url = f"/admin/machine/machine/{machine.id}/change/" if machine.config and key in machine.config: - sources_info.append(("Machine", machine.config[key] if key_is_safe(key) else "********", "purple")) + sources_info.append(("Machine", redact_sensitive_config(machine.config).get(key), "purple")) except Exception: pass @@ -2717,7 +1624,7 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont if CONSTANTS.CONFIG_FILE.exists(): file_config = BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE) if key in file_config: - sources_info.append(("Config File", file_config[key], "green")) + sources_info.append(("Config File", redact_sensitive_config(file_config).get(key), "green")) # Default value default_val = find_config_default(key) @@ -2725,9 +1632,11 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont sources_info.append(("Default", default_val, "gray")) # Final computed value + config_source = find_config_source(key, merged_config) final_value = merged_config.get(key, CONFIGS.get(key, None)) - if not key_is_safe(key): - final_value = "********" + if config_source == "Environment": + final_value = get_config(include_machine=False, redact_sensitive=True).model_dump(mode="json").get(key, CONFIGS.get(key, None)) + is_redacted = final_value == SENSITIVE_CONFIG_VALUE_REDACTED # Build sources display sources_html = "
".join([f'{source}: {value}' for source, value, color in sources_info]) @@ -2759,7 +1668,7 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont "Key": key, "Type": find_config_type(key), "Value": final_value, - "Currently read from": find_config_source(key, merged_config), + "Currently read from": config_source, }, "help_texts": { "Key": mark_safe(f""" @@ -2776,7 +1685,7 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont "Value": mark_safe(f''' { 'Value is redacted for your security. (Passwords, secrets, API tokens, etc. cannot be viewed in the Web UI)

' - if not key_is_safe(key) + if is_redacted else "" }


@@ -2787,14 +1696,12 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont To change this value, edit data/ArchiveBox.conf or run:

archivebox config --set {key}="{ - val.strip("'") - if (val := find_config_default(key)) - else (str(final_value if key_is_safe(key) else "********")).strip("'") + val.strip("'") if (val := find_config_default(key)) else str(final_value).strip("'") }"

'''), "Currently read from": mark_safe(f""" - The value shown in the "Value" field comes from the {find_config_source(key, merged_config)} source. + The value shown in the "Value" field comes from the {config_source} source.

Priority order (highest to lowest):
    diff --git a/archivebox/crawls/admin.py b/archivebox/crawls/admin.py index 440ae6e7..b0d04b32 100644 --- a/archivebox/crawls/admin.py +++ b/archivebox/crawls/admin.py @@ -1,7 +1,8 @@ __package__ = "archivebox.crawls" from copy import copy -from urllib.parse import urlencode +import json +from urllib.parse import urlencode, urlparse from django import forms from django.core.paginator import Paginator @@ -13,15 +14,14 @@ from django.utils.html import escape, format_html, format_html_join from django.utils import timezone from django.utils.safestring import mark_safe from django.contrib import admin, messages -from django.db.models import Case, CharField, Count, IntegerField, OuterRef, Q, Subquery, Value, When -from django.db.models.functions import Coalesce +from django.db.models import Case, CharField, Count, Q, Value, When from django_object_actions import action from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin -from archivebox.core.models import Snapshot +from archivebox.core.models import ArchiveResult, Snapshot from archivebox.core.permissions import ( PERMISSIONS_CHOICES, PERMISSIONS_META, @@ -34,6 +34,7 @@ from archivebox.core.permissions import ( from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget from archivebox.crawls.models import Crawl, CrawlSchedule from archivebox.misc.paginators import AcceleratedPaginator +from archivebox.progressmonitor.views import progress_endpoint from archivebox.workers.models import RETRY_AT_MAX @@ -70,12 +71,14 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50, if status_filter in valid_statuses: filtered_qs = filtered_qs.filter(status=status_filter) + # Keep ArchiveResult counters as scalar subqueries so the paginated + # Snapshot queryset does not become a join+GROUP BY over every result row. snapshots_qs = filtered_qs.order_by("-created_at").annotate( - total_results=Count("archiveresult"), - succeeded_results=Count("archiveresult", filter=Q(archiveresult__status="succeeded")), - failed_results=Count("archiveresult", filter=Q(archiveresult__status="failed")), - started_results=Count("archiveresult", filter=Q(archiveresult__status="started")), - skipped_results=Count("archiveresult", filter=Q(archiveresult__status="skipped")), + total_results=ArchiveResult.snapshot_count_expr(), + succeeded_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED), + failed_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED), + started_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.STARTED), + skipped_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SKIPPED), snapshot_permissions=Case( When(permissions=PERMISSIONS_PUBLIC, then=Value(PERMISSIONS_PUBLIC)), When(permissions=PERMISSIONS_UNLISTED, then=Value(PERMISSIONS_UNLISTED)), @@ -260,7 +263,7 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50, background: {progress_color}; transition: width 0.3s;"> - {progress_text} @@ -381,7 +384,7 @@ class URLFiltersField(forms.Field): def to_python(self, value): if isinstance(value, dict): return value - return {"allowlist": "", "denylist": "", "same_domain_only": False, "subpaths_only": False} + return {"allowlist": "", "denylist": "", "same_domain_only": False, "subpaths_only": False, "only_new": False} class CrawlAdminForm(forms.ModelForm): @@ -423,13 +426,140 @@ class CrawlAdminForm(forms.ModelForm): config = dict(self.instance.config or {}) if self.instance and self.instance.pk else {} if self.instance and self.instance.pk: self.initial["tags_editor"] = self.instance.tags_str + effective_only_new = self.effective_only_new(self.instance if self.instance and self.instance.pk else None) + derived_filter_toggles = self.derive_filter_toggles( + self.instance.urls if self.instance and self.instance.pk else "", + config.get("URL_ALLOWLIST", ""), + ) self.initial["url_filters"] = { "allowlist": config.get("URL_ALLOWLIST", ""), "denylist": config.get("URL_DENYLIST", ""), - "same_domain_only": False, - "subpaths_only": False, + "same_domain_only": derived_filter_toggles["same_domain_only"], + "subpaths_only": derived_filter_toggles["subpaths_only"], + "only_new": effective_only_new, } + @staticmethod + def extract_url_line(line): + line = str(line or "").strip() + if not line or line.startswith("#"): + return "" + if line.startswith("{"): + try: + return str(json.loads(line).get("url", "")).strip() + except (TypeError, ValueError, json.JSONDecodeError): + return "" + return line + + @staticmethod + def regex_escape(text): + escaped = "" + for char in str(text or ""): + escaped += f"\\{char}" if char in r".*+?^${}()|[]\\" else char + return escaped + + @classmethod + def generated_host_allowlist(cls, urls): + seen = set() + domains = [] + for raw_line in str(urls or "").splitlines(): + url = cls.extract_url_line(raw_line) + if not url: + continue + parsed = urlparse(url) + domain = (parsed.hostname or "").lower() + if not domain or domain in seen: + continue + seen.add(domain) + domains.append(domain) + if not domains: + return "" + return "^https?://(" + "|".join(cls.regex_escape(domain) for domain in domains) + ")([:/]|$)" + + @staticmethod + def subpath_prefix(pathname): + path = str(pathname or "/") + while "//" in path: + path = path.replace("//", "/") + if not path or path == "/": + return "/" + if path.endswith("/"): + return path + last_slash = path.rfind("/") + last_part = path[last_slash + 1 :] + if "." in last_part: + return path[: last_slash + 1] or "/" + return path + + @staticmethod + def parsed_host_and_port(parsed): + host = (parsed.hostname or "").lower() + if not host: + return "" + try: + port = parsed.port + except ValueError: + port = None + return f"{host}:{port}" if port is not None else host + + @classmethod + def generated_subpath_allowlist(cls, urls): + seen = set() + paths = [] + for raw_line in str(urls or "").splitlines(): + url = cls.extract_url_line(raw_line) + if not url: + continue + parsed = urlparse(url) + domain = (parsed.hostname or "").lower() + if domain: + seen.add(domain) + host = cls.parsed_host_and_port(parsed) + path = cls.subpath_prefix(parsed.path) + path_key = f"{host}{path}" + if not host or path_key in seen: + continue + seen.add(path_key) + paths.append((host, path)) + if not paths: + return "" + patterns = [] + for host, path in paths: + if path == "/": + patterns.append(f"^https?://{cls.regex_escape(host)}([/?#]|$)") + elif path.endswith("/"): + patterns.append(f"^https?://{cls.regex_escape(host)}{cls.regex_escape(path)}") + else: + patterns.append(f"^https?://{cls.regex_escape(host)}{cls.regex_escape(path)}([/?#]|$)") + return "\n".join(patterns) + + @classmethod + def derive_filter_toggles(cls, urls, allowlist): + normalized_allowlist = "\n".join(Crawl.split_filter_patterns(allowlist)) + if not normalized_allowlist: + return {"same_domain_only": False, "subpaths_only": False} + if normalized_allowlist == cls.generated_subpath_allowlist(urls): + return {"same_domain_only": True, "subpaths_only": True} + if normalized_allowlist == cls.generated_host_allowlist(urls): + return {"same_domain_only": True, "subpaths_only": False} + return {"same_domain_only": False, "subpaths_only": False} + + @staticmethod + def effective_only_new(crawl=None): + from archivebox.config.common import get_config + + if crawl is not None: + return bool(get_config(crawl=crawl, resolve_plugins=False).ONLY_NEW) + return bool(get_config(resolve_plugins=False).ONLY_NEW) + + @staticmethod + def inherited_only_new(crawl): + crawl_without_only_new = copy(crawl) + config = dict(crawl.config or {}) + config.pop("ONLY_NEW", None) + crawl_without_only_new.config = config + return CrawlAdminForm.effective_only_new(crawl_without_only_new) + def clean_tags_editor(self): tags_str = self.cleaned_data.get("tags_editor", "") tag_names = [] @@ -452,6 +582,7 @@ class CrawlAdminForm(forms.ModelForm): "denylist": "\n".join(Crawl.split_filter_patterns(value.get("denylist", ""))), "same_domain_only": bool(value.get("same_domain_only")), "subpaths_only": bool(value.get("subpaths_only")), + "only_new": bool(value.get("only_new")), } def save(self, commit=True): @@ -463,6 +594,14 @@ class CrawlAdminForm(forms.ModelForm): url_filters.get("allowlist", ""), url_filters.get("denylist", ""), ) + config = dict(instance.config or {}) + only_new = bool(url_filters.get("only_new")) + inherited_only_new = self.inherited_only_new(instance) + if only_new != inherited_only_new: + config["ONLY_NEW"] = only_new + else: + config.pop("ONLY_NEW", None) + instance.config = config if commit: instance.save() instance.apply_crawl_config_filters() @@ -623,19 +762,11 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): crawl_ids = [crawl.pk for crawl in crawl_list] if not crawl_ids: return - counts = { - str(row["crawl_id"]): row - for row in Snapshot.objects.filter(crawl_id__in=crawl_ids) - .values("crawl_id") - .annotate( - num_snapshots_cached=Count("pk"), - num_archived_snapshots_cached=Count("pk", filter=Q(status=Snapshot.StatusChoices.SEALED)), - ) - } + counts = Snapshot.crawl_total_and_status_counts(crawl_ids, status=Snapshot.StatusChoices.SEALED) for crawl in crawl_list: row = counts.get(str(crawl.pk), {}) - crawl.num_snapshots_cached = row.get("num_snapshots_cached", 0) - crawl.num_archived_snapshots_cached = row.get("num_archived_snapshots_cached", 0) + crawl.num_snapshots_cached = row.get("total", 0) + crawl.num_archived_snapshots_cached = row.get("status", 0) def get_queryset(self, request): """Keep joins page-local while computing per-row snapshot counts in the page query.""" @@ -649,25 +780,9 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): ) ) if self.should_annotate_snapshot_counts(request): - snapshot_count = ( - Snapshot.objects.filter(crawl_id=OuterRef("pk")).order_by().values("crawl_id").annotate(count=Count("pk")).values("count") - ) - archived_snapshot_count = ( - Snapshot.objects.filter(crawl_id=OuterRef("pk"), status=Snapshot.StatusChoices.SEALED) - .order_by() - .values("crawl_id") - .annotate(count=Count("pk")) - .values("count") - ) queryset = queryset.annotate( - num_snapshots_cached=Coalesce( - Subquery(snapshot_count, output_field=IntegerField()), - Value(0), - ), - num_archived_snapshots_cached=Coalesce( - Subquery(archived_snapshot_count, output_field=IntegerField()), - Value(0), - ), + num_snapshots_cached=Snapshot.crawl_count_expr(), + num_archived_snapshots_cached=Snapshot.crawl_count_expr(status=Snapshot.StatusChoices.SEALED), ) return queryset @@ -684,6 +799,13 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): "crawl_stop_reason": self.stop_reason_for_crawl(crawl) if crawl else "", "crawl_snapshots_changelist": self.snapshots_changelist(crawl) if crawl else "", } + if crawl and crawl.status in { + Crawl.StatusChoices.QUEUED, + Crawl.StatusChoices.STARTED, + Crawl.StatusChoices.PAUSED, + }: + extra_context["progress_auto_expand"] = True + extra_context["progress_endpoint"] = progress_endpoint("crawl", crawl.id) return super().change_view(request, object_id, form_url, extra_context) def add_view(self, request, form_url="", extra_context=None): @@ -742,7 +864,7 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): # hold SQLite behind the request for minutes on large archives. The # Crawl row is the scheduler signal; the runner observes PAUSED and # owns child-row lifecycle work. - paused = queryset.exclude(status__in=[Crawl.StatusChoices.SEALED, Crawl.StatusChoices.PAUSED]).update( + paused = queryset.exclude(status__in=Crawl.INACTIVE_STATES).update( status=Crawl.StatusChoices.PAUSED, retry_at=RETRY_AT_MAX, modified_at=timezone.now(), @@ -757,7 +879,7 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): # Keep resume symmetrical with pause: one tight scheduler UPDATE, no # save() hooks and no child fanout in the request path. Paused child # rows become runnable through their own resume/maintenance paths. - resumed = queryset.filter(status__in=[Crawl.StatusChoices.PAUSED, Crawl.StatusChoices.SEALED]).update( + resumed = queryset.filter(status__in=Crawl.INACTIVE_STATES).update( status=Crawl.StatusChoices.QUEUED, retry_at=timezone.now(), modified_at=timezone.now(), @@ -777,11 +899,7 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): Snapshot.objects.filter( crawl_id__in=crawl_ids, - status__in=[ - Snapshot.StatusChoices.QUEUED, - Snapshot.StatusChoices.STARTED, - Snapshot.StatusChoices.PAUSED, - ], + status__in=Snapshot.OPEN_STATES, ).filter( Q(retry_at__isnull=True) | Q(retry_at__gt=now), ).update( @@ -799,7 +917,7 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): ) messages.success(request, f"Sealed {sealed} crawl(s). The runner will finish cleanup on the next sweep.") - @admin.action(description="Set Permissions ▾") + @admin.action(description="Permissions ▾") def set_crawl_permissions(self, request, queryset): permissions = (request.POST.get("permissions") or "").strip().lower() if permissions not in PERMISSIONS_VALUES: @@ -836,7 +954,7 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): messages.error(request, "Cannot recrawl: original crawl has no URLs.") return redirect("admin:crawls_crawl_change", obj.id) - new_crawl = Crawl.objects.create( + new_crawl = Crawl.create_scheduler_row( urls=obj.urls, max_depth=obj.max_depth, tags_str=obj.tags_str, @@ -861,22 +979,17 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): return format_html('{}', reason) def stop_reason_for_crawl(self, obj): - from abx_dl.limits import CrawlLimitState - if obj.pk in self.stop_reason_cache: return self.stop_reason_cache[obj.pk] output_dir = obj.output_dir_for_config(self.crawl_admin_base_config) config = self.limit_config_for_crawl(obj, output_dir) - reason = "" - if (output_dir / ".abx-dl" / "limits.json").exists(): - config["CRAWL_DIR"] = str(output_dir) - reason = CrawlLimitState.from_config(config).get_stop_reason() or "" - - max_urls = int(config["CRAWL_MAX_URLS"] or 0) - if not reason and max_urls > 0 and obj.num_snapshots_cached >= max_urls and obj.count_urls_for_limit() >= max_urls: - reason = "crawl_max_urls" - + reason = obj.stop_reason( + config=config, + output_dir=output_dir, + num_snapshots=obj.num_snapshots_cached, + num_sealed_snapshots=obj.num_archived_snapshots_cached, + ) self.stop_reason_cache[obj.pk] = reason return reason @@ -901,7 +1014,7 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): @admin.display(description="Status", ordering="status") def status_with_stop_reason(self, obj): status = "PAUSED" if obj.is_paused else str(obj.status or "").upper() - reason = self.stop_reason_for_crawl(obj) if obj.status == Crawl.StatusChoices.SEALED else "" + reason = self.stop_reason_for_crawl(obj) if obj.is_paused or obj.status == Crawl.StatusChoices.SEALED else "" if reason: reason_label = reason.removeprefix("crawl_").replace("_", " ") return format_html( diff --git a/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py b/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py new file mode 100644 index 00000000..b341e18a --- /dev/null +++ b/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py @@ -0,0 +1,33 @@ +from django.db import migrations + + +def freeze_existing_crawl_configs(apps, schema_editor): + from archivebox.config.common import build_crawl_config_snapshot + from archivebox.personas.models import Persona + from django.contrib.auth import get_user_model + + Crawl = apps.get_model("crawls", "Crawl") + User = get_user_model() + db_alias = schema_editor.connection.alias + + for crawl in Crawl.objects.using(db_alias).select_related("persona", "created_by").iterator(chunk_size=200): + current_config = dict(crawl.config or {}) + persona = Persona.objects.using(db_alias).filter(pk=crawl.persona_id).first() + user = User.objects.using(db_alias).filter(pk=crawl.created_by_id).first() + frozen_config = build_crawl_config_snapshot( + user=user, + persona=persona, + overrides=current_config, + ) + if frozen_config != current_config: + Crawl.objects.using(db_alias).filter(pk=crawl.pk).update(config=frozen_config) + + +class Migration(migrations.Migration): + dependencies = [ + ("crawls", "0017_drop_stale_crawl_limit_columns"), + ] + + operations = [ + migrations.RunPython(freeze_existing_crawl_configs, migrations.RunPython.noop), + ] diff --git a/archivebox/crawls/migrations/0019_crawlschedule_config.py b/archivebox/crawls/migrations/0019_crawlschedule_config.py new file mode 100644 index 00000000..56a5faef --- /dev/null +++ b/archivebox/crawls/migrations/0019_crawlschedule_config.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +def copy_template_config_to_schedule(apps, schema_editor): + CrawlSchedule = apps.get_model("crawls", "CrawlSchedule") + db_alias = schema_editor.connection.alias + + for schedule in CrawlSchedule.objects.using(db_alias).select_related("template").iterator(chunk_size=200): + template_config = dict(schedule.template.config or {}) if schedule.template_id else {} + CrawlSchedule.objects.using(db_alias).filter(pk=schedule.pk).update(config=template_config) + + +class Migration(migrations.Migration): + dependencies = [ + ("crawls", "0018_freeze_crawl_config_snapshots"), + ] + + operations = [ + migrations.AddField( + model_name="crawlschedule", + name="config", + field=models.JSONField(blank=True, default=dict, null=True), + ), + migrations.RunPython(copy_template_config_to_schedule, migrations.RunPython.noop), + ] diff --git a/archivebox/crawls/models.py b/archivebox/crawls/models.py index eb072a70..ac8bb9b3 100755 --- a/archivebox/crawls/models.py +++ b/archivebox/crawls/models.py @@ -8,7 +8,7 @@ import json import re from itertools import islice from datetime import timedelta -from archivebox.uuid_compat import uuid7 +from archivebox.uuid_compat import CompactUUIDField, uuid7 from pathlib import Path from urllib.parse import urlparse @@ -42,7 +42,7 @@ if TYPE_CHECKING: class CrawlSchedule(ModelWithUUID, ModelWithNotes): - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False) modified_at = models.DateTimeField(auto_now=True) @@ -50,6 +50,7 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes): template: "Crawl" = models.ForeignKey("Crawl", on_delete=models.CASCADE, null=False, blank=False) # type: ignore schedule = models.CharField(max_length=64, blank=False, null=False) is_enabled = models.BooleanField(default=True) + config = models.JSONField(default=dict, null=True, blank=True) label = models.CharField(max_length=64, blank=True, null=False, default="") notes = models.TextField(blank=True, null=False, default="") @@ -102,13 +103,17 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes): return self.is_enabled and self.next_run_at <= now def enqueue(self, queued_at=None) -> "Crawl": + from archivebox.config.common import build_crawl_config_snapshot + queued_at = queued_at or timezone.now() template = self.template label = template.label or self.label + persona = template.persona if template.persona_id else None + user = template.created_by if template.created_by_id else None return Crawl.objects.create( urls=template.urls, - config=template.config or {}, + config=build_crawl_config_snapshot(user=user, persona=persona, overrides=self.config or {}), max_depth=template.max_depth, tags_str=template.tags_str, persona_id=template.persona_id, @@ -122,7 +127,7 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes): class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWithStateMachine): - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False) modified_at = models.DateTimeField(auto_now=True) @@ -162,6 +167,8 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith StatusChoices = ModelWithStateMachine.StatusChoices active_state = StatusChoices.STARTED delete_after_final_statuses = (StatusChoices.SEALED,) + RUNNABLE_STATES = (StatusChoices.QUEUED, StatusChoices.STARTED) + INACTIVE_STATES = (StatusChoices.PAUSED, StatusChoices.SEALED) schedule_id: uuid.UUID | None sm: "CrawlMachine" @@ -238,11 +245,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith # child Snapshot through its own state machine. Active children that # are already due need no write; the runner will claim them as-is. active_children = self.snapshot_set.filter( - status__in=[ - Snapshot.StatusChoices.QUEUED, - Snapshot.StatusChoices.STARTED, - Snapshot.StatusChoices.PAUSED, - ], + status__in=Snapshot.OPEN_STATES, ) return active_children.filter( Q(retry_at__isnull=True) | Q(retry_at__gt=now), @@ -259,10 +262,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith # Snapshot runner claim performs the real pause transition and cascades # its own ArchiveResults, keeping request/admin transactions tiny. active_children = self.snapshot_set.filter( - status__in=[ - Snapshot.StatusChoices.QUEUED, - Snapshot.StatusChoices.STARTED, - ], + status__in=Snapshot.RUNNABLE_STATES, ) return active_children.filter( Q(retry_at__isnull=True) | Q(retry_at__gt=now), @@ -273,10 +273,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith @classmethod def missing_delete_at_candidates(cls): - from archivebox.personas.models import Persona - - persona_ids = Persona.objects.filter(config__has_key="DELETE_AFTER").values_list("id", flat=True) - return cls.objects.filter(delete_at__isnull=True).filter(Q(config__has_key="DELETE_AFTER") | Q(persona_id__in=persona_ids)) + return cls.objects.filter(delete_at__isnull=True, config__has_key="DELETE_AFTER") def save(self, *args, **kwargs): update_fields = kwargs.get("update_fields") @@ -287,11 +284,16 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith previous_tag_names = set(self.parse_tag_names(old_crawl.tags_str or "")) config = dict(self.config or {}) + is_new = self._state.adding or old_crawl is None + persona = self.persona if self.persona_id else None + user = self.created_by if self.created_by_id else None + if is_new: + from archivebox.config.common import build_crawl_config_snapshot + + config = build_crawl_config_snapshot(user=user, persona=persona, overrides=config) if str(config.get("PERMISSIONS") or "").strip().lower() not in PERMISSIONS_VALUES: from archivebox.config.common import get_config - persona = self.persona if self.persona_id else None - user = self.created_by if self.created_by_id else None config["PERMISSIONS"] = normalize_permissions(get_config(persona=persona, user=user, include_machine=True).PERMISSIONS) if "CRAWL_MAX_CONCURRENT_SNAPSHOTS" in config: raw_concurrency = config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] @@ -338,7 +340,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith @property def api_url(self) -> str: - return str(reverse_lazy("api-1:get_crawl", args=[self.id.hex])) + return str(reverse_lazy("api-1:get_crawl", args=[self.id])) @staticmethod def parse_tag_names(tags: Iterable[str] | str, *, pattern: str = r",") -> list[str]: @@ -491,7 +493,9 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith def output_dir(self) -> Path: from archivebox.config.common import get_config - return self.output_dir_for_config(get_config(resolve_plugins=False)) + output_dir = self.output_dir_for_config(get_config(resolve_plugins=False)) + hyphen_dir = output_dir.with_name(str(uuid.UUID(hex=self.id.hex))) + return output_dir if output_dir.exists() or not hyphen_dir.exists() else hyphen_dir def get_urls_list(self) -> list[str]: """Get list of URLs from urls field, filtering out comments and empty lines.""" @@ -771,35 +775,108 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith from archivebox.personas.models import Persona if self.persona_id: - persona = Persona.objects.filter(id=self.persona_id).first() - if persona is not None: - return persona - - default_persona_name = str((self.config or {}).get("DEFAULT_PERSONA") or "").strip() - if default_persona_name: - persona, _ = Persona.objects.get_or_create(name=default_persona_name or "Default") - persona.ensure_dirs() - return persona + return Persona.objects.filter(id=self.persona_id).first() return None - def limit_stop_reason(self) -> str: - from abx_dl.limits import CrawlLimitState - from archivebox.config.common import get_config + @staticmethod + def _config_value(config: Mapping[str, Any] | Any, key: str, default: Any = None) -> Any: + if isinstance(config, Mapping): + return config.get(key, default) + return getattr(config, key, default) - config = get_config(crawl=self, include_machine=False) - if (self.output_dir / ".abx-dl" / "limits.json").exists(): - config["CRAWL_DIR"] = str(self.output_dir) - stop_reason = CrawlLimitState.from_config(config).get_stop_reason() + @classmethod + def create_scheduler_row(cls, **kwargs) -> "Crawl": + from archivebox.base_models.models import normalize_config_json_values + from archivebox.config.common import build_crawl_config_snapshot + + now = timezone.now() + kwargs.setdefault("created_at", now) + kwargs.setdefault("modified_at", now) + config = normalize_config_json_values(kwargs.get("config") or {}) + user = kwargs.get("created_by") + persona = kwargs.get("persona") + if user is None and kwargs.get("created_by_id"): + from django.contrib.auth import get_user_model + + user = get_user_model().objects.filter(pk=kwargs["created_by_id"]).first() + if persona is None and kwargs.get("persona_id"): + from archivebox.personas.models import Persona + + persona = Persona.objects.filter(pk=kwargs["persona_id"]).first() + kwargs["config"] = build_crawl_config_snapshot(user=user, persona=persona, overrides=config) + crawl = cls(**kwargs) + if crawl.delete_at is None: + crawl.set_delete_at_from_config() + cls.objects.bulk_create([crawl]) + return crawl + + def limit_stop_reason( + self, + *, + config: Mapping[str, Any] | Any | None = None, + output_dir: Path | None = None, + num_snapshots: int | None = None, + ) -> str: + from abx_dl.limits import CrawlLimitState + + if config is None: + from archivebox.config.common import get_config + + config = get_config(crawl=self, include_machine=False) + if output_dir is None: + output_dir = self.output_dir + + limits_path = output_dir / ".abx-dl" / "limits.json" + if limits_path.exists(): + config_with_crawl_dir = {**dict(config.items())} if isinstance(config, Mapping) else config + config_with_crawl_dir["CRAWL_DIR"] = str(output_dir) + stop_reason = CrawlLimitState.from_config(config_with_crawl_dir).get_stop_reason() if stop_reason: return stop_reason - max_urls = int(config.CRAWL_MAX_URLS or 0) - if max_urls > 0 and self.snapshot_set.count() >= max_urls and self.count_urls_for_limit() >= max_urls: + max_urls = int(self._config_value(config, "CRAWL_MAX_URLS", 0) or 0) + if num_snapshots is None: + num_snapshots = self.snapshot_set.count() + if max_urls > 0 and num_snapshots >= max_urls and self.count_urls_for_limit() >= max_urls: return "crawl_max_urls" return "" + def lifecycle_stop_reason(self, *, num_snapshots: int | None = None, num_sealed_snapshots: int | None = None) -> str: + if self.is_paused: + return "paused" + + if self.status != self.StatusChoices.SEALED: + return "" + + if num_snapshots is None: + num_snapshots = self.snapshot_set.count() + if num_snapshots == 0: + return "no_viable_urls" + + if num_sealed_snapshots is None: + from archivebox.core.models import Snapshot + + num_sealed_snapshots = self.snapshot_set.filter(status=Snapshot.StatusChoices.SEALED).count() + if num_sealed_snapshots >= num_snapshots: + return "done" + + return "" + + def stop_reason( + self, + *, + config: Mapping[str, Any] | Any | None = None, + output_dir: Path | None = None, + num_snapshots: int | None = None, + num_sealed_snapshots: int | None = None, + ) -> str: + return self.limit_stop_reason(config=config, output_dir=output_dir, num_snapshots=num_snapshots) or self.lifecycle_stop_reason( + num_snapshots=num_snapshots, + num_sealed_snapshots=num_sealed_snapshots, + ) + def add_url(self, entry: dict) -> bool: """ Add a URL to the crawl queue if not already present. @@ -1220,7 +1297,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith The root Snapshot for this crawl, or None for system crawls that don't create snapshots """ import time - from archivebox.hooks import run_hook, discover_hooks, process_hook_records, is_finite_background_hook + from archivebox.plugins.hooks import run_hook, discover_hooks, process_hook_records, is_finite_background_hook from archivebox.config.common import get_config from archivebox.machine.models import Binary, Machine @@ -1284,7 +1361,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith except Exception: return set() - from archivebox.hooks import extract_records_from_process + from archivebox.plugins.hooks import extract_records_from_process records = [] # Finite background hooks can exit before their completed Process @@ -1409,7 +1486,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith def cleanup(self): """Clean up background hooks and run on_CrawlEnd hooks.""" - from archivebox.hooks import run_hook, discover_hooks + from archivebox.plugins.hooks import run_hook, discover_hooks # Clean up .pid files from output directory if self.output_dir.exists(): diff --git a/archivebox/machine/models.py b/archivebox/machine/models.py index f8065d0f..713983eb 100755 --- a/archivebox/machine/models.py +++ b/archivebox/machine/models.py @@ -7,7 +7,7 @@ import sys import uuid import socket from pathlib import Path -from archivebox.uuid_compat import uuid7 +from archivebox.uuid_compat import CompactUUIDField, uuid7 from datetime import timedelta, datetime from typing import TYPE_CHECKING, Any, cast @@ -98,7 +98,7 @@ def _get_process_binary_env_keys(plugin_name: str, hook_path: str, env: dict[str add(f"{plugin_key}_BINARY") try: - from archivebox.hooks import discover_plugin_configs + from archivebox.plugins.discovery import discover_plugin_configs plugin_schema = discover_plugin_configs().get(plugin_name, {}) schema_keys = [key for key in (plugin_schema.get("properties") or {}) if key.endswith("_BINARY")] @@ -173,7 +173,7 @@ class MachineManager(models.Manager): class Machine(ModelWithHealthStats): - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) modified_at = models.DateTimeField(auto_now=True) guid = models.CharField(max_length=64, default=None, null=False, unique=True, editable=False) @@ -377,7 +377,7 @@ class NetworkInterfaceManager(models.Manager): class NetworkInterface(ModelWithHealthStats): - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) modified_at = models.DateTimeField(auto_now=True) machine = models.ForeignKey(Machine, on_delete=models.CASCADE, default=None, null=False) @@ -492,15 +492,15 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine): Installation is synchronous during queued→installed transition. If installation fails, Binary stays in queued with retry_at set for later retry. - State machine calls run() which executes on_BinaryRequest__* hooks - to install the binary using the specified providers. + State machine calls run(), which emits an abxpkg BinaryRequestEvent through + the ArchiveBox runner and installs the binary using the specified providers. """ class StatusChoices(models.TextChoices): QUEUED = "queued", "Queued" INSTALLED = "installed", "Installed" - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) modified_at = models.DateTimeField(auto_now=True) machine = models.ForeignKey(Machine, on_delete=models.CASCADE, null=False) @@ -583,7 +583,8 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine): """ from archivebox.config.common import get_config - return get_config().DATA_DIR / "machines" / str(self.machine_id) / "binaries" / self.name / str(self.id) + data_dir = get_config().DATA_DIR + return data_dir / "machines" / str(self.machine_id) / "binaries" / self.name / str(self.id) def to_json(self) -> dict: """ @@ -720,101 +721,11 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine): def run(self): """ - Execute binary installation by running on_BinaryRequest__* hooks. - - Called by BinaryMachine when entering 'started' state. - Runs ALL on_BinaryRequest__* hooks - each hook checks binproviders - and decides if it can handle this binary. First hook to succeed wins. - Updates status to SUCCEEDED or FAILED based on hook output. + Execute binary installation through the ArchiveBox binary runner. """ - import json - from archivebox.hooks import discover_hooks, run_hook - from archivebox.config.common import get_config + from archivebox.services.runner import run_binary - # Get merged config (Binary doesn't have crawl/snapshot context). - config = get_config() - - # ArchiveBox installs the puppeteer package and Chromium in separate - # hook phases. Suppress puppeteer's bundled browser download during the - # package install step so the dedicated chromium hook owns that work. - if self.name == "puppeteer": - config.setdefault("PUPPETEER_SKIP_DOWNLOAD", "true") - config.setdefault("PUPPETEER_SKIP_CHROMIUM_DOWNLOAD", "true") - - # Create output directory - output_dir = self.output_dir - output_dir.mkdir(parents=True, exist_ok=True) - - # Discover ALL on_BinaryRequest__* hooks - hooks = discover_hooks("BinaryRequest", config=config) - if not hooks: - # No hooks available - stay queued, will retry later - return - - allowed_binproviders = self._allowed_binproviders() - - # Run each hook - they decide if they can handle this binary - for hook in hooks: - plugin_name = hook.parent.name - if allowed_binproviders is not None and plugin_name not in allowed_binproviders: - continue - - plugin_output_dir = output_dir / plugin_name - plugin_output_dir.mkdir(parents=True, exist_ok=True) - - overrides_json = None - if self.overrides: - overrides_json = json.dumps(self.overrides) - - # Run the hook - process = run_hook( - hook, - output_dir=plugin_output_dir, - config=config, - timeout=600, # 10 min timeout for binary installation - binary_id=str(self.id), - machine_id=str(self.machine_id), - name=self.name, - binproviders=self.binproviders, - overrides=overrides_json, - ) - - # Background hook (unlikely for binary installation, but handle it) - if process is None: - continue - - # Failed or skipped hook - try next one - if process.exit_code != 0: - continue - - # Parse JSONL output to check for successful installation - from archivebox.hooks import extract_records_from_process, process_hook_records - - records = extract_records_from_process(process) - if records: - process_hook_records(records, overrides={}) - binary_records = [record for record in records if record.get("type") == "Binary" and record.get("abspath")] - if binary_records: - record = binary_records[0] - # Update self from successful installation - self.abspath = record["abspath"] - self.version = record.get("version", "") - self.sha256 = record.get("sha256", "") - self.binprovider = record.get("binprovider", "env") - self.status = self.StatusChoices.INSTALLED - self.save() - - # Maintain the optional human-facing LIB_BIN_DIR convenience symlink. - from archivebox.config.common import get_config - - lib_bin_dir = get_config().LIB_BIN_DIR - if lib_bin_dir: - self.symlink_to_lib_bin_after_commit(lib_bin_dir) - - return - - # No hook succeeded - leave status as QUEUED (will retry later) - # Don't set to FAILED since we don't have that status anymore + run_binary(str(self.id)) def cleanup(self): """ @@ -1044,7 +955,7 @@ class Process(ModelWithDeleteAfter, models.Model): BINARY = "binary", "Binary" # Primary fields - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) modified_at = models.DateTimeField(auto_now=True) diff --git a/archivebox/misc/checks.py b/archivebox/misc/checks.py index 7c774d49..e1d97bb0 100644 --- a/archivebox/misc/checks.py +++ b/archivebox/misc/checks.py @@ -1,8 +1,11 @@ __package__ = "archivebox.misc" import os +import signal import sys import time +import threading +from contextlib import contextmanager from pathlib import Path from rich import print @@ -20,6 +23,42 @@ from rich.panel import Panel # that the check is called after django.setup() has been called +def _migration_interrupt_message(*, before_apply: bool = False) -> str: + status = "Migration cancelled before any changes were applied." if before_apply else "Migration interrupted." + return ( + f"\n[X] {status}\n" + " Database migrations are atomic; interrupted migration work is rolled back or left unapplied,\n" + " so no partially-applied migration is recorded and no data loss has occurred.\n\n" + " To continue the upgrade, run:\n" + " archivebox init\n" + ) + + +@contextmanager +def _exit_on_migration_interrupt(): + if threading.current_thread() is not threading.main_thread(): + yield + return + + handled_signals = (signal.SIGINT, signal.SIGTERM) + previous_handlers = {sig: signal.getsignal(sig) for sig in handled_signals} + + def handle_shutdown(_signum, _frame): + try: + os.write(sys.stderr.fileno(), _migration_interrupt_message().encode()) + except Exception: + pass + os._exit(130) + + try: + for sig in handled_signals: + signal.signal(sig, handle_shutdown) + yield + finally: + for sig, previous_handler in previous_handlers.items(): + signal.signal(sig, previous_handler) + + def check_data_folder(config=None, **config_kwargs) -> None: from archivebox import DATA_DIR from archivebox.config import CONSTANTS @@ -109,14 +148,19 @@ def check_migrations(*, blocking: bool = True, auto_apply: bool = False, cancel_ try: time.sleep(cancel_delay) except KeyboardInterrupt: - print("[red][X] Migration cancelled before any changes were applied.[/red]", file=sys.stderr) + print(_migration_interrupt_message(before_apply=True), file=sys.stderr) raise SystemExit(130) from None # Always delegate to Django's migration executor. It records each # migration only after it succeeds, so power loss or SIGKILL leaves # unapplied work visible here and the next startup resumes normally. print("[yellow][*] Applying database migrations...[/yellow]", file=sys.stderr) - apply_migrations(stdout=sys.stderr, stderr=sys.stderr, verbosity=1) + try: + with _exit_on_migration_interrupt(): + apply_migrations(stdout=sys.stderr, stderr=sys.stderr, verbosity=1) + except KeyboardInterrupt: + print(_migration_interrupt_message(), file=sys.stderr) + raise SystemExit(130) from None return pending_migrations() if blocking: raise SystemExit(3) @@ -159,10 +203,7 @@ def check_not_root(): if IS_ROOT and not (is_getting_help or is_getting_version): print("[yellow][!] Running ArchiveBox as root is not recommended.[/yellow]", file=sys.stderr) - print( - " Chrome and other plugins run as non-root for security, if DATA_DIR is owned by root it can prevent archiving from succeeding.", - file=sys.stderr, - ) + print(" Root-owned DATA_DIR files may be inaccessible to non-root users later.", file=sys.stderr) print(" https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#do-not-run-as-root", file=sys.stderr) @@ -208,6 +249,7 @@ def check_data_dir_permissions(config=None, **config_kwargs): f"[violet]Hint:[/violet] Change the current ownership [red]{data_dir_uid}[/red]:{data_dir_gid} (PUID:PGID) to the user & group that will run ArchiveBox, e.g.:", ) STDERR.print(f" [grey53]sudo[/grey53] chown -R [blue]{DEFAULT_PUID}:{DEFAULT_PGID}[/blue] {DATA_DIR.resolve()}") + STDERR.print(" Avoid recursive chown on very large archives unless you know the full tree needs repair.") STDERR.print() STDERR.print("[blue]More info:[/blue]") STDERR.print( diff --git a/archivebox/misc/db.py b/archivebox/misc/db.py index d348a21d..9288b063 100644 --- a/archivebox/misc/db.py +++ b/archivebox/misc/db.py @@ -146,6 +146,18 @@ def sqlite_lock_holders(db_path: Path = DATA_DIR / "index.sqlite3") -> list[str] return holders +def log_sqlite_lock_holders(console: Any, *, db_path: Path = DATA_DIR / "index.sqlite3", limit: int = 8) -> None: + holders = sqlite_lock_holders(db_path) + if holders: + console.print("[yellow] DB holders:[/yellow]") + for holder in holders[:limit]: + console.print(f"[yellow] - {holder}[/yellow]") + if len(holders) > limit: + console.print(f"[yellow] ... {len(holders) - limit} more[/yellow]") + else: + console.print("[yellow] No local process with index.sqlite3 open was visible to this user.[/yellow]") + + def sqlite_lock_error(error: BaseException) -> bool: from django.db import OperationalError as DjangoOperationalError @@ -157,7 +169,6 @@ def retry_sqlite_locks(action: Callable[[], Any], *, label: str, stderr: TextIO from rich.console import Console console = Console(file=stderr or None, stderr=stderr is None) - attempts = 0 while True: try: return action() @@ -168,22 +179,9 @@ def retry_sqlite_locks(action: Callable[[], Any], *, label: str, stderr: TextIO if not sqlite_lock_error(err): raise - attempts += 1 connections.close_all() - holders = sqlite_lock_holders() console.print(f"[yellow][*] SQLite database is locked while {label}; retrying in 5s...[/yellow]") - if holders: - console.print("[yellow] DB holders:[/yellow]") - for holder in holders[:8]: - console.print(f"[yellow] - {holder}[/yellow]") - if len(holders) > 8: - console.print(f"[yellow] ... {len(holders) - 8} more[/yellow]") - else: - console.print("[yellow] No local process with index.sqlite3 open was visible to this user.[/yellow]") - if attempts == 1: - console.print( - "[dim] SQLite does not expose the active SQL statement from another process; only the owning local PIDs can be shown.[/dim]", - ) + log_sqlite_lock_holders(console) with console.status("[yellow]Waiting for SQLite database lock to clear...[/yellow]", spinner="dots"): time.sleep(5.0) diff --git a/archivebox/misc/serve_static.py b/archivebox/misc/serve_static.py index 506d0c09..1ec3ab33 100644 --- a/archivebox/misc/serve_static.py +++ b/archivebox/misc/serve_static.py @@ -24,6 +24,7 @@ from django.http import StreamingHttpResponse, Http404, HttpResponse, HttpRespon from django.utils._os import safe_join from django.utils.http import http_date from django.utils.translation import gettext as _ +from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay from archivebox.config.common import get_config from archivebox.misc.logging_util import printable_filesize @@ -97,7 +98,7 @@ def _cache_policy(config=None, **config_kwargs) -> str: def _render_mhtml_preview_document(filename: str, output_path: str) -> str: - from archivebox.hooks import get_plugin_template + from archivebox.plugins.discovery import get_plugin_template template_str = get_plugin_template("chrome_mhtml", "full", fallback=False) if not template_str: @@ -792,9 +793,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_ bool(request.GET.get("preview")) and content_type.startswith("image/") and not content_type.startswith("image/svg+xml") ) preview_as_mhtml_html = bool(request.GET.get("preview")) and fullpath.suffix.lower() in {".mhtml", ".mht"} - preview_as_archivewebpage_html = bool(request.GET.get("preview")) and ( - fullpath.suffix.lower() in {".wacz", ".warc"} or fullpath.name.lower().endswith(".warc.gz") - ) + preview_as_archivewebpage_html = bool(request.GET.get("preview")) and archivewebpage_replay.is_replay_target(fullpath.name) # Respect the If-Modified-Since header for non-markdown responses. if not (content_type.startswith("text/plain") or content_type.startswith("text/html")): @@ -863,48 +862,32 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_ pass if preview_as_archivewebpage_html: - # POLICY EXCEPTION: archivebox normally does not depend on any specific - # plugin. The WACZ/WARC embedded-replay viewer (ui.js + sw.js + the - # rendered preview HTML) is plugin-owned, but it has to be served at - # plugin-defined paths on the snapshot host so the same-origin service - # worker registration works. There is no clean generic "plugin - # contributes a preview handler" extension hook in archivebox yet, so - # we conditionally import the archivewebpage plugin's - # ``replay_preview`` module here. If the plugin is not installed, the - # import fails and we fall through to default static-file serving. try: - from abx_plugins.plugins.archivewebpage import replay_preview as _awp_preview - except ImportError: - _awp_preview = None - if _awp_preview is not None: - try: - raw_query = request.GET.copy() - raw_query.pop("preview", None) - raw_output_path = request.path - if raw_query: - raw_output_path = f"{raw_output_path}?{raw_query.urlencode()}" - snapshot_url_fallback = getattr(request, "archivebox_snapshot_url", "") or "" - rendered = _awp_preview.render_preview_html( - fullpath.name, - raw_output_path, - wacz_path=fullpath, - fallback_url=snapshot_url_fallback, - ) - response = HttpResponse(rendered, content_type="text/html; charset=utf-8") - response.headers["Last-Modified"] = http_date(statobj.st_mtime) - if etag: - response.headers["ETag"] = etag - response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable" - else: - response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300" - response.headers["Content-Disposition"] = f'inline; filename="{fullpath.stem}.html"' - for key, value in _awp_preview.preview_response_headers().items(): - response.headers[key] = value - if encoding: - response.headers["Content-Encoding"] = encoding - return response - except Exception: - pass + raw_query = request.GET.copy() + raw_query.pop("preview", None) + raw_output_path = request.path + if raw_query: + raw_output_path = f"{raw_output_path}?{raw_query.urlencode()}" + body, preview_content_type, headers = archivewebpage_replay.render_preview_response( + fullpath.name, + raw_output_path, + wacz_path=fullpath, + fallback_url=getattr(request, "archivebox_snapshot_url", "") or "", + last_modified=http_date(statobj.st_mtime), + etag=etag or "", + cache_control=( + f"{_cache_policy(config=config)}, max-age=31536000, immutable" + if etag + else f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300" + ), + content_encoding=encoding or "", + ) + response = HttpResponse(body, content_type=preview_content_type) + for key, value in headers.items(): + response.headers[key] = value + return response + except Exception: + pass if preview_as_mhtml_html: try: diff --git a/archivebox/personas/forms.py b/archivebox/personas/forms.py index c742a4ce..f5bafa32 100644 --- a/archivebox/personas/forms.py +++ b/archivebox/personas/forms.py @@ -6,8 +6,8 @@ from django import forms from django.utils.safestring import mark_safe from archivebox.config.common import get_config -from archivebox.core.forms import PluginConfigFormMixin from archivebox.core.permissions import PERMISSIONS_CHOICES +from archivebox.plugins.forms import PluginConfigFormMixin from archivebox.personas.importers import ( PersonaImportResult, PersonaImportSource, diff --git a/archivebox/personas/models.py b/archivebox/personas/models.py index 664daee3..1fe18761 100644 --- a/archivebox/personas/models.py +++ b/archivebox/personas/models.py @@ -25,7 +25,7 @@ from django.utils import timezone from archivebox.core.permissions import PERMISSIONS_VALUES, normalize_permissions from archivebox.base_models.models import ModelWithConfig, get_or_create_system_user_pk -from archivebox.uuid_compat import uuid7 +from archivebox.uuid_compat import CompactUUIDField, uuid7 _fcntl: Any | None = None try: @@ -81,7 +81,7 @@ class Persona(ModelWithConfig): persona.CHROME_USER_DATA_DIR # -> Path to chrome_profile """ - id = models.UUIDField(primary_key=True, default=uuid7, editable=False, unique=True) + id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True) name = models.CharField(max_length=64, unique=True) created_at = models.DateTimeField(default=timezone.now, db_index=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk) diff --git a/archivebox/plugins/__init__.py b/archivebox/plugins/__init__.py new file mode 100644 index 00000000..4ac8b362 --- /dev/null +++ b/archivebox/plugins/__init__.py @@ -0,0 +1 @@ +__package__ = "archivebox.plugins" diff --git a/archivebox/plugins/apps.py b/archivebox/plugins/apps.py new file mode 100644 index 00000000..9d3c3bb6 --- /dev/null +++ b/archivebox/plugins/apps.py @@ -0,0 +1,9 @@ +__package__ = "archivebox.plugins" + +from django.apps import AppConfig + + +class PluginsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "archivebox.plugins" + verbose_name = "Plugins" diff --git a/archivebox/plugins/discovery.py b/archivebox/plugins/discovery.py new file mode 100644 index 00000000..ecc20bfc --- /dev/null +++ b/archivebox/plugins/discovery.py @@ -0,0 +1,377 @@ +__package__ = "archivebox.plugins" + +import json +import os +from collections.abc import Iterable +from functools import lru_cache +from pathlib import Path +from typing import Any, Protocol, TypedDict + +from abx_plugins import get_plugins_dir +from django.utils.safestring import mark_safe + +from archivebox.config.constants import CONSTANTS + + +class ConfigLookup(Protocol): + def get(self, key: str, default: Any = None) -> Any: ... + + def items(self) -> Iterable[tuple[str, Any]]: ... + + +class PluginSpecialConfig(TypedDict): + enabled: bool + timeout: int + binary: str + + +BUILTIN_PLUGINS_DIR = Path(get_plugins_dir()).resolve() +USER_PLUGINS_DIR = Path( + os.environ.get("ARCHIVEBOX_USER_PLUGINS_DIR") or str(CONSTANTS.USER_PLUGINS_DIR), +).expanduser() + + +def iter_plugin_dirs() -> list[Path]: + """Iterate over all built-in and user plugin directories.""" + plugin_dirs: list[Path] = [] + + for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR): + if not base_dir.exists(): + continue + + for plugin_dir in base_dir.iterdir(): + if plugin_dir.is_dir() and not plugin_dir.name.startswith("_"): + plugin_dirs.append(plugin_dir) + + return plugin_dirs + + +@lru_cache(maxsize=1) +def get_plugins() -> list[str]: + """ + Get list of available plugins by discovering plugin directories. + + Returns plugin directory names for any plugin that exposes hooks, config.json, + or a standardized templates/icon.html asset. This includes non-extractor + plugins such as binary providers and shared base plugins. + """ + plugins = [] + + for plugin_dir in iter_plugin_dirs(): + has_hooks = any(plugin_dir.glob("on_*__*.*")) + has_config = (plugin_dir / "config.json").exists() + has_icon = (plugin_dir / "templates" / "icon.html").exists() + if has_hooks or has_config or has_icon: + plugins.append(plugin_dir.name) + + return sorted(set(plugins)) + + +def get_plugin_name(plugin: str) -> str: + """ + Get the base plugin name without numeric prefix. + + Examples: + '10_title' -> 'title' + '26_readability' -> 'readability' + '50_parse_html_urls' -> 'parse_html_urls' + """ + parts = plugin.split("_", 1) + if len(parts) == 2 and parts[0].isdigit(): + return parts[1] + return plugin + + +def get_enabled_plugins(config: ConfigLookup | None = None, **config_kwargs: Any) -> list[str]: + """ + Get the list of enabled plugins based on config and available hooks. + + Filters plugins by USE_/SAVE_ flags. Only returns plugins that are enabled. + """ + if config is None: + from archivebox.config.common import get_config + + config = get_config(**config_kwargs) + + def normalize_enabled_plugins(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + raw = value.strip() + if not raw: + return [] + if raw.startswith("["): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, list): + return [str(plugin).strip() for plugin in parsed if str(plugin).strip()] + return [plugin.strip() for plugin in raw.split(",") if plugin.strip()] + if isinstance(value, (list, tuple, set)): + return [str(plugin).strip() for plugin in value if str(plugin).strip()] + return [str(value).strip()] if str(value).strip() else [] + + plugins_override = config.get("PLUGINS") + if plugins_override: + return normalize_enabled_plugins(plugins_override) + + enabled = [] + for plugin in get_plugins(): + plugin_config = get_plugin_special_config(plugin, config) + if plugin_config["enabled"]: + enabled.append(plugin) + + return enabled + + +def discover_plugins_that_provide_interface( + module_name: str, + required_attrs: list[str], + plugin_prefix: str | None = None, +) -> dict[str, Any]: + """ + Discover plugins that provide a specific Python module with required interface. + + This enables dynamic plugin discovery for features like search backends, + storage backends, etc. without hardcoding imports. + """ + import importlib.util + + backends = {} + + for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR): + if not base_dir.exists(): + continue + + for plugin_dir in base_dir.iterdir(): + if not plugin_dir.is_dir(): + continue + + plugin_name = plugin_dir.name + if plugin_prefix and not plugin_name.startswith(plugin_prefix): + continue + + module_path = plugin_dir / f"{module_name}.py" + if not module_path.exists(): + continue + + try: + spec = importlib.util.spec_from_file_location( + f"archivebox.dynamic_plugins.{plugin_name}.{module_name}", + module_path, + ) + if spec is None or spec.loader is None: + continue + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if not all(hasattr(module, attr) for attr in required_attrs): + continue + + if plugin_prefix: + backend_name = plugin_name[len(plugin_prefix) :] + else: + backend_name = plugin_name + + backends[backend_name] = module + + except Exception: + continue + + return backends + + +def get_search_backends() -> dict[str, Any]: + """ + Discover all available search backend plugins. + + Search backends must provide a search.py module with: + - search(query: str) -> List[str] (returns snapshot IDs) + - flush(snapshot_ids: Iterable[str]) -> None + """ + return discover_plugins_that_provide_interface( + module_name="search", + required_attrs=["search", "flush"], + plugin_prefix="search_backend_", + ) + + +def discover_plugin_configs() -> dict[str, dict[str, Any]]: + """ + Discover all plugin config.json schemas. + + Each plugin can define a config.json file with JSONSchema defining + its configuration options. + """ + configs = {} + + for plugin_dir in iter_plugin_dirs(): + config_path = plugin_dir / "config.json" + if not config_path.exists(): + continue + + try: + with open(config_path) as f: + schema = json.load(f) + + if not isinstance(schema, dict): + continue + if schema.get("type") != "object": + continue + if "properties" not in schema: + continue + + configs[plugin_dir.name] = schema + + except (json.JSONDecodeError, OSError) as e: + import sys + + print(f"Warning: Failed to load config.json from {plugin_dir.name}: {e}", file=sys.stderr) + continue + + return configs + + +def get_plugin_special_config(plugin_name: str, config: ConfigLookup, _visited: set[str] | None = None) -> PluginSpecialConfig: + """ + Extract special config keys for a plugin following naming conventions. + + ArchiveBox recognizes 3 special config key patterns per plugin: + - {PLUGIN}_ENABLED: Enable/disable toggle (default True) + - {PLUGIN}_TIMEOUT: Plugin-specific timeout (fallback to TIMEOUT, default 300) + - {PLUGIN}_BINARY: Primary binary path (default to plugin_name) + """ + plugin_upper = plugin_name.upper() + + plugins_whitelist = config.get("PLUGINS", "") + if plugins_whitelist: + plugin_configs = discover_plugin_configs() + plugin_names = {p.strip().lower() for p in plugins_whitelist.split(",") if p.strip()} + pending = list(plugin_names) + + while pending: + current = pending.pop() + schema = plugin_configs.get(current, {}) + required_plugins = schema.get("required_plugins", []) + if not isinstance(required_plugins, list): + continue + + for required_plugin in required_plugins: + required_plugin_name = str(required_plugin).strip().lower() + if not required_plugin_name or required_plugin_name in plugin_names: + continue + plugin_names.add(required_plugin_name) + pending.append(required_plugin_name) + + if plugin_name.lower() not in plugin_names: + enabled = False + else: + enabled_key = f"{plugin_upper}_ENABLED" + enabled = config.get(enabled_key) + if enabled is None: + enabled = True + elif isinstance(enabled, str): + enabled = enabled.lower() not in ("false", "0", "no", "") + else: + enabled_key = f"{plugin_upper}_ENABLED" + enabled = config.get(enabled_key) + if enabled is None: + enabled = True + elif isinstance(enabled, str): + enabled = enabled.lower() not in ("false", "0", "no", "") + + plugin_configs = discover_plugin_configs() + plugin_name_lower = plugin_name.lower() + + if enabled: + visited = _visited or set() + if plugin_name_lower not in visited: + next_visited = visited | {plugin_name_lower} + schema = plugin_configs.get(plugin_name_lower, {}) + required_plugins = schema.get("required_plugins", []) + if isinstance(required_plugins, list): + for required_plugin in required_plugins: + required_plugin_name = str(required_plugin).strip() + if not required_plugin_name: + continue + required_config = get_plugin_special_config(required_plugin_name, config, _visited=next_visited) + if not required_config["enabled"]: + enabled = False + break + + timeout_key = f"{plugin_upper}_TIMEOUT" + timeout = config.get(timeout_key) or config.get("TIMEOUT", 300) + + binary_key = f"{plugin_upper}_BINARY" + binary = config.get(binary_key, plugin_name) + + return { + "enabled": bool(enabled), + "timeout": int(timeout), + "binary": str(binary), + } + + +DEFAULT_TEMPLATES = { + "icon": """ + + {{ icon }} + + """, + "card": """ + + """, + "full": """ + + """, +} + + +@lru_cache(maxsize=None) +def get_plugin_template(plugin: str, template_name: str, fallback: bool = True) -> str | None: + """ + Get a plugin template by plugin name and template type. + + Args: + plugin: Plugin name (e.g., 'screenshot', '15_singlefile') + template_name: One of 'icon', 'card', 'full' + fallback: If True, return default template if plugin template not found + """ + base_name = get_plugin_name(plugin) + if base_name in ("yt-dlp", "youtube-dl"): + base_name = "ytdlp" + + for plugin_dir in iter_plugin_dirs(): + if plugin_dir.name == base_name or plugin_dir.name.endswith(f"_{base_name}"): + template_path = plugin_dir / "templates" / f"{template_name}.html" + if template_path.exists(): + return template_path.read_text() + + if fallback: + return DEFAULT_TEMPLATES.get(template_name, "") + + return None + + +@lru_cache(maxsize=None) +def get_plugin_icon(plugin: str) -> str: + """ + Get the icon for a plugin from its icon.html template. + """ + icon_template = get_plugin_template(plugin, "icon", fallback=False) + if icon_template: + return mark_safe(icon_template.strip()) + + return mark_safe("📁") diff --git a/archivebox/plugins/forms.py b/archivebox/plugins/forms.py new file mode 100644 index 00000000..95cae2f1 --- /dev/null +++ b/archivebox/plugins/forms.py @@ -0,0 +1,599 @@ +__package__ = "archivebox.plugins" + +import json +import re +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from django import forms +from django.utils.html import format_html + +from archivebox.config.common import get_config +from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_icon, get_plugins + + +PLUGIN_CONFIG_FIELD_PREFIX = "plugin_config__" +PLUGIN_GROUP_DEFINITIONS = ( + ( + "main_plugins", + "Main", + "", + "", + "", + ( + "dom", + "screenshot", + "pdf", + "singlefile", + "wget", + "archivedotorg", + "chrome_mhtml", + "archivewebpage", + ), + ), + ( + "page_setup_plugins", + "Page Setup", + "", + "", + "", + ( + "chrome", + "infiniscroll", + "modalcloser", + "ublock", + "istilldontcareaboutcookies", + "twocaptcha", + "claudechrome", + ), + ), + ( + "media_plugins", + "Media", + "", + "", + "", + ( + "staticfile", + "responses", + "chrome_screencast", + "ytdlp", + "gallerydl", + "git", + ), + ), + ( + "text_plugins", + "Text", + "", + "", + "", + ( + "readability", + "htmltotext", + "defuddle", + "forumdl", + "mercury", + "trafilatura", + "liteparse", + "opendataloader", + "papersdl", + ), + ), + ( + "metadata_plugins", + "Metadata", + "", + "", + "", + ( + "title", + "favicon", + "headers", + "redirects", + "accessibility", + "consolelog", + "sslcerts", + "dns", + "seo", + "hashes", + ), + ), + ( + "postprocessing_plugins", + "Postprocessing", + "", + "", + "", + ( + "parse_dom_outlinks", + "parse_html_urls", + "parse_jsonl_urls", + "parse_netscape_urls", + "parse_rss_urls", + "parse_txt_urls", + "claudecode", + "claudecodecleanup", + "claudecodeextract", + ), + ), +) +HIDDEN_PLUGIN_CONFIG_UI_PLUGINS = { + "apt", + "base", + "bash", + "brew", + "cargo", + "chromewebstore", + "env", + "media", + "npm", + "pip", + "puppeteer", + "search_backend_ripgrep", + "search_backend_sonic", + "search_backend_sqlite", + "ssl", +} +TIMEOUT_INPUT_PATTERN = r"(0|[1-9][0-9]*|[0-9]+(?:\.[0-9]+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours))" + + +def get_plugin_choices(): + """Get available extractor plugins from discovered hooks.""" + return [(name, name) for name in get_plugins()] + + +def get_plugin_choice_label(plugin_name: str, plugin_configs: dict[str, dict]) -> str: + schema = plugin_configs.get(plugin_name, {}) + description = str(schema.get("description") or "").strip() + if not description: + return plugin_name + icon_html = get_plugin_icon(plugin_name) + + return format_html( + '{}{}', + icon_html, + plugin_name, + ) + + +def get_choice_field(form: forms.Form, name: str) -> forms.ChoiceField: + field = form.fields[name] + if not isinstance(field, forms.ChoiceField): + raise TypeError(f"{name} must be a ChoiceField") + return field + + +def _plugin_config_input_name(plugin_name: str, config_key: str) -> str: + return f"{PLUGIN_CONFIG_FIELD_PREFIX}{plugin_name}__{config_key}" + + +def _schema_types(schema: Mapping[str, Any]) -> list[str]: + raw_type = schema.get("type") or "string" + if isinstance(raw_type, list): + return [str(item) for item in raw_type] + return [str(raw_type)] + + +def _jsonish(value: Any) -> str: + if isinstance(value, str): + return value + return json.dumps(value, sort_keys=True, default=str) + + +def _same_config_value(left: Any, right: Any) -> bool: + return json.dumps(left, sort_keys=True, default=str) == json.dumps(right, sort_keys=True, default=str) + + +def _coerce_plugin_config_value(raw_value: Any, schema: Mapping[str, Any]) -> Any: + schema_types = _schema_types(schema) + + if "boolean" in schema_types: + if isinstance(raw_value, bool): + return raw_value + value = str(raw_value).strip().lower() + if value in {"true", "1", "yes", "on"}: + return True + if value in {"false", "0", "no", "off", ""}: + return False + raise forms.ValidationError("Must be true or false.") + + if "integer" in schema_types: + value = int(str(raw_value).strip()) + minimum = schema.get("minimum") + maximum = schema.get("maximum") + if minimum is not None and value < int(minimum): + raise forms.ValidationError(f"Must be at least {minimum}.") + if maximum is not None and value > int(maximum): + raise forms.ValidationError(f"Must be at most {maximum}.") + return value + + if "number" in schema_types: + value = float(str(raw_value).strip()) + minimum = schema.get("minimum") + maximum = schema.get("maximum") + if minimum is not None and value < float(minimum): + raise forms.ValidationError(f"Must be at least {minimum}.") + if maximum is not None and value > float(maximum): + raise forms.ValidationError(f"Must be at most {maximum}.") + return value + + if "array" in schema_types: + if isinstance(raw_value, list): + return raw_value + value = str(raw_value).strip() + if not value: + return [] + if value.startswith("["): + parsed = json.loads(value) + if not isinstance(parsed, list): + raise forms.ValidationError("Must be a JSON array.") + return parsed + return [item.strip() for item in value.replace(",", "\n").splitlines() if item.strip()] + + if "object" in schema_types: + value = str(raw_value).strip() + if not value: + return {} + parsed = json.loads(value) + if not isinstance(parsed, dict): + raise forms.ValidationError("Must be a JSON object.") + return parsed + + value = str(raw_value) + enum = schema.get("enum") + if isinstance(enum, list) and enum and value not in {str(item) for item in enum}: + raise forms.ValidationError(f"Must be one of: {', '.join(str(item) for item in enum)}.") + return value + + +class PluginConfigFormMixin: + plugin_groups: list[dict[str, Any]] + + def build_plugin_groups(self, runtime_config: Mapping[str, Any] | None = None) -> None: + all_plugins = get_plugins() + plugin_configs = discover_plugin_configs() + runtime_config = runtime_config or get_config() + self.plugin_config_binary_urls = get_plugin_config_binary_urls(runtime_config) + grouped_plugins = set().union(*(group[-1] for group in PLUGIN_GROUP_DEFINITIONS)) + other_plugins = tuple(sorted(set(all_plugins) - grouped_plugins - HIDDEN_PLUGIN_CONFIG_UI_PLUGINS)) + + for field_name, *_rest, plugin_names in PLUGIN_GROUP_DEFINITIONS: + if field_name in self.fields: + get_choice_field(self, field_name).choices = [ + (p, get_plugin_choice_label(p, plugin_configs)) for p in plugin_names if p in all_plugins + ] + + if "other_plugins" in self.fields: + get_choice_field(self, "other_plugins").choices = [(p, get_plugin_choice_label(p, plugin_configs)) for p in other_plugins] + + group_specs = ( + *PLUGIN_GROUP_DEFINITIONS, + ("other_plugins", "Other", "", "", "", other_plugins), + ) + binary_url_lookup = _build_required_binary_url_lookup(plugin_configs, runtime_config) + self.plugin_groups = [ + { + "field_name": field_name, + "title": title, + "note": note, + "dom_id": dom_id, + "select_all_group": select_all_group, + "show_selectors": field_name in self.fields, + "plugins": self._build_plugin_cards(field_name, plugin_names, plugin_configs, runtime_config, binary_url_lookup), + } + for field_name, title, note, dom_id, select_all_group, plugin_names in group_specs + if any(plugin in all_plugins for plugin in plugin_names) + ] + + def _build_plugin_cards( + self, + field_name: str, + plugin_names: Iterable[str], + plugin_configs: dict[str, dict[str, Any]], + runtime_config: Mapping[str, Any], + binary_url_lookup: Mapping[str, str] | None = None, + ) -> list[dict[str, Any]]: + if field_name in self.fields: + choices = list(get_choice_field(self, field_name).choices) + selected_values = set(self.data.getlist(field_name)) if self.is_bound else set(get_choice_field(self, field_name).initial or []) + else: + all_plugins = get_plugins() + choices = [(p, get_plugin_choice_label(p, plugin_configs)) for p in plugin_names if p in all_plugins] + selected_values = set() + + cards = [] + for index, (plugin_name, label) in enumerate(choices): + schema = plugin_configs.get(str(plugin_name), {}) + properties = schema.get("properties") or {} + enabled_config_key = f"{str(plugin_name).upper()}_ENABLED" + enabled_prop_schema = properties.get(enabled_config_key) + if not isinstance(enabled_prop_schema, dict) or "boolean" not in _schema_types(enabled_prop_schema): + enabled_config_key = "" + config_fields = [ + self._build_plugin_config_field(str(plugin_name), str(config_key), prop_schema, runtime_config) + for config_key, prop_schema in properties.items() + if isinstance(prop_schema, dict) + ] + cards.append( + { + "name": str(plugin_name), + "label": label, + "checked": str(plugin_name) in selected_values, + "checkbox_id": f"id_{field_name}_{index}", + "enabled_config_key": enabled_config_key, + "description": str(schema.get("description") or "").strip(), + "source_url": f"https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/{plugin_name}", + "docs_url": f"https://archivebox.github.io/abx-plugins/#{plugin_name}", + "required_plugins": [str(item) for item in schema.get("required_plugins") or []], + "required_binary_links": _build_required_binary_links( + schema.get("required_binaries") or [], + runtime_config, + binary_url_lookup, + ), + "config_fields": config_fields, + "config_count": len(config_fields), + }, + ) + return cards + + def _build_plugin_config_field( + self, + plugin_name: str, + config_key: str, + prop_schema: Mapping[str, Any], + runtime_config: Mapping[str, Any], + ) -> dict[str, Any]: + schema_types = _schema_types(prop_schema) + enum = prop_schema.get("enum") + input_name = _plugin_config_input_name(plugin_name, config_key) + current_value = runtime_config.get(config_key, prop_schema.get("default", "")) + if self.is_bound and input_name in self.data: + try: + current_value = _coerce_plugin_config_value(self.data.get(input_name), prop_schema) + except (TypeError, ValueError, json.JSONDecodeError, forms.ValidationError): + current_value = self.data.get(input_name) + + default_value = prop_schema.get("default", "") + fallback_key = prop_schema.get("x-fallback") + default_display = f"{{{fallback_key}}}" if fallback_key else default_value + from archivebox.config.common import is_sensitive_config_key + + is_sensitive = bool(prop_schema.get("x-sensitive")) or is_sensitive_config_key(config_key) + input_value = "" if is_sensitive else _jsonish(current_value) + field_kind = "text" + input_type = "text" + options = [] + + if "boolean" in schema_types: + field_kind = "boolean" + input_value = "true" if bool(current_value) else "false" + elif isinstance(enum, list) and enum: + field_kind = "select" + options = [ + { + "value": str(option), + "label": str(option), + "selected": str(option) == str(current_value), + } + for option in enum + ] + elif "integer" in schema_types or "number" in schema_types: + field_kind = "number" + input_type = "number" + elif "array" in schema_types or "object" in schema_types: + field_kind = "json" + input_value = "" if is_sensitive else json.dumps(current_value, indent=2, sort_keys=True, default=str) + elif is_sensitive: + input_type = "password" + else: + input_value = "" if is_sensitive else str(current_value) + + return { + "key": config_key, + "input_name": input_name, + "kind": field_kind, + "input_type": input_type, + "value": input_value, + "checked": bool(current_value), + "options": options, + "description": str(prop_schema.get("description") or "").strip(), + "default": _jsonish(default_display), + "current": "configured" + if is_sensitive and current_value + else (str(current_value) if "string" in schema_types else _jsonish(current_value)), + "current_url": self.plugin_config_binary_urls.get(config_key, "") if str(config_key).endswith("_BINARY") else "", + "is_sensitive": is_sensitive, + "minimum": prop_schema.get("minimum"), + "maximum": prop_schema.get("maximum"), + "pattern": prop_schema.get("pattern"), + "type_label": " / ".join(schema_types), + } + + def clean_plugin_config_overrides(self, effective_config: Mapping[str, Any] | None = None) -> dict[str, Any]: + if not self.is_bound: + return {} + + effective_config = effective_config or get_config() + overrides: dict[str, Any] = {} + sources: dict[str, str] = {} + + for plugin_name, schema in discover_plugin_configs().items(): + for config_key, prop_schema in (schema.get("properties") or {}).items(): + if not isinstance(prop_schema, dict): + continue + + input_name = _plugin_config_input_name(plugin_name, config_key) + if input_name not in self.data: + continue + + raw_value: Any = self.data.get(input_name) + if "array" in _schema_types(prop_schema) and isinstance(prop_schema.get("enum"), list): + raw_value = self.data.getlist(input_name) + + from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED, is_sensitive_config_key + + if (prop_schema.get("x-sensitive") or is_sensitive_config_key(config_key)) and raw_value in ( + "", + SENSITIVE_CONFIG_VALUE_REDACTED, + ): + continue + + try: + coerced_value = _coerce_plugin_config_value(raw_value, prop_schema) + except (TypeError, ValueError, json.JSONDecodeError) as err: + self.add_error("config", forms.ValidationError(f"{config_key}: {err}")) + continue + except forms.ValidationError as err: + self.add_error("config", forms.ValidationError(f"{config_key}: {err.messages[0]}")) + continue + + base_value = effective_config.get(config_key, prop_schema.get("default", "")) + if _same_config_value(coerced_value, base_value): + continue + + existing_value = overrides.get(config_key) + if config_key in overrides and not _same_config_value(existing_value, coerced_value): + self.add_error( + "config", + forms.ValidationError( + f"{config_key} was set differently under {sources[config_key]} and {plugin_name}. Set it once in Custom config overrides.", + ), + ) + continue + + overrides[config_key] = coerced_value + sources[config_key] = plugin_name + + return overrides + + def plugin_config_keys(self) -> set[str]: + return { + str(config_key) + for schema in discover_plugin_configs().values() + for config_key, prop_schema in (schema.get("properties") or {}).items() + if isinstance(prop_schema, dict) + } + + +_BINARY_TEMPLATE_PATTERN = re.compile(r"\{([A-Z_][A-Z0-9_]*)\}") + + +def _resolve_required_binary_name(template_name: str, runtime_config: Mapping[str, Any]) -> str: + if "{" not in template_name: + return template_name + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) + try: + value = runtime_config.get(key) + except Exception: + value = None + if value is None or value == "": + return match.group(0) + return str(value) + + resolved = _BINARY_TEMPLATE_PATTERN.sub(_replace, template_name).strip() + if not resolved: + return template_name + return Path(resolved).name if "/" in resolved else resolved + + +def _iter_required_binary_names( + required_binaries: Iterable[Any], + runtime_config: Mapping[str, Any], +) -> Iterable[str]: + for item in required_binaries or []: + if not isinstance(item, dict): + continue + raw_name = str(item.get("name") or "").strip() + if not raw_name: + continue + resolved = _resolve_required_binary_name(raw_name, runtime_config) + if resolved: + yield resolved + + +def _build_required_binary_url_lookup( + plugin_configs: Mapping[str, dict[str, Any]], + runtime_config: Mapping[str, Any], +) -> dict[str, str]: + """Resolve admin URLs for every required binary across all plugin schemas in a single DB query.""" + from archivebox.config.views import get_environment_binary_url, get_installed_binary_change_url + from archivebox.machine.models import Binary, Machine + + resolved_names: set[str] = set() + for schema in plugin_configs.values(): + for name in _iter_required_binary_names(schema.get("required_binaries") or [], runtime_config): + resolved_names.add(name) + + if not resolved_names: + return {} + + machine = Machine.current() + name_to_binary: dict[str, Binary] = {} + for binary in ( + Binary.objects.filter(machine=machine, name__in=resolved_names) + .exclude(abspath="") + .exclude(abspath__isnull=True) + .order_by("-modified_at") + ): + key = binary.name.lower() + if key not in name_to_binary: + name_to_binary[key] = binary + + return { + name: (get_installed_binary_change_url(name, name_to_binary.get(name.lower())) or get_environment_binary_url(name)) + for name in resolved_names + } + + +def _build_required_binary_links( + required_binaries: list[dict[str, Any]], + runtime_config: Mapping[str, Any], + binary_url_lookup: Mapping[str, str] | None = None, +) -> list[dict[str, str]]: + from archivebox.config.views import get_environment_binary_url + + links: list[dict[str, str]] = [] + seen: set[str] = set() + for resolved in _iter_required_binary_names(required_binaries, runtime_config): + if resolved in seen: + continue + seen.add(resolved) + url = (binary_url_lookup or {}).get(resolved) or get_environment_binary_url(resolved) + links.append({"name": resolved, "url": url}) + return links + + +def get_plugin_config_binary_urls(runtime_config: Mapping[str, Any]) -> dict[str, str]: + from archivebox.config.views import get_environment_binary_url, get_installed_binary_change_url + from archivebox.machine.models import Binary, Machine + + binary_keys = { + str(config_key) + for schema in discover_plugin_configs().values() + for config_key, prop_schema in (schema.get("properties") or {}).items() + if isinstance(prop_schema, dict) and str(config_key).endswith("_BINARY") + } + urls: dict[str, str] = {} + machine = Machine.current() + for key in binary_keys: + value = str(runtime_config.get(key) or "").strip() + if not value: + continue + name = Path(value).name if "/" in value else value + binary = Binary.objects.get_valid_binary(value, machine=machine) + if binary is None and "/" in value: + binary = ( + Binary.objects.exclude(abspath="") + .exclude(abspath__isnull=True) + .filter(machine=machine, abspath=value) + .order_by("-modified_at") + .first() + ) + if binary is None and name != value: + binary = Binary.objects.get_valid_binary(name, machine=machine) + urls[key] = get_installed_binary_change_url(getattr(binary, "name", name), binary) or get_environment_binary_url(name) + return urls diff --git a/archivebox/hooks.py b/archivebox/plugins/hooks.py similarity index 56% rename from archivebox/hooks.py rename to archivebox/plugins/hooks.py index a621894b..104c11c9 100644 --- a/archivebox/hooks.py +++ b/archivebox/plugins/hooks.py @@ -41,37 +41,28 @@ API: is_background_hook(name) -> bool Check if hook is background (.bg suffix) """ -__package__ = "archivebox" +__package__ = "archivebox.plugins" -import os import json -from collections.abc import Iterable, Mapping -from functools import lru_cache +import os +from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeGuard, TypedDict +from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeGuard -from abx_plugins import get_plugins_dir -from django.utils.safestring import mark_safe from archivebox.config.constants import CONSTANTS from archivebox.config.version import VERSION from archivebox.misc.util import fix_url_from_markdown, sanitize_extracted_url +from archivebox.plugins.discovery import ( + BUILTIN_PLUGINS_DIR, + USER_PLUGINS_DIR, + ConfigLookup, + get_plugin_special_config, +) if TYPE_CHECKING: from archivebox.machine.models import Process -class ConfigLookup(Protocol): - def get(self, key: str, default: Any = None) -> Any: ... - - def items(self) -> Iterable[tuple[str, Any]]: ... - - -class PluginSpecialConfig(TypedDict): - enabled: bool - timeout: int - binary: str - - class ConfigDump(Protocol): def as_dict(self) -> dict[str, Any]: ... @@ -88,13 +79,6 @@ def _config_to_overrides(config: ConfigLookup | Mapping[str, Any] | None) -> dic return dict(config.items()) -# Plugin directories -BUILTIN_PLUGINS_DIR = Path(get_plugins_dir()).resolve() -USER_PLUGINS_DIR = Path( - os.environ.get("ARCHIVEBOX_USER_PLUGINS_DIR") or str(CONSTANTS.USER_PLUGINS_DIR), -).expanduser() - - # ============================================================================= # Hook Step Extraction # ============================================================================= @@ -125,21 +109,6 @@ def is_finite_background_hook(hook_name: str) -> bool: return ".finite.bg." in hook_name -def iter_plugin_dirs() -> list[Path]: - """Iterate over all built-in and user plugin directories.""" - plugin_dirs: list[Path] = [] - - for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR): - if not base_dir.exists(): - continue - - for plugin_dir in base_dir.iterdir(): - if plugin_dir.is_dir() and not plugin_dir.name.startswith("_"): - plugin_dirs.append(plugin_dir) - - return plugin_dirs - - def normalize_hook_event_name(event_name: str) -> str | None: """ Normalize a hook event family or event class name to its on_* prefix. @@ -319,11 +288,11 @@ def run_hook( """ from archivebox.machine.models import Process, Machine, NetworkInterface from archivebox.config.common import get_config - from archivebox.config.constants import CONSTANTS import sys config_scope = {key.removeprefix("config_"): kwargs.pop(key) for key in list(kwargs) if key.startswith("config_")} resolved_config = get_config(overrides=_config_to_overrides(config), **config_scope) + hook_config = resolved_config.for_crawl_execution() # Auto-detect timeout from plugin config if not explicitly provided if timeout is None: @@ -463,7 +432,7 @@ def run_hook( "SNAP_DIR", "CRAWL_DIR", } - for key, value in resolved_config.items(): + for key, value in hook_config.items(): if key in SKIP_KEYS: continue # Already handled specially above, don't overwrite if value is None: @@ -598,474 +567,6 @@ def collect_urls_from_plugins(snapshot_dir: Path) -> list[dict[str, Any]]: return urls -@lru_cache(maxsize=1) -def get_plugins() -> list[str]: - """ - Get list of available plugins by discovering plugin directories. - - Returns plugin directory names for any plugin that exposes hooks, config.json, - or a standardized templates/icon.html asset. This includes non-extractor - plugins such as binary providers and shared base plugins. - """ - plugins = [] - - for plugin_dir in iter_plugin_dirs(): - has_hooks = any(plugin_dir.glob("on_*__*.*")) - has_config = (plugin_dir / "config.json").exists() - has_icon = (plugin_dir / "templates" / "icon.html").exists() - if has_hooks or has_config or has_icon: - plugins.append(plugin_dir.name) - - return sorted(set(plugins)) - - -def get_plugin_name(plugin: str) -> str: - """ - Get the base plugin name without numeric prefix. - - Examples: - '10_title' -> 'title' - '26_readability' -> 'readability' - '50_parse_html_urls' -> 'parse_html_urls' - """ - # Split on first underscore after any leading digits - parts = plugin.split("_", 1) - if len(parts) == 2 and parts[0].isdigit(): - return parts[1] - return plugin - - -def get_enabled_plugins(config: ConfigLookup | None = None, **config_kwargs: Any) -> list[str]: - """ - Get the list of enabled plugins based on config and available hooks. - - Filters plugins by USE_/SAVE_ flags. Only returns plugins that are enabled. - - Args: - config: Optional pre-merged config dict from get_config(). - **config_kwargs: Scope/override args forwarded to get_config() when config is not supplied. - - Returns: - Plugin names sorted alphabetically (numeric prefix controls order). - - Example: - from archivebox.config.common import get_config - config = get_config(crawl=my_crawl, snapshot=my_snapshot) - enabled = get_enabled_plugins(config) # ['wget', 'media', 'chrome', ...] - """ - # Get merged config if not provided - if config is None: - from archivebox.config.common import get_config - - config = get_config(**config_kwargs) - - def normalize_enabled_plugins(value: Any) -> list[str]: - if value is None: - return [] - if isinstance(value, str): - raw = value.strip() - if not raw: - return [] - if raw.startswith("["): - try: - parsed = json.loads(raw) - except json.JSONDecodeError: - parsed = None - if isinstance(parsed, list): - return [str(plugin).strip() for plugin in parsed if str(plugin).strip()] - return [plugin.strip() for plugin in raw.split(",") if plugin.strip()] - if isinstance(value, (list, tuple, set)): - return [str(plugin).strip() for plugin in value if str(plugin).strip()] - return [str(value).strip()] if str(value).strip() else [] - - # Support explicit PLUGINS override - plugins_override = config.get("PLUGINS") - if plugins_override: - return normalize_enabled_plugins(plugins_override) - - # Filter all plugins by enabled status - all_plugins = get_plugins() - enabled = [] - - for plugin in all_plugins: - plugin_config = get_plugin_special_config(plugin, config) - if plugin_config["enabled"]: - enabled.append(plugin) - - return enabled - - -def discover_plugins_that_provide_interface( - module_name: str, - required_attrs: list[str], - plugin_prefix: str | None = None, -) -> dict[str, Any]: - """ - Discover plugins that provide a specific Python module with required interface. - - This enables dynamic plugin discovery for features like search backends, - storage backends, etc. without hardcoding imports. - - Args: - module_name: Name of the module to look for (e.g., 'search') - required_attrs: List of attributes the module must have (e.g., ['search', 'flush']) - plugin_prefix: Optional prefix to filter plugins (e.g., 'search_backend_') - - Returns: - Dict mapping backend names to imported modules. - Backend name is derived from plugin directory name minus the prefix. - e.g., search_backend_sqlite -> 'sqlite' - - Example: - backends = discover_plugins_that_provide_interface( - module_name='search', - required_attrs=['search', 'flush'], - plugin_prefix='search_backend_', - ) - # Returns: {'sqlite': , 'sonic': , 'ripgrep': } - """ - import importlib.util - - backends = {} - - for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR): - if not base_dir.exists(): - continue - - for plugin_dir in base_dir.iterdir(): - if not plugin_dir.is_dir(): - continue - - plugin_name = plugin_dir.name - - # Filter by prefix if specified - if plugin_prefix and not plugin_name.startswith(plugin_prefix): - continue - - # Look for the module file - module_path = plugin_dir / f"{module_name}.py" - if not module_path.exists(): - continue - - try: - # Import the module dynamically - spec = importlib.util.spec_from_file_location( - f"archivebox.dynamic_plugins.{plugin_name}.{module_name}", - module_path, - ) - if spec is None or spec.loader is None: - continue - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - # Check for required attributes - if not all(hasattr(module, attr) for attr in required_attrs): - continue - - # Derive backend name from plugin directory name - if plugin_prefix: - backend_name = plugin_name[len(plugin_prefix) :] - else: - backend_name = plugin_name - - backends[backend_name] = module - - except Exception: - # Skip plugins that fail to import - continue - - return backends - - -def get_search_backends() -> dict[str, Any]: - """ - Discover all available search backend plugins. - - Search backends must provide a search.py module with: - - search(query: str) -> List[str] (returns snapshot IDs) - - flush(snapshot_ids: Iterable[str]) -> None - - Returns: - Dict mapping backend names to their modules. - e.g., {'sqlite': , 'sonic': , 'ripgrep': } - """ - return discover_plugins_that_provide_interface( - module_name="search", - required_attrs=["search", "flush"], - plugin_prefix="search_backend_", - ) - - -def discover_plugin_configs() -> dict[str, dict[str, Any]]: - """ - Discover all plugin config.json schemas. - - Each plugin can define a config.json file with JSONSchema defining - its configuration options. This function discovers and loads all such schemas. - - The config.json files use JSONSchema draft-07 with custom extensions: - - x-fallback: Global config key to use as fallback - - x-aliases: List of old/alternative config key names - - Returns: - Dict mapping plugin names to their parsed JSONSchema configs. - e.g., {'wget': {...schema...}, 'chrome': {...schema...}} - - Example config.json: - { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "SAVE_WGET": {"type": "boolean", "default": true}, - "WGET_TIMEOUT": {"type": "integer", "default": 60, "x-fallback": "TIMEOUT"} - } - } - """ - configs = {} - - for plugin_dir in iter_plugin_dirs(): - config_path = plugin_dir / "config.json" - if not config_path.exists(): - continue - - try: - with open(config_path) as f: - schema = json.load(f) - - # Basic validation: must be an object with properties - if not isinstance(schema, dict): - continue - if schema.get("type") != "object": - continue - if "properties" not in schema: - continue - - configs[plugin_dir.name] = schema - - except (json.JSONDecodeError, OSError) as e: - # Log warning but continue - malformed config shouldn't break discovery - import sys - - print(f"Warning: Failed to load config.json from {plugin_dir.name}: {e}", file=sys.stderr) - continue - - return configs - - -def get_plugin_special_config(plugin_name: str, config: ConfigLookup, _visited: set[str] | None = None) -> PluginSpecialConfig: - """ - Extract special config keys for a plugin following naming conventions. - - ArchiveBox recognizes 3 special config key patterns per plugin: - - {PLUGIN}_ENABLED: Enable/disable toggle (default True) - - {PLUGIN}_TIMEOUT: Plugin-specific timeout (fallback to TIMEOUT, default 300) - - {PLUGIN}_BINARY: Primary binary path (default to plugin_name) - - These allow ArchiveBox to: - - Skip disabled plugins (optimization) - - Enforce plugin-specific timeouts automatically - - Discover plugin binaries for validation - - Args: - plugin_name: Plugin name (e.g., 'wget', 'media', 'chrome') - config: Merged config dict from get_config() (properly merges file, env, machine, crawl, snapshot) - - Returns: - Dict with standardized keys: - { - 'enabled': True, # bool - 'timeout': 60, # int, seconds - 'binary': 'wget', # str, path or name - } - - Examples: - >>> from archivebox.config.common import get_config - >>> config = get_config(crawl=my_crawl, snapshot=my_snapshot) - >>> get_plugin_special_config('wget', config) - {'enabled': True, 'timeout': 120, 'binary': '/usr/bin/wget'} - """ - plugin_upper = plugin_name.upper() - - # 1. Enabled: Check PLUGINS whitelist first, then PLUGINNAME_ENABLED (default True) - # Old names (USE_*, SAVE_*) are aliased in config.json via x-aliases - - # Check if PLUGINS whitelist is specified (e.g., --plugins=wget,favicon) - plugins_whitelist = config.get("PLUGINS", "") - if plugins_whitelist: - # PLUGINS whitelist is specified - include transitive required_plugins from - # config.json so selecting a plugin also enables its declared plugin-level - # dependencies (e.g. singlefile -> chrome). - plugin_configs = discover_plugin_configs() - plugin_names = {p.strip().lower() for p in plugins_whitelist.split(",") if p.strip()} - pending = list(plugin_names) - - while pending: - current = pending.pop() - schema = plugin_configs.get(current, {}) - required_plugins = schema.get("required_plugins", []) - if not isinstance(required_plugins, list): - continue - - for required_plugin in required_plugins: - required_plugin_name = str(required_plugin).strip().lower() - if not required_plugin_name or required_plugin_name in plugin_names: - continue - plugin_names.add(required_plugin_name) - pending.append(required_plugin_name) - - if plugin_name.lower() not in plugin_names: - # Plugin not in whitelist - explicitly disabled - enabled = False - else: - # Plugin is in whitelist - check if explicitly disabled by PLUGINNAME_ENABLED - enabled_key = f"{plugin_upper}_ENABLED" - enabled = config.get(enabled_key) - if enabled is None: - enabled = True # Default to enabled if in whitelist - elif isinstance(enabled, str): - enabled = enabled.lower() not in ("false", "0", "no", "") - else: - # No PLUGINS whitelist - use PLUGINNAME_ENABLED (default True) - enabled_key = f"{plugin_upper}_ENABLED" - enabled = config.get(enabled_key) - if enabled is None: - enabled = True - elif isinstance(enabled, str): - # Handle string values from config file ("true"/"false") - enabled = enabled.lower() not in ("false", "0", "no", "") - - plugin_configs = discover_plugin_configs() - plugin_name_lower = plugin_name.lower() - - if enabled: - visited = _visited or set() - if plugin_name_lower not in visited: - next_visited = visited | {plugin_name_lower} - schema = plugin_configs.get(plugin_name_lower, {}) - required_plugins = schema.get("required_plugins", []) - if isinstance(required_plugins, list): - for required_plugin in required_plugins: - required_plugin_name = str(required_plugin).strip() - if not required_plugin_name: - continue - required_config = get_plugin_special_config(required_plugin_name, config, _visited=next_visited) - if not required_config["enabled"]: - enabled = False - break - - # 2. Timeout: PLUGINNAME_TIMEOUT (fallback to TIMEOUT, default 300) - timeout_key = f"{plugin_upper}_TIMEOUT" - timeout = config.get(timeout_key) or config.get("TIMEOUT", 300) - - # 3. Binary: PLUGINNAME_BINARY (default to plugin_name) - binary_key = f"{plugin_upper}_BINARY" - binary = config.get(binary_key, plugin_name) - - return { - "enabled": bool(enabled), - "timeout": int(timeout), - "binary": str(binary), - } - - -# ============================================================================= -# Plugin Template Discovery -# ============================================================================= -# -# Plugins can provide custom templates for rendering their output in the UI. -# Templates are discovered by filename convention inside each plugin's templates/ dir: -# -# abx_plugins/plugins// -# templates/ -# icon.html # Icon for admin table view (small inline HTML) -# card.html # Preview card for snapshot header -# full.html # Fullscreen view template -# -# Template context variables available: -# {{ result }} - ArchiveResult object -# {{ snapshot }} - Parent Snapshot object -# {{ output_path }} - Path to output file/dir relative to snapshot dir -# {{ plugin }} - Plugin name (e.g., 'screenshot', 'singlefile') -# - -# Default templates used when plugin doesn't provide one -DEFAULT_TEMPLATES = { - "icon": """ - - {{ icon }} - - """, - "card": """ - - """, - "full": """ - - """, -} - - -@lru_cache(maxsize=None) -def get_plugin_template(plugin: str, template_name: str, fallback: bool = True) -> str | None: - """ - Get a plugin template by plugin name and template type. - - Args: - plugin: Plugin name (e.g., 'screenshot', '15_singlefile') - template_name: One of 'icon', 'card', 'full' - fallback: If True, return default template if plugin template not found - - Returns: - Template content as string, or None if not found and fallback=False. - """ - base_name = get_plugin_name(plugin) - if base_name in ("yt-dlp", "youtube-dl"): - base_name = "ytdlp" - - for plugin_dir in iter_plugin_dirs(): - # Match by directory name (exact or partial) - if plugin_dir.name == base_name or plugin_dir.name.endswith(f"_{base_name}"): - template_path = plugin_dir / "templates" / f"{template_name}.html" - if template_path.exists(): - return template_path.read_text() - - # Fall back to default template if requested - if fallback: - return DEFAULT_TEMPLATES.get(template_name, "") - - return None - - -@lru_cache(maxsize=None) -def get_plugin_icon(plugin: str) -> str: - """ - Get the icon for a plugin from its icon.html template. - - Args: - plugin: Plugin name (e.g., 'screenshot', '15_singlefile') - - Returns: - Icon HTML/emoji string. - """ - # Try plugin-provided icon template - icon_template = get_plugin_template(plugin, "icon", fallback=False) - if icon_template: - return mark_safe(icon_template.strip()) - - # Fall back to generic folder icon - return mark_safe("📁") - - # ============================================================================= # Hook Result Processing Helpers # ============================================================================= diff --git a/archivebox/plugins/views.py b/archivebox/plugins/views.py new file mode 100644 index 00000000..9ef503d8 --- /dev/null +++ b/archivebox/plugins/views.py @@ -0,0 +1,462 @@ +__package__ = "archivebox.plugins" + +import html +import json +import re +from typing import Any +from collections.abc import Callable +from urllib.parse import quote + +from django.http import HttpRequest +from django.utils.html import format_html +from django.utils.safestring import mark_safe + +from admin_data_views.typing import ItemContext, SectionData, TableContext +from admin_data_views.utils import ItemLink, render_with_item_view, render_with_table_view + +from archivebox.config.views import get_environment_binary_url, is_superuser +from archivebox.plugins.discovery import BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR, discover_plugin_configs, iter_plugin_dirs + + +ABX_PLUGINS_DOCS_BASE_URL = "https://archivebox.github.io/abx-plugins/" +ABX_PLUGINS_GITHUB_BASE_URL = "https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/" +LIVE_CONFIG_BASE_URL = "/admin/environment/config/" +LIVE_PLUGIN_BASE_URL = "/admin/environment/plugins/" + + +JSON_TOKEN_RE = re.compile( + r'(?P"(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*")(?=\s*:)' + r'|(?P"(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*")' + r"|(?P\btrue\b|\bfalse\b)" + r"|(?P\bnull\b)" + r"|(?P-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)", +) + + +def render_code_block(text: str, *, highlighted: bool = False) -> str: + code = html.escape(text, quote=False) + + if highlighted: + + def _wrap_token(match: re.Match[str]) -> str: + styles = { + "key": "color: #0550ae;", + "string": "color: #0a7f45;", + "boolean": "color: #8250df; font-weight: 600;", + "null": "color: #6e7781; font-style: italic;", + "number": "color: #b35900;", + } + token_type = next(name for name, value in match.groupdict().items() if value is not None) + return f'{match.group(0)}' + + code = JSON_TOKEN_RE.sub(_wrap_token, code) + + return ( + '
    '
    +        '"
    +        f"{code}"
    +        "
    " + ) + + +def render_highlighted_json_block(value: Any) -> str: + return render_code_block(json.dumps(value, indent=2, ensure_ascii=False), highlighted=True) + + +def get_plugin_docs_url(plugin_name: str) -> str: + return f"{ABX_PLUGINS_DOCS_BASE_URL}#{plugin_name}" + + +def get_plugin_hook_source_url(plugin_name: str, hook_name: str) -> str: + return f"{ABX_PLUGINS_GITHUB_BASE_URL}{quote(plugin_name)}/{quote(hook_name)}" + + +def get_live_config_url(key: str) -> str: + return f"{LIVE_CONFIG_BASE_URL}{quote(key)}/" + + +def get_machine_admin_url() -> str | None: + try: + from archivebox.machine.models import Machine + + machine = Machine.current() + return getattr(machine, "admin_change_url", None) or f"/admin/machine/machine/{machine.id}/change/" + except Exception: + return None + + +def render_code_tag_list(values: list[str]) -> str: + if not values: + return '(none)' + + tags = "".join( + str( + format_html( + '{}', + value, + ), + ) + for value in values + ) + return f'
    {tags}
    ' + + +def render_link_tag_list(values: list[str], url_resolver: Callable[[str], str] | None = None) -> str: + if not values: + return '(none)' + + tags = [] + for value in values: + if url_resolver is None: + tags.append( + str( + format_html( + '{}', + value, + ), + ), + ) + else: + tags.append( + str( + format_html( + '' + '{}' + "", + url_resolver(value), + value, + ), + ), + ) + return f'
    {"".join(tags)}
    ' + + +def render_plugin_metadata_html(config: dict[str, Any]) -> str: + required_binaries = [ + str(item.get("name")) for item in (config.get("required_binaries") or []) if isinstance(item, dict) and item.get("name") + ] + rows = ( + ("Title", config.get("title") or "(none)"), + ("Description", config.get("description") or "(none)"), + ("Required Plugins", mark_safe(render_link_tag_list(config.get("required_plugins") or [], get_plugin_docs_url))), + ("Required Binaries", mark_safe(render_link_tag_list(required_binaries, get_environment_binary_url))), + ("Output MIME Types", mark_safe(render_code_tag_list(config.get("output_mimetypes") or []))), + ) + + rendered_rows = "".join( + str( + format_html( + '
    {}
    {}
    ', + label, + value, + ), + ) + for label, value in rows + ) + return f'
    {rendered_rows}
    ' + + +def render_property_links(prop_name: str, prop_info: dict[str, Any], machine_admin_url: str | None) -> str: + links = [ + str(format_html('Computed value', get_live_config_url(prop_name))), + ] + if machine_admin_url: + links.append(str(format_html('Edit override', machine_admin_url))) + + fallback = prop_info.get("x-fallback") + if isinstance(fallback, str) and fallback: + links.append(str(format_html('Fallback: {}', get_live_config_url(fallback), fallback))) + + aliases = prop_info.get("x-aliases") or [] + if isinstance(aliases, list): + for alias in aliases: + if isinstance(alias, str) and alias: + links.append(str(format_html('Alias: {}', get_live_config_url(alias), alias))) + + default = prop_info.get("default") + if prop_name.endswith("_BINARY") and isinstance(default, str) and default: + links.append(str(format_html('Binary: {}', get_environment_binary_url(default), default))) + + return "   ".join(links) + + +def render_config_properties_html(properties: dict[str, Any], machine_admin_url: str | None) -> str: + header_links = [ + str(format_html('Dependencies', "/admin/environment/binaries/")), + str(format_html('Installed Binaries', "/admin/machine/binary/")), + ] + if machine_admin_url: + header_links.insert(0, str(format_html('Machine Config Editor', machine_admin_url))) + + cards = [ + f'
    {"   |   ".join(header_links)}
    ', + ] + + for prop_name, prop_info in properties.items(): + prop_type = prop_info.get("type", "unknown") + if isinstance(prop_type, list): + prop_type = " | ".join(str(type_name) for type_name in prop_type) + prop_desc = prop_info.get("description", "") + + default_html = "" + if "default" in prop_info: + default_html = str( + format_html( + '
    Default: {}
    ', + prop_info["default"], + ), + ) + + description_html = prop_desc or mark_safe('(no description)') + cards.append( + str( + format_html( + '
    ' + '
    ' + '{}' + ' ({})' + "
    " + '
    {}
    ' + '
    {}
    ' + "{}" + "
    ", + get_live_config_url(prop_name), + prop_name, + prop_type, + description_html, + mark_safe(render_property_links(prop_name, prop_info, machine_admin_url)), + mark_safe(default_html), + ), + ), + ) + + return "".join(cards) + + +def render_hook_links_html(plugin_name: str, hooks: list[str], source: str) -> str: + if not hooks: + return '(none)' + + items = [] + for hook_name in hooks: + if source == "builtin": + items.append( + str( + format_html( + '', + get_plugin_hook_source_url(plugin_name, hook_name), + hook_name, + ), + ), + ) + else: + items.append( + str( + format_html( + '
    {}
    ', + hook_name, + ), + ), + ) + return "".join(items) + + +def get_filesystem_plugins() -> dict[str, dict[str, Any]]: + """Discover plugins from filesystem directories.""" + plugins = {} + + for base_dir, source in [(BUILTIN_PLUGINS_DIR, "builtin"), (USER_PLUGINS_DIR, "user")]: + if not base_dir.exists(): + continue + + for plugin_dir in base_dir.iterdir(): + if plugin_dir.is_dir() and not plugin_dir.name.startswith("_"): + plugin_id = f"{source}.{plugin_dir.name}" + + hooks = [] + for ext in ("sh", "py", "js"): + hooks.extend(plugin_dir.glob(f"on_*__*.{ext}")) + + config_file = plugin_dir / "config.json" + config_data = None + if config_file.exists(): + try: + with open(config_file) as f: + config_data = json.load(f) + except (json.JSONDecodeError, OSError): + config_data = None + + plugins[plugin_id] = { + "id": plugin_id, + "name": plugin_dir.name, + "path": str(plugin_dir), + "source": source, + "hooks": [str(h.name) for h in hooks], + "config": config_data, + } + + return plugins + + +def find_plugin_for_config_key(key: str) -> str | None: + for plugin_name, schema in discover_plugin_configs().items(): + if key in (schema.get("properties") or {}): + return plugin_name + return None + + +def get_config_definition_link(key: str) -> tuple[str, str]: + plugin_name = find_plugin_for_config_key(key) + if not plugin_name: + return ( + f"https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{quote(key)}&type=code", + "archivebox/config", + ) + + plugin_dir = next((path.resolve() for path in iter_plugin_dirs() if path.name == plugin_name), None) + if plugin_dir: + builtin_root = BUILTIN_PLUGINS_DIR.resolve() + if plugin_dir.is_relative_to(builtin_root): + return ( + f"{ABX_PLUGINS_GITHUB_BASE_URL}{quote(plugin_name)}/config.json", + f"abx_plugins/plugins/{plugin_name}/config.json", + ) + + user_root = USER_PLUGINS_DIR.resolve() + if plugin_dir.is_relative_to(user_root): + return ( + f"{LIVE_PLUGIN_BASE_URL}user.{quote(plugin_name)}/", + f"data/custom_plugins/{plugin_name}/config.json", + ) + + return ( + f"{LIVE_PLUGIN_BASE_URL}builtin.{quote(plugin_name)}/", + f"abx_plugins/plugins/{plugin_name}/config.json", + ) + + +@render_with_table_view +def plugins_list_view(request: HttpRequest, **kwargs) -> TableContext: + assert is_superuser(request), "Must be a superuser to view configuration settings." + + rows = { + "Name": [], + "Source": [], + "Path": [], + "Hooks": [], + "Config": [], + } + + plugins = get_filesystem_plugins() + + for plugin_id, plugin in plugins.items(): + rows["Name"].append(ItemLink(plugin["name"], key=plugin_id)) + rows["Source"].append(plugin["source"]) + rows["Path"].append(format_html("{}", plugin["path"])) + rows["Hooks"].append(", ".join(plugin["hooks"]) or "(none)") + + if plugin.get("config"): + config_properties = plugin["config"].get("properties", {}) + config_count = len(config_properties) + rows["Config"].append(f"✅ {config_count} properties" if config_count > 0 else "✅ present") + else: + rows["Config"].append("❌ none") + + if not plugins: + rows["Name"].append("(no plugins found)") + rows["Source"].append("-") + rows["Path"].append(mark_safe("abx_plugins/plugins/ or data/custom_plugins/")) + rows["Hooks"].append("-") + rows["Config"].append("-") + + return TableContext( + title="Installed plugins", + table=rows, + ) + + +@render_with_item_view +def plugin_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: + assert is_superuser(request), "Must be a superuser to view configuration settings." + + plugins = get_filesystem_plugins() + + plugin = plugins.get(key) + if not plugin: + return ItemContext( + slug=key, + title=f"Plugin not found: {key}", + data=[], + ) + + docs_url = get_plugin_docs_url(plugin["name"]) + machine_admin_url = get_machine_admin_url() + fields = { + "id": plugin["id"], + "name": plugin["name"], + "source": plugin["source"], + } + + sections: list[SectionData] = [ + { + "name": plugin["name"], + "description": format_html( + '{}
    ABX Plugin Docs', + plugin["path"], + docs_url, + ), + "fields": fields, + "help_texts": {}, + }, + ] + + if plugin["hooks"]: + sections.append( + { + "name": "Hooks", + "description": mark_safe(render_hook_links_html(plugin["name"], plugin["hooks"], plugin["source"])), + "fields": {}, + "help_texts": {}, + }, + ) + + if plugin.get("config"): + sections.append( + { + "name": "Plugin Metadata", + "description": mark_safe(render_plugin_metadata_html(plugin["config"])), + "fields": {}, + "help_texts": {}, + }, + ) + + sections.append( + { + "name": "config.json", + "description": mark_safe(render_highlighted_json_block(plugin["config"])), + "fields": {}, + "help_texts": {}, + }, + ) + + config_properties = plugin["config"].get("properties", {}) + if config_properties: + sections.append( + { + "name": "Config Properties", + "description": mark_safe(render_config_properties_html(config_properties, machine_admin_url)), + "fields": {}, + "help_texts": {}, + }, + ) + + return ItemContext( + slug=key, + title=plugin["name"], + data=sections, + ) diff --git a/archivebox/progressmonitor/__init__.py b/archivebox/progressmonitor/__init__.py new file mode 100644 index 00000000..e3f7e93a --- /dev/null +++ b/archivebox/progressmonitor/__init__.py @@ -0,0 +1 @@ +__package__ = "archivebox.progressmonitor" diff --git a/archivebox/progressmonitor/apps.py b/archivebox/progressmonitor/apps.py new file mode 100644 index 00000000..15fe1d58 --- /dev/null +++ b/archivebox/progressmonitor/apps.py @@ -0,0 +1,8 @@ +__package__ = "archivebox.progressmonitor" + +from django.apps import AppConfig + + +class ProgressMonitorConfig(AppConfig): + name = "archivebox.progressmonitor" + label = "progressmonitor" diff --git a/archivebox/templates/admin/progress_monitor.html b/archivebox/progressmonitor/templates/progressmonitor/progress_monitor.html similarity index 89% rename from archivebox/templates/admin/progress_monitor.html rename to archivebox/progressmonitor/templates/progressmonitor/progress_monitor.html index 4c6c6f39..d8bb74bf 100644 --- a/archivebox/templates/admin/progress_monitor.html +++ b/archivebox/progressmonitor/templates/progressmonitor/progress_monitor.html @@ -755,101 +755,6 @@ white-space: nowrap; } - /* Thumbnail Strip */ - #progress-monitor .thumbnail-strip { - display: flex; - gap: 8px; - padding: 10px 16px; - background: rgba(0,0,0,0.15); - border-top: 1px solid #21262d; - overflow-x: auto; - scrollbar-width: thin; - scrollbar-color: #30363d #0d1117; - } - #progress-monitor .thumbnail-strip::-webkit-scrollbar { - height: 6px; - } - #progress-monitor .thumbnail-strip::-webkit-scrollbar-track { - background: #0d1117; - } - #progress-monitor .thumbnail-strip::-webkit-scrollbar-thumb { - background: #30363d; - border-radius: 3px; - } - #progress-monitor .thumbnail-strip::-webkit-scrollbar-thumb:hover { - background: #484f58; - } - #progress-monitor .thumbnail-strip.empty { - display: none; - } - #progress-monitor .thumbnail-item { - flex-shrink: 0; - position: relative; - width: 64px; - height: 48px; - border-radius: 4px; - overflow: hidden; - border: 1px solid #30363d; - background: #161b22; - cursor: pointer; - transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; - } - #progress-monitor .thumbnail-item:hover { - transform: scale(1.1); - border-color: #58a6ff; - box-shadow: 0 0 12px rgba(88, 166, 255, 0.3); - z-index: 10; - } - #progress-monitor .thumbnail-item.new { - animation: thumbnail-pop 0.4s ease-out; - } - @keyframes thumbnail-pop { - 0% { transform: scale(0.5); opacity: 0; } - 50% { transform: scale(1.15); } - 100% { transform: scale(1); opacity: 1; } - } - #progress-monitor .thumbnail-item img { - width: 100%; - height: 100%; - object-fit: cover; - } - #progress-monitor .thumbnail-item .thumbnail-fallback { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - color: #8b949e; - background: linear-gradient(135deg, #21262d 0%, #161b22 100%); - } - #progress-monitor .thumbnail-item .thumbnail-plugin { - position: absolute; - bottom: 0; - left: 0; - right: 0; - padding: 2px 4px; - font-size: 8px; - font-weight: 600; - text-transform: uppercase; - color: #fff; - background: rgba(0,0,0,0.7); - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - #progress-monitor .thumbnail-label { - display: flex; - align-items: center; - gap: 6px; - padding: 0 4px; - color: #8b949e; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.5px; - flex-shrink: 0; - } #progress-monitor .pid-label { display: inline-flex; align-items: center; @@ -1048,9 +953,10 @@ -