feat(msp): zweiter Lieferanten-Handler — ADN Hornetsecurity-CSV

Bisher konnte der MSP-Importer nur die monatliche Microsoft-CSV von ADN
verarbeiten. ADN liefert dieselbe Dateifamilie auch für Hornetsecurity-
Abrechnungen, bisher manuell oder über das alte adnconnect-Modul
verbucht (493 historische Sales Invoices im System).

Neuer Handler "ADN Hornet CSV (Hornetsecurity)" — zweiter Eintrag in
der Auto-Detection der Dropzone, gleiches Wizard-Erlebnis. Hornet-
spezifische Punkte:

- Subscription-Identifikation via 'Vertrag' (statt SUBSCRIPTION_ID_EXTERNAL)
- Mengen werden anteilig auf den Abrechnungsmonat skaliert
  (Wartungsbeginn/-ende ergeben einen Tagesanteil — voller Monat → 1.0)
- Customer-Auflösung über das Custom-Field 'hornet_domain' statt
  direktem Customer-Name-Lookup

Generische Erweiterungen, von beiden Profilen nutzbar:

- Supplier Import Profile.customer_lookup_field: optionales Custom-Field
  für Customer-Resolution per Domain/Identifier
- resolve_customer() akzeptiert lookup_field-Argument
- Microsoft-Sniff bekommt Discriminator-Bonus auf BILLINGPLAN/MSERP/
  VERTRAGSDAUER, damit die beiden Handler bei strukturell ähnlichen
  CSVs nicht beide 100% scoren

Sniff-Validierung:
  Hornet-CSV (Filename "hornet"):    Hornet 1.00 / MS 0.70  → Hornet
  Hornet-CSV (Filename "Rechnungen"): Hornet 0.95 / MS 0.70  → Hornet
  Microsoft-CSV (real):              Hornet 0.38 / MS 1.00  → Microsoft

End-to-end gegen synthetische Hornet-CSV verifiziert: SI mit Title
"Abrechnung Hornetsecurity 04.2026", Customer korrekt über
hornet_domain aufgelöst, anteilige Menge (13,33 statt 25 für 16/30
Tage) korrekt berechnet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Malinowski
2026-04-15 09:32:19 +02:00
co-authored by Claude Opus 4.6
parent db6b4e07fa
commit 6d3619992c
8 changed files with 515 additions and 11 deletions
+28
View File
@@ -25,5 +25,33 @@
"title_template": "{prefix} {period}",
"title_template_credit_note": "{prefix} {period}",
"vendor": "Microsoft"
},
{
"customer_lookup_field": "hornet_domain",
"default_company": "itsdave GmbH",
"default_cost_center": null,
"default_output_mode": "Sales Invoice",
"default_payment_terms_template": null,
"default_taxes_and_charges": "MwSt. 19%",
"default_tc_name": "itsdave Standard AGBs",
"default_warehouse": "SST - IG",
"docstatus": 0,
"doctype": "Supplier Import Profile",
"enabled": 1,
"fallback_customer": null,
"file_type": "CSV",
"intro_text_credit_note": null,
"intro_text_invoice": null,
"list_price_discount_pct": 0.0,
"modified": "2026-04-15 09:00:00.000000",
"name": "ADN Hornet CSV v1",
"parser_key": "adn_hornet_csv_v1",
"profile_name": "ADN Hornet CSV v1",
"supplier": "SUPP-00887",
"title_prefix": "Abrechnung Hornetsecurity ",
"title_prefix_credit_note": "Gutschrift Hornetsecurity ",
"title_template": "{prefix} {period}",
"title_template_credit_note": "{prefix} {period}",
"vendor": "Hornetsecurity"
}
]
+188
View File
@@ -0,0 +1,188 @@
"""Parser für die ADN-Hornetsecurity-Abrechnungs-CSVs.
Das Format ist nahezu identisch zum monatlichen Microsoft-CSV (gleiche
Spaltenfamilie für Kopfdaten / Endkunde / Position), unterscheidet sich
inhaltlich aber in vier Punkten:
* Die Subscription-Identifikation läuft über ``Vertrag`` (statt
``SUBSCRIPTION_ID_EXTERNAL``).
* Mengen werden anteilig auf den Abrechnungsmonat heruntergerechnet
(``Wartungsbeginn``/``Wartungsende`` ergeben einen Faktor).
* Hornet-CSVs enthalten **nicht** die Microsoft-spezifischen Spalten
``BILLINGPLAN`` / ``MSERP`` / ``VERTRAGSDAUER``.
* Die Endkunden-Referenz ist eine Mail-Domain (``Customer.hornet_domain``)
und keine ERP-Customer-ID — die Auflösung übernimmt der Builder über
das Profilfeld ``customer_lookup_field``.
"""
from __future__ import annotations
import calendar
import csv
from datetime import date
from typing import Iterable
from msp.importers.base import (
BaseSupplierParser,
CanonicalRow,
ParseResult,
parse_german_date,
parse_german_decimal,
)
from msp.importers.registry import register
# Die Spaltenliste basiert auf der Validierung im legacy adnconnect.adn_hornet_import
# Modul. ADN exportiert seit jeher in mixed case; wir vergleichen aber
# case-insensitiv und tolerieren Drift.
EXPECTED_HEADER = [
"RECHNUNG", "DATUM", "KUNDENNR", "DEBITORKONTO", "SACHBEARBEITER",
"LIEFERSCHEIN", "LIEFERSCHEINDATUM", "USTID",
"RE_FIRMA", "RE_ADRESSE", "RE_PLZ", "RE_ORT", "RE_LAND",
"LI_FIRMA", "LI_STRASSE", "LI_PLZ", "LI_ORT", "LI_LAND",
"HA_FIRMA", "HA_STRASSE", "HA_PLZ", "HA_ORT", "HA_LAND",
"WARENWERT", "MWST", "GESAMTBETRAG", "NETTOZAHLBARBIS",
"LIEFERBEDINGUNGEN", "ZAHLUNGSBEDINGUNG",
"ENDKUNDE", "POSITION", "ARTIKEL", "HERSTELLERNUMMER", "ARTIKELBEZ",
"MENGE", "PREISME", "LISTPREIS", "RABATT", "EINZELPREIS", "POSITIONSPREIS",
"WARTUNGSBEGINN", "WARTUNGSENDE", "VERTRAG",
"MARKETPLACE_REF", "ORDER_REFERENCE", "ENDKUNDE_REFERENCE",
"SUBSCRIPTION_ID_EXTERNAL", "SUBSCRIPTION_START_DATE", "BUCHUNGSTYP",
"ORDERDATUM", "RECHNUNGSART",
]
def _strip_bom(value: str) -> str:
return value.lstrip("\ufeff") if value else value
def _normalize_header(header: Iterable[str]) -> list[str]:
return [_strip_bom(h).strip() for h in header]
@register
class ADNHornetCSVParser(BaseSupplierParser):
parser_key = "adn_hornet_csv_v1"
description = "ADN Distribution — Hornetsecurity-Abrechnungs-CSV"
def parse(self, file_path: str) -> ParseResult:
result = ParseResult()
with open(file_path, "r", encoding="utf-8", newline="") as fh:
reader = csv.reader(fh, delimiter=";", quotechar='"')
try:
raw_header = next(reader)
except StopIteration:
result.warnings.append("Datei ist leer.")
return result
header = _normalize_header(raw_header)
result.format_drift = not self._header_matches(header)
for source_row, row in enumerate(reader, start=2):
if not row or not any(cell.strip() for cell in row):
continue
data = self._row_to_dict(header, row)
# Header-Wiederholung pro Rechnung überspringen (Position 1 ohne Artikel)
if not (data.get("ARTIKEL") or "").strip() and not (data.get("HERSTELLERNUMMER") or "").strip():
continue
canonical = self._to_canonical(data, source_row)
if canonical is not None:
result.rows.append(canonical)
if result.rows:
first_raw = result.rows[0].raw
result.invoice_date = parse_german_date(first_raw.get("DATUM"))
return result
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _header_matches(self, actual: list[str]) -> bool:
actual_upper = {h.upper() for h in actual if h}
expected_upper = {h.upper() for h in EXPECTED_HEADER if h}
return expected_upper.issubset(actual_upper)
def _row_to_dict(self, header: list[str], row: list[str]) -> dict[str, str]:
# Robust gegen Zeilen mit abweichender Spaltenanzahl. Wir ziehen die
# Schlüssel zudem auf upper, damit Mixed-Case-Header (Endkunde vs.
# ENDKUNDE) keine Lookups vereiteln.
out: dict[str, str] = {}
for i in range(min(len(header), len(row))):
out[header[i].upper()] = row[i]
return out
def _to_canonical(self, d: dict[str, str], source_row: int) -> CanonicalRow | None:
raw_ref = (d.get("ENDKUNDE_REFERENCE") or "").strip() or None
raw_qty = parse_german_decimal(d.get("MENGE")) or 0.0
list_price = parse_german_decimal(d.get("LISTPREIS"))
unit_price = parse_german_decimal(d.get("EINZELPREIS"))
amount = parse_german_decimal(d.get("POSITIONSPREIS"))
discount = parse_german_decimal(d.get("RABATT"))
period_start = parse_german_date(d.get("WARTUNGSBEGINN"))
period_end = parse_german_date(d.get("WARTUNGSENDE"))
# Anteilige Menge: bei Hornet wird zwischen Wartungsbeginn und -ende
# tageweise abgerechnet (z. B. neue Subscription mitten im Monat). Wir
# multiplizieren daher mit (Wartungstage / Tage des Startmonats), wie
# in adnconnect.adn_hornet_import._do_import.
qty = self._apportion_quantity(raw_qty, period_start, period_end)
rechnungsart = (d.get("RECHNUNGSART") or "").strip()
document_type = "Gutschrift" if "gutschrift" in rechnungsart.lower() else "Rechnung"
if amount is not None and amount < 0:
document_type = "Gutschrift"
# Vertrag fungiert als Subscription-Identifier — eindeutig pro Hornet-
# Vertrag, damit der Idempotenz-Check (Event existiert für period?)
# greift.
subscription_id = (d.get("VERTRAG") or "").strip() or None
return CanonicalRow(
source_row=source_row,
supplier_invoice_no=(d.get("RECHNUNG") or "").strip() or None,
customer_external_ref=raw_ref,
customer_display_name=(d.get("ENDKUNDE") or "").strip() or None,
vendor_product_id=(d.get("HERSTELLERNUMMER") or "").strip() or None,
supplier_article_no=(d.get("ARTIKEL") or "").strip() or None,
description=(d.get("ARTIKELBEZ") or "").strip() or None,
qty=qty,
list_price=list_price,
discount_pct=discount,
unit_price=unit_price,
amount=amount,
period_start=period_start,
period_end=period_end,
subscription_external_id=subscription_id,
subscription_start=parse_german_date(d.get("SUBSCRIPTION_START_DATE")),
document_type=document_type,
marketplace_ref=(d.get("MARKETPLACE_REF") or "").strip() or None,
order_ref=(d.get("ORDER_REFERENCE") or "").strip() or None,
raw=d,
)
@staticmethod
def _apportion_quantity(qty: float, start: date | None, end: date | None) -> float:
"""Skaliert die Menge anteilig auf den Abrechnungsmonat.
Ist die Wartung gleich lang wie der Monat (üblicher Fall), bleibt die
Menge unverändert. Bei mitten im Monat startenden Subscriptions
entspricht die Menge dem Tagesanteil.
"""
if not start or not end or qty == 0:
return qty
days_in_month = calendar.monthrange(start.year, start.month)[1]
warranty_days = (end - start).days + 1
if days_in_month <= 0 or warranty_days <= 0:
return qty
factor = warranty_days / days_in_month
# Volle Monate runden auf den Originalwert (Schutz gegen 31/30-Rauschen)
if 0.97 <= factor <= 1.03:
return qty
return round(qty * factor, 4)
+1
View File
@@ -60,6 +60,7 @@ class DocumentBuilder:
first = rows[0]
customer, is_fallback = resolve_customer(
first.customer_external_ref, self.profile.fallback_customer,
lookup_field=getattr(self.profile, "customer_lookup_field", None) or None,
)
if not customer:
for r in rows:
@@ -0,0 +1,256 @@
"""FileHandler für ADN-Hornet-Abrechnungen.
Wrap-Schicht über :class:`msp.importers.adn_hornet_csv.ADNHornetCSVParser`.
Nutzt denselben Run-Orchestrator wie der Microsoft-Handler — Hornet-spezifisch
sind nur Sniff (Spalten-Heuristik + Filename-Hint) und der Parser.
"""
from __future__ import annotations
import os
import shutil
from typing import Iterable
import frappe
from msp.importers.adn_hornet_csv import EXPECTED_HEADER, ADNHornetCSVParser
from msp.importers.handlers.base import (
BaseFileHandler,
ExecuteResult,
PreviewResult,
ProgressCallback,
)
from msp.importers.handlers.registry import register
from msp.importers.zip_unpack import is_zip_path, unpack_single_csv
@register
class ADNHornetCSVHandler(BaseFileHandler):
handler_key = "adn_hornet_csv_v1"
display_name = "ADN Hornet CSV (Hornetsecurity)"
accepted_extensions = [".csv", ".zip"]
# ------------------------------------------------------------------
# Sniff
# ------------------------------------------------------------------
# Pflicht-Spalten zur Hornet-Erkennung; gleiche Familie wie Microsoft,
# aber {VERTRAG, WARTUNGSBEGINN, WARTUNGSENDE} sind bei Hornet stets
# inhaltlich gefüllt.
_SIGNATURE_COLUMNS = {
"RECHNUNG", "DATUM", "ENDKUNDE", "HERSTELLERNUMMER",
"ENDKUNDE_REFERENCE", "VERTRAG",
"WARTUNGSBEGINN", "WARTUNGSENDE",
}
# Spalten, die exklusiv im Microsoft-Format vorkommen — sind sie da,
# spricht das gegen Hornet.
_NEGATIVE_COLUMNS = {"BILLINGPLAN", "VERTRAGSDAUER", "MSERP", "ADDITIONALID"}
@classmethod
def sniff(cls, sample_bytes: bytes, filename: str) -> float:
name = (filename or "").lower()
if name.endswith(".zip"):
first_line = cls._peek_first_line_in_zip(sample_bytes)
if first_line is None:
if "hornet" in name:
return 0.65
return 0.0
score = cls._score_first_line(first_line)
if "hornet" in name:
score = min(1.0, score + 0.1)
return score
if not name.endswith(".csv"):
return 0.0
try:
head = sample_bytes.decode("utf-8", errors="replace")
except Exception:
return 0.0
first_line = head.split("\n", 1)[0]
score = cls._score_first_line(first_line)
if "hornet" in name:
score = min(1.0, score + 0.1)
return score
@classmethod
def _score_first_line(cls, first_line: str) -> float:
first_line = (first_line or "").lstrip("\ufeff").strip()
if not first_line:
return 0.0
cols = {c.strip().upper() for c in first_line.split(";")}
matching = cls._SIGNATURE_COLUMNS & cols
if not matching:
return 0.0
ratio = len(matching) / len(cls._SIGNATURE_COLUMNS)
base = 0.4 + 0.55 * ratio # max ≈ 0.95
# Hard penalty wenn MS-Discriminatoren da sind: dann ist's Microsoft.
if cls._NEGATIVE_COLUMNS & cols:
base = base * 0.4
return min(1.0, base)
@classmethod
def _peek_first_line_in_zip(cls, sample_bytes: bytes) -> str | None:
import io
import zipfile
if not sample_bytes:
return None
try:
with zipfile.ZipFile(io.BytesIO(sample_bytes)) as z:
csvs = [m for m in z.namelist()
if m.lower().endswith(".csv") and not m.endswith("/")]
if not csvs:
return None
with z.open(csvs[0]) as fh:
head = fh.read(2048).decode("utf-8", errors="replace")
return head.split("\n", 1)[0]
except (zipfile.BadZipFile, EOFError, KeyError):
return None
# ------------------------------------------------------------------
# Preview
# ------------------------------------------------------------------
@classmethod
def preview(cls, file_path: str) -> PreviewResult:
csv_path, temp_dir = cls._resolve_csv_path(file_path)
try:
parser = ADNHornetCSVParser()
result = parser.parse(csv_path)
customers: set[str] = set()
products: set[str] = set()
period_starts: list = []
period_ends: list = []
credit_lines = 0
for r in result.rows:
if r.customer_external_ref:
customers.add(r.customer_external_ref)
if r.vendor_product_id:
products.add(r.vendor_product_id)
if r.period_start:
period_starts.append(r.period_start)
if r.period_end:
period_ends.append(r.period_end)
if (r.qty or 0) < 0 or (r.amount or 0) < 0 or r.document_type == "Gutschrift":
credit_lines += 1
period_label = cls._format_period_label(period_starts, period_ends)
suggested_profile = frappe.db.get_value(
"Supplier Import Profile",
{"parser_key": cls.handler_key, "enabled": 1},
"name",
)
return PreviewResult(
supplier_profile_suggested=suggested_profile,
line_count=len(result.rows),
customer_count=len(customers),
product_count=len(products),
period_label=period_label,
format_drift=result.format_drift,
credit_note_lines=credit_lines,
warnings=list(result.warnings),
extra={"invoice_date": str(result.invoice_date) if result.invoice_date else None},
)
finally:
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
# ------------------------------------------------------------------
# Execute
# ------------------------------------------------------------------
@classmethod
def execute(cls, file_path: str, *, config: dict,
progress: ProgressCallback | None = None) -> ExecuteResult:
csv_path, temp_dir = cls._resolve_csv_path(file_path)
created_run_name: str | None = None
try:
site_file_url = cls._ensure_site_file_url(csv_path)
profile_name = config.get("profile") or cls._default_profile_name()
if not profile_name:
return ExecuteResult(status="Failed", message="Kein Profil gefunden/konfiguriert.")
run_doc = frappe.get_doc({
"doctype": "Supplier Import Run",
"profile": profile_name,
"file": site_file_url,
"output_mode": config.get("output_mode") or None,
"acknowledge_format_drift": 1 if config.get("acknowledge_format_drift") else 0,
})
run_doc.insert(ignore_permissions=True)
created_run_name = run_doc.name
frappe.db.commit()
if progress:
progress({"phase": "starting", "run": created_run_name})
from msp.importers.run_orchestrator import execute_run
summary = execute_run(run_doc)
return ExecuteResult(
status=summary.get("status", "Failed"),
run_name=created_run_name,
document_count=summary.get("document_count", 0),
line_count=summary.get("line_count", 0),
error_count=summary.get("error_count", 0),
skipped_count=summary.get("skipped_count", 0),
)
finally:
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
# ------------------------------------------------------------------
# Helpers — kongruent zur Microsoft-Variante, damit sich beide Handler
# operativ identisch anfühlen.
# ------------------------------------------------------------------
@classmethod
def _resolve_csv_path(cls, file_path: str) -> tuple[str, str | None]:
if is_zip_path(file_path):
temp_dir, csv_path = unpack_single_csv(file_path)
return csv_path, temp_dir
return file_path, None
@staticmethod
def _format_period_label(starts: Iterable, ends: Iterable) -> str | None:
starts = [d for d in starts if d]
ends = [d for d in ends if d]
if not (starts and ends):
return None
min_start = min(starts)
max_end = max(ends)
if min_start.strftime("%Y-%m") == max_end.strftime("%Y-%m"):
return min_start.strftime("%m.%Y")
return f"{min_start.strftime('%m.%Y')} – {max_end.strftime('%m.%Y')}"
@staticmethod
def _default_profile_name() -> str | None:
return frappe.db.get_value(
"Supplier Import Profile",
{"parser_key": "adn_hornet_csv_v1", "enabled": 1},
"name",
)
@staticmethod
def _ensure_site_file_url(csv_path: str) -> str:
import shutil as _shutil
site_path = os.path.abspath(frappe.get_site_path())
private_files = os.path.join(site_path, "private", "files")
csv_abs = os.path.abspath(csv_path)
if csv_abs.startswith(private_files + os.sep):
relative = csv_abs[len(private_files) + 1:]
return f"/private/files/{relative}"
basename = os.path.basename(csv_abs)
target = os.path.join(private_files, basename)
if not os.path.exists(target):
_shutil.copyfile(csv_abs, target)
return f"/private/files/{basename}"
@@ -39,6 +39,9 @@ class ADNMonthlyCSVHandler(BaseFileHandler):
"RECHNUNG", "DATUM", "ENDKUNDE", "HERSTELLERNUMMER", "POSITIONSPREIS",
"ENDKUNDE_REFERENCE", "SUBSCRIPTION_ID_EXTERNAL",
}
# Spalten, die ausschließlich im Microsoft-Format vorkommen — sie heben
# uns von Hornet-CSVs ab, die dieselbe Header-Familie nutzen.
_DISCRIMINATOR_COLUMNS = {"BILLINGPLAN", "VERTRAGSDAUER", "MSERP", "MSERP_BILLINGPERIOD"}
@classmethod
def sniff(cls, sample_bytes: bytes, filename: str) -> float:
@@ -78,7 +81,17 @@ class ADNMonthlyCSVHandler(BaseFileHandler):
if not matching:
return 0.0
ratio = len(matching) / len(cls._SIGNATURE_COLUMNS)
return min(1.0, 0.4 + 0.6 * ratio)
base = 0.4 + 0.55 * ratio # max ≈ 0.95
# MS-Discriminatoren heben uns sicher von Hornet ab, das nur Vertrag
# kennt: voller Bonus, wenn alle drei Spalten da sind, sonst anteilig.
discriminators = cls._DISCRIMINATOR_COLUMNS & cols
if discriminators:
base = min(1.0, base + 0.05 * len(discriminators))
else:
# Ohne MS-Discriminatoren ist es entweder Hornet oder ein älterer
# MS-Export — wir reichen unter 1.0, damit Hornet entscheiden kann.
base = min(0.7, base)
return min(1.0, base)
@classmethod
def _peek_first_line_in_zip(cls, sample_bytes: bytes) -> str | None:
+1
View File
@@ -66,3 +66,4 @@ def detect_best_handler(sample_bytes: bytes, filename: str,
def _autoload() -> None:
"""Importiert bekannte Handler-Module, damit @register-Dekoratoren laufen."""
from msp.importers.handlers import adn_monthly_csv_handler # noqa: F401
from msp.importers.handlers import adn_hornet_csv_handler # noqa: F401
+19 -9
View File
@@ -5,16 +5,26 @@ from __future__ import annotations
import frappe
def resolve_customer(external_ref: str | None, fallback: str | None = None) -> tuple[str | None, bool]:
"""Gibt (customer_name, is_fallback) zurück. None wenn weder Match noch Fallback greift."""
def resolve_customer(external_ref: str | None, fallback: str | None = None,
lookup_field: str | None = None) -> tuple[str | None, bool]:
"""Gibt (customer_name, is_fallback) zurück. None wenn weder Match noch Fallback greift.
``lookup_field`` aktiviert Lookups über ein Custom-Field statt direktem
Name-Lookup — z. B. ``hornet_domain`` für Hornet-Imports, wo die ADN-CSV
keine ERP-Customer-IDs liefert, sondern Mail-Domains.
"""
if external_ref:
exists = frappe.db.exists("Customer", external_ref)
if exists:
return external_ref, False
# Fallback-Match auf customer_name, falls ID nicht existiert
match = frappe.db.get_value("Customer", {"customer_name": external_ref}, "name")
if match:
return match, False
if lookup_field:
match = frappe.db.get_value("Customer", {lookup_field: external_ref}, "name")
if match:
return match, False
else:
if frappe.db.exists("Customer", external_ref):
return external_ref, False
# Fallback-Match auf customer_name, falls ID nicht existiert
match = frappe.db.get_value("Customer", {"customer_name": external_ref}, "name")
if match:
return match, False
if fallback and frappe.db.exists("Customer", fallback):
return fallback, True
return None, False
@@ -34,7 +34,8 @@
"intro_text_invoice",
"intro_text_credit_note",
"fallback_section",
"fallback_customer"
"fallback_customer",
"customer_lookup_field"
],
"fields": [
{
@@ -205,6 +206,12 @@
"label": "Fallback Customer",
"options": "Customer",
"description": "Wird verwendet, wenn der Endkunde in der Datei nicht im ERP gefunden wird"
},
{
"fieldname": "customer_lookup_field",
"fieldtype": "Data",
"label": "Customer Lookup Field",
"description": "Optional: Custom-Field auf Customer (z. B. 'hornet_domain'), über das die Endkunden-Referenz aufgelöst wird. Leer = direkter Customer-Name-Lookup."
}
],
"index_web_pages_for_search": 0,