ArchiveBox/archivebox/core/forms.py
2026-05-22 20:12:48 -07:00

680 lines
23 KiB
Python

__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(
'<span class="plugin-choice-icon">{}</span><span class="plugin-choice-name">{}</span><a class="plugin-choice-description" href="https://archivebox.github.io/abx-plugins/#{}" target="_blank" rel="noopener noreferrer">{}</a>',
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