__package__ = "archivebox.crawls" import json from copy import copy from typing import ClassVar from urllib.parse import urlencode, urlparse from django import forms from django.contrib import admin, messages from django.core.paginator import Paginator from django.db.models import Count, F, Q from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.template.loader import render_to_string from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils import timezone from django.utils.html import escape, format_html, format_html_join from django.utils.safestring import mark_safe from django_object_actions import action from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin from archivebox.core.models import ArchiveResult, Snapshot from archivebox.core.permissions import ( PERMISSIONS_CHOICES, PERMISSIONS_META, PERMISSIONS_PRIVATE, PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED, PERMISSIONS_VALUES, normalize_permissions, ) from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget from archivebox.crawls.models import Crawl, CrawlSchedule from archivebox.misc.paginators import AcceleratedPaginator from archivebox.progressmonitor.views import progress_endpoint from archivebox.workers.models import RETRY_AT_MAX class MaxDepthListFilter(admin.SimpleListFilter): title = "max depth" parameter_name = "max_depth" def lookups(self, request, model_admin): return [(str(depth), str(depth)) for depth in range(5)] def queryset(self, request, queryset): value = self.value() if value is not None and value.isdigit(): return queryset.filter(max_depth=int(value)) return queryset def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50, prefix="snapshots"): """Render a nice inline list view of snapshots with status, title, URL, and progress.""" query_param = f"{prefix}_q" status_param = f"{prefix}_status" page_param = f"{prefix}_page" query = (request.GET.get(query_param, "") if request is not None else "").strip() status_filter = (request.GET.get(status_param, "") if request is not None else "").strip() valid_statuses = {choice[0] for choice in Snapshot.StatusChoices.choices} filtered_qs = snapshots_qs if query: from archivebox.misc.util import filter_queryset_by_uuid_substring id_match_pks = list(filter_queryset_by_uuid_substring(Snapshot.objects.all(), query).values_list("pk", flat=True)[:100]) filtered_qs = filtered_qs.filter(Q(pk__in=id_match_pks) | Q(url__icontains=query) | Q(title__icontains=query)) if status_filter in valid_statuses: filtered_qs = filtered_qs.filter(status=status_filter) # Keep ArchiveResult counters as scalar subqueries so the paginated # Snapshot queryset does not become a join+GROUP BY over every result row. snapshots_qs = filtered_qs.order_by("-created_at").annotate( total_results=ArchiveResult.snapshot_count_expr(), succeeded_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED), failed_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED), started_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.STARTED), skipped_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SKIPPED), snapshot_permissions=F("permissions"), ) page_number = request.GET.get(page_param, 1) if request is not None else 1 paginator = Paginator(snapshots_qs, page_size) page_obj = paginator.get_page(page_number) snapshots = page_obj.object_list total_count = paginator.count def querystring(**updates): if request is None: return "#" params = request.GET.copy() for key, value in updates.items(): if value in (None, ""): params.pop(key, None) else: params[key] = str(value) return f"?{params.urlencode()}" if params else "?" preserved_inputs = "" if request is not None: managed_params = {query_param, status_param, page_param} preserved_inputs = "".join( f'' for key, values in request.GET.lists() if key not in managed_params for value in values ) status_options = "".join( f'' for value, label in Snapshot.StatusChoices.choices ) controls = f"""
{preserved_inputs} {f'Clear' if query or status_filter else ""}
{page_obj.start_index() if total_count else 0}-{page_obj.end_index() if total_count else 0} of {total_count}
""" if not snapshots: return mark_safe(f"""
{controls}
No Snapshots found.
""") # Status colors matching Django admin and progress monitor status_colors = { "queued": ("#6c757d", "#f8f9fa"), # gray "started": ("#856404", "#fff3cd"), # amber "paused": ("#1d4ed8", "#dbeafe"), # blue "sealed": ("#155724", "#d4edda"), # green "failed": ("#721c24", "#f8d7da"), # red } rows = [] for snapshot in snapshots: status = snapshot.status or "queued" color, bg = status_colors.get(status, ("#6c757d", "#f8f9fa")) permissions = snapshot.snapshot_permissions permission_icon = { PERMISSIONS_PUBLIC: "๐Ÿ‘", PERMISSIONS_UNLISTED: "๐Ÿ”—", PERMISSIONS_PRIVATE: "๐Ÿ”’", }[permissions] permission_fg, permission_bg = { PERMISSIONS_PUBLIC: ("#047857", "#d1fae5"), PERMISSIONS_UNLISTED: ("#1d4ed8", "#dbeafe"), PERMISSIONS_PRIVATE: ("#991b1b", "#fee2e2"), }[permissions] # Calculate progress total = snapshot.total_results succeeded = snapshot.succeeded_results failed = snapshot.failed_results running = snapshot.started_results skipped = snapshot.skipped_results done = succeeded + failed + skipped pending = max(total - done - running, 0) progress_pct = int((done / total) * 100) if total > 0 else 0 progress_text = f"{done}/{total}" if total > 0 else "-" progress_title = f"{succeeded} succeeded, {failed} failed, {running} running, {pending} pending, {skipped} skipped" progress_color = "#28a745" if failed: progress_color = "#dc3545" elif running: progress_color = "#17a2b8" elif pending: progress_color = "#ffc107" # Truncate title and URL snapshot_title = snapshot.title or "Untitled" title = snapshot_title[:60] if len(snapshot_title) > 60: title += "..." url_display = snapshot.url[:50] if len(snapshot.url) > 50: url_display += "..." delete_button = "" exclude_button = "" if crawl is not None: delete_url = reverse("admin:crawls_crawl_snapshot_delete", args=[crawl.pk, snapshot.pk]) exclude_url = reverse("admin:crawls_crawl_snapshot_exclude_domain", args=[crawl.pk, snapshot.pk]) delete_button = f''' ''' exclude_button = f''' ''' # Format date date_str = snapshot.created_at.strftime("%Y-%m-%d %H:%M") if snapshot.created_at else "-" rows.append(f''' {status} {permission_icon} {escape(title)} {escape(url_display)}
{progress_text}
{date_str} {f'
{exclude_button}{delete_button}
' if crawl is not None else ""} ''') pagination = "" if paginator.num_pages > 1: pagination = f"""
{"Previous" if page_obj.has_previous() else "Previous"} Page {page_obj.number} of {paginator.num_pages} {"Next" if page_obj.has_next() else "Next"}
""" return mark_safe(f"""
{controls} { '' if crawl is not None else "" } {"".join(rows)}
Status ๐Ÿ”’ Title URL Progress CreatedActions
{pagination}
{ ''' ''' if crawl is not None else "" } """) class URLFiltersField(forms.Field): widget = URLFiltersWidget(source_selector="#id_urls") def to_python(self, value): if isinstance(value, dict): return value return {"allowlist": "", "denylist": "", "same_domain_only": False, "subpaths_only": False, "only_new": False} class CrawlAdminForm(forms.ModelForm): """Custom form for Crawl admin to render urls field as textarea.""" tags_editor = forms.CharField( label="Tags", required=False, widget=TagEditorWidget(), help_text="Type tag names and press Enter or Space to add. Click ร— to remove.", ) url_filters = URLFiltersField( label="URL Filters", required=False, help_text="Set URL_ALLOWLIST / URL_DENYLIST for this crawl.", ) class Meta: model = Crawl fields = "__all__" widgets: ClassVar[dict[str, forms.Widget]] = { "urls": forms.Textarea( attrs={ "rows": 8, "style": "width: 100%; font-family: monospace; font-size: 13px;", "placeholder": "https://example.com\nhttps://example2.com\n# Comments start with #", }, ), "notes": forms.Textarea( attrs={ "rows": 1, "style": "width: 100%; min-height: 0; resize: vertical;", }, ), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) config = dict(self.instance.config or {}) if self.instance and self.instance.pk else {} if self.instance and self.instance.pk: self.initial["tags_editor"] = self.instance.tags_str effective_only_new = self.effective_only_new(self.instance if self.instance and self.instance.pk else None) derived_filter_toggles = self.derive_filter_toggles( self.instance.urls if self.instance and self.instance.pk else "", config.get("URL_ALLOWLIST", ""), ) self.initial["url_filters"] = { "allowlist": config.get("URL_ALLOWLIST", ""), "denylist": config.get("URL_DENYLIST", ""), "same_domain_only": derived_filter_toggles["same_domain_only"], "subpaths_only": derived_filter_toggles["subpaths_only"], "only_new": effective_only_new, } @staticmethod def extract_url_line(line): line = str(line or "").strip() if not line or line.startswith("#"): return "" if line.startswith("{"): try: return str(json.loads(line).get("url", "")).strip() except (TypeError, ValueError, json.JSONDecodeError): return "" return line @staticmethod def regex_escape(text): escaped = "" for char in str(text or ""): escaped += f"\\{char}" if char in r".*+?^${}()|[]\\" else char return escaped @classmethod def generated_host_allowlist(cls, urls): seen = set() domains = [] for raw_line in str(urls or "").splitlines(): url = cls.extract_url_line(raw_line) if not url: continue parsed = urlparse(url) domain = (parsed.hostname or "").lower() if not domain or domain in seen: continue seen.add(domain) domains.append(domain) if not domains: return "" return "^https?://(" + "|".join(cls.regex_escape(domain) for domain in domains) + ")([:/]|$)" @staticmethod def subpath_prefix(pathname): path = str(pathname or "/") while "//" in path: path = path.replace("//", "/") if not path or path == "/": return "/" if path.endswith("/"): return path last_slash = path.rfind("/") last_part = path[last_slash + 1 :] if "." in last_part: return path[: last_slash + 1] or "/" return path @staticmethod def parsed_host_and_port(parsed): host = (parsed.hostname or "").lower() if not host: return "" try: port = parsed.port except ValueError: port = None return f"{host}:{port}" if port is not None else host @classmethod def generated_subpath_allowlist(cls, urls): seen = set() paths = [] for raw_line in str(urls or "").splitlines(): url = cls.extract_url_line(raw_line) if not url: continue parsed = urlparse(url) domain = (parsed.hostname or "").lower() if domain: seen.add(domain) host = cls.parsed_host_and_port(parsed) path = cls.subpath_prefix(parsed.path) path_key = f"{host}{path}" if not host or path_key in seen: continue seen.add(path_key) paths.append((host, path)) if not paths: return "" patterns = [] for host, path in paths: if path == "/": patterns.append(f"^https?://{cls.regex_escape(host)}([/?#]|$)") elif path.endswith("/"): patterns.append(f"^https?://{cls.regex_escape(host)}{cls.regex_escape(path)}") else: patterns.append(f"^https?://{cls.regex_escape(host)}{cls.regex_escape(path)}([/?#]|$)") return "\n".join(patterns) @classmethod def derive_filter_toggles(cls, urls, allowlist): normalized_allowlist = "\n".join(Crawl.split_filter_patterns(allowlist)) if not normalized_allowlist: return {"same_domain_only": False, "subpaths_only": False} if normalized_allowlist == cls.generated_subpath_allowlist(urls): return {"same_domain_only": True, "subpaths_only": True} if normalized_allowlist == cls.generated_host_allowlist(urls): return {"same_domain_only": True, "subpaths_only": False} return {"same_domain_only": False, "subpaths_only": False} @staticmethod def effective_only_new(crawl=None): from archivebox.config.common import get_config if crawl is not None: return bool(get_config(crawl=crawl, resolve_plugins=False).ONLY_NEW) return bool(get_config(resolve_plugins=False).ONLY_NEW) @staticmethod def inherited_only_new(crawl): crawl_without_only_new = copy(crawl) config = dict(crawl.config or {}) config.pop("ONLY_NEW", None) crawl_without_only_new.config = config return CrawlAdminForm.effective_only_new(crawl_without_only_new) def clean_tags_editor(self): tags_str = self.cleaned_data.get("tags_editor", "") tag_names = [] seen = set() for raw_name in tags_str.split(","): name = raw_name.strip() if not name: continue lowered = name.lower() if lowered in seen: continue seen.add(lowered) tag_names.append(name) return ",".join(tag_names) def clean_url_filters(self): value = self.cleaned_data.get("url_filters") or {} return { "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")), "only_new": bool(value.get("only_new")), } def save(self, commit=True): instance = super().save(commit=False) instance.tags_str = self.cleaned_data.get("tags_editor", "") 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", ""), ) config = dict(instance.config or {}) only_new = bool(url_filters.get("only_new")) inherited_only_new = self.inherited_only_new(instance) if only_new != inherited_only_new: config["ONLY_NEW"] = only_new else: config.pop("ONLY_NEW", None) instance.config = config if commit: instance.save() instance.apply_crawl_config_filters() self._save_m2m() return instance class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin): form = CrawlAdminForm change_form_template = "admin/crawls/crawl/change_form.html" list_select_related = () paginator = AcceleratedPaginator show_full_result_count = False list_display = ( "short_id", "permissions_badge", "created_at", "owner", "depth", "status_with_stop_reason", "pause_resume_control", "label", "notes", "urls_preview", "schedule_str", "retry_at", "num_archived_snapshots", "num_total_snapshots", ) sort_fields = ( "id", "created_at", "created_by", "max_depth", "label", "notes", "schedule_str", "status", "retry_at", ) search_fields = ( "id", "created_by__username", "max_depth", "label", "notes", "schedule_id", "status", "urls", ) readonly_fields = ("created_at", "modified_at", "stop_reason_display") fieldsets = ( ( "URLs", { "fields": ("urls", "url_filters"), "classes": ("card", "wide"), }, ), ( "Overview", { "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"), }, ), ( "Config", { "fields": ("config",), "classes": ("card", "wide", "crawl-admin-config"), }, ), ) add_fieldsets = ( ( "URLs", { "fields": ("urls", "url_filters"), "classes": ("card", "wide"), }, ), ( "Overview", { "fields": ( ("label", "status", "retry_at", "schedule", "created_by"), ("max_depth",), ("notes", "tags_editor"), ), "classes": ("card", "wide", "crawl-admin-overview"), }, ), ( "Config", { "fields": ("config",), "classes": ("card", "wide", "crawl-admin-config"), }, ), ) list_filter = (MaxDepthListFilter, "schedule", "created_by", "status", "retry_at") ordering = ("-created_at", "-retry_at") list_per_page = 50 actions = ( "pause_selected_crawls", "resume_selected_crawls", "seal_selected_crawls", "delete_selected_batched", "set_crawl_permissions", ) change_actions = ("recrawl",) def __init__(self, model, admin_site): super().__init__(model, admin_site) self.crawl_admin_base_config = None self.stop_reason_cache = {} class Media: css: ClassVar[dict[str, tuple[str, ...]]] = {"all": ("admin/crawls/crawl_change.css",)} js: ClassVar[tuple[str, ...]] = ("admin/crawls/crawl_admin.js",) def changelist_view(self, request, extra_context=None): self.request = request self.crawl_admin_base_config = request.archivebox_config self.stop_reason_cache = {} response = super().changelist_view(request, extra_context) if not isinstance(response, TemplateResponse): return response cl = response.context_data.get("cl") if cl is not None and not self.should_annotate_snapshot_counts(request): self.hydrate_visible_snapshot_counts(cl.result_list) return response def should_annotate_snapshot_counts(self, request): ordering = request.GET.get("o", "") if not ordering: return False list_display = list(self.get_list_display(request)) count_positions = { str(list_display.index("num_archived_snapshots") + 1), str(list_display.index("num_total_snapshots") + 1), } return any(part.lstrip("-") in count_positions for part in ordering.split(".")) def hydrate_visible_snapshot_counts(self, crawls): crawl_list = list(crawls) crawl_ids = [crawl.pk for crawl in crawl_list] if not crawl_ids: return counts = Snapshot.crawl_total_and_status_counts(crawl_ids, status=Snapshot.StatusChoices.SEALED) for crawl in crawl_list: row = counts.get(str(crawl.pk), {}) crawl.num_snapshots_cached = row.get("total", 0) crawl.num_archived_snapshots_cached = row.get("status", 0) def get_queryset(self, request): """Keep joins page-local while computing per-row snapshot counts in the page query.""" queryset = ( super() .get_queryset(request) .prefetch_related( "created_by", "persona", "schedule__template", ) ) if self.should_annotate_snapshot_counts(request): queryset = queryset.annotate( num_snapshots_cached=Snapshot.crawl_count_expr(), num_archived_snapshots_cached=Snapshot.crawl_count_expr(status=Snapshot.StatusChoices.SEALED), ) return queryset def change_view(self, request, object_id, form_url="", extra_context=None): self.request = request self.crawl_admin_base_config = request.archivebox_config self.stop_reason_cache = {} crawl = self.get_object(request, object_id) if crawl: self.hydrate_visible_snapshot_counts([crawl]) extra_context = { **(extra_context or {}), "crawl_stop_reason": self.stop_reason_for_crawl(crawl) if crawl else "", "crawl_snapshots_changelist": self.snapshots_changelist(crawl) if crawl else "", } if crawl and crawl.status in { Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED, Crawl.StatusChoices.PAUSED, }: extra_context["progress_auto_expand"] = True extra_context["progress_endpoint"] = progress_endpoint("crawl", crawl.id) return super().change_view(request, object_id, form_url, extra_context) def add_view(self, request, form_url="", extra_context=None): 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 def get_urls(self): urls = super().get_urls() custom_urls = [ path( "/snapshot//delete/", self.admin_site.admin_view(self.delete_snapshot_view), name="crawls_crawl_snapshot_delete", ), path( "/snapshot//exclude-domain/", self.admin_site.admin_view(self.exclude_domain_view), name="crawls_crawl_snapshot_exclude_domain", ), path( "/set-permissions/", self.admin_site.admin_view(self.set_permissions_view), name="crawls_crawl_set_permissions", ), ] return custom_urls + urls def get_actions(self, request): actions = super().get_actions(request) actions.pop("delete_selected", None) return actions @admin.action(description="Delete") def delete_selected_batched(self, request, queryset): """Delete crawls in a single transaction to avoid SQLite concurrency issues.""" from django.db import transaction total = queryset.count() # Get list of IDs to delete first (outside transaction) ids_to_delete = list(queryset.values_list("pk", flat=True)) # Delete everything in a single atomic transaction with transaction.atomic(): deleted_count, _ = Crawl.objects.filter(pk__in=ids_to_delete).delete() messages.success(request, f"Successfully deleted {total} crawls ({deleted_count} total objects including related records).") @admin.action(description="Pause") def pause_selected_crawls(self, request, queryset): # Admin changelist actions must stay set-based. Calling crawl.pause() # here fans out into per-crawl Snapshot/ArchiveResult writes and can # hold SQLite behind the request for minutes on large archives. The # Crawl row is the scheduler signal; the runner observes PAUSED and # owns child-row lifecycle work. paused = queryset.exclude(status__in=Crawl.INACTIVE_STATES).update( status=Crawl.StatusChoices.PAUSED, retry_at=RETRY_AT_MAX, modified_at=timezone.now(), ) 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") def resume_selected_crawls(self, request, queryset): # Keep resume symmetrical with pause: one tight scheduler UPDATE, no # save() hooks and no child fanout in the request path. Paused child # rows become runnable through their own resume/maintenance paths. resumed = queryset.filter(status__in=Crawl.INACTIVE_STATES).update( status=Crawl.StatusChoices.QUEUED, retry_at=timezone.now(), modified_at=timezone.now(), ) 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.") @admin.action(description="Seal") def seal_selected_crawls(self, request, queryset): now = timezone.now() crawl_ids = list(queryset.exclude(status=Crawl.StatusChoices.SEALED).values_list("pk", flat=True)) if not crawl_ids: messages.warning(request, "No unsealed crawls were selected to seal.") return Snapshot.objects.filter( crawl_id__in=crawl_ids, status__in=Snapshot.OPEN_STATES, ).filter( Q(retry_at__isnull=True) | Q(retry_at__gt=now), ).update( retry_at=now, modified_at=now, ) sealed = ( Crawl.objects.filter(pk__in=crawl_ids) .exclude(status=Crawl.StatusChoices.SEALED) .update( status=Crawl.StatusChoices.SEALED, retry_at=now, modified_at=now, ) ) messages.success(request, f"Sealed {sealed} crawl(s). The runner will finish cleanup on the next sweep.") @admin.action(description="Permissions โ–พ") def set_crawl_permissions(self, request, queryset): permissions = (request.POST.get("permissions") or "").strip().lower() if permissions not in PERMISSIONS_VALUES: messages.error(request, "Choose a valid permissions value.") return updated = self.update_crawl_permissions(queryset, permissions) messages.success(request, f"Set permissions to {permissions} on {updated} crawl(s).") def update_crawl_permissions(self, queryset, permissions): now = timezone.now() updated = 0 batch = [] crawls_to_update = [] for crawl in queryset.only("id", "config", "permissions").iterator(chunk_size=500): old_permissions = crawl.permissions config = dict(crawl.config or {}) config["PERMISSIONS"] = permissions crawl.config = config crawl.modified_at = now crawls_to_update.append((crawl, old_permissions)) batch.append(crawl) if len(batch) >= 500: Crawl.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500) updated += len(batch) batch.clear() if batch: Crawl.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500) updated += len(batch) for crawl, old_permissions in crawls_to_update: crawl.update_child_snapshot_permissions(old_permissions, permissions) return updated @action(label="Recrawl", description="Create a new crawl with the same settings", methods=("POST",)) def recrawl(self, request, obj): """Duplicate this crawl as a new crawl with the same URLs and settings.""" # Validate URLs (required for crawl to start) if not obj.urls: messages.error(request, "Cannot recrawl: original crawl has no URLs.") return redirect("admin:crawls_crawl_change", obj.id) new_crawl = Crawl.create_scheduler_row( urls=obj.urls, max_depth=obj.max_depth, tags_str=obj.tags_str, config=obj.config, schedule=obj.schedule, label=f"{obj.label} (recrawl)" if obj.label else "", notes=obj.notes, created_by=request.user, status=Crawl.StatusChoices.QUEUED, retry_at=timezone.now(), ) messages.success(request, f"Created new crawl {new_crawl.id} with the same settings. It will start processing shortly.") return redirect("admin:crawls_crawl_change", new_crawl.id) @admin.display(description="Stop Reason") def stop_reason_display(self, obj): reason = self.stop_reason_for_crawl(obj) if obj else "" if not reason: return mark_safe('None') return format_html('{}', reason) def stop_reason_for_crawl(self, obj): if obj.pk in self.stop_reason_cache: return self.stop_reason_cache[obj.pk] output_dir = obj.output_dir config = self.limit_config_for_crawl(obj, output_dir) reason = obj.stop_reason( config=config, output_dir=output_dir, num_snapshots=obj.num_snapshots_cached, num_sealed_snapshots=obj.num_archived_snapshots_cached, ) self.stop_reason_cache[obj.pk] = reason return reason def limit_config_for_crawl(self, obj, output_dir): from archivebox.config.common import get_config return get_config(crawl=obj).for_crawl_runtime( crawl=obj, persona=obj.resolve_persona(), crawl_output_dir=output_dir, ) @admin.display(description="Status", ordering="status") def status_with_stop_reason(self, obj): status = "PAUSED" if obj.is_paused else str(obj.status or "").upper() reason = self.stop_reason_for_crawl(obj) if obj.is_paused or obj.status == Crawl.StatusChoices.SEALED else "" if reason: reason_label = reason.removeprefix("crawl_").replace("_", " ") return format_html( '{}{}', obj.status, status, reason, reason_label, ) return format_html('{}', obj.status, status) @admin.display(description="ID", ordering="id") def short_id(self, obj): short_id = str(obj.pk)[-8:] return format_html('{}', obj.admin_change_url, short_id) @admin.display(description="Owner", ordering="created_by") def owner(self, obj): return obj.created_by @admin.display(description="Depth", ordering="max_depth") def depth(self, obj): return obj.max_depth @admin.display(description="๐Ÿ‘", ordering="permissions") def permissions_badge(self, obj): permissions = normalize_permissions(obj.permissions) icon, label, fg, bg = PERMISSIONS_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 [PERMISSIONS_META[choice_value]] ), ) return format_html( '' '" '' "", permissions, reverse(f"{self.admin_site.name}:crawls_crawl_set_permissions", args=[obj.pk]), permissions, label, label, fg, bg, icon, menu_items, ) @admin.display(description="Pause") def pause_resume_control(self, obj): if obj.is_paused or obj.status == Crawl.StatusChoices.SEALED: reason = "paused" if obj.is_paused else (self.stop_reason_for_crawl(obj) or "sealed") return format_html( '', obj.pk, reason, ) return format_html( '', obj.pk, ) @admin.display(description="Archived", ordering="num_archived_snapshots_cached") def num_archived_snapshots(self, obj): return obj.num_archived_snapshots_cached @admin.display(description="Snapshots", ordering="num_snapshots_cached") def num_total_snapshots(self, obj): return obj.num_snapshots_cached @admin.display(description="Snapshots") def snapshots_changelist(self, obj): request = self.request snapshot_changelist = reverse("admin:core_snapshot_changelist") scoped_params = {"crawl_id": str(obj.pk)} full_url = f"{snapshot_changelist}?{urlencode(scoped_params)}" 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": return HttpResponseNotAllowed(["POST"]) crawl = get_object_or_404(Crawl, pk=object_id) snapshot = get_object_or_404(Snapshot, pk=snapshot_id, crawl=crawl) if snapshot.status == Snapshot.StatusChoices.STARTED: snapshot.cancel_running_hooks() removed_urls = crawl.prune_url(snapshot.url) snapshot.delete() return JsonResponse( { "ok": True, "snapshot_id": str(snapshot.id), "removed_urls": removed_urls, }, ) def exclude_domain_view(self, request: HttpRequest, object_id: str, snapshot_id: str): if request.method != "POST": return HttpResponseNotAllowed(["POST"]) crawl = get_object_or_404(Crawl, pk=object_id) snapshot = get_object_or_404(Snapshot, pk=snapshot_id, crawl=crawl) result = crawl.exclude_domain(snapshot.url) return JsonResponse( { "ok": True, **result, }, ) def set_permissions_view(self, request: HttpRequest, object_id: str): if request.method != "POST": return HttpResponseNotAllowed(["POST"]) permissions = (request.POST.get("permissions") or "").strip().lower() if permissions not in PERMISSIONS_VALUES: return HttpResponseBadRequest("Invalid permissions value") crawl = get_object_or_404(Crawl, pk=object_id) self.update_crawl_permissions(Crawl.objects.filter(pk=crawl.pk), permissions) icon, label, fg, bg = PERMISSIONS_META[permissions] return JsonResponse({"permissions": permissions, "icon": icon, "label": label, "fg": fg, "bg": bg}) @admin.display(description="Schedule", ordering="schedule") def schedule_str(self, obj): if not obj.schedule: return mark_safe("None") return format_html('{}', obj.schedule.admin_change_url, obj.schedule) @admin.display(description="URLs", ordering="urls") def urls_preview(self, obj): first_url = next((line.strip() for line in (obj.urls or "").splitlines() if line.strip() and not line.strip().startswith("#")), "") return first_url[:80] + "..." if len(first_url) > 80 else first_url @admin.display(description="URLs") def urls_editor(self, obj): """Editor for crawl URLs.""" widget_id = f"crawl_urls_{obj.pk}" # Escape for safe HTML embedding escaped_urls = (obj.urls or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) # Count lines for auto-expand logic line_count = len((obj.urls or "").split("\n")) uri_rows = min(max(3, line_count), 10) html = f'''

{line_count} URL{"s" if line_count != 1 else ""} ยท Note: URLs displayed here for reference only

''' return mark_safe(html) class CrawlScheduleAdmin(BaseModelAdmin): list_display = ("id", "created_at", "created_by", "label", "notes", "template_str", "crawls", "num_crawls", "num_snapshots") sort_fields = ("id", "created_at", "created_by", "label", "notes", "template_str") search_fields = ("id", "created_by__username", "label", "notes", "schedule_id", "template_id", "template__urls") readonly_fields = ("created_at", "modified_at", "crawls", "snapshots") autocomplete_fields = ("template", "created_by") fieldsets = ( ( "Schedule Info", { "fields": ("label", "notes"), "classes": ("card",), }, ), ( "Configuration", { "fields": ("schedule", "template"), "classes": ("card",), }, ), ( "Metadata", { "fields": ("created_by", "created_at", "modified_at"), "classes": ("card",), }, ), ( "Crawls", { "fields": ("crawls",), "classes": ("card", "wide"), }, ), ( "Snapshots", { "fields": ("snapshots",), "classes": ("card", "wide"), }, ), ) list_filter = ("created_by",) ordering = ("-created_at",) list_per_page = 100 actions = ("delete_selected",) def get_queryset(self, request): self.request = request return ( super() .get_queryset(request) .select_related("created_by", "template") .annotate( crawl_count=Count("crawl", distinct=True), snapshot_count=Count("crawl__snapshot_set", distinct=True), ) ) 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 add_view(self, request, form_url="", extra_context=None): return redirect("/add/#schedule") 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"}) return self.fieldsets def save_model(self, request, obj, form, change): if not obj.created_by_id and request.user.is_authenticated: obj.created_by = request.user super().save_model(request, obj, form, change) @admin.display(description="Template", ordering="template") def template_str(self, obj): return format_html('{}', obj.template.admin_change_url, obj.template) @admin.display(description="# Crawls", ordering="crawl_count") def num_crawls(self, obj): count = obj.__dict__.get("crawl_count") if count is None: count = obj.crawl_set.count() return count @admin.display(description="# Snapshots", ordering="snapshot_count") def num_snapshots(self, obj): count = obj.__dict__.get("snapshot_count") if count is None: count = Snapshot.objects.filter(crawl__schedule=obj).count() return count def crawls(self, obj): return format_html_join( "
", ' - {}', ((crawl.admin_change_url, crawl) for crawl in obj.crawl_set.all().order_by("-created_at")[:20]), ) or mark_safe("No Crawls yet...") 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), request=self.request, prefix="schedule_snapshots", ) def register_admin(admin_site): admin_site.register(Crawl, CrawlAdmin) admin_site.register(CrawlSchedule, CrawlScheduleAdmin)