diff --git a/.github/workflows/deploy-publicsite.yml b/.github/workflows/deploy-publicsite.yml
index 5fcc78ca..5316ec46 100644
--- a/.github/workflows/deploy-publicsite.yml
+++ b/.github/workflows/deploy-publicsite.yml
@@ -27,7 +27,6 @@ concurrency:
jobs:
deploy:
- if: github.event_name != 'push' || github.event.head_commit.author.email != 'release-bot@archivebox.io'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
@@ -51,6 +50,22 @@ jobs:
env:
BASE_URL: http://archivebox.localhost:8000
+ - name: Verify gallery was generated from this exact revision
+ run: |
+ uv run --no-cache --no-sync python - <<'PY'
+ import json
+ import os
+ import tomllib
+ from pathlib import Path
+
+ provenance = json.loads(Path("publicsite/screenshots/build.json").read_text())
+ version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
+ assert provenance["revision"] == os.environ["GITHUB_SHA"], provenance
+ assert provenance["version"] == version, provenance
+ assert provenance["capture_count"] >= 3 * 35, provenance
+ assert len(provenance["files"]) == provenance["capture_count"], provenance
+ PY
+
- name: Setup Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
diff --git a/Dockerfile b/Dockerfile
index 81795c61..856ec7ed 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,10 +10,10 @@
# --build-context abx-plugins=../abx-plugins \
# -t archivebox/abx-dl:dev
# docker buildx build . -f Dockerfile \
-# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.188 \
+# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.225 \
# -t archivebox:multistage
-ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.188
+ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.225
FROM archivebox/sonic:1.4.9 AS sonic
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
@@ -156,19 +156,20 @@ PY
--no-install-project \
--no-install-workspace \
--no-sources
-ABXPKG_NO_CACHE=True abxpkg env --install --binproviders=env,apt --lib="$ABXPKG_LIB_DIR" --overrides='{"apt":{"install_args":["binutils"]}}' strip >/dev/null
+builder_abxpkg_lib_dir=/tmp/archivebox-builder-abxpkg
+ABXPKG_NO_CACHE=True abxpkg env --install --binproviders=env,apt --lib="$builder_abxpkg_lib_dir" --overrides='{"apt":{"install_args":["binutils"]}}' strip >/dev/null
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print0 > /tmp/archivebox-native-libraries
while IFS= read -r -d '' native_library; do
magic=''
if IFS= read -r -N 4 magic < "$native_library" && [[ "$magic" == $'\x7fELF' ]]; then
- "$ABXPKG_LIB_DIR/env/bin/strip" --strip-unneeded "$native_library" || exit $?
+ "$builder_abxpkg_lib_dir/env/bin/strip" --strip-unneeded "$native_library" || exit $?
fi
done < /tmp/archivebox-native-libraries
rm -f /tmp/archivebox-native-libraries
rm -f /venv/bin/uv /venv/bin/uvx
-abxpkg run --binproviders=env --lib="$ABXPKG_LIB_DIR" apt-get purge -y binutils build-essential gcc libldap2-dev libsasl2-dev libssl-dev
-abxpkg run --binproviders=env --lib="$ABXPKG_LIB_DIR" apt-get autoremove -y
-/usr/bin/find "$ABXPKG_LIB_DIR/env/bin" -maxdepth 1 -type l -name strip -delete
+abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils build-essential gcc libldap2-dev libsasl2-dev libssl-dev
+abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get autoremove -y
+rm -rf "$builder_abxpkg_lib_dir"
rm -rf /venv/lib/python3.*/site-packages/pip* \
/venv/lib/python3.*/site-packages/wheel* \
/venv/bin/pip /venv/bin/pip3 /venv/bin/pip3.* /venv/bin/wheel
diff --git a/README.md b/README.md
index b5e86667..671d4c7e 100644
--- a/README.md
+++ b/README.md
@@ -80,8 +80,6 @@ docker compose up -d --wait # ini
# Option B: Or use it as a plain Docker container:
mkdir -p ~/archivebox/data && cd ~/archivebox/data
-docker run --rm -it -v "$PWD:/data" archivebox/archivebox:dev init
-docker run --rm -it -v "$PWD:/data" archivebox/archivebox:dev install
docker run -d --name archivebox -v "$PWD:/data" -p 8000:8000 archivebox/archivebox:dev
# open http://admin.archivebox.localhost:8000 to finish setup
# docker run -it -v $PWD:/data archivebox/archivebox:dev add 'https://example.com'
@@ -187,17 +185,16 @@ See below for more usage examples using the C
Install Docker on your system (if not already installed).
-
Create a new empty directory and initialize your collection (can be anywhere).
+
Create a new empty directory and start the server, which initializes the collection automatically (can be anywhere).
mkdir -p ~/archivebox/data && cd ~/archivebox/data
-docker run --rm -v $PWD:/data -it archivebox/archivebox:dev init
-docker run --rm -v $PWD:/data -it archivebox/archivebox:dev install
+docker run -d --name archivebox -v $PWD:/data -p 8000:8000 archivebox/archivebox:dev
-
Optional: Start the server, then open /admin/ on the hostname or IP used to reach ArchiveBox (local example: http://admin.archivebox.localhost:8000/admin/) to create the first admin. If BASE_URL is not configured yet, continue through the web setup wizard.
-
docker run -v $PWD:/data -p 8000:8000 archivebox/archivebox:dev
+
Open /admin/ on the hostname or IP used to reach ArchiveBox (local example: http://admin.archivebox.localhost:8000/admin/) to create the first admin. If BASE_URL is not configured yet, continue through the web setup wizard.
+
# completely optional, CLI can always be used without running a server
-# docker run -v $PWD:/data -it archivebox/archivebox:dev [subcommand] [--help]
-docker run -v $PWD:/data -it archivebox/archivebox:dev help
+# docker exec archivebox archivebox [subcommand] [--help]
+docker exec archivebox archivebox help
@@ -497,12 +494,10 @@ archivebox help # get list of archivebox subcommands that can be ru
# make sure you have `docker-compose.yml` from the Quickstart instructions first
-# docker compose run --rm archivebox [subcommand] [--help]
-docker compose run --rm archivebox init
-docker compose run --rm archivebox install
-docker compose run --rm archivebox version
-docker compose run --rm archivebox help
-docker compose run --rm archivebox add 'https://example.com'
+# docker compose exec archivebox archivebox [subcommand] [--help]
+docker compose exec archivebox archivebox version
+docker compose exec archivebox archivebox help
+docker compose exec archivebox archivebox add 'https://example.com'
# to start webserver: docker compose up
For more info, see our Usage: Docker Compose CLI wiki. ➡️
@@ -514,15 +509,12 @@ docker compose run --rm archivebox add 'https://example.com'
CLI Usage Examples: Docker
-# make sure you create and cd into in a new empty directory first
+# make sure the `archivebox` server container from the Quickstart is running first
-# docker run -it -v $PWD:/data archivebox/archivebox:dev [subcommand] [--help]
-docker run -v $PWD:/data -it archivebox/archivebox:dev init
-docker run -v $PWD:/data -it archivebox/archivebox:dev install
-docker run -v $PWD:/data -it archivebox/archivebox:dev version
-docker run -v $PWD:/data -it archivebox/archivebox:dev help
-docker run -v $PWD:/data -it archivebox/archivebox:dev add 'https://example.com'
-# to start webserver: docker run -v $PWD:/data -it -p 8000:8000 archivebox/archivebox:dev
+# docker exec archivebox archivebox [subcommand] [--help]
+docker exec archivebox archivebox version
+docker exec archivebox archivebox help
+docker exec archivebox archivebox add 'https://example.com'
For more info, see our Usage: Docker CLI wiki. ➡️
@@ -682,7 +674,7 @@ It uses all available methods out-of-the-box, but you can disable extractors and
Expand to see the full list of ways it saves each page...
-data/archive/{Snapshot.id}/
+data/archive/users/{username}/snapshots/{YYYYMMDD}/{domain}/{Snapshot.id}/
Index:index.html & index.json HTML and JSON index files containing metadata and details
Title, Favicon, Headers Response headers, site favicon, and parsed site title
@@ -849,7 +841,7 @@ The on-disk layout is optimized to be easy to browse by hand and durable long-te
...
-Each snapshot subfolder includes static metadata and plain extractor output files. ArchiveBox also maintains a backwards-compatible data/archive/TIMESTAMP symlink for each snapshot.
+Each snapshot subfolder includes static metadata and plain extractor output files. Current releases do not create top-level data/archive/TIMESTAMP projections; legacy timestamp directories are migrated into the user-scoped tree by archivebox update --migrate-only.
Learn More
@@ -1345,14 +1337,11 @@ archivebox server 0.0.0.0:8000
# inside the container will reload and pick up your changes
./bin/build_docker.sh dev
-docker run -it -v $PWD/data:/data archivebox/archivebox:dev init
-docker run -it -v $PWD/data:/data archivebox/archivebox:dev install
-
# Run the development server w/ autoreloading (but no bg workers)
-docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 8000:8000 archivebox/archivebox:dev server --debug --reload 0.0.0.0:8000
+docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 8000:8000 archivebox/archivebox:dev server --init --debug --reload 0.0.0.0:8000
# Run the production server (with bg workers but no autoreloading)
-docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 8000:8000 archivebox/archivebox:dev server
+docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 8000:8000 archivebox/archivebox:dev server --init
# (remove the --reload flag and add the --nothreading flag when profiling with the django debug toolbar)
# When using --reload, make sure any files you create can be read by the user in the Docker container, eg with 'chmod a+rX'.
@@ -1413,7 +1402,7 @@ services:
# or with plain Docker:
docker build -t archivebox:dev https://github.com/ArchiveBox/ArchiveBox.git#dev
-docker run -it -v $PWD:/data archivebox:dev init
+docker run -it -v $PWD:/data -p 8000:8000 archivebox:dev
# or with uv:
uv tool install --python 3.13 --upgrade 'git+https://github.com/ArchiveBox/ArchiveBox.git@dev'
diff --git a/archivebox/cli/archivebox_run.py b/archivebox/cli/archivebox_run.py
index 684cf68b..1275714a 100644
--- a/archivebox/cli/archivebox_run.py
+++ b/archivebox/cli/archivebox_run.py
@@ -54,6 +54,11 @@ RUNNER_DAEMON_ENV = "ARCHIVEBOX_RUNNER_DAEMON"
def _exit_daemon_runner_on_signal(sig: signal.Signals) -> None:
+ # A supervised `archivebox run --daemon` is intentionally a disposable
+ # child. If it receives SIGINT/SIGTERM directly, exit with the conventional
+ # signal status so supervisord treats it as an unexpected worker death and
+ # restarts only the runner. The parent `archivebox server` owns supervisord
+ # shutdown and must not be pulled down by a killed daemon worker.
os._exit(128 + int(sig))
@@ -292,6 +297,7 @@ def run_runner(
interactive_interrupts = current.root.process_type == Process.TypeChoices.ADD
if daemon:
os.environ[RUNNER_DAEMON_ENV] = "1"
+
try:
with (
foreground_shutdown_signals(
diff --git a/archivebox/cli/archivebox_update.py b/archivebox/cli/archivebox_update.py
index 9cc9f1fa..d1c3b310 100644
--- a/archivebox/cli/archivebox_update.py
+++ b/archivebox/cli/archivebox_update.py
@@ -561,21 +561,20 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
"""
Drain old archive/ directories (0.8.x → 0.9.x migration).
- Only processes real directories (skips symlinks - those are already migrated).
+ Removes obsolete timestamp symlinks and processes real legacy directories.
For each old dir found in archive/:
1. Load or create DB snapshot
2. Trigger fs migration on save() to move to data/archive/users/{user}/...
- 3. Leave symlink in archive/ pointing to new location
+ 3. Remove the old timestamp path after the verified migration commits
- After this drains, archive/ should only contain symlinks and we can trust
- 1:1 mapping between DB and filesystem.
+ After this drains, current snapshot data exists only under archive/users/.
"""
from archivebox.core.models import Snapshot
from archivebox.config import CONSTANTS
from archivebox.crawls.models import Crawl
from django.utils import timezone
- stats = {"processed": 0, "migrated": 0, "queued": 0, "skipped": 0, "invalid": 0}
+ stats = {"processed": 0, "migrated": 0, "queued": 0, "skipped": 0, "invalid": 0, "removed_symlinks": 0}
crawl_url_lines: dict[str, list[str]] = {}
crawl_url_sets: dict[str, set[str]] = {}
dirty_crawl_ids: set[str] = set()
@@ -606,8 +605,23 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
if changed:
Crawl.objects.filter(pk=crawl.pk).update(urls="\n".join(lines), modified_at=timezone.now())
- # Scan for real directories only (skip symlinks - they're already migrated)
+ # Compatibility timestamp projections are harmful in portable exports and
+ # duplicate the obsolete 0.7.x-looking namespace without containing data.
all_entries = list(os.scandir(archive_dir))
+ for entry in all_entries:
+ entry_path = Path(entry.path)
+ if entry.is_symlink() and Snapshot.is_legacy_archive_dir(entry_path):
+ try:
+ target_path = entry_path.resolve(strict=True)
+ points_into_current_layout = target_path.is_relative_to(CONSTANTS.USERS_DIR.resolve(strict=True))
+ except OSError:
+ points_into_current_layout = False
+
+ if points_into_current_layout:
+ entry_path.unlink(missing_ok=True)
+ stats["removed_symlinks"] += 1
+
+ # Scan real legacy directories only; these still contain data to migrate.
entries = [
(e.stat().st_mtime, e.path)
for e in all_entries
diff --git a/archivebox/config/common.py b/archivebox/config/common.py
index 4dbc4627..125b71c0 100644
--- a/archivebox/config/common.py
+++ b/archivebox/config/common.py
@@ -406,7 +406,11 @@ class DatabaseConfig(BaseConfigSet):
DATABASE_USER: str = Field(default="archivebox", alias="ARCHIVEBOX_DATABASE_USER")
DATABASE_PASSWORD: str = Field(default="", alias="ARCHIVEBOX_DATABASE_PASSWORD")
SQLITE_JOURNAL_MODE: str = Field(
- default="WAL",
+ # Docker collections commonly live on a host bind mount. WAL's -shm
+ # locking is only safe when every SQLite process is on the same host;
+ # Docker Desktop/OrbStack place the container and host in different
+ # locking domains and a host-side reader can corrupt the live DB.
+ default="DELETE" if IN_DOCKER else "WAL",
alias="ARCHIVEBOX_SQLITE_JOURNAL_MODE",
pattern=r"(?i)^(DELETE|TRUNCATE|PERSIST|MEMORY|WAL|OFF)$",
)
@@ -419,6 +423,15 @@ class DatabaseConfig(BaseConfigSet):
SQLITE_LOCK_RETRY_TIMEOUT: float = Field(default=60.0, alias="ARCHIVEBOX_SQLITE_LOCK_RETRY_TIMEOUT", ge=0)
SQLITE_LOCK_RETRY_INTERVAL: float = Field(default=5.0, alias="ARCHIVEBOX_SQLITE_LOCK_RETRY_INTERVAL", gt=0)
+ @model_validator(mode="after")
+ def reject_docker_sqlite_wal(self):
+ if IN_DOCKER and self.DATABASE_ENGINE.lower() == "sqlite" and self.SQLITE_JOURNAL_MODE.upper() == "WAL":
+ raise ValueError(
+ "SQLITE_JOURNAL_MODE=WAL is unsafe for Docker collections because host bind mounts cross SQLite "
+ "locking domains; use DELETE (the Docker default) or PostgreSQL",
+ )
+ return self
+
class ArchivingConfig(BaseConfigSet):
toml_section_header: str = "ARCHIVING_CONFIG"
diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py
index 8b4b81dd..eaf740d1 100644
--- a/archivebox/core/admin_archiveresults.py
+++ b/archivebox/core/admin_archiveresults.py
@@ -10,9 +10,12 @@ from pathlib import Path
from urllib.parse import quote
from django.contrib import admin
-from django.core.exceptions import ValidationError
+from django.contrib.admin.actions import delete_selected
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
+from django.core.exceptions import PermissionDenied, SuspiciousOperation, ValidationError
from django.db.models import Count, Min, Prefetch, Q, Subquery, TextField, Window
from django.db.models.functions import Cast
+from django.shortcuts import redirect
from django.urls import resolve, reverse
from django.utils import timezone
from django.utils.html import format_html
@@ -341,7 +344,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None, can_del
class ArchiveResultInline(admin.TabularInline):
- name = "Archive Results Log"
+ name = "Archive Results"
model = ArchiveResult
parent_model = Snapshot
extra = 0
@@ -543,10 +546,44 @@ class ArchiveResultAdmin(BaseModelAdmin):
def changelist_view(self, request, extra_context=None):
self.request = request
+ selected = request.GET.getlist(ACTION_CHECKBOX_NAME)
+ if request.method == "GET" and request.GET.get("action") == "delete_selected" and selected:
+ if not request.user.is_superuser:
+ raise PermissionDenied
+ if len(selected) > 100:
+ raise SuspiciousOperation("Too many ArchiveResults selected for deletion")
+ try:
+ queryset = self.get_queryset(request).filter(pk__in=selected)
+ if not queryset.exists():
+ snapshot = Snapshot.objects.only("id").filter(pk=request.GET.get("snapshot")).first()
+ return redirect(build_snapshot_url(str(snapshot.id), "index.html", request=request) if snapshot else request.path)
+ except (ValidationError, ValueError):
+ return redirect(request.path)
+ return delete_selected(self, request, queryset)
+ handoff_snapshot = (
+ request.GET.get("snapshot") if request.method == "POST" and request.GET.get("action") == "delete_selected" else None
+ )
+ if handoff_snapshot:
+ request.GET = request.GET.copy()
+ request.GET.clear()
+ request.META["QUERY_STRING"] = ""
+ try:
+ handoff_snapshot = Snapshot.objects.only("id").filter(pk=handoff_snapshot).first()
+ except (ValidationError, ValueError):
+ handoff_snapshot = None
saved_list_per_page = self.list_per_page
self.list_per_page = request.archivebox_config.SNAPSHOTS_PER_PAGE
try:
- return super().changelist_view(request, extra_context)
+ response = super().changelist_view(request, extra_context)
+ if (
+ handoff_snapshot
+ and response.status_code in (301, 302)
+ and not ArchiveResult.objects.filter(
+ pk__in=request.POST.getlist(ACTION_CHECKBOX_NAME),
+ ).exists()
+ ):
+ return redirect(build_snapshot_url(str(handoff_snapshot.id), "index.html", request=request))
+ return response
finally:
self.list_per_page = saved_list_per_page
diff --git a/archivebox/core/admin_users.py b/archivebox/core/admin_users.py
index d8e6ac3c..5ea04b51 100644
--- a/archivebox/core/admin_users.py
+++ b/archivebox/core/admin_users.py
@@ -96,7 +96,7 @@ class CustomUserAdmin(UserAdmin):
+ f' {total_count} total records...',
)
- @admin.display(description="Archive Result Logs")
+ @admin.display(description="Archive Results")
def archiveresult_set(self, obj):
total_count = obj.archiveresult_set.count()
return mark_safe(
diff --git a/archivebox/core/middleware.py b/archivebox/core/middleware.py
index fb8a87ca..b7178e88 100644
--- a/archivebox/core/middleware.py
+++ b/archivebox/core/middleware.py
@@ -39,8 +39,8 @@ ADMIN_LOGIN_HINT_COOKIE = "archivebox_admin_logged_in"
def _admin_login_hint_cookie_domain(config) -> str | None:
"""Resolve the parent domain to scope the cross-subdomain login hint.
- NOTE: this cookie carries only the single bit "user is logged in on
- admin somewhere"; it MUST NOT be confused with the session cookie,
+ NOTE: this cookie carries only the single bit "a superuser is logged in
+ on admin somewhere"; it MUST NOT be confused with the session cookie,
which stays admin-host-scoped (see core/settings.py
SESSION_COOKIE_DOMAIN comment — admin/web is a security boundary).
@@ -88,6 +88,10 @@ def AdminCookieIsolationMiddleware(get_response):
def middleware(request):
response = get_response(request)
+ if request.path == "/admin" or request.path.startswith("/admin/"):
+ response.headers["X-Frame-Options"] = "DENY"
+ response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
+
config = request.__dict__.get("archivebox_config")
if config is None or config.SERVER_SECURITY_MODE == "auto":
from archivebox.config.common import get_request_config
@@ -197,6 +201,21 @@ def ServerSecurityModeMiddleware(get_response):
config = get_request_config(request, resolve_plugins=False)
+ if config.USES_SUBDOMAIN_ROUTING and config.BASE_URL and request.method.upper() not in allowed_methods:
+ request_host, _request_port = split_host_port((request.get_host() or "").lower())
+ control_hosts = {
+ split_host_port(host)[0]
+ for host in (
+ get_base_host(config=config),
+ get_admin_host(config=config),
+ get_api_host(config=config),
+ get_web_host(config=config),
+ )
+ if host
+ }
+ if request_host not in control_hosts:
+ return HttpResponseForbidden("ArchiveBox is running with the control plane disabled on this host.")
+
if config.CONTROL_PLANE_ENABLED:
return get_response(request)
@@ -319,7 +338,12 @@ def HostRoutingMiddleware(get_response):
return redirect(target)
response = get_response(request)
hint_cookie_domain = _admin_login_hint_cookie_domain(config)
- if request.user.is_authenticated and not request.path.startswith("/admin/logout"):
+ if (
+ request.user.is_authenticated
+ and request.user.is_active
+ and request.user.is_superuser
+ and not request.path.startswith("/admin/logout")
+ ):
response.set_cookie(
ADMIN_LOGIN_HINT_COOKIE,
"1",
diff --git a/archivebox/core/migrations/0053_alter_archiveresult_options.py b/archivebox/core/migrations/0053_alter_archiveresult_options.py
new file mode 100644
index 00000000..a8ed522a
--- /dev/null
+++ b/archivebox/core/migrations/0053_alter_archiveresult_options.py
@@ -0,0 +1,16 @@
+# Generated by Django 6.1 on 2026-08-30 18:20
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("core", "0052_unique_archiveresult_per_snapshot_plugin"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="archiveresult",
+ options={"verbose_name": "Archive Result", "verbose_name_plural": "Archive Results"},
+ ),
+ ]
diff --git a/archivebox/core/models.py b/archivebox/core/models.py
index d5065992..33e95b95 100644
--- a/archivebox/core/models.py
+++ b/archivebox/core/models.py
@@ -46,8 +46,6 @@ from archivebox.misc.util import (
sanitize_html_text,
to_json,
ts_to_date_str,
- urldecode,
- urlencode,
validate_url,
)
from archivebox.plugins.discovery import (
@@ -440,7 +438,7 @@ class SnapshotQuerySet(models.QuerySet):
else {}
)
- snapshot_dicts = [s.to_dict(extended=True) for s in self.iterator(chunk_size=500)]
+ snapshot_dicts = [s.to_dict(extended=True, static_export=True) for s in self.iterator(chunk_size=500)]
if with_headers:
output = {
@@ -474,6 +472,24 @@ class SnapshotQuerySet(models.QuerySet):
template = "static_index.html" if with_headers else "minimal_index.html"
snapshot_list = list(self.iterator(chunk_size=500))
+ manifest_records = []
+ for snapshot in snapshot_list:
+ outputs = snapshot.discover_outputs(include_filesystem_fallback=True)
+ output_paths = [str(output.get("path") or "") for output in outputs]
+ snapshot._public_preview_paths = [
+ path for preferred in ("screenshot/screenshot.png", "screenshot.png") for path in output_paths if path == preferred
+ ]
+ snapshot._public_favicon_paths = [path for path in output_paths if path in ("favicon/favicon.ico", "favicon.ico")]
+ snapshot.write_html_details()
+ if with_headers:
+ # Use the same portable schema as the JSON export. Rendering
+ # above has already populated result-count caches, archive_size
+ # reuses the sealed output_size field, and tags are prefetched.
+ manifest_records.append(snapshot.to_dict(extended=True, static_export=True))
+
+ if with_headers:
+ manifest = "".join(f"{to_json(record, indent=None, sort_keys=True)}\n" for record in manifest_records)
+ atomic_write(str(CONSTANTS.DATA_DIR / CONSTANTS.JSONL_INDEX_FILENAME), manifest)
return render_to_string(
template,
@@ -485,6 +501,8 @@ class SnapshotQuerySet(models.QuerySet):
"time_updated": datetime.now(UTC).strftime("%Y-%m-%d %H:%M"),
"links": snapshot_list,
"FOOTER_INFO": config.FOOTER_INFO,
+ "STATIC_EXPORT": True,
+ "STATIC_EXPORT_DIR": CONSTANTS.DATA_DIR,
},
)
@@ -1009,7 +1027,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
from django.db import transaction
def finish_snapshot_save():
- self.ensure_legacy_archive_symlink()
+ self.remove_legacy_archive_symlink()
self.ensure_crawl_symlink()
crawl = Crawl.objects.filter(pk=self.crawl_id).first()
if crawl is None:
@@ -1286,9 +1304,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return (old_dir, new_dir)
def _cleanup_old_migration_dir(self, old_dir: Path, new_dir: Path):
- """
- Delete old directory and create symlink after successful migration.
- """
+ """Delete the old directory after its contents are verified at the new path."""
import logging
import shutil
@@ -1305,20 +1321,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
logging.getLogger("archivebox.migration").warning(
f"Could not remove old migration directory {old_dir}: {e}",
)
- return # Don't create symlink if cleanup failed
+ return
- # Create backwards-compat symlink (after old dir is deleted)
- symlink_path = old_dir # Same path as old_dir
- if symlink_path.is_symlink():
- symlink_path.unlink()
-
- if not symlink_path.exists():
- try:
- symlink_path.symlink_to(new_dir, target_is_directory=True)
- except OSError as e:
- logging.getLogger("archivebox.migration").warning(
- f"Could not create symlink from {symlink_path} to {new_dir}: {e}",
- )
+ # Older migration runs may already have left a timestamp projection.
+ if old_dir.is_symlink():
+ old_dir.unlink(missing_ok=True)
# =========================================================================
# Path Calculation and Migration Helpers
@@ -2297,8 +2304,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return calc_tags_str()
return calc_tags_str()
- def icons(self, path: str | None = None) -> str:
+ def icons(self, path: str | None = None, prefix: str = "/", quote_paths: bool = False) -> str:
"""Generate HTML icons showing which extractor plugins have succeeded for this snapshot"""
+ from urllib.parse import quote
+
from django.utils.html import format_html
compact_icons = self.__dict__.get("_icons_compact", False)
@@ -2384,7 +2393,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
archive_path = path or self.archive_path
output = ""
- output_template = '{}'
+ output_template = '{}'
# Get all plugins from hooks system (sorted by numeric prefix)
all_plugins = self.__dict__.get("_icons_plugin_names")
@@ -2410,8 +2419,20 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
continue
embed_path = f"{plugin}/" if compact_icons else result.embed_path()
+ if not embed_path or str(embed_path).strip() in (".", "/", "./"):
+ continue
+ output_path = Path(str(embed_path))
+ if (
+ quote_paths
+ and not compact_icons
+ and (output_path.is_absolute() or ".." in output_path.parts or not (Path(self.output_dir) / output_path).exists())
+ ):
+ continue
+ if quote_paths:
+ embed_path = quote(str(embed_path), safe="/@-._~!$&'()*+,;=")
output += format_html(
output_template,
+ prefix,
archive_path,
embed_path,
str(bool(existing)),
@@ -2562,35 +2583,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return current_path
- def ensure_legacy_archive_symlink(self) -> None:
- """Ensure the legacy archive/ path resolves to this snapshot."""
- import os
-
- legacy_path = CONSTANTS.ARCHIVE_DIR / self.timestamp
- target = Path(self.get_storage_path_for_version(self._fs_current_version()))
-
- if target == legacy_path:
- return
-
- legacy_path.parent.mkdir(parents=True, exist_ok=True)
-
- if legacy_path.exists() or legacy_path.is_symlink():
- if legacy_path.is_symlink():
- try:
- if legacy_path.resolve() == target.resolve():
- return
- except OSError:
- pass
- legacy_path.unlink(missing_ok=True)
- else:
- return
-
- rel_target = os.path.relpath(target, legacy_path.parent)
- try:
- legacy_path.symlink_to(rel_target, target_is_directory=True)
- except OSError:
- return
-
def ensure_crawl_symlink(self, *, crawl_dir: Path | None = None, snapshot_dir: Path | None = None) -> None:
"""Ensure snapshot is symlinked under its crawl output directory."""
import os
@@ -2630,6 +2622,21 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
except OSError:
return
+ def remove_legacy_archive_symlink(self) -> None:
+ """Remove a stale archive/ compatibility projection."""
+ legacy_path = CONSTANTS.ARCHIVE_DIR / self.timestamp
+ current_path = self.get_storage_path_for_version(self._fs_current_version())
+ if not legacy_path.is_symlink() or not current_path.exists():
+ return
+
+ try:
+ points_to_current_path = legacy_path.resolve(strict=True) == current_path.resolve(strict=True)
+ except OSError:
+ points_to_current_path = False
+
+ if points_to_current_path:
+ legacy_path.unlink(missing_ok=True)
+
@cached_property
def legacy_archive_path(self) -> str:
return f"{CONSTANTS.ARCHIVE_DIR_NAME}/{self.timestamp}"
@@ -3460,7 +3467,15 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
# Serialization Methods
# =========================================================================
- def to_dict(self, extended: bool = False) -> dict[str, Any]:
+ @property
+ def static_archive_path(self) -> str:
+ """Snapshot output path relative to the data root, for portable exports."""
+ try:
+ return Path(self.output_dir).relative_to(CONSTANTS.DATA_DIR).as_posix()
+ except ValueError:
+ return Path(self.output_dir).as_posix()
+
+ def to_dict(self, extended: bool = False, static_export: bool = False) -> dict[str, Any]:
"""Convert Snapshot to a dictionary (replacement for Link._asdict())"""
from archivebox.core.routes_util import build_snapshot_url
@@ -3491,8 +3506,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"extension": self.extension,
"is_static": self.is_static,
"is_archived": self.is_archived,
- "archive_path": self.archive_path,
- "archive_url": build_snapshot_url(str(self.id), "index.html"),
+ "archive_path": self.static_archive_path if static_export else self.archive_path,
+ "archive_url": f"./{self.static_archive_path}/index.html" if static_export else build_snapshot_url(str(self.id), "index.html"),
"output_dir": self.output_dir,
"link_dir": self.output_dir, # backwards compatibility alias
"archive_size": archive_size,
@@ -3522,67 +3537,173 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"""Write JSON index file for this snapshot to its output directory"""
output_dir = Path(out_dir) if out_dir is not None else self.output_dir
path = output_dir / CONSTANTS.JSON_INDEX_FILENAME
- atomic_write(str(path), self.to_dict(extended=True))
-
- def write_html_details(self, out_dir: Path | str | None = None) -> None:
- """Write HTML detail page for this snapshot to its output directory"""
- from django.template.loader import render_to_string
+ atomic_write(str(path), self.to_dict(extended=True, static_export=True))
+ def get_html_details_context(self, request=None, *, static_export_dir: Path | None = None) -> dict[str, Any]:
+ """Build the one context used by both served and on-disk snapshot pages."""
+ from archivebox.config.common import get_request_config
+ from archivebox.core.permissions import get_snapshot_permissions
from archivebox.core.widgets import TagEditorWidget
from archivebox.misc.logging_util import printable_filesize
+ from archivebox.progressmonitor.views import progress_endpoint
- output_dir = Path(out_dir) if out_dir is not None else self.output_dir
- TITLE_LOADING_MSG = "Not yet archived..."
+ runtime_config = get_request_config(request) if request is not None else get_config()
+ self._runtime_config = runtime_config
+ snapshot_permissions = get_snapshot_permissions(self)
+ archive_results = list(self.archiveresult_set.all().order_by("start_ts"))
+ tags = list(self.tags.all())
+ self.__dict__["_admin_archiveresults"] = archive_results
+ self.__dict__["_tags_str_cached"] = ",".join(sorted(tag.name for tag in tags))
+ self.__dict__["num_outputs_cached"] = sum(result.status == ArchiveResult.StatusChoices.SUCCEEDED for result in archive_results)
+ self.__dict__["num_failures_cached"] = sum(result.status == ArchiveResult.StatusChoices.FAILED for result in archive_results)
- preview_priority = [
- "singlefile",
- "screenshot",
- "wget",
- "dom",
- "pdf",
- "readability",
+ hidden_card_plugins = {"archivedotorg", "favicon", "title"}
+ outputs = [
+ output
+ for output in self.discover_outputs(include_filesystem_fallback=True, archive_results=archive_results)
+ if (output.get("size") or 0) > 0 and output.get("name") not in hidden_card_plugins
]
+ if static_export_dir is not None:
- outputs = self.discover_outputs(include_filesystem_fallback=True)
- loose_items, failed_items = self.get_detail_page_auxiliary_items(outputs)
- outputs_by_plugin = {out["name"]: out for out in outputs}
- output_size = sum(int(out.get("size") or 0) for out in outputs)
- is_archived = bool(outputs or self.downloaded_at or self.status == self.StatusChoices.SEALED)
+ def static_output_exists(output: dict[str, Any]) -> bool:
+ raw_path = str(output.get("path") or "")
+ if not raw_path:
+ return False
+ output_path = Path(raw_path)
+ return not output_path.is_absolute() and ".." not in output_path.parts and (static_export_dir / output_path).exists()
- best_preview_path = "about:blank"
+ outputs = [output for output in outputs if static_output_exists(output)]
+ outputs_by_name: dict[str, dict[str, Any]] = {}
+ result_ids_by_name: dict[str, list[str]] = {}
+ for output in outputs:
+ if output.get("result"):
+ result_ids_by_name.setdefault(output["name"], []).append(str(output["result"].id))
+ current = outputs_by_name.get(output["name"])
+ if current is None or (output.get("size") or 0) > (current.get("size") or 0):
+ outputs_by_name[output["name"]] = output
+ for name, output in outputs_by_name.items():
+ output["result_ids"] = ",".join(result_ids_by_name.get(name, ()))
+
+ hash_index = self.hashes_index
+ loose_items, failed_items = self.get_detail_page_auxiliary_items(
+ outputs,
+ hidden_card_plugins=hidden_card_plugins,
+ archive_results=archive_results,
+ )
+ preview_priority = ("singlefile", "screenshot", "wget", "dom", "pdf", "readability")
+ output_order = {result_type: index for index, result_type in enumerate(outputs_by_name)}
+ ordered_outputs = sorted(
+ outputs_by_name.values(),
+ key=lambda output: (
+ preview_priority.index(output["name"]) if output["name"] in preview_priority else len(preview_priority),
+ output_order.get(output["name"], len(output_order)),
+ ),
+ )
best_result = {"path": "about:blank", "result": None}
- for plugin in preview_priority:
- out = outputs_by_plugin.get(plugin)
- if out and out.get("path"):
- best_preview_path = str(out["path"])
- best_result = out
+ for result_type in preview_priority:
+ if result_type in outputs_by_name:
+ best_result = outputs_by_name[result_type]
break
+ if best_result["path"] == "about:blank" and ordered_outputs:
+ best_result = ordered_outputs[0]
- if best_preview_path == "about:blank" and outputs:
- best_preview_path = str(outputs[0].get("path") or "about:blank")
- best_result = outputs[0]
+ non_compact_outputs = [output for output in ordered_outputs if not output.get("is_compact") and not output.get("is_metadata")]
+ compact_outputs = [output for output in ordered_outputs if output.get("is_compact") or output.get("is_metadata")]
+ archive_dates = [result.start_ts for result in archive_results if result.start_ts]
+ output_size = sum(int(output.get("size") or 0) for output in ordered_outputs)
+ has_outputs = bool(ordered_outputs)
+ is_archived = has_outputs or self.status == self.StatusChoices.SEALED
+ snapshot_status = str(self.status or "").lower()
+ status_label_by_state = {
+ "queued": ("queued", "info"),
+ "started": ("running", "warning"),
+ "paused": ("paused", "default"),
+ "sealed": ("archived", "success"),
+ }
+ if has_outputs:
+ status_label, status_color = ("archived", "success") if is_archived else ("partial", "warning")
+ else:
+ status_label, status_color = status_label_by_state.get(snapshot_status, ("not yet archived", "danger"))
+
+ related_snapshots = list(
+ type(self)
+ .objects.filter(url=self.url)
+ .exclude(id=self.id)
+ .only("id", "url", "bookmarked_at", "created_at", "downloaded_at", "output_size")
+ .order_by("-bookmarked_at", "-created_at", "-timestamp")[:25],
+ )
+ related_years_map: dict[int, list[Snapshot]] = {}
+ for snapshot in [self, *related_snapshots]:
+ snapshot_date = snapshot.bookmarked_at or snapshot.created_at or snapshot.downloaded_at
+ if snapshot_date:
+ related_years_map.setdefault(snapshot_date.year, []).append(snapshot)
+ related_years = []
+ for year, snapshots in related_years_map.items():
+ snapshots.sort(
+ key=lambda snapshot: snapshot.bookmarked_at or snapshot.created_at or snapshot.downloaded_at or timezone.now(),
+ reverse=True,
+ )
+ related_years.append({"year": year, "latest": snapshots[0], "snapshots": snapshots})
+ related_years.sort(key=lambda item: item["year"], reverse=True)
+
+ warc_path = next(
+ (rel_path for rel_path in hash_index if rel_path.startswith("warc/") and ".warc" in Path(rel_path).name),
+ "warc/",
+ )
+ user = getattr(request, "user", None)
+ can_delete_outputs = bool(
+ static_export_dir is None
+ and request is not None
+ and (
+ (user and user.is_authenticated and user.is_active and user.is_superuser)
+ or request.COOKIES.get("archivebox_admin_logged_in") == "1"
+ ),
+ )
tag_widget = TagEditorWidget()
- context = {
- **self.to_dict(extended=True),
- "snapshot": self,
- "title": htmldecode(self.resolved_title or (self.base_url if is_archived else TITLE_LOADING_MSG)),
- "url_str": htmldecode(urldecode(self.base_url)),
- "archive_url": urlencode(f"warc/{self.timestamp}") or "about:blank",
+ return {
+ "id": str(self.id),
+ "snapshot_id": str(self.id),
+ "progress_endpoint": progress_endpoint("snapshot", self.id) if request is not None else "",
+ "progress_auto_expand": snapshot_status in {"queued", "started", "paused"},
+ "url": self.url,
+ "archive_path": self.archive_path_from_db,
+ "title": htmldecode(self.resolved_title or (self.base_url if is_archived else "Not yet archived...")),
"extension": self.extension or "html",
"tags": self.tags_str() or "untagged",
- "size": printable_filesize(output_size) if output_size else "pending",
- "status": "archived" if is_archived else "not yet archived",
- "status_color": "success" if is_archived else "danger",
- "oldest_archive_date": ts_to_date_str(self.oldest_archive_date),
- "best_preview_path": best_preview_path,
+ "size": printable_filesize(output_size) if output_size else "—",
+ "status": status_label,
+ "status_color": status_color,
+ "snapshot_state": snapshot_status,
+ "has_outputs": has_outputs,
+ "snapshot_permissions": snapshot_permissions,
+ "snapshot_permissions_icon": {"public": "👥", "unlisted": "🔗", "private": "🔒"}.get(snapshot_permissions, "👥"),
+ "bookmarked_date": self.bookmarked_date,
+ "downloaded_datestr": self.downloaded_datestr,
+ "num_outputs": self.num_outputs,
+ "num_failures": self.num_failures,
+ "oldest_archive_date": ts_to_date_str(min(archive_dates) if archive_dates else None),
+ "warc_path": warc_path,
+ "archiveresults": [*non_compact_outputs, *compact_outputs],
"best_result": best_result,
- "archiveresults": outputs,
+ "snapshot": self,
+ "CONFIG": runtime_config,
+ "related_snapshots": related_snapshots,
+ "related_years": related_years,
"loose_items": loose_items,
"failed_items": failed_items,
- "related_snapshots": [],
- "related_years": [],
- "title_tags": [{"name": tag.name, "style": tag_widget._tag_style(tag.name)} for tag in self.tags.all().order_by("name")],
+ "can_delete_outputs": can_delete_outputs,
+ "title_tags": [{"name": tag.name, "style": tag_widget._tag_style(tag.name)} for tag in sorted(tags, key=lambda tag: tag.name)],
+ "STATIC_EXPORT": static_export_dir is not None,
+ "STATIC_EXPORT_DIR": static_export_dir,
}
+
+ def write_html_details(self, out_dir: Path | str | None = None) -> None:
+ """Write the unified snapshot detail page with portable filesystem URLs."""
+ from django.template.loader import render_to_string
+
+ output_dir = Path(out_dir) if out_dir is not None else self.output_dir
+ output_dir.mkdir(parents=True, exist_ok=True)
+ context = self.get_html_details_context(static_export_dir=output_dir)
rendered_html = render_to_string("core/snapshot.html", context)
atomic_write(str(output_dir / CONSTANTS.HTML_INDEX_FILENAME), rendered_html)
@@ -4003,7 +4124,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
):
app_label = "core"
verbose_name = "Archive Result"
- verbose_name_plural = "Archive Results Log"
+ verbose_name_plural = "Archive Results"
indexes: ClassVar[list[models.Index]] = [
models.Index(fields=["snapshot", "status"], name="archiveresult_snap_status_idx"),
models.Index(fields=["status", "snapshot"], name="archiveresult_status_snap_idx"),
diff --git a/archivebox/core/takeover_util.py b/archivebox/core/takeover_util.py
index 3013dee2..3872ade1 100644
--- a/archivebox/core/takeover_util.py
+++ b/archivebox/core/takeover_util.py
@@ -1,3 +1,17 @@
+"""Coordinate local foreground processes without pretending to provide a distributed lock.
+
+ArchiveBox wants one orchestrator per collection, but Process rows can only prove
+liveness for PIDs visible on the current machine/PID namespace. We therefore
+enforce one active runner per ``(Machine, DATA_DIR)`` locally, retire stale rows
+from sequential containers on that machine, and only warn about rows owned by a
+different machine. Foreign-machine rows must never block progress or be killed:
+multi-machine coordination belongs in the Crawl/Snapshot CAS claim layer, not in
+process takeover.
+
+These helpers only hand local supervisord/runner ownership between CLI parents.
+They must not hold database transactions or filesystem locks while work runs.
+"""
+
from __future__ import annotations
import time
@@ -15,25 +29,6 @@ RUNNER_WAITING_WORKER_TYPE = "runner_waiting"
RUNNER_GATE_WORKER_TYPES = (RUNNER_ACTIVE_WORKER_TYPE, RUNNER_WAITING_WORKER_TYPE, "")
-def runtime_stack_owner_types():
- from archivebox.machine.models import Process
-
- return (
- Process.TypeChoices.SERVER,
- Process.TypeChoices.ORCHESTRATOR,
- )
-
-
-def foreground_runner_owner_types():
- from archivebox.machine.models import Process
-
- return (
- Process.TypeChoices.SERVER,
- Process.TypeChoices.ADD,
- Process.TypeChoices.UPDATE,
- )
-
-
def current_command(process_type: str, *, data_dir: str | Path, url: str | None = None):
from archivebox.machine.models import Process
@@ -42,31 +37,8 @@ def current_command(process_type: str, *, data_dir: str | Path, url: str | None
return proc
-def live_processes(*, process_type: str, data_dir: str | Path, url: str | None = None):
- from archivebox.machine.models import Machine, Process
-
- qs = Process.objects.filter(
- machine=Machine.current(),
- process_type=process_type,
- status=Process.StatusChoices.RUNNING,
- pwd=str(data_dir),
- )
- if url is not None:
- qs = qs.filter(url=url)
- return [proc for proc in qs.order_by("-created_at", "-modified_at").iterator(chunk_size=50) if proc.is_running]
-
-
-def newest_live_process(*, process_type: str, data_dir: str | Path, url: str | None = None):
- processes = live_processes(process_type=process_type, data_dir=data_dir, url=url)
- return processes[0] if processes else None
-
-
-def command_is_newest(command, *, process_type: str, data_dir: str | Path, url: str | None = None) -> bool:
- leader = newest_live_process(process_type=process_type, data_dir=data_dir, url=url)
- return bool(leader and leader.id == command.id)
-
-
def runtime_stack_owner(*, data_dir: str | Path, exclude_id=None):
+ """Return the live local parent allowed to own the server runtime stack."""
from archivebox.machine.models import Machine, Process
machine = Machine.current()
@@ -74,7 +46,7 @@ def runtime_stack_owner(*, data_dir: str | Path, exclude_id=None):
machine=machine,
status=Process.StatusChoices.RUNNING,
pwd=str(data_dir),
- process_type__in=runtime_stack_owner_types(),
+ process_type__in=(Process.TypeChoices.SERVER, Process.TypeChoices.ORCHESTRATOR),
)
if exclude_id is not None:
base_qs = base_qs.exclude(id=exclude_id)
@@ -103,6 +75,7 @@ def command_owns_runtime_stack(command, *, data_dir: str | Path) -> bool:
def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
+ """Return the newest live local parent allowed to borrow runner/sonic."""
from archivebox.machine.models import Machine, Process
machine = Machine.current()
@@ -110,7 +83,7 @@ def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
machine=machine,
status=Process.StatusChoices.RUNNING,
pwd=str(data_dir),
- process_type__in=foreground_runner_owner_types(),
+ process_type__in=(Process.TypeChoices.SERVER, Process.TypeChoices.ADD, Process.TypeChoices.UPDATE),
)
if exclude_id is not None:
qs = qs.exclude(id=exclude_id)
@@ -126,26 +99,6 @@ def command_owns_foreground_runner(command, *, data_dir: str | Path) -> bool:
return bool(owner and owner.id == command.id)
-def runtime_stack_component_label(*, owner=None, data_dir: str | Path) -> str:
- try:
- from archivebox.workers.supervisord_util import active_supervisord_runtime_components
-
- components = active_supervisord_runtime_components()
- except Exception:
- components = []
-
- names = list(components)
- if not names and owner is not None:
- from archivebox.machine.models import Process
-
- if owner.process_type == Process.TypeChoices.SERVER:
- names = ["orchestrator", "server"]
- elif owner.process_type == Process.TypeChoices.ORCHESTRATOR:
- names = ["orchestrator"]
-
- return ", ".join(dict.fromkeys(names)) or "runtime stack"
-
-
def ensure_daemon_stack(*, reason: str = ""):
from archivebox.config.common import get_config
from archivebox.workers.supervisord_util import (
@@ -181,56 +134,65 @@ def ensure_daemon_stack(*, reason: str = ""):
return start_worker(supervisor, sonic_worker)
-def healthy_orchestrator(*, data_dir: str | Path):
- from archivebox.machine.models import Machine, Process
- from archivebox.workers.supervisord_util import get_existing_supervisord_process, get_worker
+def live_runner_processes(*, data_dir: str | Path):
+ """Return locally verifiable runners and warn about unsupported overlap.
- supervisor = get_existing_supervisord_process()
- worker = get_worker(supervisor, "worker_runner") if supervisor else None
- if isinstance(worker, dict) and worker.get("statename") in ("STARTING", "RUNNING"):
- return worker
-
- for proc in Process.objects.filter(
- machine=Machine.current(),
- process_type=Process.TypeChoices.ORCHESTRATOR,
- status=Process.StatusChoices.RUNNING,
- pwd=str(data_dir),
- ).order_by("-created_at"):
- if proc.is_running:
- return proc
- return None
-
-
-def _runner_sort_key(process):
- return (process.started_at or process.created_at, process.created_at, str(process.id))
-
-
-def live_runner_processes(*, data_dir: str | Path, exclude_id=None):
+ A Process row from another machine is observability only: its PID cannot be
+ checked or signalled here, so it neither joins the local election nor gets
+ mutated. A row for this same Machine from another PID namespace represents
+ a previous sequential container under the supported model; warn, retire the
+ unreachable row, and let the new container continue.
+ """
from archivebox.machine.models import Machine, Process
machine = Machine.current()
Process.cleanup_stale_running(machine=machine)
qs = Process.objects.filter(
- machine=machine,
status=Process.StatusChoices.RUNNING,
process_type=Process.TypeChoices.ORCHESTRATOR,
worker_type__in=RUNNER_GATE_WORKER_TYPES,
pwd=str(data_dir),
)
- if exclude_id is not None:
- qs = qs.exclude(id=exclude_id)
- return [process for process in qs.order_by("started_at", "created_at").iterator(chunk_size=20) if process.is_running]
+ foreign_machine_exists = qs.exclude(machine=machine).exists()
+ qs = qs.filter(machine=machine)
+ live = []
+ foreign_namespace_ids = []
+ for process in qs.order_by("started_at", "created_at").iterator(chunk_size=20):
+ if not process.shares_pid_namespace:
+ foreign_namespace_ids.append(process.id)
+ continue
+ if process.is_running:
+ live.append(process)
+ if foreign_machine_exists or foreign_namespace_ids:
+ rprint(
+ "[bold yellow]WARNING: Multiple orchestrators sharing a single collection is not officially supported! "
+ "Corruption may occur if you run two ArchiveBox workers on the same collection at once.[/bold yellow]",
+ file=sys.stderr,
+ soft_wrap=True,
+ )
+ if foreign_namespace_ids:
+ now = timezone.now()
+ Process.objects.filter(id__in=foreign_namespace_ids, status=Process.StatusChoices.RUNNING).update(
+ status=Process.StatusChoices.EXITED,
+ exit_code=0,
+ ended_at=now,
+ retry_at=None,
+ modified_at=now,
+ )
+ return live
def enter_single_runner_gate(command, *, data_dir: str | Path, graceful_timeout: float = 5.0) -> bool:
"""
- Admit exactly one active runner for this DATA_DIR using Process rows.
+ Admit one active runner for this Machine and DATA_DIR using Process rows.
The current process is a real OS process while it waits, so we keep its
Process row RUNNING but mark worker_type=runner_waiting. Only the process
that wins takeover is promoted to worker_type=worker_runner, which is
- protected by a partial unique DB constraint. Older runners are terminated
- and fully waited out before promotion, so the runner work loop never overlaps.
+ protected by a partial unique DB constraint scoped to (Machine, DATA_DIR).
+ Older locally verifiable runners are terminated and fully waited out before
+ promotion, so runner work never overlaps on one machine. Foreign machines
+ are intentionally outside this gate and only produce a warning above.
"""
from archivebox.machine.models import Process
@@ -252,7 +214,7 @@ def enter_single_runner_gate(command, *, data_dir: str | Path, graceful_timeout:
)
runners = live_runner_processes(data_dir=data_dir)
- newest = max(runners, key=_runner_sort_key)
+ newest = max(runners, key=lambda process: (process.started_at or process.created_at, process.created_at, str(process.id)))
if newest.id != command.id:
rprint(
f"[yellow][*] Newer ArchiveBox runner pid={newest.pid} is taking over; exiting this runner.[/yellow]",
@@ -263,14 +225,8 @@ def enter_single_runner_gate(command, *, data_dir: str | Path, graceful_timeout:
older_runners = [process for process in runners if process.id != command.id]
if older_runners:
for process in older_runners:
- if process.shares_pid_namespace:
- rprint(f"[yellow][*] Stopping older ArchiveBox runner process (pid={process.pid})...[/yellow]", file=sys.stderr)
- process.kill_tree(graceful_timeout=graceful_timeout)
- else:
- rprint(
- "[yellow][*] Waiting for older ArchiveBox runner in another PID namespace to stop...[/yellow]",
- file=sys.stderr,
- )
+ rprint(f"[yellow][*] Stopping older ArchiveBox runner process (pid={process.pid})...[/yellow]", file=sys.stderr)
+ process.kill_tree(graceful_timeout=graceful_timeout)
time.sleep(0.1)
continue
@@ -290,24 +246,9 @@ def enter_single_runner_gate(command, *, data_dir: str | Path, graceful_timeout:
time.sleep(0.1)
-def standby_until_leader_needed(command, *, process_type: str, data_dir: str | Path, url: str | None = None, interval: float = 2.0) -> None:
- from archivebox.workers.supervisord_util import reap_foreground_supervisord_process
-
- announced = False
- while not command_is_newest(command, process_type=process_type, data_dir=data_dir, url=url):
- reap_foreground_supervisord_process()
- if not announced:
- leader = newest_live_process(process_type=process_type, data_dir=data_dir, url=url)
- leader_pid = leader.pid if leader else "unknown"
- rprint(f"[yellow][*] Standing by; newer ArchiveBox process pid={leader_pid} is running the orchestrator and server.[/yellow]")
- announced = True
- time.sleep(interval)
- command.modified_at = timezone.now()
- command.save(update_fields=["modified_at"])
-
-
def standby_until_runtime_stack_needed(command, *, data_dir: str | Path, interval: float = 2.0) -> dict[str, object]:
- from archivebox.workers.supervisord_util import reap_foreground_supervisord_process
+ from archivebox.machine.models import Process
+ from archivebox.workers.supervisord_util import active_supervisord_runtime_components, reap_foreground_supervisord_process
announced = False
previous_owner_pid = None
@@ -316,7 +257,16 @@ def standby_until_runtime_stack_needed(command, *, data_dir: str | Path, interva
if not announced:
owner = runtime_stack_owner(data_dir=data_dir)
owner_pid = owner.pid if owner else "unknown"
- components = runtime_stack_component_label(owner=owner, data_dir=data_dir)
+ try:
+ component_names = list(active_supervisord_runtime_components())
+ except Exception:
+ component_names = []
+ if not component_names and owner is not None:
+ if owner.process_type == Process.TypeChoices.SERVER:
+ component_names = ["orchestrator", "server"]
+ elif owner.process_type == Process.TypeChoices.ORCHESTRATOR:
+ component_names = ["orchestrator"]
+ components = ", ".join(dict.fromkeys(component_names)) or "runtime stack"
previous_owner_pid = owner_pid
rprint(
f"[yellow][*] A newer archivebox process took over the {components} "
diff --git a/archivebox/core/templatetags/core_tags.py b/archivebox/core/templatetags/core_tags.py
index efcb9f34..7bc5b4d2 100644
--- a/archivebox/core/templatetags/core_tags.py
+++ b/archivebox/core/templatetags/core_tags.py
@@ -2,6 +2,7 @@ import os
from html import unescape
from pathlib import Path
from typing import Any
+from urllib.parse import quote
from abx_plugins.plugins.archivewebpage.replay_preview import is_replay_target as is_archivewebpage_replay_target
from django import template
@@ -11,6 +12,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.text import Truncator
+from archivebox.config import CONSTANTS
from archivebox.core.routes_util import (
build_snapshot_url,
get_admin_base_url,
@@ -28,6 +30,7 @@ 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")
+_STATIC_URL_SAFE = "/@-._~!$&'()*+,;="
_MEDIA_FILE_EXTS = {
".mp4",
@@ -202,16 +205,70 @@ def _is_root_snapshot_output_path(raw_output_path: str | None) -> bool:
return normalized in ("", ".", "./", "/", "index.html", "index.json")
-def _build_snapshot_files_url(snapshot_id: str, request=None, config=None) -> str:
- return build_snapshot_url(str(snapshot_id), "/?files=1", request=request, config=config)
+def _static_snapshot_base_url(context, snapshot) -> str:
+ start_dir = Path(context["STATIC_EXPORT_DIR"])
+ relative_path = os.path.relpath(Path(snapshot.output_dir), start=start_dir).replace(os.sep, "/")
+ if relative_path == ".":
+ return "."
+ return f"./{quote(relative_path, safe=_STATIC_URL_SAFE)}"
-def _build_snapshot_preview_url(snapshot_id: str, path: str = "", request=None, config=None, plugin: str = "") -> str:
+def _snapshot_base_url_for_context(context, snapshot) -> str:
+ if context.get("STATIC_EXPORT"):
+ return _static_snapshot_base_url(context, snapshot)
+ return get_snapshot_base_url(
+ str(_snapshot_id(snapshot)),
+ request=context.get("request"),
+ config=context.get("CONFIG"),
+ )
+
+
+def _snapshot_url_for_context(context, snapshot, path: str = "") -> str:
+ if not context.get("STATIC_EXPORT"):
+ return build_snapshot_url(
+ str(_snapshot_id(snapshot)),
+ path,
+ request=context.get("request"),
+ config=context.get("CONFIG"),
+ )
+
+ base_url = _static_snapshot_base_url(context, snapshot)
+ raw_path = str(path or "")
+ if not raw_path:
+ return base_url
+ path_part, separator, query = raw_path.lstrip("/").partition("?")
+ quoted_path = quote(path_part, safe=_STATIC_URL_SAFE)
+ suffix = f"?{query}" if separator else ""
+ return f"{base_url.rstrip('/')}/{quoted_path}{suffix}"
+
+
+def _build_snapshot_files_url(snapshot_id: str, request=None, config=None, base_url: str | None = None) -> str:
+ return (
+ f"{base_url.rstrip('/')}/index.jsonl"
+ if base_url
+ else build_snapshot_url(str(snapshot_id), "/?files=1", request=request, config=config)
+ )
+
+
+def _build_snapshot_preview_url(
+ snapshot_id: str,
+ path: str = "",
+ request=None,
+ config=None,
+ plugin: str = "",
+ base_url: str | None = None,
+) -> str:
if path == "about:blank":
return path
if _is_root_snapshot_output_path(path):
- return _build_snapshot_files_url(snapshot_id, request=request, config=config)
- url = build_snapshot_url(str(snapshot_id), path, request=request, config=config)
+ return _build_snapshot_files_url(snapshot_id, request=request, config=config, base_url=base_url)
+ if base_url:
+ path_part, separator, query = str(path).lstrip("/").partition("?")
+ url = f"{base_url.rstrip('/')}/{quote(path_part, safe=_STATIC_URL_SAFE)}"
+ if separator:
+ url = f"{url}?{query}"
+ else:
+ url = build_snapshot_url(str(snapshot_id), path, request=request, config=config)
path_parts = Path(path).parts
plugin = get_plugin_name(plugin) if plugin else (path_parts[0] if len(path_parts) > 1 else "")
has_plugin_preview = bool(plugin and get_plugin_template(plugin, "full", fallback=False))
@@ -441,23 +498,35 @@ def web_base_url(context) -> str:
return get_web_base_url(request=context.get("request"), config=context.get("CONFIG"))
+@register.simple_tag(takes_context=True)
+def static_export_root_url(context) -> str:
+ if not context.get("STATIC_EXPORT"):
+ return ""
+ relative_path = os.path.relpath(CONSTANTS.DATA_DIR, start=Path(context["STATIC_EXPORT_DIR"]))
+ relative_path = relative_path.replace(os.sep, "/")
+ return "./" if relative_path == "." else f"{quote(relative_path, safe=_STATIC_URL_SAFE)}/"
+
+
@register.simple_tag(takes_context=True)
def snapshot_base_url(context, snapshot) -> str:
- snapshot_id = _snapshot_id(snapshot)
- return get_snapshot_base_url(str(snapshot_id), request=context.get("request"), config=context.get("CONFIG"))
+ return _snapshot_base_url_for_context(context, snapshot)
@register.simple_tag(takes_context=True)
def snapshot_url(context, snapshot, path: str = "") -> str:
- snapshot_id = _snapshot_id(snapshot)
- return build_snapshot_url(str(snapshot_id), path, request=context.get("request"), config=context.get("CONFIG"))
+ return _snapshot_url_for_context(context, snapshot, path)
@register.simple_tag(takes_context=True)
def snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) -> str:
snapshot_id = str(_snapshot_id(snapshot))
url_cache = snapshot.__dict__.setdefault("_snapshot_archiveresult_url_cache", {})
- cache_key = (plugin, filename)
+ cache_key = (
+ plugin,
+ filename,
+ bool(context.get("STATIC_EXPORT")),
+ str(context.get("STATIC_EXPORT_DIR") or ""),
+ )
if cache_key in url_cache:
return url_cache[cache_key]
@@ -493,7 +562,7 @@ def snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) ->
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
continue
output_path = filename if file_info.get("root_relative") else f"{plugin}/{filename}"
- url_cache[cache_key] = build_snapshot_url(snapshot_id, output_path, request=context.get("request"), config=context.get("CONFIG"))
+ url_cache[cache_key] = _snapshot_url_for_context(context, snapshot, output_path)
return url_cache[cache_key]
url_cache[cache_key] = ""
@@ -502,10 +571,7 @@ def snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) ->
@register.simple_tag(takes_context=True)
def snapshot_index_row(context, link) -> str:
- snapshot_id = str(_snapshot_id(link))
- request = context.get("request")
- config = context.get("CONFIG")
- snapshot_base = get_snapshot_base_url(snapshot_id, request=request, config=config)
+ snapshot_base = _snapshot_base_url_for_context(context, link)
status = getattr(link, "status", None) or "unknown"
bookmarked_at = getattr(link, "bookmarked_at", None)
@@ -534,22 +600,28 @@ def snapshot_index_row(context, link) -> str:
tag_cell = '...'
num_outputs = int(getattr(link, "num_outputs", 0) or 0)
- icons = link.icons() if callable(getattr(link, "icons", None)) else getattr(link, "icons", "")
+ if context.get("STATIC_EXPORT") and callable(getattr(link, "icons", None)):
+ icons = link.icons(path=quote(link.static_archive_path, safe=_STATIC_URL_SAFE), prefix="./", quote_paths=True)
+ else:
+ icons = link.icons() if callable(getattr(link, "icons", None)) else getattr(link, "icons", "")
icons_cell = str(icons) if icons else '...'
archive_size = int(getattr(link, "archive_size", 0) or 0)
size_cell = file_size(archive_size) if archive_size else '...'
output_plural = "" if num_outputs == 1 else "s"
+ files_url = _snapshot_url_for_context(context, link, "index.jsonl") if context.get("STATIC_EXPORT") else f"{snapshot_base}/?files=1"
if is_pending:
- preview_html = (
- ''
- f''
- ""
- )
+ preview_html = ''
+ if not context.get("STATIC_EXPORT"):
+ preview_html = (
+ ''
+ f''
+ ""
+ )
elif "_public_preview_paths" in link.__dict__:
preview_paths = list(getattr(link, "_public_preview_paths", []) or [])
if preview_paths:
- preview_urls = [build_snapshot_url(snapshot_id, path, request=request, config=config) for path in preview_paths]
+ preview_urls = [_snapshot_url_for_context(context, link, path) for path in preview_paths]
preview_html = (
f' str:
if "_public_favicon_paths" in link.__dict__:
favicon_paths = list(getattr(link, "_public_favicon_paths", []) or [])
if favicon_paths:
- favicon_urls = [build_snapshot_url(snapshot_id, path, request=request, config=config) for path in favicon_paths]
+ favicon_urls = [_snapshot_url_for_context(context, link, path) for path in favicon_paths]
favicon_html = (
f' str:
-
+
{size_cell}
{num_outputs} output{output_plural}
@@ -660,6 +732,7 @@ def snapshot_preview_url(context, snapshot, path: str = "", result=None) -> str:
request=context.get("request"),
config=context.get("CONFIG"),
plugin=plugin,
+ base_url=_snapshot_base_url_for_context(context, snapshot) if context.get("STATIC_EXPORT") else None,
)
@@ -699,24 +772,26 @@ def plugin_card(context, result) -> str:
# Use embed_path() for the display path
raw_output_path = result.embed_path() or ""
- output_url = build_snapshot_url(
- str(result.snapshot_id),
- raw_output_path or "",
- request=context.get("request"),
- config=context.get("CONFIG"),
- )
+ output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path or "")
icon_html = get_plugin_icon(plugin)
plugin_lower = (plugin or "").lower()
media_file_count = _count_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else 0
media_files = _list_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else []
+ if context.get("STATIC_EXPORT") and media_files:
+ media_files = [
+ item
+ for item in media_files
+ if (media_path := Path(str(item.get("path") or "")))
+ and not media_path.is_absolute()
+ and ".." not in media_path.parts
+ and (Path(result.snapshot_dir) / media_path).is_file()
+ ]
+ media_file_count = len(media_files)
if media_files:
- snapshot_id = str(result.snapshot_id)
- request = context.get("request")
- config = context.get("CONFIG")
for item in media_files:
path = item.get("path") or ""
- item["url"] = build_snapshot_url(snapshot_id, path, request=request, config=config) if path else ""
+ item["url"] = _snapshot_url_for_context(context, result.snapshot, path) if path else ""
output_lower = (raw_output_path or "").lower()
force_text_preview = output_lower.endswith(_TEXT_PREVIEW_EXTS)
@@ -794,12 +869,7 @@ def plugin_full(context, result) -> str:
raw_output_path = result.embed_path() or ""
if _is_root_snapshot_output_path(raw_output_path):
return ""
- output_url = build_snapshot_url(
- str(result.snapshot_id),
- raw_output_path,
- request=context.get("request"),
- config=context.get("CONFIG"),
- )
+ output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path)
try:
tpl = template.Template(template_str)
diff --git a/archivebox/core/views.py b/archivebox/core/views.py
index 2d9e05b0..e3b71e16 100644
--- a/archivebox/core/views.py
+++ b/archivebox/core/views.py
@@ -21,7 +21,6 @@ from django.core.paginator import InvalidPage
from django.db.models import Case, IntegerField, Q, Value, When
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseForbidden, QueryDict
from django.shortcuts import redirect, render
-from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
@@ -54,7 +53,6 @@ from archivebox.core.permissions import (
can_view_snapshot,
direct_snapshots_queryset,
filter_personas_by_permissions,
- get_snapshot_permissions,
is_admin_user,
public_snapshots_queryset,
)
@@ -69,15 +67,12 @@ from archivebox.core.routes_util import (
host_matches,
)
from archivebox.crawls.models import Crawl
-from archivebox.misc.logging_util import printable_filesize
from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.misc.serve_static import serve_static_with_byterange_support
from archivebox.misc.util import (
base_url,
filter_queryset_by_uuid_substring,
- htmldecode,
sanitize_html_text,
- ts_to_date_str,
urldecode,
validate_url,
without_fragment,
@@ -85,7 +80,7 @@ from archivebox.misc.util import (
from archivebox.plugins.discovery import get_plugin_name, get_plugin_template
from archivebox.plugins.forms import get_plugin_config_binary_urls
from archivebox.plugins.views import get_config_definition_link
-from archivebox.progressmonitor.views import live_progress_view, progress_endpoint
+from archivebox.progressmonitor.views import live_progress_view
from archivebox.search.config import (
get_search_mode,
get_search_mode_backend,
@@ -332,162 +327,11 @@ class SnapshotView(View):
@staticmethod
def render_live_index(request, snapshot):
- TITLE_LOADING_MSG = "Not yet archived..."
- from archivebox.core.widgets import TagEditorWidget
-
- # Reuse the middleware-attached config; never re-bootstrap from env + plugin
- # schemas just to render a snapshot page (that pays ~30ms for no reason).
- runtime_config = get_request_config(request)
- snapshot._runtime_config = runtime_config
- snapshot_permissions = get_snapshot_permissions(snapshot)
- archive_results = list(snapshot.archiveresult_set.all().order_by("start_ts"))
- tags = list(snapshot.tags.all())
- snapshot.__dict__["_admin_archiveresults"] = archive_results
- snapshot.__dict__["_tags_str_cached"] = ",".join(sorted(tag.name for tag in tags))
- snapshot.__dict__["num_outputs_cached"] = sum(result.status == ArchiveResult.StatusChoices.SUCCEEDED for result in archive_results)
- snapshot.__dict__["num_failures_cached"] = sum(result.status == ArchiveResult.StatusChoices.FAILED for result in archive_results)
- hidden_card_plugins = {"archivedotorg", "favicon", "title"}
- outputs = [
- out
- for out in snapshot.discover_outputs(include_filesystem_fallback=True, archive_results=archive_results)
- if (out.get("size") or 0) > 0 and out.get("name") not in hidden_card_plugins
- ]
- archiveresults = {}
- result_ids_by_name = {}
- for output in outputs:
- if output.get("result"):
- result_ids_by_name.setdefault(output["name"], []).append(str(output["result"].id))
- current = archiveresults.get(output["name"])
- if current is None or (output.get("size") or 0) > (current.get("size") or 0):
- archiveresults[output["name"]] = output
- for name, output in archiveresults.items():
- output["result_ids"] = ",".join(result_ids_by_name.get(name, ()))
- hash_index = snapshot.hashes_index
- loose_items, failed_items = snapshot.get_detail_page_auxiliary_items(
- outputs,
- hidden_card_plugins=hidden_card_plugins,
- archive_results=archive_results,
+ return render(
+ template_name="core/snapshot.html",
+ request=request,
+ context=snapshot.get_html_details_context(request=request),
)
- preview_priority = [
- "singlefile",
- "screenshot",
- "wget",
- "dom",
- "pdf",
- "readability",
- ]
- preferred_types = tuple(preview_priority)
- output_order = {result_type: index for index, result_type in enumerate(archiveresults.keys())}
-
- best_result = {"path": "about:blank", "result": None}
- for result_type in preferred_types:
- if result_type in archiveresults:
- best_result = archiveresults[result_type]
- break
-
- related_snapshots_qs = SnapshotView.find_snapshots_for_url(
- snapshot.url,
- allow_fallback=False,
- ).only("id", "url", "bookmarked_at", "created_at", "downloaded_at", "output_size")
- related_snapshots = list(
- related_snapshots_qs.exclude(id=snapshot.id).order_by("-bookmarked_at", "-created_at", "-timestamp")[:25],
- )
- related_years_map: dict[int, list[Snapshot]] = {}
- for snap in [snapshot, *related_snapshots]:
- snap_dt = snap.bookmarked_at or snap.created_at or snap.downloaded_at
- if not snap_dt:
- continue
- related_years_map.setdefault(snap_dt.year, []).append(snap)
- related_years = []
- for year, snaps in related_years_map.items():
- snaps_sorted = sorted(
- snaps,
- key=lambda s: s.bookmarked_at or s.created_at or s.downloaded_at or timezone.now(),
- reverse=True,
- )
- related_years.append(
- {
- "year": year,
- "latest": snaps_sorted[0],
- "snapshots": snaps_sorted,
- },
- )
- related_years.sort(key=lambda item: item["year"], reverse=True)
-
- warc_path = next(
- (rel_path for rel_path in hash_index if rel_path.startswith("warc/") and ".warc" in Path(rel_path).name),
- "warc/",
- )
-
- ordered_outputs = sorted(
- archiveresults.values(),
- key=lambda r: (
- preferred_types.index(r["name"]) if r["name"] in preferred_types else len(preferred_types),
- output_order.get(r["name"], len(output_order)),
- ),
- )
- if best_result["path"] == "about:blank" and ordered_outputs:
- best_result = ordered_outputs[0]
- non_compact_outputs = [out for out in ordered_outputs if not out.get("is_compact") and not out.get("is_metadata")]
- compact_outputs = [out for out in ordered_outputs if out.get("is_compact") or out.get("is_metadata")]
- tag_widget = TagEditorWidget()
- output_size = sum(int(out.get("size") or 0) for out in ordered_outputs)
- archive_dates = [result.start_ts for result in archive_results if result.start_ts]
- has_outputs = bool(ordered_outputs)
- is_archived = has_outputs or snapshot.status == Snapshot.StatusChoices.SEALED
- snapshot_status = str(snapshot.status or "").lower()
- status_label_by_state = {
- "queued": ("queued", "info"),
- "started": ("running", "warning"),
- "paused": ("paused", "default"),
- "sealed": ("archived", "success"),
- }
- if has_outputs and not is_archived:
- status_label, status_color = ("partial", "warning")
- elif has_outputs:
- status_label, status_color = ("archived", "success")
- else:
- status_label, status_color = status_label_by_state.get(snapshot_status, ("not yet archived", "danger"))
-
- context = {
- "id": str(snapshot.id),
- "snapshot_id": str(snapshot.id),
- "progress_endpoint": progress_endpoint("snapshot", snapshot.id),
- "progress_auto_expand": snapshot_status in {"queued", "started", "paused"},
- "url": snapshot.url,
- "archive_path": snapshot.archive_path_from_db,
- "title": htmldecode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)),
- "extension": snapshot.extension or "html",
- "tags": snapshot.tags_str() or "untagged",
- "size": printable_filesize(output_size) if output_size else "—",
- "status": status_label,
- "status_color": status_color,
- "snapshot_state": snapshot_status,
- "has_outputs": has_outputs,
- "snapshot_permissions": snapshot_permissions,
- "snapshot_permissions_icon": {
- "public": "👥",
- "unlisted": "🔗",
- "private": "🔒",
- }.get(snapshot_permissions, "👥"),
- "bookmarked_date": snapshot.bookmarked_date,
- "downloaded_datestr": snapshot.downloaded_datestr,
- "num_outputs": snapshot.num_outputs,
- "num_failures": snapshot.num_failures,
- "oldest_archive_date": ts_to_date_str(min(archive_dates) if archive_dates else None),
- "warc_path": warc_path,
- "archiveresults": [*non_compact_outputs, *compact_outputs],
- "best_result": best_result,
- "snapshot": snapshot, # Pass the snapshot object for template tags
- "CONFIG": runtime_config,
- "related_snapshots": related_snapshots,
- "related_years": related_years,
- "loose_items": loose_items,
- "failed_items": failed_items,
- "can_delete_outputs": bool(request.user.is_authenticated and request.user.is_active and request.user.is_superuser),
- "title_tags": [{"name": tag.name, "style": tag_widget._tag_style(tag.name)} for tag in sorted(tags, key=lambda tag: tag.name)],
- }
- return render(template_name="core/snapshot.html", request=request, context=context)
def get(self, request, path):
snapshot = None
diff --git a/archivebox/machine/models.py b/archivebox/machine/models.py
index 7a569e4c..b90015db 100755
--- a/archivebox/machine/models.py
+++ b/archivebox/machine/models.py
@@ -1156,6 +1156,11 @@ class Process(ModelWithDeleteAfter, models.Model):
models.Index(fields=["machine", "status", "process_type"], name="mach_proc_running_idx"),
]
constraints = [
+ # This is deliberately machine-scoped. It prevents two locally
+ # verifiable runners from working the same collection, while not
+ # claiming to coordinate independent hosts that share a database.
+ # Cross-machine work ownership belongs to short Crawl/Snapshot CAS
+ # claims so PostgreSQL deployments can support that model later.
models.UniqueConstraint(
fields=["machine", "pwd"],
condition=Q(status="running", process_type="orchestrator", worker_type="worker_runner"),
@@ -1362,7 +1367,7 @@ class Process(ModelWithDeleteAfter, models.Model):
self.save(update_fields=updates)
def heartbeat(self) -> None:
- """Touch modified_at so standby/leader selection can see this parent is alive."""
+ """Keep a long-lived watcher visible in recent-process monitoring."""
self.save(update_fields=["modified_at"])
def mark_exited(self, *, exit_code: int = 0) -> None:
diff --git a/archivebox/misc/checks.py b/archivebox/misc/checks.py
index 0570d379..3796cc60 100644
--- a/archivebox/misc/checks.py
+++ b/archivebox/misc/checks.py
@@ -48,6 +48,11 @@ def _exit_on_migration_interrupt():
os.write(sys.stderr.fileno(), _migration_interrupt_message().encode())
except Exception:
pass
+ # Django's migration executor can catch or delay normal exceptions while
+ # unwinding transactions. Use the real process exit path after printing
+ # the recovery command so Ctrl+C/SIGTERM during auto-migrations does not
+ # leave `archivebox server` apparently hung after the user asked it to
+ # stop. Migrations are atomic, so this does not record partial progress.
os._exit(130)
try:
diff --git a/archivebox/services/runner.py b/archivebox/services/runner.py
index 5035ef48..1ff0ee42 100644
--- a/archivebox/services/runner.py
+++ b/archivebox/services/runner.py
@@ -261,6 +261,10 @@ class CrawlRunner:
def _request_abort_from_signal(self, _sig: signal.Signals) -> None:
if os.environ.get("ARCHIVEBOX_RUNNER_DAEMON") == "1":
+ # The daemon runner is owned by supervisord, not by the interactive
+ # CLI foreground flow. A direct signal to this child should be short
+ # and unambiguous: exit non-zero immediately so supervisord restarts
+ # the runner, while the parent server and supervisord stay alive.
os._exit(128 + int(_sig))
already_requested = self._signal_abort_requested
self._signal_abort_requested = True
@@ -479,6 +483,11 @@ class CrawlRunner:
await self.enqueue_pending_snapshots_from_projection()
async def heartbeat_active_leases(self) -> None:
+ # These are resumable work-item leases, not orchestrator-election
+ # heartbeats. Each update is a short autocommit statement; network and
+ # filesystem work continues outside a database transaction. A future
+ # PostgreSQL multi-machine runner uses these Crawl/Snapshot claims as
+ # its coordination boundary while SQLite keeps one local orchestrator.
if self._run_task is None:
return
now_monotonic = time.monotonic()
@@ -717,10 +726,8 @@ class CrawlRunner:
stdout_is_tty = sys.stdout.isatty()
stderr_is_tty = sys.stderr.isatty()
interactive_tty = stdout_is_tty or stderr_is_tty
- if not interactive_tty:
- return None
- stream = sys.stderr if stderr_is_tty else sys.stdout
- if os.path.exists("/dev/tty"):
+ stream = sys.stderr if stderr_is_tty or not stdout_is_tty else sys.stdout
+ if interactive_tty and os.path.exists("/dev/tty"):
try:
self._live_stream = open("/dev/tty", "w", buffering=1, encoding=stream.encoding or "utf-8")
stream = self._live_stream
@@ -736,7 +743,7 @@ class CrawlRunner:
terminal_height = terminal_size.lines
ui_console = Console(
file=stream,
- force_terminal=True,
+ force_terminal=interactive_tty,
width=terminal_width,
height=terminal_height,
_environ={
@@ -750,7 +757,7 @@ class CrawlRunner:
total_hooks=_count_selected_hooks(self.plugins, self.selected_plugins),
timeout_seconds=self.base_config["TIMEOUT"],
ui_console=ui_console,
- interactive_tty=True,
+ interactive_tty=interactive_tty,
)
live_ui.print_intro(
url=self.primary_url or "crawl",
diff --git a/archivebox/templates/core/snapshot.html b/archivebox/templates/core/snapshot.html
index 8fb3cbb7..0b339970 100644
--- a/archivebox/templates/core/snapshot.html
+++ b/archivebox/templates/core/snapshot.html
@@ -319,9 +319,9 @@
user-select: all;
}
.header-toggle {
- line-height: 12px;
- font-size: 70px;
- margin-top: -13px;
+ line-height: 1;
+ font-size: 24px;
+ margin-top: 0;
margin-left: 4px;
}
@container snapshot-header (max-width: 900px) {
@@ -1056,8 +1056,9 @@
.thumb-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(clamp(180px, 14vw, 250px), 1fr));
+ grid-auto-flow: row dense;
+ grid-auto-rows: 42px;
gap: 6px;
- align-content: start;
width: 100%;
max-width: 100%;
margin-left: 0;
@@ -1077,17 +1078,23 @@
display: inline-flex;
flex-direction: column;
align-items: stretch;
+ grid-row: span 3;
break-inside: avoid;
- margin-bottom: 6px;
+ margin-bottom: 0;
vertical-align: top;
content-visibility: auto;
contain-intrinsic-size: 138px;
}
+ .thumb-card:not([data-plugin-name]) {
+ order: 1;
+ }
.thumb-card:has([data-compact]) {
- height: 46px;
- min-height: 46px;
- max-height: 46px;
- contain-intrinsic-size: 46px;
+ height: 42px;
+ min-height: 42px;
+ max-height: 42px;
+ grid-row: span 1;
+ order: 2;
+ contain-intrinsic-size: 42px;
}
.thumb-card .thumb-body {
display: grid;
@@ -1247,7 +1254,7 @@
}
.thumb-card:has([data-compact]) .thumbnail-wrapper,
.thumb-card:has([data-compact]) .thumbnail-wrapper.compact {
- height: 24px;
+ height: 20px;
flex: 0 0 auto;
}
.thumb-card:has([data-compact]) .thumb-body {
@@ -1468,17 +1475,19 @@
+ {% if not STATIC_EXPORT %}
{% if snapshot_state == 'queued' or snapshot_state == 'started' or snapshot_state == 'paused' %}
{% include "progressmonitor/progress_monitor.html" with progress_endpoint=progress_endpoint progress_scope="snapshot" %}