Files
inwx-scripts/inwx_domains_owner.py
T
maier a9b6d00fca inwx_domains_owner: optionalen Zeitstempel im citeq-TXT auswerten
TXT-Werte koennen jetzt 'citeq:<ziffer>;<YYYYMMDD>;' lauten. get_group()
liefert (ziffer, datum); die Domains werden je Gruppe (und im Block
'Ohne Zuordnung') nach Datum absteigend sortiert (juengste oben,
undatierte ans Ende) und das Datum als eigene Spalte ausgegeben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:48:13 +02:00

277 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Listet die Domains gruppiert nach den Einträgen in domain_owner auf.
Reihenfolge richtet sich nach der Datei domain_owner: zuerst der 1. Eintrag
(Ziffer + Klarname), darunter alle Domains mit dieser Ziffer, dann eine
Leerzeile, dann der 2. Eintrag usw. Domains ohne passende Zuordnung werden
am Ende separat aufgeführt.
"""
import os
import subprocess
import sys
from collections import Counter
from inwx_common import get_client, call_api
PAGE_LIMIT = 100 # Domains pro API-Seite
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OWNER_FILE = os.path.join(BASE_DIR, "domain_owner")
PRICE_FILE = os.path.join(BASE_DIR, "domain_price")
TXT_PREFIX = "citeq:" # Präfix der Gruppen-TXT-Einträge
def fetch_all_domains(api):
"""Holt alle Domains über alle Seiten hinweg (domain.list ist paginiert)."""
domains = []
page = 1
while True:
res = call_api(api, 'domain.list', {'page': page, 'pageLimit': PAGE_LIMIT})
if res['code'] != 1000:
print(f"❌ API Fehler: {res['msg']} (Code: {res['code']})")
sys.exit(1)
data = res['resData']
domains.extend(data.get('domain', []))
# Solange wir noch nicht alle laut 'count' geladen haben, weiterblättern.
if len(domains) >= data.get('count', 0) or not data.get('domain'):
break
page += 1
return domains
def get_nameservers(api, domain):
"""Ermittelt die zuständigen Nameserver (Registry-Delegation) einer Domain.
Nutzt domain.info -> Feld 'ns', das die delegierten Nameserver enthält und
auch dann funktioniert, wenn die DNS-Zone nicht bei INWX gehostet wird.
Liefert eine sortierte Liste der NS-Hostnamen oder None bei Fehler.
"""
res = call_api(api, 'domain.info', {'domain': domain})
if res['code'] != 1000:
return None
ns = res['resData'].get('ns') or []
return sorted(n.rstrip('.') for n in ns)
def load_owners():
"""Liest die Zuordnung Ziffer -> Klarname aus der Datei domain_owner.
Die Reihenfolge der Einträge aus der Datei bleibt erhalten (dict behält
seit Python 3.7 die Einfügereihenfolge). Format pro Zeile:
<ziffer><Whitespace><klarname>, Trenner ist beliebiger Whitespace (ein
oder mehrere Tabs/Leerzeichen); der Klarname darf Leerzeichen enthalten.
Leere Zeilen und Kommentarzeilen (#) werden ignoriert.
"""
owners = {}
if not os.path.exists(OWNER_FILE):
print(f"⚠️ Datei {OWNER_FILE} nicht gefunden keine Gruppierung möglich.")
return owners
try:
with open(OWNER_FILE, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1) # an erstem Whitespace trennen
if len(parts) == 2:
ziffer, klarname = parts
owners[ziffer.strip()] = klarname.strip()
except Exception as e:
print(f"⚠️ Konnte {OWNER_FILE} nicht lesen: {e}")
return owners
def get_group(domain):
"""Ermittelt Gruppen-Ziffer und optionalen Zeitstempel aus dem citeq:-TXT.
Nutzt eine echte DNS-Abfrage (dig +short TXT) und funktioniert daher
unabhängig davon, wo die DNS-Zone gehostet wird. Der TXT-Wert hat die Form
'citeq:<ziffer>' oder mit Zeitstempel 'citeq:<ziffer>;<YYYYMMDD>;'
(der abschließende Strichpunkt ist optional).
Liefert das Tupel (ziffer, datum), jeweils als String oder None, wenn kein
passender TXT-Eintrag bzw. kein Datum gefunden wird.
"""
try:
out = subprocess.run(
["dig", "+short", "TXT", domain],
capture_output=True, text=True, timeout=10,
)
except FileNotFoundError:
print("⚠️ 'dig' nicht gefunden Gruppen können nicht ermittelt werden.")
return None, None
except subprocess.TimeoutExpired:
return None, None
if out.returncode != 0:
return None, None
for line in out.stdout.splitlines():
# dig liefert TXT-Werte in Anführungszeichen, lange Werte ggf. in
# mehreren Teilen ("teil1" "teil2") -> zusammenfügen und entkleiden.
content = line.replace('" "', '').strip().strip('"')
if content.startswith(TXT_PREFIX):
rest = content[len(TXT_PREFIX):].strip()
# Ziffer und optionalen Zeitstempel am Semikolon trennen.
parts = [p.strip() for p in rest.split(";")]
ziffer = parts[0] if parts and parts[0] else None
datum = parts[1] if len(parts) > 1 and parts[1] else None
return ziffer, datum
return None, None
def tld_of(domain):
"""Liefert die Top-Level-Domain (Teil hinter dem letzten Punkt)."""
return domain.rsplit('.', 1)[-1].lower() if '.' in domain else domain.lower()
def load_prices():
"""Liest die Preise je Top-Level-Domain aus der Datei domain_price.
Format pro Zeile: <tld><Whitespace><preis>, z.B. 'de 5,00'. Als Dezimal-
trennzeichen wird Komma akzeptiert. Leere Zeilen und Kommentare (#) werden
ignoriert. Liefert ein Dict {tld: preis_als_float}.
"""
prices = {}
if not os.path.exists(PRICE_FILE):
print(f"⚠️ Datei {PRICE_FILE} nicht gefunden keine Preise verfügbar.")
return prices
try:
with open(PRICE_FILE, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
if len(parts) == 2:
tld, preis = parts
try:
prices[tld.strip().lower()] = float(preis.strip().replace(",", "."))
except ValueError:
print(f"⚠️ Ungültiger Preis in domain_price: {line!r}")
except Exception as e:
print(f"⚠️ Konnte {PRICE_FILE} nicht lesen: {e}")
return prices
def fmt_price(value):
"""Formatiert einen Betrag im deutschen Format, z.B. 10,00 €."""
return f"{value:.2f}".replace(".", ",") + ""
def fmt_date(datum):
"""Formatiert einen Zeitstempel 'YYYYMMDD' als 'DD.MM.YYYY'.
Liefert '-' bei fehlendem und den Rohwert bei unerwartetem Format.
"""
if not datum:
return "-"
if len(datum) == 8 and datum.isdigit():
return f"{datum[6:8]}.{datum[4:6]}.{datum[0:4]}"
return datum
def print_group_sums(recs, ziffer, prices):
"""Gibt für eine Gruppe die Anzahl Domains pro Top-Level-Domain aus.
Eine Zeile je TLD im Format <ziffer> <tld> <anzahl> <preis>, z.B.
'10 de 1 5,00 €'. Der Preis ergibt sich aus Anzahl × Einzelpreis laut
domain_price; fehlt ein Preis, wird ein Hinweis ausgegeben.
"""
counts = Counter(tld_of(r['name']) for r in recs)
for tld, anzahl in sorted(counts.items()):
einzel = prices.get(tld)
if einzel is None:
print(f" {ziffer} {tld} {anzahl} (kein Preis)")
else:
print(f" {ziffer} {tld} {anzahl} {fmt_price(anzahl * einzel)}")
def format_domain_line(rec):
"""Einzeilige Darstellung einer Domain (eingerückt unter der Gruppe)."""
if rec['ns'] is None:
ns = "(nicht abrufbar)"
elif not rec['ns']:
ns = "(keine NS-Einträge gefunden)"
else:
ns = ", ".join(rec['ns'])
return f" {rec['name']:<35} {rec['status']:<8} {fmt_date(rec['datum']):<12} {ns}"
def sort_by_date_desc(recs):
"""Sortiert Domains nach Zeitstempel absteigend (jüngste zuerst).
Domains ohne Zeitstempel wandern ans Ende. Bei gleichem Datum bleibt die
vorherige (alphabetische) Reihenfolge erhalten sorted() ist stabil, auch
mit reverse=True.
"""
recs.sort(key=lambda r: (r['datum'] is not None, r['datum'] or ""), reverse=True)
return recs
def main():
api = get_client()
print("Rufe Domain-Liste ab...")
domains = fetch_all_domains(api)
if not domains:
print("️ Keine Domains gefunden.")
return
# Zuordnung Ziffer -> Klarname und Preise je TLD laden
owners = load_owners()
prices = load_prices()
# Pro Domain einmal Ziffer (DNS) und Nameserver (API) ermitteln
records = []
for d in domains:
name = d.get('domain', '')
ziffer, datum = get_group(name)
records.append({
'name': name,
'status': d.get('status', ''),
'ziffer': ziffer,
'datum': datum,
'ns': get_nameservers(api, name),
})
# Alphabetisch sortieren (Groß-/Kleinschreibung ignorieren)
records.sort(key=lambda r: r['name'].lower())
# Ausgabe in der Reihenfolge der Einträge aus domain_owner
print()
for ziffer, klarname in owners.items():
print(f"{ziffer} ({klarname})")
matching = sort_by_date_desc([r for r in records if r['ziffer'] == ziffer])
if matching:
for rec in matching:
print(format_domain_line(rec))
print_group_sums(matching, ziffer, prices) # Summe direkt unter den Domains
else:
print(" (keine Domains)")
print() # Leerzeile zwischen den Gruppen
# Domains, die keiner Ziffer aus domain_owner zugeordnet sind
rest = sort_by_date_desc([r for r in records if r['ziffer'] not in owners])
if rest:
print("Ohne Zuordnung")
for rec in rest:
ziffer = rec['ziffer']
hinweis = "" if ziffer is None else f" [citeq:{ziffer} unbekannt]"
print(format_domain_line(rec) + hinweis)
print_group_sums(rest, "-", prices) # Summe direkt unter den Domains
print()
gesamt_preis = sum(prices.get(tld_of(r['name']), 0.0) for r in records)
print(f"Gesamt: {len(records)} Domain(s), {fmt_price(gesamt_preis)}")
# Kein logout() -> die Session bleibt gültig und kann wiederverwendet werden.
if __name__ == "__main__":
main()