mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-09-14 11:06:13 +05:00
352 lines
13 KiB
Python
352 lines
13 KiB
Python
__package__ = "archivebox.search"
|
|
|
|
from typing import Any
|
|
|
|
from django.db import connection
|
|
from django.db.models import Case, IntegerField, Q, QuerySet, Value, When
|
|
|
|
from archivebox.config.common import get_config
|
|
from archivebox.misc.logging import stderr
|
|
from archivebox.misc.util import enforce_types
|
|
from archivebox.search.backends import get_available_backends, get_backend, normalize_search_backend_name, search_backend_command_env
|
|
from archivebox.search.config import get_search_mode, get_search_mode_backend, get_search_mode_base
|
|
|
|
|
|
MAX_SEARCH_RANK_IDS = 500
|
|
|
|
|
|
def escape_like_query(query: str) -> str:
|
|
"""Escape a string for SQL LIKE matching (used with ESCAPE '\\')."""
|
|
return query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
def crawl_config_values_search_wave(query: str) -> Q | None:
|
|
"""Build a Snapshot Q predicate matching values inside Crawl.config."""
|
|
from archivebox.crawls.models import Crawl
|
|
|
|
pattern = f"%{escape_like_query(query).lower()}%"
|
|
if connection.vendor == "sqlite":
|
|
matching_crawls = Crawl.objects.extra(
|
|
where=[
|
|
"""
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM json_tree(config)
|
|
WHERE json_tree.atom IS NOT NULL
|
|
AND LOWER(CAST(json_tree.atom AS TEXT)) LIKE %s ESCAPE '\\'
|
|
)
|
|
""",
|
|
],
|
|
params=[pattern],
|
|
)
|
|
elif connection.vendor == "postgresql":
|
|
# Match only scalar config *values*, not keys (mirrors SQLite's
|
|
# json_tree.atom). jsonb_path_query('$.**') walks every nested node;
|
|
# keep the non-container leaves and compare their text form.
|
|
matching_crawls = Crawl.objects.extra(
|
|
where=[
|
|
"""
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_path_query(config, '$.**') AS leaf
|
|
WHERE jsonb_typeof(leaf) NOT IN ('object', 'array')
|
|
AND LOWER(leaf #>> '{}') LIKE %s ESCAPE '\\'
|
|
)
|
|
""",
|
|
],
|
|
params=[pattern],
|
|
)
|
|
else:
|
|
return None
|
|
return Q(crawl_id__in=matching_crawls.values("pk"))
|
|
|
|
|
|
def snapshot_metadata_search_waves(query: str, *, include_id_matches: bool = False) -> list[Q]:
|
|
"""Build ordered metadata predicates for Snapshot search."""
|
|
waves = []
|
|
if include_id_matches:
|
|
waves.append(Q(id__istartswith=query) | Q(id__iendswith=query))
|
|
|
|
waves.extend(
|
|
[
|
|
Q(title__icontains=query) | Q(url__icontains=query) | Q(timestamp__icontains=query),
|
|
Q(tags__name__icontains=query),
|
|
Q(notes__icontains=query) | Q(crawl__notes__icontains=query) | Q(crawl__label__icontains=query),
|
|
Q(crawl__created_by__username=query),
|
|
],
|
|
)
|
|
|
|
config_wave = crawl_config_values_search_wave(query)
|
|
if config_wave is not None:
|
|
waves.append(config_wave)
|
|
return waves
|
|
|
|
|
|
def prioritize_metadata_matches(
|
|
base_queryset: QuerySet,
|
|
metadata_queryset: QuerySet,
|
|
fulltext_queryset: QuerySet,
|
|
*,
|
|
deep_queryset: QuerySet | None = None,
|
|
ordering: list[str] | tuple[str, ...] | None = None,
|
|
) -> QuerySet:
|
|
"""Rank metadata hits before backend full-text hits."""
|
|
metadata_ids = list(metadata_queryset.values_list("pk", flat=True).distinct()[: MAX_SEARCH_RANK_IDS + 1])
|
|
metadata_id_set = set(metadata_ids)
|
|
fulltext_ids = [
|
|
pk for pk in fulltext_queryset.values_list("pk", flat=True).distinct()[: MAX_SEARCH_RANK_IDS + 1] if pk not in metadata_id_set
|
|
]
|
|
fulltext_id_set = set(fulltext_ids)
|
|
deep_ids = []
|
|
if deep_queryset is not None:
|
|
deep_ids = [
|
|
pk
|
|
for pk in deep_queryset.values_list("pk", flat=True).distinct()[: MAX_SEARCH_RANK_IDS + 1]
|
|
if pk not in metadata_id_set and pk not in fulltext_id_set
|
|
]
|
|
|
|
if not metadata_ids and not fulltext_ids and not deep_ids:
|
|
return base_queryset.none()
|
|
|
|
if any(len(ids) > MAX_SEARCH_RANK_IDS for ids in (metadata_ids, fulltext_ids, deep_ids)):
|
|
search_filter = Q()
|
|
if metadata_ids:
|
|
search_filter |= Q(pk__in=metadata_queryset.values("pk").distinct())
|
|
if fulltext_ids:
|
|
search_filter |= Q(pk__in=fulltext_queryset.values("pk").distinct())
|
|
if deep_queryset is not None and deep_ids:
|
|
search_filter |= Q(pk__in=deep_queryset.values("pk").distinct())
|
|
qs = base_queryset.filter(search_filter)
|
|
if ordering is not None:
|
|
qs = qs.order_by(*ordering)
|
|
return qs.distinct()
|
|
|
|
qs = base_queryset.filter(pk__in=[*metadata_ids, *fulltext_ids, *deep_ids]).annotate(
|
|
search_rank=Case(
|
|
When(pk__in=metadata_ids, then=Value(0)),
|
|
When(pk__in=fulltext_ids, then=Value(1)),
|
|
default=Value(2),
|
|
output_field=IntegerField(),
|
|
),
|
|
)
|
|
|
|
if ordering is not None:
|
|
qs = qs.order_by("search_rank", *ordering)
|
|
|
|
return qs.distinct()
|
|
|
|
|
|
def apply_snapshot_search(
|
|
base_queryset: QuerySet,
|
|
query: str,
|
|
*,
|
|
search_mode: str | None = None,
|
|
config: dict[str, Any] | None = None,
|
|
ordering: list[str] | tuple[str, ...] | None = None,
|
|
max_results: int | None = None,
|
|
skip_backend_when_metadata_satisfies_limit: bool = False,
|
|
include_metadata_for_forced_backend: bool = False,
|
|
include_id_matches: bool = False,
|
|
) -> QuerySet:
|
|
"""Apply shared CLI/API/public/admin Snapshot search semantics."""
|
|
query = (query or "").strip()
|
|
if not query:
|
|
return base_queryset
|
|
|
|
config = config or get_config()
|
|
search_mode = get_search_mode(search_mode, config=config)
|
|
search_mode_base = get_search_mode_base(search_mode, config=config)
|
|
search_mode_backend = get_search_mode_backend(search_mode, config=config)
|
|
metadata_filter = Q()
|
|
for wave in snapshot_metadata_search_waves(query, include_id_matches=include_id_matches):
|
|
metadata_filter |= wave
|
|
metadata_queryset = base_queryset.filter(metadata_filter)
|
|
|
|
if search_mode_base == "meta":
|
|
return metadata_queryset.distinct()
|
|
|
|
if skip_backend_when_metadata_satisfies_limit and max_results:
|
|
metadata_ids = list(metadata_queryset.values_list("pk", flat=True).distinct()[:max_results])
|
|
if len(metadata_ids) >= max_results:
|
|
return metadata_queryset.distinct()
|
|
|
|
if search_mode_base == "deep":
|
|
fulltext_search_mode = f"contents:{search_mode_backend}" if search_mode_backend else "contents"
|
|
fulltext_queryset = query_search_index(query, search_mode=fulltext_search_mode, config=config, max_results=max_results)
|
|
deep_queryset = query_search_index(query, search_mode=search_mode, config=config, max_results=max_results)
|
|
return prioritize_metadata_matches(
|
|
base_queryset,
|
|
metadata_queryset,
|
|
fulltext_queryset,
|
|
deep_queryset=deep_queryset,
|
|
ordering=ordering,
|
|
)
|
|
|
|
backend_queryset = query_search_index(query, search_mode=search_mode, config=config, max_results=max_results)
|
|
if search_mode_backend and not include_metadata_for_forced_backend:
|
|
return base_queryset.filter(pk__in=backend_queryset.values("pk")).distinct()
|
|
|
|
return prioritize_metadata_matches(
|
|
base_queryset,
|
|
metadata_queryset,
|
|
backend_queryset,
|
|
ordering=ordering,
|
|
)
|
|
|
|
|
|
@enforce_types
|
|
def query_search_index(
|
|
query: str,
|
|
search_mode: str | None = None,
|
|
config: dict[str, Any] | None = None,
|
|
max_results: int | None = None,
|
|
**config_kwargs: Any,
|
|
) -> QuerySet:
|
|
"""Return a Snapshot queryset from backend search IDs."""
|
|
from archivebox.core.models import Snapshot
|
|
|
|
config = config or get_config(**config_kwargs)
|
|
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)
|
|
if search_mode_base == "meta":
|
|
return Snapshot.objects.none()
|
|
|
|
snapshot_pks = list(iter_query_search_ids(query, search_mode=search_mode, config=config, max_results=max_results))
|
|
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,
|
|
max_results: int | None = None,
|
|
**config_kwargs: Any,
|
|
):
|
|
"""Yield snapshot IDs from configured search backend modules."""
|
|
config = config or get_config(**config_kwargs)
|
|
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()
|
|
configured_backend = normalize_search_backend_name(config.SEARCH_BACKEND_ENGINE)
|
|
fallback_enabled = search_mode_base == "deep" and (not forced_backend or forced_backend == configured_backend == "sonic")
|
|
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,
|
|
*(name for name in backends if name not in {forced_backend, "ripgrep"}),
|
|
*(["ripgrep"] if "ripgrep" in backends and forced_backend != "ripgrep" else []),
|
|
]
|
|
if fallback_enabled
|
|
else [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 = [configured_backend]
|
|
elif "ripgrep" in backends:
|
|
backend_names = ["ripgrep"]
|
|
else:
|
|
get_backend()
|
|
return
|
|
|
|
errors: list[Exception] = []
|
|
successful_backends = 0
|
|
seen: set[str] = set()
|
|
try:
|
|
for backend_name in backend_names:
|
|
backend = backends[backend_name]
|
|
try:
|
|
if backend_name == "sonic":
|
|
import sys
|
|
from contextlib import redirect_stdout
|
|
|
|
from archivebox.core.takeover_util import ensure_daemon_stack
|
|
|
|
# Search commands can produce structured stdout such as
|
|
# --csv/--json. Keep daemon diagnostics on stderr.
|
|
with redirect_stdout(sys.stderr):
|
|
ensure_daemon_stack(reason="search query")
|
|
from abx_dl.execution import iter_plugin_command
|
|
from archivebox.config.constants import CONSTANTS
|
|
from archivebox.plugins.discovery import get_plugin_catalog
|
|
|
|
command = get_plugin_catalog().command(backend.name, "search")
|
|
if command is None:
|
|
raise RuntimeError(f'Plugin "{backend.name}" does not expose a search command')
|
|
ids = iter_plugin_command(
|
|
command,
|
|
arguments={"query": query, "search_mode": search_mode_base},
|
|
env=search_backend_command_env(config=config),
|
|
cwd=CONSTANTS.DATA_DIR,
|
|
timeout=max(1, int(config.get("TIMEOUT", 60))) * 4,
|
|
)
|
|
for snapshot_id in ids:
|
|
if snapshot_id in seen:
|
|
continue
|
|
seen.add(snapshot_id)
|
|
yield snapshot_id
|
|
if max_results and len(seen) >= max_results:
|
|
return
|
|
successful_backends += 1
|
|
except Exception as err:
|
|
errors.append(err)
|
|
if not fallback_enabled:
|
|
raise
|
|
except Exception as err:
|
|
stderr()
|
|
stderr(
|
|
f"[X] The search backend threw an exception={err}:",
|
|
color="red",
|
|
)
|
|
raise
|
|
else:
|
|
if not successful_backends and errors and search_mode_base == "deep":
|
|
raise errors[0]
|
|
|
|
|
|
@enforce_types
|
|
def flush_search_index(snapshots: QuerySet, config: dict[str, Any] | None = None, **config_kwargs: Any) -> None:
|
|
"""Remove Snapshot IDs from the configured search backend index."""
|
|
config = config or get_config(**config_kwargs)
|
|
if not snapshots:
|
|
return
|
|
|
|
backend = get_backend(config=config)
|
|
snapshot_pks = [str(pk) for pk in snapshots.values_list("pk", flat=True)]
|
|
|
|
try:
|
|
from abx_dl.execution import iter_plugin_command
|
|
from archivebox.config.constants import CONSTANTS
|
|
from archivebox.plugins.discovery import get_plugin_catalog
|
|
|
|
command = get_plugin_catalog().command(backend.name, "flush")
|
|
if command is None:
|
|
raise RuntimeError(f'Plugin "{backend.name}" does not expose a flush command')
|
|
list(
|
|
iter_plugin_command(
|
|
command,
|
|
stdin=snapshot_pks,
|
|
env=search_backend_command_env(config=config),
|
|
cwd=CONSTANTS.DATA_DIR,
|
|
timeout=max(1, int(config.get("TIMEOUT", 60))) * 4,
|
|
),
|
|
)
|
|
except Exception as err:
|
|
stderr()
|
|
stderr(
|
|
f"[X] The search backend threw an exception={err}:",
|
|
color="red",
|
|
)
|