= 0")
if crawl_max_size < 0:
raise ValueError("crawl_max_size must be >= 0")
+ if crawl_timeout < 0:
+ raise ValueError("crawl_timeout must be >= 0")
if snapshot_max_size < 0:
raise ValueError("snapshot_max_size must be >= 0")
if crawl_max_concurrent_snapshots < 1:
@@ -158,6 +162,10 @@ def add(
and crawl_max_concurrent_snapshots != int(effective_persona_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS)
else {}
),
+ **({"CRAWL_MAX_URLS": max_urls} if max_urls else {}),
+ **({"CRAWL_MAX_SIZE": crawl_max_size} if crawl_max_size else {}),
+ **({"CRAWL_TIMEOUT": crawl_timeout} if crawl_timeout else {}),
+ **({"SNAPSHOT_MAX_SIZE": snapshot_max_size} if snapshot_max_size else {}),
**({"PARSER": parser} if parser != "auto" else {}),
**({"URL_ALLOWLIST": url_allowlist} if url_allowlist else {}),
**({"URL_DENYLIST": url_denylist} if url_denylist else {}),
@@ -166,9 +174,6 @@ def add(
crawl = Crawl.objects.create(
urls=urls_content,
max_depth=depth,
- max_urls=max_urls,
- crawl_max_size=crawl_max_size,
- snapshot_max_size=snapshot_max_size,
tags_str=tag,
persona_id=persona_obj.id,
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
@@ -274,6 +279,7 @@ def add(
)
@click.option("--max-urls", type=int, default=0, help="Maximum number of URLs to snapshot for this crawl (0 = unlimited)")
@click.option("--crawl-max-size", default="0", help="Maximum total crawl size in bytes or units like 45mb / 1gb (0 = unlimited)")
+@click.option("--crawl-timeout", type=int, default=0, help="Maximum total crawl runtime in seconds (0 = unlimited)")
@click.option("--snapshot-max-size", default="0", help="Maximum per-snapshot size in bytes or units like 45mb / 1gb (0 = unlimited)")
@click.option("--crawl-max-concurrent-snapshots", type=int, default=None, help="Maximum snapshots to archive concurrently within one crawl")
@click.option("--tag", "-t", default="", help="Comma-separated list of tags to add to each snapshot e.g. tag1,tag2,tag3")
@@ -297,6 +303,8 @@ def main(**kwargs):
raise click.UsageError("No URLs provided. Pass URLs as arguments or via stdin.")
if int(kwargs.get("max_urls") or 0) < 0:
raise click.BadParameter("max_urls must be 0 or a positive integer.", param_hint="--max-urls")
+ if int(kwargs.get("crawl_timeout") or 0) < 0:
+ raise click.BadParameter("crawl_timeout must be 0 or a positive integer.", param_hint="--crawl-timeout")
try:
kwargs["crawl_max_size"] = parse_filesize_to_bytes(kwargs.get("crawl_max_size"))
except ValueError as err:
diff --git a/archivebox/cli/archivebox_extract.py b/archivebox/cli/archivebox_extract.py
index 054382c1..8f536439 100644
--- a/archivebox/cli/archivebox_extract.py
+++ b/archivebox/cli/archivebox_extract.py
@@ -32,6 +32,7 @@ __command__ = "archivebox extract"
import sys
from collections import defaultdict
+from itertools import product
import rich_click as click
@@ -45,7 +46,6 @@ def process_archiveresult_by_id(archiveresult_id: str) -> int:
through the shared crawl runner with the corresponding plugin selected.
"""
from rich import print as rprint
- from django.utils import timezone
from archivebox.core.models import ArchiveResult
from archivebox.services.runner import run_crawl
@@ -58,19 +58,29 @@ def process_archiveresult_by_id(archiveresult_id: str) -> int:
rprint(f"[blue]Extracting {archiveresult.plugin} for {archiveresult.snapshot.url}[/blue]", file=sys.stderr)
try:
+ was_paused = archiveresult.snapshot.is_paused
archiveresult.reset_for_retry()
snapshot = archiveresult.snapshot
- snapshot.status = snapshot.StatusChoices.QUEUED
- snapshot.retry_at = timezone.now()
- snapshot.save(update_fields=["status", "retry_at", "modified_at"])
-
+ if not was_paused:
+ snapshot.queue_for_extraction()
+ else:
+ # A paused snapshot may still accept explicit maintenance for one
+ # ArchiveResult, but this path must not transition it back to
+ # queued/startable work.
+ snapshot.save(update_fields=["retry_at", "modified_at"])
crawl = snapshot.crawl
- if crawl.status != crawl.StatusChoices.STARTED:
- crawl.status = crawl.StatusChoices.QUEUED
- crawl.retry_at = timezone.now()
- crawl.save(update_fields=["status", "retry_at", "modified_at"])
+ if not crawl.claim_processing_lock(lock_seconds=10):
+ rprint(
+ f"[yellow]Crawl {crawl.id} is already owned by another runner[/yellow]",
+ file=sys.stderr,
+ )
+ return 1
- run_crawl(str(crawl.id), snapshot_ids=[str(snapshot.id)], selected_plugins=[archiveresult.plugin])
+ try:
+ run_crawl(str(snapshot.crawl_id), snapshot_ids=[str(snapshot.id)], selected_plugins=[archiveresult.plugin])
+ finally:
+ if was_paused:
+ snapshot.restore_paused_scheduler_marker()
archiveresult.refresh_from_db()
if archiveresult.status == ArchiveResult.StatusChoices.SUCCEEDED:
@@ -98,6 +108,7 @@ def run_plugins(
plugins: str = "",
wait: bool = True,
emit_results: bool = True,
+ show_progress: bool = True,
) -> int:
"""
Run plugins on Snapshots from input.
@@ -118,7 +129,9 @@ def run_plugins(
TYPE_ARCHIVERESULT,
)
from archivebox.core.models import Snapshot
+ from archivebox.core.models import ArchiveResult
from archivebox.services.runner import run_crawl
+ from abx_dl.models import discover_plugins
is_tty = sys.stdout.isatty()
@@ -145,7 +158,7 @@ def run_plugins(
if record_type == TYPE_SNAPSHOT:
snapshot_id = record.get("id")
if snapshot_id:
- snapshot_ids.add(snapshot_id)
+ snapshot_ids.add(str(snapshot_id))
elif record.get("url"):
# Look up by URL (get most recent if multiple exist)
snap = Snapshot.objects.filter(url=record["url"]).order_by("-created_at").first()
@@ -157,41 +170,124 @@ def run_plugins(
elif record_type == TYPE_ARCHIVERESULT:
snapshot_id = record.get("snapshot_id")
if snapshot_id:
- snapshot_ids.add(snapshot_id)
+ snapshot_ids.add(str(snapshot_id))
plugin_name = record.get("plugin")
if plugin_name and not plugins_list:
requested_plugins_by_snapshot[str(snapshot_id)].add(str(plugin_name))
elif "id" in record:
# Assume it's a snapshot ID
- snapshot_ids.add(record["id"])
+ snapshot_ids.add(str(record["id"]))
if not snapshot_ids:
rprint("[red]No valid snapshot IDs found in input[/red]", file=sys.stderr)
return 1
- # Get snapshots and ensure they have pending ArchiveResults
- processed_count = 0
- for snapshot_id in snapshot_ids:
- try:
- snapshot = Snapshot.objects.get(id=snapshot_id)
- except Snapshot.DoesNotExist:
- rprint(f"[yellow]Snapshot {snapshot_id} not found[/yellow]", file=sys.stderr)
- continue
+ existing_snapshots = list(Snapshot.objects.filter(id__in=snapshot_ids).values_list("id", "crawl_id"))
+ existing_snapshot_ids = {str(snapshot_id) for snapshot_id, _crawl_id in existing_snapshots}
+ existing_crawl_ids = {str(crawl_id) for _snapshot_id, crawl_id in existing_snapshots}
+ missing_snapshot_ids = sorted(str(snapshot_id) for snapshot_id in snapshot_ids - existing_snapshot_ids)
+ for snapshot_id in missing_snapshot_ids:
+ rprint(f"[yellow]Snapshot {snapshot_id} not found[/yellow]", file=sys.stderr)
- requested_plugin_names = set(plugins_list) | requested_plugins_by_snapshot.get(str(snapshot.id), set())
- for plugin_name in requested_plugin_names:
- existing_result = snapshot.archiveresult_set.filter(plugin=plugin_name).order_by("-created_at").first()
- if existing_result:
- existing_result.reset_for_retry()
+ # Queue only the target plugin rows. Bulk updates keep large reindex runs
+ # from doing one SELECT+UPDATE per snapshot/plugin before hooks even start.
+ requested_pairs: set[tuple[str, str]] = set()
+ if plugins_list:
+ requested_pairs.update((snapshot_id, plugin_name) for snapshot_id, plugin_name in product(existing_snapshot_ids, plugins_list))
+ else:
+ requested_pairs.update(
+ (snapshot_id, plugin_name)
+ for snapshot_id, plugin_names in requested_plugins_by_snapshot.items()
+ if snapshot_id in existing_snapshot_ids
+ for plugin_name in plugin_names
+ )
+ plugins_by_name = discover_plugins()
+ requested_rows: set[tuple[str, str, str]] = set()
+ for snapshot_id, plugin_name in requested_pairs:
+ plugin = plugins_by_name.get(plugin_name)
+ hooks = plugin.filter_hooks("Snapshot") if plugin is not None else []
+ if hooks:
+ requested_rows.update((snapshot_id, plugin_name, hook.name) for hook in hooks)
+ else:
+ requested_rows.add((snapshot_id, plugin_name, ""))
- # Reset snapshot status to allow processing
- if snapshot.status == Snapshot.StatusChoices.SEALED:
- snapshot.status = Snapshot.StatusChoices.STARTED
- snapshot.retry_at = timezone.now()
- snapshot.save()
+ reset_fields = {
+ "status": ArchiveResult.StatusChoices.QUEUED,
+ "output_str": "",
+ "output_json": None,
+ "output_files": {},
+ "output_size": 0,
+ "output_mimetypes": "",
+ "start_ts": None,
+ "end_ts": None,
+ "modified_at": timezone.now(),
+ }
+ if plugins_list:
+ ArchiveResult.objects.filter(snapshot_id__in=existing_snapshot_ids, plugin__in=plugins_list).update(**reset_fields)
+ elif requested_plugins_by_snapshot:
+ snapshot_ids_by_plugin: dict[str, set[str]] = defaultdict(set)
+ for snapshot_id, plugin_names in requested_plugins_by_snapshot.items():
+ if snapshot_id in existing_snapshot_ids:
+ for plugin_name in plugin_names:
+ snapshot_ids_by_plugin[plugin_name].add(snapshot_id)
+ for plugin_name, plugin_snapshot_ids in snapshot_ids_by_plugin.items():
+ ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name).update(**reset_fields)
+ existing_rows = set(
+ ArchiveResult.objects.filter(
+ snapshot_id__in=existing_snapshot_ids,
+ plugin__in={plugin_name for _snapshot_id, plugin_name, _hook_name in requested_rows},
+ ).values_list("snapshot_id", "plugin", "hook_name"),
+ )
+ missing_rows = requested_rows - {(str(snapshot_id), plugin_name, hook_name) for snapshot_id, plugin_name, hook_name in existing_rows}
+ if missing_rows:
+ ArchiveResult.objects.bulk_create(
+ [
+ ArchiveResult(
+ snapshot_id=snapshot_id,
+ plugin=plugin_name,
+ hook_name=hook_name,
+ status=ArchiveResult.StatusChoices.QUEUED,
+ )
+ for snapshot_id, plugin_name, hook_name in sorted(missing_rows)
+ ],
+ batch_size=500,
+ )
- processed_count += 1
+ processed_count = len(existing_snapshot_ids)
+ queue_at = timezone.now()
+ if existing_snapshot_ids:
+ if requested_rows:
+ # Targeted ArchiveResult retries use retry_at as the scheduling
+ # signal and keep sealed snapshots sealed so extractors are not
+ # re-run outside the explicitly queued plugin rows. Paused snapshots
+ # also keep status=paused here: `retry_at` only asks the orchestrator
+ # to process the queued plugin rows, and run_due_snapshot restores
+ # retry_at=MAX afterward instead of resuming the snapshot lifecycle.
+ Snapshot.objects.filter(id__in=existing_snapshot_ids).update(
+ retry_at=queue_at,
+ modified_at=queue_at,
+ )
+ else:
+ # No plugin rows were requested, so this is a full snapshot retry.
+ Snapshot.objects.filter(id__in=existing_snapshot_ids).update(
+ status=Snapshot.StatusChoices.QUEUED,
+ retry_at=queue_at,
+ current_step=0,
+ modified_at=queue_at,
+ )
+ if existing_crawl_ids and not requested_rows:
+ from archivebox.crawls.models import Crawl
+
+ Crawl.objects.filter(id__in=existing_crawl_ids).exclude(status=Crawl.StatusChoices.STARTED).update(
+ status=Crawl.StatusChoices.QUEUED,
+ retry_at=queue_at,
+ modified_at=queue_at,
+ )
+ Crawl.objects.filter(id__in=existing_crawl_ids, status=Crawl.StatusChoices.STARTED).update(
+ retry_at=queue_at,
+ modified_at=queue_at,
+ )
if processed_count == 0:
rprint("[red]No snapshots to process[/red]", file=sys.stderr)
@@ -203,14 +299,19 @@ def run_plugins(
if wait:
rprint("[blue]Running plugins...[/blue]", file=sys.stderr)
snapshot_ids_by_crawl: dict[str, set[str]] = defaultdict(set)
- for snapshot_id in snapshot_ids:
- try:
- snapshot = Snapshot.objects.only("id", "crawl_id").get(id=snapshot_id)
- except Snapshot.DoesNotExist:
- continue
- snapshot_ids_by_crawl[str(snapshot.crawl_id)].add(str(snapshot.id))
+ for snapshot_id, crawl_id in existing_snapshots:
+ snapshot_ids_by_crawl[str(crawl_id)].add(str(snapshot_id))
for crawl_id, crawl_snapshot_ids in snapshot_ids_by_crawl.items():
+ from archivebox.crawls.models import Crawl
+
+ crawl = Crawl.objects.get(id=crawl_id)
+ if not crawl.claim_processing_lock(lock_seconds=10):
+ rprint(
+ f"[yellow]Crawl {crawl_id} is already owned by another runner[/yellow]",
+ file=sys.stderr,
+ )
+ return 1
selected_plugins = (
plugins_list
or sorted(
@@ -222,6 +323,7 @@ def run_plugins(
crawl_id,
snapshot_ids=sorted(crawl_snapshot_ids),
selected_plugins=selected_plugins,
+ show_progress=show_progress,
)
if not emit_results:
diff --git a/archivebox/cli/archivebox_persona.py b/archivebox/cli/archivebox_persona.py
index d2cb9127..88691790 100644
--- a/archivebox/cli/archivebox_persona.py
+++ b/archivebox/cli/archivebox_persona.py
@@ -31,12 +31,8 @@ import os
import sys
import shutil
import platform
-import subprocess
-import tempfile
-import json
from pathlib import Path
from collections.abc import Iterable
-from collections import OrderedDict
import rich_click as click
from rich import print as rprint
@@ -214,136 +210,6 @@ CHROMIUM_BROWSERS = {"chrome", "chromium", "brave", "edge"}
# Cookie Extraction via CDP
# =============================================================================
-NETSCAPE_COOKIE_HEADER = [
- "# Netscape HTTP Cookie File",
- "# https://curl.se/docs/http-cookies.html",
- "# This file was generated by ArchiveBox persona cookie extraction",
- "#",
- "# Format: domain\\tincludeSubdomains\\tpath\\tsecure\\texpiry\\tname\\tvalue",
- "",
-]
-
-
-def _parse_netscape_cookies(path: Path) -> "OrderedDict[tuple[str, str, str], tuple[str, str, str, str, str, str, str]]":
- cookies = OrderedDict()
- if not path.exists():
- return cookies
-
- for line in path.read_text().splitlines():
- if not line or line.startswith("#"):
- continue
- parts = line.split("\t")
- if len(parts) < 7:
- continue
- domain, include_subdomains, cookie_path, secure, expiry, name, value = parts[:7]
- key = (domain, cookie_path, name)
- cookies[key] = (domain, include_subdomains, cookie_path, secure, expiry, name, value)
- return cookies
-
-
-def _write_netscape_cookies(path: Path, cookies: "OrderedDict[tuple[str, str, str], tuple[str, str, str, str, str, str, str]]") -> None:
- lines = list(NETSCAPE_COOKIE_HEADER)
- for cookie in cookies.values():
- lines.append("\t".join(cookie))
- path.write_text("\n".join(lines) + "\n")
-
-
-def _merge_netscape_cookies(existing_file: Path, new_file: Path) -> None:
- existing = _parse_netscape_cookies(existing_file)
- new = _parse_netscape_cookies(new_file)
- for key, cookie in new.items():
- existing[key] = cookie
- _write_netscape_cookies(existing_file, existing)
-
-
-def extract_cookies_via_cdp(
- user_data_dir: Path,
- output_file: Path,
- profile_dir: str | None = None,
- chrome_binary: str | None = None,
-) -> bool:
- """
- Launch Chrome with the given user data dir and extract cookies via CDP.
-
- Returns True if successful, False otherwise.
- """
- from archivebox.config.common import get_config
-
- # Find the cookie extraction script
- chrome_plugin_dir = Path(__file__).parent.parent / "plugins" / "chrome"
- extract_script = chrome_plugin_dir / "extract_cookies.js"
-
- if not extract_script.exists():
- rprint(f"[yellow]Cookie extraction script not found at {extract_script}[/yellow]", file=sys.stderr)
- return False
-
- # Get node modules dir
- node_modules_dir = get_config().LIB_DIR / "npm" / "node_modules"
-
- # Set up environment
- env = os.environ.copy()
- env["NODE_MODULES_DIR"] = str(node_modules_dir)
- env["CHROME_USER_DATA_DIR"] = str(user_data_dir)
- env["CHROME_HEADLESS"] = "true"
- if chrome_binary:
- env["CHROME_BINARY"] = str(chrome_binary)
- output_path = output_file
- temp_output = None
- temp_dir = None
- if output_file.exists():
- temp_dir = Path(tempfile.mkdtemp(prefix="ab_cookies_"))
- temp_output = temp_dir / "cookies.txt"
- output_path = temp_output
- if profile_dir:
- extra_arg = f"--profile-directory={profile_dir}"
- existing_extra = env.get("CHROME_ARGS_EXTRA", "").strip()
- args_list = []
- if existing_extra:
- if existing_extra.startswith("["):
- try:
- parsed = json.loads(existing_extra)
- if isinstance(parsed, list):
- args_list.extend(str(x) for x in parsed)
- except Exception:
- args_list.extend([s.strip() for s in existing_extra.split(",") if s.strip()])
- else:
- args_list.extend([s.strip() for s in existing_extra.split(",") if s.strip()])
- args_list.append(extra_arg)
- env["CHROME_ARGS_EXTRA"] = json.dumps(args_list)
-
- env["COOKIES_OUTPUT_FILE"] = str(output_path)
-
- try:
- result = subprocess.run(
- ["node", str(extract_script)],
- env=env,
- capture_output=True,
- text=True,
- timeout=60,
- )
-
- if result.returncode == 0:
- if temp_output and temp_output.exists():
- _merge_netscape_cookies(output_file, temp_output)
- return True
- else:
- rprint(f"[yellow]Cookie extraction failed: {result.stderr}[/yellow]", file=sys.stderr)
- return False
-
- except subprocess.TimeoutExpired:
- rprint("[yellow]Cookie extraction timed out[/yellow]", file=sys.stderr)
- return False
- except FileNotFoundError:
- rprint("[yellow]Node.js not found. Cannot extract cookies.[/yellow]", file=sys.stderr)
- return False
- except Exception as e:
- rprint(f"[yellow]Cookie extraction error: {e}[/yellow]", file=sys.stderr)
- return False
- finally:
- if temp_dir and temp_dir.exists():
- shutil.rmtree(temp_dir, ignore_errors=True)
-
-
# =============================================================================
# Validation Helpers
# =============================================================================
diff --git a/archivebox/cli/archivebox_run.py b/archivebox/cli/archivebox_run.py
index 5d0b11ad..2bec20a0 100644
--- a/archivebox/cli/archivebox_run.py
+++ b/archivebox/cli/archivebox_run.py
@@ -40,6 +40,7 @@ Examples:
__package__ = "archivebox.cli"
__command__ = "archivebox run"
+import os
import sys
from collections import defaultdict
@@ -111,10 +112,10 @@ def process_stdin_records() -> int:
crawl = Crawl.from_json(record, overrides={"created_by_id": created_by_id})
if crawl:
- crawl.retry_at = timezone.now()
- if crawl.status not in [Crawl.StatusChoices.SEALED]:
- crawl.status = Crawl.StatusChoices.QUEUED
- crawl.save()
+ crawl.update_and_requeue(
+ status=Crawl.StatusChoices.QUEUED,
+ retry_at=timezone.now(),
+ )
full_crawl_ids.add(str(crawl.id))
run_all_plugins_for_crawl.add(str(crawl.id))
output_records.append(crawl.to_json())
@@ -132,15 +133,7 @@ def process_stdin_records() -> int:
snapshot = Snapshot.from_json(record, overrides={"created_by_id": created_by_id})
if snapshot:
- snapshot.retry_at = timezone.now()
- if snapshot.status not in [Snapshot.StatusChoices.SEALED]:
- snapshot.status = Snapshot.StatusChoices.QUEUED
- snapshot.save()
- crawl = snapshot.crawl
- crawl.retry_at = timezone.now()
- if crawl.status != Crawl.StatusChoices.STARTED:
- crawl.status = Crawl.StatusChoices.QUEUED
- crawl.save(update_fields=["status", "retry_at", "modified_at"])
+ snapshot.queue_for_extraction()
crawl_id = str(snapshot.crawl_id)
snapshot_ids_by_crawl[crawl_id].add(str(snapshot.id))
run_all_plugins_for_crawl.add(crawl_id)
@@ -177,15 +170,7 @@ def process_stdin_records() -> int:
snapshot = None
if snapshot:
- snapshot.retry_at = timezone.now()
- if snapshot.status != Snapshot.StatusChoices.STARTED:
- snapshot.status = Snapshot.StatusChoices.QUEUED
- snapshot.save(update_fields=["status", "retry_at", "modified_at"])
- crawl = snapshot.crawl
- crawl.retry_at = timezone.now()
- if crawl.status != Crawl.StatusChoices.STARTED:
- crawl.status = Crawl.StatusChoices.QUEUED
- crawl.save(update_fields=["status", "retry_at", "modified_at"])
+ snapshot.queue_for_extraction()
crawl_id = str(snapshot.crawl_id)
snapshot_ids_by_crawl[crawl_id].add(str(snapshot.id))
if plugin_name:
@@ -236,6 +221,13 @@ def process_stdin_records() -> int:
targeted_crawl_ids = full_crawl_ids | set(snapshot_ids_by_crawl)
if targeted_crawl_ids:
for crawl_id in sorted(targeted_crawl_ids):
+ try:
+ crawl = Crawl.objects.get(id=crawl_id)
+ except Crawl.DoesNotExist:
+ continue
+ if not crawl.claim_processing_lock(lock_seconds=10):
+ rprint(f"[yellow]Crawl {crawl_id} is already owned by another runner[/yellow]", file=sys.stderr)
+ return 1
run_crawl(
crawl_id,
snapshot_ids=None if crawl_id in full_crawl_ids else sorted(snapshot_ids_by_crawl[crawl_id]),
@@ -253,17 +245,20 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None) -> int:
Returns exit code (0 = success, 1 = error).
"""
- from django.utils import timezone
+ from archivebox.config import CONSTANTS
from archivebox.machine.models import Machine, Process
- from archivebox.services.runner import cleanup_orchestrator_state, run_pending_crawls
+ from archivebox.services.supervision_service import healthy_orchestrator
+ from archivebox.services.runner import recover_orchestrator_state, run_pending_crawls
- cleanup_orchestrator_state(include_chrome=True)
+ recover_orchestrator_state(include_chrome=True)
Machine.current()
+ existing = healthy_orchestrator(data_dir=CONSTANTS.DATA_DIR)
current = Process.current()
- if current.process_type != Process.TypeChoices.ORCHESTRATOR:
- current.process_type = Process.TypeChoices.ORCHESTRATOR
- current.save(update_fields=["process_type", "modified_at"])
-
+ existing_pid = existing.get("pid") if isinstance(existing, dict) else getattr(existing, "pid", None)
+ if existing_pid and existing_pid != os.getpid():
+ rprint(f"[green][*] Existing ArchiveBox orchestrator pid={existing_pid} is already running.[/green]", file=sys.stderr)
+ return 0
+ current.mark_running(process_type=Process.TypeChoices.ORCHESTRATOR, pwd=str(CONSTANTS.DATA_DIR), timeout=0)
try:
run_pending_crawls(daemon=daemon, crawl_id=crawl_id)
return 0
@@ -275,9 +270,7 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None) -> int:
finally:
current.refresh_from_db()
if current.status != Process.StatusChoices.EXITED:
- current.status = Process.StatusChoices.EXITED
- current.ended_at = current.ended_at or timezone.now()
- current.save(update_fields=["status", "ended_at", "modified_at"])
+ current.mark_exited()
@click.command()
@@ -328,11 +321,15 @@ def main(daemon: bool, crawl_id: str, snapshot_id: str, binary_id: str):
def run_snapshot_worker(snapshot_id: str) -> int:
from archivebox.core.models import Snapshot
- from archivebox.services.runner import run_crawl
+ from archivebox.services.runner import run_due_snapshot
+ from django.utils import timezone
try:
snapshot = Snapshot.objects.select_related("crawl").get(id=snapshot_id)
- run_crawl(str(snapshot.crawl_id), snapshot_ids=[str(snapshot.id)])
+ if snapshot.retry_at is None:
+ Snapshot.objects.filter(pk=snapshot.pk).update(retry_at=timezone.now(), modified_at=timezone.now())
+ snapshot.refresh_from_db()
+ run_due_snapshot(snapshot, lock_seconds=60)
return 0
except KeyboardInterrupt:
return 0
diff --git a/archivebox/cli/archivebox_server.py b/archivebox/cli/archivebox_server.py
index 57abfc49..71c59c5f 100644
--- a/archivebox/cli/archivebox_server.py
+++ b/archivebox/cli/archivebox_server.py
@@ -13,83 +13,6 @@ from archivebox.misc.util import docstring, enforce_types
from archivebox.config.common import get_config
-def stop_existing_background_runner(*, machine, process_model, supervisor=None, stop_worker_fn=None, log=print) -> int:
- """Stop any existing orchestrator process so the server can take ownership."""
- running_runners = list(
- process_model.objects.filter(
- machine=machine,
- status=process_model.StatusChoices.RUNNING,
- process_type=process_model.TypeChoices.ORCHESTRATOR,
- ).order_by("created_at"),
- )
-
- if not running_runners:
- return 0
-
- log("[yellow][*] Stopping existing ArchiveBox background runner...[/yellow]")
-
- if supervisor is not None and stop_worker_fn is not None:
- for worker_name in ("worker_runner", "worker_runner_watch"):
- try:
- stop_worker_fn(supervisor, worker_name)
- except Exception:
- pass
-
- for proc in running_runners:
- try:
- proc.kill_tree(graceful_timeout=2.0)
- except Exception:
- try:
- proc.terminate(graceful_timeout=2.0)
- except Exception:
- pass
-
- return len(running_runners)
-
-
-def _read_supervisor_worker_command(worker_name: str) -> str:
- from archivebox.workers.supervisord_util import WORKERS_DIR_NAME, get_sock_file
-
- worker_conf = get_sock_file().parent / WORKERS_DIR_NAME / f"{worker_name}.conf"
- if not worker_conf.exists():
- return ""
-
- for line in worker_conf.read_text().splitlines():
- if line.startswith("command="):
- return line.removeprefix("command=").strip()
- return ""
-
-
-def _worker_command_matches_bind(command: str, host: str, port: str) -> bool:
- if not command:
- return False
- return f"{host}:{port}" in command or (f"--bind={host}" in command and f"--port={port}" in command)
-
-
-def stop_existing_server_workers(*, supervisor, stop_worker_fn, host: str, port: str, log=print) -> int:
- """Stop existing ArchiveBox web workers if they already own the requested bind."""
- stopped = 0
-
- for worker_name in ("worker_runserver", "worker_daphne"):
- try:
- proc = supervisor.getProcessInfo(worker_name) if supervisor else None
- except Exception:
- proc = None
- if not isinstance(proc, dict) or proc.get("statename") != "RUNNING":
- continue
-
- command = _read_supervisor_worker_command(worker_name)
- if not _worker_command_matches_bind(command, host, port):
- continue
-
- if stopped == 0:
- log("[yellow][*] Taking over existing ArchiveBox web server on same port...[/yellow]")
- stop_worker_fn(supervisor, worker_name)
- stopped += 1
-
- return stopped
-
-
@enforce_types
def server(
runserver_args: Iterable[str] | None = None,
@@ -144,60 +67,17 @@ def server(
pass
from archivebox.workers.supervisord_util import (
- get_existing_supervisord_process,
- get_worker,
- stop_worker,
start_server_workers,
+ stop_existing_supervisord_process,
is_port_in_use,
)
- from archivebox.machine.models import Machine, Process
-
- machine = Machine.current()
- supervisor = get_existing_supervisord_process()
- stop_existing_background_runner(
- machine=machine,
- process_model=Process,
- supervisor=supervisor,
- stop_worker_fn=stop_worker,
+ from archivebox.machine.models import Process
+ from archivebox.services.supervision_service import (
+ command_owns_runtime_stack,
+ current_command,
+ standby_until_runtime_stack_needed,
)
- if supervisor:
- stop_existing_server_workers(
- supervisor=supervisor,
- stop_worker_fn=stop_worker,
- host=host,
- port=port,
- )
-
- # Check if port is already in use
- if is_port_in_use(host, int(port)):
- print(f"[red][X] Error: Port {port} is already in use[/red]")
- print(f" Another process (possibly daphne or runserver) is already listening on {host}:{port}")
- print(" Stop the conflicting process or choose a different port")
- sys.exit(1)
-
- supervisor = get_existing_supervisord_process()
- if supervisor:
- server_worker_name = "worker_runserver" if run_in_debug else "worker_daphne"
- server_proc = get_worker(supervisor, server_worker_name)
- server_state = server_proc.get("statename") if isinstance(server_proc, dict) else None
- if server_state == "RUNNING":
- runner_proc = get_worker(supervisor, "worker_runner")
- runner_watch_proc = get_worker(supervisor, "worker_runner_watch")
- runner_state = runner_proc.get("statename") if isinstance(runner_proc, dict) else None
- runner_watch_state = runner_watch_proc.get("statename") if isinstance(runner_watch_proc, dict) else None
- print("[red][X] Error: ArchiveBox server is already running[/red]")
- print(
- f" [green]β[/green] Web server ({server_worker_name}) is RUNNING on [deep_sky_blue4][link=http://{host}:{port}]http://{host}:{port}[/link][/deep_sky_blue4]",
- )
- if runner_state == "RUNNING":
- print(" [green]β[/green] Background runner (worker_runner) is RUNNING")
- if runner_watch_state == "RUNNING":
- print(" [green]β[/green] Reload watcher (worker_runner_watch) is RUNNING")
- print()
- print("[yellow]To stop the existing server, run:[/yellow]")
- print(' pkill -f "archivebox server"')
- print(" pkill -f supervisord")
- sys.exit(1)
+ from archivebox.core.shutdown_util import foreground_shutdown_signals
if run_in_debug:
print("[green][+] Starting ArchiveBox webserver in DEBUG mode...[/green]")
@@ -211,7 +91,42 @@ def server(
)
print(" > Writing ArchiveBox error log to ./logs/errors.log")
print()
- start_server_workers(host=host, port=port, daemonize=daemonize, debug=run_in_debug, reload=reload, nothreading=nothreading)
+ bind_url = f"http://{host}:{port}"
+ command = current_command(Process.TypeChoices.SERVER, data_dir=config.DATA_DIR, url=bind_url)
+
+ try:
+ with foreground_shutdown_signals():
+ while True:
+ standby_until_runtime_stack_needed(command, data_dir=config.DATA_DIR)
+ sys.stdout.write(f"[*] ArchiveBox server parent pid={os.getpid()} is now running the orchestrator and server...\n")
+ sys.stdout.flush()
+ stop_existing_supervisord_process()
+ if is_port_in_use(host, int(port)):
+ print(f"[red][X] Error: Port {port} is already in use[/red]")
+ print(f" Another process outside this ArchiveBox runtime is listening on {host}:{port}")
+ sys.exit(1)
+
+ result = start_server_workers(
+ host=host,
+ port=port,
+ daemonize=daemonize,
+ debug=run_in_debug,
+ reload=reload,
+ nothreading=nothreading,
+ keep_running=lambda: command_owns_runtime_stack(command, data_dir=config.DATA_DIR),
+ should_stop_supervisord=lambda: command_owns_runtime_stack(command, data_dir=config.DATA_DIR),
+ )
+ if not command_owns_runtime_stack(command, data_dir=config.DATA_DIR):
+ print("[yellow][*] Another ArchiveBox command took over the runtime stack; standing by.[/yellow]")
+ continue
+ if result == "exited":
+ print("[yellow][*] Runtime stack exited while this parent is still leader; restarting...[/yellow]")
+ continue
+ break
+ except KeyboardInterrupt:
+ pass
+ finally:
+ command.mark_exited()
print("\n[i][green][π©] ArchiveBox server shut down gracefully.[/green][/i]")
diff --git a/archivebox/cli/archivebox_snapshot.py b/archivebox/cli/archivebox_snapshot.py
index 541319e4..d41b1e2b 100644
--- a/archivebox/cli/archivebox_snapshot.py
+++ b/archivebox/cli/archivebox_snapshot.py
@@ -35,8 +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, Sum, When
-from django.db.models.functions import Coalesce
+from django.db.models import Case, IntegerField, Q, QuerySet, When
from archivebox.cli.cli_utils import apply_filters
@@ -179,26 +178,17 @@ def create_snapshots(
# =============================================================================
-def list_snapshots(
+def build_snapshot_queryset(
+ *,
status: str | None = None,
url__icontains: str | None = None,
url__istartswith: str | None = None,
tag: str | None = None,
crawl_id: str | None = None,
- limit: int | None = None,
sort: str | None = None,
- csv: str | None = None,
- with_headers: bool = False,
search: str | None = None,
query: str | None = None,
-) -> int:
- """
- List Snapshots as JSONL with optional filters.
-
- Exit codes:
- 0: Success (even if no results)
- """
- from archivebox.misc.jsonl import write_record
+) -> QuerySet:
from archivebox.core.models import Snapshot
from archivebox.search import (
get_default_search_mode,
@@ -207,24 +197,17 @@ def list_snapshots(
query_search_index,
)
- if with_headers and not csv:
- rprint("[red]--with-headers requires --csv[/red]", file=sys.stderr)
- return 2
-
- is_tty = sys.stdout.isatty() and not csv
-
queryset = Snapshot.objects.order_by("-created_at")
+ queryset = apply_filters(
+ queryset,
+ {
+ "status": status,
+ "url__icontains": url__icontains,
+ "url__istartswith": url__istartswith,
+ "crawl_id": crawl_id,
+ },
+ )
- # Apply filters
- filter_kwargs = {
- "status": status,
- "url__icontains": url__icontains,
- "url__istartswith": url__istartswith,
- "crawl_id": crawl_id,
- }
- queryset = apply_filters(queryset, filter_kwargs)
-
- # Tag filter requires special handling (M2M)
if tag:
queryset = queryset.filter(tags__name__iexact=tag)
@@ -265,6 +248,48 @@ def list_snapshots(
if sort:
queryset = queryset.order_by(sort)
+ return queryset
+
+
+def list_snapshots(
+ status: str | None = None,
+ url__icontains: str | None = None,
+ url__istartswith: str | None = None,
+ tag: str | None = None,
+ crawl_id: str | None = None,
+ limit: int | None = None,
+ sort: str | None = None,
+ csv: str | None = None,
+ with_headers: bool = False,
+ search: str | None = None,
+ query: str | None = None,
+) -> int:
+ """
+ List Snapshots as JSONL with optional filters.
+
+ Exit codes:
+ 0: Success (even if no results)
+ """
+ from archivebox.misc.jsonl import write_record
+ from archivebox.core.models import Snapshot
+
+ if with_headers and not csv:
+ rprint("[red]--with-headers requires --csv[/red]", file=sys.stderr)
+ return 2
+
+ is_tty = sys.stdout.isatty() and not csv
+
+ queryset = build_snapshot_queryset(
+ status=status,
+ url__icontains=url__icontains,
+ url__istartswith=url__istartswith,
+ tag=tag,
+ crawl_id=crawl_id,
+ sort=sort,
+ search=search,
+ query=query,
+ )
+
if not is_tty:
if limit:
limited_ids = list(queryset.values_list("id", flat=True)[:limit])
@@ -273,7 +298,7 @@ def list_snapshots(
output_field=IntegerField(),
)
queryset = Snapshot.objects.filter(id__in=limited_ids).order_by(preserved_order)
- queryset = queryset.annotate(output_size_sum=Coalesce(Sum("archiveresult__output_size"), 0)).prefetch_related("tags")
+ queryset = queryset.prefetch_related("tags")
elif limit:
queryset = queryset[:limit]
diff --git a/archivebox/cli/archivebox_status.py b/archivebox/cli/archivebox_status.py
index 9cbd38ef..781df941 100644
--- a/archivebox/cli/archivebox_status.py
+++ b/archivebox/cli/archivebox_status.py
@@ -130,7 +130,7 @@ def status(out_dir: Path = DATA_DIR) -> None:
print(" [green]archivebox manage createsuperuser[/green]")
print()
- recent_snapshots = snapshots_qs.annotate(output_size_sum=Coalesce(Sum("archiveresult__output_size"), 0)).order_by(
+ recent_snapshots = snapshots_qs.order_by(
"-downloaded_at",
"-modified_at",
)[:10]
@@ -141,7 +141,7 @@ def status(out_dir: Path = DATA_DIR) -> None:
(
"[grey53] "
f" > {str(snapshot.downloaded_at)[:16]} "
- f"[{snapshot.num_outputs} {('X', 'β')[snapshot.status == Snapshot.StatusChoices.SEALED]} {printable_filesize(snapshot.output_size_sum or 0)}] "
+ f"[{snapshot.num_outputs} {('X', 'β')[snapshot.status == Snapshot.StatusChoices.SEALED]} {printable_filesize(snapshot.output_size or 0)}] "
f'"{snapshot.title}": {snapshot.url}'
"[/grey53]"
)[: config.TERM_WIDTH],
diff --git a/archivebox/cli/archivebox_update.py b/archivebox/cli/archivebox_update.py
index 136a5456..8f357d23 100644
--- a/archivebox/cli/archivebox_update.py
+++ b/archivebox/cli/archivebox_update.py
@@ -3,15 +3,17 @@
__package__ = "archivebox.cli"
import os
+import asyncio
+import shlex
import time
from typing import TYPE_CHECKING, Any
-from collections.abc import Callable, Iterable
+from collections.abc import Iterable
from pathlib import Path
import rich_click as click
from django.core.exceptions import ObjectDoesNotExist
-from django.db.models import Q, QuerySet
+from django.db.models import QuerySet
from archivebox.misc.util import enforce_types, docstring
@@ -20,33 +22,6 @@ if TYPE_CHECKING:
from archivebox.crawls.models import Crawl
-LINK_FILTERS: dict[str, Callable[[str], Q]] = {
- "exact": lambda pattern: Q(url=pattern),
- "substring": lambda pattern: Q(url__icontains=pattern),
- "regex": lambda pattern: Q(url__iregex=pattern),
- "domain": lambda pattern: (
- Q(url__istartswith=f"http://{pattern}") | Q(url__istartswith=f"https://{pattern}") | Q(url__istartswith=f"ftp://{pattern}")
- ),
- "tag": lambda pattern: Q(tags__name=pattern),
- "timestamp": lambda pattern: Q(timestamp=pattern),
-}
-
-
-def _apply_pattern_filters(
- snapshots: QuerySet["Snapshot", "Snapshot"],
- filter_patterns: list[str],
- filter_type: str,
-) -> QuerySet["Snapshot", "Snapshot"]:
- filter_builder = LINK_FILTERS.get(filter_type)
- if filter_builder is None:
- raise SystemExit(2)
-
- query = Q()
- for pattern in filter_patterns:
- query |= filter_builder(pattern)
- return snapshots.filter(query)
-
-
def _get_snapshot_crawl(snapshot: "Snapshot") -> "Crawl | None":
try:
return snapshot.crawl
@@ -73,17 +48,35 @@ def _build_filtered_snapshots_queryset(
*,
filter_patterns: Iterable[str],
filter_type: str,
- before: float | None,
- after: float | None,
+ status: str | None = None,
+ url__icontains: str | None = None,
+ url__istartswith: str | None = None,
+ tag: str | None = None,
+ crawl_id: str | None = None,
+ limit: int | None = None,
+ sort: str | None = None,
+ search: str | None = None,
+ before: float | None = None,
+ after: float | None = None,
resume: str | None = None,
):
- from archivebox.core.models import Snapshot
from datetime import datetime
+ from archivebox.cli.archivebox_snapshot import build_snapshot_queryset
- snapshots = Snapshot.objects.all()
+ filter_patterns = tuple(filter_patterns)
+ snapshots = build_snapshot_queryset(
+ status=status,
+ url__icontains=url__icontains,
+ url__istartswith=url__istartswith,
+ tag=tag,
+ crawl_id=crawl_id,
+ sort=sort,
+ search=search,
+ query=" ".join(filter_patterns) if search else None,
+ )
- if filter_patterns:
- snapshots = _apply_pattern_filters(snapshots, list(filter_patterns), filter_type)
+ if filter_patterns and not search:
+ snapshots = snapshots.filter_by_patterns(list(filter_patterns), filter_type)
if before:
snapshots = snapshots.filter(bookmarked_at__lt=datetime.fromtimestamp(before))
@@ -91,8 +84,14 @@ def _build_filtered_snapshots_queryset(
snapshots = snapshots.filter(bookmarked_at__gt=datetime.fromtimestamp(after))
if resume:
snapshots = snapshots.filter(timestamp__lte=resume)
+ if not sort:
+ snapshots = snapshots.order_by("-timestamp")
+ snapshots = snapshots.select_related("crawl")
+ if limit:
+ limited_ids = list(snapshots.values_list("id", flat=True)[:limit])
+ snapshots = snapshots.model.objects.filter(id__in=limited_ids).select_related("crawl")
- return snapshots.select_related("crawl").order_by("-bookmarked_at")
+ return snapshots
def reindex_snapshots(
@@ -100,53 +99,64 @@ def reindex_snapshots(
*,
search_plugins: list[str],
batch_size: int,
-) -> dict[str, int]:
+ collect_ids: bool = False,
+) -> dict[str, Any]:
from archivebox.cli.archivebox_extract import run_plugins
- stats = {"processed": 0, "reconciled": 0, "queued": 0, "reindexed": 0}
+ stats: dict[str, Any] = {"processed": 0, "queued": 0, "reindexed": 0, "snapshot_ids": []}
records: list[dict[str, str]] = []
total = snapshots.count()
print(f"[*] Reindexing {total} snapshots with search plugins: {', '.join(search_plugins)}")
- for snapshot in snapshots.iterator(chunk_size=batch_size):
- stats["processed"] += 1
+ def run_batch() -> None:
+ if not records:
+ return
+ batch_records = list(records)
+ # Index-only backfill intentionally queues only search ArchiveResult
+ # rows. The extract runner bumps Snapshot.retry_at so the orchestrator
+ # sees the maintenance work, but it does not change status away from
+ # PAUSED; run_due_snapshot restores retry_at=MAX after the targeted
+ # plugin rows finish.
+ exit_code = run_plugins(
+ args=(),
+ records=batch_records,
+ wait=False,
+ emit_results=False,
+ show_progress=False,
+ )
+ if exit_code != 0:
+ raise SystemExit(exit_code)
+ print(
+ f" [{stats['processed']}/{total}] Queued {len(batch_records)} index jobs for orchestrator",
+ )
+ records.clear()
- if _get_snapshot_crawl(snapshot) is None:
- continue
+ for snapshot in snapshots.select_related("crawl").paged_iterator(chunk_size=batch_size):
+ try:
+ stats["processed"] += 1
- output_dir = Path(snapshot.output_dir)
- has_directory = output_dir.exists() and output_dir.is_dir()
- if has_directory:
- snapshot.reconcile_with_index_json()
- stats["reconciled"] += 1
+ if _get_snapshot_crawl(snapshot) is None:
+ continue
- for plugin_name in search_plugins:
- existing_result = snapshot.archiveresult_set.filter(plugin=plugin_name).order_by("-created_at").first()
- if existing_result:
- existing_result.reset_for_retry()
- records.append(
- {
- "type": "ArchiveResult",
- "snapshot_id": str(snapshot.id),
- "plugin": plugin_name,
- },
- )
- stats["queued"] += 1
+ if collect_ids:
+ stats["snapshot_ids"].append(str(snapshot.id))
+ for plugin_name in search_plugins:
+ records.append(
+ {
+ "type": "ArchiveResult",
+ "snapshot_id": str(snapshot.id),
+ "plugin": plugin_name,
+ },
+ )
+ stats["queued"] += 1
+ if len(records) >= batch_size:
+ run_batch()
+ except KeyboardInterrupt as err:
+ err.archivebox_resume = snapshot.timestamp
+ raise
- if not records:
- return stats
-
- exit_code = run_plugins(
- args=(),
- records=records,
- wait=True,
- emit_results=False,
- )
- if exit_code != 0:
- raise SystemExit(exit_code)
-
- stats["reindexed"] = len(records)
+ run_batch()
return stats
@@ -154,12 +164,21 @@ def reindex_snapshots(
def update(
filter_patterns: Iterable[str] = (),
filter_type: str = "exact",
+ status: str | None = None,
+ url__icontains: str | None = None,
+ url__istartswith: str | None = None,
+ tag: str | None = None,
+ crawl_id: str | None = None,
+ limit: int | None = None,
+ sort: str | None = None,
+ search: str | None = None,
before: float | None = None,
after: float | None = None,
resume: str | None = None,
batch_size: int = 100,
continuous: bool = False,
index_only: bool = False,
+ migrate_only: bool = False,
) -> None:
"""
Update snapshots: migrate old dirs, reconcile DB, and re-queue for archiving.
@@ -174,83 +193,173 @@ def update(
"""
from rich import print
+ from archivebox.config import CONSTANTS
from archivebox.config.django import setup_django
setup_django()
+ from archivebox.machine.models import Process
+ from archivebox.services.supervision_service import current_command, ensure_daemon_stack
+ from archivebox.workers.supervisord_util import stop_existing_supervisord_process
- from django.core.management import call_command
+ command = current_command(Process.TypeChoices.UPDATE, data_dir=CONSTANTS.DATA_DIR)
+ is_filtered_update = any(
+ (
+ filter_patterns,
+ status,
+ url__icontains,
+ url__istartswith,
+ tag,
+ crawl_id,
+ limit,
+ sort,
+ search,
+ before,
+ after,
+ ),
+ )
+ touched_snapshot_ids: set[str] = set()
+
+ from archivebox.misc.checks import check_migrations
- # Run migrations first to ensure DB schema is up-to-date
- print("[*] Checking for pending migrations...")
try:
- call_command("migrate", "--no-input", verbosity=0)
- except Exception as e:
- print(f"[!] Warning: Migration check failed: {e}")
+ # Run migrations first to ensure DB schema is up-to-date
+ print("[*] Checking for pending migrations...")
+ check_migrations(auto_apply=True)
+ stop_existing_supervisord_process()
- while True:
- if index_only:
- search_plugins = _get_search_indexing_plugins()
- if not search_plugins:
- print("[*] No search indexing plugins are available, nothing to backfill.")
+ while True:
+ do_migrate = migrate_only or not index_only
+ do_index = index_only or not migrate_only
+ do_run_until_idle = do_migrate or do_index
+
+ if do_migrate:
+ if filter_patterns or status or url__icontains or url__istartswith or tag or crawl_id or limit or sort or search or before or after:
+ print("[*] Processing filtered snapshots from database...")
+ stats = process_filtered_snapshots(
+ filter_patterns=filter_patterns,
+ filter_type=filter_type,
+ status=status,
+ url__icontains=url__icontains,
+ url__istartswith=url__istartswith,
+ tag=tag,
+ crawl_id=crawl_id,
+ limit=limit,
+ sort=sort,
+ search=search,
+ before=before,
+ after=after,
+ resume=resume,
+ batch_size=batch_size,
+ queue_for_archiving=do_run_until_idle,
+ )
+ print_stats(stats)
+ touched_snapshot_ids.update(stats.get("snapshot_ids", []))
+ else:
+ stats_combined = {"phase1": {}, "phase2": {}}
+
+ print("[*] Phase 1: Draining old archive/ directories (0.8.x β 0.9.x migration)...")
+ stats_combined["phase1"] = drain_old_archive_dirs(
+ resume_from=resume,
+ batch_size=batch_size,
+ )
+
+ print("[*] Phase 2: Processing all database snapshots (most recent first)...")
+ stats_combined["phase2"] = process_all_db_snapshots(batch_size=batch_size, resume=resume)
+ print_combined_stats(stats_combined)
+
+ if do_index:
+ ensure_daemon_stack(reason="search indexing")
+ search_plugins = _get_search_indexing_plugins()
+ if not search_plugins:
+ print("[*] No search indexing plugins are available, nothing to backfill.")
+ else:
+ snapshots = _build_filtered_snapshots_queryset(
+ filter_patterns=filter_patterns,
+ filter_type=filter_type,
+ status=status,
+ url__icontains=url__icontains,
+ url__istartswith=url__istartswith,
+ tag=tag,
+ crawl_id=crawl_id,
+ limit=limit,
+ sort=sort,
+ search=search,
+ before=before,
+ after=after,
+ resume=resume,
+ )
+ stats = reindex_snapshots(
+ snapshots,
+ search_plugins=search_plugins,
+ batch_size=batch_size,
+ collect_ids=is_filtered_update,
+ )
+ print_index_stats(stats)
+ touched_snapshot_ids.update(stats.get("snapshot_ids", []))
+
+ if do_run_until_idle:
+ print("[*] Phase 3: Running queued/interrupted crawl work until idle...")
+ from archivebox.cli.archivebox_run import run_runner, run_snapshot_worker
+
+ if is_filtered_update:
+ if not touched_snapshot_ids:
+ print("[*] No matching snapshots queued work for the runner.")
+ for snapshot_id in sorted(touched_snapshot_ids):
+ exit_code = run_snapshot_worker(snapshot_id)
+ if exit_code != 0:
+ raise SystemExit(exit_code)
+ else:
+ exit_code = run_runner(daemon=False)
+ if exit_code != 0:
+ raise SystemExit(exit_code)
+
+ if not continuous:
break
- if not (filter_patterns or before or after):
- print("[*] Phase 1: Draining old archive/ directories (0.8.x β 0.9.x migration)...")
- drain_old_archive_dirs(
- resume_from=resume,
- batch_size=batch_size,
- )
-
- snapshots = _build_filtered_snapshots_queryset(
- filter_patterns=filter_patterns,
- filter_type=filter_type,
- before=before,
- after=after,
- resume=resume,
- )
- stats = reindex_snapshots(
- snapshots,
- search_plugins=search_plugins,
- batch_size=batch_size,
- )
- print_index_stats(stats)
- elif filter_patterns or before or after:
- # Filtered mode: query DB only
- print("[*] Processing filtered snapshots from database...")
- stats = process_filtered_snapshots(
- filter_patterns=filter_patterns,
- filter_type=filter_type,
- before=before,
- after=after,
- resume=resume,
- batch_size=batch_size,
- )
- print_stats(stats)
- else:
- # Full mode: drain old dirs + process DB
- stats_combined = {"phase1": {}, "phase2": {}}
-
- print("[*] Phase 1: Draining old archive/ directories (0.8.x β 0.9.x migration)...")
- stats_combined["phase1"] = drain_old_archive_dirs(
- resume_from=resume,
- batch_size=batch_size,
- )
-
- print("[*] Phase 2: Processing all database snapshots (most recent first)...")
- stats_combined["phase2"] = process_all_db_snapshots(batch_size=batch_size, resume=resume)
-
- # Phase 3: Deduplication (disabled for now)
- # print('[*] Phase 3: Deduplicating...')
- # stats_combined['deduplicated'] = Snapshot.find_and_merge_duplicates()
-
- print_combined_stats(stats_combined)
-
- if not continuous:
- break
-
- print("[yellow]Sleeping 60s before next pass...[/yellow]")
- time.sleep(60)
- resume = None
+ print("[yellow]Sleeping 60s before next pass...[/yellow]")
+ time.sleep(60)
+ resume = None
+ except (KeyboardInterrupt, asyncio.CancelledError) as err:
+ exact_resume = getattr(err, "archivebox_resume", None)
+ resume_cmd = ["archivebox", "update"]
+ if migrate_only:
+ resume_cmd.append("--migrate-only")
+ if index_only:
+ resume_cmd.append("--index-only")
+ if batch_size != 100:
+ resume_cmd.extend(["--batch-size", str(batch_size)])
+ if exact_resume or resume:
+ resume_cmd.extend(["--resume", str(exact_resume or resume)])
+ if before is not None:
+ resume_cmd.extend(["--before", str(before)])
+ if after is not None:
+ resume_cmd.extend(["--after", str(after)])
+ if filter_type != "exact":
+ resume_cmd.extend(["--filter-type", filter_type])
+ if status:
+ resume_cmd.extend(["--status", status])
+ if url__icontains:
+ resume_cmd.extend(["--url__icontains", url__icontains])
+ if url__istartswith:
+ resume_cmd.extend(["--url__istartswith", url__istartswith])
+ if tag:
+ resume_cmd.extend(["--tag", tag])
+ if crawl_id:
+ resume_cmd.extend(["--crawl-id", crawl_id])
+ if limit:
+ resume_cmd.extend(["--limit", str(limit)])
+ if sort:
+ resume_cmd.extend(["--sort", sort])
+ if search:
+ resume_cmd.extend(["--search", search])
+ resume_cmd.extend(str(pattern) for pattern in filter_patterns)
+ print("\n[red][X] archivebox update interrupted.[/red]")
+ print("[yellow]Hint: resume this idempotent update with:[/yellow]")
+ print(f" [green]{' '.join(shlex.quote(part) for part in resume_cmd)}[/green]")
+ raise SystemExit(130)
+ finally:
+ command.mark_exited()
+ stop_existing_supervisord_process()
def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 100) -> dict[str, int]:
@@ -269,11 +378,9 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 100
from archivebox.core.models import Snapshot
from archivebox.config.common import get_config
from archivebox.crawls.models import Crawl
- from django.db import transaction
from django.utils import timezone
- stats = {"processed": 0, "migrated": 0, "skipped": 0, "invalid": 0}
- crawl_output_dirs: dict[str, Path] = {}
+ stats = {"processed": 0, "migrated": 0, "queued": 0, "skipped": 0, "invalid": 0}
crawl_url_lines: dict[str, list[str]] = {}
crawl_url_sets: dict[str, set[str]] = {}
dirty_crawl_ids: set[str] = set()
@@ -283,18 +390,27 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 100
if not archive_dir.exists():
return stats
- for crawl in Crawl.objects.filter(label__startswith="[migration] orphaned").iterator():
- url_entries = crawl._iter_url_lines()
- existing_urls = {url for _raw_line, url in url_entries if url}
- lines = (crawl.urls or "").splitlines()
- changed = False
- for url in crawl.snapshot_set.order_by("timestamp").values_list("url", flat=True):
- if url not in existing_urls:
- lines.append(url)
- existing_urls.add(url)
- changed = True
- if changed:
- Crawl.objects.filter(pk=crawl.pk).update(urls="\n".join(lines), modified_at=timezone.now())
+ last_crawl_id = None
+ while True:
+ crawl_qs = Crawl.objects.filter(label__startswith="[migration] orphaned").order_by("id")
+ if last_crawl_id is not None:
+ crawl_qs = crawl_qs.filter(id__gt=last_crawl_id)
+ crawl_batch = list(crawl_qs[:batch_size])
+ if not crawl_batch:
+ break
+ for crawl in crawl_batch:
+ last_crawl_id = crawl.id
+ url_entries = crawl._iter_url_lines()
+ existing_urls = {url for _raw_line, url in url_entries if url}
+ lines = (crawl.urls or "").splitlines()
+ changed = False
+ for url in crawl.snapshot_set.order_by("timestamp").values_list("url", flat=True):
+ if url not in existing_urls:
+ lines.append(url)
+ existing_urls.add(url)
+ changed = True
+ 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)
all_entries = list(os.scandir(archive_dir))
@@ -329,29 +445,17 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 100
continue
try:
+ snapshot.status = Snapshot.StatusChoices.SEALED
+ snapshot.retry_at = timezone.now()
Snapshot.objects.bulk_create([snapshot])
- snapshot.migrate_filesystem_to_current_version(source_dir=entry_path, config=runtime_config)
Snapshot.objects.filter(pk=snapshot.pk).update(
- fs_version=snapshot.fs_version,
+ status=Snapshot.StatusChoices.SEALED,
+ retry_at=snapshot.retry_at,
)
- migration_cleanup = getattr(snapshot, "_pending_fs_migration_cleanup", None)
- new_dir = None
- if migration_cleanup:
- old_dir, new_dir = migration_cleanup
- transaction.on_commit(
- lambda old_dir=old_dir, new_dir=new_dir, snapshot=snapshot: snapshot._cleanup_old_migration_dir(old_dir, new_dir),
- )
- delattr(snapshot, "_pending_fs_migration_cleanup")
crawl = _get_snapshot_crawl(snapshot)
- crawl_dir = None
if crawl is not None:
crawl_cache_key = str(crawl.id)
- crawl_dir = crawl_output_dirs.get(crawl_cache_key)
- if crawl_dir is None:
- crawl_dir = Path(crawl.output_dir)
- crawl_output_dirs[crawl_cache_key] = crawl_dir
-
existing_urls = crawl_url_sets.get(crawl_cache_key)
if existing_urls is None:
url_entries = crawl._iter_url_lines()
@@ -363,9 +467,8 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 100
existing_urls.add(snapshot.url)
dirty_crawl_ids.add(crawl_cache_key)
- snapshot.ensure_crawl_symlink(crawl_dir=crawl_dir, snapshot_dir=new_dir)
- stats["migrated"] += 1
- print(f" [{stats['processed']}] Imported orphaned snapshot: {entry_path.name}")
+ stats["queued"] += 1
+ print(f" [{stats['processed']}] Imported orphaned snapshot and queued migration: {entry_path.name}")
except Exception as e:
stats["skipped"] += 1
print(f" [{stats['processed']}] Skipped (error: {e}): {entry_path.name}")
@@ -377,7 +480,9 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 100
if not has_valid_crawl:
# Create a new crawl (created_by will default to system user)
crawl = Crawl.objects.create(urls=snapshot.url)
- # Use queryset update to avoid triggering save() hooks
+ # Use queryset update to avoid save() hooks and keep the SQLite
+ # write to one statement while the migration loop does filesystem
+ # work outside any transaction.
from archivebox.core.models import Snapshot as SnapshotModel
SnapshotModel.objects.filter(pk=snapshot.pk).update(crawl=crawl)
@@ -386,32 +491,13 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 100
# Check if needs migration (0.8.x β 0.9.x)
try:
- old_version = snapshot.fs_version
- snapshot.migrate_filesystem_to_current_version(source_dir=entry_path, config=runtime_config)
- if snapshot.fs_version != old_version or getattr(snapshot, "_pending_fs_migration_cleanup", None):
+ if snapshot.fs_migration_needed:
Snapshot.objects.filter(pk=snapshot.pk).update(
- fs_version=snapshot.fs_version,
+ retry_at=timezone.now(),
+ modified_at=timezone.now(),
)
- migration_cleanup = getattr(snapshot, "_pending_fs_migration_cleanup", None)
- new_dir = None
- if migration_cleanup:
- old_dir, new_dir = migration_cleanup
- transaction.on_commit(
- lambda old_dir=old_dir, new_dir=new_dir, snapshot=snapshot: snapshot._cleanup_old_migration_dir(old_dir, new_dir),
- )
- delattr(snapshot, "_pending_fs_migration_cleanup")
- crawl_dir = None
- if snapshot.crawl_id:
- crawl_cache_key = str(snapshot.crawl_id)
- crawl_dir = crawl_output_dirs.get(crawl_cache_key)
- if crawl_dir is None:
- crawl = _get_snapshot_crawl(snapshot)
- if crawl is not None:
- crawl_dir = Path(crawl.output_dir)
- crawl_output_dirs[crawl_cache_key] = crawl_dir
- snapshot.ensure_crawl_symlink(crawl_dir=crawl_dir, snapshot_dir=new_dir)
- stats["migrated"] += 1
- print(f" [{stats['processed']}] Migrated: {entry_path.name}")
+ stats["queued"] += 1
+ print(f" [{stats['processed']}] Queued filesystem migration: {entry_path.name}")
else:
stats["skipped"] += 1
except Exception as e:
@@ -425,7 +511,6 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 100
modified_at=timezone.now(),
)
dirty_crawl_ids.clear()
- transaction.commit()
for crawl_id in tuple(dirty_crawl_ids):
Crawl.objects.filter(pk=crawl_id).update(
@@ -433,7 +518,6 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 100
modified_at=timezone.now(),
)
dirty_crawl_ids.clear()
- transaction.commit()
return stats
@@ -448,14 +532,21 @@ def process_all_db_snapshots(batch_size: int = 100, resume: str | None = None) -
No orphan detection needed - we trust 1:1 mapping between DB and filesystem
after Phase 1 has drained all old archive/ directories.
"""
- from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.config.common import get_config
+ import uuid
+ from archivebox.core.models import Snapshot
from archivebox.crawls.models import Crawl
- from django.db import transaction
from django.utils import timezone
- stats = {"processed": 0, "reconciled": 0, "sealed": 0, "crawls_sealed": 0}
- runtime_config = get_config()
+ stats = {
+ "processed": 0,
+ "scanned_dirs": 0,
+ "updated_json": 0,
+ "updated_db": 0,
+ "queued": 0,
+ "sealed": 0,
+ "crawls_sealed": 0,
+ }
+ current_fs_version = Snapshot._fs_current_version()
queryset = Snapshot.objects.all()
if resume:
@@ -463,89 +554,83 @@ def process_all_db_snapshots(batch_size: int = 100, resume: str | None = None) -
total = queryset.count()
print(f"[*] Processing {total} snapshots from database (most recent first)...")
- # Process from most recent to least recent
- for snapshot in queryset.select_related("crawl__created_by").order_by("-bookmarked_at").iterator(chunk_size=batch_size):
- stats["processed"] += 1
+ def update_in_batches(rows, *, label: str, **updates) -> int:
+ updated = 0
+ checked = 0
+ while True:
+ ids = list(rows.order_by("-timestamp").values_list("id", flat=True)[:batch_size])
+ if not ids:
+ if updated:
+ print(f" [{label}] complete: {updated} rows updated")
+ return updated
+ checked += len(ids)
+ print(f" [{label}] updating next {len(ids)} rows (seen {checked})...")
+ # Each batch is one short UPDATE and is intentionally idempotent.
+ # If the command is interrupted, these rows no longer match on the
+ # next run and remaining rows continue from DB state.
+ updated += Snapshot.objects.filter(id__in=ids).update(**updates)
+ print(f" [{label}] updated {updated} rows so far")
- # Skip snapshots with missing crawl references (orphaned by migration errors)
- if _get_snapshot_crawl(snapshot) is None:
- continue
+ now = timezone.now()
+ updated_rows = update_in_batches(
+ queryset.exclude(
+ status__in=[
+ Snapshot.StatusChoices.QUEUED,
+ Snapshot.StatusChoices.STARTED,
+ Snapshot.StatusChoices.PAUSED,
+ Snapshot.StatusChoices.SEALED,
+ ],
+ ),
+ label="snapshot status normalization",
+ status=Snapshot.StatusChoices.SEALED,
+ retry_at=None,
+ modified_at=now,
+ )
+ stats["sealed"] += updated_rows
+ stats["updated_db"] += updated_rows
+ fs_version_rows = queryset.exclude(fs_version=current_fs_version)
+ stale_batch: list[tuple[uuid.UUID, uuid.UUID | None, str]] = []
+
+ def queue_stale_fs_batch() -> None:
+ if not stale_batch:
+ return
+ now = timezone.now()
+ snapshot_ids = [snapshot_id for snapshot_id, _crawl_id, _timestamp in stale_batch]
+ # Do not bump fs_version here. The orchestrator calls Snapshot.save(),
+ # which performs the idempotent filesystem migration and commits the new
+ # fs_version in the same serialized worker path as normal crawls.
+ updated = Snapshot.objects.filter(id__in=snapshot_ids).update(
+ retry_at=now,
+ modified_at=now,
+ )
+ stats["processed"] += len(stale_batch)
+ stats["updated_db"] += updated
+ stats["queued"] += updated
+ print(f" [{stats['processed']}/{total}] Queued {updated} filesystem migrations for orchestrator...")
+ stale_batch.clear()
+
+ for snapshot in fs_version_rows.only("id", "crawl_id", "timestamp").order_by("-timestamp").paged_iterator(chunk_size=batch_size):
try:
- # Check if snapshot has a directory on disk
- from pathlib import Path
+ stale_batch.append((snapshot.id, snapshot.crawl_id, snapshot.timestamp))
+ if len(stale_batch) >= batch_size:
+ queue_stale_fs_batch()
+ except KeyboardInterrupt as err:
+ err.archivebox_resume = snapshot.timestamp
+ raise
+ queue_stale_fs_batch()
- output_dir = Path(snapshot.get_storage_path_for_version(snapshot.fs_version, config=runtime_config))
- has_directory = output_dir.exists() and output_dir.is_dir()
- current_fs_version = Snapshot._fs_current_version()
- update_values = {
- "status": Snapshot.StatusChoices.SEALED,
- "retry_at": None,
- }
-
- # Only reconcile if directory exists (don't create empty directories for orphans)
- if has_directory:
- old_title = snapshot.title
- snapshot.reconcile_with_index_json(output_dir=output_dir, update_existing_archive_results=False)
- metadata_updates = []
- for archiveresult in ArchiveResult.objects.filter(snapshot=snapshot).only(
- "id",
- "snapshot_id",
- "plugin",
- "output_str",
- "output_files",
- "output_size",
- "output_mimetypes",
- "modified_at",
- ):
- if archiveresult.update_output_metadata_from_filesystem(snapshot_dir=output_dir, save=False):
- metadata_updates.append(archiveresult)
- if metadata_updates:
- ArchiveResult.objects.bulk_update(
- metadata_updates,
- ["output_files", "output_size", "output_mimetypes", "modified_at"],
- batch_size=batch_size,
- )
- if snapshot.title != old_title:
- update_values["title"] = snapshot.title
- update_values["modified_at"] = timezone.now()
-
- # Clean up invalid field values from old migrations
- if not isinstance(snapshot.current_step, int):
- update_values["current_step"] = 0
-
- if snapshot.fs_migration_needed:
- legacy_dir = snapshot.get_storage_path_for_version("0.8.0", config=runtime_config)
- current_dir = snapshot.get_storage_path_for_version(current_fs_version, config=runtime_config)
- if legacy_dir.exists() or current_dir.exists():
- snapshot.migrate_filesystem_to_current_version(config=runtime_config)
- update_values["fs_version"] = snapshot.fs_version
- Snapshot.objects.filter(pk=snapshot.pk).update(**update_values)
- else:
- update_values["fs_version"] = current_fs_version
- Snapshot.objects.filter(pk=snapshot.pk).update(**update_values)
- else:
- Snapshot.objects.filter(pk=snapshot.pk).update(**update_values)
-
- stats["reconciled"] += 1 if has_directory else 0
- stats["sealed"] += 1
- except Exception as e:
- # Skip snapshots that can't be processed (e.g., missing crawl)
- print(f" [!] Skipping snapshot {snapshot.id}: {e}")
- continue
-
- if stats["processed"] % batch_size == 0:
- transaction.commit()
- print(f" [{stats['processed']}/{total}] Processed...")
-
- transaction.commit()
now = timezone.now()
stats["crawls_sealed"] = (
Crawl.objects.filter(
status__in=[Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED],
)
.exclude(
- snapshot_set__status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED],
+ snapshot_set__status__in=[
+ Snapshot.StatusChoices.QUEUED,
+ Snapshot.StatusChoices.STARTED,
+ Snapshot.StatusChoices.PAUSED,
+ ],
)
.update(
status=Crawl.StatusChoices.SEALED,
@@ -553,26 +638,44 @@ def process_all_db_snapshots(batch_size: int = 100, resume: str | None = None) -
modified_at=now,
)
)
+ stats["updated_db"] += stats["crawls_sealed"]
return stats
def process_filtered_snapshots(
filter_patterns: Iterable[str],
filter_type: str,
+ status: str | None,
+ url__icontains: str | None,
+ url__istartswith: str | None,
+ tag: str | None,
+ crawl_id: str | None,
+ limit: int | None,
+ sort: str | None,
+ search: str | None,
before: float | None,
after: float | None,
resume: str | None,
batch_size: int,
-) -> dict[str, int]:
+ queue_for_archiving: bool = True,
+) -> dict[str, Any]:
"""Process snapshots matching filters (DB query only)."""
- from django.db import transaction
+ from archivebox.core.models import Snapshot
from django.utils import timezone
- stats = {"processed": 0, "reconciled": 0, "queued": 0}
+ stats: dict[str, Any] = {"processed": 0, "updated_json": 0, "updated_db": 0, "queued": 0, "snapshot_ids": []}
snapshots = _build_filtered_snapshots_queryset(
filter_patterns=filter_patterns,
filter_type=filter_type,
+ status=status,
+ url__icontains=url__icontains,
+ url__istartswith=url__istartswith,
+ tag=tag,
+ crawl_id=crawl_id,
+ limit=limit,
+ sort=sort,
+ search=search,
before=before,
after=after,
resume=resume,
@@ -581,7 +684,7 @@ def process_filtered_snapshots(
total = snapshots.count()
print(f"[*] Found {total} matching snapshots")
- for snapshot in snapshots.select_related("crawl").iterator(chunk_size=batch_size):
+ for snapshot in snapshots.select_related("crawl").paged_iterator(chunk_size=batch_size):
stats["processed"] += 1
# Skip snapshots with missing crawl references
@@ -589,30 +692,40 @@ def process_filtered_snapshots(
continue
try:
- # Reconcile index.json with DB
- snapshot.reconcile_with_index_json()
-
- # Clean up invalid field values from old migrations
+ stats["snapshot_ids"].append(str(snapshot.id))
+ update_values = {}
if not isinstance(snapshot.current_step, int):
- snapshot.current_step = 0
+ update_values["current_step"] = 0
+ if queue_for_archiving:
+ update_values.update(
+ {
+ "status": Snapshot.StatusChoices.QUEUED,
+ "retry_at": timezone.now(),
+ "modified_at": timezone.now(),
+ },
+ )
+ if update_values:
+ # update() is intentionally used instead of save(); save()
+ # runs output-dir hooks, which must not happen while SQLite
+ # is holding the write lock for this state change. Index-only
+ # maintenance goes through reindex_snapshots/run_plugins instead
+ # so paused snapshots keep status=paused while only their
+ # targeted search ArchiveResult rows run.
+ Snapshot.objects.filter(pk=snapshot.pk).update(**update_values)
+ stats["updated_db"] += 1
- # Queue for archiving
- snapshot.status = Snapshot.StatusChoices.QUEUED
- snapshot.retry_at = timezone.now()
- snapshot.save()
-
- stats["reconciled"] += 1
- stats["queued"] += 1
+ stats["queued"] += 1 if queue_for_archiving else 0
+ except KeyboardInterrupt as err:
+ err.archivebox_resume = snapshot.timestamp
+ raise
except Exception as e:
# Skip snapshots that can't be processed
print(f" [!] Skipping snapshot {snapshot.id}: {e}")
continue
if stats["processed"] % batch_size == 0:
- transaction.commit()
print(f" [{stats['processed']}/{total}] Processed...")
- transaction.commit()
return stats
@@ -622,9 +735,10 @@ def print_stats(stats: dict):
print(f"""
[green]Update Complete[/green]
- Processed: {stats["processed"]}
- Reconciled: {stats["reconciled"]}
- Queued: {stats["queued"]}
+ Scanned rows: {stats["processed"]}
+ Updated JSON: {stats.get("updated_json", 0)}
+ Updated DB rows: {stats.get("updated_db", 0)}
+ Queued snapshots: {stats["queued"]}
""")
@@ -639,16 +753,17 @@ def print_combined_stats(stats_combined: dict):
[green]Archive Update Complete[/green]
Phase 1 (Drain Old Dirs):
- Checked: {s1.get("processed", 0)}
- Migrated: {s1.get("migrated", 0)}
- Skipped: {s1.get("skipped", 0)}
- Invalid: {s1.get("invalid", 0)}
+ Scanned dirs: {s1.get("processed", 0)}
+ Moved files: {s1.get("migrated", 0)}
+ Skipped dirs: {s1.get("skipped", 0)}
+ Invalid dirs: {s1.get("invalid", 0)}
Phase 2 (Process DB):
- Processed: {s2.get("processed", 0)}
- Reconciled: {s2.get("reconciled", 0)}
- Sealed: {s2.get("sealed", 0)}
- Crawls: {s2.get("crawls_sealed", 0)} sealed
+ Scanned dirs: {s2.get("scanned_dirs", 0)}
+ Updated JSON: {s2.get("updated_json", 0)}
+ Updated DB rows: {s2.get("updated_db", 0)}
+ Sealed snapshots: {s2.get("sealed", 0)}
+ Sealed crawls: {s2.get("crawls_sealed", 0)}
""")
@@ -657,21 +772,28 @@ def print_index_stats(stats: dict[str, Any]) -> None:
print(f"""
[green]Search Reindex Complete[/green]
- Processed: {stats["processed"]}
- Reconciled: {stats["reconciled"]}
- Queued: {stats["queued"]}
- Reindexed: {stats["reindexed"]}
+ Scanned rows: {stats["processed"]}
+ Queued index jobs: {stats["queued"]}
""")
@click.command()
@click.option("--resume", type=str, help="Resume from timestamp")
+@click.option("--status", "-s", help="Filter by status (queued, started, sealed)")
+@click.option("--url__icontains", help="Filter by URL contains")
+@click.option("--url__istartswith", help="Filter by URL starts with")
+@click.option("--tag", "-t", help="Filter by tag name")
+@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("--before", type=float, help="Only snapshots before timestamp")
@click.option("--after", type=float, help="Only snapshots after timestamp")
-@click.option("--filter-type", "-t", type=click.Choice(["exact", "substring", "regex", "domain", "tag", "timestamp"]), default="exact")
+@click.option("--filter-type", type=click.Choice(["exact", "substring", "regex", "domain", "tag", "timestamp"]), default="exact")
@click.option("--batch-size", type=int, default=100, help="Commit every N snapshots")
@click.option("--continuous", is_flag=True, help="Run continuously as background worker")
@click.option("--index-only", is_flag=True, help="Backfill available search indexes from existing archived content")
+@click.option("--migrate-only", is_flag=True, help="Only migrate filesystem and update database/index state")
@click.argument("filter_patterns", nargs=-1)
@docstring(update.__doc__)
def main(**kwargs):
diff --git a/archivebox/config/common.py b/archivebox/config/common.py
index c986efc7..958f1700 100644
--- a/archivebox/config/common.py
+++ b/archivebox/config/common.py
@@ -139,8 +139,6 @@ class ServerConfig(BaseConfigSet):
# CUSTOM_TEMPLATES_DIR: Path = Field(default=None) # this is now a constant
PUBLIC_INDEX: bool = Field(default=True)
- PUBLIC_SNAPSHOTS: bool = Field(default=True)
- PUBLIC_SNAPSHOTS_LIST: bool | None = Field(default=None)
PUBLIC_ADD_VIEW: bool = Field(default=False)
ADMIN_USERNAME: str | None = Field(default=None)
@@ -254,6 +252,7 @@ class ArchivingConfig(BaseConfigSet):
MAX_DEPTH: int = Field(default=0)
CRAWL_MAX_URLS: int = Field(default=0)
CRAWL_MAX_SIZE: int = Field(default=0)
+ CRAWL_TIMEOUT: int = Field(default=0, description="Maximum total crawl runtime in seconds (0 = unlimited).")
CRAWL_MAX_CONCURRENT_SNAPSHOTS: int = Field(
default=4,
description="Maximum number of snapshots to archive concurrently within one crawl.",
@@ -274,6 +273,10 @@ class ArchivingConfig(BaseConfigSet):
SAVE_DENYLIST: dict[str, list[str]] = Field(default={})
DEFAULT_PERSONA: str = Field(default="Default")
+ PERMISSIONS: str = Field(
+ default="public",
+ description="Snapshot visibility: public lists and serves content, unlisted serves direct links only, private requires admin login.",
+ )
DELETE_AFTER: str = Field(
default="0",
description=(
@@ -310,6 +313,14 @@ class ArchivingConfig(BaseConfigSet):
return "0"
return str(value).strip() or "0"
+ @field_validator("PERMISSIONS", mode="before")
+ @classmethod
+ def validate_permissions(cls, value):
+ normalized = str(value or "public").strip().lower()
+ if normalized not in {"public", "unlisted", "private"}:
+ raise ValueError("PERMISSIONS must be one of: public, unlisted, private.")
+ return normalized
+
@property
def URL_ALLOWLIST_PTN(self) -> re.Pattern | None:
return re.compile(self.URL_ALLOWLIST, CONSTANTS.ALLOWDENYLIST_REGEX_FLAGS) if self.URL_ALLOWLIST else None
@@ -561,20 +572,7 @@ def get_config(
machine = None
if persona is None and crawl is not None:
- from archivebox.personas.models import Persona
-
- persona_id = crawl.persona_id
- if persona_id:
- persona = Persona.objects.filter(id=persona_id).first()
- if persona is None:
- raise Persona.DoesNotExist(f"Crawl {crawl.id} references missing Persona {persona_id}")
-
- if persona is None:
- crawl_config = crawl.config or {}
- default_persona_name = str(crawl_config.get("DEFAULT_PERSONA") or "").strip()
- if default_persona_name:
- persona, _ = Persona.objects.get_or_create(name=default_persona_name or "Default")
- persona.ensure_dirs()
+ persona = crawl.resolve_persona()
config_data: ConfigPayload = dict(defaults or {})
config_data.update(ArchiveBoxConfig().model_dump(mode="json"))
diff --git a/archivebox/config/django.py b/archivebox/config/django.py
index 530c3afa..cab18036 100644
--- a/archivebox/config/django.py
+++ b/archivebox/config/django.py
@@ -28,14 +28,6 @@ STDERR = Console(stderr=True)
logging.CONSOLE = CONSOLE
-def setup_django_minimal():
- # sys.path.append(str(CONSTANTS.PACKAGE_DIR))
- # os.environ.setdefault('ARCHIVEBOX_DATA_DIR', str(CONSTANTS.DATA_DIR))
- # os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
- # django.setup()
- raise Exception("dont use this anymore")
-
-
DJANGO_SET_UP = False
diff --git a/archivebox/core/actors.py b/archivebox/core/actors.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py
index 529f108b..1970fdd4 100644
--- a/archivebox/core/admin_archiveresults.py
+++ b/archivebox/core/admin_archiveresults.py
@@ -105,6 +105,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
"failed": ("#991b1b", "#fee2e2"), # red
"queued": ("#6b7280", "#f3f4f6"), # gray
"started": ("#92400e", "#fef3c7"), # amber
+ "paused": ("#1d4ed8", "#dbeafe"), # blue
"backoff": ("#92400e", "#fef3c7"),
"skipped": ("#475569", "#f1f5f9"),
"noresults": ("#475569", "#f1f5f9"),
@@ -152,7 +153,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
'''
# Truncate output for display
- full_output = result.output_str or "-"
+ full_output = result.output_str_for_display() or "-"
output_display = full_output[:60]
if len(full_output) > 60:
output_display += "..."
@@ -718,12 +719,12 @@ class ArchiveResultAdmin(BaseModelAdmin):
return format_html(
'
βοΈ {} ',
build_snapshot_url(snapshot_id, output_path, request=request, config=config),
- result.output_str,
+ result.output_str_for_display(),
)
@admin.display(description="Output", ordering="output_str")
def output_str_display(self, result):
- output_text = str(result.output_str or "").strip()
+ output_text = str(result.output_str_for_display() or "").strip()
if not output_text:
return "-"
@@ -787,7 +788,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
snapshot_dir = Path(DATA_DIR) / str(result.pwd).split("data/", 1)[-1]
output_html = format_html(
'
{} ',
- result.output_str,
+ result.output_str_for_display(),
)
snapshot_id = str(result.snapshot_id)
request = getattr(self, "request", None)
diff --git a/archivebox/core/admin_snapshots.py b/archivebox/core/admin_snapshots.py
index 59e57f1f..41a9ed36 100644
--- a/archivebox/core/admin_snapshots.py
+++ b/archivebox/core/admin_snapshots.py
@@ -1,15 +1,24 @@
__package__ = "archivebox.core"
-from functools import lru_cache
+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.urls import path
+from django.urls import path, reverse
from django.shortcuts import get_object_or_404, redirect
-from django.utils.html import format_html
+from django.core.cache import cache
+from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotAllowed, QueryDict, StreamingHttpResponse
+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, Sum, Count, Prefetch
-from django.db.models.functions import Coalesce
+from django.db.models import Q, Count, Exists, F, OuterRef, Prefetch
from django import forms
from django.template import Template, RequestContext
from django.contrib.admin.helpers import ActionForm
@@ -18,8 +27,9 @@ from archivebox.config.common import get_config
from archivebox.misc.util import htmldecode, urldecode
from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.misc.logging_util import printable_filesize
-from archivebox.search.admin import SearchResultsAdminMixin
+from archivebox.search.admin import SEARCH_RESULT_CACHE_TTL, SearchResultsAdminMixin, SearchResultsChangeList, get_admin_search_cache_key
from archivebox.core.host_utils import build_snapshot_url, build_web_url
+from archivebox.core.tag_utils import get_or_create_tag
from archivebox.hooks import discover_hooks, get_plugin_icon, get_plugin_name, get_plugins
from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin
@@ -27,13 +37,27 @@ from archivebox.workers.tasks import bg_archive_snapshots, bg_add
from archivebox.core.models import Tag, Snapshot, ArchiveResult
from archivebox.core.admin_archiveresults import render_archiveresults_list
+from archivebox.core.permissions import (
+ PERMISSIONS_CHOICES,
+ PERMISSIONS_PRIVATE,
+ PERMISSIONS_PUBLIC,
+ PERMISSIONS_UNLISTED,
+ get_snapshot_permissions,
+)
from archivebox.core.widgets import TagEditorWidget, InlineTagEditorWidget
from archivebox.crawls.models import Crawl
+from archivebox.personas.models import Persona
# GLOBAL_CONTEXT = {'VERSION': VERSION, 'VERSIONS_AVAILABLE': [], 'CAN_UPGRADE': False}
GLOBAL_CONTEXT = {}
+SNAPSHOT_PERMISSION_META = {
+ PERMISSIONS_PUBLIC: ("π₯", "Public", "#047857", "#d1fae5"),
+ PERMISSIONS_UNLISTED: ("π", "Unlisted", "#1d4ed8", "#dbeafe"),
+ PERMISSIONS_PRIVATE: ("π", "Private", "#991b1b", "#fee2e2"),
+}
+
@lru_cache(maxsize=1)
def _plugin_sort_order() -> dict[str, int]:
@@ -51,22 +75,12 @@ class SnapshotActionForm(ActionForm):
)
def clean_tags(self):
- """Parse comma-separated tag names into Tag objects."""
+ """Parse comma-separated tag names without touching the DB."""
tags_str = self.cleaned_data.get("tags", "")
if not tags_str:
return []
- tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
- tags = []
- for name in tag_names:
- tag, _ = Tag.objects.get_or_create(
- name__iexact=name,
- defaults={"name": name},
- )
- # Use the existing tag if found by case-insensitive match
- tag = Tag.objects.filter(name__iexact=name).first() or tag
- tags.append(tag)
- return tags
+ return [name.strip() for name in tags_str.split(",") if name.strip()]
# TODO: allow selecting actions for specific extractor plugins? is this useful?
# plugin = forms.ChoiceField(
@@ -95,6 +109,268 @@ class TagNameListFilter(admin.SimpleListFilter):
return queryset
+class SnapshotPermissionsListFilter(admin.SimpleListFilter):
+ title = "permission"
+ parameter_name = "permissions"
+
+ def lookups(self, request, model_admin):
+ return PERMISSIONS_CHOICES
+
+ def queryset(self, request, queryset):
+ value = self.value()
+ if value:
+ global_permissions = str(get_config(resolve_plugins=False).PERMISSIONS).strip().lower()
+ has_overrides = (
+ Snapshot.objects.filter(permissions__gt="").exists()
+ or Crawl.objects.filter(permissions__gt="").exists()
+ or Persona.objects.filter(permissions__gt="").exists()
+ )
+ if not has_overrides:
+ return queryset if value == global_permissions else queryset.none()
+
+ persona_query = Q(crawl__persona_id__in=self.persona_ids_for_value())
+ if global_permissions == value:
+ valid_persona_ids = Persona.objects.values_list("id", flat=True)
+ persona_query |= Q(crawl__persona_id__isnull=True) | ~Q(crawl__persona_id__in=valid_persona_ids)
+ return queryset.filter(
+ Q(permissions=value)
+ | (Q(permissions__isnull=True) & Q(crawl__permissions=value))
+ | (Q(permissions__isnull=True) & Q(crawl__permissions__isnull=True) & persona_query)
+ )
+ return queryset
+
+ def persona_ids_for_value(self):
+ global_permissions = str(get_config(resolve_plugins=False).PERMISSIONS).strip().lower()
+ query = Q(permissions=self.value())
+ if global_permissions == self.value():
+ query |= Q(permissions__isnull=True)
+ return Persona.objects.filter(query).values_list("id", flat=True)
+
+
+class SnapshotStatusListFilter(admin.SimpleListFilter):
+ title = "snapshot status"
+ parameter_name = "snapshot_status"
+
+ def lookups(self, request, model_admin):
+ return Snapshot.StatusChoices.choices
+
+ def queryset(self, request, queryset):
+ value = self.value()
+ if value in Snapshot.StatusChoices.values:
+ return queryset.filter(status=value)
+ 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"
+
+ def lookups(self, request, model_admin):
+ return (
+ ("downloaded", "Downloaded"),
+ ("not_downloaded", "Not downloaded"),
+ ("has_output", "Has saved files"),
+ ("empty_output", "No saved files"),
+ ("has_title", "Has title"),
+ ("missing_title", "Missing title"),
+ )
+
+ def queryset(self, request, queryset):
+ value = self.value()
+ if value == "downloaded":
+ return queryset.filter(downloaded_at__isnull=False)
+ if value == "not_downloaded":
+ return queryset.filter(downloaded_at__isnull=True)
+ if value == "has_output":
+ return queryset.filter(output_size__gt=0)
+ if value == "empty_output":
+ return queryset.filter(output_size=0)
+ if value == "has_title":
+ return queryset.exclude(Q(title__isnull=True) | Q(title=""))
+ if value == "missing_title":
+ return queryset.filter(Q(title__isnull=True) | Q(title=""))
+ return queryset
+
+
+class SnapshotSizeListFilter(admin.SimpleListFilter):
+ title = "size"
+ parameter_name = "size"
+
+ def lookups(self, request, model_admin):
+ return (
+ ("1gb", ">1GB"),
+ ("500mb", ">500MB"),
+ ("250mb", ">250MB"),
+ ("100mb", ">100MB"),
+ ("50mb", ">50MB"),
+ ("25mb", ">25MB"),
+ )
+
+ def queryset(self, request, queryset):
+ value = self.value()
+ thresholds = {
+ "1gb": 1024 * 1024 * 1024,
+ "500mb": 500 * 1024 * 1024,
+ "250mb": 250 * 1024 * 1024,
+ "100mb": 100 * 1024 * 1024,
+ "50mb": 50 * 1024 * 1024,
+ "25mb": 25 * 1024 * 1024,
+ }
+ if value in thresholds:
+ return queryset.filter(output_size__gt=thresholds[value])
+ 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"
+
+ def lookups(self, request, model_admin):
+ return (
+ ("none", "No ArchiveResults"),
+ ("has_results", "Has ArchiveResults"),
+ ("succeeded", ">50% succeeded"),
+ ("failed", ">50% failed"),
+ ("running", ">50% running"),
+ ("pending", ">50% queued"),
+ ("backoff", ">50% waiting to retry"),
+ ("noresults", ">50% noresults"),
+ )
+
+ def queryset(self, request, queryset):
+ value = self.value()
+ if value:
+ results = ArchiveResult.objects.filter(snapshot_id=OuterRef("pk"))
+ if value == "none":
+ return queryset.annotate(has_results=Exists(results)).filter(has_results=False)
+ if value == "has_results":
+ return queryset.annotate(has_results=Exists(results)).filter(has_results=True)
+ status_by_value = {
+ "succeeded": ArchiveResult.StatusChoices.SUCCEEDED,
+ "failed": ArchiveResult.StatusChoices.FAILED,
+ "running": ArchiveResult.StatusChoices.STARTED,
+ "pending": ArchiveResult.StatusChoices.QUEUED,
+ "backoff": ArchiveResult.StatusChoices.BACKOFF,
+ "noresults": ArchiveResult.StatusChoices.NORESULTS,
+ }
+ if value in status_by_value:
+ queryset = queryset.annotate(
+ total_results=Count("archiveresult"),
+ matching_results=Count(
+ "archiveresult",
+ filter=Q(archiveresult__status=status_by_value[value]),
+ ),
+ )
+ return queryset.filter(matching_results__gt=F("total_results") / 2)
+ return queryset
+
+
+class SnapshotChangeList(SearchResultsChangeList):
+ def __init__(self, request, *args, **kwargs):
+ super().__init__(request, *args, **kwargs)
+ resolver_name = getattr(getattr(request, "resolver_match", None), "url_name", "")
+ self.embedded_changelist = request.GET.get("_embedded") == "crawl"
+ self.snapshot_is_grid_view = not self.embedded_changelist and (resolver_name == "grid" or request.path.rstrip("/").endswith("/grid"))
+
+ def get_results(self, request):
+ super().get_results(request)
+ if request.GET.get("_embedded") == "crawl":
+ self.full_result_count = self.result_count
+ else:
+ self.full_result_count = self.model_admin.get_paginator(request, self.model._default_manager.all().order_by(), self.list_per_page).count
+ self.show_full_result_count = True
+
+ snapshot_ids = [obj.pk for obj in self.result_list]
+ if snapshot_ids:
+ results_by_snapshot = {snapshot_id: [] for snapshot_id in snapshot_ids}
+ seen_plugins = {snapshot_id: set() for snapshot_id in snapshot_ids}
+ rows = (
+ ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids, status=ArchiveResult.StatusChoices.SUCCEEDED, output_size__gt=0)
+ .order_by("snapshot_id", "plugin")
+ .values_list("snapshot_id", "plugin", "status", "output_size")
+ )
+ for snapshot_id, plugin, status, output_size in rows.iterator(chunk_size=1000):
+ if plugin in seen_plugins[snapshot_id]:
+ continue
+ seen_plugins[snapshot_id].add(plugin)
+ results_by_snapshot[snapshot_id].append(SimpleNamespace(plugin=plugin, status=status, output_size=output_size))
+
+ for obj in self.result_list:
+ obj.__dict__["_admin_archiveresults"] = results_by_snapshot[obj.pk]
+
+
class SnapshotAdminForm(forms.ModelForm):
"""Custom form for Snapshot admin with tag editor widget."""
@@ -104,6 +380,12 @@ class SnapshotAdminForm(forms.ModelForm):
widget=TagEditorWidget(),
help_text="Type tag names and press Enter or Space to add. Click Γ to remove.",
)
+ permissions_config = forms.ChoiceField(
+ label="Permissions",
+ choices=PERMISSIONS_CHOICES,
+ required=True,
+ help_text="Per-snapshot visibility. Matching the crawl/persona default clears the per-snapshot override.",
+ )
class Meta:
model = Snapshot
@@ -116,9 +398,18 @@ class SnapshotAdminForm(forms.ModelForm):
self.initial["tags_editor"] = ",".join(
sorted(tag.name for tag in self.instance.tags.all()),
)
+ self.initial["permissions_config"] = get_snapshot_permissions(self.instance)
def save(self, commit=True):
instance = super().save(commit=False)
+ permissions = self.cleaned_data["permissions_config"]
+ inherited_permissions = str(get_config(crawl=instance.crawl, resolve_plugins=False).PERMISSIONS).strip().lower()
+ config = dict(instance.config or {})
+ if permissions == inherited_permissions:
+ config.pop("PERMISSIONS", None)
+ else:
+ config["PERMISSIONS"] = permissions
+ instance.config = config
# Handle tags_editor field
if commit:
@@ -150,7 +441,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
form = SnapshotAdminForm
raw_id_fields = ("crawl", "parent_snapshot")
list_select_related = ()
- list_display = ("created_at", "preview_icon", "title_str", "tags_inline", "status_with_progress", "files", "size_with_stats")
+ list_display = ("permissions_badge", "created_at", "preview_icon", "title_str", "tags_inline", "status_with_progress", "files", "size_with_stats")
+ list_display_links = ("created_at",)
sort_fields = ("title_str", "created_at", "status", "crawl")
readonly_fields = (
"admin_actions",
@@ -165,7 +457,20 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
"archiveresults_list",
)
search_fields = ("id", "url", "timestamp", "title", "tags__name")
- list_filter = ("created_at", "downloaded_at", "archiveresult__status", "crawl__created_by", TagNameListFilter)
+ list_filter = (
+ SnapshotPermissionsListFilter,
+ SnapshotStatusListFilter,
+ SnapshotResultHealthListFilter,
+ SnapshotDepthListFilter,
+ SnapshotRelationListFilter,
+ SnapshotArchiveStateListFilter,
+ SnapshotSizeListFilter,
+ SnapshotRetryListFilter,
+ "created_at",
+ "downloaded_at",
+ "crawl__created_by",
+ TagNameListFilter,
+ )
fieldsets = (
(
@@ -192,7 +497,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
(
"Tags",
{
- "fields": ("tags_editor",),
+ "fields": ("tags_editor", "permissions_config"),
"classes": ("card",),
},
),
@@ -244,7 +549,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
ordering = ["-timestamp"]
actions = ["add_tags", "remove_tags", "resnapshot_snapshot", "update_snapshots", "overwrite_snapshots", "delete_snapshots"]
inlines = [] # Removed TagInline, using TagEditorWidget instead
- list_per_page = 40
+ list_per_page = 50
action_form = SnapshotActionForm
paginator = AcceleratedPaginator
@@ -252,6 +557,14 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
save_on_top = True
show_full_result_count = False
+ def get_changelist(self, request, **kwargs):
+ return SnapshotChangeList
+
+ def get_ordering(self, request):
+ if request.GET.get("o"):
+ return []
+ return super().get_ordering(request)
+
def change_view(self, request, object_id, form_url="", extra_context=None):
request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
extra_context = extra_context or {}
@@ -262,8 +575,17 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
self.request = request
request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
saved_list_per_page = self.list_per_page
- self.list_per_page = min(max(5, request.archivebox_config.SNAPSHOTS_PER_PAGE), 25)
+ embedded_changelist = request.GET.get("_embedded") == "crawl"
+ if embedded_changelist:
+ try:
+ requested_per_page = int(request.GET.get("per_page", "200"))
+ except ValueError:
+ requested_per_page = 200
+ self.list_per_page = min(max(200, requested_per_page), 500)
+ else:
+ self.list_per_page = min(max(50, request.archivebox_config.SNAPSHOTS_PER_PAGE), 500)
extra_context = extra_context or {}
+ extra_context["embedded_changelist"] = embedded_changelist
extra_context["CONFIG"] = request.archivebox_config
try:
try:
@@ -281,6 +603,11 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
actions.pop("delete_selected", None)
return actions
+ def lookup_allowed(self, lookup, value, request=None):
+ if lookup in {"crawl__id__exact", "crawl_id__exact", "crawl_id"}:
+ return True
+ return super().lookup_allowed(lookup, value, request=request)
+
def get_snapshot_view_url(self, obj: Snapshot) -> str:
request = getattr(self, "request", None)
return build_snapshot_url(str(obj.id), request=request, config=getattr(request, "archivebox_config", None))
@@ -296,10 +623,185 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
urls = super().get_urls()
custom_urls = [
path("grid/", self.admin_site.admin_view(self.grid_view), name="grid"),
+ path("search-stream/", self.admin_site.admin_view(self.search_stream_view), name="core_snapshot_search_stream"),
path("
/redo-failed/", self.admin_site.admin_view(self.redo_failed_view), name="core_snapshot_redo_failed"),
+ path("/set-permissions/", self.admin_site.admin_view(self.set_permissions_view), name="core_snapshot_set_permissions"),
]
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()
+
+ def set_permissions_view(self, request, object_id):
+ if request.method != "POST":
+ return HttpResponseNotAllowed(["POST"])
+
+ permissions = (request.POST.get("permissions") or "").strip().lower()
+ if permissions not in dict(PERMISSIONS_CHOICES):
+ return HttpResponseBadRequest("Invalid permissions value")
+
+ snapshot = get_object_or_404(Snapshot.objects.select_related("crawl"), pk=object_id)
+ config = dict(snapshot.config or {})
+ inherited_permissions = str(get_config(crawl=snapshot.crawl, resolve_plugins=False).PERMISSIONS).strip().lower()
+ if permissions == inherited_permissions:
+ config.pop("PERMISSIONS", None)
+ else:
+ config["PERMISSIONS"] = permissions
+
+ # Keep the quick-edit write to one targeted UPDATE so SQLite only holds
+ # the write lock for the permission/config change itself.
+ Snapshot.objects.filter(pk=snapshot.pk).update(config=config, modified_at=timezone.now())
+ icon, label, fg, bg = SNAPSHOT_PERMISSION_META[permissions]
+ return JsonResponse({"permissions": permissions, "icon": icon, "label": label, "fg": fg, "bg": bg})
+
def redo_failed_view(self, request, object_id):
snapshot = get_object_or_404(Snapshot, pk=object_id)
@@ -327,42 +829,56 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
def get_queryset(self, request):
self.request = request
ordering_fields = self._get_ordering_fields(request)
- needs_size_sort = "size_with_stats" in ordering_fields
needs_files_sort = "files" in ordering_fields
needs_tags_sort = "tags_inline" in ordering_fields
is_change_view = getattr(getattr(request, "resolver_match", None), "url_name", "") == "core_snapshot_change"
+ request.archivebox_default_permissions = str(get_config(resolve_plugins=False).PERMISSIONS).strip().lower()
- prefetch_qs = ArchiveResult.objects.only(
- "id",
- "snapshot_id",
- "plugin",
- "status",
- "output_size",
- "output_files",
- "output_str",
- )
- if not is_change_view:
- prefetch_qs = prefetch_qs.filter(Q(status="succeeded"))
-
- qs = (
- super()
- .get_queryset(request)
- .defer("config", "notes")
- .prefetch_related(
- Prefetch("crawl", queryset=Crawl.objects.select_related("created_by")),
- "tags",
- Prefetch("archiveresult_set", queryset=prefetch_qs),
- )
- )
-
- if needs_size_sort:
- qs = qs.annotate(
- output_size_sum=Coalesce(
- Sum("archiveresult__output_size"),
- 0,
+ prefetches = [
+ Prefetch(
+ "crawl",
+ queryset=Crawl.objects.only(
+ "id",
+ "permissions",
+ "persona_id",
+ "status",
+ "created_by_id",
+ ).prefetch_related("created_by"),
+ ),
+ "tags",
+ ]
+ if is_change_view:
+ prefetches.append(
+ Prefetch(
+ "archiveresult_set",
+ queryset=ArchiveResult.objects.only(
+ "id",
+ "snapshot_id",
+ "plugin",
+ "status",
+ "output_size",
+ ),
),
)
+ qs = super().get_queryset(request)
+ if is_change_view:
+ qs = qs.defer("notes")
+ else:
+ qs = qs.only(
+ "id",
+ "created_at",
+ "url",
+ "timestamp",
+ "bookmarked_at",
+ "crawl_id",
+ "title",
+ "status",
+ "fs_version",
+ "output_size",
+ "permissions",
+ )
+ qs = qs.prefetch_related(*prefetches)
if needs_files_sort:
qs = qs.annotate(
ar_succeeded_count=Count(
@@ -375,6 +891,66 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
return qs
+ @admin.display(description="π", ordering="permissions")
+ def permissions_badge(self, obj):
+ request = self.request
+ permissions = getattr(obj, "snapshot_permissions", None)
+ if permissions is None:
+ if obj.permissions:
+ permissions = obj.permissions
+ elif obj.crawl.permissions:
+ permissions = obj.crawl.permissions
+ elif obj.crawl.persona_id:
+ persona_permissions = getattr(request, "archivebox_persona_permissions", None)
+ if persona_permissions is None:
+ persona_permissions = {
+ str(persona.id): persona.permissions or request.archivebox_default_permissions
+ for persona in Persona.objects.only("id", "permissions")
+ }
+ request.archivebox_persona_permissions = persona_permissions
+ permissions = persona_permissions.get(str(obj.crawl.persona_id), request.archivebox_default_permissions)
+ else:
+ permissions = request.archivebox_default_permissions
+ icon, label, fg, bg = SNAPSHOT_PERMISSION_META[permissions]
+ menu_items = format_html_join(
+ "",
+ (
+ '"
+ ),
+ (
+ (
+ " is-active" if choice_value == permissions else "",
+ choice_value,
+ choice_fg,
+ choice_bg,
+ choice_icon,
+ choice_label,
+ )
+ for choice_value, choice_label in PERMISSIONS_CHOICES
+ for choice_icon, _choice_title, choice_fg, choice_bg in [SNAPSHOT_PERMISSION_META[choice_value]]
+ ),
+ )
+ return format_html(
+ ''
+ ''
+ '{} '
+ " "
+ ''
+ " ",
+ permissions,
+ reverse(f"{self.admin_site.name}:core_snapshot_set_permissions", args=[obj.pk]),
+ permissions,
+ label,
+ label,
+ fg,
+ bg,
+ icon,
+ menu_items,
+ )
+
@admin.display(description="Imported Timestamp")
def imported_timestamp(self, obj):
context = RequestContext(
@@ -555,15 +1131,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
@admin.display(description="Tags", ordering="tag_count")
def tags_inline(self, obj):
- widget = InlineTagEditorWidget(snapshot_id=str(obj.pk))
+ widget = InlineTagEditorWidget(snapshot_id=str(obj.pk), editable=True)
tags = self._get_prefetched_tags(obj)
tags_html = widget.render(
- name=f"tags_{obj.pk}",
+ name=f"tags_inline_{obj.pk}",
value=tags if tags is not None else obj.tags.all(),
- attrs={"id": f"tags_{obj.pk}"},
+ attrs={"id": f"tags_inline_{obj.pk}"},
snapshot_id=str(obj.pk),
)
- return mark_safe(f'{tags_html} ')
+ return mark_safe(f'{tags_html} ')
@admin.display(description="Tags")
def tags_badges(self, obj):
@@ -716,13 +1292,13 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
def files(self, obj):
results = self._get_prefetched_results(obj)
if results is None:
- results = obj.archiveresult_set.only("plugin", "status", "output_files", "output_str")
+ results = obj.archiveresult_set.only("plugin", "status", "output_size")
plugins_with_output: dict[str, ArchiveResult] = {}
for result in results:
if result.status != ArchiveResult.StatusChoices.SUCCEEDED:
continue
- if not (result.output_files or str(result.output_str or "").strip()):
+ if not result.output_size:
continue
plugins_with_output.setdefault(result.plugin, result)
@@ -734,15 +1310,21 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
key=lambda result: (_plugin_sort_order().get(result.plugin, 9999), result.plugin),
)
visible_results = sorted_results[:14]
- output = [
- format_html(
- '{} ',
- f"/{obj.archive_path_from_db}/{result.plugin}/",
- result.plugin,
- get_plugin_icon(result.plugin),
+ output = []
+ request = getattr(self, "request", None)
+ config = getattr(request, "archivebox_config", None)
+ for result in visible_results:
+ icon = mark_safe(get_plugin_icon(result.plugin))
+ if not icon.strip():
+ continue
+ output.append(
+ format_html(
+ '{} ',
+ build_web_url(f"/{obj.archive_path_from_db}/{result.plugin}/", request=request, config=config),
+ result.plugin,
+ icon,
+ ),
)
- for result in visible_results
- ]
if len(sorted_results) > len(visible_results):
output.append(
format_html(
@@ -788,6 +1370,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
status_colors = {
"queued": ("#f59e0b", "#fef3c7"), # amber
"started": ("#3b82f6", "#dbeafe"), # blue
+ "paused": ("#1d4ed8", "#dbeafe"), # blue
"sealed": ("#10b981", "#d1fae5"), # green
"succeeded": ("#10b981", "#d1fae5"), # green
"failed": ("#ef4444", "#fee2e2"), # red
@@ -840,7 +1423,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
@admin.display(
description="Size",
- ordering="output_size_sum",
+ ordering="output_size",
)
def size_with_stats(self, obj):
"""Show archive size with output size from archive results."""
@@ -902,14 +1485,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
pending = max(total - succeeded - failed - running - skipped - noresults, 0)
completed = succeeded + failed + skipped + noresults
percent = int((completed / total * 100) if total > 0 else 0)
- is_sealed = obj.status not in (obj.StatusChoices.QUEUED, obj.StatusChoices.STARTED)
- output_size = None
-
- if hasattr(obj, "output_size_sum"):
- output_size = obj.output_size_sum or 0
- else:
- output_size = sum(r.output_size or 0 for r in results)
-
+ is_sealed = obj.status not in (obj.StatusChoices.QUEUED, obj.StatusChoices.STARTED, obj.StatusChoices.PAUSED)
stats = {
"total": total,
"succeeded": succeeded,
@@ -919,13 +1495,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
"skipped": skipped,
"noresults": noresults,
"percent": percent,
- "output_size": output_size or 0,
+ "output_size": obj.output_size or 0,
"is_sealed": is_sealed,
}
obj._admin_progress_stats = stats
return stats
def _get_prefetched_results(self, obj):
+ if "_admin_archiveresults" in obj.__dict__:
+ return obj.__dict__["_admin_archiveresults"]
if hasattr(obj, "_prefetched_objects_cache") and "archiveresult_set" in obj._prefetched_objects_cache:
return obj.archiveresult_set.all()
return None
@@ -1009,31 +1587,9 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
return format_html('{} ', color, h)
def grid_view(self, request, extra_context=None):
-
- # cl = self.get_changelist_instance(request)
-
- # Save before monkey patching to restore for changelist list view
- admin_cls = type(self)
- saved_change_list_template = admin_cls.change_list_template
- saved_list_per_page = admin_cls.list_per_page
- saved_list_max_show_all = admin_cls.list_max_show_all
-
- # Monkey patch here plus core_tags.py
- admin_cls.change_list_template = "private_index_grid.html"
- config = getattr(request, "archivebox_config", None) or get_config()
- request.archivebox_config = config
- admin_cls.list_per_page = config.SNAPSHOTS_PER_PAGE
- admin_cls.list_max_show_all = admin_cls.list_per_page
-
- # Call monkey patched view
- rendered_response = self.changelist_view(request, extra_context=extra_context)
-
- # Restore values
- admin_cls.change_list_template = saved_change_list_template
- admin_cls.list_per_page = saved_list_per_page
- admin_cls.list_max_show_all = saved_list_max_show_all
-
- return rendered_response
+ extra_context = extra_context or {}
+ extra_context["snapshot_is_grid_view"] = True
+ return self.changelist_view(request, extra_context=extra_context)
# for debugging, uncomment this to print all requests:
# def changelist_view(self, request, extra_context=None):
@@ -1123,28 +1679,24 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
messages.warning(request, "No tags specified.")
return
- # Parse comma-separated tag names and get/create Tag objects
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
tags = []
for name in tag_names:
- tag, _ = Tag.objects.get_or_create(
- name__iexact=name,
- defaults={"name": name},
+ tag, _ = get_or_create_tag(
+ name,
+ created_by=request.user if request.user.is_authenticated else None,
)
- tag = Tag.objects.filter(name__iexact=name).first() or tag
tags.append(tag)
# Get snapshot IDs efficiently (works with select_across for all pages)
snapshot_ids = list(queryset.values_list("id", flat=True))
num_snapshots = len(snapshot_ids)
- print("[+] Adding tags", [t.name for t in tags], "to", num_snapshots, "Snapshots")
-
- # Bulk create M2M relationships (1 query per tag, not per snapshot)
for tag in tags:
SnapshotTag.objects.bulk_create(
- [SnapshotTag(snapshot_id=sid, tag=tag) for sid in snapshot_ids],
- ignore_conflicts=True, # Skip if relationship already exists
+ [SnapshotTag(snapshot_id=sid, tag_id=tag.pk) for sid in snapshot_ids],
+ ignore_conflicts=True,
+ batch_size=1000,
)
messages.success(
@@ -1181,9 +1733,6 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
num_snapshots = len(snapshot_ids)
tag_ids = [t.pk for t in tags]
- print("[-] Removing tags", [t.name for t in tags], "from", num_snapshots, "Snapshots")
-
- # Bulk delete M2M relationships (1 query total, not per snapshot)
deleted_count, _ = SnapshotTag.objects.filter(
snapshot_id__in=snapshot_ids,
tag_id__in=tag_ids,
diff --git a/archivebox/core/apps.py b/archivebox/core/apps.py
index 9316377c..c8f54921 100644
--- a/archivebox/core/apps.py
+++ b/archivebox/core/apps.py
@@ -25,18 +25,6 @@ class CoreConfig(AppConfig):
if "makemigrations" not in sys.argv:
from archivebox.core import models # noqa: F401
- pidfile = os.environ.get("ARCHIVEBOX_RUNSERVER_PIDFILE")
- if pidfile:
- should_write_pid = True
- if os.environ.get("ARCHIVEBOX_AUTORELOAD") == "1":
- should_write_pid = os.environ.get(DJANGO_AUTORELOAD_ENV) == "true"
- if should_write_pid:
- try:
- with open(pidfile, "w") as handle:
- handle.write(str(os.getpid()))
- except Exception:
- pass
-
def _should_prepare_runtime() -> bool:
if os.environ.get("ARCHIVEBOX_RUNSERVER") == "1":
if os.environ.get("ARCHIVEBOX_AUTORELOAD") == "1":
@@ -45,6 +33,13 @@ class CoreConfig(AppConfig):
return False
if _should_prepare_runtime():
- from archivebox.machine.models import Machine
+ from archivebox.config import CONSTANTS
+ from archivebox.machine.models import Process
- Machine.current()
+ Process.current().mark_running(
+ process_type=Process.TypeChoices.WORKER,
+ worker_type="worker_runserver",
+ pwd=str(CONSTANTS.DATA_DIR),
+ url=os.environ.get("ARCHIVEBOX_RUNSERVER_BIND_URL") or "",
+ timeout=0,
+ )
diff --git a/archivebox/core/forms.py b/archivebox/core/forms.py
index c77043d3..d8382a8f 100644
--- a/archivebox/core/forms.py
+++ b/archivebox/core/forms.py
@@ -1,7 +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
@@ -13,6 +15,7 @@ from taggit.utils import edit_string_for_tags, parse_tags
from archivebox.base_models.admin import KeyValueWidget
from archivebox.crawls.schedule_utils 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.personas.models import Persona
@@ -146,6 +149,7 @@ HIDDEN_PLUGIN_CONFIG_UI_PLUGINS = {
"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():
@@ -509,6 +513,13 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
widget=forms.Textarea(
attrs={
"data-url-regex": URL_REGEX.pattern,
+ "placeholder": (
+ "Enter URLs to archive, as one per line, CSV, JSON, or embedded in text "
+ "(e.g. markdown, HTML, etc.). Examples:\n"
+ "https://example.com\n"
+ "https://news.ycombinator.com,https://news.google.com\n"
+ "[ArchiveBox](https://github.com/ArchiveBox/ArchiveBox)"
+ ),
},
),
required=True,
@@ -526,7 +537,7 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
widget=forms.RadioSelect(attrs={"class": "depth-selection"}),
)
max_urls = forms.IntegerField(
- label="Max URLs",
+ label="Max crawl URLs",
required=False,
min_value=0,
initial=0,
@@ -548,6 +559,29 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
},
),
)
+ crawl_timeout = forms.CharField(
+ label="Max crawl time",
+ required=False,
+ initial=0,
+ widget=forms.TextInput(
+ attrs={
+ "pattern": TIMEOUT_INPUT_PATTERN,
+ "title": "Use 0, integer seconds, or a duration like 1.5m or 1hr. Non-zero values must be greater than 10 seconds.",
+ "placeholder": "0, 300, 1.5m, or 1hr",
+ },
+ ),
+ )
+ timeout = forms.CharField(
+ label="Max subtask time",
+ required=False,
+ widget=forms.TextInput(
+ attrs={
+ "pattern": TIMEOUT_INPUT_PATTERN,
+ "title": "Use integer seconds or a duration like 1.5m or 1hr. Non-zero values must be greater than 10 seconds.",
+ "placeholder": "60, 1.5m, or 1hr",
+ },
+ ),
+ )
snapshot_max_size = forms.CharField(
label="Max snapshot size",
required=False,
@@ -569,7 +603,7 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
),
)
crawl_max_concurrent_snapshots = forms.IntegerField(
- label="Max concurrent snapshots",
+ label="Max in parallel",
required=False,
min_value=1,
widget=forms.NumberInput(
@@ -657,6 +691,12 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
empty_label=None,
to_field_name="name",
)
+ permissions = forms.ChoiceField(
+ label="Permissions",
+ choices=PERMISSIONS_CHOICES,
+ initial="public",
+ required=True,
+ )
index_only = forms.BooleanField(
label="Index only dry run (add crawl but don't archive yet)",
initial=False,
@@ -670,23 +710,45 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
)
def __init__(self, *args, **kwargs):
+ self.request = kwargs.pop("request", None)
+ self.can_override_crawl_config = bool(self.request and is_admin_user(self.request))
super().__init__(*args, **kwargs)
default_persona = Persona.get_or_create_default()
- default_config = get_config(persona=default_persona)
- self.fields["persona"].queryset = Persona.objects.order_by("name")
- self.fields["persona"].initial = default_persona.name
+ persona_queryset = Persona.objects.order_by("name")
+ if not self.can_override_crawl_config:
+ persona_queryset = filter_personas_by_permissions(persona_queryset, {PERMISSIONS_PUBLIC})
+ self.fields["persona"].queryset = persona_queryset
+
+ selected_persona = persona_queryset.filter(id=default_persona.id).first() or persona_queryset.first()
+ default_config = get_config(persona=selected_persona) if selected_persona else get_config()
+ if selected_persona:
+ self.fields["persona"].initial = selected_persona.name
+ self.fields["permissions"].initial = default_config.PERMISSIONS
+ self.fields["timeout"].initial = default_config.TIMEOUT
self.fields["crawl_max_concurrent_snapshots"].initial = default_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS
self.fields["delete_after"].initial = default_config.DELETE_AFTER
- selected_persona = default_persona
if self.is_bound:
- selected_persona = Persona.objects.filter(name=str(self.data.get(self.add_prefix("persona")) or "")).first() or default_persona
- self.build_plugin_groups(get_config(persona=selected_persona))
+ selected_persona = persona_queryset.filter(name=str(self.data.get(self.add_prefix("persona")) or "")).first() or selected_persona
+ if self.can_override_crawl_config:
+ self.build_plugin_groups(get_config(persona=selected_persona) if selected_persona else get_config())
+ else:
+ all_plugins = get_plugins()
+ for field_name, *_rest, plugin_names in PLUGIN_GROUP_DEFINITIONS:
+ get_choice_field(self, field_name).choices = [(p, p) for p in all_plugins if p in plugin_names]
+ get_choice_field(self, "other_plugins").choices = [(p, p) for p in all_plugins]
+ self.plugin_groups = []
def clean(self):
cleaned_data = super().clean() or {}
+ if not self.can_override_crawl_config:
+ cleaned_data["plugins"] = []
+ cleaned_data["plugin_config"] = {}
+ cleaned_data["config"] = {}
+ return cleaned_data
+
# Combine all plugin groups into single list
all_selected_plugins = []
for field in [
@@ -731,6 +793,7 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
"allowlist": "\n".join(Crawl.split_filter_patterns(value.get("allowlist", ""))),
"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")),
}
def clean_max_urls(self):
@@ -749,6 +812,37 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
raise forms.ValidationError("Max crawl size must be 0 or a positive number of bytes.")
return value
+ def clean_crawl_timeout(self):
+ return self._clean_timeout_seconds(self.cleaned_data.get("crawl_timeout"), "Max crawl time", blank_value=0)
+
+ def clean_timeout(self):
+ return self._clean_timeout_seconds(self.cleaned_data.get("timeout"), "Max subtask time", blank_value=None)
+
+ def _clean_timeout_seconds(self, raw_value, field_label: str, *, blank_value):
+ raw_value = str(raw_value or "").strip().lower()
+ if not raw_value:
+ return blank_value
+ if raw_value.isdigit():
+ value = int(raw_value)
+ else:
+ match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)", raw_value)
+ if not match:
+ raise forms.ValidationError(f"{field_label} must be seconds or a duration like 1.5m or 1hr.")
+ amount_str, unit = match.groups()
+ try:
+ amount = Decimal(amount_str)
+ except InvalidOperation as err:
+ raise forms.ValidationError(f"{field_label} must be seconds or a duration like 1.5m or 1hr.") from err
+ multiplier = 1
+ if unit in {"m", "min", "mins", "minute", "minutes"}:
+ multiplier = 60
+ elif unit in {"h", "hr", "hrs", "hour", "hours"}:
+ multiplier = 60 * 60
+ value = int((amount * multiplier).to_integral_value(rounding=ROUND_CEILING))
+ if 0 < value <= 10:
+ raise forms.ValidationError(f"{field_label} must be 0 or greater than 10 seconds.")
+ return value
+
def clean_snapshot_max_size(self):
raw_value = str(self.cleaned_data.get("snapshot_max_size") or "").strip()
if not raw_value:
diff --git a/archivebox/core/host_utils.py b/archivebox/core/host_utils.py
index 62e2dc4a..c5688a56 100644
--- a/archivebox/core/host_utils.py
+++ b/archivebox/core/host_utils.py
@@ -206,11 +206,6 @@ def get_public_base_url(request=None, config: dict[str, Any] | None = None, **co
return _build_base_url_for_host(get_public_host(config=config), request=request, config=config)
-# Backwards-compat aliases (archive == web)
-def get_archive_base_url(request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
- return get_web_base_url(request=request, config=config, **config_kwargs)
-
-
def get_snapshot_base_url(snapshot_id: str, request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
config = config or get_config(**config_kwargs)
if not config.USES_SUBDOMAIN_ROUTING:
@@ -233,14 +228,6 @@ def build_web_url(path: str = "", request=None, config: dict[str, Any] | None =
return _build_url(get_web_base_url(request, config=config, **config_kwargs), path)
-def build_api_url(path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
- return _build_url(get_api_base_url(request, config=config, **config_kwargs), path)
-
-
-def build_archive_url(path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
- return _build_url(get_archive_base_url(request, config=config, **config_kwargs), path)
-
-
def build_snapshot_url(snapshot_id: str, path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
return _build_url(get_snapshot_base_url(snapshot_id, request=request, config=config, **config_kwargs), path)
diff --git a/archivebox/core/middleware.py b/archivebox/core/middleware.py
index f8275456..b7d26aec 100644
--- a/archivebox/core/middleware.py
+++ b/archivebox/core/middleware.py
@@ -104,7 +104,7 @@ def CacheControlMiddleware(get_response):
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
- policy = "public" if config.PUBLIC_SNAPSHOTS else "private"
+ policy = "private" if config.PERMISSIONS == "private" else "public"
response["Cache-Control"] = f"{policy}, max-age=60, stale-while-revalidate=300"
# print('Set Cache-Control header to', response['Cache-Control'])
return response
diff --git a/archivebox/core/migrations/0041_snapshot_permissions.py b/archivebox/core/migrations/0041_snapshot_permissions.py
new file mode 100644
index 00000000..3e903984
--- /dev/null
+++ b/archivebox/core/migrations/0041_snapshot_permissions.py
@@ -0,0 +1,19 @@
+# Generated by Django 6.0.5 on 2026-05-28 07:25
+
+import django.db.models.fields.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0040_archiveresult_delete_at_snapshot_delete_at'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='snapshot',
+ name='permissions',
+ field=models.GeneratedField(db_index=True, db_persist=True, expression=django.db.models.fields.json.KeyTextTransform('PERMISSIONS', 'config'), output_field=models.CharField(max_length=16, null=True)),
+ ),
+ ]
diff --git a/archivebox/core/migrations/0042_snapshot_output_size.py b/archivebox/core/migrations/0042_snapshot_output_size.py
new file mode 100644
index 00000000..f6ac7bde
--- /dev/null
+++ b/archivebox/core/migrations/0042_snapshot_output_size.py
@@ -0,0 +1,39 @@
+# Generated by Django 6.0.5 on 2026-05-28 08:22
+
+from django.db import migrations, models
+from django.db.models import Sum
+
+
+def backfill_snapshot_output_size(apps, schema_editor):
+ Snapshot = apps.get_model("core", "Snapshot")
+ ArchiveResult = apps.get_model("core", "ArchiveResult")
+ batch = []
+ rows = ArchiveResult.objects.values("snapshot_id").annotate(total_size=Sum("output_size")).order_by()
+ for row in rows.iterator(chunk_size=2000):
+ batch.append(Snapshot(id=row["snapshot_id"], output_size=row["total_size"] or 0))
+ if len(batch) >= 2000:
+ Snapshot.objects.bulk_update(batch, ["output_size"], batch_size=2000)
+ batch = []
+ if batch:
+ Snapshot.objects.bulk_update(batch, ["output_size"], batch_size=2000)
+
+
+def clear_snapshot_output_size(apps, schema_editor):
+ Snapshot = apps.get_model("core", "Snapshot")
+ Snapshot.objects.update(output_size=0)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0041_snapshot_permissions'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='snapshot',
+ name='output_size',
+ field=models.BigIntegerField(db_index=True, default=0, editable=False, help_text='Total bytes of all ArchiveResult output files'),
+ ),
+ migrations.RunPython(backfill_snapshot_output_size, clear_snapshot_output_size),
+ ]
diff --git a/archivebox/core/migrations/0043_archiveresult_retry_at.py b/archivebox/core/migrations/0043_archiveresult_retry_at.py
new file mode 100644
index 00000000..a02146cb
--- /dev/null
+++ b/archivebox/core/migrations/0043_archiveresult_retry_at.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.5 on 2026-05-28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0042_snapshot_output_size"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="archiveresult",
+ name="retry_at",
+ field=models.DateTimeField(blank=True, db_index=True, default=None, null=True),
+ ),
+ ]
diff --git a/archivebox/core/migrations/0044_alter_archiveresult_status_alter_snapshot_status.py b/archivebox/core/migrations/0044_alter_archiveresult_status_alter_snapshot_status.py
new file mode 100644
index 00000000..9ecdfdef
--- /dev/null
+++ b/archivebox/core/migrations/0044_alter_archiveresult_status_alter_snapshot_status.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.5 on 2026-05-28 12:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0043_archiveresult_retry_at'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='archiveresult',
+ name='status',
+ field=models.CharField(choices=[('queued', 'Queued'), ('started', 'Started'), ('paused', 'Paused'), ('backoff', 'Waiting to retry'), ('succeeded', 'Succeeded'), ('failed', 'Failed'), ('skipped', 'Skipped'), ('noresults', 'No Results')], db_index=True, default='queued', max_length=16),
+ ),
+ migrations.AlterField(
+ model_name='snapshot',
+ name='status',
+ field=models.CharField(choices=[('queued', 'Queued'), ('started', 'Started'), ('paused', 'Paused'), ('sealed', 'Sealed')], db_index=True, default='queued', max_length=15),
+ ),
+ ]
diff --git a/archivebox/core/models.py b/archivebox/core/models.py
index fb8a3c26..5b754cc2 100755
--- a/archivebox/core/models.py
+++ b/archivebox/core/models.py
@@ -13,8 +13,9 @@ from urllib.parse import urlparse
from statemachine import State, registry
-from django.db import models
-from django.db.models import Q, QuerySet
+from django.db import models, transaction
+from django.db.models import Q, QuerySet, Sum
+from django.db.models.fields.json import KT
from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils import timezone
@@ -27,7 +28,7 @@ from django.utils.safestring import mark_safe
from archivebox.config import CONSTANTS
from archivebox.config.common import get_config
-from archivebox.misc.system import get_dir_size, atomic_write
+from archivebox.misc.system import atomic_write
from archivebox.misc.util import (
MAX_URL_LENGTH,
parse_date,
@@ -53,7 +54,7 @@ from archivebox.base_models.models import (
ModelWithHealthStats,
get_or_create_system_user_pk,
)
-from archivebox.workers.models import ModelWithStateMachine, BaseStateMachine
+from archivebox.workers.models import RETRY_AT_MAX, ModelWithStateMachine, BaseStateMachine
from archivebox.workers.tasks import bg_archive_snapshot
from archivebox.crawls.models import Crawl
from archivebox.machine.models import Binary
@@ -148,6 +149,101 @@ class SnapshotTag(models.Model):
class SnapshotQuerySet(models.QuerySet):
"""Custom QuerySet for Snapshot model with export methods that persist through .filter() etc."""
+ def paged_iterator(self, chunk_size: int = 500):
+ """
+ Iterate snapshots using bounded keyset pages instead of one streaming cursor.
+
+ Django's iterator(chunk_size=...) still keeps a single SQLite SELECT
+ cursor open until the full queryset is exhausted. That is fine for
+ read-only exports, but update/migration code does filesystem work and
+ writes while iterating; a long-lived read cursor there can stretch lock
+ waits across thousands of rows. This respects the queryset's existing
+ filters, order_by(), select_related(), and prefetch_related() state; if
+ no ordering is defined, it falls back to primary-key order.
+ """
+ pk_field = self.model._meta.pk.name
+ raw_ordering = tuple(self.query.order_by or self.model._meta.ordering or (pk_field,))
+
+ if any(not isinstance(term, str) or term == "?" for term in raw_ordering):
+ offset = 0
+ while True:
+ batch = list(self[offset : offset + chunk_size])
+ if not batch:
+ break
+ yield from batch
+ offset += chunk_size
+ return
+
+ ordering = []
+ for term in raw_ordering:
+ descending = term.startswith("-")
+ field_name = term[1:] if descending else term
+ if field_name == "pk":
+ field_name = pk_field
+ ordering.append(f"-{field_name}" if descending else field_name)
+
+ ordered_field_names = [term[1:] if term.startswith("-") else term for term in ordering]
+ try:
+ if any(self.model._meta.get_field(field_name).null for field_name in ordered_field_names):
+ offset = 0
+ while True:
+ batch = list(self[offset : offset + chunk_size])
+ if not batch:
+ break
+ yield from batch
+ offset += chunk_size
+ return
+ except Exception:
+ offset = 0
+ while True:
+ batch = list(self[offset : offset + chunk_size])
+ if not batch:
+ break
+ yield from batch
+ offset += chunk_size
+ return
+
+ unique_field_names = {pk_field, *(field.name for field in self.model._meta.fields if getattr(field, "unique", False))}
+ if not any(field_name in unique_field_names for field_name in ordered_field_names):
+ offset = 0
+ while True:
+ batch = list(self[offset : offset + chunk_size])
+ if not batch:
+ break
+ yield from batch
+ offset += chunk_size
+ return
+
+ last_values = None
+ value_field_names = tuple(dict.fromkeys([*ordered_field_names, pk_field]))
+ while True:
+ batch_qs = self.order_by(*ordering)
+ if last_values is not None:
+ page_filter = models.Q()
+ for idx, term in enumerate(ordering):
+ descending = term.startswith("-")
+ field_name = term[1:] if descending else term
+ prefix = {ordered_field_names[i]: last_values[i] for i in range(idx)}
+ comparison = "lt" if descending else "gt"
+ page_filter |= models.Q(**prefix, **{f"{field_name}__{comparison}": last_values[idx]})
+ batch_qs = batch_qs.filter(page_filter)
+
+ batch_rows = list(batch_qs.values_list(*value_field_names)[:chunk_size])
+ if not batch_rows:
+ break
+
+ pk_idx = value_field_names.index(pk_field)
+ snapshot_ids = [row[pk_idx] for row in batch_rows]
+ snapshots_by_id = {snapshot.pk: snapshot for snapshot in self.filter(pk__in=snapshot_ids).order_by()}
+
+ for row in batch_rows:
+ snapshot_id = row[pk_idx]
+ snapshot = snapshots_by_id.get(snapshot_id)
+ if snapshot is not None:
+ yield snapshot
+
+ last_values = batch_rows[-1][: len(ordered_field_names)]
+
# =========================================================================
# Filtering Methods
# =========================================================================
@@ -345,6 +441,14 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
default=ModelWithStateMachine.StatusChoices.QUEUED,
)
config = models.JSONField(default=dict, null=False, blank=False, editable=True)
+ permissions = models.GeneratedField(
+ expression=KT("config__PERMISSIONS"),
+ output_field=models.CharField(max_length=16, null=True),
+ db_persist=True,
+ db_index=True,
+ editable=False,
+ )
+ output_size = models.BigIntegerField(default=0, db_index=True, editable=False, help_text="Total bytes of all ArchiveResult output files")
notes = models.TextField(blank=True, null=False, default="")
# output_dir is computed via @cached_property from fs_version and get_storage_path_for_version()
@@ -389,6 +493,69 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
def __str__(self):
return f"[{self.id}] {self.url[:64]}"
+ def update_and_requeue(self, **kwargs) -> bool:
+ """
+ Update this Snapshot through the shared retry_at ownership path.
+
+ Any non-final Snapshot work means the parent Crawl must also be visible
+ to the runner. Keep that invariant here so CLI/admin callers do not
+ hand-edit the parent Crawl state every time they retry a hook.
+ """
+ updated = super().update_and_requeue(**kwargs)
+ if not updated:
+ return False
+
+ next_status = kwargs.get("status", self.status)
+ if next_status not in (self.StatusChoices.QUEUED, self.StatusChoices.STARTED) or not self.crawl_id:
+ return True
+
+ crawl = self.crawl
+ crawl_status = crawl.StatusChoices.STARTED if crawl.status == crawl.StatusChoices.STARTED else crawl.StatusChoices.QUEUED
+ crawl.update_and_requeue(
+ status=crawl_status,
+ retry_at=kwargs.get("retry_at") or timezone.now(),
+ )
+ return True
+
+ def queue_for_extraction(self, *, when=None) -> bool:
+ """Queue this Snapshot for the runner using the normal state path."""
+ return self.update_and_requeue(
+ status=self.StatusChoices.QUEUED,
+ retry_at=when or timezone.now(),
+ current_step=0,
+ )
+
+ def pause(self, *, save: bool = True) -> bool:
+ paused = super().pause(save=save)
+ if paused and self.pk:
+ ArchiveResult.pause_queryset(self.archiveresult_set.all())
+ return paused
+
+ def resume(self, *, when: datetime | None = None, save: bool = True) -> bool:
+ resumed = super().resume(when=when, save=save)
+ if resumed and self.pk:
+ ArchiveResult.resume_queryset(self.archiveresult_set.all(), when=when)
+ return resumed
+
+ def restore_paused_scheduler_marker(self) -> None:
+ """
+ Keep explicit maintenance from accidentally resuming paused snapshots.
+
+ Targeted jobs such as `archivebox update --index-only` may bump
+ retry_at so the orchestrator can run only queued search ArchiveResult
+ rows. After that maintenance pass, the lifecycle must remain PAUSED and
+ retry_at must go back to MAX until a real resume transition happens.
+ """
+ type(self).objects.filter(pk=self.pk, status=self.StatusChoices.PAUSED).update(
+ retry_at=RETRY_AT_MAX,
+ modified_at=timezone.now(),
+ )
+
+ def cancel(self) -> None:
+ self.status = self.StatusChoices.SEALED
+ self.retry_at = None
+ self.save(update_fields=["status", "retry_at", "modified_at"])
+
def get_delete_after_config_value(self):
return get_config(snapshot=self).DELETE_AFTER
@@ -1989,22 +2156,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@cached_property
def archive_size(self):
- if hasattr(self, "output_size_sum"):
- return int(self.output_size_sum or 0)
-
- prefetched_results = None
- if hasattr(self, "_prefetched_objects_cache"):
- prefetched_results = self._prefetched_objects_cache.get("archiveresult_set")
- if prefetched_results:
- return sum(result.output_size or result.output_size_from_files() for result in prefetched_results)
-
- stats = self.archiveresult_set.aggregate(result_count=models.Count("id"), total_size=models.Sum("output_size"))
- if stats["result_count"]:
- return int(stats["total_size"] or 0)
- try:
- return get_dir_size(self.output_dir)[0]
- except Exception:
- return 0
+ return int(self.output_size or 0)
def save_tags(self, tags: Iterable[str] = ()) -> None:
tags_id = [Tag.objects.get_or_create(name=tag)[0].pk for tag in tags if tag.strip()]
@@ -2245,9 +2397,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
update_fields = []
if queue_for_extraction:
- snapshot.status = Snapshot.StatusChoices.QUEUED
+ if snapshot.status != Snapshot.StatusChoices.PAUSED:
+ snapshot.status = Snapshot.StatusChoices.QUEUED
+ update_fields.append("status")
snapshot.retry_at = timezone.now()
- update_fields.extend(["status", "retry_at"])
+ update_fields.append("retry_at")
# Update additional fields if provided
for field_name in ("depth", "parent_snapshot_id", "crawl_id", "bookmarked_at", "created_at", "downloaded_at"):
@@ -2384,6 +2538,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
],
)
legacy_result_count = retryable_results.filter(hook_name="").count()
+ now = timezone.now()
count = retryable_results.exclude(hook_name="").update(
status=ArchiveResult.StatusChoices.QUEUED,
output_str="",
@@ -2393,19 +2548,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
output_mimetypes="",
start_ts=None,
end_ts=None,
+ modified_at=now,
)
if count + legacy_result_count > 0:
- self.status = self.StatusChoices.QUEUED
- self.retry_at = timezone.now()
- self.current_step = 0 # Reset to step 0 for retry
- self.save(update_fields=["status", "retry_at", "current_step", "modified_at"])
-
- crawl = self.crawl
- if crawl.status != crawl.StatusChoices.STARTED:
- crawl.status = crawl.StatusChoices.QUEUED
- crawl.retry_at = timezone.now()
- crawl.save(update_fields=["status", "retry_at", "modified_at"])
+ self.queue_for_extraction(when=now)
return count + legacy_result_count
@@ -2961,13 +3108,21 @@ class SnapshotMachine(BaseStateMachine):
# States
queued = State(value=Snapshot.StatusChoices.QUEUED, initial=True)
started = State(value=Snapshot.StatusChoices.STARTED)
+ paused = State(value=Snapshot.StatusChoices.PAUSED)
sealed = State(value=Snapshot.StatusChoices.SEALED, final=True)
# Tick Event (polled by workers)
- tick = queued.to.itself(unless="can_start") | queued.to(started, cond="can_start") | started.to(sealed, cond="is_finished")
+ tick = (
+ queued.to.itself(unless="can_start")
+ | queued.to(started, cond="can_start")
+ | started.to(sealed, cond="is_finished")
+ | paused.to.itself()
+ )
# Manual event (can also be triggered by last ArchiveResult finishing)
seal = started.to(sealed)
+ pause_requested = queued.to(paused) | started.to(paused)
+ resume_requested = paused.to(queued)
snapshot: Snapshot
@@ -2986,6 +3141,13 @@ class SnapshotMachine(BaseStateMachine):
status=Snapshot.StatusChoices.QUEUED,
)
+ @paused.enter
+ def enter_paused(self):
+ self.snapshot.update_and_requeue(
+ retry_at=RETRY_AT_MAX,
+ status=Snapshot.StatusChoices.PAUSED,
+ )
+
@started.enter
def enter_started(self):
"""Just mark as started. The shared runner creates ArchiveResults and runs hooks."""
@@ -2995,8 +3157,6 @@ class SnapshotMachine(BaseStateMachine):
@sealed.enter
def enter_sealed(self):
- import sys
-
# Clean up background hooks
self.snapshot.cleanup()
@@ -3005,19 +3165,19 @@ class SnapshotMachine(BaseStateMachine):
status=Snapshot.StatusChoices.SEALED,
)
- print(f"[cyan] β
SnapshotMachine.enter_sealed() - sealed {self.snapshot.url}[/cyan]", file=sys.stderr)
-
# Check if this is the last snapshot for the parent crawl - if so, seal the crawl
if self.snapshot.crawl:
crawl = self.snapshot.crawl
remaining_active = Snapshot.objects.filter(
crawl=crawl,
- status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED],
+ status__in=[
+ Snapshot.StatusChoices.QUEUED,
+ Snapshot.StatusChoices.STARTED,
+ Snapshot.StatusChoices.PAUSED,
+ ],
).count()
if remaining_active == 0 and crawl.status == crawl.StatusChoices.STARTED:
- print(f"[cyan]π All snapshots sealed for crawl {crawl.id}, sealing crawl[/cyan]", file=sys.stderr)
- # Seal the parent crawl
cast(Any, crawl).sm.seal()
@@ -3025,6 +3185,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
class StatusChoices(models.TextChoices):
QUEUED = "queued", "Queued"
STARTED = "started", "Started"
+ PAUSED = "paused", "Paused"
BACKOFF = "backoff", "Waiting to retry"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
@@ -3053,6 +3214,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
"noresults": cls.StatusChoices.NORESULTS,
"queued": cls.StatusChoices.QUEUED,
"started": cls.StatusChoices.STARTED,
+ "paused": cls.StatusChoices.PAUSED,
"backoff": cls.StatusChoices.BACKOFF,
}.get(str(status or "").strip().lower(), cls.StatusChoices.FAILED)
@@ -3100,6 +3262,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
end_ts = models.DateTimeField(default=None, null=True, blank=True)
status = models.CharField(max_length=16, choices=StatusChoices.choices, default=StatusChoices.QUEUED, db_index=True)
+ retry_at = models.DateTimeField(default=None, null=True, blank=True, db_index=True)
notes = models.TextField(blank=True, null=False, default="")
# output_dir is computed via @property from snapshot.output_dir / plugin
@@ -3123,6 +3286,22 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
def __str__(self):
return f"[{self.id}] {self.snapshot.url[:64]} -> {self.plugin}"
+ @staticmethod
+ def _format_output_line_for_display(line: str) -> str:
+ raw_line = str(line or "")
+ stripped = raw_line.strip()
+ if not stripped or "://" in stripped or not stripped.startswith(("/", "~/")):
+ return raw_line
+ try:
+ data_dir = CONSTANTS.DATA_DIR.expanduser().resolve(strict=False)
+ rel_path = Path(stripped).expanduser().resolve(strict=False).relative_to(data_dir)
+ except (OSError, ValueError):
+ return raw_line
+ return f"{raw_line[: len(raw_line) - len(raw_line.lstrip())]}./{rel_path}{raw_line[len(raw_line.rstrip()):]}"
+
+ def output_str_for_display(self) -> str:
+ return "\n".join(self._format_output_line_for_display(line) for line in str(self.output_str or "").splitlines())
+
def get_delete_after_config_value(self):
return get_config(archiveresult=self).DELETE_AFTER
@@ -3225,6 +3404,13 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
return None
def save(self, *args, **kwargs):
+ is_new = self._state.adding
+ update_fields = kwargs.get("update_fields")
+ refresh_snapshot_size = is_new or update_fields is None or "output_size" in update_fields or "snapshot" in update_fields or "snapshot_id" in update_fields
+ old_snapshot_id = None
+ if refresh_snapshot_size and not is_new:
+ old_snapshot_id = type(self).objects.filter(pk=self.pk).values_list("snapshot_id", flat=True).first()
+
update_fields = kwargs.get("update_fields")
if self.delete_at is None:
self.set_delete_at_from_config()
@@ -3234,6 +3420,9 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
# Skip ModelWithOutputDir.save() to avoid creating index.json in plugin directories
# Call the Django Model.save() directly instead
models.Model.save(self, *args, **kwargs)
+ if refresh_snapshot_size:
+ snapshot_ids = {snapshot_id for snapshot_id in (old_snapshot_id, self.snapshot_id) if snapshot_id}
+ transaction.on_commit(lambda: type(self).refresh_snapshot_output_sizes(snapshot_ids))
# if is_new:
# from archivebox.misc.logging_util import log_worker_event
@@ -3250,6 +3439,19 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
# },
# )
+ def delete(self, *args, **kwargs):
+ snapshot_id = self.snapshot_id
+ deleted = super().delete(*args, **kwargs)
+ if snapshot_id:
+ transaction.on_commit(lambda: type(self).refresh_snapshot_output_sizes({snapshot_id}))
+ return deleted
+
+ @staticmethod
+ def refresh_snapshot_output_sizes(snapshot_ids):
+ for snapshot_id in snapshot_ids:
+ total_size = ArchiveResult.objects.filter(snapshot_id=snapshot_id).aggregate(total_size=Sum("output_size"))["total_size"] or 0
+ Snapshot.objects.filter(pk=snapshot_id).update(output_size=total_size)
+
@cached_property
def snapshot_dir(self):
return Path(self.snapshot.output_dir)
@@ -3267,6 +3469,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
def reset_for_retry(self, *, save: bool = True) -> None:
self.status = self.StatusChoices.QUEUED
+ self.retry_at = None
self.output_str = ""
self.output_json = None
self.output_files = {}
@@ -3278,6 +3481,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
self.save(
update_fields=[
"status",
+ "retry_at",
"output_str",
"output_json",
"output_files",
@@ -3289,6 +3493,48 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
],
)
+ @property
+ def is_paused(self) -> bool:
+ return self.status == self.StatusChoices.PAUSED
+
+ @classmethod
+ def pause_queryset(cls, queryset) -> int:
+ return queryset.exclude(status__in=[*cls.FINAL_STATES, cls.StatusChoices.PAUSED]).update(
+ status=cls.StatusChoices.PAUSED,
+ retry_at=RETRY_AT_MAX,
+ modified_at=timezone.now(),
+ )
+
+ @classmethod
+ def resume_queryset(cls, queryset, *, when: datetime | None = None) -> int:
+ return queryset.filter(status=cls.StatusChoices.PAUSED).update(
+ status=cls.StatusChoices.QUEUED,
+ retry_at=when or timezone.now(),
+ modified_at=timezone.now(),
+ )
+
+ def pause(self, *, save: bool = True) -> bool:
+ if self.status in self.FINAL_STATES:
+ return False
+ if self.is_paused:
+ return False
+ self.status = self.StatusChoices.PAUSED
+ self.retry_at = RETRY_AT_MAX
+ if save:
+ self.pause_queryset(type(self).objects.filter(pk=self.pk))
+ self.refresh_from_db()
+ return True
+
+ def resume(self, *, when: datetime | None = None, save: bool = True) -> bool:
+ if not self.is_paused:
+ return False
+ self.status = self.StatusChoices.QUEUED
+ self.retry_at = when or timezone.now()
+ if save:
+ self.resume_queryset(type(self).objects.filter(pk=self.pk), when=self.retry_at)
+ self.refresh_from_db()
+ return True
+
@property
def plugin_module(self) -> Any | None:
# Hook scripts are now used instead of Python plugin modules
@@ -3370,7 +3616,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
return False
snapshot_dir = Path(snapshot_dir or self.snapshot.output_dir)
- exclude_names = {"stdout.log", "stderr.log", "process.pid", "hook.pid", "listener.pid", "cmd.sh"}
+ exclude_names = {"stdout.log", "stderr.log", "process.pid", "hook.pid", "listener.pid"}
output_files: dict[str, dict[str, Any]] = {}
mime_sizes: dict[str, int] = defaultdict(int)
total_size = 0
@@ -3505,7 +3751,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
plugin_name: str | None = None,
output_file_map: dict[str, dict[str, Any]] | None = None,
) -> str | None:
- ignored = {"stdout.log", "stderr.log", "hook.pid", "listener.pid", "cmd.sh"}
+ ignored = {"stdout.log", "stderr.log", "hook.pid", "listener.pid"}
candidates = [
path
for path in output_file_paths
@@ -3731,15 +3977,14 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
self.save()
return
- # Read and parse JSONL output from stdout.log
- stdout_file = plugin_dir / "stdout.log"
records = []
process = self.process_record
if process:
records = extract_records_from_process(process)
if not records:
- stdout = stdout_file.read_text() if stdout_file.exists() else ""
+ stdout_file = plugin_dir / "stdout.log"
+ stdout = stdout_file.read_text(errors="replace") if stdout_file.exists() else ""
records = Process.parse_records_from_text(stdout)
# Find ArchiveResult record and update status/output from it
@@ -3785,7 +4030,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
self.output_str = "Hook did not output ArchiveResult record"
# Walk filesystem and populate output_files, output_size, output_mimetypes
- exclude_names = {"stdout.log", "stderr.log", "process.pid", "hook.pid", "listener.pid", "cmd.sh"}
+ exclude_names = {"stdout.log", "stderr.log", "process.pid", "hook.pid", "listener.pid"}
mime_sizes = defaultdict(int)
total_size = 0
output_files = {}
diff --git a/archivebox/core/permissions.py b/archivebox/core/permissions.py
new file mode 100644
index 00000000..045ad6d1
--- /dev/null
+++ b/archivebox/core/permissions.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from django.db.models import Q, QuerySet
+from django.http import HttpRequest
+
+from archivebox.config.common import get_config
+
+PERMISSIONS_PUBLIC = "public"
+PERMISSIONS_UNLISTED = "unlisted"
+PERMISSIONS_PRIVATE = "private"
+PERMISSIONS_CHOICES = (
+ (PERMISSIONS_PUBLIC, "Public"),
+ (PERMISSIONS_UNLISTED, "Unlisted"),
+ (PERMISSIONS_PRIVATE, "Private"),
+)
+
+
+def is_admin_user(request: HttpRequest) -> bool:
+ user = request.user
+ return bool(user.is_authenticated and user.is_active and user.is_staff)
+
+
+def get_snapshot_permissions(snapshot) -> str:
+ try:
+ return str(get_config(snapshot=snapshot, resolve_plugins=False).PERMISSIONS).strip().lower()
+ except Exception:
+ return PERMISSIONS_PRIVATE
+
+
+def can_view_snapshot(request: HttpRequest, snapshot) -> bool:
+ permissions = get_snapshot_permissions(snapshot)
+ return permissions in {PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED} or is_admin_user(request)
+
+
+def _persona_ids_for_permissions(allowed_permissions: set[str]) -> list[str]:
+ from archivebox.personas.models import Persona
+
+ fallback_permissions = str(get_config(resolve_plugins=False).PERMISSIONS).strip().lower()
+ personas = Persona.objects.only("id", "config")
+ return [
+ str(persona.id)
+ for persona in personas
+ if (persona.permissions or fallback_permissions) in allowed_permissions
+ ]
+
+
+def filter_personas_by_permissions(queryset: QuerySet, allowed_permissions: set[str]) -> QuerySet:
+ return queryset.filter(id__in=_persona_ids_for_permissions(allowed_permissions))
+
+
+def filter_snapshots_by_permissions(queryset: QuerySet, *, direct: bool = False, allowed_permissions: set[str] | None = None) -> QuerySet:
+ from archivebox.crawls.models import Crawl
+ from archivebox.personas.models import Persona
+
+ allowed_permissions = allowed_permissions or ({PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED} if direct else {PERMISSIONS_PUBLIC})
+ fallback_permissions = str(get_config(resolve_plugins=False).PERMISSIONS).strip().lower()
+ has_overrides = (
+ queryset.model.objects.filter(permissions__gt="").exists()
+ or Crawl.objects.filter(permissions__gt="").exists()
+ or Persona.objects.filter(permissions__gt="").exists()
+ )
+ if not has_overrides:
+ return queryset if fallback_permissions in allowed_permissions else queryset.none()
+
+ allowed_persona_ids = _persona_ids_for_permissions(allowed_permissions)
+ valid_persona_ids = [str(persona_id) for persona_id in Persona.objects.values_list("id", flat=True)]
+ fallback_query = Q(crawl__persona_id__in=allowed_persona_ids)
+ if fallback_permissions in allowed_permissions:
+ fallback_query |= Q(crawl__persona_id__isnull=True) | ~Q(crawl__persona_id__in=valid_persona_ids)
+ inherited_query = Q(crawl__permissions__in=sorted(allowed_permissions)) | (Q(crawl__permissions__isnull=True) & fallback_query)
+ return queryset.filter(
+ Q(permissions__in=sorted(allowed_permissions)) | (Q(permissions__isnull=True) & inherited_query),
+ )
+
+
+def public_snapshots_queryset(queryset: QuerySet) -> QuerySet:
+ return filter_snapshots_by_permissions(queryset, direct=False)
+
+
+def direct_snapshots_queryset(request: HttpRequest, queryset: QuerySet) -> QuerySet:
+ return queryset if is_admin_user(request) else filter_snapshots_by_permissions(queryset, direct=True)
diff --git a/archivebox/core/recovery_util.py b/archivebox/core/recovery_util.py
new file mode 100644
index 00000000..1cb92be0
--- /dev/null
+++ b/archivebox/core/recovery_util.py
@@ -0,0 +1,349 @@
+from __future__ import annotations
+
+from datetime import timedelta
+
+from django.utils import timezone
+from rich.console import Console
+
+
+def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int]:
+ from archivebox.crawls.models import Crawl
+ from archivebox.core.models import ArchiveResult, Snapshot
+ from archivebox.machine.models import Process
+ from django.db.models import Exists, OuterRef, Q, Subquery, Value
+ from django.db.models.functions import Coalesce
+
+ now = timezone.now()
+ stuck_cutoff = now - timedelta(hours=12)
+ recovery_console = Console(stderr=True, highlight=False)
+ cleaned = {
+ "stale_processes": Process.cleanup_stale_running(),
+ "orphaned_processes": Process.cleanup_orphaned_workers(),
+ "orphaned_chrome": Process.cleanup_orphaned_chrome() if include_chrome else 0,
+ "queued_crawls_unlocked": 0,
+ "sealed_crawl_locks_cleared": 0,
+ "sealed_snapshots": 0,
+ "unlocked_snapshots": 0,
+ "requeued_snapshots": 0,
+ "queued_snapshots_unlocked": 0,
+ "sealed_snapshot_locks_cleared": 0,
+ "requeued_archiveresults": 0,
+ "sealed_crawls": 0,
+ "unlocked_crawls": 0,
+ "requeued_crawls": 0,
+ "sealed_queued_snapshots": 0,
+ "sealed_queued_crawls": 0,
+ }
+
+ any_archiveresults = ArchiveResult.objects.filter(snapshot_id=OuterRef("pk"))
+ unfinished_archiveresults = any_archiveresults.exclude(status__in=ArchiveResult.FINAL_STATES)
+ recent_snapshots = Snapshot.objects.filter(crawl_id=OuterRef("pk"), modified_at__gt=stuck_cutoff)
+ recent_archiveresults = ArchiveResult.objects.filter(snapshot__crawl_id=OuterRef("pk"), modified_at__gt=stuck_cutoff)
+ recent_archiveresult_processes = Process.objects.filter(
+ archiveresult__snapshot__crawl_id=OuterRef("pk"),
+ modified_at__gt=stuck_cutoff,
+ )
+ recent_crawl_snapshots_for_snapshot = Snapshot.objects.filter(crawl_id=OuterRef("crawl_id"), modified_at__gt=stuck_cutoff)
+ recent_crawl_archiveresults_for_snapshot = ArchiveResult.objects.filter(
+ snapshot__crawl_id=OuterRef("crawl_id"),
+ modified_at__gt=stuck_cutoff,
+ )
+ recent_crawl_archiveresult_processes_for_snapshot = Process.objects.filter(
+ archiveresult__snapshot__crawl_id=OuterRef("crawl_id"),
+ modified_at__gt=stuck_cutoff,
+ )
+
+ # Stale-only repair: if a queued snapshot/crawl already has only final
+ # projected result rows and the whole crawl has been quiet for >12hr, it
+ # was likely interrupted after hook completion but before state sealing.
+ # Never run this on fresh rows: queued work is normal during direct
+ # reindex/extract and while a daemon runner is active.
+ stale_finished_snapshot_ids = (
+ Snapshot.objects.filter(
+ status=Snapshot.StatusChoices.QUEUED,
+ modified_at__lte=stuck_cutoff,
+ crawl__modified_at__lte=stuck_cutoff,
+ )
+ .filter(Q(retry_at__isnull=True) | Q(retry_at__lte=stuck_cutoff))
+ .annotate(
+ has_results=Exists(any_archiveresults),
+ has_unfinished_results=Exists(unfinished_archiveresults),
+ has_recent_snapshot=Exists(recent_crawl_snapshots_for_snapshot),
+ has_recent_archiveresult=Exists(recent_crawl_archiveresults_for_snapshot),
+ has_recent_archiveresult_process=Exists(recent_crawl_archiveresult_processes_for_snapshot),
+ )
+ .filter(
+ has_results=True,
+ has_unfinished_results=False,
+ has_recent_snapshot=False,
+ has_recent_archiveresult=False,
+ has_recent_archiveresult_process=False,
+ )
+ .values_list("id", flat=True)
+ )
+ cleaned["sealed_queued_snapshots"] = Snapshot.objects.filter(id__in=stale_finished_snapshot_ids).update(
+ status=Snapshot.StatusChoices.SEALED,
+ retry_at=None,
+ downloaded_at=Coalesce("downloaded_at", Value(now)),
+ )
+ unrecoverable_active_child_snapshots = (
+ Snapshot.objects.filter(
+ crawl_id=OuterRef("pk"),
+ status__in=[
+ Snapshot.StatusChoices.QUEUED,
+ Snapshot.StatusChoices.STARTED,
+ Snapshot.StatusChoices.PAUSED,
+ ],
+ )
+ .annotate(
+ has_results=Exists(any_archiveresults),
+ has_unfinished_results=Exists(unfinished_archiveresults),
+ )
+ .filter(
+ Q(status=Snapshot.StatusChoices.STARTED)
+ | Q(modified_at__gt=stuck_cutoff)
+ | Q(retry_at__gt=stuck_cutoff)
+ | Q(has_results=False)
+ | Q(has_unfinished_results=True),
+ )
+ )
+ cleaned["sealed_queued_crawls"] = (
+ Crawl.objects.filter(
+ status=Crawl.StatusChoices.QUEUED,
+ snapshot_set__isnull=False,
+ modified_at__lte=stuck_cutoff,
+ )
+ .filter(Q(retry_at__isnull=True) | Q(retry_at__lte=stuck_cutoff))
+ .annotate(
+ has_unrecoverable_active_child=Exists(unrecoverable_active_child_snapshots),
+ has_recent_snapshot=Exists(recent_snapshots),
+ has_recent_archiveresult=Exists(recent_archiveresults),
+ has_recent_archiveresult_process=Exists(recent_archiveresult_processes),
+ )
+ .filter(
+ has_unrecoverable_active_child=False,
+ has_recent_snapshot=False,
+ has_recent_archiveresult=False,
+ has_recent_archiveresult_process=False,
+ )
+ .update(
+ status=Crawl.StatusChoices.SEALED,
+ retry_at=None,
+ modified_at=now,
+ )
+ )
+
+ stale_crawls = (
+ Crawl.objects.filter(
+ status__in=[Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED],
+ modified_at__lte=stuck_cutoff,
+ )
+ .filter(Q(retry_at__isnull=True) | Q(retry_at__lte=now))
+ .annotate(
+ has_recent_snapshot=Exists(recent_snapshots),
+ has_recent_archiveresult=Exists(recent_archiveresults),
+ has_recent_archiveresult_process=Exists(recent_archiveresult_processes),
+ )
+ .filter(has_recent_snapshot=False, has_recent_archiveresult=False, has_recent_archiveresult_process=False)
+ .order_by("modified_at")[:10]
+ )
+ stale_crawl_messages = []
+ for crawl in stale_crawls:
+ if not Process.objects.filter(
+ pwd__contains=str(crawl.id),
+ status=Process.StatusChoices.RUNNING,
+ modified_at__gt=stuck_cutoff,
+ ).exists():
+ stale_crawl_messages.append(
+ f"{crawl.id} status={crawl.status} retry_at={crawl.retry_at} modified_at={crawl.modified_at}",
+ )
+ if stale_crawl_messages:
+ recovery_console.print(
+ "[red]β Orchestrator recovery found stuck active crawl invariant violation; refusing to continue.[/red]",
+ )
+ raise RuntimeError(
+ "Stuck crawl invariant violated: active crawls had no crawl/snapshot/result/process changes for >12hr: "
+ + "; ".join(stale_crawl_messages),
+ )
+
+ running_archiveresults = ArchiveResult.objects.filter(
+ snapshot_id=OuterRef("pk"),
+ status=ArchiveResult.StatusChoices.STARTED,
+ process__status=Process.StatusChoices.RUNNING,
+ )
+ unfinished_archiveresult_statuses = [
+ ArchiveResult.StatusChoices.QUEUED,
+ ArchiveResult.StatusChoices.STARTED,
+ ArchiveResult.StatusChoices.PAUSED,
+ ArchiveResult.StatusChoices.BACKOFF,
+ ]
+ running_unfinished_archiveresults = ArchiveResult.objects.filter(
+ snapshot_id=OuterRef("pk"),
+ status__in=unfinished_archiveresult_statuses,
+ process__status=Process.StatusChoices.RUNNING,
+ )
+ unfinished_without_running_archiveresults = ArchiveResult.objects.filter(
+ snapshot_id=OuterRef("pk"),
+ status__in=[
+ ArchiveResult.StatusChoices.QUEUED,
+ ArchiveResult.StatusChoices.STARTED,
+ ArchiveResult.StatusChoices.BACKOFF,
+ ],
+ ).exclude(
+ status=ArchiveResult.StatusChoices.PAUSED,
+ ).exclude(
+ process__status=Process.StatusChoices.RUNNING,
+ )
+ active_child_snapshots = Snapshot.objects.filter(
+ crawl_id=OuterRef("pk"),
+ status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.PAUSED],
+ )
+ due_child_snapshots = active_child_snapshots.exclude(status=Snapshot.StatusChoices.PAUSED).filter(
+ Q(retry_at__isnull=True) | Q(retry_at__lte=now),
+ )
+ next_future_child_retry = Subquery(
+ active_child_snapshots.filter(retry_at__gt=now).order_by("retry_at").values("retry_at")[:1],
+ )
+
+ # Broken lock repair: QUEUED rows with retry_at=NULL are invisible to the
+ # queue. Set only the scheduling field so the runner owns the next tick.
+ cleaned["queued_crawls_unlocked"] = Crawl.objects.filter(
+ status=Crawl.StatusChoices.QUEUED,
+ retry_at__isnull=True,
+ ).update(retry_at=now, modified_at=now)
+ cleaned["queued_snapshots_unlocked"] = Snapshot.objects.filter(
+ status=Snapshot.StatusChoices.QUEUED,
+ retry_at__isnull=True,
+ ).update(retry_at=now, modified_at=now)
+
+ # ArchiveResult has no retry_at scheduler; BACKOFF is a legacy/impossible
+ # persisted state here, so move it back to QUEUED for the snapshot runner.
+ cleaned["requeued_archiveresults"] = ArchiveResult.objects.filter(
+ status=ArchiveResult.StatusChoices.BACKOFF,
+ ).update(status=ArchiveResult.StatusChoices.QUEUED, modified_at=now)
+ # Impossible state repair: STARTED ArchiveResults without a live Process
+ # have no owner left to emit completion. Requeue only the result row; the
+ # snapshot/crawl schedulers will pick up normal retry processing.
+ cleaned["requeued_archiveresults"] += (
+ ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.STARTED)
+ .exclude(process__status=Process.StatusChoices.RUNNING)
+ .update(status=ArchiveResult.StatusChoices.QUEUED, process=None, modified_at=now)
+ )
+
+ started_snapshots = Snapshot.objects.filter(
+ status=Snapshot.StatusChoices.STARTED,
+ retry_at__isnull=True,
+ )
+
+ # Normal transition: the snapshot has finished all known extractor work,
+ # but the process died before the state machine got to seal it.
+ finished_snapshot_ids = (
+ started_snapshots.annotate(
+ has_results=Exists(any_archiveresults),
+ has_unfinished_results=Exists(unfinished_archiveresults),
+ )
+ .filter(has_results=True, has_unfinished_results=False)
+ .values_list("id", flat=True)
+ )
+ for snapshot in Snapshot.objects.filter(id__in=finished_snapshot_ids).select_related("crawl").iterator(chunk_size=100):
+ snapshot.sm.seal()
+ cleaned["sealed_snapshots"] += 1
+
+ # Broken lock repair: STARTED + retry_at=NULL means "owned by an active
+ # runner". If no ArchiveResult has a live process anymore, only unlock it.
+ # The existing runner will pick the row up through the normal queue path.
+ cleaned["unlocked_snapshots"] = (
+ started_snapshots.annotate(has_running_results=Exists(running_archiveresults))
+ .filter(has_running_results=False)
+ .update(
+ retry_at=now,
+ modified_at=now,
+ )
+ )
+
+ # Impossible state repair: a SEALED snapshot with a still-running child is
+ # active, not final. Reflect that without starting duplicate work.
+ cleaned["requeued_snapshots"] += (
+ Snapshot.objects.filter(status=Snapshot.StatusChoices.SEALED)
+ .annotate(has_running_unfinished_results=Exists(running_unfinished_archiveresults))
+ .filter(has_running_unfinished_results=True)
+ .update(
+ status=Snapshot.StatusChoices.STARTED,
+ retry_at=None,
+ modified_at=now,
+ )
+ )
+
+ # Impossible state repair: SEALED snapshots should not contain unfinished
+ # ArchiveResults. There is no valid state-machine transition from final
+ # back to queued, so repair only the fields needed for the runner to retry.
+ cleaned["requeued_snapshots"] += (
+ Snapshot.objects.filter(status=Snapshot.StatusChoices.SEALED)
+ .annotate(has_unfinished_results_without_running=Exists(unfinished_without_running_archiveresults))
+ .filter(has_unfinished_results_without_running=True)
+ .update(
+ status=Snapshot.StatusChoices.QUEUED,
+ retry_at=now,
+ modified_at=now,
+ )
+ )
+
+ # Normal transition: a started crawl has no active snapshots left.
+ finished_crawl_ids = (
+ Crawl.objects.filter(status=Crawl.StatusChoices.STARTED, retry_at__isnull=True)
+ .exclude(
+ snapshot_set__status__in=[
+ Snapshot.StatusChoices.QUEUED,
+ Snapshot.StatusChoices.STARTED,
+ Snapshot.StatusChoices.PAUSED,
+ ],
+ )
+ .values_list("id", flat=True)
+ )
+ for crawl in Crawl.objects.filter(id__in=finished_crawl_ids).iterator(chunk_size=100):
+ crawl.sm.seal()
+ cleaned["sealed_crawls"] += 1
+
+ # Broken lock repair: STARTED + retry_at=NULL with unfinished snapshots is
+ # recoverable by unlocking the crawl. Do not create snapshots or results.
+ due_started_crawls = (
+ Crawl.objects.filter(status=Crawl.StatusChoices.STARTED, retry_at__isnull=True)
+ .annotate(has_due_child=Exists(due_child_snapshots))
+ .filter(has_due_child=True)
+ )
+ cleaned["unlocked_crawls"] = due_started_crawls.update(retry_at=now, modified_at=now)
+ future_started_crawls = (
+ Crawl.objects.filter(status=Crawl.StatusChoices.STARTED, retry_at__isnull=True)
+ .annotate(has_active_child=Exists(active_child_snapshots), has_due_child=Exists(due_child_snapshots), next_child_retry=next_future_child_retry)
+ .filter(has_active_child=True, has_due_child=False)
+ )
+ cleaned["unlocked_crawls"] += future_started_crawls.update(retry_at=Coalesce("next_child_retry", Value(now)), modified_at=now)
+
+ cleaned["requeued_crawls"] = 0
+
+ warning_recoveries = {
+ "stale_processes": "marked stale running Process row(s) exited",
+ "orphaned_processes": "marked orphaned worker/hook Process row(s) exited",
+ "orphaned_chrome": "terminated orphaned Chrome process(es)",
+ "sealed_snapshots": "sealed started Snapshot row(s) whose ArchiveResults were already final",
+ "unlocked_snapshots": "unlocked started Snapshot row(s) whose owner process was gone",
+ "sealed_crawls": "sealed started Crawl row(s) with no active Snapshots",
+ "unlocked_crawls": "unlocked started Crawl row(s) with pending child Snapshots",
+ }
+ error_recoveries = {
+ "queued_crawls_unlocked": "repaired queued Crawl row(s) with retry_at=NULL",
+ "queued_snapshots_unlocked": "repaired queued Snapshot row(s) with retry_at=NULL",
+ "requeued_archiveresults": "requeued ArchiveResult row(s) left in BACKOFF",
+ "requeued_snapshots": "reopened sealed Snapshot row(s) with unfinished ArchiveResults",
+ "requeued_crawls": "reopened sealed Crawl row(s) with active child Snapshots",
+ "sealed_queued_snapshots": "sealed stale queued Snapshot row(s) whose ArchiveResults were already final",
+ "sealed_queued_crawls": "sealed stale queued Crawl row(s) whose Snapshots were already final",
+ }
+ for key, message in warning_recoveries.items():
+ if cleaned[key]:
+ recovery_console.print(f"[yellow]β οΈ Orchestrator recovery: {cleaned[key]} {message}.[/yellow]")
+ for key, message in error_recoveries.items():
+ if cleaned[key]:
+ recovery_console.print(f"[red]β Orchestrator invariant repair: {cleaned[key]} {message}.[/red]")
+
+ return cleaned
diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py
index 97970bb5..70e3c3e1 100644
--- a/archivebox/core/settings.py
+++ b/archivebox/core/settings.py
@@ -229,7 +229,7 @@ SQLITE_JOURNAL_MODE = os.environ.get("ARCHIVEBOX_SQLITE_JOURNAL_MODE", "WAL")
SQLITE_MMAP_SIZE = os.environ.get("ARCHIVEBOX_SQLITE_MMAP_SIZE", "0" if CONSTANTS.IN_DOCKER else "134217728")
SQLITE_CONNECTION_OPTIONS = {
- "ENGINE": "django.db.backends.sqlite3",
+ "ENGINE": "archivebox.core.sqlite_backend",
"TIME_ZONE": CONSTANTS.TIMEZONE,
"OPTIONS": {
# https://gcollazo.com/optimal-sqlite-settings-for-django/
@@ -237,7 +237,13 @@ SQLITE_CONNECTION_OPTIONS = {
# https://docs.djangoproject.com/en/5.1/ref/databases/#setting-pragma-options
"timeout": 30,
"check_same_thread": False,
- "transaction_mode": "IMMEDIATE",
+ # Keep SQLite on Django's default deferred transaction mode. BEGIN
+ # IMMEDIATE grabs the write lock as soon as atomic() opens, which is
+ # exactly what hurts ArchiveBox on large collections where Python code
+ # may do filesystem work before the actual row write. Deferred BEGIN
+ # keeps writes statement-scoped unless a caller explicitly opens a
+ # transaction around multiple writes.
+ "transaction_mode": None,
"init_command": (
"PRAGMA foreign_keys=ON;"
"PRAGMA busy_timeout = 30000;"
diff --git a/archivebox/core/shutdown_util.py b/archivebox/core/shutdown_util.py
new file mode 100644
index 00000000..3a392107
--- /dev/null
+++ b/archivebox/core/shutdown_util.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import signal
+import subprocess
+import sys
+from collections.abc import Iterator
+from contextlib import contextmanager
+from dataclasses import dataclass
+
+import psutil
+
+
+@dataclass
+class ShutdownSignalState:
+ """Tracks the exact OS signal that asked a foreground command to exit."""
+
+ signal_name: str | None = None
+
+
+def configured_stopwaitsecs(workers: list[dict[str, str]] | tuple[dict[str, str], ...], *, default: int = 5, buffer: int = 5) -> int:
+ """Return a deterministic shutdown bound from generated worker definitions."""
+
+ stop_grace_seconds = default
+ for worker in workers:
+ try:
+ stop_grace_seconds = max(stop_grace_seconds, int(worker.get("stopwaitsecs") or default) + buffer)
+ except (TypeError, ValueError):
+ continue
+ return stop_grace_seconds
+
+
+def wait_popen_and_kill_children(
+ proc: subprocess.Popen,
+ children: list[psutil.Process],
+ *,
+ timeout: float,
+ kill_timeout: float = 2.0,
+) -> None:
+ """Wait for a Popen parent and then hard-kill any surviving descendants."""
+
+ try:
+ proc.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait(timeout=kill_timeout)
+ kill_remaining_processes(children, timeout=kill_timeout)
+
+
+def wait_psutil_and_kill_children(
+ proc: psutil.Process,
+ children: list[psutil.Process],
+ *,
+ timeout: float,
+ kill_timeout: float = 2.0,
+) -> None:
+ """Wait for a psutil parent and then hard-kill any surviving descendants."""
+
+ try:
+ if proc.status() == psutil.STATUS_ZOMBIE:
+ # Another ArchiveBox foreground parent owns this Popen and must reap
+ # it. By the time supervisord is a zombie it has already stopped
+ # accepting work, so the caller can clear stale pid/socket files
+ # without blocking for a process it cannot reap itself.
+ kill_remaining_processes(children, timeout=kill_timeout)
+ return
+ proc.wait(timeout=timeout)
+ except psutil.TimeoutExpired:
+ proc.kill()
+ kill_remaining_processes(children, timeout=kill_timeout)
+ try:
+ proc.wait(timeout=kill_timeout)
+ except (psutil.NoSuchProcess, psutil.TimeoutExpired):
+ pass
+
+
+def kill_remaining_processes(processes: list[psutil.Process], *, timeout: float = 2.0) -> None:
+ _gone, alive = psutil.wait_procs(processes, timeout=timeout)
+ for process in alive:
+ try:
+ process.kill()
+ except psutil.NoSuchProcess:
+ pass
+ psutil.wait_procs(alive, timeout=timeout)
+
+
+@contextmanager
+def foreground_shutdown_signals(
+ handled_signals: tuple[signal.Signals, ...] = (signal.SIGHUP, signal.SIGINT, signal.SIGTERM),
+) -> Iterator[ShutdownSignalState]:
+ """Install foreground signal handlers that print an immediate exit notice.
+
+ Some log-tail loops intentionally swallow KeyboardInterrupt so that callers
+ can centralize cleanup in finally blocks. The handler writes the signal name
+ immediately, then raises KeyboardInterrupt to break out of the blocking read.
+ """
+
+ state = ShutdownSignalState()
+ previous_handlers = {sig: signal.getsignal(sig) for sig in handled_signals}
+
+ def raise_keyboard_interrupt(signum, _frame):
+ state.signal_name = signal.Signals(signum).name
+ sys.stdout.write(f"\n[π] Got {state.signal_name}, stopping gracefully...\n")
+ sys.stdout.flush()
+ raise KeyboardInterrupt
+
+ try:
+ for sig in handled_signals:
+ signal.signal(sig, raise_keyboard_interrupt)
+ yield state
+ finally:
+ for sig, previous_handler in previous_handlers.items():
+ signal.signal(sig, previous_handler)
diff --git a/archivebox/ideas/__init__.py b/archivebox/core/sqlite_backend/__init__.py
similarity index 100%
rename from archivebox/ideas/__init__.py
rename to archivebox/core/sqlite_backend/__init__.py
diff --git a/archivebox/core/sqlite_backend/base.py b/archivebox/core/sqlite_backend/base.py
new file mode 100644
index 00000000..029a810e
--- /dev/null
+++ b/archivebox/core/sqlite_backend/base.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import sqlite3
+import time
+from collections.abc import Mapping
+from itertools import tee
+import re
+
+from django.db.backends.sqlite3.base import DatabaseWrapper as DjangoSQLiteDatabaseWrapper
+from django.db.backends.sqlite3.base import SQLiteCursorWrapper as DjangoSQLiteCursorWrapper
+
+
+def _is_locked_error(error: BaseException) -> bool:
+ from django.db import OperationalError
+
+ return isinstance(error, (sqlite3.OperationalError, OperationalError)) and "database is locked" in str(error).lower()
+
+
+def _format_sql(query: str, params=None) -> str:
+ compact = " ".join(str(query).split())
+ match = re.match(r'^(INSERT INTO|UPDATE|DELETE FROM|SELECT) "?([A-Za-z0-9_]+)"?', compact, flags=re.IGNORECASE)
+ if match:
+ compact = f"{match.group(1).upper()} {match.group(2)}"
+ if params is not None:
+ if isinstance(params, str):
+ params_summary = params
+ elif isinstance(params, (tuple, list)):
+ preview = ", ".join(repr(param)[:60] for param in params[:4])
+ params_summary = f"{len(params)} params: {preview}"
+ elif isinstance(params, Mapping):
+ preview = ", ".join(f"{key}={repr(value)[:60]}" for key, value in list(params.items())[:4])
+ params_summary = f"{len(params)} params: {preview}"
+ else:
+ params_summary = repr(params)[:120]
+ compact = f"{compact} ({params_summary})"
+ return compact[:260]
+
+
+def _log_locked_database(query: str, params=None, *, attempt: int, elapsed: float) -> None:
+ from rich.console import Console
+
+ from archivebox.misc.db import sqlite_lock_holders
+
+ console = Console(stderr=True)
+ console.print(f"[yellow][*] SQLite database is locked for {elapsed:.0f}s; retrying in 5s... 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]",
+ )
+
+
+def _retry_locked_database(action, query: str, params=None):
+ attempt = 0
+ started_at = time.monotonic()
+ while True:
+ try:
+ return action()
+ except (sqlite3.OperationalError, Exception) as err:
+ if not _is_locked_error(err):
+ raise
+ attempt += 1
+ _log_locked_database(query, params, attempt=attempt, elapsed=time.monotonic() - started_at)
+ time.sleep(5.0)
+
+
+class SQLiteCursorWrapper(DjangoSQLiteCursorWrapper):
+ def execute(self, query, params=None):
+ if params is None:
+ return _retry_locked_database(lambda: super(SQLiteCursorWrapper, self).execute(query), query)
+ param_names = list(params) if isinstance(params, Mapping) else None
+ converted_query = self.convert_query(query, param_names=param_names)
+ return _retry_locked_database(
+ lambda: super(DjangoSQLiteCursorWrapper, self).execute(converted_query, params),
+ converted_query,
+ params,
+ )
+
+ def executemany(self, query, param_list):
+ peekable, param_list = tee(iter(param_list))
+ if (params := next(peekable, None)) and isinstance(params, Mapping):
+ param_names = list(params)
+ else:
+ param_names = None
+ converted_query = self.convert_query(query, param_names=param_names)
+ param_list = tuple(param_list)
+ return _retry_locked_database(
+ lambda: super(DjangoSQLiteCursorWrapper, self).executemany(converted_query, param_list),
+ converted_query,
+ f"{len(param_list)} parameter sets",
+ )
+
+
+class DatabaseWrapper(DjangoSQLiteDatabaseWrapper):
+ def create_cursor(self, name=None):
+ return self.connection.cursor(factory=SQLiteCursorWrapper)
+
+ def _commit(self):
+ return _retry_locked_database(lambda: super(DatabaseWrapper, self)._commit(), "COMMIT")
+
+ def _rollback(self):
+ return _retry_locked_database(lambda: super(DatabaseWrapper, self)._rollback(), "ROLLBACK")
diff --git a/archivebox/core/tag_utils.py b/archivebox/core/tag_utils.py
index 9f2e23e4..6851b4f5 100644
--- a/archivebox/core/tag_utils.py
+++ b/archivebox/core/tag_utils.py
@@ -104,14 +104,17 @@ def get_matching_tags(
return queryset
-def add_snapshot_counts(tags: list[Tag]) -> None:
+def add_snapshot_counts(tags: list[Tag], snapshot_queryset: QuerySet[Snapshot] | None = None) -> None:
tag_ids = [tag.pk for tag in tags]
if not tag_ids:
return
+ queryset = SnapshotTag.objects.filter(tag_id__in=tag_ids)
+ if snapshot_queryset is not None:
+ queryset = queryset.filter(snapshot_id__in=snapshot_queryset.values("id"))
counts = {
row["tag_id"]: row["num_snapshots"]
- for row in SnapshotTag.objects.filter(tag_id__in=tag_ids).values("tag_id").annotate(num_snapshots=Count("snapshot_id"))
+ for row in queryset.values("tag_id").annotate(num_snapshots=Count("snapshot_id"))
}
for tag in tags:
tag.num_snapshots = counts.get(tag.pk, 0)
diff --git a/archivebox/core/templatetags/core_tags.py b/archivebox/core/templatetags/core_tags.py
index 597d4c11..4b024b6d 100644
--- a/archivebox/core/templatetags/core_tags.py
+++ b/archivebox/core/templatetags/core_tags.py
@@ -278,6 +278,14 @@ def file_size(num_bytes: int | float) -> str:
return "{:3.1f} {}".format(num_bytes, "TB")
+@register.filter
+def intcomma(value: int | str | None) -> str:
+ try:
+ return f"{int(value or 0):,}"
+ except (TypeError, ValueError):
+ return str(value or "")
+
+
def result_list(context, cl):
"""
Monkey patched result
diff --git a/archivebox/core/urls.py b/archivebox/core/urls.py
index 819789f0..a7bc4cfe 100644
--- a/archivebox/core/urls.py
+++ b/archivebox/core/urls.py
@@ -24,6 +24,7 @@ from archivebox.core.views import (
AddView,
WebAddView,
HealthCheckView,
+ live_progress_screencast_frame_view,
live_progress_view,
)
@@ -69,6 +70,11 @@ urlpatterns = [
path("accounts/login/", RedirectView.as_view(url="/admin/login/")),
path("accounts/logout/", RedirectView.as_view(url="/admin/logout/")),
path("accounts/", include("django.contrib.auth.urls")),
+ re_path(
+ r"^admin/live-progress/screencast/(?P[0-9a-fA-F-]{8,36})\.jpg$",
+ archivebox_admin.admin_view(live_progress_screencast_frame_view),
+ name="live_progress_screencast_frame",
+ ),
path("admin/live-progress/", archivebox_admin.admin_view(live_progress_view), name="live_progress"),
path("admin/", archivebox_admin.urls),
path("api/", include("archivebox.api.urls"), name="api"),
diff --git a/archivebox/core/views.py b/archivebox/core/views.py
index 9654e6aa..8c4683bf 100644
--- a/archivebox/core/views.py
+++ b/archivebox/core/views.py
@@ -12,7 +12,7 @@ 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 FileResponse, JsonResponse, HttpRequest, HttpResponse, Http404, HttpResponseForbidden, QueryDict
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.views import View
@@ -22,6 +22,7 @@ from django.db.models import CharField, Count, Q, Prefetch, Sum
from django.db.models.functions import Cast
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
+from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.gzip import gzip_page
from django.utils.decorators import method_decorator
@@ -37,9 +38,25 @@ from archivebox.config.configset import BaseConfigSet
from archivebox.misc.util import base_url, htmlencode, ts_to_date_str, urldecode, without_fragment
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_mode, prioritize_metadata_matches, query_search_index
+from archivebox.search import (
+ get_search_backend_display_name,
+ get_search_mode,
+ get_search_mode_backend,
+ get_search_mode_base,
+ get_search_mode_options,
+ prioritize_metadata_matches,
+ query_search_index,
+)
from archivebox.core.models import ArchiveResult, Snapshot
+from archivebox.core.permissions import (
+ PERMISSIONS_PUBLIC,
+ can_view_snapshot,
+ direct_snapshots_queryset,
+ filter_personas_by_permissions,
+ is_admin_user,
+ public_snapshots_queryset,
+)
from archivebox.core.host_utils import (
build_admin_url,
build_snapshot_url,
@@ -62,6 +79,7 @@ from archivebox.hooks import (
ABX_PLUGINS_GITHUB_BASE_URL = "https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/"
LIVE_PLUGIN_BASE_URL = "/admin/environment/plugins/"
+SCREENCAST_SIGNER = TimestampSigner(salt="archivebox.live-progress.screencast")
def _get_request_config(request: HttpRequest, *, resolve_plugins: bool = False):
@@ -175,7 +193,7 @@ class SnapshotView(View):
"USES_SUBDOMAIN_ROUTING",
"ADMIN_BASE_URL",
"ARCHIVE_BASE_URL",
- "PUBLIC_SNAPSHOTS",
+ "PERMISSIONS",
"SERVER_SECURITY_MODE",
}
scoped_config_keys = set((getattr(snapshot, "config", None) or {}).keys())
@@ -278,6 +296,12 @@ class SnapshotView(View):
"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",
+ "snapshot_permissions": str(runtime_config.PERMISSIONS).strip().lower(),
+ "snapshot_permissions_icon": {
+ "public": "π₯",
+ "unlisted": "π",
+ "private": "π",
+ }[str(runtime_config.PERMISSIONS).strip().lower()],
"bookmarked_date": snapshot.bookmarked_date,
"downloaded_datestr": snapshot.downloaded_datestr,
"num_outputs": snapshot.num_outputs,
@@ -298,10 +322,6 @@ class SnapshotView(View):
return render(template_name="core/snapshot.html", request=request, context=context)
def get(self, request, path):
- request_config = _get_request_config(request)
- if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
- return _admin_login_redirect_or_forbidden(request)
-
snapshot = None
try:
@@ -318,6 +338,8 @@ class SnapshotView(View):
try:
try:
snapshot = Snapshot.objects.get(Q(timestamp=slug) | Q(id__startswith=slug))
+ if not can_view_snapshot(request, snapshot):
+ return _admin_login_redirect_or_forbidden(request)
canonical_base = snapshot.url_path
if canonical_base != snapshot.legacy_archive_path:
target_path = f"/{canonical_base}/{archivefile or 'index.html'}"
@@ -377,7 +399,7 @@ class SnapshotView(View):
snap.url,
snap.title_stripped[:64] or "",
)
- for snap in Snapshot.objects.filter(timestamp__startswith=slug)
+ for snap in direct_snapshots_queryset(request, Snapshot.objects.filter(timestamp__startswith=slug))
.only("url", "timestamp", "title", "bookmarked_at")
.order_by("-bookmarked_at")
)
@@ -436,7 +458,7 @@ class SnapshotView(View):
# slug is a URL
try:
try:
- snapshot = SnapshotView.find_snapshots_for_url(path).get()
+ snapshot = direct_snapshots_queryset(request, SnapshotView.find_snapshots_for_url(path)).get()
except Snapshot.DoesNotExist:
raise
except Snapshot.DoesNotExist:
@@ -457,7 +479,7 @@ class SnapshotView(View):
status=404,
)
except Snapshot.MultipleObjectsReturned:
- snapshots = SnapshotView.find_snapshots_for_url(path)
+ snapshots = direct_snapshots_queryset(request, SnapshotView.find_snapshots_for_url(path))
snapshot_hrefs = mark_safe(" ").join(
format_html(
'{} {} {} {} {} ',
@@ -501,11 +523,6 @@ class SnapshotPathView(View):
path: str = "",
url: str | None = None,
):
- request_config = _get_request_config(request)
-
- if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
- return _admin_login_redirect_or_forbidden(request)
-
if username == "system":
return redirect(request.path.replace("/system/", "/web/", 1))
@@ -517,7 +534,7 @@ class SnapshotPathView(View):
requested_url = domain
snapshot = None
- snapshots_qs = Snapshot.objects.select_related("crawl", "crawl__created_by")
+ snapshots_qs = direct_snapshots_queryset(request, Snapshot.objects.select_related("crawl", "crawl__created_by"))
if snapshot_id:
try:
snapshot = snapshots_qs.get(pk=snapshot_id)
@@ -672,7 +689,27 @@ def _snapshot_sort_key(match_path: str, cache: dict[str, float]) -> tuple[float,
return (cache[snapshot_id], match_path)
-def _latest_response_match(domain: str, rel_path: str, *, data_root: Path) -> tuple[Path, Path] | None:
+def _snapshot_id_from_replay_path(path: Path) -> str | None:
+ parts = path.parts
+ try:
+ responses_idx = parts.index("responses")
+ except ValueError:
+ return None
+ return parts[responses_idx - 1] if responses_idx > 0 else None
+
+
+def _replay_path_visible(request: HttpRequest, path: Path) -> bool:
+ snapshot_id = _snapshot_id_from_replay_path(path)
+ if not snapshot_id:
+ return False
+ 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)
+ return True
+
+
+def _latest_response_match(request: HttpRequest, domain: str, rel_path: str, *, data_root: Path) -> tuple[Path, Path] | None:
if not domain or not rel_path:
return None
domain = domain.split(":", 1)[0].lower()
@@ -685,8 +722,10 @@ def _latest_response_match(domain: str, rel_path: str, *, data_root: Path) -> tu
return None
sort_cache: dict[str, float] = {}
- best = max(matches, key=lambda match_path: _snapshot_sort_key(match_path, sort_cache))
- best_path = Path(best)
+ best_paths = sorted(matches, key=lambda match_path: _snapshot_sort_key(match_path, sort_cache), reverse=True)
+ best_path = next((Path(match_path) for match_path in best_paths if _replay_path_visible(request, Path(match_path))), None)
+ if best_path is None:
+ return None
parts = best_path.parts
try:
responses_idx = parts.index("responses")
@@ -697,7 +736,7 @@ def _latest_response_match(domain: str, rel_path: str, *, data_root: Path) -> tu
return responses_root, rel_to_root
-def _latest_responses_root(domain: str, *, data_root: Path) -> Path | None:
+def _latest_responses_root(request: HttpRequest, domain: str, *, data_root: Path) -> Path | None:
if not domain:
return None
domain = domain.split(":", 1)[0].lower()
@@ -708,16 +747,19 @@ def _latest_responses_root(domain: str, *, data_root: Path) -> Path | None:
return None
sort_cache: dict[str, float] = {}
- best = max(matches, key=lambda match_path: _snapshot_sort_key(match_path, sort_cache))
- return Path(best)
+ best_paths = sorted(matches, key=lambda match_path: _snapshot_sort_key(match_path, sort_cache), reverse=True)
+ return next((Path(match_path) for match_path in best_paths if _replay_path_visible(request, Path(match_path))), None)
-def _latest_snapshot_for_domain(domain: str) -> Snapshot | None:
+def _latest_snapshot_for_domain(request: HttpRequest, domain: str) -> Snapshot | None:
if not domain:
return None
requested_domain = domain.split(":", 1)[0].lower()
- snapshots = SnapshotView.find_snapshots_for_url(f"https://{requested_domain}").order_by("-bookmarked_at", "-created_at", "-timestamp")
+ snapshots = direct_snapshots_queryset(
+ request,
+ SnapshotView.find_snapshots_for_url(f"https://{requested_domain}"),
+ ).order_by("-bookmarked_at", "-created_at", "-timestamp")
for snapshot in snapshots:
if Snapshot.extract_domain_from_url(snapshot.url).lower() == requested_domain:
return snapshot
@@ -774,7 +816,8 @@ 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_request_config(request)
+ request_config = get_config(snapshot=snapshot, resolve_plugins=False)
+ request.archivebox_config = request_config
snapshot._runtime_config = request_config
rel_path = path or ""
is_directory_request = bool(path) and path.endswith("/")
@@ -823,13 +866,13 @@ def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str =
raise Http404
domain = domain.lower()
- match = _latest_response_match(domain, rel_path, data_root=request_config.USERS_DIR)
+ match = _latest_response_match(request, domain, rel_path, data_root=request_config.USERS_DIR)
if not match and "." not in Path(rel_path).name:
index_path = f"{rel_path.rstrip('/')}/index.html"
- match = _latest_response_match(domain, index_path, data_root=request_config.USERS_DIR)
+ match = _latest_response_match(request, domain, index_path, data_root=request_config.USERS_DIR)
if not match and "." not in Path(rel_path).name:
html_path = f"{rel_path}.html"
- match = _latest_response_match(domain, html_path, data_root=request_config.USERS_DIR)
+ match = _latest_response_match(request, domain, html_path, data_root=request_config.USERS_DIR)
show_indexes = bool(request.GET.get("files"))
if match:
@@ -838,14 +881,14 @@ def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str =
if response is not None:
return response
- responses_root = _latest_responses_root(domain, data_root=request_config.USERS_DIR)
+ responses_root = _latest_responses_root(request, domain, data_root=request_config.USERS_DIR)
if responses_root:
response = _serve_responses_path(request, responses_root, rel_path, show_indexes)
if response is not None:
return response
if requested_root_index and not show_indexes:
- snapshot = _latest_snapshot_for_domain(domain)
+ snapshot = _latest_snapshot_for_domain(request, domain)
if snapshot:
return SnapshotView.render_live_index(request, snapshot)
@@ -861,12 +904,12 @@ class SnapshotHostView(View):
def get(self, request, snapshot_id: str, path: str = ""):
request_config = _get_request_config(request)
- if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
- return _admin_login_redirect_or_forbidden(request)
snapshot = _find_snapshot_by_ref(snapshot_id)
if not snapshot:
raise Http404
+ if not can_view_snapshot(request, snapshot):
+ return _admin_login_redirect_or_forbidden(request)
canonical_host = get_snapshot_host(str(snapshot.id), config=request_config)
if not host_matches(request.get_host(), canonical_host):
@@ -882,13 +925,11 @@ class SnapshotReplayView(View):
"""Serve snapshot directory contents on a one-domain replay path."""
def get(self, request, snapshot_id: str, path: str = ""):
- request_config = _get_request_config(request)
- if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
- return _admin_login_redirect_or_forbidden(request)
-
snapshot = _find_snapshot_by_ref(snapshot_id)
if not snapshot:
raise Http404
+ if not can_view_snapshot(request, snapshot):
+ return _admin_login_redirect_or_forbidden(request)
return _serve_snapshot_replay(request, snapshot, path)
@@ -897,9 +938,6 @@ class OriginalDomainHostView(View):
"""Serve responses from the most recent snapshot when using ./."""
def get(self, request, domain: str, path: str = ""):
- request_config = _get_request_config(request)
- if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
- return _admin_login_redirect_or_forbidden(request)
return _serve_original_domain_replay(request, domain, path)
@@ -907,9 +945,6 @@ class OriginalDomainReplayView(View):
"""Serve original-domain replay content on a one-domain replay path."""
def get(self, request, domain: str, path: str = ""):
- request_config = _get_request_config(request)
- if not request.user.is_authenticated and not request_config.PUBLIC_SNAPSHOTS:
- return _admin_login_redirect_or_forbidden(request)
return _serve_original_domain_replay(request, domain, path)
@@ -928,6 +963,8 @@ class PublicIndexView(ListView):
runtime_config = getattr(self, "runtime_config", None)
if runtime_config is None:
self.runtime_config = runtime_config = _get_request_config(self.request, resolve_plugins=True)
+ search_mode = get_search_mode(self.request.GET.get("search_mode"), config=runtime_config)
+ search_mode_backend = get_search_mode_backend(search_mode, config=runtime_config)
context = {
**super().get_context_data(**kwargs),
"VERSION": VERSION,
@@ -935,21 +972,27 @@ class PublicIndexView(ListView):
"COMMIT_HASH": runtime_config.COMMIT_HASH,
"FOOTER_INFO": runtime_config.FOOTER_INFO,
"WEB_BASE_URL": build_web_url(request=self.request, config=runtime_config),
- "search_mode": get_search_mode(self.request.GET.get("search_mode")),
+ "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")
+ and get_search_mode_base(search_mode, config=runtime_config) == "deep"
+ and search_mode_backend
+ and getattr(context.get("paginator"), "count", 0) == 0
+ )
for snapshot in context.get("object_list") or ():
snapshot._icons_compact = True
snapshot._is_archived_cached = bool(snapshot.downloaded_at or snapshot.status == Snapshot.StatusChoices.SEALED)
results = getattr(snapshot, "_prefetched_objects_cache", {}).get("archiveresult_set")
if results is not None:
- snapshot.output_size_sum = sum(result.output_size or 0 for result in results)
snapshot.num_outputs_cached = len(results)
return context
def get_queryset(self, **kwargs):
qs = (
- super()
- .get_queryset(**kwargs)
+ public_snapshots_queryset(super().get_queryset(**kwargs))
.prefetch_related(
Prefetch("crawl", queryset=Crawl.objects.select_related("created_by")),
"tags",
@@ -970,24 +1013,30 @@ class PublicIndexView(ListView):
if not query:
return qs
- search_mode = get_search_mode(self.request.GET.get("search_mode"))
+ 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),
)
- if search_mode == "meta":
+ 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:
- qs = prioritize_metadata_matches(
- qs,
- metadata_qs,
- query_search_index(query, search_mode=search_mode),
- ordering=self.ordering,
- )
+ 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 = metadata_qs
+ qs = qs.none() if search_mode_backend else metadata_qs
return qs.distinct()
@@ -1015,12 +1064,16 @@ class AddView(UserPassesTestMixin, FormView):
return super().get_initial()
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["request"] = self.request
+ return kwargs
+
def test_func(self):
return _get_request_config(self.request).PUBLIC_ADD_VIEW or self.request.user.is_authenticated
def _can_override_crawl_config(self) -> bool:
- user = self.request.user
- return bool(user.is_authenticated and (getattr(user, "is_superuser", False) or getattr(user, "is_staff", False)))
+ return is_admin_user(self.request)
def _get_custom_config_overrides(self, form: AddLinkForm) -> dict:
custom_config = form.cleaned_data.get("config") or {}
@@ -1034,35 +1087,55 @@ class AddView(UserPassesTestMixin, FormView):
return custom_config
def get_context_data(self, **kwargs):
- from archivebox.personas.models import Persona
-
+ context = super().get_context_data(**kwargs)
request_config = _get_request_config(self.request, resolve_plugins=True)
required_search_plugin = f"search_backend_{request_config.SEARCH_BACKEND_ENGINE}".strip()
- plugin_configs = discover_plugin_configs()
+ can_override_crawl_config = self._can_override_crawl_config()
+ plugin_configs = discover_plugin_configs() if can_override_crawl_config else {}
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")
}
+ public_persona_config_keys = {
+ "CRAWL_MAX_CONCURRENT_SNAPSHOTS",
+ "DELETE_AFTER",
+ "PERMISSIONS",
+ "TIMEOUT",
+ }
+ persona_queryset = context["form"].fields["persona"].queryset
+ if not can_override_crawl_config:
+ persona_queryset = filter_personas_by_permissions(persona_queryset, {PERMISSIONS_PUBLIC})
persona_config_map = {}
- for persona in Persona.objects.order_by("name"):
- raw_config = {str(key): value for key, value in (persona.config or {}).items() if str(key) not in sensitive_keys}
+ for persona in persona_queryset.order_by("name"):
effective_config = get_config(persona=persona)
+ if can_override_crawl_config:
+ raw_config = {str(key): value for key, value in (persona.config or {}).items() if str(key) not in sensitive_keys}
+ effective_config_json = {str(key): value for key, value in effective_config.items() if str(key) not in sensitive_keys}
+ 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}
+ binary_urls = {}
persona_config_map[persona.name] = {
"config": raw_config,
- "effective_config": {str(key): value for key, value in effective_config.items() if str(key) not in sensitive_keys},
- "binary_urls": get_plugin_config_binary_urls(effective_config),
+ "effective_config": effective_config_json,
+ "binary_urls": binary_urls,
+ }
+ plugin_dependency_map = {}
+ if can_override_crawl_config:
+ plugin_dependency_map = {
+ plugin_name: [
+ str(required_plugin).strip()
+ for required_plugin in (schema.get("required_plugins") or [])
+ if str(required_plugin).strip()
+ ]
+ for plugin_name, schema in plugin_configs.items()
+ if isinstance(schema.get("required_plugins"), list) and schema.get("required_plugins")
}
- plugin_dependency_map = {
- plugin_name: [
- str(required_plugin).strip() for required_plugin in (schema.get("required_plugins") or []) if str(required_plugin).strip()
- ]
- for plugin_name, schema in plugin_configs.items()
- if isinstance(schema.get("required_plugins"), list) and schema.get("required_plugins")
- }
return {
- **super().get_context_data(**kwargs),
+ **context,
"title": "Create Crawl",
# We can't just call request.build_absolute_uri in the template, because it would include query parameters
"absolute_add_path": self.request.build_absolute_uri(self.request.path),
@@ -1071,6 +1144,7 @@ class AddView(UserPassesTestMixin, FormView):
"required_search_plugin": required_search_plugin,
"plugin_dependency_map_json": json.dumps(plugin_dependency_map, sort_keys=True),
"persona_config_map_json": json.dumps(persona_config_map, sort_keys=True, default=str),
+ "can_override_crawl_config": can_override_crawl_config,
"stdout": "",
}
@@ -1083,20 +1157,27 @@ class AddView(UserPassesTestMixin, FormView):
depth = int(form.cleaned_data["depth"])
max_urls = int(form.cleaned_data.get("max_urls") or 0)
crawl_max_size = int(form.cleaned_data.get("crawl_max_size") or 0)
+ crawl_timeout = int(form.cleaned_data.get("crawl_timeout") or 0)
+ timeout = form.cleaned_data.get("timeout")
snapshot_max_size = int(form.cleaned_data.get("snapshot_max_size") or 0)
delete_after = str(form.cleaned_data.get("delete_after") or "0").strip() or "0"
crawl_max_concurrent_snapshots = int(form.cleaned_data["crawl_max_concurrent_snapshots"])
- plugins = ",".join(form.cleaned_data.get("plugins", []))
- schedule = form.cleaned_data.get("schedule", "").strip()
+ permissions = str(form.cleaned_data.get("permissions") or "public").strip().lower()
+ can_override_crawl_config = self._can_override_crawl_config()
+ plugins = ",".join(form.cleaned_data.get("plugins", [])) if can_override_crawl_config else ""
+ schedule = form.cleaned_data.get("schedule", "").strip() if can_override_crawl_config else ""
persona = form.cleaned_data.get("persona")
- index_only = form.cleaned_data.get("index_only", False)
+ index_only = form.cleaned_data.get("index_only", False) if can_override_crawl_config else False
notes = form.cleaned_data.get("notes", "")
url_filters = form.cleaned_data.get("url_filters") or {}
plugin_config = form.cleaned_data.get("plugin_config") or {}
if not isinstance(plugin_config, dict):
plugin_config = {}
+ if not can_override_crawl_config:
+ plugin_config = {}
custom_config = self._get_custom_config_overrides(form)
custom_config.pop("DEFAULT_PERSONA", None)
+ custom_config.pop("PERMISSIONS", None)
if persona:
persona.ensure_dirs()
@@ -1132,6 +1213,18 @@ class AddView(UserPassesTestMixin, FormView):
config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] = crawl_max_concurrent_snapshots
if delete_after != str(effective_config.DELETE_AFTER):
config["DELETE_AFTER"] = delete_after
+ if permissions != str(effective_config.PERMISSIONS):
+ config["PERMISSIONS"] = permissions
+ if max_urls:
+ config["CRAWL_MAX_URLS"] = max_urls
+ if crawl_max_size:
+ config["CRAWL_MAX_SIZE"] = crawl_max_size
+ if crawl_timeout:
+ config["CRAWL_TIMEOUT"] = crawl_timeout
+ if timeout is not None and int(timeout) != int(effective_config.TIMEOUT):
+ config["TIMEOUT"] = int(timeout)
+ if snapshot_max_size:
+ config["SNAPSHOT_MAX_SIZE"] = snapshot_max_size
# Merge custom config overrides
config.update(plugin_config)
@@ -1144,9 +1237,6 @@ class AddView(UserPassesTestMixin, FormView):
crawl = Crawl.objects.create(
urls=urls_content,
max_depth=depth,
- max_urls=max_urls,
- crawl_max_size=crawl_max_size,
- snapshot_max_size=snapshot_max_size,
tags_str=tag,
notes=notes,
label=f"{created_by_name}@{HOSTNAME}{self.request.path} {timestamp}",
@@ -1175,11 +1265,6 @@ class AddView(UserPassesTestMixin, FormView):
ensure_background_runner()
- # 4. start the Orchestrator & wait until it completes
- # ... orchestrator will create the root Snapshot, which creates pending ArchiveResults, which gets run by the ArchiveResultActors ...
- # from archivebox.crawls.actors import CrawlActor
- # from archivebox.core.actors import SnapshotActor, ArchiveResultActor
-
return crawl
def form_valid(self, form):
@@ -1191,7 +1276,7 @@ class AddView(UserPassesTestMixin, FormView):
# Build success message with schedule link if created
schedule_msg = ""
- if schedule:
+ if schedule and crawl.schedule_id:
schedule_msg = f" and scheduled to repeat {schedule} "
messages.success(
@@ -1207,7 +1292,10 @@ class AddView(UserPassesTestMixin, FormView):
class WebAddView(AddView):
def _latest_snapshot_for_url(self, requested_url: str):
- return SnapshotView.find_snapshots_for_url(requested_url).order_by("-bookmarked_at", "-created_at", "-timestamp").first()
+ return direct_snapshots_queryset(
+ self.request,
+ SnapshotView.find_snapshots_for_url(requested_url),
+ ).order_by("-bookmarked_at", "-created_at", "-timestamp").first()
def _normalize_add_url(self, requested_url: str) -> str:
if requested_url.startswith(("http://", "https://")):
@@ -1263,10 +1351,13 @@ class WebAddView(AddView):
"depth": defaults_form.fields["depth"].initial or "0",
"max_urls": defaults_form.fields["max_urls"].initial or 0,
"crawl_max_size": defaults_form.fields["crawl_max_size"].initial or "0",
+ "crawl_timeout": defaults_form.fields["crawl_timeout"].initial or 0,
+ "timeout": defaults_form.fields["timeout"].initial or 0,
"snapshot_max_size": defaults_form.fields["snapshot_max_size"].initial or "0",
"delete_after": defaults_form.fields["delete_after"].initial or "0",
"crawl_max_concurrent_snapshots": defaults_form.fields["crawl_max_concurrent_snapshots"].initial,
"persona": defaults_form.fields["persona"].initial or "Default",
+ "permissions": defaults_form.fields["permissions"].initial or "public",
"config": "{}",
},
)
@@ -1295,6 +1386,37 @@ class HealthCheckView(View):
return HttpResponse("OK", content_type="text/plain", status=200)
+def live_progress_screencast_frame_view(request, snapshot_id: str):
+ """Serve cache-only Chrome screencast frames through the admin app."""
+ if not is_admin_user(request):
+ return HttpResponseForbidden("Permission denied")
+
+ token = request.GET.get("token", "")
+ try:
+ if SCREENCAST_SIGNER.unsign(token, max_age=60) != str(snapshot_id):
+ return HttpResponseForbidden("Permission denied")
+ except (BadSignature, SignatureExpired):
+ return HttpResponseForbidden("Permission denied")
+
+ snapshot = Snapshot.objects.filter(id=snapshot_id).select_related("crawl", "crawl__created_by").first()
+ if not snapshot:
+ raise Http404
+
+ live_root = (CONSTANTS.CACHE_DIR / "chrome_screencast").resolve()
+ frame_path = live_root / str(snapshot.id) / "latest.jpg"
+ try:
+ resolved_frame_path = frame_path.resolve(strict=True)
+ except FileNotFoundError:
+ raise Http404 from None
+ if not resolved_frame_path.is_file() or live_root not in resolved_frame_path.parents:
+ raise Http404
+
+ response = FileResponse(resolved_frame_path.open("rb"), content_type="image/jpeg")
+ response["Cache-Control"] = "no-store, max-age=0"
+ response["X-Content-Type-Options"] = "nosniff"
+ return response
+
+
@gzip_page
def live_progress_view(request):
"""Simple JSON endpoint for live progress status - used by admin progress monitor."""
@@ -1456,18 +1578,20 @@ def live_progress_view(request):
else None
)
runner_worker = None
- try:
- from archivebox.workers.supervisord_util import get_existing_supervisord_process, get_worker
+ 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()
- runner_worker = get_worker(supervisor, "worker_runner") if supervisor else None
- except Exception:
- runner_worker = None
+ 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 is not None or runner_worker_running
- orchestrator_pid = orchestrator_proc.pid if orchestrator_proc else runner_worker_pid
+ 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]:
counts = {status: 0 for status in statuses}
@@ -1476,9 +1600,13 @@ def live_progress_view(request):
return counts
# Get model counts by status
- crawl_status_counts = count_statuses(crawl_scope, (Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED))
+ 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
@@ -1487,23 +1615,31 @@ def live_progress_view(request):
recently_cancelled_after = now - timedelta(minutes=10)
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_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)
archiveresult_status_counts = count_statuses(
archiveresult_scope,
(
ArchiveResult.StatusChoices.QUEUED,
ArchiveResult.StatusChoices.STARTED,
+ ArchiveResult.StatusChoices.PAUSED,
),
)
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
# Build hierarchical active crawls with nested snapshots and archive results
+ max_progress_crawls = 3
+ max_progress_snapshots = 50
active_crawl_fields = (
"id",
@@ -1513,9 +1649,6 @@ def live_progress_view(request):
"urls",
"config",
"max_depth",
- "max_urls",
- "crawl_max_size",
- "snapshot_max_size",
"tags_str",
"persona_id",
"status",
@@ -1525,18 +1658,23 @@ def live_progress_view(request):
"created_by__username",
)
active_crawl_candidates = []
- for status in (Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED, Crawl.StatusChoices.SEALED):
+ for status in (Crawl.StatusChoices.STARTED, Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.PAUSED, Crawl.StatusChoices.SEALED):
status_qs = crawl_scope.filter(status=status)
if status == Crawl.StatusChoices.SEALED:
status_qs = status_qs.filter(modified_at__gte=recently_cancelled_after)
active_crawl_candidates.extend(
- status_qs.values(*active_crawl_fields).order_by("-modified_at"),
+ status_qs.values(*active_crawl_fields).order_by("-modified_at")[:max_progress_crawls],
)
+ crawl_status_priority = {
+ Crawl.StatusChoices.STARTED: 0,
+ Crawl.StatusChoices.QUEUED: 1,
+ Crawl.StatusChoices.PAUSED: 2,
+ Crawl.StatusChoices.SEALED: 3,
+ }
active_crawls_list = sorted(
{str(crawl["id"]): crawl for crawl in active_crawl_candidates}.values(),
- key=lambda crawl: crawl["modified_at"],
- reverse=True,
- )
+ key=lambda crawl: (crawl_status_priority.get(crawl["status"], 9), -(crawl["modified_at"].timestamp() if crawl["modified_at"] else 0)),
+ )[:max_progress_crawls]
for crawl in active_crawls_list:
crawl["id"] = str(crawl["id"])
if crawl["persona_id"]:
@@ -1558,6 +1696,10 @@ def live_progress_view(request):
persona_details_by_id[str(persona.id)] = persona_details
persona_details_by_name[persona.name] = persona_details
active_crawl_ids = [crawl["id"] for crawl in active_crawls_list]
+ active_crawl_objects = {
+ str(crawl.id): crawl
+ for crawl in Crawl.objects.filter(id__in=active_crawl_ids).select_related("created_by")
+ }
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}
@@ -1588,7 +1730,6 @@ def live_progress_view(request):
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()
- active_snapshot_statuses = {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED}
recently_cancelled_snapshots_q = Q(
status=Snapshot.StatusChoices.SEALED,
downloaded_at__isnull=True,
@@ -1596,21 +1737,7 @@ def live_progress_view(request):
)
crawls_by_id = {str(crawl["id"]): crawl for crawl in active_crawls_list}
snapshots = list(
- active_snapshot_scope.filter(status=Snapshot.StatusChoices.QUEUED)
- .annotate(id_str=Cast("id", CharField()), crawl_id_str=Cast("crawl_id", CharField()))
- .values(
- "id_str",
- "url",
- "crawl_id_str",
- "title",
- "status",
- )
- .order_by("crawl_id", "modified_at"),
- )
- snapshots.extend(
- active_snapshot_scope.filter(
- Q(status__in=active_snapshot_statuses - {Snapshot.StatusChoices.QUEUED}) | recently_cancelled_snapshots_q,
- )
+ active_snapshot_scope.filter(status=Snapshot.StatusChoices.STARTED)
.annotate(id_str=Cast("id", CharField()), crawl_id_str=Cast("crawl_id", CharField()))
.values(
"id_str",
@@ -1625,8 +1752,42 @@ def live_progress_view(request):
"fs_version",
"status",
)
- .order_by("crawl_id", "status", "modified_at"),
+ .order_by("crawl_id", "-modified_at")[:max_progress_snapshots],
)
+ remaining_snapshot_slots = max_progress_snapshots - len(snapshots)
+ if remaining_snapshot_slots > 0:
+ snapshots.extend(
+ active_snapshot_scope.filter(status=Snapshot.StatusChoices.QUEUED)
+ .annotate(id_str=Cast("id", CharField()), crawl_id_str=Cast("crawl_id", CharField()))
+ .values(
+ "id_str",
+ "url",
+ "crawl_id_str",
+ "title",
+ "status",
+ )
+ .order_by("crawl_id", "modified_at")[:remaining_snapshot_slots],
+ )
+ remaining_snapshot_slots = max_progress_snapshots - len(snapshots)
+ if remaining_snapshot_slots > 0:
+ snapshots.extend(
+ active_snapshot_scope.filter(recently_cancelled_snapshots_q)
+ .annotate(id_str=Cast("id", CharField()), crawl_id_str=Cast("crawl_id", CharField()))
+ .values(
+ "id_str",
+ "created_at",
+ "modified_at",
+ "url",
+ "timestamp",
+ "bookmarked_at",
+ "crawl_id_str",
+ "title",
+ "downloaded_at",
+ "fs_version",
+ "status",
+ )
+ .order_by("crawl_id", "-modified_at")[:remaining_snapshot_slots],
+ )
def dashed_uuid(value: str) -> str:
value = str(value)
@@ -1762,7 +1923,7 @@ def live_progress_view(request):
if proc["status"] == Process.StatusChoices.RUNNING
else (
"skipped"
- if proc["exit_code"] == PROCESS_EXIT_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")
)
)
@@ -1839,6 +2000,8 @@ def live_progress_view(request):
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")
@@ -1859,6 +2022,17 @@ def live_progress_view(request):
elif snapshot_favicon_url:
snapshot_preview_url = snapshot_favicon_url
+ if snapshot["status"] == Snapshot.StatusChoices.STARTED:
+ live_preview_path = CONSTANTS.CACHE_DIR / "chrome_screencast" / str(snapshot["id"]) / "latest.jpg"
+ try:
+ live_preview_stat = live_preview_path.stat()
+ except OSError:
+ live_preview_stat = None
+ if live_preview_stat and live_preview_stat.st_size > 0:
+ token = SCREENCAST_SIGNER.sign(str(snapshot["id"]))
+ snapshot_screencast_url = f"/admin/live-progress/screencast/{snapshot['id']}.jpg?v={live_preview_stat.st_mtime_ns}&token={quote(token)}"
+ snapshot_screencast_link = snapshot_view_url(snapshot)
+
def plugin_sort_key(ar):
status_order = {
ArchiveResult.StatusChoices.STARTED: 0,
@@ -1989,6 +2163,9 @@ def live_progress_view(request):
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"])):
@@ -2005,17 +2182,25 @@ def live_progress_view(request):
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
+ effective_crawl_config = get_config(crawl=active_crawl_objects[crawl_id])
+ 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
- seconds_until_retry = int((crawl["retry_at"] - now).total_seconds()) if crawl["retry_at"] and retry_at_future else 0
+ is_paused = active_crawl_objects[crawl_id].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 crawl["status"] == Crawl.StatusChoices.SEALED and cancelled_snapshots:
+ if is_paused:
+ crawl_worker_state = "paused"
+ elif crawl["status"] == Crawl.StatusChoices.SEALED and cancelled_snapshots:
crawl_worker_state = "cancelled"
elif (
crawl["status"] == Crawl.StatusChoices.STARTED
@@ -2029,19 +2214,20 @@ def live_progress_view(request):
"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": crawl["max_urls"],
- "max_crawl_size": crawl["crawl_max_size"],
- "max_snapshot_size": crawl["snapshot_max_size"],
- "max_crawl_size_display": printable_filesize(crawl["crawl_max_size"]) if crawl["crawl_max_size"] else "unlimited",
- "max_snapshot_size_display": printable_filesize(crawl["snapshot_max_size"])
- if crawl["snapshot_max_size"]
- else "unlimited",
+ "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",
@@ -2075,11 +2261,14 @@ def live_progress_view(request):
"total_workers": total_workers,
"crawls_pending": crawls_pending,
"crawls_started": crawls_started,
+ "crawls_paused": crawls_paused,
"crawls_recent": crawls_recent,
"snapshots_pending": snapshots_pending,
"snapshots_started": snapshots_started,
+ "snapshots_paused": snapshots_paused,
"archiveresults_pending": archiveresults_pending,
"archiveresults_started": archiveresults_started,
+ "archiveresults_paused": archiveresults_paused,
"archiveresults_succeeded": archiveresults_succeeded,
"archiveresults_failed": archiveresults_failed,
"active_crawls": active_crawls,
@@ -2103,11 +2292,14 @@ def live_progress_view(request):
"total_workers": 0,
"crawls_pending": 0,
"crawls_started": 0,
+ "crawls_paused": 0,
"crawls_recent": 0,
"snapshots_pending": 0,
"snapshots_started": 0,
+ "snapshots_paused": 0,
"archiveresults_pending": 0,
"archiveresults_started": 0,
+ "archiveresults_paused": 0,
"archiveresults_succeeded": 0,
"archiveresults_failed": 0,
"active_crawls": [],
diff --git a/archivebox/core/widgets.py b/archivebox/core/widgets.py
index e7d43e0f..f79e9c08 100644
--- a/archivebox/core/widgets.py
+++ b/archivebox/core/widgets.py
@@ -402,7 +402,7 @@ class URLFiltersWidget(forms.Widget):
π’ URL_ALLOWLIST
- Regex patterns or domains to exclude, one pattern per line.
+ Regex patterns or domains to include, one pattern per line.
- ''')
-
- def value_from_datadict(self, data, files, name):
- return {
- "allowlist": data.get(f"{name}_allowlist", ""),
- "denylist": data.get(f"{name}_denylist", ""),
- "same_domain_only": data.get(f"{name}_same_domain_only") in ("1", "on", "true"),
- }
-
-
class URLFiltersField(forms.Field):
- widget = URLFiltersWidget
+ widget = URLFiltersWidget(source_selector="#id_urls")
def to_python(self, value):
if isinstance(value, dict):
return value
- return {"allowlist": "", "denylist": "", "same_domain_only": False}
+ return {"allowlist": "", "denylist": "", "same_domain_only": False, "subpaths_only": False}
class CrawlAdminForm(forms.ModelForm):
@@ -416,6 +429,7 @@ class CrawlAdminForm(forms.ModelForm):
"allowlist": config.get("URL_ALLOWLIST", ""),
"denylist": config.get("URL_DENYLIST", ""),
"same_domain_only": False,
+ "subpaths_only": False,
}
def clean_tags_editor(self):
@@ -439,16 +453,18 @@ class CrawlAdminForm(forms.ModelForm):
"allowlist": "\n".join(Crawl.split_filter_patterns(value.get("allowlist", ""))),
"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")),
}
def save(self, commit=True):
instance = super().save(commit=False)
instance.tags_str = self.cleaned_data.get("tags_editor", "")
- url_filters = self.cleaned_data.get("url_filters") or {}
- instance.set_url_filters(
- url_filters.get("allowlist", ""),
- url_filters.get("denylist", ""),
- )
+ if f"{self.add_prefix('url_filters')}_allowlist" in self.data or f"{self.add_prefix('url_filters')}_denylist" in self.data:
+ url_filters = self.cleaned_data.get("url_filters") or {}
+ instance.set_url_filters(
+ url_filters.get("allowlist", ""),
+ url_filters.get("denylist", ""),
+ )
if commit:
instance.save()
instance.apply_crawl_config_filters()
@@ -460,15 +476,16 @@ class CrawlAdminForm(forms.ModelForm):
class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
form = CrawlAdminForm
+ change_form_template = "admin/crawls/crawl/change_form.html"
list_select_related = ()
list_display = (
"id",
"created_at",
"created_by",
"max_depth",
- "max_urls",
- "crawl_max_size",
- "snapshot_max_size",
+ "stop_reason_badge",
+ "pause_control",
+ "resume_control",
"label",
"notes",
"urls_preview",
@@ -483,9 +500,6 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
"created_at",
"created_by",
"max_depth",
- "max_urls",
- "crawl_max_size",
- "snapshot_max_size",
"label",
"notes",
"schedule_str",
@@ -496,9 +510,6 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
"id",
"created_by__username",
"max_depth",
- "max_urls",
- "crawl_max_size",
- "snapshot_max_size",
"label",
"notes",
"schedule_id",
@@ -506,56 +517,33 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
"urls",
)
- readonly_fields = ("created_at", "modified_at", "snapshots")
+ readonly_fields = ("created_at", "modified_at", "stop_reason_display")
fieldsets = (
(
"URLs",
{
- "fields": ("urls",),
+ "fields": ("urls", "url_filters"),
"classes": ("card", "wide"),
},
),
(
- "Info",
+ "Overview",
{
- "fields": ("label", "notes", "tags_editor"),
- "classes": ("card",),
+ "fields": (
+ ("label", "status", "retry_at", "schedule", "created_by", "created_at", "modified_at"),
+ ("max_depth",),
+ ("stop_reason_display",),
+ ("notes", "tags_editor"),
+ ),
+ "classes": ("card", "wide", "crawl-admin-overview"),
},
),
(
- "Settings",
+ "Config",
{
- "fields": (("max_depth", "max_urls", "crawl_max_size", "snapshot_max_size"), "url_filters", "config"),
- "classes": ("card",),
- },
- ),
- (
- "Status",
- {
- "fields": ("status", "retry_at"),
- "classes": ("card",),
- },
- ),
- (
- "Relations",
- {
- "fields": ("schedule", "created_by"),
- "classes": ("card",),
- },
- ),
- (
- "Timestamps",
- {
- "fields": ("created_at", "modified_at"),
- "classes": ("card",),
- },
- ),
- (
- "Snapshots",
- {
- "fields": ("snapshots",),
- "classes": ("card", "wide"),
+ "fields": ("config",),
+ "classes": ("card", "wide", "crawl-admin-config"),
},
),
)
@@ -563,36 +551,26 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
(
"URLs",
{
- "fields": ("urls",),
+ "fields": ("urls", "url_filters"),
"classes": ("card", "wide"),
},
),
(
- "Info",
+ "Overview",
{
- "fields": ("label", "notes", "tags_editor"),
- "classes": ("card",),
+ "fields": (
+ ("label", "status", "retry_at", "schedule", "created_by"),
+ ("max_depth",),
+ ("notes", "tags_editor"),
+ ),
+ "classes": ("card", "wide", "crawl-admin-overview"),
},
),
(
- "Settings",
+ "Config",
{
- "fields": (("max_depth", "max_urls", "crawl_max_size", "snapshot_max_size"), "url_filters", "config"),
- "classes": ("card",),
- },
- ),
- (
- "Status",
- {
- "fields": ("status", "retry_at"),
- "classes": ("card",),
- },
- ),
- (
- "Relations",
- {
- "fields": ("schedule", "created_by"),
- "classes": ("card",),
+ "fields": ("config",),
+ "classes": ("card", "wide", "crawl-admin-config"),
},
),
)
@@ -600,9 +578,13 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
list_filter = (MaxDepthListFilter, "schedule", "created_by", "status", "retry_at")
ordering = ["-created_at", "-retry_at"]
list_per_page = 50
- actions = ["delete_selected_batched"]
+ actions = ["pause_selected_crawls", "resume_selected_crawls", "delete_selected_batched"]
change_actions = ["recrawl"]
+ class Media:
+ css = {"all": ("admin/crawls/crawl_change.css",)}
+ js = ("admin/crawls/crawl_admin.js",)
+
def get_queryset(self, request):
"""Keep joins page-local while computing per-row snapshot counts in the page query."""
snapshot_count = (
@@ -623,6 +605,20 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
)
)
+ def change_view(self, request, object_id, form_url="", extra_context=None):
+ self.request = request
+ crawl = self.get_object(request, object_id)
+ extra_context = {
+ **(extra_context or {}),
+ "crawl_stop_reason": crawl.limit_stop_reason() if crawl else "",
+ "crawl_snapshots_changelist": self.snapshots_changelist(crawl) if crawl else "",
+ }
+ return super().change_view(request, object_id, form_url, extra_context)
+
+ def add_view(self, request, form_url="", extra_context=None):
+ self.request = request
+ return super().add_view(request, form_url, extra_context)
+
def get_fieldsets(self, request, obj=None):
return self.fieldsets if obj else self.add_fieldsets
@@ -658,6 +654,29 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
messages.success(request, f"Successfully deleted {total} crawls ({deleted_count} total objects including related records).")
+ @admin.action(description="Pause selected crawls")
+ def pause_selected_crawls(self, request, queryset):
+ paused = 0
+ for crawl in queryset.exclude(status=Crawl.StatusChoices.SEALED).iterator(chunk_size=100):
+ paused += int(crawl.pause())
+ if paused:
+ messages.success(request, f"Paused {paused} crawl(s). The runner will stop scheduling new work on the next sweep.")
+ else:
+ messages.warning(request, "No active crawls were selected to pause.")
+
+ @admin.action(description="Resume selected crawls")
+ def resume_selected_crawls(self, request, queryset):
+ resumed = 0
+ for crawl in queryset.iterator(chunk_size=100):
+ if crawl.status == Crawl.StatusChoices.SEALED:
+ crawl.status = Crawl.StatusChoices.PAUSED
+ crawl.save(update_fields=["status", "modified_at"])
+ resumed += int(crawl.resume())
+ if resumed:
+ messages.success(request, f"Resumed {resumed} crawl(s). The runner will pick them up on the next sweep.")
+ else:
+ messages.warning(request, "No paused or sealed crawls were selected to resume.")
+
@action(label="Recrawl", description="Create a new crawl with the same settings")
def recrawl(self, request, obj):
"""Duplicate this crawl as a new crawl with the same URLs and settings."""
@@ -670,9 +689,6 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
new_crawl = Crawl.objects.create(
urls=obj.urls,
max_depth=obj.max_depth,
- max_urls=obj.max_urls,
- crawl_max_size=obj.crawl_max_size,
- snapshot_max_size=obj.snapshot_max_size,
tags_str=obj.tags_str,
config=obj.config,
schedule=obj.schedule,
@@ -687,6 +703,39 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
return redirect("admin:crawls_crawl_change", new_crawl.id)
+ @admin.display(description="Stop Reason")
+ def stop_reason_display(self, obj):
+ reason = obj.limit_stop_reason() if obj else ""
+ if not reason:
+ return mark_safe('None ')
+ return format_html('{} ', reason)
+
+ @admin.display(description="Stop Reason")
+ def stop_reason_badge(self, obj):
+ return self.stop_reason_display(obj)
+
+ @admin.display(description="Resume")
+ def resume_control(self, obj):
+ if obj.status != Crawl.StatusChoices.SEALED and not obj.is_paused:
+ return mark_safe('- ')
+ reason = "paused" if obj.is_paused else (obj.limit_stop_reason() or "sealed")
+ return format_html(
+ 'Resume ',
+ obj.pk,
+ reason,
+ )
+
+ @admin.display(description="Pause")
+ def pause_control(self, obj):
+ if obj.status == Crawl.StatusChoices.SEALED:
+ return mark_safe('- ')
+ if obj.is_paused:
+ return mark_safe('Paused ')
+ return format_html(
+ 'Pause ',
+ obj.pk,
+ )
+
def num_snapshots(self, obj):
# Use cached annotation from get_queryset to avoid N+1
count = getattr(obj, "num_snapshots_cached", None)
@@ -694,8 +743,40 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
count = obj.snapshot_set.count()
return count
- def snapshots(self, obj):
- return render_snapshots_list(obj.snapshot_set.all(), crawl=obj)
+ @admin.display(description="Snapshots")
+ def snapshots_changelist(self, obj):
+ request = getattr(self, "request", None)
+ snapshot_changelist = reverse("admin:core_snapshot_changelist")
+ scoped_params = {"crawl_id": str(obj.pk)}
+ full_url = f"{snapshot_changelist}?{urlencode(scoped_params)}"
+ if request is None:
+ return format_html('Open snapshots changelist ', full_url)
+
+ snapshot_admin = self.admin_site._registry[Snapshot]
+ changelist_request = copy(request)
+ changelist_request.method = "GET"
+ changelist_request.path = snapshot_changelist
+ changelist_request.GET = request.GET.copy()
+ changelist_request.GET.update(
+ {
+ **scoped_params,
+ "_embedded": "crawl",
+ "per_page": "200",
+ },
+ )
+ changelist_request.POST = request.POST.copy()
+ changelist_request.POST.clear()
+
+ response = snapshot_admin.changelist_view(
+ changelist_request,
+ extra_context={"embedded_changelist": True},
+ )
+ context = {
+ **response.context_data,
+ "snapshot_changelist_url": full_url,
+ "crawl": obj,
+ }
+ return mark_safe(render_to_string("admin/crawls/crawl/snapshots_changelist.html", context, request=request))
def delete_snapshot_view(self, request: HttpRequest, object_id: str, snapshot_id: str):
if request.method != "POST":
@@ -832,6 +913,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
actions = ["delete_selected"]
def get_queryset(self, request):
+ self.request = request
return (
super()
.get_queryset(request)
@@ -842,6 +924,10 @@ class CrawlScheduleAdmin(BaseModelAdmin):
)
)
+ def change_view(self, request, object_id, form_url="", extra_context=None):
+ self.request = request
+ return super().change_view(request, object_id, form_url, extra_context)
+
def get_fieldsets(self, request, obj=None):
if obj is None:
return tuple(fieldset for fieldset in self.fieldsets if fieldset[0] not in {"Crawls", "Snapshots"})
@@ -879,7 +965,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
def snapshots(self, obj):
crawl_ids = obj.crawl_set.values_list("pk", flat=True)
- return render_snapshots_list(Snapshot.objects.filter(crawl_id__in=crawl_ids))
+ return render_snapshots_list(Snapshot.objects.filter(crawl_id__in=crawl_ids), request=getattr(self, "request", None), prefix="schedule_snapshots")
def register_admin(admin_site):
diff --git a/archivebox/crawls/migrations/0011_move_crawl_limits_to_config.py b/archivebox/crawls/migrations/0011_move_crawl_limits_to_config.py
new file mode 100644
index 00000000..ed6df28f
--- /dev/null
+++ b/archivebox/crawls/migrations/0011_move_crawl_limits_to_config.py
@@ -0,0 +1,38 @@
+from django.db import migrations
+
+
+def move_limit_fields_to_config(apps, schema_editor):
+ Crawl = apps.get_model("crawls", "Crawl")
+ rows = Crawl.objects.values("id", "config", "max_urls", "crawl_max_size", "snapshot_max_size").iterator(chunk_size=1000)
+ for row in rows:
+ config = dict(row["config"] or {})
+ if row["max_urls"]:
+ config["CRAWL_MAX_URLS"] = row["max_urls"]
+ if row["crawl_max_size"]:
+ config["CRAWL_MAX_SIZE"] = row["crawl_max_size"]
+ if row["snapshot_max_size"]:
+ config["SNAPSHOT_MAX_SIZE"] = row["snapshot_max_size"]
+ if config != (row["config"] or {}):
+ Crawl.objects.filter(id=row["id"]).update(config=config)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("crawls", "0010_crawl_delete_at"),
+ ]
+
+ operations = [
+ migrations.RunPython(move_limit_fields_to_config, migrations.RunPython.noop),
+ migrations.RemoveField(
+ model_name="crawl",
+ name="max_urls",
+ ),
+ migrations.RemoveField(
+ model_name="crawl",
+ name="crawl_max_size",
+ ),
+ migrations.RemoveField(
+ model_name="crawl",
+ name="snapshot_max_size",
+ ),
+ ]
diff --git a/archivebox/crawls/migrations/0012_drop_stale_crawl_timeout_column.py b/archivebox/crawls/migrations/0012_drop_stale_crawl_timeout_column.py
new file mode 100644
index 00000000..35393a77
--- /dev/null
+++ b/archivebox/crawls/migrations/0012_drop_stale_crawl_timeout_column.py
@@ -0,0 +1,22 @@
+from django.db import migrations
+
+
+def drop_stale_crawl_timeout_column(apps, schema_editor):
+ table_name = "crawls_crawl"
+ column_name = "crawl_timeout"
+ connection = schema_editor.connection
+ with connection.cursor() as cursor:
+ columns = {column.name for column in connection.introspection.get_table_description(cursor, table_name)}
+ if column_name not in columns:
+ return
+ schema_editor.execute(f"ALTER TABLE {schema_editor.quote_name(table_name)} DROP COLUMN {schema_editor.quote_name(column_name)}")
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("crawls", "0011_move_crawl_limits_to_config"),
+ ]
+
+ operations = [
+ migrations.RunPython(drop_stale_crawl_timeout_column, migrations.RunPython.noop),
+ ]
diff --git a/archivebox/crawls/migrations/0013_crawl_permissions.py b/archivebox/crawls/migrations/0013_crawl_permissions.py
new file mode 100644
index 00000000..ebe554f4
--- /dev/null
+++ b/archivebox/crawls/migrations/0013_crawl_permissions.py
@@ -0,0 +1,19 @@
+# Generated by Django 6.0.5 on 2026-05-28 07:25
+
+import django.db.models.fields.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('crawls', '0012_drop_stale_crawl_timeout_column'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='crawl',
+ name='permissions',
+ field=models.GeneratedField(db_index=True, db_persist=True, expression=django.db.models.fields.json.KeyTextTransform('PERMISSIONS', 'config'), output_field=models.CharField(max_length=16, null=True)),
+ ),
+ ]
diff --git a/archivebox/crawls/migrations/0014_crawl_persona_fk.py b/archivebox/crawls/migrations/0014_crawl_persona_fk.py
new file mode 100644
index 00000000..0521c09d
--- /dev/null
+++ b/archivebox/crawls/migrations/0014_crawl_persona_fk.py
@@ -0,0 +1,41 @@
+# Generated by hand on 2026-05-28
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def clear_stale_persona_ids(apps, _schema_editor):
+ Crawl = apps.get_model("crawls", "Crawl")
+ Persona = apps.get_model("personas", "Persona")
+ Crawl.objects.filter(persona_id__isnull=False).exclude(
+ persona_id__in=Persona.objects.values_list("id", flat=True),
+ ).update(persona_id=None)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("crawls", "0013_crawl_permissions"),
+ ("personas", "0003_persona_permissions"),
+ ]
+
+ operations = [
+ migrations.RunPython(clear_stale_persona_ids, migrations.RunPython.noop),
+ migrations.RenameField(
+ model_name="crawl",
+ old_name="persona_id",
+ new_name="persona",
+ ),
+ migrations.AlterField(
+ model_name="crawl",
+ name="persona",
+ field=models.ForeignKey(
+ blank=True,
+ db_column="persona_id",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="crawls",
+ to="personas.persona",
+ ),
+ ),
+ ]
diff --git a/archivebox/crawls/migrations/0015_alter_crawl_status.py b/archivebox/crawls/migrations/0015_alter_crawl_status.py
new file mode 100644
index 00000000..4cfac303
--- /dev/null
+++ b/archivebox/crawls/migrations/0015_alter_crawl_status.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.5 on 2026-05-28 12:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('crawls', '0014_crawl_persona_fk'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='crawl',
+ name='status',
+ field=models.CharField(choices=[('queued', 'Queued'), ('started', 'Started'), ('paused', 'Paused'), ('sealed', 'Sealed')], db_index=True, default='queued', max_length=15),
+ ),
+ ]
diff --git a/archivebox/crawls/models.py b/archivebox/crawls/models.py
index a2660590..3c2c54a6 100755
--- a/archivebox/crawls/models.py
+++ b/archivebox/crawls/models.py
@@ -14,6 +14,7 @@ from urllib.parse import urlparse
from django.db import IntegrityError, models, transaction
from django.db.models import Q
+from django.db.models.fields.json import KT
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.conf import settings
@@ -31,7 +32,7 @@ from archivebox.base_models.models import (
ModelWithHealthStats,
get_or_create_system_user_pk,
)
-from archivebox.workers.models import ModelWithStateMachine, BaseStateMachine
+from archivebox.workers.models import RETRY_AT_MAX, ModelWithStateMachine, BaseStateMachine
from archivebox.crawls.schedule_utils import next_run_for_schedule, validate_schedule
from archivebox.misc.util import validate_url_length
@@ -101,9 +102,6 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
urls=template.urls,
config=template.config or {},
max_depth=template.max_depth,
- max_urls=template.max_urls,
- crawl_max_size=template.crawl_max_size,
- snapshot_max_size=template.snapshot_max_size,
tags_str=template.tags_str,
persona_id=template.persona_id,
label=label,
@@ -123,24 +121,23 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
urls = models.TextField(blank=False, null=False, help_text="Newline-separated list of URLs to crawl")
config = models.JSONField(default=dict, null=True, blank=True)
+ permissions = models.GeneratedField(
+ expression=KT("config__PERMISSIONS"),
+ output_field=models.CharField(max_length=16, null=True),
+ db_persist=True,
+ db_index=True,
+ editable=False,
+ )
max_depth = models.PositiveSmallIntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(4)])
- max_urls = models.IntegerField(
- default=0,
- validators=[MinValueValidator(0)],
- help_text="Maximum number of URLs to snapshot for this crawl (0 = unlimited).",
- )
- crawl_max_size = models.BigIntegerField(
- default=0,
- validators=[MinValueValidator(0)],
- help_text="Maximum total archived output size in bytes for this crawl (0 = unlimited).",
- )
- snapshot_max_size = models.BigIntegerField(
- default=0,
- validators=[MinValueValidator(0)],
- help_text="Maximum archived output size in bytes for each snapshot (0 = unlimited).",
- )
tags_str = models.CharField(max_length=1024, blank=True, null=False, default="")
- persona_id = models.UUIDField(null=True, blank=True)
+ persona = models.ForeignKey(
+ "personas.Persona",
+ db_column="persona_id",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="crawls",
+ )
label = models.CharField(max_length=64, blank=True, null=False, default="")
notes = models.TextField(blank=True, null=False, default="")
schedule = models.ForeignKey(CrawlSchedule, on_delete=models.SET_NULL, null=True, blank=True, editable=True)
@@ -189,6 +186,59 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return get_config(crawl=self).DELETE_AFTER
+ def pause(self, *, save: bool = True) -> bool:
+ paused = super().pause(save=save)
+ if paused and self.pk:
+ from archivebox.core.models import ArchiveResult, Snapshot
+
+ active_snapshots = self.snapshot_set.filter(
+ status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED],
+ )
+ active_snapshots.update(
+ status=Snapshot.StatusChoices.PAUSED,
+ retry_at=RETRY_AT_MAX,
+ modified_at=timezone.now(),
+ )
+ ArchiveResult.pause_queryset(ArchiveResult.objects.filter(snapshot__crawl=self))
+ return paused
+
+ def resume(self, *, when=None, save: bool = True) -> bool:
+ resumed = super().resume(when=when, save=save)
+ if resumed and self.pk:
+ from archivebox.core.models import ArchiveResult, Snapshot
+
+ resume_at = when or timezone.now()
+ active_snapshots = self.snapshot_set.filter(
+ status=Snapshot.StatusChoices.PAUSED,
+ )
+ active_snapshots.update(
+ status=Snapshot.StatusChoices.QUEUED,
+ retry_at=resume_at,
+ modified_at=timezone.now(),
+ )
+ ArchiveResult.resume_queryset(ArchiveResult.objects.filter(snapshot__crawl=self), when=resume_at)
+ return resumed
+
+ def cancel(self) -> None:
+ from archivebox.core.models import Snapshot
+
+ cancelled_at = timezone.now()
+ self.status = self.StatusChoices.SEALED
+ self.retry_at = None
+ self.save(update_fields=["status", "retry_at", "modified_at"])
+ Snapshot.objects.filter(
+ crawl=self,
+ status__in=[
+ Snapshot.StatusChoices.QUEUED,
+ Snapshot.StatusChoices.STARTED,
+ Snapshot.StatusChoices.PAUSED,
+ ],
+ ).update(
+ status=Snapshot.StatusChoices.SEALED,
+ retry_at=None,
+ modified_at=cancelled_at,
+ )
+
@classmethod
def missing_delete_at_candidates(cls):
from archivebox.personas.models import Persona
@@ -199,27 +249,12 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
sync_tags = update_fields is None or "tags_str" in update_fields
+ old_crawl = type(self).objects.filter(pk=self.pk).first() if self.pk else None
previous_tag_names = set()
- if sync_tags and self.pk:
- previous_tags_str = type(self).objects.filter(pk=self.pk).values_list("tags_str", flat=True).first()
- previous_tag_names = set(self.parse_tag_names(previous_tags_str or ""))
+ if sync_tags and old_crawl is not None:
+ previous_tag_names = set(self.parse_tag_names(old_crawl.tags_str or ""))
config = dict(self.config or {})
- if self.max_urls > 0:
- config["CRAWL_MAX_URLS"] = self.max_urls
- else:
- config.pop("CRAWL_MAX_URLS", None)
-
- if self.crawl_max_size > 0:
- config["CRAWL_MAX_SIZE"] = self.crawl_max_size
- else:
- config.pop("CRAWL_MAX_SIZE", None)
-
- if self.snapshot_max_size > 0:
- config["SNAPSHOT_MAX_SIZE"] = self.snapshot_max_size
- else:
- config.pop("SNAPSHOT_MAX_SIZE", None)
-
if "CRAWL_MAX_CONCURRENT_SNAPSHOTS" in config:
raw_concurrency = config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]
if raw_concurrency in (None, ""):
@@ -342,9 +377,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
"urls": self.urls,
"status": self.status,
"max_depth": self.max_depth,
- "max_urls": self.max_urls,
- "crawl_max_size": self.crawl_max_size,
- "snapshot_max_size": self.snapshot_max_size,
+ "config": self.config or {},
"tags_str": self.tags_str,
"label": self.label,
"created_at": self.created_at.isoformat() if self.created_at else None,
@@ -386,9 +419,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
crawl = Crawl.objects.create(
urls=urls,
max_depth=record.get("max_depth", record.get("depth", 0)),
- max_urls=record.get("max_urls", 0),
- crawl_max_size=record.get("crawl_max_size", 0),
- snapshot_max_size=record.get("snapshot_max_size", 0),
+ config=record.get("config") or {},
tags_str=record.get("tags_str", record.get("tags", "")),
label=record.get("label", ""),
status=Crawl.StatusChoices.QUEUED,
@@ -551,7 +582,11 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
filtered_snapshots = [
snapshot
for snapshot in self.snapshot_set.filter(
- status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED],
+ status__in=[
+ Snapshot.StatusChoices.QUEUED,
+ Snapshot.StatusChoices.STARTED,
+ Snapshot.StatusChoices.PAUSED,
+ ],
).only("pk", "url", "status")
if not self.url_passes_filters(snapshot.url, snapshot=snapshot, use_effective_config=False)
]
@@ -603,18 +638,24 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return len(urls)
def remaining_url_capacity(self) -> int | None:
- if self.max_urls <= 0:
+ from archivebox.config.common import get_config
+
+ max_urls = int(get_config(crawl=self).CRAWL_MAX_URLS or 0)
+ if max_urls <= 0:
return None
- return max(self.max_urls - self.count_urls_for_limit(), 0)
+ return max(max_urls - self.count_urls_for_limit(), 0)
def has_remaining_url_capacity(self) -> bool:
remaining = self.remaining_url_capacity()
return remaining is None or remaining > 0
def remaining_snapshot_capacity(self) -> int | None:
- if self.max_urls <= 0:
+ from archivebox.config.common import get_config
+
+ max_urls = int(get_config(crawl=self).CRAWL_MAX_URLS or 0)
+ if max_urls <= 0:
return None
- return max(self.max_urls - self.snapshot_set.count(), 0)
+ return max(max_urls - self.snapshot_set.count(), 0)
def has_remaining_snapshot_capacity(self) -> bool:
remaining = self.remaining_snapshot_capacity()
@@ -687,17 +728,27 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
if self.persona_id:
persona = Persona.objects.filter(id=self.persona_id).first()
- if persona is None:
- raise Persona.DoesNotExist(f"Crawl {self.id} references missing Persona {self.persona_id}")
- return persona
+ 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 None
+ def limit_stop_reason(self) -> str:
+ from abx_dl.limits import CrawlLimitState
+ from archivebox.config.common import get_config
+
+ if not (self.output_dir / ".abx-dl" / "limits.json").exists():
+ return ""
+ config = get_config(crawl=self, include_machine=False)
+ config["CRAWL_DIR"] = str(self.output_dir)
+ return CrawlLimitState.from_config(config).get_stop_reason()
+
def add_url(self, entry: dict) -> bool:
"""
Add a URL to the crawl queue if not already present.
@@ -1132,17 +1183,10 @@ 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 pathlib import Path
from archivebox.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
- # Debug logging to file (since stdout/stderr redirected to /dev/null in progress mode)
- debug_log = Path("/tmp/archivebox_crawl_debug.log")
- with open(debug_log, "a") as f:
- f.write(f"\n=== Crawl.run() starting for {self.id} at {time.time()} ===\n")
- f.flush()
-
def get_runtime_config():
config = get_config(crawl=self)
if persona_runtime_overrides:
@@ -1177,9 +1221,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
self.urls.strip(),
)
- with open(debug_log, "a") as f:
- f.write(f"Running hook: {hook.name}\n")
- f.flush()
hook_start = time.time()
plugin_name = hook.parent.name
output_dir = self.output_dir / plugin_name
@@ -1194,10 +1235,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
url=primary_url,
snapshot_id=str(self.id),
)
- with open(debug_log, "a") as f:
- f.write(f"Hook {hook.name} completed with status={process.status}\n")
- f.flush()
-
hook_elapsed = time.time() - hook_start
if hook_elapsed > 0.5:
print(f"[yellow]β±οΈ Hook {hook.name} took {hook_elapsed:.2f}s[/yellow]")
@@ -1213,9 +1250,9 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
from archivebox.hooks import extract_records_from_process
records = []
- # Finite background hooks can exit before their stdout log is fully
- # visible to our polling loop. Give successful hooks a brief chance
- # to flush JSONL records before we move on to downstream hooks.
+ # Finite background hooks can exit before their completed Process
+ # metadata is visible. Give successful hooks a brief chance to
+ # flush JSONL stdout into the Process row before downstream hooks.
for delay in (0.0, 0.05, 0.1, 0.25, 0.5):
if delay:
time.sleep(delay)
@@ -1284,14 +1321,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
for hook in provider_hooks:
resolved_binary_names.update(run_crawl_hook(hook))
- # Discover and run on_Crawl hooks
- with open(debug_log, "a") as f:
- f.write("Discovering Crawl hooks...\n")
- f.flush()
hooks = discover_hooks("Crawl", config=get_runtime_config())
- with open(debug_log, "a") as f:
- f.write(f"Found {len(hooks)} hooks\n")
- f.flush()
for hook in hooks:
hook_binary_names = run_crawl_hook(hook)
@@ -1309,20 +1339,9 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
leaked_count = leaked_snapshots.count()
leaked_snapshots.delete()
print(f"[yellow]β οΈ Removed {leaked_count} leaked snapshot(s) created during system crawl {system_task}[/yellow]")
- with open(debug_log, "a") as f:
- f.write(f"Skipping snapshot creation for system crawl: {system_task}\n")
- f.write("=== Crawl.run() complete ===\n\n")
- f.flush()
return None
- with open(debug_log, "a") as f:
- f.write("Creating snapshots from URLs...\n")
- f.flush()
- created_snapshots = self.create_snapshots_from_urls()
- with open(debug_log, "a") as f:
- f.write(f"Created {len(created_snapshots)} snapshots\n")
- f.write("=== Crawl.run() complete ===\n\n")
- f.flush()
+ self.create_snapshots_from_urls()
# Return first snapshot for this crawl (newly created or existing)
# This ensures the crawl doesn't seal if snapshots exist, even if they weren't just created
@@ -1340,7 +1359,13 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return True
# If snapshots exist, check if all are sealed
- if snapshots.filter(status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED]).exists():
+ if snapshots.filter(
+ status__in=[
+ Snapshot.StatusChoices.QUEUED,
+ Snapshot.StatusChoices.STARTED,
+ Snapshot.StatusChoices.PAUSED,
+ ],
+ ).exists():
return False
return True
@@ -1426,13 +1451,16 @@ class CrawlMachine(BaseStateMachine):
# States
queued = State(value=Crawl.StatusChoices.QUEUED, initial=True)
started = State(value=Crawl.StatusChoices.STARTED)
+ paused = State(value=Crawl.StatusChoices.PAUSED)
sealed = State(value=Crawl.StatusChoices.SEALED, final=True)
# Tick Event (polled by workers)
- tick = queued.to.itself(unless="can_start") | queued.to(started, cond="can_start") | started.to(sealed, cond="is_finished")
+ tick = queued.to.itself(unless="can_start") | queued.to(started, cond="can_start") | started.to(sealed, cond="is_finished") | paused.to.itself()
# Manual event (triggered by last Snapshot sealing)
seal = started.to(sealed)
+ pause_requested = queued.to(paused) | started.to(paused)
+ resume_requested = paused.to(queued)
def can_start(self) -> bool:
if not self.crawl.urls:
@@ -1448,6 +1476,13 @@ class CrawlMachine(BaseStateMachine):
"""Check if all Snapshots for this crawl are finished."""
return self.crawl.is_finished()
+ @queued.enter
+ def enter_queued(self):
+ self.crawl.update_and_requeue(
+ retry_at=timezone.now(),
+ status=Crawl.StatusChoices.QUEUED,
+ )
+
@started.enter
def enter_started(self):
import sys
@@ -1482,6 +1517,13 @@ class CrawlMachine(BaseStateMachine):
traceback.print_exc()
raise
+ @paused.enter
+ def enter_paused(self):
+ self.crawl.update_and_requeue(
+ retry_at=RETRY_AT_MAX,
+ status=Crawl.StatusChoices.PAUSED,
+ )
+
@sealed.enter
def enter_sealed(self):
# Clean up background hooks and run on_CrawlEnd hooks
diff --git a/archivebox/dead/archivebox_persona.py b/archivebox/dead/archivebox_persona.py
new file mode 100644
index 00000000..74ef9336
--- /dev/null
+++ b/archivebox/dead/archivebox_persona.py
@@ -0,0 +1,129 @@
+# ruff: noqa
+NETSCAPE_COOKIE_HEADER = [
+ "# Netscape HTTP Cookie File",
+ "# https://curl.se/docs/http-cookies.html",
+ "# This file was generated by ArchiveBox persona cookie extraction",
+ "#",
+ "# Format: domain\\tincludeSubdomains\\tpath\\tsecure\\texpiry\\tname\\tvalue",
+ "",
+]
+
+
+def _parse_netscape_cookies(path: Path) -> "OrderedDict[tuple[str, str, str], tuple[str, str, str, str, str, str, str]]":
+ cookies = OrderedDict()
+ if not path.exists():
+ return cookies
+
+ for line in path.read_text().splitlines():
+ if not line or line.startswith("#"):
+ continue
+ parts = line.split("\t")
+ if len(parts) < 7:
+ continue
+ domain, include_subdomains, cookie_path, secure, expiry, name, value = parts[:7]
+ key = (domain, cookie_path, name)
+ cookies[key] = (domain, include_subdomains, cookie_path, secure, expiry, name, value)
+ return cookies
+
+
+def _write_netscape_cookies(path: Path, cookies: "OrderedDict[tuple[str, str, str], tuple[str, str, str, str, str, str, str]]") -> None:
+ lines = list(NETSCAPE_COOKIE_HEADER)
+ for cookie in cookies.values():
+ lines.append("\t".join(cookie))
+ path.write_text("\n".join(lines) + "\n")
+
+
+def _merge_netscape_cookies(existing_file: Path, new_file: Path) -> None:
+ existing = _parse_netscape_cookies(existing_file)
+ new = _parse_netscape_cookies(new_file)
+ for key, cookie in new.items():
+ existing[key] = cookie
+ _write_netscape_cookies(existing_file, existing)
+
+
+def extract_cookies_via_cdp(
+ user_data_dir: Path,
+ output_file: Path,
+ profile_dir: str | None = None,
+ chrome_binary: str | None = None,
+) -> bool:
+ """
+ Launch Chrome with the given user data dir and extract cookies via CDP.
+
+ Returns True if successful, False otherwise.
+ """
+ from archivebox.config.common import get_config
+
+ # Find the cookie extraction script
+ chrome_plugin_dir = Path(__file__).parent.parent / "plugins" / "chrome"
+ extract_script = chrome_plugin_dir / "extract_cookies.js"
+
+ if not extract_script.exists():
+ rprint(f"[yellow]Cookie extraction script not found at {extract_script}[/yellow]", file=sys.stderr)
+ return False
+
+ # Get node modules dir
+ node_modules_dir = get_config().LIB_DIR / "npm" / "node_modules"
+
+ # Set up environment
+ env = os.environ.copy()
+ env["NODE_MODULES_DIR"] = str(node_modules_dir)
+ env["CHROME_USER_DATA_DIR"] = str(user_data_dir)
+ env["CHROME_HEADLESS"] = "true"
+ if chrome_binary:
+ env["CHROME_BINARY"] = str(chrome_binary)
+ output_path = output_file
+ temp_output = None
+ temp_dir = None
+ if output_file.exists():
+ temp_dir = Path(tempfile.mkdtemp(prefix="ab_cookies_"))
+ temp_output = temp_dir / "cookies.txt"
+ output_path = temp_output
+ if profile_dir:
+ extra_arg = f"--profile-directory={profile_dir}"
+ existing_extra = env.get("CHROME_ARGS_EXTRA", "").strip()
+ args_list = []
+ if existing_extra:
+ if existing_extra.startswith("["):
+ try:
+ parsed = json.loads(existing_extra)
+ if isinstance(parsed, list):
+ args_list.extend(str(x) for x in parsed)
+ except Exception:
+ args_list.extend([s.strip() for s in existing_extra.split(",") if s.strip()])
+ else:
+ args_list.extend([s.strip() for s in existing_extra.split(",") if s.strip()])
+ args_list.append(extra_arg)
+ env["CHROME_ARGS_EXTRA"] = json.dumps(args_list)
+
+ env["COOKIES_OUTPUT_FILE"] = str(output_path)
+
+ try:
+ result = subprocess.run(
+ ["node", str(extract_script)],
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+
+ if result.returncode == 0:
+ if temp_output and temp_output.exists():
+ _merge_netscape_cookies(output_file, temp_output)
+ return True
+ else:
+ rprint(f"[yellow]Cookie extraction failed: {result.stderr}[/yellow]", file=sys.stderr)
+ return False
+
+ except subprocess.TimeoutExpired:
+ rprint("[yellow]Cookie extraction timed out[/yellow]", file=sys.stderr)
+ return False
+ except FileNotFoundError:
+ rprint("[yellow]Node.js not found. Cannot extract cookies.[/yellow]", file=sys.stderr)
+ return False
+ except Exception as e:
+ rprint(f"[yellow]Cookie extraction error: {e}[/yellow]", file=sys.stderr)
+ return False
+ finally:
+ if temp_dir and temp_dir.exists():
+ shutil.rmtree(temp_dir, ignore_errors=True)
diff --git a/archivebox/dead/auth.py b/archivebox/dead/auth.py
new file mode 100644
index 00000000..0ae658bd
--- /dev/null
+++ b/archivebox/dead/auth.py
@@ -0,0 +1,26 @@
+# ruff: noqa
+class UsernameAndPasswordAuth(HttpBasicAuth):
+ """Allow authenticating by passing username & password via HTTP Basic Authentication (not recommended)"""
+
+ def authenticate(self, request: HttpRequest, username: str, password: str) -> User | None:
+ return _require_superuser(
+ auth_using_password(username=username, password=password, request=request),
+ request,
+ self.__class__.__name__,
+ )
+
+
+class DjangoSessionAuth:
+ """Allow authenticating with existing Django session cookies (same-origin only)."""
+
+ def __call__(self, request: HttpRequest) -> User | None:
+ return self.authenticate(request)
+
+ def authenticate(self, request: HttpRequest, **kwargs) -> User | None:
+ user = getattr(request, "user", None)
+ if isinstance(user, User) and user.is_authenticated:
+ setattr(request, "_api_auth_method", self.__class__.__name__)
+ if not user.is_superuser:
+ raise HttpError(403, "Valid session but User does not have permission (make sure user.is_superuser=True)")
+ return user
+ return None
diff --git a/archivebox/core/templatetags/config_tags.py b/archivebox/dead/config_tags.py
similarity index 97%
rename from archivebox/core/templatetags/config_tags.py
rename to archivebox/dead/config_tags.py
index 8c305c57..16d442e2 100644
--- a/archivebox/core/templatetags/config_tags.py
+++ b/archivebox/dead/config_tags.py
@@ -1,3 +1,4 @@
+# ruff: noqa
"""Template tags for accessing config values in templates."""
from typing import Any
diff --git a/archivebox/dead/db.py b/archivebox/dead/db.py
new file mode 100644
index 00000000..2df1da3e
--- /dev/null
+++ b/archivebox/dead/db.py
@@ -0,0 +1,29 @@
+# ruff: noqa
+def list_migrations(out_dir: Path = DATA_DIR) -> list[tuple[bool, str]]:
+ """List all Django migrations and their status"""
+ from django.core.management import call_command
+
+ def showmigrations() -> StringIO:
+ out = StringIO()
+ call_command("showmigrations", list=True, stdout=out)
+ out.seek(0)
+ return out
+
+ out = retry_sqlite_locks(showmigrations, label="checking migrations")
+
+ migrations = []
+ for line in out.readlines():
+ if line.strip() and "]" in line:
+ status_str, name_str = line.strip().split("]", 1)
+ is_applied = "X" in status_str
+ migration_name = name_str.strip()
+ migrations.append((is_applied, migration_name))
+
+ return migrations
+
+
+def get_admins(out_dir: Path = DATA_DIR) -> list[Any]:
+ """Get list of superuser accounts"""
+ from django.contrib.auth.models import User
+
+ return list(User.objects.filter(is_superuser=True).exclude(username="system"))
diff --git a/archivebox/misc/debugging.py b/archivebox/dead/debugging.py
similarity index 98%
rename from archivebox/misc/debugging.py
rename to archivebox/dead/debugging.py
index 4ada510c..c4f0dd57 100644
--- a/archivebox/misc/debugging.py
+++ b/archivebox/dead/debugging.py
@@ -1,3 +1,4 @@
+# ruff: noqa
from functools import wraps
from time import time
diff --git a/archivebox/dead/detect.py b/archivebox/dead/detect.py
new file mode 100644
index 00000000..e5341351
--- /dev/null
+++ b/archivebox/dead/detect.py
@@ -0,0 +1,3 @@
+# ruff: noqa
+def get_host_immutable_info(host_info: dict[str, Any]) -> dict[str, Any]:
+ return {key: value for key, value in host_info.items() if key in ["guid", "net_mac", "os_family", "cpu_arch"]}
diff --git a/archivebox/dead/django.py b/archivebox/dead/django.py
new file mode 100644
index 00000000..c7e2ed99
--- /dev/null
+++ b/archivebox/dead/django.py
@@ -0,0 +1,7 @@
+# ruff: noqa
+def setup_django_minimal():
+ # sys.path.append(str(CONSTANTS.PACKAGE_DIR))
+ # os.environ.setdefault('ARCHIVEBOX_DATA_DIR', str(CONSTANTS.DATA_DIR))
+ # os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
+ # django.setup()
+ raise Exception("dont use this anymore")
diff --git a/archivebox/misc/folders.py b/archivebox/dead/folders.py
similarity index 99%
rename from archivebox/misc/folders.py
rename to archivebox/dead/folders.py
index 38d4b2ed..b3edcb41 100644
--- a/archivebox/misc/folders.py
+++ b/archivebox/dead/folders.py
@@ -1,3 +1,4 @@
+# ruff: noqa
"""
Folder utilities for ArchiveBox.
diff --git a/archivebox/dead/hooks.py b/archivebox/dead/hooks.py
new file mode 100644
index 00000000..db61b6b9
--- /dev/null
+++ b/archivebox/dead/hooks.py
@@ -0,0 +1,35 @@
+# ruff: noqa
+class HookResult(TypedDict, total=False):
+ """Raw result from run_hook()."""
+
+ returncode: int
+ stdout: str
+ stderr: str
+ output_json: dict[str, Any] | None
+ output_files: list[dict[str, Any]]
+ duration_ms: int
+ hook: str
+ plugin: str # Plugin name (directory name, e.g., 'wget', 'screenshot')
+ hook_name: str # Full hook filename (e.g., 'on_Snapshot__50_wget.py')
+ # New fields for JSONL parsing
+ records: list[dict[str, Any]] # Parsed JSONL records with 'type' field
+
+
+def get_config_defaults_from_plugins() -> dict[str, Any]:
+ """
+ Get default values for all plugin config options.
+
+ Returns:
+ Dict mapping config keys to their default values.
+ e.g., {'SAVE_WGET': True, 'WGET_TIMEOUT': 60, ...}
+ """
+ plugin_configs = discover_plugin_configs()
+ defaults = {}
+
+ for plugin_name, schema in plugin_configs.items():
+ properties = schema.get("properties", {})
+ for key, prop_schema in properties.items():
+ if "default" in prop_schema:
+ defaults[key] = prop_schema["default"]
+
+ return defaults
diff --git a/archivebox/dead/host_utils.py b/archivebox/dead/host_utils.py
new file mode 100644
index 00000000..9397f78f
--- /dev/null
+++ b/archivebox/dead/host_utils.py
@@ -0,0 +1,11 @@
+# ruff: noqa
+def get_archive_base_url(request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
+ return get_web_base_url(request=request, config=config, **config_kwargs)
+
+
+def build_api_url(path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
+ return _build_url(get_api_base_url(request, config=config, **config_kwargs), path)
+
+
+def build_archive_url(path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
+ return _build_url(get_archive_base_url(request, config=config, **config_kwargs), path)
diff --git a/archivebox/dead/jsonl.py b/archivebox/dead/jsonl.py
new file mode 100644
index 00000000..f56bd488
--- /dev/null
+++ b/archivebox/dead/jsonl.py
@@ -0,0 +1,12 @@
+# ruff: noqa
+def write_records(records: Iterator[dict[str, Any]], stream: TextIO | None = None) -> int:
+ """
+ Write multiple JSONL records to stdout (or provided stream).
+
+ Returns count of records written.
+ """
+ count = 0
+ for record in records:
+ write_record(record, stream)
+ count += 1
+ return count
diff --git a/archivebox/misc/legacy.py b/archivebox/dead/legacy.py
similarity index 99%
rename from archivebox/misc/legacy.py
rename to archivebox/dead/legacy.py
index 477c27d6..8251b794 100644
--- a/archivebox/misc/legacy.py
+++ b/archivebox/dead/legacy.py
@@ -1,3 +1,4 @@
+# ruff: noqa
"""
Legacy archive import utilities.
diff --git a/archivebox/services/live_ui.py b/archivebox/dead/live_ui.py
similarity index 81%
rename from archivebox/services/live_ui.py
rename to archivebox/dead/live_ui.py
index a89f016c..c7bafee1 100644
--- a/archivebox/services/live_ui.py
+++ b/archivebox/dead/live_ui.py
@@ -1,3 +1,4 @@
+# ruff: noqa
from abx_dl.cli import LiveBusUI
__all__ = ["LiveBusUI"]
diff --git a/archivebox/ideas/process_plugin.py b/archivebox/dead/process_plugin.py
similarity index 99%
rename from archivebox/ideas/process_plugin.py
rename to archivebox/dead/process_plugin.py
index aad584bb..051f8f1a 100644
--- a/archivebox/ideas/process_plugin.py
+++ b/archivebox/dead/process_plugin.py
@@ -1,3 +1,4 @@
+# ruff: noqa
__package__ = "archivebox.ideas"
import asyncio
diff --git a/archivebox/dead/shutdown_util.py b/archivebox/dead/shutdown_util.py
new file mode 100644
index 00000000..98682228
--- /dev/null
+++ b/archivebox/dead/shutdown_util.py
@@ -0,0 +1,101 @@
+# ruff: noqa
+def pid_is_running(pid: int) -> bool:
+ """Return True when the OS still has a process for pid.
+
+ This intentionally does not inspect ArchiveBox state. It is used only for
+ foreground parent processes and stale pid files; orchestrator ownership
+ still belongs to the database state machine and retry_at locks.
+ """
+
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ except PermissionError:
+ return True
+ return True
+
+
+def read_pid_file(pid_file: Path) -> int | None:
+ try:
+ return int(pid_file.read_text().strip())
+ except (FileNotFoundError, ValueError):
+ return None
+
+
+def unlink_pid_file_if_owner(pid_file: Path, pid: int) -> None:
+ """Remove a pid file only if it still points at the expected process."""
+
+ try:
+ if pid_file.read_text().strip() == str(pid):
+ pid_file.unlink(missing_ok=True)
+ except FileNotFoundError:
+ pass
+
+
+def wait_for_pid_exit(pid: int, *, timeout: float, interval: float = 0.1) -> bool:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if not pid_is_running(pid):
+ return True
+ time.sleep(interval)
+ return not pid_is_running(pid)
+
+
+def stop_pidfile_owner(
+ pid_file: Path,
+ *,
+ current_pid: int,
+ description: str,
+ graceful_timeout: float,
+ log: Callable[[str], object],
+ owner_matches: Callable[[int], bool] | None = None,
+ on_stale_pid: Callable[[], object] | None = None,
+ on_forced_stop: Callable[[], object] | None = None,
+) -> int:
+ """Stop a previous foreground owner recorded in pid_file.
+
+ This is for command-parent takeover only. It deliberately does not claim
+ Crawl/Snapshot work; crashed work is resumed by the existing retry_at/state
+ machine path after the parent process is gone.
+ """
+
+ pid = read_pid_file(pid_file)
+ if pid is None or pid == current_pid:
+ return 0
+
+ if not pid_is_running(pid):
+ pid_file.unlink(missing_ok=True)
+ if on_stale_pid is not None:
+ on_stale_pid()
+ return 0
+ if owner_matches is not None and not owner_matches(pid):
+ # PIDs can be reused after an unclean exit. A stale pidfile must never
+ # let one ArchiveBox collection stop a process owned by another
+ # collection or another app entirely.
+ pid_file.unlink(missing_ok=True)
+ if on_stale_pid is not None:
+ on_stale_pid()
+ return 0
+
+ log(f"[yellow][*] Stopping existing {description} pid={pid}...[/yellow]")
+ try:
+ os.kill(pid, signal.SIGTERM)
+ except ProcessLookupError:
+ pid_file.unlink(missing_ok=True)
+ if on_stale_pid is not None:
+ on_stale_pid()
+ return 0
+
+ if wait_for_pid_exit(pid, timeout=graceful_timeout):
+ pid_file.unlink(missing_ok=True)
+ return 1
+
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ if on_forced_stop is not None:
+ on_forced_stop()
+ pid_file.unlink(missing_ok=True)
+ return 1
diff --git a/archivebox/dead/supervision_service.py b/archivebox/dead/supervision_service.py
new file mode 100644
index 00000000..5a22eb9b
--- /dev/null
+++ b/archivebox/dead/supervision_service.py
@@ -0,0 +1,41 @@
+# ruff: noqa
+def ensure_single_orchestrator(*, data_dir: str | Path, takeover: bool, reason: str = ""):
+ from archivebox.machine.models import Machine, Process
+ from archivebox.workers.supervisord_util import (
+ RUNNER_WORKER,
+ get_existing_supervisord_process,
+ get_or_create_supervisord_process,
+ start_worker,
+ stop_worker,
+ )
+
+ existing = healthy_orchestrator(data_dir=data_dir)
+ if existing and not takeover:
+ if reason:
+ pid = existing.get("pid") if isinstance(existing, dict) else existing.pid
+ print(f"[green][*] {reason}; existing orchestrator pid={pid} will process it.[/green]")
+ return existing
+
+ supervisor = get_existing_supervisord_process() or get_or_create_supervisord_process(daemonize=False)
+ if existing and takeover:
+ print("[yellow][*] Taking over existing ArchiveBox orchestrator...[/yellow]")
+ try:
+ stop_worker(supervisor, RUNNER_WORKER["name"])
+ except Exception:
+ pass
+ 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:
+ proc.terminate(graceful_timeout=2.0)
+
+ return start_worker(supervisor, RUNNER_WORKER)
+
+
+def wait_until_replaced_or_signal(command, *, process_type: str, data_dir: str | Path, url: str | None = None, interval: float = 2.0) -> None:
+ while command_is_newest(command, process_type=process_type, data_dir=data_dir, url=url):
+ command.heartbeat()
+ time.sleep(interval)
diff --git a/archivebox/dead/supervisord_util.py b/archivebox/dead/supervisord_util.py
new file mode 100644
index 00000000..fe45f49b
--- /dev/null
+++ b/archivebox/dead/supervisord_util.py
@@ -0,0 +1,90 @@
+# ruff: noqa
+def follow(file, sleep_sec=0.1) -> Iterator[str]:
+ """Yield each line from a file as they are written.
+ `sleep_sec` is the time to sleep after empty reads."""
+ line = ""
+ while True:
+ tmp = file.readline()
+ if tmp is not None and tmp != "":
+ line += tmp
+ if line.endswith("\n"):
+ yield line
+ line = ""
+ elif sleep_sec:
+ time.sleep(sleep_sec)
+
+
+def tail_worker_logs(log_path: str):
+ get_or_create_supervisord_process(daemonize=False)
+
+ from rich.live import Live
+ from rich.table import Table
+
+ table = Table()
+ table.add_column("TS")
+ table.add_column("URL")
+
+ try:
+ with Live(table, refresh_per_second=1) as live: # update 4 times a second to feel fluid
+ with open(log_path) as f:
+ for line in follow(f):
+ if "://" in line:
+ live.console.print(f"Working on: {line.strip()}")
+ # table.add_row("123124234", line.strip())
+ except (KeyboardInterrupt, BrokenPipeError, OSError):
+ STDERR.print("\n[π] Got Ctrl+C, stopping gracefully...")
+ except SystemExit:
+ pass
+
+
+def watch_worker(supervisor, daemon_name, interval=5):
+ """loop continuously and monitor worker's health"""
+ while True:
+ proc = get_worker(supervisor, daemon_name)
+ if not proc:
+ raise Exception("Worker disappeared while running! " + daemon_name)
+
+ if proc["statename"] == "STOPPED":
+ return proc
+
+ if proc["statename"] == "RUNNING":
+ time.sleep(1)
+ continue
+
+ if proc["statename"] in ("STARTING", "BACKOFF", "FATAL", "EXITED", "STOPPING"):
+ print(f"[π¦ΈββοΈ] WARNING: Worker {daemon_name} {proc['statename']} {proc['description']}")
+ time.sleep(interval)
+ continue
+
+
+def start_cli_workers(watch=False):
+ from archivebox.config.common import get_config
+
+ supervisor = get_or_create_supervisord_process(daemonize=False)
+
+ sonic_worker = get_sonic_supervisord_worker_from_plugin(get_config())
+ workers = [(RUNNER_WORKER, False)]
+ if sonic_worker is not None:
+ workers.insert(0, (sonic_worker, False))
+
+ sync_supervisord_workers(supervisor, workers, prune=True)
+
+ if watch:
+ try:
+ # Block on supervisord process - it will handle signals and stop children
+ if _supervisord_proc:
+ _supervisord_proc.wait()
+ else:
+ # Fallback to watching worker if no proc reference
+ watch_worker(supervisor, RUNNER_WORKER["name"])
+ except (KeyboardInterrupt, BrokenPipeError, OSError):
+ STDERR.print("\n[π] Got Ctrl+C, stopping gracefully...")
+ except SystemExit:
+ pass
+ except BaseException as e:
+ STDERR.print(f"\n[π] Got {e.__class__.__name__} exception, stopping gracefully...")
+ finally:
+ # Ensure supervisord and all children are stopped
+ stop_existing_supervisord_process()
+ time.sleep(1.0) # Give processes time to fully terminate
+ return [RUNNER_WORKER]
diff --git a/archivebox/dead/system.py b/archivebox/dead/system.py
new file mode 100644
index 00000000..712bf052
--- /dev/null
+++ b/archivebox/dead/system.py
@@ -0,0 +1,78 @@
+# ruff: noqa
+@enforce_types
+def chmod_file(path: str, cwd: str = "", config=None, **config_kwargs) -> None:
+ """chmod -R /"""
+
+ root = Path(cwd or os.getcwd()) / path
+ if not os.access(root, os.R_OK):
+ raise Exception(f"Failed to chmod: {path} does not exist (did the previous step fail?)")
+
+ if not root.is_dir():
+ # path is just a plain file
+ config = config or get_config(**config_kwargs)
+ os.chmod(root, int(config.OUTPUT_PERMISSIONS, base=8))
+ else:
+ config = config or get_config(**config_kwargs)
+ for subpath in Path(path).glob("**/*"):
+ if subpath.is_dir():
+ # directories need execute permissions to be able to list contents
+ os.chmod(subpath, int(config.DIR_OUTPUT_PERMISSIONS, base=8))
+ else:
+ os.chmod(subpath, int(config.OUTPUT_PERMISSIONS, base=8))
+
+
+@enforce_types
+def copy_and_overwrite(from_path: str | Path, to_path: str | Path):
+ """copy a given file or directory to a given path, overwriting the destination"""
+
+ assert os.access(from_path, os.R_OK)
+
+ if Path(from_path).is_dir():
+ shutil.rmtree(to_path, ignore_errors=True)
+ shutil.copytree(from_path, to_path)
+ else:
+ with open(from_path, "rb") as src:
+ contents = src.read()
+ atomic_write(to_path, contents)
+
+
+class suppress_output:
+ """
+ A context manager for doing a "deep suppression" of stdout and stderr in
+ Python, i.e. will suppress all print, even if the print originates in a
+ compiled C/Fortran sub-function.
+
+ This will not suppress raised exceptions, since exceptions are printed
+ to stderr just before a script exits, and after the context manager has
+ exited (at least, I think that is why it lets exceptions through).
+
+ with suppress_stdout_stderr():
+ rogue_function()
+ """
+
+ def __init__(self, stdout=True, stderr=True):
+ # Open a pair of null files
+ # Save the actual stdout (1) and stderr (2) file descriptors.
+ self.stdout, self.stderr = stdout, stderr
+ if stdout:
+ self.null_stdout = os.open(os.devnull, os.O_RDWR)
+ self.real_stdout = os.dup(1)
+ if stderr:
+ self.null_stderr = os.open(os.devnull, os.O_RDWR)
+ self.real_stderr = os.dup(2)
+
+ def __enter__(self):
+ # Assign the null pointers to stdout and stderr.
+ if self.stdout:
+ os.dup2(self.null_stdout, 1)
+ if self.stderr:
+ os.dup2(self.null_stderr, 2)
+
+ def __exit__(self, *_):
+ # Re-assign the real stdout/stderr back to (1) and (2)
+ if self.stdout:
+ os.dup2(self.real_stdout, 1)
+ os.close(self.null_stdout)
+ if self.stderr:
+ os.dup2(self.real_stderr, 2)
+ os.close(self.null_stderr)
diff --git a/archivebox/dead/util.py b/archivebox/dead/util.py
new file mode 100644
index 00000000..1bf33be3
--- /dev/null
+++ b/archivebox/dead/util.py
@@ -0,0 +1,114 @@
+# ruff: noqa
+def short_ts(ts: Any) -> str | None:
+ parsed = parse_date(ts)
+ return None if parsed is None else str(parsed.timestamp()).split(".")[0]
+
+
+def ts_to_iso(ts: Any) -> str | None:
+ parsed = parse_date(ts)
+ return None if parsed is None else parsed.isoformat()
+
+
+def is_static_file(url: str):
+ # TODO: the proper way is with MIME type detection + ext, not only extension
+ return extension(url).lower() in CONSTANTS.STATICFILE_EXTENSIONS
+
+
+@enforce_types
+def str_between(string: str, start: str, end: str | None = None) -> str:
+ """(12345, , ) -> 12345"""
+
+ content = string.split(start, 1)[-1]
+ if end is not None:
+ content = content.rsplit(end, 1)[0]
+
+ return content
+
+
+@enforce_types
+def get_headers(url: str, timeout: int | None = None, config=None, **config_kwargs) -> str:
+ """Download the contents of a remote url and return the headers"""
+ # TODO: get rid of this and use an abx pluggy hook instead
+
+ from archivebox.config.common import get_config
+
+ config = config or get_config(**config_kwargs)
+ timeout = timeout or config.TIMEOUT
+
+ try:
+ response = requests.head(
+ url,
+ headers={"User-Agent": config.USER_AGENT},
+ verify=config.CHECK_SSL_VALIDITY,
+ timeout=timeout,
+ allow_redirects=True,
+ )
+ if response.status_code >= 400:
+ raise RequestException
+ except ReadTimeout:
+ raise
+ except RequestException:
+ response = requests.get(
+ url,
+ headers={"User-Agent": config.USER_AGENT},
+ verify=config.CHECK_SSL_VALIDITY,
+ timeout=timeout,
+ stream=True,
+ )
+
+ return pyjson.dumps(
+ {
+ "URL": url,
+ "Status-Code": response.status_code,
+ "Elapsed": response.elapsed.total_seconds() * 1000,
+ "Encoding": str(response.encoding),
+ "Apparent-Encoding": response.apparent_encoding,
+ **dict(response.headers),
+ },
+ indent=4,
+ )
+
+
+def chrome_cleanup(config=None, **config_kwargs):
+ """
+ Cleans up any state or runtime files that Chrome leaves behind when killed by
+ a timeout or other error. Handles:
+ - All persona chrome_profile directories (via Persona.cleanup_chrome_all())
+ - Explicit CHROME_USER_DATA_DIR from config
+ - Legacy Docker chromium path
+ """
+ import os
+ from pathlib import Path
+ from archivebox.config.permissions import IN_DOCKER
+
+ # Clean up all persona chrome directories using Persona class
+ try:
+ from archivebox.personas.models import Persona
+
+ # Clean up all personas
+ Persona.cleanup_chrome_all()
+
+ # Also clean up the active persona's explicit CHROME_USER_DATA_DIR if set
+ # (in case it's a custom path not under PERSONAS_DIR)
+ from archivebox.config.common import get_config
+
+ config = config or get_config(**config_kwargs)
+ chrome_user_data_dir = config.get("CHROME_USER_DATA_DIR")
+ if chrome_user_data_dir:
+ singleton_lock = Path(chrome_user_data_dir) / "SingletonLock"
+ if os.path.lexists(singleton_lock):
+ try:
+ singleton_lock.unlink()
+ except OSError:
+ pass
+ except Exception:
+ pass # Persona/config not available during early startup
+
+ # Legacy Docker cleanup (for backwards compatibility)
+ if IN_DOCKER:
+ singleton_lock = "/home/archivebox/.config/chromium/SingletonLock"
+ if os.path.lexists(singleton_lock):
+ try:
+ os.remove(singleton_lock)
+ except OSError:
+ pass
diff --git a/archivebox/personas/views.py b/archivebox/dead/views.py
similarity index 66%
rename from archivebox/personas/views.py
rename to archivebox/dead/views.py
index 60f00ef0..667554af 100644
--- a/archivebox/personas/views.py
+++ b/archivebox/dead/views.py
@@ -1 +1,2 @@
+# ruff: noqa
# Create your views here.
diff --git a/archivebox/hooks.py b/archivebox/hooks.py
index 603b2c16..54c96f3d 100644
--- a/archivebox/hooks.py
+++ b/archivebox/hooks.py
@@ -159,22 +159,6 @@ def normalize_hook_event_name(event_name: str) -> str | None:
return normalized
-class HookResult(TypedDict, total=False):
- """Raw result from run_hook()."""
-
- returncode: int
- stdout: str
- stderr: str
- output_json: dict[str, Any] | None
- output_files: list[dict[str, Any]]
- duration_ms: int
- hook: str
- plugin: str # Plugin name (directory name, e.g., 'wget', 'screenshot')
- hook_name: str # Full hook filename (e.g., 'on_Snapshot__50_wget.py')
- # New fields for JSONL parsing
- records: list[dict[str, Any]] # Parsed JSONL records with 'type' field
-
-
def _model_output_dir_from_child_path(path: Path, marker: str) -> Path | None:
"""
Infer the model output dir from a model dir or one of its plugin subdirs.
@@ -873,26 +857,6 @@ def discover_plugin_configs() -> dict[str, dict[str, Any]]:
return configs
-def get_config_defaults_from_plugins() -> dict[str, Any]:
- """
- Get default values for all plugin config options.
-
- Returns:
- Dict mapping config keys to their default values.
- e.g., {'SAVE_WGET': True, 'WGET_TIMEOUT': 60, ...}
- """
- plugin_configs = discover_plugin_configs()
- defaults = {}
-
- for plugin_name, schema in plugin_configs.items():
- properties = schema.get("properties", {})
- for key, prop_schema in properties.items():
- if "default" in prop_schema:
- defaults[key] = prop_schema["default"]
-
- return defaults
-
-
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.
diff --git a/archivebox/machine/detect.py b/archivebox/machine/detect.py
index 7b58f5d7..6a48e746 100644
--- a/archivebox/machine/detect.py
+++ b/archivebox/machine/detect.py
@@ -275,10 +275,6 @@ def get_host_stats() -> dict[str, Any]:
return {}
-def get_host_immutable_info(host_info: dict[str, Any]) -> dict[str, Any]:
- return {key: value for key, value in host_info.items() if key in ["guid", "net_mac", "os_family", "cpu_arch"]}
-
-
def get_host_guid() -> str:
return machineid.hashed_id("archivebox")
diff --git a/archivebox/machine/migrations/0017_shorten_process_progress_index_names.py b/archivebox/machine/migrations/0017_shorten_process_progress_index_names.py
new file mode 100644
index 00000000..617238f0
--- /dev/null
+++ b/archivebox/machine/migrations/0017_shorten_process_progress_index_names.py
@@ -0,0 +1,26 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("machine", "0016_process_delete_at"),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name="process",
+ name="machine_pro_progress_recent_idx",
+ ),
+ migrations.RemoveIndex(
+ model_name="process",
+ name="machine_pro_progress_running_idx",
+ ),
+ migrations.AddIndex(
+ model_name="process",
+ index=models.Index(fields=["machine", "process_type", "-modified_at"], name="mach_proc_recent_idx"),
+ ),
+ migrations.AddIndex(
+ model_name="process",
+ index=models.Index(fields=["machine", "status", "process_type"], name="mach_proc_running_idx"),
+ ),
+ ]
diff --git a/archivebox/machine/migrations/0018_alter_process_process_type.py b/archivebox/machine/migrations/0018_alter_process_process_type.py
new file mode 100644
index 00000000..9a93188c
--- /dev/null
+++ b/archivebox/machine/migrations/0018_alter_process_process_type.py
@@ -0,0 +1,34 @@
+# Generated by ArchiveBox on 2026-05-28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("machine", "0017_shorten_process_progress_index_names"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="process",
+ name="process_type",
+ field=models.CharField(
+ choices=[
+ ("supervisord", "Supervisord"),
+ ("orchestrator", "Orchestrator"),
+ ("server", "Server"),
+ ("update", "Update"),
+ ("add", "Add"),
+ ("search", "Search"),
+ ("worker", "Worker"),
+ ("cli", "CLI"),
+ ("hook", "Hook"),
+ ("binary", "Binary"),
+ ],
+ db_index=True,
+ default="cli",
+ help_text="Type of process (cli, worker, orchestrator, binary, supervisord)",
+ max_length=16,
+ ),
+ ),
+ ]
diff --git a/archivebox/machine/models.py b/archivebox/machine/models.py
index 9368c97b..0cef7c6d 100755
--- a/archivebox/machine/models.py
+++ b/archivebox/machine/models.py
@@ -3,7 +3,6 @@ from __future__ import annotations
__package__ = "archivebox.machine"
import os
-import shlex
import sys
import uuid
import socket
@@ -196,8 +195,10 @@ class Machine(ModelWithHealthStats):
app_label = "machine"
@classmethod
- def current(cls) -> Machine:
+ def current(cls, refresh: bool = False) -> Machine:
global _CURRENT_MACHINE
+ if refresh:
+ _CURRENT_MACHINE = None
if _CURRENT_MACHINE:
if timezone.now() < _CURRENT_MACHINE.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL):
return _CURRENT_MACHINE
@@ -335,7 +336,7 @@ class NetworkInterface(ModelWithHealthStats):
@classmethod
def current(cls, refresh: bool = False) -> NetworkInterface:
global _CURRENT_INTERFACE
- machine = Machine.current()
+ machine = Machine.current(refresh=refresh)
if _CURRENT_INTERFACE:
if (
not refresh
@@ -928,6 +929,10 @@ class Process(ModelWithDeleteAfter, models.Model):
class TypeChoices(models.TextChoices):
SUPERVISORD = "supervisord", "Supervisord"
ORCHESTRATOR = "orchestrator", "Orchestrator"
+ SERVER = "server", "Server"
+ UPDATE = "update", "Update"
+ ADD = "add", "Add"
+ SEARCH = "search", "Search"
WORKER = "worker", "Worker"
CLI = "cli", "CLI"
HOOK = "hook", "Hook"
@@ -1107,8 +1112,8 @@ class Process(ModelWithDeleteAfter, models.Model):
models.Index(fields=["binary", "exit_code"]),
models.Index(fields=["pid", "started_at"]),
models.Index(fields=["process_type", "worker_type", "pwd", "started_at"]),
- models.Index(fields=["machine", "process_type", "-modified_at"], name="machine_pro_progress_recent_idx"),
- models.Index(fields=["machine", "status", "process_type"], name="machine_pro_progress_running_idx"),
+ models.Index(fields=["machine", "process_type", "-modified_at"], name="mach_proc_recent_idx"),
+ models.Index(fields=["machine", "status", "process_type"], name="mach_proc_running_idx"),
]
def __str__(self) -> str:
@@ -1225,7 +1230,7 @@ class Process(ModelWithDeleteAfter, models.Model):
"""Parse JSONL records from this process's stdout."""
stdout = self.stdout
if not stdout and self.stdout_file and self.stdout_file.exists():
- stdout = self.stdout_file.read_text()
+ stdout = self.stdout_file.read_text(errors="replace")
return self.parse_records_from_text(stdout or "")
@staticmethod
@@ -1259,6 +1264,50 @@ class Process(ModelWithDeleteAfter, models.Model):
self.save()
return True
+ def mark_running(
+ self,
+ *,
+ process_type: str | None = None,
+ pwd: str | Path | None = None,
+ url: str | None = None,
+ worker_type: str = "",
+ timeout: int | None = None,
+ ) -> None:
+ """Record the current process role without changing ownership state elsewhere."""
+ updates = ["status", "retry_at", "modified_at"]
+ self.status = self.StatusChoices.RUNNING
+ self.retry_at = None
+ if process_type is not None and self.process_type != process_type:
+ self.process_type = process_type
+ updates.append("process_type")
+ if worker_type and self.worker_type != worker_type:
+ self.worker_type = worker_type
+ updates.append("worker_type")
+ if pwd is not None and self.pwd != str(pwd):
+ self.pwd = str(pwd)
+ updates.append("pwd")
+ if url is not None and self.url != url:
+ self.url = url
+ updates.append("url")
+ if timeout is not None and self.timeout != timeout:
+ self.timeout = timeout
+ updates.append("timeout")
+ self.save(update_fields=updates)
+
+ def heartbeat(self) -> None:
+ """Touch modified_at so standby/leader selection can see this parent is alive."""
+ self.save(update_fields=["modified_at"])
+
+ def mark_exited(self, *, exit_code: int = 0) -> None:
+ """Mark a foreground/internal process row exited after command cleanup."""
+ if self.status == self.StatusChoices.EXITED and self.exit_code == exit_code:
+ return
+ self.status = self.StatusChoices.EXITED
+ self.exit_code = exit_code
+ self.ended_at = self.ended_at or timezone.now()
+ self.retry_at = None
+ self.save(update_fields=["status", "exit_code", "ended_at", "retry_at", "modified_at"])
+
# =========================================================================
# Process.current() and hierarchy methods
# =========================================================================
@@ -1426,6 +1475,14 @@ class Process(ModelWithDeleteAfter, models.Model):
return cls.TypeChoices.SUPERVISORD
elif "runner_watch" in argv_str:
return cls.TypeChoices.WORKER
+ elif "archivebox server" in argv_str:
+ return cls.TypeChoices.SERVER
+ elif "archivebox update" in argv_str:
+ return cls.TypeChoices.UPDATE
+ elif "archivebox add" in argv_str:
+ return cls.TypeChoices.ADD
+ elif "archivebox search" in argv_str or "archivebox list" in argv_str:
+ return cls.TypeChoices.SEARCH
elif "archivebox run" in argv_str:
return cls.TypeChoices.ORCHESTRATOR
elif "archivebox" in argv_str:
@@ -1451,7 +1508,9 @@ class Process(ModelWithDeleteAfter, models.Model):
if machine is not None:
stale = stale.filter(machine=machine)
- for proc in stale:
+ # Recovery can run against damaged DB state; stream rows so a large
+ # stale Process backlog cannot be materialized in memory at once.
+ for proc in stale.iterator(chunk_size=100):
if proc.poll() is not None:
cleaned += 1
continue
@@ -1461,7 +1520,7 @@ class Process(ModelWithDeleteAfter, models.Model):
if proc.started_at:
timeout_seconds = max(int(proc.timeout or 0), 0)
timeout_deadline = proc.started_at + timedelta(seconds=timeout_seconds) + PROCESS_TIMEOUT_GRACE
- if timezone.now() >= timeout_deadline:
+ if timeout_seconds > 0 and timezone.now() >= timeout_deadline:
is_stale = True
# Check if too old (PID definitely reused)
@@ -1483,7 +1542,7 @@ class Process(ModelWithDeleteAfter, models.Model):
proc.status = cls.StatusChoices.EXITED
proc.ended_at = proc.ended_at or timezone.now()
proc.exit_code = proc.exit_code if proc.exit_code is not None else 0
- proc.save(update_fields=["status", "ended_at", "exit_code"])
+ proc.save(update_fields=["status", "ended_at", "exit_code", "modified_at"])
cleaned += 1
return cleaned
@@ -1652,18 +1711,6 @@ class Process(ModelWithDeleteAfter, models.Model):
# Lifecycle methods (launch, kill, poll, wait)
# =========================================================================
- @property
- def pid_file(self) -> Path | None:
- """Path to PID file for this process."""
- runtime_dir = self.runtime_dir
- return runtime_dir / "process.pid" if runtime_dir else None
-
- @property
- def cmd_file(self) -> Path | None:
- """Path to cmd.sh script for this process."""
- runtime_dir = self.runtime_dir
- return runtime_dir / "cmd.sh" if runtime_dir else None
-
@property
def stdout_file(self) -> Path | None:
"""Path to stdout log."""
@@ -1694,7 +1741,7 @@ class Process(ModelWithDeleteAfter, models.Model):
@property
def runtime_dir(self) -> Path | None:
- """Directory where this process stores runtime logs/pid/cmd metadata."""
+ """Directory where this process stores runtime stdout/stderr logs."""
if not self.pwd:
return None
@@ -1830,32 +1877,6 @@ class Process(ModelWithDeleteAfter, models.Model):
for line in self.tail_stderr(lines=lines, follow=follow):
print(line, file=sys.stderr, flush=True)
- def _write_pid_file(self) -> None:
- """Write PID file with mtime set to process start time."""
- if self.pid and self.started_at and self.pid_file:
- self.pid_file.parent.mkdir(parents=True, exist_ok=True)
- # Write PID to file
- self.pid_file.write_text(str(self.pid))
- # Set mtime to process start time for validation
- try:
- start_time = self.started_at.timestamp()
- os.utime(self.pid_file, (start_time, start_time))
- except OSError:
- pass # mtime optional, validation degrades gracefully
-
- def _write_cmd_file(self) -> None:
- """Write cmd.sh script for debugging/validation."""
- if self.cmd and self.cmd_file:
- self.cmd_file.parent.mkdir(parents=True, exist_ok=True)
-
- # Write executable shell script
- script = "#!/bin/bash\n" + shlex.join(self.cmd) + "\n"
- self.cmd_file.write_text(script)
- try:
- self.cmd_file.chmod(0o755)
- except OSError:
- pass
-
def ensure_log_files(self) -> None:
"""Ensure stdout/stderr log files exist for this process."""
runtime_dir = self.runtime_dir
@@ -1918,9 +1939,6 @@ class Process(ModelWithDeleteAfter, models.Model):
# Use provided cwd or default to pwd
working_dir = cwd or self.pwd
- # Write cmd.sh for debugging
- self._write_cmd_file()
-
stdout_path = self.stdout_file
stderr_path = self.stderr_file
if stdout_path:
@@ -1956,8 +1974,6 @@ class Process(ModelWithDeleteAfter, models.Model):
self.status = self.StatusChoices.RUNNING
self.save()
- self._write_pid_file()
-
if not background:
try:
proc.wait(timeout=self.timeout)
@@ -1971,9 +1987,9 @@ class Process(ModelWithDeleteAfter, models.Model):
self.ended_at = timezone.now()
if stdout_path.exists():
- self.stdout = stdout_path.read_text()
+ self.stdout = stdout_path.read_text(errors="replace")
if stderr_path.exists():
- self.stderr = stderr_path.read_text()
+ self.stderr = stderr_path.read_text(errors="replace")
self.status = self.StatusChoices.EXITED
self.save()
@@ -2013,10 +2029,6 @@ class Process(ModelWithDeleteAfter, models.Model):
self.status = self.StatusChoices.EXITED
self.save()
- # Clean up PID file
- if self.pid_file and self.pid_file.exists():
- self.pid_file.unlink(missing_ok=True)
-
return True
except (psutil.NoSuchProcess, psutil.AccessDenied, ProcessLookupError):
# Process already exited between proc check and kill
@@ -2052,24 +2064,14 @@ class Process(ModelWithDeleteAfter, models.Model):
pass
# Process exited - read output and copy to DB
if self.stdout_file and self.stdout_file.exists():
- self.stdout = self.stdout_file.read_text()
+ self.stdout = self.stdout_file.read_text(errors="replace")
# TODO: Uncomment to cleanup (keeping for debugging for now)
# self.stdout_file.unlink(missing_ok=True)
if self.stderr_file and self.stderr_file.exists():
- self.stderr = self.stderr_file.read_text()
+ self.stderr = self.stderr_file.read_text(errors="replace")
# TODO: Uncomment to cleanup (keeping for debugging for now)
# self.stderr_file.unlink(missing_ok=True)
- # Clean up PID file (not needed for debugging)
- if self.pid_file and self.pid_file.exists():
- self.pid_file.unlink(missing_ok=True)
-
- # TODO: Uncomment to cleanup cmd.sh (keeping for debugging for now)
- # if self.pwd:
- # cmd_file = Path(self.pwd) / 'cmd.sh'
- # if cmd_file.exists():
- # cmd_file.unlink(missing_ok=True)
-
# Try to get exit code from proc or default to unknown
self.exit_code = self.exit_code if self.exit_code is not None else 0
if self.exit_code == -1:
@@ -2433,12 +2435,14 @@ class Process(ModelWithDeleteAfter, models.Model):
status=cls.StatusChoices.RUNNING,
)
- for proc in running_children:
+ # Recovery can run against damaged DB state; stream rows so a large
+ # orphaned Process backlog cannot be materialized in memory at once.
+ for proc in running_children.iterator(chunk_size=100):
if not proc.is_running:
proc.status = cls.StatusChoices.EXITED
proc.ended_at = proc.ended_at or timezone.now()
proc.exit_code = proc.exit_code if proc.exit_code is not None else 0
- proc.save(update_fields=["status", "ended_at", "exit_code"])
+ proc.save(update_fields=["status", "ended_at", "exit_code", "modified_at"])
cleaned += 1
continue
@@ -2447,14 +2451,21 @@ class Process(ModelWithDeleteAfter, models.Model):
if root.id == proc.id and root.process_type in (cls.TypeChoices.WORKER, cls.TypeChoices.HOOK):
continue
- # If root is an active orchestrator/cli, keep it
- if root.process_type in (cls.TypeChoices.ORCHESTRATOR, cls.TypeChoices.CLI) and root.is_running:
+ # If root is an active ArchiveBox command/orchestrator, keep it.
+ if root.process_type in (
+ cls.TypeChoices.ORCHESTRATOR,
+ cls.TypeChoices.SERVER,
+ cls.TypeChoices.UPDATE,
+ cls.TypeChoices.ADD,
+ cls.TypeChoices.SEARCH,
+ cls.TypeChoices.CLI,
+ ) and root.is_running:
continue
proc.status = cls.StatusChoices.EXITED
proc.ended_at = proc.ended_at or timezone.now()
proc.exit_code = proc.exit_code if proc.exit_code is not None else 0
- proc.save(update_fields=["status", "ended_at", "exit_code"])
+ proc.save(update_fields=["status", "ended_at", "exit_code", "modified_at"])
cleaned += 1
if cleaned:
diff --git a/archivebox/misc/checks.py b/archivebox/misc/checks.py
index 2e56c891..33399b45 100644
--- a/archivebox/misc/checks.py
+++ b/archivebox/misc/checks.py
@@ -2,6 +2,7 @@ __package__ = "archivebox.misc"
import os
import sys
+import time
from pathlib import Path
from rich import print
@@ -55,23 +56,43 @@ def check_data_folder(config=None, **config_kwargs) -> None:
check_data_dir_permissions(config=config)
-def check_migrations():
+def check_migrations(*, blocking: bool = True, auto_apply: bool = False, cancel_delay: int = 3) -> list[str]:
from archivebox import DATA_DIR
- from archivebox.misc.db import list_migrations
+ from archivebox.misc.db import apply_migrations, pending_migrations
- pending_migrations = [name for status, name in list_migrations() if not status]
+ pending = pending_migrations()
is_migrating = any(arg in sys.argv for arg in ["makemigrations", "migrate", "init"])
- if pending_migrations and not is_migrating:
- print("[red][X] This collection was created with an older version of ArchiveBox and must be upgraded first.[/red]")
+ if pending and not is_migrating:
+ print("[red][X] This collection was created with an older version of ArchiveBox and must be upgraded first.[/red]", file=sys.stderr)
print(f" {DATA_DIR}", file=sys.stderr)
print(file=sys.stderr)
print(
- f" [violet]Hint:[/violet] To upgrade it to the latest version and apply the {len(pending_migrations)} pending migrations, run:",
+ f" [violet]Hint:[/violet] To upgrade it to the latest version and apply the {len(pending)} pending migrations, run:",
file=sys.stderr,
)
print(" archivebox init", file=sys.stderr)
- raise SystemExit(3)
+ if auto_apply:
+ print(file=sys.stderr)
+ print(
+ f"[yellow][*] ArchiveBox will apply migrations automatically in {cancel_delay}s. Press CTRL+C to cancel.[/yellow]",
+ file=sys.stderr,
+ )
+ try:
+ time.sleep(cancel_delay)
+ except KeyboardInterrupt:
+ print("[red][X] Migration cancelled before any changes were applied.[/red]", 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)
+ return pending_migrations()
+ if blocking:
+ raise SystemExit(3)
+ return pending
def check_io_encoding():
diff --git a/archivebox/misc/db.py b/archivebox/misc/db.py
index d9e66f3f..b9eeb853 100644
--- a/archivebox/misc/db.py
+++ b/archivebox/misc/db.py
@@ -6,48 +6,181 @@ __package__ = "archivebox.misc"
from io import StringIO
from pathlib import Path
+from typing import TextIO
from typing import Any
+import fcntl
+import importlib
+import time
+from collections.abc import Callable
+from contextlib import contextmanager
+from sqlite3 import OperationalError as SQLiteOperationalError
from archivebox.config import DATA_DIR
from archivebox.misc.util import enforce_types
-@enforce_types
-def list_migrations(out_dir: Path = DATA_DIR) -> list[tuple[bool, str]]:
- """List all Django migrations and their status"""
- from django.core.management import call_command
+def compact_command(cmdline: list[str] | None, fallback: str = "") -> str:
+ parts = [str(part) for part in (cmdline or []) if str(part)]
+ if not parts:
+ return fallback
+ for marker in ("archivebox", "daphne", "gunicorn", "uvicorn", "supervisord", "sonic", "node"):
+ for idx, part in enumerate(parts):
+ if Path(part).name == marker or part == marker:
+ return " ".join([Path(parts[idx]).name, *parts[idx + 1 :]])[:220]
+ return " ".join([Path(parts[0]).name, *parts[1:]])[:220]
- out = StringIO()
- call_command("showmigrations", list=True, stdout=out)
- out.seek(0)
- migrations = []
- for line in out.readlines():
- if line.strip() and "]" in line:
- status_str, name_str = line.strip().split("]", 1)
- is_applied = "X" in status_str
- migration_name = name_str.strip()
- migrations.append((is_applied, migration_name))
+def sqlite_lock_holders(db_path: Path = DATA_DIR / "index.sqlite3") -> list[str]:
+ import psutil
- return migrations
+ db_path = db_path.resolve()
+ holders: list[str] = []
+ for proc in psutil.process_iter(["pid", "ppid", "name", "cmdline", "status"]):
+ try:
+ open_files = proc.open_files()
+ except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess):
+ continue
+ for open_file in open_files:
+ try:
+ open_path = Path(open_file.path).resolve()
+ except (OSError, RuntimeError):
+ continue
+ if open_path == db_path or open_path.name in {f"{db_path.name}-wal", f"{db_path.name}-shm", f"{db_path.name}-journal"}:
+ info = proc.info
+ cmdline = compact_command(info.get("cmdline"), fallback=info.get("name") or "")
+ holders.append(f"pid={info['pid']} ppid={info['ppid']} {info['status']} {cmdline}")
+ break
+ return holders
+
+
+def sqlite_lock_error(error: BaseException) -> bool:
+ return isinstance(error, SQLiteOperationalError) and "database is locked" in str(error).lower()
+
+
+def retry_sqlite_locks(action: Callable[[], Any], *, label: str, stderr: TextIO | None = None) -> Any:
+ from django.db import OperationalError, connections
+ from rich.console import Console
+
+ console = Console(file=stderr or None, stderr=stderr is None)
+ attempts = 0
+ while True:
+ try:
+ return action()
+ except OperationalError as err:
+ if "database is locked" not in str(err).lower():
+ raise
+ except SQLiteOperationalError as err:
+ 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]")
+ with console.status("[yellow]Waiting for SQLite database lock to clear...[/yellow]", spinner="dots"):
+ time.sleep(5.0)
+
+
+@contextmanager
+def migration_lock(stdout: TextIO | None = None):
+ from archivebox.config.paths import get_or_create_working_tmp_dir
+ from rich.console import Console
+
+ lock_path = get_or_create_working_tmp_dir(autofix=True, quiet=True) / "migrate.lock"
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+ with lock_path.open("a+") as lock_file:
+ try:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ # Migrations on large SQLite collections can run for hours. Use a
+ # kernel lock with no timeout so parallel ArchiveBox commands queue
+ # behind the active migrate process instead of racing it.
+ console = Console(file=stdout or None, stderr=stdout is None)
+ with console.status("[yellow]Waiting for migration lock...[/yellow]", spinner="dots"):
+ while True:
+ try:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ break
+ except BlockingIOError:
+ time.sleep(1.0)
+ try:
+ yield
+ finally:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
@enforce_types
-def apply_migrations(out_dir: Path = DATA_DIR) -> list[str]:
+def pending_migrations(out_dir: Path = DATA_DIR) -> list[str]:
+ """Cheaply compare migration files to django_migrations without invoking migrate."""
+ from django.apps import apps
+ from django.db import connection
+ from django.db.migrations.loader import MigrationLoader
+
+ def applied_rows() -> set[tuple[str, str]]:
+ with connection.cursor() as cursor:
+ try:
+ cursor.execute("SELECT app, name FROM django_migrations")
+ except Exception as err:
+ if "no such table" in str(err).lower():
+ return set()
+ raise
+ return {(str(app), str(name)) for app, name in cursor.fetchall()}
+
+ applied = retry_sqlite_locks(applied_rows, label="checking applied migrations")
+ disk_migrations: set[tuple[str, str]] = set()
+ for app_config in apps.get_app_configs():
+ module_name, explicit = MigrationLoader.migrations_module(app_config.label)
+ if module_name is None:
+ continue
+ try:
+ module = importlib.import_module(module_name)
+ except ModuleNotFoundError:
+ if explicit:
+ raise
+ continue
+ module_file = getattr(module, "__file__", None)
+ if not module_file:
+ continue
+ for migration_file in Path(module_file).parent.glob("[0-9][0-9][0-9][0-9]_*.py"):
+ disk_migrations.add((app_config.label, migration_file.stem))
+
+ return [f"{app}.{name}" for app, name in sorted(disk_migrations - applied)]
+
+
+@enforce_types
+def apply_migrations(out_dir: Path = DATA_DIR, stdout: TextIO | None = None, stderr: TextIO | None = None, verbosity: int = 1) -> list[str]:
"""Apply pending Django migrations"""
from django.core.management import call_command
- out1 = StringIO()
+ with migration_lock(stdout=stderr or stdout):
+ if not pending_migrations():
+ return []
- call_command("migrate", interactive=False, database="default", stdout=out1)
- out1.seek(0)
+ if stdout is not None:
+ retry_sqlite_locks(
+ lambda: call_command("migrate", interactive=False, database="default", stdout=stdout, stderr=stderr, verbosity=verbosity),
+ label="applying migrations",
+ stderr=stderr,
+ )
+ return []
- return [line.strip() for line in out1.readlines() if line.strip()]
+ def migrate() -> StringIO:
+ out1 = StringIO()
+ call_command("migrate", interactive=False, database="default", stdout=out1, verbosity=verbosity)
+ out1.seek(0)
+ return out1
+ out1 = retry_sqlite_locks(migrate, label="applying migrations")
-@enforce_types
-def get_admins(out_dir: Path = DATA_DIR) -> list[Any]:
- """Get list of superuser accounts"""
- from django.contrib.auth.models import User
+ return [line.strip() for line in out1.readlines() if line.strip()]
- return list(User.objects.filter(is_superuser=True).exclude(username="system"))
diff --git a/archivebox/misc/jsonl.py b/archivebox/misc/jsonl.py
index e2322774..8d3201ca 100644
--- a/archivebox/misc/jsonl.py
+++ b/archivebox/misc/jsonl.py
@@ -164,15 +164,3 @@ def write_record(record: dict[str, Any], stream: TextIO | None = None) -> None:
active_stream.write(json.dumps(record) + "\n")
active_stream.flush()
-
-def write_records(records: Iterator[dict[str, Any]], stream: TextIO | None = None) -> int:
- """
- Write multiple JSONL records to stdout (or provided stream).
-
- Returns count of records written.
- """
- count = 0
- for record in records:
- write_record(record, stream)
- count += 1
- return count
diff --git a/archivebox/misc/monkey_patches.py b/archivebox/misc/monkey_patches.py
index 20430385..2e8f0906 100644
--- a/archivebox/misc/monkey_patches.py
+++ b/archivebox/misc/monkey_patches.py
@@ -35,13 +35,28 @@ warnings.filterwarnings("ignore", category=SyntaxWarning, module="sonic")
class ModifiedAccessLogGenerator(access.AccessLogGenerator):
"""Clutge workaround until daphne uses the Python logging framework. https://github.com/django/daphne/pull/473/files"""
- def write_entry(self, host, date, request, status=None, length=None, ident=None, user=None):
+ def __call__(self, protocol, action, details):
+ if protocol == "http" and action == "complete":
+ self.write_entry(
+ host=details["client"],
+ date=datetime.datetime.now(),
+ request="%(method)s %(path)s" % details,
+ status=details["status"],
+ length=details["size"],
+ time_taken=details.get("time_taken"),
+ )
+ return
+ return super().__call__(protocol, action, details)
+
+ def write_entry(self, host, date, request, status=None, length=None, ident=None, user=None, time_taken=None):
# Ignore noisy requests to staticfiles / favicons / etc.
if "GET /static/" in request:
return
if "GET /health/" in request:
return
+ if "GET /admin/live-progress/" in request and (time_taken is None or time_taken < 1.0):
+ return
if "GET /admin/jsi18n/" in request:
return
if request.endswith("/favicon.ico") or request.endswith("/robots.txt") or request.endswith("/screenshot.png"):
diff --git a/archivebox/misc/serve_static.py b/archivebox/misc/serve_static.py
index 7b602dcd..64d5c210 100644
--- a/archivebox/misc/serve_static.py
+++ b/archivebox/misc/serve_static.py
@@ -93,7 +93,7 @@ def _resolve_archive_path(document_root: str | Path, rel_path: str) -> tuple[Pat
def _cache_policy(config=None, **config_kwargs) -> str:
config = config or get_config(resolve_plugins=False, **config_kwargs)
- return "public" if config.PUBLIC_SNAPSHOTS else "private"
+ return "private" if config.PERMISSIONS == "private" else "public"
def _render_mhtml_preview_document(filename: str, output_path: str) -> str:
diff --git a/archivebox/misc/system.py b/archivebox/misc/system.py
index c1fdb071..e24f2667 100644
--- a/archivebox/misc/system.py
+++ b/archivebox/misc/system.py
@@ -3,7 +3,6 @@ __package__ = "archivebox.misc"
import os
import signal
-import shutil
import sys
from json import dump
@@ -124,43 +123,6 @@ def atomic_write(path: Path | str, contents: dict | str | bytes, overwrite: bool
os.chmod(path, int(config.OUTPUT_PERMISSIONS, base=8))
-@enforce_types
-def chmod_file(path: str, cwd: str = "", config=None, **config_kwargs) -> None:
- """chmod -R /"""
-
- root = Path(cwd or os.getcwd()) / path
- if not os.access(root, os.R_OK):
- raise Exception(f"Failed to chmod: {path} does not exist (did the previous step fail?)")
-
- if not root.is_dir():
- # path is just a plain file
- config = config or get_config(**config_kwargs)
- os.chmod(root, int(config.OUTPUT_PERMISSIONS, base=8))
- else:
- config = config or get_config(**config_kwargs)
- for subpath in Path(path).glob("**/*"):
- if subpath.is_dir():
- # directories need execute permissions to be able to list contents
- os.chmod(subpath, int(config.DIR_OUTPUT_PERMISSIONS, base=8))
- else:
- os.chmod(subpath, int(config.OUTPUT_PERMISSIONS, base=8))
-
-
-@enforce_types
-def copy_and_overwrite(from_path: str | Path, to_path: str | Path):
- """copy a given file or directory to a given path, overwriting the destination"""
-
- assert os.access(from_path, os.R_OK)
-
- if Path(from_path).is_dir():
- shutil.rmtree(to_path, ignore_errors=True)
- shutil.copytree(from_path, to_path)
- else:
- with open(from_path, "rb") as src:
- contents = src.read()
- atomic_write(to_path, contents)
-
-
@enforce_types
def get_dir_size(path: str | Path, recursive: bool = True, pattern: str | None = None) -> tuple[int, int, int]:
"""get the total disk size of a given directory, optionally summing up
@@ -187,44 +149,3 @@ def get_dir_size(path: str | Path, recursive: bool = True, pattern: str | None =
pass
return num_bytes, num_dirs, num_files
-
-class suppress_output:
- """
- A context manager for doing a "deep suppression" of stdout and stderr in
- Python, i.e. will suppress all print, even if the print originates in a
- compiled C/Fortran sub-function.
-
- This will not suppress raised exceptions, since exceptions are printed
- to stderr just before a script exits, and after the context manager has
- exited (at least, I think that is why it lets exceptions through).
-
- with suppress_stdout_stderr():
- rogue_function()
- """
-
- def __init__(self, stdout=True, stderr=True):
- # Open a pair of null files
- # Save the actual stdout (1) and stderr (2) file descriptors.
- self.stdout, self.stderr = stdout, stderr
- if stdout:
- self.null_stdout = os.open(os.devnull, os.O_RDWR)
- self.real_stdout = os.dup(1)
- if stderr:
- self.null_stderr = os.open(os.devnull, os.O_RDWR)
- self.real_stderr = os.dup(2)
-
- def __enter__(self):
- # Assign the null pointers to stdout and stderr.
- if self.stdout:
- os.dup2(self.null_stdout, 1)
- if self.stderr:
- os.dup2(self.null_stderr, 2)
-
- def __exit__(self, *_):
- # Re-assign the real stdout/stderr back to (1) and (2)
- if self.stdout:
- os.dup2(self.real_stdout, 1)
- os.close(self.null_stdout)
- if self.stderr:
- os.dup2(self.real_stderr, 2)
- os.close(self.null_stderr)
diff --git a/archivebox/misc/util.py b/archivebox/misc/util.py
index 05584305..3e8bde11 100644
--- a/archivebox/misc/util.py
+++ b/archivebox/misc/util.py
@@ -16,7 +16,6 @@ from hashlib import sha256
from urllib.parse import urlparse, quote, unquote
from html import escape, unescape
from datetime import datetime, timezone
-from requests.exceptions import RequestException, ReadTimeout
from base32_crockford import encode as base32_encode
from w3lib.encoding import html_body_declared_encoding, http_content_type_encoding
@@ -29,8 +28,6 @@ except ImportError:
detect_encoding = lambda rawdata: "utf-8"
-from archivebox.config.constants import CONSTANTS
-
from .logging import COLOR_DICT
@@ -61,21 +58,11 @@ htmlencode = lambda s: s and escape(s, quote=True)
htmldecode = lambda s: s and unescape(s)
-def short_ts(ts: Any) -> str | None:
- parsed = parse_date(ts)
- return None if parsed is None else str(parsed.timestamp()).split(".")[0]
-
-
def ts_to_date_str(ts: Any) -> str | None:
parsed = parse_date(ts)
return None if parsed is None else parsed.strftime("%Y-%m-%d %H:%M")
-def ts_to_iso(ts: Any) -> str | None:
- parsed = parse_date(ts)
- return None if parsed is None else parsed.isoformat()
-
-
COLOR_REGEX = re.compile(r"\[(?P\d+)(;(?P\d+)(;(?P\d+))?)?m")
@@ -296,11 +283,6 @@ def parse_filesize_to_bytes(value: str | int | float | None) -> int:
return int(amount * multiplier)
-def is_static_file(url: str):
- # TODO: the proper way is with MIME type detection + ext, not only extension
- return extension(url).lower() in CONSTANTS.STATICFILE_EXTENSIONS
-
-
def enforce_types(func):
"""
Enforce function arg and kwarg types at runtime using its python3 type hints
@@ -355,17 +337,6 @@ def docstring(text: str | None):
return decorator
-@enforce_types
-def str_between(string: str, start: str, end: str | None = None) -> str:
- """(12345, , ) -> 12345"""
-
- content = string.split(start, 1)[-1]
- if end is not None:
- content = content.rsplit(end, 1)[0]
-
- return content
-
-
@enforce_types
def parse_date(date: Any) -> datetime | None:
"""Parse unix timestamps, iso format, and human-readable strings"""
@@ -448,50 +419,6 @@ def download_url(url: str, timeout: int | None = None, config=None, **config_kwa
return url.rsplit("/", 1)[-1]
-@enforce_types
-def get_headers(url: str, timeout: int | None = None, config=None, **config_kwargs) -> str:
- """Download the contents of a remote url and return the headers"""
- # TODO: get rid of this and use an abx pluggy hook instead
-
- from archivebox.config.common import get_config
-
- config = config or get_config(**config_kwargs)
- timeout = timeout or config.TIMEOUT
-
- try:
- response = requests.head(
- url,
- headers={"User-Agent": config.USER_AGENT},
- verify=config.CHECK_SSL_VALIDITY,
- timeout=timeout,
- allow_redirects=True,
- )
- if response.status_code >= 400:
- raise RequestException
- except ReadTimeout:
- raise
- except RequestException:
- response = requests.get(
- url,
- headers={"User-Agent": config.USER_AGENT},
- verify=config.CHECK_SSL_VALIDITY,
- timeout=timeout,
- stream=True,
- )
-
- return pyjson.dumps(
- {
- "URL": url,
- "Status-Code": response.status_code,
- "Elapsed": response.elapsed.total_seconds() * 1000,
- "Encoding": str(response.encoding),
- "Apparent-Encoding": response.apparent_encoding,
- **dict(response.headers),
- },
- indent=4,
- )
-
-
@enforce_types
def ansi_to_html(text: str) -> str:
"""
@@ -698,50 +625,3 @@ _test_url_strs = {
for url_str, num_urls in _test_url_strs.items():
assert len(list(find_all_urls(url_str))) == num_urls, f"{url_str} does not contain {num_urls} urls"
-
-### Chrome Helpers
-
-
-def chrome_cleanup(config=None, **config_kwargs):
- """
- Cleans up any state or runtime files that Chrome leaves behind when killed by
- a timeout or other error. Handles:
- - All persona chrome_profile directories (via Persona.cleanup_chrome_all())
- - Explicit CHROME_USER_DATA_DIR from config
- - Legacy Docker chromium path
- """
- import os
- from pathlib import Path
- from archivebox.config.permissions import IN_DOCKER
-
- # Clean up all persona chrome directories using Persona class
- try:
- from archivebox.personas.models import Persona
-
- # Clean up all personas
- Persona.cleanup_chrome_all()
-
- # Also clean up the active persona's explicit CHROME_USER_DATA_DIR if set
- # (in case it's a custom path not under PERSONAS_DIR)
- from archivebox.config.common import get_config
-
- config = config or get_config(**config_kwargs)
- chrome_user_data_dir = config.get("CHROME_USER_DATA_DIR")
- if chrome_user_data_dir:
- singleton_lock = Path(chrome_user_data_dir) / "SingletonLock"
- if os.path.lexists(singleton_lock):
- try:
- singleton_lock.unlink()
- except OSError:
- pass
- except Exception:
- pass # Persona/config not available during early startup
-
- # Legacy Docker cleanup (for backwards compatibility)
- if IN_DOCKER:
- singleton_lock = "/home/archivebox/.config/chromium/SingletonLock"
- if os.path.lexists(singleton_lock):
- try:
- os.remove(singleton_lock)
- except OSError:
- pass
diff --git a/archivebox/personas/admin.py b/archivebox/personas/admin.py
index 7caee68a..bcde4c16 100644
--- a/archivebox/personas/admin.py
+++ b/archivebox/personas/admin.py
@@ -26,7 +26,7 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
(
"Persona",
{
- "fields": ("name", "created_by"),
+ "fields": ("name", "created_by", "permissions"),
"classes": ("card", "persona-card-primary"),
},
),
diff --git a/archivebox/personas/forms.py b/archivebox/personas/forms.py
index 7fee60e6..5520a004 100644
--- a/archivebox/personas/forms.py
+++ b/archivebox/personas/forms.py
@@ -7,6 +7,7 @@ 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.personas.importers import (
PersonaImportResult,
PersonaImportSource,
@@ -25,6 +26,12 @@ def _mode_label(title: str, description: str) -> str:
class PersonaAdminForm(PluginConfigFormMixin, forms.ModelForm):
+ permissions = forms.ChoiceField(
+ label="Permissions",
+ choices=PERMISSIONS_CHOICES,
+ required=True,
+ help_text="Default visibility for crawls and snapshots that use this persona.",
+ )
import_mode = forms.ChoiceField(
required=False,
initial="none",
@@ -102,6 +109,7 @@ class PersonaAdminForm(PluginConfigFormMixin, forms.ModelForm):
self.fields["import_mode"].widget.attrs["class"] = "abx-import-mode"
self.fields["import_discovered_profile"].widget.attrs["class"] = "abx-profile-picker"
+ self.fields["permissions"].initial = str((self.instance.config or {}).get("PERMISSIONS") or "public").strip().lower()
if self.discovered_profiles:
self.fields["import_discovered_profile"].choices = [
@@ -131,6 +139,7 @@ class PersonaAdminForm(PluginConfigFormMixin, forms.ModelForm):
manual_config = cleaned_data.get("config") or {}
if not isinstance(manual_config, dict):
manual_config = {}
+ manual_config["PERMISSIONS"] = cleaned_data.get("permissions") or "public"
plugin_config_overrides = self.clean_plugin_config_overrides(get_config())
cleaned_data["plugin_config"] = plugin_config_overrides
cleaned_data["config"] = {
diff --git a/archivebox/personas/migrations/0003_persona_permissions.py b/archivebox/personas/migrations/0003_persona_permissions.py
new file mode 100644
index 00000000..ca3e9917
--- /dev/null
+++ b/archivebox/personas/migrations/0003_persona_permissions.py
@@ -0,0 +1,19 @@
+# Generated by Django 6.0.5 on 2026-05-28 07:25
+
+import django.db.models.fields.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('personas', '0002_alter_persona_id'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='persona',
+ name='permissions',
+ field=models.GeneratedField(db_index=True, db_persist=True, expression=django.db.models.fields.json.KeyTextTransform('PERMISSIONS', 'config'), output_field=models.CharField(max_length=16, null=True)),
+ ),
+ ]
diff --git a/archivebox/personas/models.py b/archivebox/personas/models.py
index a8da9fe9..93b9a930 100644
--- a/archivebox/personas/models.py
+++ b/archivebox/personas/models.py
@@ -19,6 +19,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from django.db import models
+from django.db.models.fields.json import KT
from django.conf import settings
from django.utils import timezone
@@ -83,6 +84,13 @@ class Persona(ModelWithConfig):
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)
+ permissions = models.GeneratedField(
+ expression=KT("config__PERMISSIONS"),
+ output_field=models.CharField(max_length=16, null=True),
+ db_persist=True,
+ db_index=True,
+ editable=False,
+ )
class Meta(ModelWithConfig.Meta):
app_label = "personas"
diff --git a/archivebox/search/__init__.py b/archivebox/search/__init__.py
index 6d89b66a..9c7ad698 100644
--- a/archivebox/search/__init__.py
+++ b/archivebox/search/__init__.py
@@ -28,6 +28,11 @@ from archivebox.config.common import get_config
# Cache discovered backends to avoid repeated filesystem scans
_search_backends_cache: dict | None = None
SEARCH_MODES = ("meta", "contents", "deep")
+SEARCH_BACKEND_UI_NAMES = {
+ "rg": "ripgrep",
+ "sonic": "sonic",
+ "fts": "sqlite",
+}
@contextmanager
@@ -54,14 +59,72 @@ def search_backend_env(config: dict[str, Any] | None = None, **config_kwargs: An
os.environ[key] = value
+def normalize_search_backend_name(backend_name: str | None) -> str:
+ return (backend_name or "").strip().lower().replace("-", "_")
+
+
+def get_search_backend_display_name(backend_name: str) -> str:
+ backend_name = normalize_search_backend_name(backend_name)
+ return next((ui_name for ui_name, canonical_name in SEARCH_BACKEND_UI_NAMES.items() if canonical_name == backend_name), backend_name)
+
+
def get_default_search_mode(config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
config = config or get_config(**config_kwargs)
- return "meta" if config.SEARCH_BACKEND_ENGINE == "ripgrep" else "contents"
+ backend_name = normalize_search_backend_name(config.SEARCH_BACKEND_ENGINE)
+ backends = get_available_backends()
+ if backend_name in backends:
+ return f"deep:{backend_name}"
+ if "ripgrep" in backends:
+ return "deep:ripgrep"
+ return "contents"
def get_search_mode(search_mode: str | None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
- normalized = (search_mode or "").strip().lower()
- return normalized if normalized in SEARCH_MODES else get_default_search_mode(config=config, **config_kwargs)
+ normalized = (search_mode or "").strip().lower().replace(" ", "")
+ if normalized in SEARCH_MODES:
+ return normalized
+ if ":" in normalized:
+ mode, backend_name = normalized.split(":", 1)
+ backend_name = normalize_search_backend_name(backend_name)
+ if mode == "deep" and backend_name in get_available_backends():
+ return f"{mode}:{backend_name}"
+ return get_default_search_mode(config=config, **config_kwargs)
+
+
+def get_search_mode_base(search_mode: str | None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
+ return get_search_mode(search_mode, config=config, **config_kwargs).split(":", 1)[0]
+
+
+def get_search_mode_backend(search_mode: str | None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str | None:
+ normalized = get_search_mode(search_mode, config=config, **config_kwargs)
+ if ":" not in normalized:
+ return None
+ return normalized.split(":", 1)[1]
+
+
+def get_search_mode_options(config: dict[str, Any] | None = None, **config_kwargs: Any) -> list[dict[str, str]]:
+ config = config or get_config(**config_kwargs)
+ backends = get_available_backends()
+ configured_backend = normalize_search_backend_name(config.SEARCH_BACKEND_ENGINE)
+ backend_names = [
+ *([configured_backend] if configured_backend in backends else []),
+ *(name for name in sorted(backends) if name != configured_backend),
+ ]
+ options = [
+ {"value": "meta", "label": "meta"},
+ {"value": "contents", "label": "contents"},
+ ]
+ if backend_names:
+ options.extend(
+ {
+ "value": f"deep:{backend_name}",
+ "label": f"deep: {get_search_backend_display_name(backend_name)}",
+ }
+ for backend_name in backend_names
+ )
+ else:
+ options.append({"value": "deep", "label": "deep"})
+ return options
def prioritize_metadata_matches(
@@ -127,7 +190,7 @@ def get_backend(config: dict[str, Any] | None = None, **config_kwargs: Any) -> A
Falls back to 'ripgrep' if configured backend is not found.
"""
config = config or get_config(**config_kwargs)
- backend_name = config.SEARCH_BACKEND_ENGINE
+ backend_name = normalize_search_backend_name(config.SEARCH_BACKEND_ENGINE)
backends = get_available_backends()
if backend_name in backends:
@@ -158,25 +221,50 @@ def query_search_index(query: str, search_mode: str | None = None, config: dict[
return Snapshot.objects.none()
search_mode = "contents" if search_mode is None else get_search_mode(search_mode, config=config)
- if search_mode == "meta":
+ search_mode_base = get_search_mode_base(search_mode, config=config)
+ if search_mode_base == "meta":
return Snapshot.objects.none()
+ from archivebox.services.supervision_service import ensure_daemon_stack
+
+ ensure_daemon_stack(reason="search query")
+ snapshot_pks = list(iter_query_search_ids(query, search_mode=search_mode, config=config))
+ return Snapshot.objects.filter(pk__in=list(dict.fromkeys(snapshot_pks)))
+
+
+def iter_query_search_ids(query: str, search_mode: str | None = None, config: dict[str, Any] | None = None, **config_kwargs: Any):
+ """Yield snapshot IDs from configured search backends as soon as each backend produces them."""
+ config = config or get_config(**config_kwargs)
+ if not config.USE_SEARCHING_BACKEND:
+ return
+
+ search_mode = "contents" if search_mode is None else get_search_mode(search_mode, config=config)
+ search_mode_base = get_search_mode_base(search_mode, config=config)
+ forced_backend = get_search_mode_backend(search_mode, config=config)
+ if search_mode_base == "meta":
+ return
backends = get_available_backends()
- backend_names: list[str] = []
- configured_backend = config.SEARCH_BACKEND_ENGINE
- if search_mode == "deep":
- if "ripgrep" in backends:
- backend_names.append("ripgrep")
- backend_names.extend(name for name in backends if name != "ripgrep")
+ configured_backend = normalize_search_backend_name(config.SEARCH_BACKEND_ENGINE)
+ if forced_backend:
+ if forced_backend not in backends:
+ raise RuntimeError(
+ f'Search backend "{forced_backend}" not found. Available backends: {list(backends) or "none"}',
+ )
+ backend_names = [forced_backend]
+ elif search_mode_base == "deep":
+ backend_names = [
+ *([configured_backend] if configured_backend in backends and configured_backend != "ripgrep" else []),
+ *(name for name in backends if name not in {configured_backend, "ripgrep"}),
+ *(["ripgrep"] if "ripgrep" in backends else []),
+ ]
elif configured_backend in backends:
- backend_names.append(configured_backend)
+ backend_names = [configured_backend]
elif "ripgrep" in backends:
- backend_names.append("ripgrep")
+ backend_names = ["ripgrep"]
else:
get_backend()
- return Snapshot.objects.none()
+ return
- snapshot_pks: list[str] = []
errors: list[Exception] = []
successful_backends = 0
try:
@@ -184,14 +272,16 @@ def query_search_index(query: str, search_mode: str | None = None, config: dict[
backend = backends[backend_name]
try:
with search_backend_env(config=config):
- if backend_name == "ripgrep":
- snapshot_pks.extend(backend.search(query, search_mode=search_mode))
+ if hasattr(backend, "iter_search"):
+ yield from backend.iter_search(query, search_mode=search_mode_base)
+ elif backend_name == "ripgrep":
+ yield from backend.search(query, search_mode=search_mode_base)
else:
- snapshot_pks.extend(backend.search(query))
+ yield from backend.search(query)
successful_backends += 1
except Exception as err:
errors.append(err)
- if search_mode != "deep":
+ if search_mode_base != "deep" or forced_backend:
raise
except Exception as err:
stderr()
@@ -201,9 +291,8 @@ def query_search_index(query: str, search_mode: str | None = None, config: dict[
)
raise
else:
- if not successful_backends and errors and search_mode == "deep":
+ if not successful_backends and errors and search_mode_base == "deep":
raise errors[0]
- return Snapshot.objects.filter(pk__in=list(dict.fromkeys(snapshot_pks)))
@enforce_types
diff --git a/archivebox/search/admin.py b/archivebox/search/admin.py
index 9540870f..3c07cb2c 100644
--- a/archivebox/search/admin.py
+++ b/archivebox/search/admin.py
@@ -1,16 +1,69 @@
__package__ = "archivebox.search"
-from django.contrib import messages
-from django.contrib import admin
-from django.contrib.admin.views.main import ChangeList, ORDER_VAR
+import hashlib
+import json
-from archivebox.search import get_default_search_mode, get_search_mode, prioritize_metadata_matches, query_search_index
+from django.contrib import admin
+from django.contrib.admin.views.main import ChangeList
+from django.core.cache import cache
+
+from archivebox.search import (
+ get_search_backend_display_name,
+ get_default_search_mode,
+ get_search_mode,
+ get_search_mode_backend,
+ get_search_mode_base,
+ get_search_mode_options,
+ query_search_index,
+)
+
+
+SEARCH_RESULT_CACHE_TTL = 60
+
+
+def get_admin_search_cache_key(request, url: str | None = None) -> str:
+ # Search streams publish IDs for one exact changelist URL. Keeping the URL
+ # whole makes sidebar filters, ordering, and user scope part of the key.
+ payload = json.dumps(
+ {
+ "user": str(request.user.pk or "anon"),
+ "url": url or request.get_full_path(),
+ },
+ sort_keys=True,
+ )
+ return f"abx:admin-search:{hashlib.sha256(payload.encode()).hexdigest()}"
+
+
+def get_cached_admin_search_ids(request) -> list[str] | None:
+ cached = cache.get(get_admin_search_cache_key(request))
+ if isinstance(cached, dict):
+ return cached.get("ids") or []
+ return None
class SearchResultsChangeList(ChangeList):
+ def __init__(self, request, *args, **kwargs):
+ self.search_mode = get_search_mode(request.GET.get("search_mode"), config=getattr(request, "archivebox_config", None))
+ self.search_mode_backend = get_search_mode_backend(self.search_mode, config=getattr(request, "archivebox_config", None))
+ self.search_backend_label = get_search_backend_display_name(self.search_mode_backend) if self.search_mode_backend else ""
+ super().__init__(request, *args, **kwargs)
+ self.embedded_changelist = request.GET.get("_embedded") == "crawl"
+
+ def get_results(self, request):
+ super().get_results(request)
+ self.show_search_index_hint = bool(
+ self.opts.model_name == "snapshot"
+ and self.query
+ and self.result_count == 0
+ and get_search_mode_base(self.search_mode, config=getattr(request, "archivebox_config", None)) == "deep"
+ and self.search_mode_backend
+ )
+
def get_filters_params(self, params=None):
lookup_params = super().get_filters_params(params)
lookup_params.pop("search_mode", None)
+ lookup_params.pop("_embedded", None)
+ lookup_params.pop("per_page", None)
return lookup_params
@@ -24,37 +77,36 @@ class SearchResultsAdminMixin(admin.ModelAdmin):
request = getattr(self, "request", None)
return get_default_search_mode(config=getattr(request, "archivebox_config", None))
+ def get_search_mode_options(self):
+ request = getattr(self, "request", None)
+ return get_search_mode_options(config=getattr(request, "archivebox_config", None))
+
def get_search_results(self, request, queryset, search_term: str):
"""Enhances the search queryset with results from the search backend"""
- qs, use_distinct = super().get_search_results(request, queryset, search_term)
-
search_term = search_term.strip()
if not search_term:
- return qs, use_distinct
- search_mode = get_search_mode(request.GET.get("search_mode"))
- if search_mode == "meta":
- return qs, use_distinct
- try:
- deep_qsearch = None
- if search_mode == "deep":
- qsearch = query_search_index(search_term, search_mode="contents")
- deep_qsearch = query_search_index(search_term, search_mode="deep")
- else:
- qsearch = query_search_index(search_term, search_mode=search_mode)
- qs = prioritize_metadata_matches(
- queryset,
- qs,
- qsearch,
- deep_queryset=deep_qsearch,
- ordering=() if not request.GET.get(ORDER_VAR) else None,
- )
- except Exception as err:
- print(f"[!] Error while using search backend: {err.__class__.__name__} {err}")
- messages.add_message(
- request,
- messages.WARNING,
- f"Error from the search backend, only showing results from default admin search fields - Error: {err}",
- )
+ return super().get_search_results(request, queryset, search_term)
+ search_mode = get_search_mode(request.GET.get("search_mode"), config=getattr(request, "archivebox_config", None))
+ if queryset.model._meta.label_lower == "core.snapshot" and request.GET.get("_embedded") != "crawl":
+ cached_ids = get_cached_admin_search_ids(request)
+ if cached_ids is not None:
+ return queryset.filter(pk__in=cached_ids) if cached_ids else queryset.none(), False
+ return queryset.none(), False
- return qs, True
+ if get_search_mode_base(search_mode, config=getattr(request, "archivebox_config", None)) == "meta":
+ qs, use_distinct = super().get_search_results(request, queryset, search_term)
+ return qs, use_distinct
+ if request.GET.get("_embedded") == "crawl":
+ try:
+ return queryset.filter(
+ pk__in=query_search_index(
+ search_term,
+ search_mode=search_mode,
+ config=getattr(request, "archivebox_config", None),
+ ).values("pk"),
+ ), False
+ except Exception as err:
+ print(f"[!] Error while using search backend: {err.__class__.__name__} {err}")
+ return queryset.none(), False
+ return queryset.none(), False
diff --git a/archivebox/search/sonic_daemon.py b/archivebox/search/sonic_daemon.py
index b085434a..b0237cd5 100644
--- a/archivebox/search/sonic_daemon.py
+++ b/archivebox/search/sonic_daemon.py
@@ -21,6 +21,9 @@ def register_sonic_daemon_event_handler(bus) -> None:
from archivebox.workers.supervisord_util import get_existing_supervisord_process, get_worker
daemon_event = SonicDaemonStartEvent.from_record(record)
+ if is_port_listening(daemon_event.host, daemon_event.port):
+ return
+
supervisor = get_existing_supervisord_process()
if supervisor is None:
raise RuntimeError("Sonic search backend is required, but ArchiveBox supervisord is not running")
@@ -32,7 +35,6 @@ def register_sonic_daemon_event_handler(bus) -> None:
raise RuntimeError(
f"Sonic search backend worker is {worker.get('statename')}: {worker.get('description')}",
)
- if not is_port_listening(daemon_event.host, daemon_event.port):
- raise RuntimeError(f"Sonic search backend is not listening at {daemon_event.url}")
+ raise RuntimeError(f"Sonic search backend is not listening at {daemon_event.url}")
bus.on(ProcessStdoutEvent, on_ProcessStdoutEvent__require_sonic_daemon)
diff --git a/archivebox/services/archive_result_service.py b/archivebox/services/archive_result_service.py
index 427cb114..c53b46cb 100644
--- a/archivebox/services/archive_result_service.py
+++ b/archivebox/services/archive_result_service.py
@@ -17,7 +17,7 @@ from .process_service import parse_event_datetime
def _collect_output_metadata(plugin_dir: Path) -> tuple[dict[str, dict], int, str]:
- exclude_names = {"stdout.log", "stderr.log", "process.pid", "hook.pid", "listener.pid", "cmd.sh"}
+ exclude_names = {"stdout.log", "stderr.log", "process.pid", "hook.pid", "listener.pid"}
output_files: dict[str, dict] = {}
mime_sizes: dict[str, int] = defaultdict(int)
total_size = 0
diff --git a/archivebox/services/crawl_service.py b/archivebox/services/crawl_service.py
index 9b89b34f..ae410aee 100644
--- a/archivebox/services/crawl_service.py
+++ b/archivebox/services/crawl_service.py
@@ -22,6 +22,8 @@ class CrawlService(BaseService):
from archivebox.crawls.models import Crawl
crawl = await Crawl.objects.aget(id=self.crawl_id)
+ if crawl.is_paused:
+ return
if crawl.status != Crawl.StatusChoices.SEALED:
crawl.status = Crawl.StatusChoices.STARTED
crawl.retry_at = None
@@ -31,6 +33,8 @@ class CrawlService(BaseService):
from archivebox.crawls.models import Crawl
crawl = await Crawl.objects.aget(id=self.crawl_id)
+ if crawl.is_paused:
+ return
if crawl.status != Crawl.StatusChoices.SEALED:
crawl.status = Crawl.StatusChoices.STARTED
crawl.retry_at = None
@@ -41,8 +45,10 @@ class CrawlService(BaseService):
from archivebox.core.models import Snapshot
crawl = await Crawl.objects.aget(id=self.crawl_id)
+ if crawl.is_paused:
+ return
is_finished = not await crawl.snapshot_set.filter(
- status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED],
+ status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.PAUSED],
).aexists()
if is_finished:
crawl.status = Crawl.StatusChoices.SEALED
@@ -59,8 +65,10 @@ class CrawlService(BaseService):
from archivebox.core.models import Snapshot
crawl = await Crawl.objects.aget(id=self.crawl_id)
+ if crawl.is_paused:
+ return
is_finished = not await crawl.snapshot_set.filter(
- status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED],
+ status__in=[Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.PAUSED],
).aexists()
if not is_finished:
if crawl.status != Crawl.StatusChoices.SEALED:
diff --git a/archivebox/services/runner.py b/archivebox/services/runner.py
index 24ff7240..7e33b7db 100644
--- a/archivebox/services/runner.py
+++ b/archivebox/services/runner.py
@@ -11,7 +11,6 @@ import sys
import time
from collections.abc import Mapping
from contextlib import nullcontext
-from datetime import timedelta
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
@@ -50,11 +49,13 @@ from abx_dl.orchestrator import (
from abx_dl.services.process_service import ProcessService as HookProcessService
from abx_dl.services.binary_service import BinaryService as HookBinaryService
from abx_dl.services.snapshot_service import SnapshotService as HookSnapshotService
+from abx_dl.cli import LiveBusUI
from abxbus import BaseEvent
from abxbus.event_bus import EventBus, get_current_event, in_handler_context
from abxbus.event_handler import EventHandlerAbortedError, EventHandlerCancelledError
from archivebox.config.configset import BaseConfigSet
+from archivebox.core.recovery_util import recover_orchestrator_state
from archivebox.search.sonic_daemon import register_sonic_daemon_event_handler
from .archive_result_service import ArchiveResultService
@@ -64,7 +65,6 @@ from .machine_service import MachineService
from .process_service import ProcessService as PersistedProcessService
from .snapshot_service import SnapshotService
from .tag_service import TagService
-from .live_ui import LiveBusUI
def _bus_name(prefix: str, identifier: str) -> str:
@@ -182,6 +182,7 @@ class CrawlRunner:
snapshot_ids: list[str] | None = None,
selected_plugins: list[str] | None = None,
process_discovered_snapshots_inline: bool = True,
+ show_progress: bool = True,
):
self.crawl = crawl
self.bus = create_bus(name=_bus_name("ArchiveBox", str(crawl.id)), total_timeout=3600.0)
@@ -194,6 +195,7 @@ class CrawlRunner:
CrawlService(self.bus, crawl_id=str(crawl.id))
MachineService(self.bus)
self.process_discovered_snapshots_inline = process_discovered_snapshots_inline
+ self.show_progress = show_progress
async def ignore_snapshot(_snapshot_id: str) -> None:
return None
@@ -224,7 +226,7 @@ class CrawlRunner:
def _install_signal_handlers(self) -> list[tuple[signal.Signals, Any, bool]]:
loop = asyncio.get_running_loop()
installed: list[tuple[signal.Signals, Any, bool]] = []
- for sig in (signal.SIGINT, signal.SIGTERM):
+ for sig in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM):
previous = signal.getsignal(sig)
def request_abort(sig=sig) -> None:
@@ -273,6 +275,12 @@ class CrawlRunner:
return True
return await Crawl.objects.filter(id=self.crawl.id, status=Crawl.StatusChoices.SEALED).aexists()
+ async def crawl_is_paused(self) -> bool:
+ from archivebox.crawls.models import Crawl
+
+ crawl = await Crawl.objects.only("status").aget(id=self.crawl.id)
+ return crawl.is_paused
+
async def watch_for_cancelled_crawl(self, parent_event: BaseEvent, *, poll_interval: float = 1.0) -> None:
while True:
await asyncio.sleep(poll_interval)
@@ -285,6 +293,10 @@ class CrawlRunner:
def runtime_plugins(self) -> dict[str, Plugin]:
return filter_plugins(self.plugins, self.selected_plugins, include_providers=True) if self.selected_plugins else self.plugins
+ @property
+ def allow_paused_snapshot_maintenance(self) -> bool:
+ return bool(self.initial_snapshot_ids and self.selected_plugins)
+
async def run(self) -> None:
heartbeat = CrawlHeartbeat(
Path(self.crawl_output_dir),
@@ -293,6 +305,7 @@ class CrawlRunner:
)
installed_signal_handlers = self._install_signal_handlers()
root_snapshot_id: str | None = None
+ bus_destroyed = False
try:
self._run_task = asyncio.current_task()
snapshot_ids = await sync_to_async(self.load_run_state, thread_sensitive=True)()
@@ -301,24 +314,37 @@ class CrawlRunner:
self.snapshot_semaphore = asyncio.Semaphore(max_concurrent_snapshots)
live_ui = self._create_live_ui()
with live_ui if live_ui is not None else nullcontext():
- await heartbeat.start()
- await _emit_machine_config(
- self.bus,
- config={
- **self.base_config,
- "ABX_RUNTIME": "archivebox",
- },
- derived_config=self.derived_config,
- )
- if snapshot_ids:
- root_snapshot_id = snapshot_ids[0]
- await self.run_crawl(root_snapshot_id, snapshot_ids)
+ try:
+ await heartbeat.start()
+ await _emit_machine_config(
+ self.bus,
+ config={
+ **self.base_config,
+ "ABX_RUNTIME": "archivebox",
+ },
+ derived_config=self.derived_config,
+ )
+ if snapshot_ids:
+ root_snapshot_id = snapshot_ids[0]
+ await self.run_crawl(root_snapshot_id, snapshot_ids)
+ finally:
+ self._run_task = None
+ self._restore_signal_handlers(installed_signal_handlers)
+ await heartbeat.stop()
+ await self.stop_snapshot_tasks()
+ try:
+ if not self._skip_wait_until_idle:
+ await self.bus.wait_until_idle(timeout=30.0)
+ finally:
+ await self.bus.destroy(clear=False)
+ bus_destroyed = True
finally:
- self._run_task = None
- self._restore_signal_handlers(installed_signal_handlers)
- await heartbeat.stop()
- if not self._skip_wait_until_idle:
- await self.bus.wait_until_idle(timeout=30.0)
+ if not bus_destroyed:
+ self._run_task = None
+ self._restore_signal_handlers(installed_signal_handlers)
+ await heartbeat.stop()
+ await self.stop_snapshot_tasks()
+ await self.bus.destroy(clear=False)
if self._live_stream is not None:
try:
self._live_stream.close()
@@ -330,6 +356,8 @@ class CrawlRunner:
async def enqueue_snapshot(self, snapshot_id: str, crawl_start_event: CrawlStartEvent | None = None) -> None:
if await self.crawl_is_cancelled():
return
+ if await self.crawl_is_paused() and not self.allow_paused_snapshot_maintenance:
+ return
task = self.snapshot_tasks.get(snapshot_id)
if task is not None and not task.done():
return
@@ -342,6 +370,15 @@ class CrawlRunner:
task = asyncio.create_task(self.run_snapshot(snapshot_id), context=_runner_task_context())
self.snapshot_tasks[snapshot_id] = task
+ async def stop_snapshot_tasks(self) -> None:
+ if not self.snapshot_tasks:
+ return
+ done, pending = await asyncio.wait(list(self.snapshot_tasks.values()), timeout=5.0)
+ for task in pending:
+ task.cancel()
+ await asyncio.gather(*done, *pending, return_exceptions=True)
+ self.snapshot_tasks.clear()
+
async def wait_for_snapshot_tasks(self) -> None:
task_errors: list[Exception] = []
stop_scheduling = False
@@ -388,7 +425,10 @@ class CrawlRunner:
except Exception as err:
task_errors.append(err)
stop_scheduling = True
- if self.snapshot_tasks and await self.crawl_is_cancelled():
+ if self.snapshot_tasks and (
+ await self.crawl_is_cancelled()
+ or (await self.crawl_is_paused() and not self.allow_paused_snapshot_maintenance)
+ ):
stop_scheduling = True
if not stop_scheduling:
await self.enqueue_pending_snapshots_from_projection()
@@ -422,6 +462,8 @@ class CrawlRunner:
return
if await self.crawl_is_cancelled():
return
+ if await self.crawl_is_paused() and not self.allow_paused_snapshot_maintenance:
+ return
await sync_to_async(self.crawl.refresh_from_db, thread_sensitive=True)()
config = await sync_to_async(lambda: get_config(crawl=self.crawl, include_machine=False), thread_sensitive=True)()
@@ -433,7 +475,7 @@ class CrawlRunner:
return
pending_snapshot_ids = await sync_to_async(
lambda: list(
- self.crawl.snapshot_set.exclude(status=Snapshot.StatusChoices.SEALED)
+ self.crawl.snapshot_set.filter(status=Snapshot.StatusChoices.QUEUED)
.exclude(id__in=active_snapshot_ids)
.filter(retry_at__lte=timezone.now())
.order_by("depth", "created_at")
@@ -447,6 +489,7 @@ class CrawlRunner:
def load_run_state(self) -> list[str]:
from archivebox.config.common import get_config
+ from archivebox.core.models import Snapshot
from archivebox.hooks import discover_hooks
from archivebox.machine.models import Machine, NetworkInterface, Process, _sanitize_machine_config
@@ -480,22 +523,34 @@ class CrawlRunner:
),
)
if self.initial_snapshot_ids:
+ # Direct snapshot maintenance paths are allowed to name paused
+ # snapshots explicitly. The runner still requires selected_plugins
+ # later, so this does not restart the crawl lifecycle.
return [str(snapshot_id) for snapshot_id in self.initial_snapshot_ids]
+ if self.crawl.is_paused:
+ return []
pending_snapshots = list(
- self.crawl.snapshot_set.exclude(status="sealed").order_by("depth", "created_at"),
+ self.crawl.snapshot_set.filter(status=Snapshot.StatusChoices.QUEUED)
+ .filter(retry_at__lte=timezone.now())
+ .order_by("depth", "created_at"),
)
if pending_snapshots:
return [str(snapshot.id) for snapshot in pending_snapshots]
+ if self.crawl.snapshot_set.exclude(status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED]).exists():
+ return []
created = self.crawl.create_snapshots_from_urls()
snapshots = created or list(self.crawl.snapshot_set.filter(depth=0).order_by("created_at"))
return [str(snapshot.id) for snapshot in snapshots]
def finalize_run_state(self) -> None:
from archivebox.crawls.models import Crawl
+ from archivebox.core.models import Snapshot
if self.persona:
self.persona.cleanup_runtime_for_crawl(self.crawl)
crawl = Crawl.objects.get(id=self.crawl.id)
+ if crawl.is_paused:
+ return
if crawl.is_finished():
if crawl.status != Crawl.StatusChoices.SEALED:
if crawl.status == Crawl.StatusChoices.STARTED:
@@ -506,23 +561,33 @@ class CrawlRunner:
retry_at=None,
)
return
+ active_snapshots = crawl.snapshot_set.filter(
+ status__in=[
+ Snapshot.StatusChoices.QUEUED,
+ Snapshot.StatusChoices.STARTED,
+ Snapshot.StatusChoices.PAUSED,
+ ],
+ )
+ next_snapshot_retry = active_snapshots.order_by("retry_at", "created_at").values_list("retry_at", flat=True).first()
if crawl.status == Crawl.StatusChoices.SEALED:
crawl.update_and_requeue(
status=Crawl.StatusChoices.QUEUED,
- retry_at=timezone.now(),
+ retry_at=next_snapshot_retry or timezone.now(),
)
return
elif crawl.status != Crawl.StatusChoices.STARTED:
crawl.update_and_requeue(
status=Crawl.StatusChoices.STARTED,
- retry_at=crawl.retry_at or timezone.now(),
+ retry_at=crawl.retry_at or next_snapshot_retry or timezone.now(),
)
return
crawl.update_and_requeue(
- retry_at=crawl.retry_at or timezone.now(),
+ retry_at=crawl.retry_at or next_snapshot_retry or timezone.now(),
)
def _create_live_ui(self) -> LiveBusUI | None:
+ if not self.show_progress:
+ return None
stdout_is_tty = sys.stdout.isatty()
stderr_is_tty = sys.stderr.isatty()
interactive_tty = stdout_is_tty or stderr_is_tty
@@ -606,6 +671,8 @@ class CrawlRunner:
from archivebox.hooks import collect_urls_from_plugins
await sync_to_async(self.crawl.refresh_from_db, thread_sensitive=True)()
+ if self.crawl.is_paused and not self.allow_paused_snapshot_maintenance:
+ return
if int(snapshot_payload["depth"]) >= self.crawl.max_depth:
return
@@ -623,7 +690,7 @@ class CrawlRunner:
lambda: get_config(crawl=self.crawl, snapshot=parent_snapshot, include_machine=False),
thread_sensitive=True,
)()
- if CrawlLimitState.from_config(config).get_stop_reason() == "crawl_max_size":
+ if CrawlLimitState.from_config(config).get_stop_reason() in ("crawl_max_size", "crawl_timeout"):
return
await sync_to_async(self.crawl.create_discovered_snapshots, thread_sensitive=True)(
@@ -650,7 +717,7 @@ class CrawlRunner:
crawl_setup_phase_timeout = compute_phase_timeout(setup_hooks, config)
install_phase_timeout = compute_install_phase_timeout(get_install_plugins(plugins), config)
snapshot_hooks = [(plugin, hook) for plugin in plugins.values() for hook in plugin.filter_hooks("Snapshot")]
- max_snapshot_count = max(1, int(self.crawl.max_urls or len(snapshot_ids) or 1))
+ max_snapshot_count = max(1, int(config.get("CRAWL_MAX_URLS") or len(snapshot_ids) or 1))
snapshot_phase_timeout = compute_phase_timeout(snapshot_hooks, config) * max_snapshot_count
crawl_cleanup_phase_timeout = crawl_setup_phase_timeout
crawl_lifecycle_timeout = (
@@ -723,6 +790,8 @@ class CrawlRunner:
break
if await self.crawl_is_cancelled():
break
+ if await self.crawl_is_paused() and not self.allow_paused_snapshot_maintenance:
+ break
await self.enqueue_snapshot(snapshot_id)
await self.wait_for_snapshot_tasks()
@@ -732,7 +801,9 @@ class CrawlRunner:
cancel_watcher = asyncio.create_task(self.watch_for_cancelled_crawl(event))
try:
try:
- if not await self.crawl_is_cancelled():
+ if not await self.crawl_is_cancelled() and (
+ not await self.crawl_is_paused() or self.allow_paused_snapshot_maintenance
+ ):
await _run_event_now(
event.emit(
CrawlSetupEvent(
@@ -745,7 +816,9 @@ class CrawlRunner:
),
crawl_setup_phase_timeout,
)
- if not await self.crawl_is_cancelled():
+ if not await self.crawl_is_cancelled() and (
+ not await self.crawl_is_paused() or self.allow_paused_snapshot_maintenance
+ ):
crawl_start_event = CrawlStartEvent(
url=snapshot["url"],
snapshot_id=snapshot["id"],
@@ -838,9 +911,13 @@ class CrawlRunner:
if not isinstance(crawl_start_event, CrawlStartEvent):
raise RuntimeError("Snapshot events must be emitted from a CrawlStartEvent handler")
snapshot = await sync_to_async(self.load_snapshot_payload, thread_sensitive=True)(snapshot_id)
- if snapshot["status"] == "sealed":
+ if snapshot["status"] == "sealed" and not self.selected_plugins:
+ await sync_to_async(run_snapshot_maintenance, thread_sensitive=True)(snapshot_id)
return
- if snapshot["depth"] > 0 and CrawlLimitState.from_config(snapshot["config"]).get_stop_reason() == "crawl_max_size":
+ if snapshot["depth"] > 0 and CrawlLimitState.from_config(snapshot["config"]).get_stop_reason() in (
+ "crawl_max_size",
+ "crawl_timeout",
+ ):
await sync_to_async(self.seal_snapshot_due_to_limit, thread_sensitive=True)(snapshot_id)
return
config = _normalize_runtime_config(snapshot["config"])
@@ -889,6 +966,9 @@ class CrawlRunner:
raise RuntimeError(f"Snapshot {snapshot_id} did not complete")
await completed_snapshot.wait(timeout=snapshot_phase_timeout)
await completed_snapshot.event_results_list()
+ if snapshot["status"] == "sealed":
+ await sync_to_async(run_snapshot_maintenance, thread_sensitive=True)(snapshot_id)
+ return
await self.enqueue_discovered_snapshots_from_outputs(snapshot)
await sync_to_async(
lambda: (
@@ -898,6 +978,7 @@ class CrawlRunner:
status__in=[
self.crawl.snapshot_set.model.StatusChoices.QUEUED,
self.crawl.snapshot_set.model.StatusChoices.STARTED,
+ self.crawl.snapshot_set.model.StatusChoices.PAUSED,
],
).exists()
else None
@@ -928,6 +1009,7 @@ def run_crawl(
snapshot_ids: list[str] | None = None,
selected_plugins: list[str] | None = None,
process_discovered_snapshots_inline: bool = True,
+ show_progress: bool = True,
) -> None:
from archivebox.crawls.models import Crawl
@@ -938,6 +1020,7 @@ def run_crawl(
snapshot_ids=snapshot_ids,
selected_plugins=selected_plugins,
process_discovered_snapshots_inline=process_discovered_snapshots_inline,
+ show_progress=show_progress,
).run(),
)
@@ -994,6 +1077,148 @@ def run_binary(binary_id: str) -> None:
asyncio.run(_run_binary(binary_id))
+def queued_plugins_for_snapshot(snapshot_id: str) -> list[str] | None:
+ from archivebox.core.models import ArchiveResult
+
+ queued_plugins = sorted(
+ set(
+ ArchiveResult.objects.filter(
+ snapshot_id=snapshot_id,
+ status=ArchiveResult.StatusChoices.QUEUED,
+ )
+ .exclude(plugin="")
+ .values_list("plugin", flat=True),
+ ),
+ )
+ if queued_plugins:
+ return queued_plugins
+ return None
+
+
+def run_snapshot_maintenance(snapshot_id: str) -> bool:
+ from archivebox.core.models import ArchiveResult, Snapshot
+
+ snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by").filter(id=snapshot_id).first()
+ if snapshot is None:
+ return False
+ if snapshot.archiveresult_set.filter(status=ArchiveResult.StatusChoices.QUEUED).exists():
+ return False
+
+ # retry_at is the universal "tick me" signal. For already-sealed snapshots,
+ # a tick with no queued ArchiveResults is maintenance-only: run normal
+ # save/write side effects like lazy fs migration/json rewriting, then clear
+ # retry_at. Paused snapshots do not reach this helper while search/index
+ # plugin rows are queued; run_due_snapshot restores their paused scheduler
+ # marker after the targeted plugin rows finish.
+ snapshot.retry_at = None
+ snapshot.save(update_fields=["retry_at", "modified_at"])
+ snapshot.write_index_jsonl()
+ snapshot.write_json_details()
+ snapshot.write_html_details()
+ return True
+
+
+def run_due_crawl(crawl, *, lock_seconds: int) -> bool:
+ if crawl.is_paused:
+ return True
+ if crawl.status in (crawl.StatusChoices.QUEUED, crawl.StatusChoices.STARTED):
+ from archivebox.core.models import Snapshot
+
+ snapshot_count = crawl.snapshot_set.count()
+ due_nonsealed_snapshots = (
+ crawl.snapshot_set.filter(status=Snapshot.StatusChoices.QUEUED, retry_at__lte=timezone.now()).exists()
+ )
+ if snapshot_count and not due_nonsealed_snapshots:
+ crawl.retry_at = None
+ crawl.save(update_fields=["retry_at", "modified_at"])
+ return True
+ if not crawl.claim_processing_lock(lock_seconds=lock_seconds):
+ return False
+ run_crawl(str(crawl.id), process_discovered_snapshots_inline=True)
+ return True
+
+ if crawl.status == crawl.StatusChoices.SEALED:
+ crawl.retry_at = None
+ crawl.save(update_fields=["retry_at", "modified_at"])
+ return True
+
+ crawl.retry_at = None
+ crawl.save(update_fields=["retry_at", "modified_at"])
+ return True
+
+
+def run_due_snapshot(snapshot, *, lock_seconds: int) -> bool:
+ from archivebox.core.models import Snapshot
+
+ if snapshot.is_paused:
+ selected_plugins = queued_plugins_for_snapshot(str(snapshot.id))
+ if not selected_plugins:
+ # Paused is a real lifecycle state; retry_at=MAX is only the
+ # orchestrator selection marker. If a direct maintenance/update
+ # command bumps retry_at on a paused snapshot but there are no
+ # targeted ArchiveResult rows to run, restore the scheduler marker
+ # without changing status.
+ snapshot.restore_paused_scheduler_marker()
+ return True
+ if not Snapshot.claim_for_worker(snapshot, lock_seconds=lock_seconds):
+ return False
+ try:
+ # Explicit maintenance, e.g. `archivebox update --index-only`, may
+ # need to run search/index hooks for a paused snapshot. That should
+ # not resume the crawl or make unrelated queued work runnable, so
+ # selected_plugins is required and the paused state is restored in
+ # the finally block below.
+ run_crawl(
+ str(snapshot.crawl_id),
+ snapshot_ids=[str(snapshot.id)],
+ selected_plugins=selected_plugins,
+ process_discovered_snapshots_inline=True,
+ )
+ finally:
+ # Targeted plugin rows can complete while the Snapshot remains
+ # paused. Put retry_at back at MAX so the orchestrator leaves the
+ # paused lifecycle alone until an explicit resume transition.
+ snapshot.restore_paused_scheduler_marker()
+ return True
+ if snapshot.status == Snapshot.StatusChoices.SEALED:
+ if not Snapshot.claim_for_worker(snapshot, lock_seconds=lock_seconds):
+ return False
+ snapshot.refresh_from_db()
+ selected_plugins = queued_plugins_for_snapshot(str(snapshot.id))
+ if selected_plugins:
+ run_crawl(
+ str(snapshot.crawl_id),
+ snapshot_ids=[str(snapshot.id)],
+ selected_plugins=selected_plugins,
+ process_discovered_snapshots_inline=True,
+ )
+ return True
+ return run_snapshot_maintenance(str(snapshot.id))
+
+ if not snapshot.claim_processing_lock(lock_seconds=lock_seconds):
+ return False
+ run_crawl(
+ str(snapshot.crawl_id),
+ snapshot_ids=[str(snapshot.id)],
+ selected_plugins=queued_plugins_for_snapshot(str(snapshot.id)),
+ process_discovered_snapshots_inline=True,
+ )
+ return True
+
+
+def run_due_binary(binary, *, lock_seconds: int) -> bool:
+ binary_name = str(binary.name or "")
+ binary_path = Path(binary_name).expanduser()
+ if (binary_path.is_absolute() or binary_name.startswith("~")) and not binary_path.exists():
+ binary.retry_at = None
+ binary.save(update_fields=["retry_at", "modified_at"])
+ return True
+ if not binary.claim_processing_lock(lock_seconds=lock_seconds):
+ return False
+ run_binary(str(binary.id))
+ return True
+
+
async def _run_install(plugin_names: list[str] | None = None) -> None:
from archivebox.config.common import get_config
from archivebox.machine.models import Machine, _sanitize_machine_config
@@ -1012,6 +1237,7 @@ async def _run_install(plugin_names: list[str] | None = None) -> None:
MachineService(bus)
await _emit_machine_config(bus, config=config, derived_config=derived_config)
live_stream = None
+ bus_destroyed = False
try:
selected_plugins = filter_plugins(plugins, list(plugin_names), include_providers=True) if plugin_names else plugins
@@ -1068,20 +1294,28 @@ async def _run_install(plugin_names: list[str] | None = None) -> None:
plugins_label=plugins_label,
)
with live_ui if live_ui is not None else nullcontext():
- await abx_install_plugins(
- plugin_names=plugin_names,
- plugins=plugins,
- output_dir=output_dir,
- config_overrides=config,
- derived_config_overrides=derived_config,
- emit_jsonl=False,
- bus=bus,
- MachineService=None,
- )
+ try:
+ await abx_install_plugins(
+ plugin_names=plugin_names,
+ plugins=plugins,
+ output_dir=output_dir,
+ config_overrides=config,
+ derived_config_overrides=derived_config,
+ emit_jsonl=False,
+ bus=bus,
+ MachineService=None,
+ )
+ finally:
+ try:
+ await bus.wait_until_idle()
+ finally:
+ await bus.destroy(clear=False)
+ bus_destroyed = True
if live_ui is not None:
live_ui.print_summary(output_dir=output_dir)
finally:
- await bus.wait_until_idle()
+ if not bus_destroyed:
+ await bus.destroy(clear=False)
try:
if live_stream is not None:
live_stream.close()
@@ -1093,219 +1327,16 @@ def run_install(*, plugin_names: list[str] | None = None) -> None:
asyncio.run(_run_install(plugin_names=plugin_names))
-def recover_orphaned_crawls() -> int:
- from archivebox.crawls.models import Crawl
- from archivebox.core.models import Snapshot
- from archivebox.machine.models import Process
-
- active_crawl_ids: set[str] = set()
- orphaned_crawls = list(
- Crawl.objects.filter(
- status=Crawl.StatusChoices.STARTED,
- retry_at__isnull=True,
- ).prefetch_related("snapshot_set"),
- )
- running_processes = (
- Process.get_running()
- .filter(
- process_type__in=[
- Process.TypeChoices.WORKER,
- Process.TypeChoices.HOOK,
- Process.TypeChoices.BINARY,
- ],
- )
- .only("pwd")
- )
-
- for proc in running_processes:
- if not proc.pwd:
- continue
- proc_pwd = Path(proc.pwd)
- for crawl in orphaned_crawls:
- matched_snapshot = None
- for snapshot in crawl.snapshot_set.all():
- try:
- proc_pwd.relative_to(snapshot.output_dir)
- matched_snapshot = snapshot
- break
- except ValueError:
- continue
- if matched_snapshot is not None:
- active_crawl_ids.add(str(crawl.id))
- break
-
- recovered = 0
- now = timezone.now()
- for crawl in orphaned_crawls:
- if str(crawl.id) in active_crawl_ids:
- continue
-
- snapshots = list(crawl.snapshot_set.all())
- if not snapshots or all(snapshot.status == Snapshot.StatusChoices.SEALED for snapshot in snapshots):
- if crawl.status == Crawl.StatusChoices.STARTED:
- crawl.sm.seal()
- else:
- crawl.update_and_requeue(
- status=Crawl.StatusChoices.SEALED,
- retry_at=None,
- )
- recovered += 1
- continue
-
- crawl.update_and_requeue(
- status=Crawl.StatusChoices.STARTED,
- retry_at=now,
- )
- recovered += 1
-
- return recovered
-
-
-def recover_orphaned_snapshots() -> int:
- from archivebox.crawls.models import Crawl
- from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.machine.models import Process
- from django.db.models import Exists, OuterRef
-
- active_snapshot_ids: set[str] = set()
- now = timezone.now()
- orphaned_snapshots = list(
- Snapshot.objects.filter(status=Snapshot.StatusChoices.STARTED, retry_at__isnull=True)
- .select_related("crawl")
- .prefetch_related("archiveresult_set"),
- )
-
- queued_result_snapshot_ids = list(
- ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED).values_list("snapshot_id", flat=True).distinct(),
- )
- if queued_result_snapshot_ids:
- orphaned_snapshots.extend(
- snapshot
- for snapshot in Snapshot.objects.filter(id__in=queued_result_snapshot_ids)
- .select_related("crawl")
- .prefetch_related("archiveresult_set")
- if snapshot.status == Snapshot.StatusChoices.SEALED
- )
-
- recent_active_crawl_ids = list(
- Crawl.objects.filter(
- status__in=[Crawl.StatusChoices.STARTED, Crawl.StatusChoices.SEALED],
- modified_at__gte=now - timedelta(days=1),
- )
- .order_by("-modified_at")
- .values_list("id", flat=True)[:1000],
- )
- if recent_active_crawl_ids:
- orphaned_snapshots.extend(
- Snapshot.objects.filter(
- crawl_id__in=recent_active_crawl_ids,
- status=Snapshot.StatusChoices.SEALED,
- downloaded_at__isnull=False,
- )
- .annotate(has_results=Exists(ArchiveResult.objects.filter(snapshot_id=OuterRef("pk"))))
- .filter(has_results=False)
- .select_related("crawl")
- .prefetch_related("archiveresult_set")
- .order_by("-modified_at")[:1000],
- )
- running_processes = (
- Process.get_running()
- .filter(
- process_type__in=[
- Process.TypeChoices.WORKER,
- Process.TypeChoices.HOOK,
- Process.TypeChoices.BINARY,
- ],
- )
- .only("pwd")
- )
-
- for proc in running_processes:
- if not proc.pwd:
- continue
- proc_pwd = Path(proc.pwd)
- for snapshot in orphaned_snapshots:
- try:
- proc_pwd.relative_to(snapshot.output_dir)
- active_snapshot_ids.add(str(snapshot.id))
- break
- except ValueError:
- continue
-
- recovered = 0
- for snapshot in orphaned_snapshots:
- if str(snapshot.id) in active_snapshot_ids:
- continue
-
- results = list(snapshot.archiveresult_set.all())
- if results and all(result.status in ArchiveResult.FINAL_STATES for result in results):
- snapshot.downloaded_at = snapshot.downloaded_at or now
- snapshot.save(update_fields=["downloaded_at", "modified_at"])
- if snapshot.status == Snapshot.StatusChoices.STARTED:
- snapshot.sm.seal()
- else:
- snapshot.update_and_requeue(
- status=Snapshot.StatusChoices.SEALED,
- retry_at=None,
- )
-
- crawl = snapshot.crawl
- if crawl.is_finished() and crawl.status != Crawl.StatusChoices.SEALED:
- if crawl.status == Crawl.StatusChoices.STARTED:
- crawl.sm.seal()
- else:
- crawl.update_and_requeue(
- status=Crawl.StatusChoices.SEALED,
- retry_at=None,
- )
- recovered += 1
- continue
-
- snapshot.update_and_requeue(
- status=Snapshot.StatusChoices.QUEUED,
- retry_at=now,
- )
-
- crawl = snapshot.crawl
- crawl_status = crawl.status if crawl.status == Crawl.StatusChoices.STARTED else Crawl.StatusChoices.QUEUED
- crawl.update_and_requeue(
- status=crawl_status,
- retry_at=now,
- )
- recovered += 1
-
- return recovered
-
-
-def cleanup_orchestrator_state(*, recover: bool = True, include_chrome: bool = False) -> dict[str, int]:
- from archivebox.machine.models import Process
-
- cleaned = {
- "stale_processes": Process.cleanup_stale_running(),
- "orphaned_processes": Process.cleanup_orphaned_workers(),
- "orphaned_chrome": Process.cleanup_orphaned_chrome() if include_chrome else 0,
- "orphaned_snapshots": 0,
- "orphaned_crawls": 0,
- }
- if recover:
- cleaned["orphaned_snapshots"] = recover_orphaned_snapshots()
- cleaned["orphaned_crawls"] = recover_orphaned_crawls()
- return cleaned
-
-
def run_pending_crawls(*, daemon: bool = False, crawl_id: str | None = None) -> int:
from archivebox.crawls.models import Crawl, CrawlSchedule
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.machine.models import Binary, Process
+ crawl_claim_lock_seconds = 10
last_recovery_at = 0.0
last_retention_at = 0.0
while True:
now_monotonic = time.monotonic()
- if daemon:
- if now_monotonic - last_recovery_at >= 30.0:
- cleanup_orchestrator_state()
- last_recovery_at = now_monotonic
if now_monotonic - last_retention_at >= (60.0 if daemon else 1.0):
for model in (ArchiveResult, Snapshot, Crawl, Process):
model.delete_expired(batch_size=100)
@@ -1317,77 +1348,41 @@ def run_pending_crawls(*, daemon: bool = False, crawl_id: str | None = None) ->
if schedule.is_due(now):
schedule.enqueue(queued_at=now)
- queued_crawls = Crawl.objects.filter(
- retry_at__lte=timezone.now(),
- status=Crawl.StatusChoices.QUEUED,
- )
+ due_crawls = Crawl.objects.filter(retry_at__lte=timezone.now())
if crawl_id:
- queued_crawls = queued_crawls.filter(id=crawl_id)
- queued_crawls = queued_crawls.order_by("retry_at", "created_at")
-
- queued_crawl = queued_crawls.first()
- if queued_crawl is not None:
- if not queued_crawl.claim_processing_lock(lock_seconds=60):
+ due_crawls = due_crawls.filter(id=crawl_id)
+ due_crawl = due_crawls.order_by("retry_at", "created_at").first()
+ if due_crawl is not None:
+ if not run_due_crawl(due_crawl, lock_seconds=crawl_claim_lock_seconds):
continue
- run_crawl(str(queued_crawl.id), process_discovered_snapshots_inline=True)
continue
- pending = Crawl.objects.filter(
- retry_at__lte=timezone.now(),
- status=Crawl.StatusChoices.STARTED,
- )
+ due_snapshots = Snapshot.objects.filter(retry_at__lte=timezone.now()).select_related("crawl")
if crawl_id:
- pending = pending.filter(id=crawl_id)
- pending = pending.order_by("retry_at", "created_at")
-
- crawl = pending.first()
- if crawl is not None:
- if not crawl.claim_processing_lock(lock_seconds=60):
+ due_snapshots = due_snapshots.filter(crawl_id=crawl_id)
+ due_snapshot = due_snapshots.order_by("retry_at", "created_at").first()
+ if due_snapshot is not None:
+ if not run_due_snapshot(due_snapshot, lock_seconds=60):
continue
- run_crawl(str(crawl.id), process_discovered_snapshots_inline=True)
continue
if crawl_id is None:
- snapshot = (
- Snapshot.objects.filter(retry_at__lte=timezone.now())
- .exclude(status=Snapshot.StatusChoices.SEALED)
- .exclude(crawl__status__in=[Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED])
- .select_related("crawl")
- .order_by("retry_at", "created_at")
- .first()
- )
- if snapshot is not None:
- if not snapshot.claim_processing_lock(lock_seconds=60):
- continue
- run_crawl(
- str(snapshot.crawl_id),
- snapshot_ids=[str(snapshot.id)],
- process_discovered_snapshots_inline=True,
- )
- continue
-
- if crawl_id is None:
- # Standalone binary backlog should not starve queued crawls or snapshots.
- # Crawl.run() already claims and installs crawl-declared Binary rows as needed.
- binary = (
+ due_binary = (
Binary.objects.filter(retry_at__lte=timezone.now())
.exclude(status=Binary.StatusChoices.INSTALLED)
.order_by("retry_at", "created_at")
.first()
)
- if binary is not None:
- binary_name = str(binary.name or "")
- binary_path = Path(binary_name).expanduser()
- if (binary_path.is_absolute() or binary_name.startswith("~")) and not binary_path.exists():
- binary.retry_at = None
- binary.save(update_fields=["retry_at", "modified_at"])
+ if due_binary is not None:
+ if not run_due_binary(due_binary, lock_seconds=60):
continue
- if not binary.claim_processing_lock(lock_seconds=60):
- continue
- run_binary(str(binary.id))
continue
if daemon:
+ now_monotonic = time.monotonic()
+ if now_monotonic - last_recovery_at >= 30.0:
+ recover_orchestrator_state()
+ last_recovery_at = now_monotonic
time.sleep(2.0)
continue
return 0
diff --git a/archivebox/services/snapshot_service.py b/archivebox/services/snapshot_service.py
index 5b259554..e48b8188 100644
--- a/archivebox/services/snapshot_service.py
+++ b/archivebox/services/snapshot_service.py
@@ -24,6 +24,8 @@ class SnapshotService(BaseService):
snapshot = await Snapshot.objects.filter(id=event.snapshot_id, crawl_id=self.crawl_id).afirst()
if snapshot is not None:
+ if snapshot.is_paused:
+ return
if snapshot.status == Snapshot.StatusChoices.QUEUED:
await sync_to_async(snapshot.sm.tick, thread_sensitive=True)()
await sync_to_async(snapshot.refresh_from_db, thread_sensitive=True)()
@@ -42,7 +44,7 @@ class SnapshotService(BaseService):
snapshot.downloaded_at = snapshot.downloaded_at or timezone.now()
await snapshot.asave(update_fields=["downloaded_at", "modified_at"])
stop_reason = await sync_to_async(self._crawl_limit_stop_reason, thread_sensitive=True)(snapshot.crawl)
- if snapshot.crawl_id and stop_reason == "crawl_max_size":
+ if snapshot.crawl_id and stop_reason in ("crawl_max_size", "crawl_timeout"):
await (
Snapshot.objects.filter(
crawl_id=snapshot.crawl_id,
diff --git a/archivebox/services/supervision_service.py b/archivebox/services/supervision_service.py
new file mode 100644
index 00000000..ada118f3
--- /dev/null
+++ b/archivebox/services/supervision_service.py
@@ -0,0 +1,169 @@
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+from django.utils import timezone
+from rich import print
+
+
+def runtime_stack_owner_types():
+ from archivebox.machine.models import Process
+
+ return (
+ Process.TypeChoices.UPDATE,
+ Process.TypeChoices.SERVER,
+ Process.TypeChoices.ORCHESTRATOR,
+ Process.TypeChoices.ADD,
+ )
+
+
+def current_command(process_type: str, *, data_dir: str | Path, url: str | None = None):
+ from archivebox.machine.models import Process
+
+ proc = Process.current()
+ proc.mark_running(process_type=process_type, pwd=str(data_dir), url=url, timeout=0)
+ return proc
+
+
+def live_processes(*, process_type: str, data_dir: str | Path, url: str | None = None):
+ from archivebox.machine.models import Machine, Process
+
+ Process.cleanup_stale_running(machine=Machine.current())
+ 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):
+ from archivebox.machine.models import Machine, Process
+
+ Process.cleanup_stale_running(machine=Machine.current())
+ base_qs = Process.objects.filter(
+ machine=Machine.current(),
+ status=Process.StatusChoices.RUNNING,
+ pwd=str(data_dir),
+ process_type__in=runtime_stack_owner_types(),
+ )
+ for process_types in (
+ (Process.TypeChoices.UPDATE,),
+ (Process.TypeChoices.SERVER, Process.TypeChoices.ADD),
+ (Process.TypeChoices.ORCHESTRATOR,),
+ ):
+ qs = base_qs.filter(process_type__in=process_types)
+ for proc in qs.order_by("-created_at", "-modified_at").iterator(chunk_size=50):
+ if proc.is_running:
+ return proc
+ return None
+
+
+def command_owns_runtime_stack(command, *, data_dir: str | Path) -> bool:
+ owner = runtime_stack_owner(data_dir=data_dir)
+ return bool(owner and owner.id == command.id)
+
+
+def ensure_daemon_stack(*, reason: str = ""):
+ from archivebox.config.common import get_config
+ from archivebox.workers.supervisord_util import (
+ get_existing_supervisord_process,
+ get_or_create_supervisord_process,
+ get_sonic_supervisord_worker_from_plugin,
+ get_worker,
+ start_worker,
+ )
+
+ config = get_config()
+ sonic_worker = get_sonic_supervisord_worker_from_plugin(config)
+ if sonic_worker is None:
+ return None
+
+ from abx_plugins.plugins.search_backend_sonic.daemon import is_port_listening, prepare_sonic_daemon
+
+ sonic_event = prepare_sonic_daemon(config)
+ if is_port_listening(sonic_event.host, sonic_event.port):
+ return {
+ "name": sonic_event.worker_name,
+ "statename": "RUNNING",
+ "description": f"existing Sonic daemon at {sonic_event.url}",
+ }
+
+ supervisor = get_existing_supervisord_process() or get_or_create_supervisord_process(daemonize=False)
+ worker = get_worker(supervisor, sonic_worker["name"])
+ if isinstance(worker, dict) and worker.get("statename") in ("STARTING", "RUNNING"):
+ return worker
+
+ if reason:
+ print(f"[yellow][*] Starting daemon stack for {reason}...[/yellow]")
+ 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
+
+ Process.cleanup_stale_running(machine=Machine.current())
+ 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 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"
+ print(f"[yellow][*] Standing by; newer ArchiveBox parent pid={leader_pid} is running the orchestrator and server.[/yellow]")
+ announced = True
+ command.heartbeat()
+ 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) -> None:
+ from archivebox.workers.supervisord_util import reap_foreground_supervisord_process
+
+ announced = False
+ while not command_owns_runtime_stack(command, data_dir=data_dir):
+ reap_foreground_supervisord_process()
+ if not announced:
+ owner = runtime_stack_owner(data_dir=data_dir)
+ owner_pid = owner.pid if owner else "unknown"
+ owner_type = owner.process_type if owner else "unknown"
+ print(f"[yellow][*] Standing by; ArchiveBox {owner_type} pid={owner_pid} owns the runtime stack.[/yellow]")
+ announced = True
+ command.heartbeat()
+ time.sleep(interval)
+ command.modified_at = timezone.now()
+ command.save(update_fields=["modified_at"])
diff --git a/archivebox/templates/admin/actions.html b/archivebox/templates/admin/actions.html
index 66dc93ae..9c4134b3 100644
--- a/archivebox/templates/admin/actions.html
+++ b/archivebox/templates/admin/actions.html
@@ -1,5 +1,5 @@
-{% load i18n %}
-
+{% load i18n core_tags %}
+
- {% if action_index|default:0 == 0 %}
- {% if cl.has_filters or opts.model_name == 'snapshot' %}
-
- {% if cl.has_filters %}
-
- {% translate "Filters" %}
-
- {% endif %}
- {% if request.resolver_match.url_name == 'grid' %}
-
- {% translate "List" %}
-
- {% elif opts.model_name == 'snapshot' %}
-
- {% translate "Grid" %}
-
- {% endif %}
-
- {% endif %}
- {% endif %}
diff --git a/archivebox/templates/admin/actions_as_select.html b/archivebox/templates/admin/actions_as_select.html
deleted file mode 100644
index e69de29b..00000000
diff --git a/archivebox/templates/admin/base.html b/archivebox/templates/admin/base.html
index 2c8b4198..8927ce03 100644
--- a/archivebox/templates/admin/base.html
+++ b/archivebox/templates/admin/base.html
@@ -1216,6 +1216,28 @@
font-size: 15px;
}
+ .search-empty-state {
+ padding: 28px 18px;
+ text-align: center;
+ color: #475569;
+ background: #f8fafc;
+ border-top: 1px solid #e2e8f0;
+ }
+
+ .search-empty-state p {
+ margin: 0;
+ }
+
+ .search-empty-state code {
+ display: inline-block;
+ margin-left: 4px;
+ padding: 2px 6px;
+ border-radius: 5px;
+ background: #e2e8f0;
+ color: #0f172a;
+ font-size: 13px;
+ }
+
/* Date hierarchy */
.xfull {
padding: 12px 16px;
@@ -1457,9 +1479,63 @@
.actions .tag-inline-input {
min-width: 40px;
padding: 0;
+ margin: 0;
+ height: auto;
+ min-height: 0;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ background: transparent;
font-size: 11px;
}
+ .model-snapshot.change-list #changelist .actions-tags-with-buttons {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 34px 34px;
+ gap: 0;
+ align-items: stretch;
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ background: #fff;
+ overflow: hidden;
+ }
+
+ .model-snapshot.change-list #changelist .actions-tags-with-buttons .tag-editor-container {
+ width: auto;
+ max-width: none;
+ height: 34px;
+ min-height: 34px;
+ padding: 4px 9px;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ overflow: hidden;
+ }
+
+ .model-snapshot.change-list #changelist .actions-tags-with-buttons .tag-inline-input {
+ height: auto;
+ min-height: 0;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ background: transparent;
+ }
+
+ .model-snapshot.change-list #changelist .actions-tags-with-buttons .button[name="add_tags"],
+ .model-snapshot.change-list #changelist .actions-tags-with-buttons .button[name="remove_tags"] {
+ width: 34px;
+ min-width: 34px;
+ height: 34px;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ border-left: 1px solid #cbd5e1;
+ border-radius: 0;
+ justify-content: center;
+ }
+
/* Container in list view title column */
.tags-inline-editor {
@@ -1675,18 +1751,41 @@
// change the admin actions button from a dropdown to buttons across
function fix_actions() {
const container = $('div.actions')
+ if (container.find('.action-buttons').length) return
// too many actions to turn into buttons
if (container.find('select[name=action] option').length >= 11) return
// hide the empty default option thats just a placeholder with no value
- container.find('label:nth-child(1), button[value=0]').hide()
+ container.find('label:nth-child(1), button[type=submit][name=index][value=0]').hide()
const buttons = $('
')
.insertAfter('div.actions button[type=submit]')
.css('display', 'inline')
.addClass('action-buttons');
+ function flushPendingActionTags() {
+ const tagContainer = document.querySelector('.actions-tags')
+ const input = tagContainer?.querySelector('.tag-inline-input')
+ const hidden = tagContainer?.querySelector('input[type="hidden"][name="tags"]')
+ const pending = (input?.value || '').trim()
+ if (!pending || !hidden) return
+ const seen = new Set()
+ const tags = (hidden.value ? hidden.value.split(',') : [])
+ .concat(pending.split(','))
+ .map((tag) => tag.trim())
+ .filter((tag) => {
+ const key = tag.toLowerCase()
+ if (!tag || seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+ hidden.value = tags.join(',')
+ input.value = ''
+ hidden.dispatchEvent(new Event('input', { bubbles: true }))
+ hidden.dispatchEvent(new Event('change', { bubbles: true }))
+ }
+
// for each action in the dropdown, turn it into a button instead
container.find('select[name=action] option:gt(0)').each(function () {
const action_type = this.value
@@ -1699,7 +1798,11 @@
e.preventDefault()
e.stopPropagation()
- const num_selected = document.querySelector('.action-counter').innerText.split(' ')[0]
+ const num_selected = (
+ document.querySelector('.action-selected-count')?.textContent.split('/')[0].trim()
+ || document.querySelector('.action-counter')?.textContent.split(' ')[0]
+ || '0'
+ )
if (action_type === 'overwrite_snapshots') {
const message = (
@@ -1715,6 +1818,9 @@
)
if (!window.confirm(message)) return false
}
+ if (action_type === 'add_tags' || action_type === 'remove_tags') {
+ flushPendingActionTags()
+ }
// select the action from the original Django admin dropdown
container.find('select[name=action]')
@@ -1729,6 +1835,14 @@
.appendTo(buttons)
})
console.log('Converted', buttons.children().length, 'admin actions from dropdown to buttons')
+ const tagContainer = document.querySelector('.actions-tags')
+ if (tagContainer) {
+ const tagButtons = buttons.find('button[name="add_tags"], button[name="remove_tags"]')
+ if (tagButtons.length) {
+ tagContainer.classList.add('actions-tags-with-buttons')
+ tagButtons.appendTo(tagContainer)
+ }
+ }
if (window.jQuery && window.jQuery.fn.select2) {
window.jQuery('select[multiple]').select2();
}
@@ -1737,7 +1851,188 @@
const tagContainer = document.querySelector('.actions-tags');
if (!tagContainer) return;
const checked = document.querySelectorAll('#changelist-form input.action-select:checked').length;
- tagContainer.style.display = checked > 0 ? 'inline-flex' : 'none';
+ tagContainer.style.display = tagContainer.classList.contains('actions-tags-with-buttons') || checked > 0 ? 'inline-flex' : 'none';
+ }
+ function setupActionSummary() {
+ const summary = document.querySelector('.action-summary')
+ if (!summary || summary.dataset.summaryReady) return
+ summary.dataset.summaryReady = '1'
+ const selectedCount = summary.querySelector('.action-selected-count')
+ const counter = summary.querySelector('.action-counter')
+ const formatter = new Intl.NumberFormat()
+ const update = function() {
+ if (!selectedCount || !counter) return
+ const match = counter.textContent.match(/(\d+)\s+of\s+(\d+)\s+selected/)
+ const selectAcross = document.querySelector('div.actions input.select-across')?.value === '1'
+ const selected = selectAcross
+ ? Number(summary.dataset.resultCount || 0)
+ : (match ? Number(match[1]) : document.querySelectorAll('#changelist-form input.action-select:checked').length)
+ const pageCount = match ? Number(match[2]) : Number(summary.dataset.pageCount || 0)
+ const selectedLimit = selectAcross ? Number(summary.dataset.resultCount || pageCount) : pageCount
+ selectedCount.textContent = formatter.format(selected) + ' / ' + formatter.format(selectedLimit) + ' selected'
+ summary.classList.toggle('action-summary-has-selection', selected > 0)
+ summary.classList.toggle('action-summary-select-across', selectAcross)
+ const totalCount = summary.querySelector('.action-total-count')
+ if (totalCount) {
+ totalCount.hidden = selectAcross
+ }
+ }
+ new MutationObserver(update).observe(counter, { childList: true, characterData: true, subtree: true })
+ document.querySelector('#changelist-form')?.addEventListener('change', function() {
+ window.setTimeout(update, 0)
+ })
+ summary.addEventListener('click', function(event) {
+ const explicitSelectAll = event.target.closest('.action-total-select, .action-total-count .question a')
+ const selectedTotalClick = event.target.closest('.action-total-reset') && summary.classList.contains('action-summary-has-selection')
+ if (explicitSelectAll || selectedTotalClick) {
+ const questionLink = summary.querySelector('.action-total-count .question a')
+ const allToggle = document.getElementById('action-toggle')
+ const pageCount = Number(summary.dataset.pageCount || 0)
+ const resultCount = Number(summary.dataset.resultCount || pageCount)
+ event.preventDefault()
+ if (allToggle && !allToggle.checked) {
+ allToggle.click()
+ }
+ if (questionLink && resultCount > pageCount) {
+ questionLink.click()
+ }
+ }
+ window.setTimeout(update, 0)
+ })
+ update()
+ }
+ function setupSearchModeSelect() {
+ const storageKey = 'archivebox-admin-search-mode'
+ const params = new URLSearchParams(window.location.search)
+ document.querySelectorAll('.search-mode-select').forEach(function(select) {
+ if (select.dataset.searchModeReady) return
+ select.dataset.searchModeReady = '1'
+ const values = Array.from(select.options).map(option => option.value)
+ const stored = localStorage.getItem(storageKey)
+ if (!params.has('search_mode') && values.includes(stored)) {
+ select.value = stored
+ if ((params.get('q') || '').trim()) {
+ params.set('search_mode', stored)
+ params.delete('p')
+ window.location.replace(window.location.pathname + '?' + params.toString() + window.location.hash)
+ return
+ }
+ }
+ select.addEventListener('change', function() {
+ localStorage.setItem(storageKey, select.value)
+ const search = select.closest('#changelist-search')
+ if (search && search.dataset.embeddedSearch === '1') {
+ submitEmbeddedChangelistSearch(search)
+ return
+ }
+ if (search && search.tagName === 'FORM' && document.activeElement === select) {
+ search.requestSubmit()
+ }
+ })
+ })
+ }
+ function submitEmbeddedChangelistSearch(search) {
+ const params = new URLSearchParams(window.location.search)
+ search.querySelectorAll('[name]').forEach(function(input) {
+ const value = (input.value || '').trim()
+ if (value) {
+ params.set(input.name, value)
+ } else {
+ params.delete(input.name)
+ }
+ })
+ params.delete('p')
+ const query = params.toString()
+ window.location.href = window.location.pathname + (query ? '?' + query : '') + window.location.hash
+ }
+ function setupEmbeddedChangelistSearch() {
+ document.querySelectorAll('#changelist-search[data-embedded-search="1"]').forEach(function(search) {
+ if (search.dataset.searchReady) return
+ search.dataset.searchReady = '1'
+ search.querySelector('.changelist-search-submit')?.addEventListener('click', function() {
+ submitEmbeddedChangelistSearch(search)
+ })
+ search.querySelector('#searchbar')?.addEventListener('keydown', function(event) {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ submitEmbeddedChangelistSearch(search)
+ }
+ })
+ })
+ }
+ function setupSnapshotPermissionsQuickEdit() {
+ if (document.body.dataset.snapshotPermissionsReady) return
+ document.body.dataset.snapshotPermissionsReady = '1'
+ document.addEventListener('click', function(event) {
+ const toggle = event.target.closest('.snapshot-permissions-button')
+ const item = event.target.closest('.snapshot-permissions-menu-item')
+ document.querySelectorAll('.snapshot-permissions-quick.is-open').forEach(function(openMenu) {
+ if (!openMenu.contains(event.target)) {
+ openMenu.classList.remove('is-open')
+ openMenu.querySelector('.snapshot-permissions-button')?.setAttribute('aria-expanded', 'false')
+ const menu = openMenu.querySelector('.snapshot-permissions-menu')
+ if (menu) menu.hidden = true
+ }
+ })
+ if (toggle) {
+ event.preventDefault()
+ const wrapper = toggle.closest('.snapshot-permissions-quick')
+ const menu = wrapper?.querySelector('.snapshot-permissions-menu')
+ if (!wrapper || !menu) return
+ const isOpen = wrapper.classList.toggle('is-open')
+ toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false')
+ menu.hidden = !isOpen
+ return
+ }
+ if (!item) return
+ event.preventDefault()
+ const wrapper = item.closest('.snapshot-permissions-quick')
+ const permissions = item.dataset.permissions
+ const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value
+ if (!wrapper || !permissions || !csrf || wrapper.dataset.saving === '1') return
+ wrapper.dataset.saving = '1'
+ const body = new URLSearchParams({permissions: permissions, csrfmiddlewaretoken: csrf})
+ fetch(wrapper.dataset.permissionsUrl, {
+ method: 'POST',
+ headers: {'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded'},
+ body: body.toString(),
+ }).then(function(response) {
+ if (!response.ok) throw new Error('Failed to update permissions')
+ return response.json()
+ }).then(function(data) {
+ wrapper.dataset.currentPermissions = data.permissions
+ wrapper.querySelectorAll('.snapshot-permissions-menu-item').forEach(function(button) {
+ button.classList.toggle('is-active', button.dataset.permissions === data.permissions)
+ })
+ const icon = wrapper.querySelector('.snapshot-permissions-icon')
+ if (icon) {
+ icon.textContent = data.icon
+ icon.style.color = data.fg
+ icon.style.background = data.bg
+ }
+ const toggle = wrapper.querySelector('.snapshot-permissions-button')
+ if (toggle) {
+ toggle.className = 'snapshot-permissions-button snapshot-permissions-' + data.permissions
+ toggle.title = data.label
+ toggle.setAttribute('aria-label', 'Change snapshot permissions: ' + data.label)
+ toggle.setAttribute('aria-expanded', 'false')
+ }
+ wrapper.classList.remove('is-open')
+ const menu = wrapper.querySelector('.snapshot-permissions-menu')
+ if (menu) menu.hidden = true
+ }).catch(function(error) {
+ window.alert(error.message)
+ }).finally(function() {
+ wrapper.dataset.saving = ''
+ })
+ })
+ }
+ function setupChangelistFormHandlers() {
+ const form = document.querySelector('#changelist-form')
+ if (form && !form.dataset.archiveboxActionsReady) {
+ form.dataset.archiveboxActionsReady = '1'
+ form.addEventListener('change', updateTagWidgetVisibility)
+ }
}
function fixInlineAddRow() {
$('#id_snapshottag-MAX_NUM_FORMS').val('1000')
@@ -1858,28 +2153,30 @@
})
return false
}
- if ($) {
- $(document).ready(function() {
+ window.archiveboxInitAdminChangelist = function() {
+ if (window.jQuery) {
fix_actions()
- updateTagWidgetVisibility()
- const form = document.querySelector('#changelist-form')
- if (form) {
- form.addEventListener('change', updateTagWidgetVisibility)
- }
fixInlineAddRow()
setupSnapshotGridListToggle()
+ }
+ updateTagWidgetVisibility()
+ setupActionSummary()
+ setupSearchModeSelect()
+ setupEmbeddedChangelistSearch()
+ setupChangelistFormHandlers()
+ selectSnapshotIfHotlinked()
+ }
+ if ($) {
+ $(document).ready(function() {
+ window.archiveboxInitAdminChangelist()
+ setupSnapshotPermissionsQuickEdit()
setTimeOffset()
- selectSnapshotIfHotlinked()
})
} else {
document.addEventListener('DOMContentLoaded', function() {
- updateTagWidgetVisibility()
- const form = document.querySelector('#changelist-form')
- if (form) {
- form.addEventListener('change', updateTagWidgetVisibility)
- }
+ window.archiveboxInitAdminChangelist()
+ setupSnapshotPermissionsQuickEdit()
setTimeOffset()
- selectSnapshotIfHotlinked()
})
}
diff --git a/archivebox/templates/admin/change_list.html b/archivebox/templates/admin/change_list.html
index a1c8eafc..3c711d95 100644
--- a/archivebox/templates/admin/change_list.html
+++ b/archivebox/templates/admin/change_list.html
@@ -46,7 +46,7 @@
{% endblock %}
-{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %}
+{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% if embedded_changelist %} embedded-change-list{% endif %}{% endblock %}
{% if not is_popup %}
{% block breadcrumbs %}
@@ -63,11 +63,13 @@
{% block content %}
{% block object-tools %}
+ {% if not embedded_changelist %}
+ {% endif %}
{% endblock %}
{% if cl.formset and cl.formset.errors %}
@@ -75,101 +77,6 @@
{{ cl.formset.non_form_errors }}
{% endif %}
- {% if cl.model_admin.show_search_mode_selector %}
- {% with current_search_mode=cl.params.search_mode|default:cl.model_admin.get_default_search_mode %}
-
- {% endwith %}
- {% else %}
-
- {% endif %}
-
- {% block filters %}
- {% if cl.has_filters %}
-
-
- {% translate 'Filter' %}
-
- {% translate 'Hide' %}
-
-
- {% if cl.is_facets_optional or cl.has_active_filters %}{% endif %}
- {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
-
- {% endif %}
- {% endblock %}
-
+ {% include "admin/change_list_panel.html" %}
- {% if cl.has_filters %}
-
- {% endif %}
{% endblock %}
diff --git a/archivebox/templates/admin/change_list_panel.html b/archivebox/templates/admin/change_list_panel.html
new file mode 100644
index 00000000..eb262793
--- /dev/null
+++ b/archivebox/templates/admin/change_list_panel.html
@@ -0,0 +1,107 @@
+{% load i18n admin_list core_tags %}
+
+{% if cl.model_admin.show_search_mode_selector %}
+ {% with current_search_mode=cl.params.search_mode|default:cl.model_admin.get_default_search_mode %}
+
+ {% endwith %}
+{% else %}
+
+{% endif %}
+
+ {% if cl.has_filters and not embedded_changelist %}
+
+
+ {% translate 'Filter' %}
+
+ {% translate 'Hide' %}
+
+
+ {% if cl.is_facets_optional or cl.has_active_filters %}{% endif %}
+ {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
+
+ {% endif %}
+
+
+{% if cl.has_filters and not embedded_changelist %}
+
+{% endif %}
+{% include "admin/snapshot_search_stream.html" %}
diff --git a/archivebox/templates/admin/change_list_results.html b/archivebox/templates/admin/change_list_results.html
index 71f410e3..5893c2cf 100644
--- a/archivebox/templates/admin/change_list_results.html
+++ b/archivebox/templates/admin/change_list_results.html
@@ -7,6 +7,14 @@
{% if results %}
+{% if cl.opts.model_name == "snapshot" %}
+
+
+
+
+
+
+{% endif %}
{% for header in result_headers %}
@@ -35,4 +43,12 @@
+{% elif cl.show_search_index_hint %}
+
+
+ 0 results from deep: {{ cl.search_backend_label }}.
+ If this looks wrong, the search index may need to be updated:
+ archivebox update --index-only
+
+
{% endif %}
diff --git a/archivebox/templates/admin/core/archiveresult/change_list.html b/archivebox/templates/admin/core/archiveresult/change_list.html
index b44e9211..440d4ec1 100644
--- a/archivebox/templates/admin/core/archiveresult/change_list.html
+++ b/archivebox/templates/admin/core/archiveresult/change_list.html
@@ -118,8 +118,8 @@
toggle.textContent = collapsed ? toggle.dataset.showLabel : toggle.dataset.hideLabel;
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if (toolbarToggle) {
- toolbarToggle.textContent = toggle.dataset.showLabel;
- toolbarToggle.style.display = collapsed ? 'inline-block' : 'none';
+ toolbarToggle.textContent = collapsed ? 'Filters βΈ' : 'Filters β';
+ toolbarToggle.style.display = 'inline-flex';
}
}
diff --git a/archivebox/templates/admin/crawls/crawl/change_form.html b/archivebox/templates/admin/crawls/crawl/change_form.html
new file mode 100644
index 00000000..dcf4e27b
--- /dev/null
+++ b/archivebox/templates/admin/crawls/crawl/change_form.html
@@ -0,0 +1,42 @@
+{% extends "admin/change_form.html" %}
+
+{% block object-tools-items %}
+{% if original %}
+
+
+ Stop reason:
+ {% if crawl_stop_reason %}
+ {{ crawl_stop_reason }}
+ {% else %}
+ none
+ {% endif %}
+
+ {% if original.status != "sealed" and not original.is_paused %}
+
+ {% endif %}
+ {% if original.status == "sealed" or original.is_paused %}
+
+ {% endif %}
+
+{% endif %}
+{{ block.super }}
+{% endblock %}
+
+{% block content %}
+{{ block.super }}
+{% if crawl_snapshots_changelist %}
+ {{ crawl_snapshots_changelist }}
+{% endif %}
+{% endblock %}
diff --git a/archivebox/templates/admin/crawls/crawl/snapshots_changelist.html b/archivebox/templates/admin/crawls/crawl/snapshots_changelist.html
new file mode 100644
index 00000000..925d243b
--- /dev/null
+++ b/archivebox/templates/admin/crawls/crawl/snapshots_changelist.html
@@ -0,0 +1,9 @@
+{% load i18n %}
+
+
diff --git a/archivebox/templates/admin/private_index.html b/archivebox/templates/admin/private_index.html
deleted file mode 100644
index 7db75b30..00000000
--- a/archivebox/templates/admin/private_index.html
+++ /dev/null
@@ -1,138 +0,0 @@
-{% extends "admin/base_site.html" %}
-{% load i18n admin_urls static admin_list %}
-{% load core_tags %}
-
-{% block extrastyle %}
- {{ block.super }}
-
- {% if cl.formset %}
-
- {% endif %}
- {% if cl.formset or action_form %}
-
- {% endif %}
- {{ media.css }}
- {% if not actions_on_top and not actions_on_bottom %}
-
- {% endif %}
-{% endblock %}
-
-{% block extrahead %}
-{{ block.super }}
-{{ media.js }}
-{% endblock %}
-
-{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %}
-
-{% if not is_popup %}
-{% block breadcrumbs %}
-
-{% endblock %}
-{% endif %}
-
-{% block coltype %}{% endblock %}
-
-{% block content %}
-
- {% block object-tools %}
-
- {% endblock %}
- {% if cl.formset and cl.formset.errors %}
-
- {% if cl.formset.total_error_count == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %}
-
- {{ cl.formset.non_form_errors }}
- {% endif %}
-
-
- {% block filters %}
- {% if cl.has_filters %}
-
-
- {% translate 'Filter' %}
-
- {% translate 'Hide' %}
-
-
- {% if cl.has_active_filters %}
{% endif %}
- {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
-
- {% endif %}
- {% endblock %}
-
-
- {% if cl.has_filters %}
-
- {% endif %}
-{% endblock %}
diff --git a/archivebox/templates/admin/private_index_grid.html b/archivebox/templates/admin/private_index_grid.html
deleted file mode 100644
index 7db75b30..00000000
--- a/archivebox/templates/admin/private_index_grid.html
+++ /dev/null
@@ -1,138 +0,0 @@
-{% extends "admin/base_site.html" %}
-{% load i18n admin_urls static admin_list %}
-{% load core_tags %}
-
-{% block extrastyle %}
- {{ block.super }}
-
- {% if cl.formset %}
-
- {% endif %}
- {% if cl.formset or action_form %}
-
- {% endif %}
- {{ media.css }}
- {% if not actions_on_top and not actions_on_bottom %}
-
- {% endif %}
-{% endblock %}
-
-{% block extrahead %}
-{{ block.super }}
-{{ media.js }}
-{% endblock %}
-
-{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %}
-
-{% if not is_popup %}
-{% block breadcrumbs %}
-
-{% endblock %}
-{% endif %}
-
-{% block coltype %}{% endblock %}
-
-{% block content %}
-
- {% block object-tools %}
-
- {% endblock %}
- {% if cl.formset and cl.formset.errors %}
-
- {% if cl.formset.total_error_count == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %}
-
- {{ cl.formset.non_form_errors }}
- {% endif %}
-
-
- {% block filters %}
- {% if cl.has_filters %}
-
-
- {% translate 'Filter' %}
-
- {% translate 'Hide' %}
-
-
- {% if cl.has_active_filters %}
{% endif %}
- {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
-
- {% endif %}
- {% endblock %}
-
-
- {% if cl.has_filters %}
-
- {% endif %}
-{% endblock %}
diff --git a/archivebox/templates/admin/progress_monitor.html b/archivebox/templates/admin/progress_monitor.html
index c703bc21..7c08b9af 100644
--- a/archivebox/templates/admin/progress_monitor.html
+++ b/archivebox/templates/admin/progress_monitor.html
@@ -16,6 +16,12 @@
max-height: 350px;
overflow-y: auto;
}
+ #progress-monitor .progress-content {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 12px 16px;
+ }
/* Header Bar */
#progress-monitor .header-bar {
@@ -33,11 +39,13 @@
display: flex;
align-items: center;
gap: 16px;
+ min-width: 0;
}
#progress-monitor .header-right {
display: flex;
align-items: center;
gap: 12px;
+ flex-shrink: 0;
}
/* Orchestrator Status */
@@ -130,10 +138,10 @@
color: #c9d1d9;
border-color: #8b949e;
}
+ #progress-monitor .crawl-action-btn,
#progress-monitor .cancel-item-btn {
background: transparent;
border: 1px solid #30363d;
- color: #f85149;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
@@ -142,11 +150,23 @@
transition: all 0.2s;
flex-shrink: 0;
}
+ #progress-monitor .pause-item-btn {
+ color: #d29922;
+ }
+ #progress-monitor .pause-item-btn:hover {
+ background: rgba(210, 153, 34, 0.12);
+ border-color: #d29922;
+ color: #f0b72f;
+ }
+ #progress-monitor .cancel-item-btn {
+ color: #f85149;
+ }
#progress-monitor .cancel-item-btn:hover {
background: rgba(248, 81, 73, 0.12);
border-color: #f85149;
color: #ff7b72;
}
+ #progress-monitor .crawl-action-btn.is-busy,
#progress-monitor .cancel-item-btn.is-busy {
opacity: 0.6;
cursor: wait;
@@ -156,12 +176,88 @@
/* Tree Container */
#progress-monitor .tree-container {
- padding: 12px 16px;
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 0;
}
- #progress-monitor.collapsed .tree-container {
+ #progress-monitor.collapsed .progress-content {
display: none;
}
+ /* Chrome Screencast */
+ #progress-monitor .screencast-panel {
+ display: none;
+ flex: 0 0 336px;
+ width: 336px;
+ position: sticky;
+ top: 49px;
+ border: 1px solid rgba(88, 166, 255, 0.5);
+ border-radius: 8px;
+ overflow: hidden;
+ background: #0d1117;
+ box-shadow: 0 12px 34px rgba(1, 4, 9, 0.4), 0 0 0 1px rgba(88, 166, 255, 0.12);
+ }
+ #progress-monitor .screencast-panel.visible {
+ display: block;
+ }
+ #progress-monitor .screencast-frame {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ background: #010409;
+ color: #6e7681;
+ text-decoration: none;
+ overflow: hidden;
+ }
+ #progress-monitor .screencast-frame img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: top center;
+ }
+ #progress-monitor .screencast-caption {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border-top: 1px solid #21262d;
+ background: rgba(13, 17, 23, 0.96);
+ color: inherit;
+ text-decoration: none;
+ }
+ #progress-monitor .screencast-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex: 0 0 7px;
+ background: #3fb950;
+ box-shadow: 0 0 8px rgba(63, 185, 80, 0.9);
+ }
+ #progress-monitor .screencast-text {
+ min-width: 0;
+ }
+ #progress-monitor .screencast-title {
+ display: block;
+ color: #f0f6fc;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.25;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ #progress-monitor .screencast-url {
+ display: block;
+ color: #8b949e;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 10px;
+ line-height: 1.25;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
/* Idle Message */
#progress-monitor .idle-message {
color: #8b949e;
@@ -563,6 +659,10 @@
background: rgba(210, 153, 34, 0.2);
color: #d29922;
}
+ #progress-monitor .status-badge.paused {
+ background: rgba(88, 166, 255, 0.18);
+ color: #58a6ff;
+ }
#progress-monitor .status-badge.sealed,
#progress-monitor .status-badge.succeeded {
background: rgba(63, 185, 80, 0.2);
@@ -711,9 +811,182 @@
font-size: 9px;
}
+ @media (max-width: 900px) {
+ #progress-monitor .header-bar {
+ align-items: flex-start;
+ gap: 8px;
+ }
+ #progress-monitor .header-left {
+ flex: 1;
+ min-width: 0;
+ flex-wrap: wrap;
+ gap: 8px 14px;
+ }
+ #progress-monitor .stats {
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ }
+ #progress-monitor .tree-container {
+ max-height: 310px;
+ }
+ #progress-monitor .progress-content {
+ flex-direction: column;
+ padding: 10px 8px;
+ }
+ #progress-monitor .screencast-panel {
+ position: static;
+ order: -1;
+ width: min(100%, 336px);
+ flex-basis: auto;
+ align-self: center;
+ }
+ #progress-monitor .crawl-header {
+ align-items: flex-start;
+ gap: 8px;
+ padding: 8px;
+ }
+ #progress-monitor .crawl-header-link {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
+ align-items: start;
+ gap: 8px;
+ width: 100%;
+ }
+ #progress-monitor .crawl-info {
+ grid-column: 1 / -1;
+ width: 100%;
+ }
+ #progress-monitor .crawl-label {
+ max-width: 100%;
+ }
+ #progress-monitor .crawl-badges,
+ #progress-monitor .crawl-stats {
+ justify-content: flex-start;
+ min-width: 0;
+ }
+ #progress-monitor .crawl-badge {
+ max-width: min(100%, 260px);
+ }
+ #progress-monitor .crawl-stats {
+ grid-column: 1 / 2;
+ }
+ #progress-monitor .crawl-body {
+ padding: 0 8px 10px;
+ }
+ #progress-monitor .crawl-progress {
+ padding: 8px;
+ }
+ #progress-monitor .snapshot-header {
+ align-items: flex-start;
+ padding: 8px;
+ }
+ #progress-monitor .snapshot-header-link {
+ min-width: 0;
+ }
+ }
+
+ @media (max-width: 520px) {
+ #progress-monitor {
+ font-size: 11px;
+ }
+ #progress-monitor .header-bar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ padding: 7px 8px;
+ }
+ #progress-monitor .header-left {
+ gap: 6px 10px;
+ }
+ #progress-monitor .header-right {
+ justify-self: end;
+ }
+ #progress-monitor .toggle-btn {
+ white-space: nowrap;
+ }
+ #progress-monitor .orchestrator-status {
+ max-width: 100%;
+ }
+ #progress-monitor .stats {
+ flex: 1 0 100%;
+ gap: 6px 10px;
+ }
+ #progress-monitor .stat-label {
+ font-size: 9px;
+ }
+ #progress-monitor .tree-container {
+ max-height: 300px;
+ }
+ #progress-monitor .progress-content {
+ padding: 8px;
+ }
+ #progress-monitor .screencast-panel {
+ width: 100%;
+ align-self: stretch;
+ }
+ #progress-monitor .crawl-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ padding: 8px;
+ }
+ #progress-monitor .crawl-header-link {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ #progress-monitor .crawl-stats {
+ grid-column: auto;
+ }
+ #progress-monitor .crawl-title-link {
+ white-space: normal;
+ line-height: 1.35;
+ }
+ #progress-monitor .crawl-badges {
+ gap: 4px;
+ }
+ #progress-monitor .crawl-badge {
+ max-width: 100%;
+ min-width: 0;
+ font-size: 10px;
+ white-space: nowrap;
+ }
+ #progress-monitor .crawl-stats {
+ gap: 4px;
+ }
+ #progress-monitor .status-badge,
+ #progress-monitor .duration-badge,
+ #progress-monitor .pid-label {
+ justify-self: start;
+ }
+ #progress-monitor .snapshot-header-link {
+ display: grid;
+ grid-template-columns: 56px minmax(0, 1fr);
+ gap: 8px;
+ }
+ #progress-monitor .snapshot-preview {
+ width: 56px;
+ height: 42px;
+ flex-basis: 56px;
+ }
+ #progress-monitor .snapshot-title-line {
+ align-items: flex-start;
+ }
+ #progress-monitor .snapshot-title,
+ #progress-monitor .snapshot-url {
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+ #progress-monitor .extractor-list {
+ padding: 7px 8px;
+ gap: 4px;
+ }
+ #progress-monitor .extractor-badge {
+ max-width: 100%;
+ white-space: nowrap;
+ }
+ }
+
-
+
-
@@ -758,12 +1034,13 @@
const treeContainer = document.getElementById('tree-container');
const crawlTree = document.getElementById('crawl-tree');
const idleMessage = document.getElementById('idle-message');
+ const screencastPanel = document.getElementById('screencast-panel');
const thumbnailStrip = null;
let pollInterval = null;
let pollDelayMs = 1000;
let idleTicks = 0;
- let isCollapsed = monitor.classList.contains('collapsed');
+ let isCollapsed = true;
let knownThumbnailIds = new Set();
function getApiKey() {
@@ -866,6 +1143,43 @@
});
}
+ function activeScreencastSnapshot(data) {
+ for (const crawl of data.active_crawls || []) {
+ for (const snapshot of crawl.active_snapshots || []) {
+ if (!Array.isArray(snapshot) && snapshot.screencast_url) {
+ return snapshot;
+ }
+ }
+ }
+ return null;
+ }
+
+ function updateScreencastPanel(data) {
+ const snapshot = activeScreencastSnapshot(data);
+ if (!snapshot) {
+ screencastPanel.classList.remove('visible');
+ screencastPanel.innerHTML = '';
+ return;
+ }
+ const adminUrl = snapshot.admin_url || `/admin/core/snapshot/${snapshot.id || 'unknown'}/change/`;
+ const linkUrl = snapshot.screencast_link || snapshot.view_url || adminUrl;
+ const titleText = snapshot.title || formatUrl(snapshot.full_url || snapshot.url);
+ const urlText = snapshot.full_url || snapshot.url || '';
+ screencastPanel.classList.add('visible');
+ screencastPanel.innerHTML = `
+
+
+
+
+
+
+ ${escapeHtml(titleText)}
+ ${escapeHtml(urlText)}
+
+
+ `;
+ }
+
function renderExtractor(extractor) {
const icon = extractor.status === 'started' ? 'βΆ' :
extractor.status === 'succeeded' ? 'β' :
@@ -1003,7 +1317,14 @@
const crawlId = (crawl.id || 'unknown').toString();
const crawlShortId = crawlId === 'unknown' ? 'unknown' : crawlId.slice(-8);
const startedDate = crawl.started ? crawl.started.slice(0, 10) : 'unknown date';
- const canCancel = crawl.status === 'queued' || crawl.status === 'started';
+ const canCancel = crawl.status === 'queued' || crawl.status === 'started' || crawl.status === 'paused';
+ const canPause = (crawl.status === 'queued' || crawl.status === 'started') && !crawl.is_paused;
+ const canResume = crawl.status === 'paused' || crawl.is_paused;
+ const pauseBtn = canPause
+ ? `
β
‘ `
+ : canResume
+ ? `
βΆ `
+ : '';
const cancelBtn = canCancel
? `
β `
: '';
@@ -1016,7 +1337,6 @@
}
let setupHtml = '';
if (crawl.setup_plugins && crawl.setup_plugins.length > 0) {
- const setupSummary = `${crawl.setup_completed_plugins || 0}/${crawl.setup_total_plugins || 0} setup tasks${(crawl.setup_failed_plugins || 0) > 0 ? `
(${crawl.setup_failed_plugins} failed) ` : ''}`;
const sortedSetup = [...crawl.setup_plugins].sort((a, b) =>
(a.plugin || '').localeCompare(b.plugin || '')
);
@@ -1027,7 +1347,6 @@
⚙
Crawl Setup
-
${setupSummary}
@@ -1046,6 +1365,12 @@
Crawl cannot start: ${crawl.urls_preview ? 'unknown error' : 'no URLs'}
`;
+ } else if (crawl.is_paused) {
+ warningHtml = `
+
+ Crawl is paused. Resume it to continue processing queued snapshots.
+
+ `;
} else if (crawl.status === 'queued' && crawl.retry_at_future) {
// Queued but retry_at is in future (was claimed by worker, will retry)
warningHtml = `
@@ -1085,12 +1410,14 @@
const maxUrlsText = (crawl.max_urls || 0) > 0 ? crawl.max_urls : 'unlimited';
const urlLimitText = `${currentUrlCount} / ${maxUrlsText}`;
const crawlSizeLimitText = `${crawl.crawl_output_size_display || '0 B'} / ${crawl.max_crawl_size_display || 'unlimited'}`;
+ const crawlTimeoutText = (crawl.crawl_timeout || 0) > 0 ? `${crawl.crawl_timeout}s` : 'unlimited';
const snapshotSizeLimitText = `${crawl.avg_snapshot_size_display || '0 B'} / ${crawl.max_snapshot_size_display || 'unlimited'}`;
const crawlBadges = [
crawlBadge('persona', 'Persona Config', crawl.persona || 'Default', crawl.persona_admin_url, 'Edit persona config'),
crawlBadge('limit', 'depth', crawl.max_depth || 0, adminFieldUrl('max_depth'), 'Edit crawl depth'),
crawlBadge('limit', 'urls', urlLimitText, adminFieldUrl('max_urls'), 'Edit max URLs'),
crawlBadge('size', 'crawl size', crawlSizeLimitText, adminFieldUrl('crawl_max_size'), 'Edit max crawl size'),
+ crawlBadge('limit', 'time', crawlTimeoutText, adminFieldUrl('crawl_timeout'), 'Edit max crawl time'),
crawlBadge('size', 'avg snap', snapshotSizeLimitText, adminFieldUrl('snapshot_max_size'), 'Edit max snapshot size'),
...(crawl.tags || []).map(tag => `
#${escapeHtml(tag)} `),
].join('');
@@ -1114,8 +1441,9 @@
${crawlPidHtml}
${crawlDurationHtml}
-
${crawl.status || 'unknown'}
+
${crawl.is_paused ? 'paused' : (crawl.status || 'unknown')}
+ ${pauseBtn}
${cancelBtn}