diff --git a/archivebox/core/forms.py b/archivebox/core/forms.py
index 6731262d..645aec3a 100644
--- a/archivebox/core/forms.py
+++ b/archivebox/core/forms.py
@@ -1,5 +1,9 @@
__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
@@ -20,6 +24,84 @@ DEPTH_CHOICES = (
("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."""
@@ -49,7 +131,294 @@ def get_choice_field(form: forms.Form, name: str) -> forms.ChoiceField:
return field
-class AddLinkForm(forms.Form):
+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",
@@ -149,6 +518,12 @@ class AddLinkForm(forms.Form):
widget=forms.CheckboxSelectMultiple,
choices=[],
)
+ other_plugins = forms.MultipleChoiceField(
+ label="Other plugins",
+ required=False,
+ widget=forms.CheckboxSelectMultiple,
+ choices=[],
+ )
# Advanced options
schedule = forms.CharField(
@@ -187,82 +562,7 @@ class AddLinkForm(forms.Form):
self.fields["persona"].queryset = Persona.objects.order_by("name")
self.fields["persona"].initial = default_persona.name
- # Get all plugins
- all_plugins = get_plugins()
- plugin_configs = discover_plugin_configs()
-
- # Define plugin groups
- chrome_dependent = {
- "accessibility",
- "chrome",
- "consolelog",
- "dom",
- "headers",
- "parse_dom_outlinks",
- "pdf",
- "redirects",
- "responses",
- "screenshot",
- "seo",
- "singlefile",
- "ssl",
- "staticfile",
- "title",
- }
- archiving = {
- "archivedotorg",
- "defuddle",
- "favicon",
- "forumdl",
- "gallerydl",
- "git",
- "htmltotext",
- "mercury",
- "papersdl",
- "readability",
- "trafilatura",
- "wget",
- "ytdlp",
- }
- parsing = {
- "parse_html_urls",
- "parse_jsonl_urls",
- "parse_netscape_urls",
- "parse_rss_urls",
- "parse_txt_urls",
- }
- search = {
- "search_backend_ripgrep",
- "search_backend_sonic",
- "search_backend_sqlite",
- }
- binary = {"apt", "brew", "custom", "env", "npm", "pip"}
- extensions = {"twocaptcha", "istilldontcareaboutcookies", "ublock"}
-
- # Populate plugin field choices
- get_choice_field(self, "chrome_plugins").choices = [
- (p, get_plugin_choice_label(p, plugin_configs)) for p in sorted(all_plugins) if p in chrome_dependent
- ]
- get_choice_field(self, "archiving_plugins").choices = [
- (p, get_plugin_choice_label(p, plugin_configs)) for p in sorted(all_plugins) if p in archiving
- ]
- get_choice_field(self, "parsing_plugins").choices = [
- (p, get_plugin_choice_label(p, plugin_configs)) for p in sorted(all_plugins) if p in parsing
- ]
- get_choice_field(self, "search_plugins").choices = [
- (p, get_plugin_choice_label(p, plugin_configs)) for p in sorted(all_plugins) if p in search
- ]
- get_choice_field(self, "binary_plugins").choices = [
- (p, get_plugin_choice_label(p, plugin_configs)) for p in sorted(all_plugins) if p in binary
- ]
- get_choice_field(self, "extension_plugins").choices = [
- (p, get_plugin_choice_label(p, plugin_configs)) for p in sorted(all_plugins) if p in extensions
- ]
-
- 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]
+ self.build_plugin_groups()
def clean(self):
cleaned_data = super().clean() or {}
@@ -276,6 +576,7 @@ class AddLinkForm(forms.Form):
"search_plugins",
"binary_plugins",
"extension_plugins",
+ "other_plugins",
]:
selected = cleaned_data.get(field)
if isinstance(selected, list):
@@ -284,6 +585,13 @@ class AddLinkForm(forms.Form):
# 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):
diff --git a/archivebox/core/views.py b/archivebox/core/views.py
index 1bb2d323..4743d20e 100644
--- a/archivebox/core/views.py
+++ b/archivebox/core/views.py
@@ -1008,8 +1008,26 @@ class AddView(UserPassesTestMixin, FormView):
return custom_config
def get_context_data(self, **kwargs):
+ from archivebox.personas.models import Persona
+
required_search_plugin = f"search_backend_{get_config().SEARCH_BACKEND_ENGINE}".strip()
plugin_configs = discover_plugin_configs()
+ 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")
+ }
+ base_config = get_config()
+ 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}
+ persona_config_map[persona.name] = {
+ "config": raw_config,
+ "effective_config": {
+ str(key): value for key, value in {**base_config, **raw_config}.items() if str(key) not in sensitive_keys
+ },
+ }
plugin_dependency_map = {
plugin_name: [
str(required_plugin).strip() for required_plugin in (schema.get("required_plugins") or []) if str(required_plugin).strip()
@@ -1026,6 +1044,7 @@ class AddView(UserPassesTestMixin, FormView):
"FOOTER_INFO": get_config().FOOTER_INFO,
"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),
"stdout": "",
}
@@ -1044,6 +1063,9 @@ class AddView(UserPassesTestMixin, FormView):
index_only = form.cleaned_data.get("index_only", 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 = {}
custom_config = self._get_custom_config_overrides(form)
persona_name = persona.name if persona else "Default"
if persona:
@@ -1078,6 +1100,7 @@ class AddView(UserPassesTestMixin, FormView):
}
# Merge custom config overrides
+ config.update(plugin_config)
config.update(custom_config)
config["DEFAULT_PERSONA"] = persona_name
if url_filters.get("allowlist"):
diff --git a/archivebox/personas/forms.py b/archivebox/personas/forms.py
index 894f7af9..a801f356 100644
--- a/archivebox/personas/forms.py
+++ b/archivebox/personas/forms.py
@@ -5,6 +5,8 @@ from typing import Any
from django import forms
from django.utils.safestring import mark_safe
+from archivebox.config.common import get_config
+from archivebox.core.forms import PluginConfigFormMixin
from archivebox.personas.importers import (
PersonaImportResult,
PersonaImportSource,
@@ -22,7 +24,7 @@ def _mode_label(title: str, description: str) -> str:
)
-class PersonaAdminForm(forms.ModelForm):
+class PersonaAdminForm(PluginConfigFormMixin, forms.ModelForm):
import_mode = forms.ChoiceField(
required=False,
initial="none",
@@ -112,6 +114,9 @@ class PersonaAdminForm(forms.ModelForm):
"Use the custom path/CDP option if the browser data lives elsewhere."
)
+ persona_config = self.instance.config if self.instance and self.instance.pk and isinstance(self.instance.config, dict) else {}
+ self.build_plugin_groups({**get_config(), **persona_config})
+
def clean_name(self) -> str:
name = str(self.cleaned_data.get("name") or "").strip()
is_valid, error_message = validate_persona_name(name)
@@ -124,6 +129,16 @@ class PersonaAdminForm(forms.ModelForm):
self._resolved_import_source = None
import_mode = str(cleaned_data.get("import_mode") or "none").strip() or "none"
+ manual_config = cleaned_data.get("config") or {}
+ if not isinstance(manual_config, dict):
+ manual_config = {}
+ plugin_config_overrides = self.clean_plugin_config_overrides(get_config())
+ cleaned_data["plugin_config"] = plugin_config_overrides
+ cleaned_data["config"] = {
+ **{key: value for key, value in manual_config.items() if key not in self.plugin_config_keys()},
+ **plugin_config_overrides,
+ }
+
if import_mode == "none":
return cleaned_data
diff --git a/archivebox/templates/admin/personas/persona/change_form.html b/archivebox/templates/admin/personas/persona/change_form.html
index 1bdde87a..b3bcb072 100644
--- a/archivebox/templates/admin/personas/persona/change_form.html
+++ b/archivebox/templates/admin/personas/persona/change_form.html
@@ -1,9 +1,11 @@
{% extends "admin/change_form.html" %}
+{% load static %}
{% block bodyclass %}{{ block.super }} app-personas model-persona{% endblock %}
{% block extrastyle %}
{{ block.super }}
+
{% endblock %}
@@ -247,3 +281,12 @@ document.addEventListener('DOMContentLoaded', function () {
{{ block.super }}
{% endblock %}
+
+{% block after_field_sets %}
+{{ block.super }}
+ These typed controls update the same Persona config JSON shown above. Shared config keys stay synced across plugin sections.Plugin Config
+
{{ field.current }} · Default: {{ field.default }}