__package__ = "archivebox.core" import json from collections.abc import Mapping from typing import Any from django import forms from django.utils.html import format_html from archivebox.misc.util import URL_REGEX, find_all_urls, parse_filesize_to_bytes from taggit.utils import edit_string_for_tags, parse_tags from archivebox.base_models.admin import KeyValueWidget from archivebox.crawls.schedule_utils import validate_schedule from archivebox.config.common import get_config 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 DEPTH_CHOICES = ( ("0", "depth = 0 (archive just these URLs)"), ("1", "depth = 1 (+ URLs one hop away)"), ("2", "depth = 2 (+ URLs two hops away)"), ("3", "depth = 3 (+ URLs three hops away)"), ("4", "depth = 4 (+ URLs four hops away)"), ) PLUGIN_CONFIG_FIELD_PREFIX = "plugin_config__" PLUGIN_GROUP_DEFINITIONS = ( ( "chrome_plugins", "Chrome-dependent plugins", "", "chrome-plugins", "chrome", { "accessibility", "chrome", "consolelog", "dom", "headers", "parse_dom_outlinks", "pdf", "redirects", "responses", "screenshot", "seo", "singlefile", "ssl", "staticfile", "title", }, ), ( "archiving_plugins", "Archiving", "", "", "", { "archivedotorg", "defuddle", "favicon", "forumdl", "gallerydl", "git", "htmltotext", "mercury", "papersdl", "readability", "trafilatura", "wget", "ytdlp", }, ), ( "parsing_plugins", "Parsing", "", "", "", { "parse_html_urls", "parse_jsonl_urls", "parse_netscape_urls", "parse_rss_urls", "parse_txt_urls", }, ), ( "search_plugins", "Search", "(defaults to SEARCH_BACKEND_ENGINE)", "", "", { "search_backend_ripgrep", "search_backend_sonic", "search_backend_sqlite", }, ), ("binary_plugins", "Binary Providers", "", "", "", {"apt", "brew", "custom", "env", "npm", "pip"}), ("extension_plugins", "Browser Extensions", "", "", "", {"twocaptcha", "istilldontcareaboutcookies", "ublock"}), ) def get_plugin_choices(): """Get available extractor plugins from discovered hooks.""" return [(name, name) for name in get_plugins()] def get_plugin_choice_label(plugin_name: str, plugin_configs: dict[str, dict]) -> str: schema = plugin_configs.get(plugin_name, {}) description = str(schema.get("description") or "").strip() if not description: return plugin_name icon_html = get_plugin_icon(plugin_name) return format_html( '{}{}{}', icon_html, plugin_name, plugin_name, description, ) def get_choice_field(form: forms.Form, name: str) -> forms.ChoiceField: field = form.fields[name] if not isinstance(field, forms.ChoiceField): raise TypeError(f"{name} must be a ChoiceField") return field def _plugin_config_input_name(plugin_name: str, config_key: str) -> str: return f"{PLUGIN_CONFIG_FIELD_PREFIX}{plugin_name}__{config_key}" def _schema_types(schema: Mapping[str, Any]) -> list[str]: raw_type = schema.get("type") or "string" if isinstance(raw_type, list): return [str(item) for item in raw_type] return [str(raw_type)] def _jsonish(value: Any) -> str: if isinstance(value, str): return value return json.dumps(value, sort_keys=True, default=str) def _same_config_value(left: Any, right: Any) -> bool: return json.dumps(left, sort_keys=True, default=str) == json.dumps(right, sort_keys=True, default=str) def _coerce_plugin_config_value(raw_value: Any, schema: Mapping[str, Any]) -> Any: schema_types = _schema_types(schema) if "boolean" in schema_types: if isinstance(raw_value, bool): return raw_value value = str(raw_value).strip().lower() if value in {"true", "1", "yes", "on"}: return True if value in {"false", "0", "no", "off", ""}: return False raise forms.ValidationError("Must be true or false.") if "integer" in schema_types: value = int(str(raw_value).strip()) minimum = schema.get("minimum") maximum = schema.get("maximum") if minimum is not None and value < int(minimum): raise forms.ValidationError(f"Must be at least {minimum}.") if maximum is not None and value > int(maximum): raise forms.ValidationError(f"Must be at most {maximum}.") return value if "number" in schema_types: value = float(str(raw_value).strip()) minimum = schema.get("minimum") maximum = schema.get("maximum") if minimum is not None and value < float(minimum): raise forms.ValidationError(f"Must be at least {minimum}.") if maximum is not None and value > float(maximum): raise forms.ValidationError(f"Must be at most {maximum}.") return value if "array" in schema_types: if isinstance(raw_value, list): return raw_value value = str(raw_value).strip() if not value: return [] if value.startswith("["): parsed = json.loads(value) if not isinstance(parsed, list): raise forms.ValidationError("Must be a JSON array.") return parsed return [item.strip() for item in value.replace(",", "\n").splitlines() if item.strip()] if "object" in schema_types: value = str(raw_value).strip() if not value: return {} parsed = json.loads(value) if not isinstance(parsed, dict): raise forms.ValidationError("Must be a JSON object.") return parsed value = str(raw_value) enum = schema.get("enum") if isinstance(enum, list) and enum and value not in {str(item) for item in enum}: raise forms.ValidationError(f"Must be one of: {', '.join(str(item) for item in enum)}.") return value class PluginConfigFormMixin: plugin_groups: list[dict[str, Any]] def build_plugin_groups(self, runtime_config: Mapping[str, Any] | None = None) -> None: all_plugins = get_plugins() plugin_configs = discover_plugin_configs() runtime_config = runtime_config or get_config() grouped_plugins = set().union(*(group[-1] for group in PLUGIN_GROUP_DEFINITIONS)) for field_name, *_rest, plugin_names in PLUGIN_GROUP_DEFINITIONS: if field_name in self.fields: get_choice_field(self, field_name).choices = [ (p, get_plugin_choice_label(p, plugin_configs)) for p in sorted(all_plugins) if p in plugin_names ] if "other_plugins" in self.fields: get_choice_field(self, "other_plugins").choices = [ (p, get_plugin_choice_label(p, plugin_configs)) for p in sorted(all_plugins) if p not in grouped_plugins ] if "search_plugins" in self.fields: required_search_plugin = f"search_backend_{get_config().SEARCH_BACKEND_ENGINE}".strip() search_choices = [choice[0] for choice in get_choice_field(self, "search_plugins").choices] if required_search_plugin in search_choices: get_choice_field(self, "search_plugins").initial = [required_search_plugin] group_specs = (*PLUGIN_GROUP_DEFINITIONS, ("other_plugins", "Other", "", "", "", set(all_plugins) - grouped_plugins)) self.plugin_groups = [ { "field_name": field_name, "title": title, "note": note, "dom_id": dom_id, "select_all_group": select_all_group, "show_selectors": field_name in self.fields, "plugins": self._build_plugin_cards(field_name, plugin_names, plugin_configs, runtime_config), } for field_name, title, note, dom_id, select_all_group, plugin_names in group_specs if any(plugin in all_plugins for plugin in plugin_names) ] def _build_plugin_cards( self, field_name: str, plugin_names: set[str], plugin_configs: dict[str, dict[str, Any]], runtime_config: Mapping[str, Any], ) -> list[dict[str, Any]]: if field_name in self.fields: choices = list(get_choice_field(self, field_name).choices) selected_values = set(self.data.getlist(field_name)) if self.is_bound else set(get_choice_field(self, field_name).initial or []) else: choices = [(p, get_plugin_choice_label(p, plugin_configs)) for p in sorted(get_plugins()) if p in plugin_names] selected_values = set() cards = [] for index, (plugin_name, label) in enumerate(choices): schema = plugin_configs.get(str(plugin_name), {}) properties = schema.get("properties") or {} config_fields = [ self._build_plugin_config_field(str(plugin_name), str(config_key), prop_schema, runtime_config) for config_key, prop_schema in properties.items() if isinstance(prop_schema, dict) ] cards.append( { "name": str(plugin_name), "label": label, "checked": str(plugin_name) in selected_values, "checkbox_id": f"id_{field_name}_{index}", "description": str(schema.get("description") or "").strip(), "required_plugins": [str(item) for item in schema.get("required_plugins") or []], "required_binaries_count": len(schema.get("required_binaries") or []), "config_fields": config_fields, "config_count": len(config_fields), }, ) return cards def _build_plugin_config_field( self, plugin_name: str, config_key: str, prop_schema: Mapping[str, Any], runtime_config: Mapping[str, Any], ) -> dict[str, Any]: schema_types = _schema_types(prop_schema) enum = prop_schema.get("enum") input_name = _plugin_config_input_name(plugin_name, config_key) current_value = runtime_config.get(config_key, prop_schema.get("default", "")) if self.is_bound and input_name in self.data: try: current_value = _coerce_plugin_config_value(self.data.get(input_name), prop_schema) except (TypeError, ValueError, json.JSONDecodeError, forms.ValidationError): current_value = self.data.get(input_name) default_value = prop_schema.get("default", "") is_sensitive = bool(prop_schema.get("x-sensitive")) input_value = "" if is_sensitive else _jsonish(current_value) field_kind = "text" input_type = "text" options = [] if "boolean" in schema_types: field_kind = "boolean" input_value = "true" if bool(current_value) else "false" elif isinstance(enum, list) and enum: field_kind = "select" options = [ { "value": str(option), "label": str(option), "selected": str(option) == str(current_value), } for option in enum ] elif "integer" in schema_types or "number" in schema_types: field_kind = "number" input_type = "number" elif "array" in schema_types or "object" in schema_types: field_kind = "json" input_value = "" if is_sensitive else json.dumps(current_value, indent=2, sort_keys=True, default=str) elif is_sensitive: input_type = "password" return { "key": config_key, "input_name": input_name, "kind": field_kind, "input_type": input_type, "value": input_value, "checked": bool(current_value), "options": options, "description": str(prop_schema.get("description") or "").strip(), "default": _jsonish(default_value), "current": "configured" if is_sensitive and current_value else _jsonish(current_value), "is_sensitive": is_sensitive, "minimum": prop_schema.get("minimum"), "maximum": prop_schema.get("maximum"), "pattern": prop_schema.get("pattern"), "type_label": " / ".join(schema_types), } def clean_plugin_config_overrides(self, effective_config: Mapping[str, Any] | None = None) -> dict[str, Any]: if not self.is_bound: return {} effective_config = effective_config or get_config() overrides: dict[str, Any] = {} sources: dict[str, str] = {} for plugin_name, schema in discover_plugin_configs().items(): for config_key, prop_schema in (schema.get("properties") or {}).items(): if not isinstance(prop_schema, dict): continue input_name = _plugin_config_input_name(plugin_name, config_key) if input_name not in self.data: continue raw_value: Any = self.data.get(input_name) if "array" in _schema_types(prop_schema) and isinstance(prop_schema.get("enum"), list): raw_value = self.data.getlist(input_name) if prop_schema.get("x-sensitive") and raw_value == "": continue try: coerced_value = _coerce_plugin_config_value(raw_value, prop_schema) except (TypeError, ValueError, json.JSONDecodeError) as err: self.add_error("config", forms.ValidationError(f"{config_key}: {err}")) continue except forms.ValidationError as err: self.add_error("config", forms.ValidationError(f"{config_key}: {err.messages[0]}")) continue base_value = effective_config.get(config_key, prop_schema.get("default", "")) if _same_config_value(coerced_value, base_value): continue existing_value = overrides.get(config_key) if config_key in overrides and not _same_config_value(existing_value, coerced_value): self.add_error( "config", forms.ValidationError( f"{config_key} was set differently under {sources[config_key]} and {plugin_name}. Set it once in Custom config overrides.", ), ) continue overrides[config_key] = coerced_value sources[config_key] = plugin_name return overrides def plugin_config_keys(self) -> set[str]: return { str(config_key) for schema in discover_plugin_configs().values() for config_key, prop_schema in (schema.get("properties") or {}).items() if isinstance(prop_schema, dict) } class AddLinkForm(PluginConfigFormMixin, forms.Form): # Basic fields url = forms.CharField( label="URLs", strip=True, widget=forms.Textarea( attrs={ "data-url-regex": URL_REGEX.pattern, }, ), required=True, ) tag = forms.CharField( label="Tags", strip=True, required=False, widget=TagEditorWidget(), ) depth = forms.ChoiceField( label="Archive depth", choices=DEPTH_CHOICES, initial="0", widget=forms.RadioSelect(attrs={"class": "depth-selection"}), ) max_urls = forms.IntegerField( label="Max URLs", required=False, min_value=0, initial=0, widget=forms.NumberInput( attrs={ "min": 0, "step": 1, "placeholder": "0 = unlimited", }, ), ) max_size = forms.CharField( label="Max size", required=False, initial="0", widget=forms.TextInput( attrs={ "placeholder": "0 = unlimited, or e.g. 45mb / 1gb", }, ), ) notes = forms.CharField( label="Notes", strip=True, required=False, widget=forms.TextInput( attrs={ "placeholder": "Optional notes about this crawl", }, ), ) url_filters = forms.Field( label="URL allowlist / denylist", required=False, widget=URLFiltersWidget(source_selector='textarea[name="url"]'), ) # Plugin groups chrome_plugins = forms.MultipleChoiceField( label="Chrome-dependent plugins", required=False, widget=forms.CheckboxSelectMultiple, choices=[], # populated in __init__ ) archiving_plugins = forms.MultipleChoiceField( label="Archiving", required=False, widget=forms.CheckboxSelectMultiple, choices=[], ) parsing_plugins = forms.MultipleChoiceField( label="Parsing", required=False, widget=forms.CheckboxSelectMultiple, choices=[], ) search_plugins = forms.MultipleChoiceField( label="Search", required=False, widget=forms.CheckboxSelectMultiple, choices=[], ) binary_plugins = forms.MultipleChoiceField( label="Binary providers", required=False, widget=forms.CheckboxSelectMultiple, choices=[], ) extension_plugins = forms.MultipleChoiceField( label="Browser extensions", required=False, widget=forms.CheckboxSelectMultiple, choices=[], ) other_plugins = forms.MultipleChoiceField( label="Other plugins", required=False, widget=forms.CheckboxSelectMultiple, choices=[], ) # Advanced options schedule = forms.CharField( label="Repeat schedule", max_length=64, required=False, widget=forms.TextInput( attrs={ "placeholder": "e.g., daily, weekly, 0 */6 * * * (every 6 hours)", }, ), ) persona = forms.ModelChoiceField( label="Persona (authentication profile)", required=False, queryset=Persona.objects.none(), empty_label=None, to_field_name="name", ) index_only = forms.BooleanField( label="Index only dry run (add crawl but don't archive yet)", initial=False, required=False, ) config = forms.JSONField( label="Custom config overrides", widget=KeyValueWidget(), initial=dict, required=False, ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) default_persona = Persona.get_or_create_default() self.fields["persona"].queryset = Persona.objects.order_by("name") self.fields["persona"].initial = default_persona.name self.build_plugin_groups() def clean(self): cleaned_data = super().clean() or {} # Combine all plugin groups into single list all_selected_plugins = [] for field in [ "chrome_plugins", "archiving_plugins", "parsing_plugins", "search_plugins", "binary_plugins", "extension_plugins", "other_plugins", ]: selected = cleaned_data.get(field) if isinstance(selected, list): all_selected_plugins.extend(selected) # Store combined list for easy access cleaned_data["plugins"] = all_selected_plugins plugin_config_overrides = self.clean_plugin_config_overrides(get_config(persona=cleaned_data.get("persona"))) custom_config = cleaned_data.get("config") or {} if not isinstance(custom_config, dict): custom_config = {} cleaned_data["plugin_config"] = plugin_config_overrides cleaned_data["config"] = {**plugin_config_overrides, **custom_config} return cleaned_data def clean_url(self): value = self.cleaned_data.get("url") or "" urls = "\n".join(find_all_urls(value)) if not urls: raise forms.ValidationError("Enter at least one valid URL.") return urls def clean_url_filters(self): from archivebox.crawls.models import Crawl 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")), } def clean_max_urls(self): value = self.cleaned_data.get("max_urls") return int(value or 0) def clean_max_size(self): raw_value = str(self.cleaned_data.get("max_size") or "").strip() if not raw_value: return 0 try: value = parse_filesize_to_bytes(raw_value) except ValueError as err: raise forms.ValidationError(str(err)) if value < 0: raise forms.ValidationError("Max size must be 0 or a positive number of bytes.") return value def clean_schedule(self): schedule = (self.cleaned_data.get("schedule") or "").strip() if not schedule: return "" try: validate_schedule(schedule) except ValueError as err: raise forms.ValidationError(str(err)) return schedule class TagWidget(forms.TextInput): def format_value(self, value): if value is not None and not isinstance(value, str): value = edit_string_for_tags(value) return super().format_value(value) class TagField(forms.CharField): widget = TagWidget def clean(self, value): value = super().clean(value) try: return parse_tags(value) except ValueError: raise forms.ValidationError( "Please provide a comma-separated list of tags.", ) def has_changed(self, initial, data): # Always return False if the field is disabled since self.bound_data # always uses the initial value in this case. if self.disabled: return False try: cleaned_data = self.clean(data) except forms.ValidationError: cleaned_data = data initial_value = [] if initial is None else initial if not isinstance(initial_value, list): initial_value = list(initial_value) normalized_initial = sorted(tag.name for tag in initial_value) return normalized_initial != cleaned_data